docker_wrapper/template/redis/
sentinel.rs

1//! Redis Sentinel template for high availability setup
2//!
3//! This template sets up a complete Redis Sentinel environment with:
4//! - One Redis master instance
5//! - Multiple Redis replica instances
6//! - Multiple Sentinel instances for monitoring and failover
7
8#![allow(clippy::doc_markdown)]
9#![allow(clippy::must_use_candidate)]
10#![allow(clippy::return_self_not_must_use)]
11#![allow(clippy::needless_borrows_for_generic_args)]
12#![allow(clippy::unnecessary_get_then_check)]
13
14use super::common::{DEFAULT_REDIS_IMAGE, DEFAULT_REDIS_TAG};
15use crate::{DockerCommand, NetworkCreateCommand, RunCommand};
16
17/// Redis Sentinel template for high availability setup
18pub struct RedisSentinelTemplate {
19    name: String,
20    master_name: String,
21    num_replicas: usize,
22    num_sentinels: usize,
23    quorum: usize,
24    master_port: u16,
25    replica_port_base: u16,
26    sentinel_port_base: u16,
27    password: Option<String>,
28    down_after_milliseconds: u32,
29    failover_timeout: u32,
30    parallel_syncs: u32,
31    persistence: bool,
32    network: Option<String>,
33    /// Custom Redis image
34    redis_image: Option<String>,
35    /// Custom Redis tag
36    redis_tag: Option<String>,
37    /// Platform for containers
38    platform: Option<String>,
39}
40
41impl RedisSentinelTemplate {
42    /// Create a new Redis Sentinel template
43    pub fn new(name: impl Into<String>) -> Self {
44        Self {
45            name: name.into(),
46            master_name: "mymaster".to_string(),
47            num_replicas: 2,
48            num_sentinels: 3,
49            quorum: 2,
50            master_port: 6379,
51            replica_port_base: 6380,
52            sentinel_port_base: 26379,
53            password: None,
54            down_after_milliseconds: 5000,
55            failover_timeout: 10000,
56            parallel_syncs: 1,
57            persistence: false,
58            network: None,
59            redis_image: None,
60            redis_tag: None,
61            platform: None,
62        }
63    }
64
65    /// Set the master name for Sentinel monitoring
66    pub fn master_name(mut self, name: impl Into<String>) -> Self {
67        self.master_name = name.into();
68        self
69    }
70
71    /// Set the number of Redis replicas
72    pub fn num_replicas(mut self, num: usize) -> Self {
73        self.num_replicas = num;
74        self
75    }
76
77    /// Set the number of Sentinel instances
78    pub fn num_sentinels(mut self, num: usize) -> Self {
79        self.num_sentinels = num;
80        self
81    }
82
83    /// Set the quorum for failover decisions
84    pub fn quorum(mut self, quorum: usize) -> Self {
85        self.quorum = quorum;
86        self
87    }
88
89    /// Set the Redis master port
90    pub fn master_port(mut self, port: u16) -> Self {
91        self.master_port = port;
92        self
93    }
94
95    /// Set the base port for replicas (will increment for each replica)
96    pub fn replica_port_base(mut self, port: u16) -> Self {
97        self.replica_port_base = port;
98        self
99    }
100
101    /// Set the base port for Sentinels (will increment for each Sentinel)
102    pub fn sentinel_port_base(mut self, port: u16) -> Self {
103        self.sentinel_port_base = port;
104        self
105    }
106
107    /// Set Redis password for authentication
108    pub fn password(mut self, password: impl Into<String>) -> Self {
109        self.password = Some(password.into());
110        self
111    }
112
113    /// Set the time in milliseconds before master is considered down
114    pub fn down_after_milliseconds(mut self, ms: u32) -> Self {
115        self.down_after_milliseconds = ms;
116        self
117    }
118
119    /// Set the failover timeout in milliseconds
120    pub fn failover_timeout(mut self, ms: u32) -> Self {
121        self.failover_timeout = ms;
122        self
123    }
124
125    /// Set the number of parallel syncs during failover
126    pub fn parallel_syncs(mut self, num: u32) -> Self {
127        self.parallel_syncs = num;
128        self
129    }
130
131    /// Enable persistence for Redis instances
132    pub fn with_persistence(mut self) -> Self {
133        self.persistence = true;
134        self
135    }
136
137    /// Use a specific network
138    pub fn network(mut self, network: impl Into<String>) -> Self {
139        self.network = Some(network.into());
140        self
141    }
142
143    /// Use a custom Redis image and tag
144    pub fn custom_redis_image(mut self, image: impl Into<String>, tag: impl Into<String>) -> Self {
145        self.redis_image = Some(image.into());
146        self.redis_tag = Some(tag.into());
147        self
148    }
149
150    /// Set the platform for the containers (e.g., "linux/arm64", "linux/amd64")
151    pub fn platform(mut self, platform: impl Into<String>) -> Self {
152        self.platform = Some(platform.into());
153        self
154    }
155
156    /// Start the Redis Sentinel cluster
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if:
161    /// - Network creation fails
162    /// - Starting any container (master, replica, or sentinel) fails
163    pub async fn start(self) -> Result<SentinelConnectionInfo, crate::Error> {
164        let network_name = self
165            .network
166            .clone()
167            .unwrap_or_else(|| format!("{}-network", self.name));
168
169        // Create network if not provided
170        if self.network.is_none() {
171            NetworkCreateCommand::new(&network_name)
172                .execute()
173                .await
174                .map_err(|e| crate::Error::Custom {
175                    message: format!("Failed to create network: {e}"),
176                })?;
177        }
178
179        // Start Redis master
180        let master_name = format!("{}-master", self.name);
181        let mut master_cmd = self.build_redis_command(&master_name, self.master_port, None);
182        master_cmd = master_cmd.network(&network_name);
183
184        master_cmd
185            .execute()
186            .await
187            .map_err(|e| crate::Error::Custom {
188                message: format!("Failed to start master: {e}"),
189            })?;
190
191        // Start Redis replicas
192        let mut replica_containers = Vec::new();
193        for i in 0..self.num_replicas {
194            let replica_name = format!("{}-replica-{}", self.name, i + 1);
195            let replica_port = self.replica_port_base + u16::try_from(i).unwrap_or(0);
196
197            let mut replica_cmd =
198                self.build_redis_command(&replica_name, replica_port, Some(&master_name));
199            replica_cmd = replica_cmd.network(&network_name);
200
201            replica_cmd
202                .execute()
203                .await
204                .map_err(|e| crate::Error::Custom {
205                    message: format!("Failed to start replica {}: {e}", i + 1),
206                })?;
207
208            replica_containers.push(replica_name);
209        }
210
211        // Create Sentinel configuration
212        let sentinel_config = self.build_sentinel_config(&master_name);
213
214        // Start Sentinel instances
215        let mut sentinel_containers = Vec::new();
216        for i in 0..self.num_sentinels {
217            let sentinel_name = format!("{}-sentinel-{}", self.name, i + 1);
218            let sentinel_port = self.sentinel_port_base + u16::try_from(i).unwrap_or(0);
219
220            let mut sentinel_cmd = Self::build_sentinel_command(
221                &sentinel_name,
222                sentinel_port,
223                &sentinel_config,
224                self.redis_image.as_deref(),
225                self.redis_tag.as_deref(),
226                self.platform.as_deref(),
227            );
228            sentinel_cmd = sentinel_cmd.network(&network_name);
229
230            sentinel_cmd
231                .execute()
232                .await
233                .map_err(|e| crate::Error::Custom {
234                    message: format!("Failed to start sentinel {}: {e}", i + 1),
235                })?;
236
237            sentinel_containers.push((sentinel_name, sentinel_port));
238        }
239
240        Ok(SentinelConnectionInfo {
241            name: self.name.clone(),
242            master_name: self.master_name.clone(),
243            master_host: "localhost".to_string(),
244            master_port: self.master_port,
245            replica_ports: (0..self.num_replicas)
246                .map(|i| self.replica_port_base + u16::try_from(i).unwrap_or(0))
247                .collect(),
248            sentinels: sentinel_containers
249                .into_iter()
250                .map(|(_, port)| SentinelInfo {
251                    host: "localhost".to_string(),
252                    port,
253                })
254                .collect(),
255            password: self.password.clone(),
256            network: network_name,
257            containers: {
258                let mut containers = vec![master_name];
259                containers.extend(replica_containers);
260                containers.extend(
261                    (0..self.num_sentinels).map(|i| format!("{}-sentinel-{}", self.name, i + 1)),
262                );
263                containers
264            },
265        })
266    }
267
268    /// Build a Redis command (master or replica)
269    fn build_redis_command(&self, name: &str, port: u16, master: Option<&str>) -> RunCommand {
270        // Choose image based on custom image or default
271        let image = if let Some(ref custom_image) = self.redis_image {
272            if let Some(ref tag) = self.redis_tag {
273                format!("{custom_image}:{tag}")
274            } else {
275                custom_image.clone()
276            }
277        } else {
278            format!("{DEFAULT_REDIS_IMAGE}:{DEFAULT_REDIS_TAG}")
279        };
280
281        let mut cmd = RunCommand::new(image).name(name).port(port, 6379).detach();
282
283        // Add platform if specified
284        if let Some(ref platform) = self.platform {
285            cmd = cmd.platform(platform);
286        }
287
288        // Add persistence if enabled
289        if self.persistence {
290            cmd = cmd.volume(format!("{name}-data"), "/data");
291        }
292
293        // Build command arguments
294        let mut args = Vec::new();
295
296        // If this is a replica, configure replication
297        if let Some(master_name) = master {
298            args.push(format!("--replicaof {master_name} 6379"));
299        }
300
301        // Add password if set
302        if let Some(ref password) = self.password {
303            args.push(format!("--requirepass {password}"));
304            if master.is_some() {
305                args.push(format!("--masterauth {password}"));
306            }
307        }
308
309        // Add protected mode
310        args.push("--protected-mode no".to_string());
311
312        if !args.is_empty() {
313            cmd = cmd.entrypoint("redis-server").cmd(args);
314        }
315
316        cmd
317    }
318
319    /// Build Sentinel command
320    fn build_sentinel_command(
321        name: &str,
322        port: u16,
323        config: &str,
324        redis_image: Option<&str>,
325        redis_tag: Option<&str>,
326        platform: Option<&str>,
327    ) -> RunCommand {
328        // Choose image based on custom image or default
329        let image = if let Some(custom_image) = redis_image {
330            if let Some(tag) = redis_tag {
331                format!("{custom_image}:{tag}")
332            } else {
333                custom_image.to_string()
334            }
335        } else {
336            format!("{DEFAULT_REDIS_IMAGE}:{DEFAULT_REDIS_TAG}")
337        };
338
339        let mut cmd = RunCommand::new(image).name(name).port(port, 26379).detach();
340
341        // Add platform if specified
342        if let Some(platform) = platform {
343            cmd = cmd.platform(platform);
344        }
345
346        // Create inline Sentinel config using echo
347        let config_cmd = format!(
348            "echo '{}' > /tmp/sentinel.conf && redis-sentinel /tmp/sentinel.conf",
349            config.replace('\'', "'\\''").replace('\n', "\\n")
350        );
351
352        cmd = cmd.entrypoint("sh").cmd(vec!["-c".to_string(), config_cmd]);
353
354        cmd
355    }
356
357    /// Build Sentinel configuration
358    fn build_sentinel_config(&self, master_container: &str) -> String {
359        let mut config = Vec::new();
360
361        config.push("port 26379".to_string());
362        config.push(format!(
363            "sentinel monitor {} {} 6379 {}",
364            self.master_name, master_container, self.quorum
365        ));
366
367        if let Some(ref password) = self.password {
368            config.push(format!(
369                "sentinel auth-pass {} {}",
370                self.master_name, password
371            ));
372        }
373
374        config.push(format!(
375            "sentinel down-after-milliseconds {} {}",
376            self.master_name, self.down_after_milliseconds
377        ));
378        config.push(format!(
379            "sentinel failover-timeout {} {}",
380            self.master_name, self.failover_timeout
381        ));
382        config.push(format!(
383            "sentinel parallel-syncs {} {}",
384            self.master_name, self.parallel_syncs
385        ));
386
387        config.join("\n")
388    }
389}
390
391/// Connection information for Redis Sentinel
392pub struct SentinelConnectionInfo {
393    /// Name of the Sentinel deployment
394    pub name: String,
395    /// Master name used by Sentinel
396    pub master_name: String,
397    /// Host address of the Redis master
398    pub master_host: String,
399    /// Port of the Redis master
400    pub master_port: u16,
401    /// Ports of the Redis replica instances
402    pub replica_ports: Vec<u16>,
403    /// Information about Sentinel instances
404    pub sentinels: Vec<SentinelInfo>,
405    /// Redis password if authentication is enabled
406    pub password: Option<String>,
407    /// Docker network name
408    pub network: String,
409    /// Names of all containers in the cluster
410    pub containers: Vec<String>,
411}
412
413/// Information about a Sentinel instance
414pub struct SentinelInfo {
415    /// Host address of the Sentinel
416    pub host: String,
417    /// Port of the Sentinel
418    pub port: u16,
419}
420
421impl SentinelConnectionInfo {
422    /// Get Redis URL for direct master connection
423    pub fn master_url(&self) -> String {
424        if let Some(ref password) = self.password {
425            format!(
426                "redis://default:{}@{}:{}",
427                password, self.master_host, self.master_port
428            )
429        } else {
430            format!("redis://{}:{}", self.master_host, self.master_port)
431        }
432    }
433
434    /// Get Sentinel URLs for Sentinel-aware clients
435    pub fn sentinel_urls(&self) -> Vec<String> {
436        self.sentinels
437            .iter()
438            .map(|s| format!("redis://{}:{}", s.host, s.port))
439            .collect()
440    }
441
442    /// Stop all containers in the Sentinel cluster
443    ///
444    /// # Errors
445    ///
446    /// Returns an error if:
447    /// - Stopping or removing any container fails
448    /// - Removing the network fails
449    pub async fn stop(self) -> Result<(), crate::Error> {
450        use crate::{NetworkRmCommand, RmCommand, StopCommand};
451
452        // Stop and remove all containers
453        for container in &self.containers {
454            StopCommand::new(container)
455                .execute()
456                .await
457                .map_err(|e| crate::Error::Custom {
458                    message: format!("Failed to stop {container}: {e}"),
459                })?;
460
461            RmCommand::new(container)
462                .force()
463                .volumes()
464                .execute()
465                .await
466                .map_err(|e| crate::Error::Custom {
467                    message: format!("Failed to remove {container}: {e}"),
468                })?;
469        }
470
471        // Remove network if it was created by us
472        if self.network.starts_with(&self.name) {
473            NetworkRmCommand::new(&self.network)
474                .execute()
475                .await
476                .map_err(|e| crate::Error::Custom {
477                    message: format!("Failed to remove network: {e}"),
478                })?;
479        }
480
481        Ok(())
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn test_sentinel_template_defaults() {
491        let template = RedisSentinelTemplate::new("test-sentinel");
492        assert_eq!(template.name, "test-sentinel");
493        assert_eq!(template.master_name, "mymaster");
494        assert_eq!(template.num_replicas, 2);
495        assert_eq!(template.num_sentinels, 3);
496        assert_eq!(template.quorum, 2);
497    }
498
499    #[test]
500    fn test_sentinel_template_builder() {
501        let template = RedisSentinelTemplate::new("test-sentinel")
502            .master_name("primary")
503            .num_replicas(3)
504            .num_sentinels(5)
505            .quorum(3)
506            .password("secret")
507            .with_persistence();
508
509        assert_eq!(template.master_name, "primary");
510        assert_eq!(template.num_replicas, 3);
511        assert_eq!(template.num_sentinels, 5);
512        assert_eq!(template.quorum, 3);
513        assert_eq!(template.password, Some("secret".to_string()));
514        assert!(template.persistence);
515    }
516
517    #[test]
518    fn test_sentinel_config_generation() {
519        let template = RedisSentinelTemplate::new("test")
520            .master_name("mymaster")
521            .password("secret")
522            .quorum(2);
523
524        let config = template.build_sentinel_config("redis-master");
525
526        assert!(config.contains("sentinel monitor mymaster redis-master 6379 2"));
527        assert!(config.contains("sentinel auth-pass mymaster secret"));
528        assert!(config.contains("sentinel down-after-milliseconds mymaster 5000"));
529    }
530}