Skip to main content

redis_server_wrapper/
cluster.rs

1//! Redis Cluster lifecycle management built on `RedisServer`.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use crate::cli::RedisCli;
9use crate::error::{Error, Result};
10use crate::server::{RedisServer, RedisServerHandle, SavePolicy};
11
12/// Context passed to the per-node configuration callback.
13///
14/// Provides information about the node being configured so the callback
15/// can make per-node decisions (e.g., different config for masters vs. replicas,
16/// or for a specific node index).
17pub struct NodeContext {
18    /// The pre-configured [`RedisServer`] builder for this node.
19    ///
20    /// All uniform cluster-level settings have already been applied.
21    /// The callback should modify and return this builder.
22    pub server: RedisServer,
23    /// Zero-based index of this node in the cluster.
24    ///
25    /// Nodes are ordered by port: masters occupy indices `0..masters`,
26    /// replicas occupy indices `masters..total`.
27    pub index: usize,
28    /// The port assigned to this node.
29    pub port: u16,
30    /// Total number of nodes in the cluster.
31    pub total_nodes: u16,
32    /// Number of master nodes.
33    pub masters: u16,
34    /// Number of replicas per master.
35    pub replicas_per_master: u16,
36}
37
38impl NodeContext {
39    /// Whether this node is a master (by initial topology order).
40    pub fn is_master(&self) -> bool {
41        self.index < self.masters as usize
42    }
43
44    /// Whether this node is a replica (by initial topology order).
45    pub fn is_replica(&self) -> bool {
46        !self.is_master()
47    }
48}
49
50/// Builder for a Redis Cluster.
51///
52/// # Example
53///
54/// ```no_run
55/// use redis_server_wrapper::RedisCluster;
56///
57/// # async fn example() {
58/// let cluster = RedisCluster::builder()
59///     .masters(3)
60///     .replicas_per_master(1)
61///     .base_port(7000)
62///     .start()
63///     .await
64///     .unwrap();
65///
66/// assert!(cluster.is_healthy().await);
67/// // Stopped automatically on Drop.
68/// # }
69/// ```
70pub struct RedisClusterBuilder {
71    masters: u16,
72    replicas_per_master: u16,
73    base_port: u16,
74    bind: String,
75    password: Option<String>,
76    logfile: Option<String>,
77    save: Option<SavePolicy>,
78    appendonly: Option<bool>,
79    cluster_node_timeout: Option<u64>,
80    cluster_require_full_coverage: Option<bool>,
81    cluster_allow_reads_when_down: Option<bool>,
82    cluster_allow_pubsubshard_when_down: Option<bool>,
83    cluster_allow_replica_migration: Option<bool>,
84    cluster_migration_barrier: Option<u32>,
85    cluster_announce_hostname: Option<String>,
86    cluster_announce_human_nodename: Option<String>,
87    cluster_preferred_endpoint_type: Option<String>,
88    cluster_replica_no_failover: Option<bool>,
89    cluster_replica_validity_factor: Option<u32>,
90    cluster_announce_ip: Option<String>,
91    cluster_announce_port: Option<u16>,
92    cluster_announce_bus_port: Option<u16>,
93    cluster_announce_tls_port: Option<u16>,
94    cluster_port: Option<u16>,
95    cluster_link_sendbuf_limit: Option<u64>,
96    cluster_compatibility_sample_ratio: Option<u32>,
97    cluster_slot_migration_handoff_max_lag_bytes: Option<u64>,
98    cluster_slot_migration_write_pause_timeout: Option<u64>,
99    cluster_slot_stats_enabled: Option<bool>,
100    min_replicas_to_write: Option<u32>,
101    min_replicas_max_lag: Option<u32>,
102    repl_diskless_sync: Option<bool>,
103    repl_diskless_sync_delay: Option<u32>,
104    repl_ping_replica_period: Option<u32>,
105    repl_timeout: Option<u32>,
106    tls_port: Option<u16>,
107    tls_cert_file: Option<PathBuf>,
108    tls_key_file: Option<PathBuf>,
109    tls_ca_cert_file: Option<PathBuf>,
110    tls_ca_cert_dir: Option<PathBuf>,
111    tls_auth_clients: Option<bool>,
112    tls_replication: Option<bool>,
113    tls_cluster: Option<bool>,
114    extra: HashMap<String, String>,
115    redis_server_bin: String,
116    redis_cli_bin: String,
117    node_config_fn: Option<Box<dyn FnMut(NodeContext) -> RedisServer + Send>>,
118}
119
120impl RedisClusterBuilder {
121    /// Set the number of master nodes (default: `3`).
122    pub fn masters(mut self, n: u16) -> Self {
123        self.masters = n;
124        self
125    }
126
127    /// Set the number of replicas per master (default: `0`).
128    pub fn replicas_per_master(mut self, n: u16) -> Self {
129        self.replicas_per_master = n;
130        self
131    }
132
133    /// Set the base port for cluster nodes (default: `7000`).
134    ///
135    /// Nodes are assigned consecutive ports starting at this value.
136    pub fn base_port(mut self, port: u16) -> Self {
137        self.base_port = port;
138        self
139    }
140
141    /// Set the bind address for all cluster nodes (default: `"127.0.0.1"`).
142    pub fn bind(mut self, bind: impl Into<String>) -> Self {
143        self.bind = bind.into();
144        self
145    }
146
147    /// Set a `requirepass` password for all cluster nodes.
148    pub fn password(mut self, password: impl Into<String>) -> Self {
149        self.password = Some(password.into());
150        self
151    }
152
153    /// Set the log file path for all cluster nodes.
154    pub fn logfile(mut self, path: impl Into<String>) -> Self {
155        self.logfile = Some(path.into());
156        self
157    }
158
159    /// Set the RDB save policy for all cluster nodes.
160    ///
161    /// `true` omits the `save` directive (Redis defaults apply).
162    /// `false` emits `save ""` to disable RDB entirely.
163    pub fn save(mut self, save: bool) -> Self {
164        self.save = Some(if save {
165            SavePolicy::Default
166        } else {
167            SavePolicy::Disabled
168        });
169        self
170    }
171
172    /// Set a custom RDB save schedule for all cluster nodes.
173    pub fn save_schedule(mut self, schedule: Vec<(u64, u64)>) -> Self {
174        self.save = Some(SavePolicy::Custom(schedule));
175        self
176    }
177
178    /// Enable or disable AOF persistence for all cluster nodes.
179    pub fn appendonly(mut self, appendonly: bool) -> Self {
180        self.appendonly = Some(appendonly);
181        self
182    }
183
184    /// Set the cluster node timeout in milliseconds for all nodes (default: `5000`).
185    pub fn cluster_node_timeout(mut self, ms: u64) -> Self {
186        self.cluster_node_timeout = Some(ms);
187        self
188    }
189
190    /// Require full hash slot coverage for the cluster to accept writes.
191    pub fn cluster_require_full_coverage(mut self, require: bool) -> Self {
192        self.cluster_require_full_coverage = Some(require);
193        self
194    }
195
196    /// Allow reads when the cluster is down.
197    pub fn cluster_allow_reads_when_down(mut self, allow: bool) -> Self {
198        self.cluster_allow_reads_when_down = Some(allow);
199        self
200    }
201
202    /// Allow pubsub shard channels when the cluster is down.
203    pub fn cluster_allow_pubsubshard_when_down(mut self, allow: bool) -> Self {
204        self.cluster_allow_pubsubshard_when_down = Some(allow);
205        self
206    }
207
208    /// Allow automatic replica migration between masters.
209    pub fn cluster_allow_replica_migration(mut self, allow: bool) -> Self {
210        self.cluster_allow_replica_migration = Some(allow);
211        self
212    }
213
214    /// Set the minimum number of replicas a master must retain before one can migrate.
215    pub fn cluster_migration_barrier(mut self, barrier: u32) -> Self {
216        self.cluster_migration_barrier = Some(barrier);
217        self
218    }
219
220    /// Set the hostname each node announces to the cluster.
221    pub fn cluster_announce_hostname(mut self, hostname: impl Into<String>) -> Self {
222        self.cluster_announce_hostname = Some(hostname.into());
223        self
224    }
225
226    /// Set the preferred endpoint type for cluster redirections (e.g. `"ip"`, `"hostname"`).
227    pub fn cluster_preferred_endpoint_type(mut self, endpoint_type: impl Into<String>) -> Self {
228        self.cluster_preferred_endpoint_type = Some(endpoint_type.into());
229        self
230    }
231
232    /// Prevent replicas from attempting automatic failover.
233    ///
234    /// Manual failover via `CLUSTER FAILOVER` still works.
235    pub fn cluster_replica_no_failover(mut self, no_failover: bool) -> Self {
236        self.cluster_replica_no_failover = Some(no_failover);
237        self
238    }
239
240    /// Set the replica validity factor for failover eligibility.
241    ///
242    /// A replica will not failover if it has been disconnected from the master
243    /// for more than `(node-timeout * factor) + repl-ping-replica-period` seconds.
244    /// Set to `0` to allow any replica to failover regardless of staleness.
245    pub fn cluster_replica_validity_factor(mut self, factor: u32) -> Self {
246        self.cluster_replica_validity_factor = Some(factor);
247        self
248    }
249
250    /// Set the IP address nodes announce for client redirects (MOVED/ASKING).
251    pub fn cluster_announce_ip(mut self, ip: impl Into<String>) -> Self {
252        self.cluster_announce_ip = Some(ip.into());
253        self
254    }
255
256    /// Set the client port nodes announce for redirects.
257    pub fn cluster_announce_port(mut self, port: u16) -> Self {
258        self.cluster_announce_port = Some(port);
259        self
260    }
261
262    /// Set the cluster bus port nodes announce for gossip.
263    pub fn cluster_announce_bus_port(mut self, port: u16) -> Self {
264        self.cluster_announce_bus_port = Some(port);
265        self
266    }
267
268    /// Set the TLS client port nodes announce for redirects.
269    pub fn cluster_announce_tls_port(mut self, port: u16) -> Self {
270        self.cluster_announce_tls_port = Some(port);
271        self
272    }
273
274    /// Set a friendly node name broadcast for debugging/admin display.
275    pub fn cluster_announce_human_nodename(mut self, name: impl Into<String>) -> Self {
276        self.cluster_announce_human_nodename = Some(name.into());
277        self
278    }
279
280    /// Set a dedicated cluster bus port (default: client port + 10000).
281    pub fn cluster_port(mut self, port: u16) -> Self {
282        self.cluster_port = Some(port);
283        self
284    }
285
286    /// Set the maximum memory for a cluster bus link's output buffer.
287    ///
288    /// When exceeded, the link is disconnected. Set to `0` for unlimited.
289    pub fn cluster_link_sendbuf_limit(mut self, limit: u64) -> Self {
290        self.cluster_link_sendbuf_limit = Some(limit);
291        self
292    }
293
294    /// Set the cluster compatibility sample ratio.
295    pub fn cluster_compatibility_sample_ratio(mut self, ratio: u32) -> Self {
296        self.cluster_compatibility_sample_ratio = Some(ratio);
297        self
298    }
299
300    /// Set the maximum replication lag in bytes before slot migration handoff.
301    pub fn cluster_slot_migration_handoff_max_lag_bytes(mut self, bytes: u64) -> Self {
302        self.cluster_slot_migration_handoff_max_lag_bytes = Some(bytes);
303        self
304    }
305
306    /// Set the write pause timeout in milliseconds during slot migration.
307    pub fn cluster_slot_migration_write_pause_timeout(mut self, ms: u64) -> Self {
308        self.cluster_slot_migration_write_pause_timeout = Some(ms);
309        self
310    }
311
312    /// Enable per-slot statistics tracking.
313    pub fn cluster_slot_stats_enabled(mut self, enable: bool) -> Self {
314        self.cluster_slot_stats_enabled = Some(enable);
315        self
316    }
317
318    // -- replication directives relevant in cluster mode --
319
320    /// Set the minimum number of connected replicas before the master accepts writes.
321    ///
322    /// Useful for split-brain protection: a partitioned master with no reachable
323    /// replicas stops accepting writes, reducing data loss during partitions.
324    pub fn min_replicas_to_write(mut self, n: u32) -> Self {
325        self.min_replicas_to_write = Some(n);
326        self
327    }
328
329    /// Set the maximum replication lag (seconds) before a replica is considered disconnected.
330    ///
331    /// Used with `min_replicas_to_write` to determine if enough replicas are connected.
332    pub fn min_replicas_max_lag(mut self, seconds: u32) -> Self {
333        self.min_replicas_max_lag = Some(seconds);
334        self
335    }
336
337    /// Enable or disable diskless replication sync.
338    ///
339    /// Diskless sync is faster but uses more memory during transfer.
340    pub fn repl_diskless_sync(mut self, enable: bool) -> Self {
341        self.repl_diskless_sync = Some(enable);
342        self
343    }
344
345    /// Set the delay in seconds before starting a diskless replication transfer.
346    ///
347    /// Allows batching multiple replicas syncing at once.
348    pub fn repl_diskless_sync_delay(mut self, seconds: u32) -> Self {
349        self.repl_diskless_sync_delay = Some(seconds);
350        self
351    }
352
353    /// Set how often replicas ping the master (seconds).
354    ///
355    /// Used in the replica validity calculation:
356    /// `(node-timeout * validity-factor) + repl-ping-replica-period`.
357    pub fn repl_ping_replica_period(mut self, seconds: u32) -> Self {
358        self.repl_ping_replica_period = Some(seconds);
359        self
360    }
361
362    /// Set the replication timeout in seconds.
363    ///
364    /// If a replica doesn't hear from master for this long, it considers the link dead.
365    pub fn repl_timeout(mut self, seconds: u32) -> Self {
366        self.repl_timeout = Some(seconds);
367        self
368    }
369
370    // -- TLS directives --
371
372    /// Set the TLS listening port for all cluster nodes.
373    pub fn tls_port(mut self, port: u16) -> Self {
374        self.tls_port = Some(port);
375        self
376    }
377
378    /// Set the TLS certificate file path for all cluster nodes.
379    pub fn tls_cert_file(mut self, path: impl Into<PathBuf>) -> Self {
380        self.tls_cert_file = Some(path.into());
381        self
382    }
383
384    /// Set the TLS private key file path for all cluster nodes.
385    pub fn tls_key_file(mut self, path: impl Into<PathBuf>) -> Self {
386        self.tls_key_file = Some(path.into());
387        self
388    }
389
390    /// Set the TLS CA certificate file path for all cluster nodes.
391    pub fn tls_ca_cert_file(mut self, path: impl Into<PathBuf>) -> Self {
392        self.tls_ca_cert_file = Some(path.into());
393        self
394    }
395
396    /// Set the TLS CA certificate directory for all cluster nodes.
397    pub fn tls_ca_cert_dir(mut self, path: impl Into<PathBuf>) -> Self {
398        self.tls_ca_cert_dir = Some(path.into());
399        self
400    }
401
402    /// Require TLS client authentication for all cluster nodes.
403    pub fn tls_auth_clients(mut self, auth: bool) -> Self {
404        self.tls_auth_clients = Some(auth);
405        self
406    }
407
408    /// Use TLS for replication traffic between cluster nodes.
409    pub fn tls_replication(mut self, enable: bool) -> Self {
410        self.tls_replication = Some(enable);
411        self
412    }
413
414    /// Use TLS for cluster bus communication between nodes.
415    pub fn tls_cluster(mut self, enable: bool) -> Self {
416        self.tls_cluster = Some(enable);
417        self
418    }
419
420    /// Set a per-node configuration callback.
421    ///
422    /// The callback receives a [`NodeContext`] containing the pre-configured
423    /// [`RedisServer`] builder (with all uniform settings already applied) and
424    /// metadata about the node's position in the cluster. It must return the
425    /// (possibly modified) `RedisServer` builder.
426    ///
427    /// # Example
428    ///
429    /// ```no_run
430    /// use redis_server_wrapper::RedisCluster;
431    ///
432    /// # async fn example() {
433    /// let cluster = RedisCluster::builder()
434    ///     .masters(3)
435    ///     .replicas_per_master(1)
436    ///     .base_port(7000)
437    ///     .with_node_config(|ctx| {
438    ///         let is_replica = ctx.is_replica();
439    ///         let index = ctx.index;
440    ///         let mut server = ctx.server;
441    ///         if is_replica {
442    ///             server = server.cluster_replica_no_failover(true);
443    ///         }
444    ///         if index == 0 {
445    ///             server = server.maxmemory("512mb");
446    ///         }
447    ///         server
448    ///     })
449    ///     .start()
450    ///     .await
451    ///     .unwrap();
452    /// # }
453    /// ```
454    pub fn with_node_config(
455        mut self,
456        f: impl FnMut(NodeContext) -> RedisServer + Send + 'static,
457    ) -> Self {
458        self.node_config_fn = Some(Box::new(f));
459        self
460    }
461
462    /// Set an arbitrary config directive for all cluster nodes.
463    pub fn extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
464        self.extra.insert(key.into(), value.into());
465        self
466    }
467
468    /// Set a custom `redis-server` binary path.
469    pub fn redis_server_bin(mut self, bin: impl Into<String>) -> Self {
470        self.redis_server_bin = bin.into();
471        self
472    }
473
474    /// Set a custom `redis-cli` binary path.
475    pub fn redis_cli_bin(mut self, bin: impl Into<String>) -> Self {
476        self.redis_cli_bin = bin.into();
477        self
478    }
479
480    fn total_nodes(&self) -> u16 {
481        self.masters * (1 + self.replicas_per_master)
482    }
483
484    fn ports(&self) -> impl Iterator<Item = u16> {
485        let base = self.base_port;
486        let total = self.total_nodes();
487        (0..total).map(move |i| base + i)
488    }
489
490    /// Whether TLS is configured (cert + key present).
491    fn has_tls(&self) -> bool {
492        self.tls_cert_file.is_some() && self.tls_key_file.is_some()
493    }
494
495    /// Apply TLS flags to a CLI instance based on builder config.
496    fn apply_tls_to_cli(&self, mut cli: RedisCli) -> RedisCli {
497        if self.has_tls() {
498            cli = cli.tls(true);
499            if let Some(ref ca) = self.tls_ca_cert_file {
500                cli = cli.cacert(ca);
501            } else {
502                cli = cli.insecure(true);
503            }
504            if let Some(ref cert) = self.tls_cert_file {
505                cli = cli.cert(cert);
506            }
507            if let Some(ref key) = self.tls_key_file {
508                cli = cli.key(key);
509            }
510        }
511        cli
512    }
513
514    /// Start all nodes and form the cluster.
515    pub async fn start(mut self) -> Result<RedisClusterHandle> {
516        // Stop any leftover nodes from previous runs.
517        for port in self.ports() {
518            let mut cli = RedisCli::new()
519                .bin(&self.redis_cli_bin)
520                .host(&self.bind)
521                .port(port);
522            if let Some(ref password) = self.password {
523                cli = cli.password(password);
524            }
525            cli = self.apply_tls_to_cli(cli);
526            cli.shutdown();
527        }
528        tokio::time::sleep(Duration::from_millis(500)).await;
529
530        // Start each node.
531        let total_nodes = self.total_nodes();
532        let ports: Vec<u16> = self.ports().collect();
533        let unique = SystemTime::now()
534            .duration_since(UNIX_EPOCH)
535            .map(|duration| duration.as_nanos())
536            .unwrap_or(0);
537        let cluster_base = std::env::temp_dir().join(format!(
538            "redis-cluster-wrapper-{}-{}",
539            std::process::id(),
540            unique
541        ));
542        let mut nodes = Vec::new();
543        for (index, port) in ports.into_iter().enumerate() {
544            let node_dir = cluster_base.join(format!("node-{port}"));
545            let mut server = RedisServer::new()
546                .port(port)
547                .bind(&self.bind)
548                .dir(node_dir)
549                .cluster_enabled(true)
550                .cluster_node_timeout(self.cluster_node_timeout.unwrap_or(5000))
551                .redis_server_bin(&self.redis_server_bin)
552                .redis_cli_bin(&self.redis_cli_bin);
553            if let Some(v) = self.cluster_require_full_coverage {
554                server = server.cluster_require_full_coverage(v);
555            }
556            if let Some(v) = self.cluster_allow_reads_when_down {
557                server = server.cluster_allow_reads_when_down(v);
558            }
559            if let Some(v) = self.cluster_allow_pubsubshard_when_down {
560                server = server.cluster_allow_pubsubshard_when_down(v);
561            }
562            if let Some(v) = self.cluster_allow_replica_migration {
563                server = server.cluster_allow_replica_migration(v);
564            }
565            if let Some(barrier) = self.cluster_migration_barrier {
566                server = server.cluster_migration_barrier(barrier);
567            }
568            if let Some(ref hostname) = self.cluster_announce_hostname {
569                server = server.cluster_announce_hostname(hostname.clone());
570            }
571            if let Some(ref endpoint_type) = self.cluster_preferred_endpoint_type {
572                server = server.cluster_preferred_endpoint_type(endpoint_type.clone());
573            }
574            if let Some(v) = self.cluster_replica_no_failover {
575                server = server.cluster_replica_no_failover(v);
576            }
577            if let Some(factor) = self.cluster_replica_validity_factor {
578                server = server.cluster_replica_validity_factor(factor);
579            }
580            if let Some(ref ip) = self.cluster_announce_ip {
581                server = server.cluster_announce_ip(ip.clone());
582            }
583            if let Some(port) = self.cluster_announce_port {
584                server = server.cluster_announce_port(port);
585            }
586            if let Some(port) = self.cluster_announce_bus_port {
587                server = server.cluster_announce_bus_port(port);
588            }
589            if let Some(port) = self.cluster_announce_tls_port {
590                server = server.cluster_announce_tls_port(port);
591            }
592            if let Some(ref name) = self.cluster_announce_human_nodename {
593                server = server.cluster_announce_human_nodename(name.clone());
594            }
595            if let Some(port) = self.cluster_port {
596                server = server.cluster_port(port);
597            }
598            if let Some(limit) = self.cluster_link_sendbuf_limit {
599                server = server.cluster_link_sendbuf_limit(limit);
600            }
601            if let Some(ratio) = self.cluster_compatibility_sample_ratio {
602                server = server.cluster_compatibility_sample_ratio(ratio);
603            }
604            if let Some(bytes) = self.cluster_slot_migration_handoff_max_lag_bytes {
605                server = server.cluster_slot_migration_handoff_max_lag_bytes(bytes);
606            }
607            if let Some(ms) = self.cluster_slot_migration_write_pause_timeout {
608                server = server.cluster_slot_migration_write_pause_timeout(ms);
609            }
610            if let Some(v) = self.cluster_slot_stats_enabled {
611                server = server.cluster_slot_stats_enabled(v);
612            }
613            if let Some(n) = self.min_replicas_to_write {
614                server = server.min_replicas_to_write(n);
615            }
616            if let Some(seconds) = self.min_replicas_max_lag {
617                server = server.min_replicas_max_lag(seconds);
618            }
619            if let Some(v) = self.repl_diskless_sync {
620                server = server.repl_diskless_sync(v);
621            }
622            if let Some(seconds) = self.repl_diskless_sync_delay {
623                server = server.repl_diskless_sync_delay(seconds);
624            }
625            if let Some(seconds) = self.repl_ping_replica_period {
626                server = server.repl_ping_replica_period(seconds);
627            }
628            if let Some(seconds) = self.repl_timeout {
629                server = server.repl_timeout(seconds);
630            }
631            if let Some(ref password) = self.password {
632                server = server.password(password).masterauth(password);
633            }
634            if let Some(ref logfile) = self.logfile {
635                server = server.logfile(logfile.clone());
636            }
637            if let Some(ref save) = self.save {
638                match save {
639                    SavePolicy::Disabled => server = server.save(false),
640                    SavePolicy::Default => server = server.save(true),
641                    SavePolicy::Custom(pairs) => {
642                        server = server.save_schedule(pairs.clone());
643                    }
644                }
645            }
646            if let Some(appendonly) = self.appendonly {
647                server = server.appendonly(appendonly);
648            }
649            // TLS directives.
650            if let Some(port) = self.tls_port {
651                server = server.tls_port(port + index as u16);
652            }
653            if let Some(ref path) = self.tls_cert_file {
654                server = server.tls_cert_file(path);
655            }
656            if let Some(ref path) = self.tls_key_file {
657                server = server.tls_key_file(path);
658            }
659            if let Some(ref path) = self.tls_ca_cert_file {
660                server = server.tls_ca_cert_file(path);
661            }
662            if let Some(ref path) = self.tls_ca_cert_dir {
663                server = server.tls_ca_cert_dir(path);
664            }
665            if let Some(v) = self.tls_auth_clients {
666                server = server.tls_auth_clients(v);
667            }
668            if let Some(v) = self.tls_replication {
669                server = server.tls_replication(v);
670            }
671            if let Some(v) = self.tls_cluster {
672                server = server.tls_cluster(v);
673            }
674            for (key, value) in &self.extra {
675                server = server.extra(key.clone(), value.clone());
676            }
677            // Apply per-node customization if configured.
678            if let Some(ref mut f) = self.node_config_fn {
679                server = f(NodeContext {
680                    server,
681                    index,
682                    port,
683                    total_nodes,
684                    masters: self.masters,
685                    replicas_per_master: self.replicas_per_master,
686                });
687            }
688            let handle = server.start().await?;
689            nodes.push(handle);
690        }
691
692        // Form the cluster.
693        let node_addrs: Vec<String> = nodes.iter().map(|n| n.addr()).collect();
694        let mut cli = RedisCli::new()
695            .bin(&self.redis_cli_bin)
696            .host(&self.bind)
697            .port(self.base_port);
698        if let Some(ref password) = self.password {
699            cli = cli.password(password);
700        }
701        cli = self.apply_tls_to_cli(cli);
702        cli.cluster_create(&node_addrs, self.replicas_per_master)
703            .await?;
704
705        // Wait for convergence.
706        tokio::time::sleep(Duration::from_secs(2)).await;
707
708        Ok(RedisClusterHandle {
709            nodes,
710            num_masters: self.masters,
711            bind: self.bind,
712            base_port: self.base_port,
713            password: self.password,
714            redis_cli_bin: self.redis_cli_bin,
715            tls: TlsConfig {
716                cert_file: self.tls_cert_file,
717                key_file: self.tls_key_file,
718                ca_cert_file: self.tls_ca_cert_file,
719            },
720            cluster_base,
721        })
722    }
723}
724
725/// TLS configuration snapshot stored in the handle for building CLI instances.
726#[derive(Clone, Debug, Default)]
727struct TlsConfig {
728    cert_file: Option<PathBuf>,
729    key_file: Option<PathBuf>,
730    ca_cert_file: Option<PathBuf>,
731}
732
733impl TlsConfig {
734    fn has_tls(&self) -> bool {
735        self.cert_file.is_some() && self.key_file.is_some()
736    }
737
738    fn apply(&self, mut cli: RedisCli) -> RedisCli {
739        if self.has_tls() {
740            cli = cli.tls(true);
741            if let Some(ref ca) = self.ca_cert_file {
742                cli = cli.cacert(ca);
743            } else {
744                cli = cli.insecure(true);
745            }
746            if let Some(ref cert) = self.cert_file {
747                cli = cli.cert(cert);
748            }
749            if let Some(ref key) = self.key_file {
750                cli = cli.key(key);
751            }
752        }
753        cli
754    }
755}
756
757/// A running Redis Cluster. Stops all nodes on Drop.
758pub struct RedisClusterHandle {
759    nodes: Vec<RedisServerHandle>,
760    num_masters: u16,
761    bind: String,
762    base_port: u16,
763    password: Option<String>,
764    redis_cli_bin: String,
765    tls: TlsConfig,
766    /// Unique per-invocation base directory holding all node working directories.
767    cluster_base: PathBuf,
768}
769
770/// Entry point for building a Redis Cluster topology.
771///
772/// Call [`RedisCluster::builder`] to obtain a [`RedisClusterBuilder`], then
773/// configure it and call [`RedisClusterBuilder::start`] to launch the cluster.
774pub struct RedisCluster;
775
776impl RedisCluster {
777    /// Create a new cluster builder with defaults (3 masters, 0 replicas, port 7000).
778    pub fn builder() -> RedisClusterBuilder {
779        RedisClusterBuilder {
780            masters: 3,
781            replicas_per_master: 0,
782            base_port: 7000,
783            bind: "127.0.0.1".into(),
784            password: None,
785            logfile: None,
786            save: None,
787            appendonly: None,
788            cluster_node_timeout: None,
789            cluster_require_full_coverage: None,
790            cluster_allow_reads_when_down: None,
791            cluster_allow_pubsubshard_when_down: None,
792            cluster_allow_replica_migration: None,
793            cluster_migration_barrier: None,
794            cluster_announce_hostname: None,
795            cluster_announce_human_nodename: None,
796            cluster_preferred_endpoint_type: None,
797            cluster_replica_no_failover: None,
798            cluster_replica_validity_factor: None,
799            cluster_announce_ip: None,
800            cluster_announce_port: None,
801            cluster_announce_bus_port: None,
802            cluster_announce_tls_port: None,
803            cluster_port: None,
804            cluster_link_sendbuf_limit: None,
805            cluster_compatibility_sample_ratio: None,
806            cluster_slot_migration_handoff_max_lag_bytes: None,
807            cluster_slot_migration_write_pause_timeout: None,
808            cluster_slot_stats_enabled: None,
809            min_replicas_to_write: None,
810            min_replicas_max_lag: None,
811            repl_diskless_sync: None,
812            repl_diskless_sync_delay: None,
813            repl_ping_replica_period: None,
814            repl_timeout: None,
815            tls_port: None,
816            tls_cert_file: None,
817            tls_key_file: None,
818            tls_ca_cert_file: None,
819            tls_ca_cert_dir: None,
820            tls_auth_clients: None,
821            tls_replication: None,
822            tls_cluster: None,
823            extra: HashMap::new(),
824            redis_server_bin: "redis-server".into(),
825            redis_cli_bin: "redis-cli".into(),
826            node_config_fn: None,
827        }
828    }
829}
830
831impl RedisClusterHandle {
832    /// The seed address (first node).
833    pub fn addr(&self) -> String {
834        format!("{}:{}", self.bind, self.base_port)
835    }
836
837    /// All node addresses.
838    pub fn node_addrs(&self) -> Vec<String> {
839        self.nodes.iter().map(|n| n.addr()).collect()
840    }
841
842    /// The PIDs of all `redis-server` processes in the cluster.
843    pub fn pids(&self) -> Vec<u32> {
844        self.nodes.iter().map(|n| n.pid()).collect()
845    }
846
847    /// Check if all nodes are alive.
848    pub async fn all_alive(&self) -> bool {
849        for node in &self.nodes {
850            if !node.is_alive().await {
851                return false;
852            }
853        }
854        true
855    }
856
857    /// Check CLUSTER INFO for state=ok and all slots assigned.
858    pub async fn is_healthy(&self) -> bool {
859        for node in &self.nodes {
860            if let Ok(info) = node.run(&["CLUSTER", "INFO"]).await
861                && info.contains("cluster_state:ok")
862                && info.contains("cluster_slots_ok:16384")
863            {
864                return true;
865            }
866        }
867        false
868    }
869
870    /// Wait until the cluster is healthy or timeout.
871    pub async fn wait_for_healthy(&self, timeout: Duration) -> Result<()> {
872        let start = std::time::Instant::now();
873        loop {
874            if self.is_healthy().await {
875                return Ok(());
876            }
877            if start.elapsed() > timeout {
878                return Err(Error::Timeout {
879                    message: "cluster did not become healthy in time".into(),
880                });
881            }
882            tokio::time::sleep(Duration::from_millis(500)).await;
883        }
884    }
885
886    /// Access a specific node by index.
887    ///
888    /// Nodes are ordered by port: masters first (indices `0..masters`),
889    /// then replicas (indices `masters..total`).
890    ///
891    /// # Panics
892    ///
893    /// Panics if `index >= total_nodes`.
894    pub fn node(&self, index: usize) -> &RedisServerHandle {
895        &self.nodes[index]
896    }
897
898    /// All node handles.
899    pub fn nodes(&self) -> &[RedisServerHandle] {
900        &self.nodes
901    }
902
903    /// The number of master nodes in the cluster.
904    pub fn num_masters(&self) -> u16 {
905        self.num_masters
906    }
907
908    /// Handles for the master nodes (the first `masters` nodes by port order).
909    ///
910    /// Note: this reflects the *initial* topology. After a failover, the actual
911    /// roles may differ from the startup ordering.
912    pub fn master_nodes(&self) -> &[RedisServerHandle] {
913        &self.nodes[..self.num_masters as usize]
914    }
915
916    /// Handles for the replica nodes (all nodes after the masters by port order).
917    ///
918    /// Note: this reflects the *initial* topology. After a failover, the actual
919    /// roles may differ from the startup ordering.
920    pub fn replica_nodes(&self) -> &[RedisServerHandle] {
921        &self.nodes[self.num_masters as usize..]
922    }
923
924    /// Run `CONFIG SET` on all nodes.
925    pub async fn config_set_all(&self, key: &str, value: &str) -> Result<()> {
926        for node in &self.nodes {
927            node.run(&["CONFIG", "SET", key, value]).await?;
928        }
929        Ok(())
930    }
931
932    /// Run `CONFIG SET` on master nodes only (initial topology).
933    pub async fn config_set_masters(&self, key: &str, value: &str) -> Result<()> {
934        for node in self.master_nodes() {
935            node.run(&["CONFIG", "SET", key, value]).await?;
936        }
937        Ok(())
938    }
939
940    /// Run `CONFIG SET` on replica nodes only (initial topology).
941    pub async fn config_set_replicas(&self, key: &str, value: &str) -> Result<()> {
942        for node in self.replica_nodes() {
943            node.run(&["CONFIG", "SET", key, value]).await?;
944        }
945        Ok(())
946    }
947
948    /// The cluster's `requirepass` password, if one was set.
949    ///
950    /// Used internally (e.g. by [`crate::chaos::migrate_slot`]) to pass
951    /// `MIGRATE ... AUTH` when moving keys between password-protected nodes.
952    pub(crate) fn password(&self) -> Option<&str> {
953        self.password.as_deref()
954    }
955
956    /// Get a `RedisCli` for the seed node.
957    pub fn cli(&self) -> RedisCli {
958        let mut cli = RedisCli::new()
959            .bin(&self.redis_cli_bin)
960            .host(&self.bind)
961            .port(self.base_port);
962        if let Some(ref password) = self.password {
963            cli = cli.password(password);
964        }
965        cli = self.tls.apply(cli);
966        cli
967    }
968
969    /// The unique per-invocation base directory holding all node working directories.
970    pub fn cluster_base(&self) -> &Path {
971        &self.cluster_base
972    }
973}
974
975impl Drop for RedisClusterHandle {
976    fn drop(&mut self) {
977        // RedisServerHandle::drop() handles each node.
978    }
979}
980
981#[cfg(test)]
982mod tests {
983    use super::*;
984
985    #[test]
986    fn builder_defaults() {
987        let b = RedisCluster::builder();
988        assert_eq!(b.masters, 3);
989        assert_eq!(b.replicas_per_master, 0);
990        assert_eq!(b.base_port, 7000);
991        assert_eq!(b.password, None);
992        assert!(b.logfile.is_none());
993        assert!(b.extra.is_empty());
994        assert_eq!(b.total_nodes(), 3);
995        assert!(b.cluster_node_timeout.is_none());
996        assert!(b.cluster_require_full_coverage.is_none());
997        assert!(b.cluster_allow_reads_when_down.is_none());
998        assert!(b.cluster_allow_pubsubshard_when_down.is_none());
999        assert!(b.cluster_allow_replica_migration.is_none());
1000        assert!(b.cluster_migration_barrier.is_none());
1001        assert!(b.cluster_announce_hostname.is_none());
1002        assert!(b.cluster_preferred_endpoint_type.is_none());
1003    }
1004
1005    #[test]
1006    fn builder_with_replicas() {
1007        let b = RedisCluster::builder().masters(3).replicas_per_master(1);
1008        assert_eq!(b.total_nodes(), 6);
1009        let ports: Vec<u16> = b.ports().collect();
1010        assert_eq!(ports, vec![7000, 7001, 7002, 7003, 7004, 7005]);
1011    }
1012
1013    #[test]
1014    fn builder_password() {
1015        let b = RedisCluster::builder().password("secret");
1016        assert_eq!(b.password.as_deref(), Some("secret"));
1017    }
1018
1019    #[test]
1020    fn builder_cluster_directives() {
1021        let b = RedisCluster::builder()
1022            .cluster_node_timeout(10000)
1023            .cluster_require_full_coverage(false)
1024            .cluster_allow_reads_when_down(true)
1025            .cluster_allow_pubsubshard_when_down(true)
1026            .cluster_allow_replica_migration(false)
1027            .cluster_migration_barrier(2)
1028            .cluster_announce_hostname("node.example.com")
1029            .cluster_preferred_endpoint_type("hostname")
1030            .cluster_replica_no_failover(true)
1031            .cluster_replica_validity_factor(0)
1032            .cluster_announce_ip("10.0.0.1")
1033            .cluster_announce_port(7000)
1034            .cluster_announce_bus_port(17000)
1035            .cluster_announce_tls_port(7100)
1036            .cluster_announce_human_nodename("node-1")
1037            .cluster_port(17000)
1038            .cluster_link_sendbuf_limit(67108864)
1039            .cluster_compatibility_sample_ratio(50)
1040            .cluster_slot_migration_handoff_max_lag_bytes(1048576)
1041            .cluster_slot_migration_write_pause_timeout(5000)
1042            .cluster_slot_stats_enabled(true);
1043        assert_eq!(b.cluster_node_timeout, Some(10000));
1044        assert_eq!(b.cluster_require_full_coverage, Some(false));
1045        assert_eq!(b.cluster_allow_reads_when_down, Some(true));
1046        assert_eq!(b.cluster_allow_pubsubshard_when_down, Some(true));
1047        assert_eq!(b.cluster_allow_replica_migration, Some(false));
1048        assert_eq!(b.cluster_migration_barrier, Some(2));
1049        assert_eq!(
1050            b.cluster_announce_hostname.as_deref(),
1051            Some("node.example.com")
1052        );
1053        assert_eq!(
1054            b.cluster_preferred_endpoint_type.as_deref(),
1055            Some("hostname")
1056        );
1057        assert_eq!(b.cluster_replica_no_failover, Some(true));
1058        assert_eq!(b.cluster_replica_validity_factor, Some(0));
1059        assert_eq!(b.cluster_announce_ip.as_deref(), Some("10.0.0.1"));
1060        assert_eq!(b.cluster_announce_port, Some(7000));
1061        assert_eq!(b.cluster_announce_bus_port, Some(17000));
1062        assert_eq!(b.cluster_announce_tls_port, Some(7100));
1063        assert_eq!(b.cluster_announce_human_nodename.as_deref(), Some("node-1"));
1064        assert_eq!(b.cluster_port, Some(17000));
1065        assert_eq!(b.cluster_link_sendbuf_limit, Some(67108864));
1066        assert_eq!(b.cluster_compatibility_sample_ratio, Some(50));
1067        assert_eq!(
1068            b.cluster_slot_migration_handoff_max_lag_bytes,
1069            Some(1048576)
1070        );
1071        assert_eq!(b.cluster_slot_migration_write_pause_timeout, Some(5000));
1072        assert_eq!(b.cluster_slot_stats_enabled, Some(true));
1073    }
1074
1075    #[test]
1076    fn builder_replication_directives() {
1077        let b = RedisCluster::builder()
1078            .min_replicas_to_write(1)
1079            .min_replicas_max_lag(10)
1080            .repl_diskless_sync(true)
1081            .repl_diskless_sync_delay(0)
1082            .repl_ping_replica_period(5)
1083            .repl_timeout(30);
1084        assert_eq!(b.min_replicas_to_write, Some(1));
1085        assert_eq!(b.min_replicas_max_lag, Some(10));
1086        assert_eq!(b.repl_diskless_sync, Some(true));
1087        assert_eq!(b.repl_diskless_sync_delay, Some(0));
1088        assert_eq!(b.repl_ping_replica_period, Some(5));
1089        assert_eq!(b.repl_timeout, Some(30));
1090    }
1091
1092    #[test]
1093    fn builder_logfile_and_extra() {
1094        let b = RedisCluster::builder()
1095            .logfile("/tmp/cluster.log")
1096            .extra("maxmemory", "10mb");
1097        assert_eq!(b.logfile.as_deref(), Some("/tmp/cluster.log"));
1098        assert_eq!(b.extra.get("maxmemory").map(String::as_str), Some("10mb"));
1099    }
1100}