docker_wrapper/template/redis/
sentinel.rs1#![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
17pub 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 redis_image: Option<String>,
35 redis_tag: Option<String>,
37 platform: Option<String>,
39}
40
41impl RedisSentinelTemplate {
42 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 pub fn master_name(mut self, name: impl Into<String>) -> Self {
67 self.master_name = name.into();
68 self
69 }
70
71 pub fn num_replicas(mut self, num: usize) -> Self {
73 self.num_replicas = num;
74 self
75 }
76
77 pub fn num_sentinels(mut self, num: usize) -> Self {
79 self.num_sentinels = num;
80 self
81 }
82
83 pub fn quorum(mut self, quorum: usize) -> Self {
85 self.quorum = quorum;
86 self
87 }
88
89 pub fn master_port(mut self, port: u16) -> Self {
91 self.master_port = port;
92 self
93 }
94
95 pub fn replica_port_base(mut self, port: u16) -> Self {
97 self.replica_port_base = port;
98 self
99 }
100
101 pub fn sentinel_port_base(mut self, port: u16) -> Self {
103 self.sentinel_port_base = port;
104 self
105 }
106
107 pub fn password(mut self, password: impl Into<String>) -> Self {
109 self.password = Some(password.into());
110 self
111 }
112
113 pub fn down_after_milliseconds(mut self, ms: u32) -> Self {
115 self.down_after_milliseconds = ms;
116 self
117 }
118
119 pub fn failover_timeout(mut self, ms: u32) -> Self {
121 self.failover_timeout = ms;
122 self
123 }
124
125 pub fn parallel_syncs(mut self, num: u32) -> Self {
127 self.parallel_syncs = num;
128 self
129 }
130
131 pub fn with_persistence(mut self) -> Self {
133 self.persistence = true;
134 self
135 }
136
137 pub fn network(mut self, network: impl Into<String>) -> Self {
139 self.network = Some(network.into());
140 self
141 }
142
143 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 pub fn platform(mut self, platform: impl Into<String>) -> Self {
152 self.platform = Some(platform.into());
153 self
154 }
155
156 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 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 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 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 let sentinel_config = self.build_sentinel_config(&master_name);
213
214 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 fn build_redis_command(&self, name: &str, port: u16, master: Option<&str>) -> RunCommand {
270 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 if let Some(ref platform) = self.platform {
285 cmd = cmd.platform(platform);
286 }
287
288 if self.persistence {
290 cmd = cmd.volume(format!("{name}-data"), "/data");
291 }
292
293 let mut args = Vec::new();
295
296 if let Some(master_name) = master {
298 args.push(format!("--replicaof {master_name} 6379"));
299 }
300
301 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 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 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 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 if let Some(platform) = platform {
343 cmd = cmd.platform(platform);
344 }
345
346 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 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
391pub struct SentinelConnectionInfo {
393 pub name: String,
395 pub master_name: String,
397 pub master_host: String,
399 pub master_port: u16,
401 pub replica_ports: Vec<u16>,
403 pub sentinels: Vec<SentinelInfo>,
405 pub password: Option<String>,
407 pub network: String,
409 pub containers: Vec<String>,
411}
412
413pub struct SentinelInfo {
415 pub host: String,
417 pub port: u16,
419}
420
421impl SentinelConnectionInfo {
422 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 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 pub async fn stop(self) -> Result<(), crate::Error> {
450 use crate::{NetworkRmCommand, RmCommand, StopCommand};
451
452 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 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}