#![allow(clippy::doc_markdown)]
#![allow(clippy::must_use_candidate)]
#![allow(clippy::return_self_not_must_use)]
#![allow(clippy::needless_borrows_for_generic_args)]
#![allow(clippy::unnecessary_get_then_check)]
use super::common::{DEFAULT_REDIS_IMAGE, DEFAULT_REDIS_TAG};
use crate::{DockerCommand, NetworkCreateCommand, RunCommand};
pub struct RedisSentinelTemplate {
name: String,
master_name: String,
num_replicas: usize,
num_sentinels: usize,
quorum: usize,
master_port: u16,
replica_port_base: u16,
sentinel_port_base: u16,
password: Option<String>,
down_after_milliseconds: u32,
failover_timeout: u32,
parallel_syncs: u32,
persistence: bool,
network: Option<String>,
redis_image: Option<String>,
redis_tag: Option<String>,
platform: Option<String>,
}
impl RedisSentinelTemplate {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
master_name: "mymaster".to_string(),
num_replicas: 2,
num_sentinels: 3,
quorum: 2,
master_port: 6379,
replica_port_base: 6380,
sentinel_port_base: 26379,
password: None,
down_after_milliseconds: 5000,
failover_timeout: 10000,
parallel_syncs: 1,
persistence: false,
network: None,
redis_image: None,
redis_tag: None,
platform: None,
}
}
pub fn master_name(mut self, name: impl Into<String>) -> Self {
self.master_name = name.into();
self
}
pub fn num_replicas(mut self, num: usize) -> Self {
self.num_replicas = num;
self
}
pub fn num_sentinels(mut self, num: usize) -> Self {
self.num_sentinels = num;
self
}
pub fn quorum(mut self, quorum: usize) -> Self {
self.quorum = quorum;
self
}
pub fn master_port(mut self, port: u16) -> Self {
self.master_port = port;
self
}
pub fn replica_port_base(mut self, port: u16) -> Self {
self.replica_port_base = port;
self
}
pub fn sentinel_port_base(mut self, port: u16) -> Self {
self.sentinel_port_base = port;
self
}
pub fn password(mut self, password: impl Into<String>) -> Self {
self.password = Some(password.into());
self
}
pub fn down_after_milliseconds(mut self, ms: u32) -> Self {
self.down_after_milliseconds = ms;
self
}
pub fn failover_timeout(mut self, ms: u32) -> Self {
self.failover_timeout = ms;
self
}
pub fn parallel_syncs(mut self, num: u32) -> Self {
self.parallel_syncs = num;
self
}
pub fn with_persistence(mut self) -> Self {
self.persistence = true;
self
}
pub fn network(mut self, network: impl Into<String>) -> Self {
self.network = Some(network.into());
self
}
pub fn custom_redis_image(mut self, image: impl Into<String>, tag: impl Into<String>) -> Self {
self.redis_image = Some(image.into());
self.redis_tag = Some(tag.into());
self
}
pub fn platform(mut self, platform: impl Into<String>) -> Self {
self.platform = Some(platform.into());
self
}
pub async fn start(self) -> Result<SentinelConnectionInfo, crate::Error> {
let network_name = self
.network
.clone()
.unwrap_or_else(|| format!("{}-network", self.name));
if self.network.is_none() {
NetworkCreateCommand::new(&network_name)
.execute()
.await
.map_err(|e| crate::Error::Custom {
message: format!("Failed to create network: {e}"),
})?;
}
let master_name = format!("{}-master", self.name);
let mut master_cmd = self.build_redis_command(&master_name, self.master_port, None);
master_cmd = master_cmd.network(&network_name);
master_cmd
.execute()
.await
.map_err(|e| crate::Error::Custom {
message: format!("Failed to start master: {e}"),
})?;
let mut replica_containers = Vec::new();
for i in 0..self.num_replicas {
let replica_name = format!("{}-replica-{}", self.name, i + 1);
let replica_port = self.replica_port_base + u16::try_from(i).unwrap_or(0);
let mut replica_cmd =
self.build_redis_command(&replica_name, replica_port, Some(&master_name));
replica_cmd = replica_cmd.network(&network_name);
replica_cmd
.execute()
.await
.map_err(|e| crate::Error::Custom {
message: format!("Failed to start replica {}: {e}", i + 1),
})?;
replica_containers.push(replica_name);
}
let sentinel_config = self.build_sentinel_config(&master_name);
let mut sentinel_containers = Vec::new();
for i in 0..self.num_sentinels {
let sentinel_name = format!("{}-sentinel-{}", self.name, i + 1);
let sentinel_port = self.sentinel_port_base + u16::try_from(i).unwrap_or(0);
let mut sentinel_cmd = Self::build_sentinel_command(
&sentinel_name,
sentinel_port,
&sentinel_config,
self.redis_image.as_deref(),
self.redis_tag.as_deref(),
self.platform.as_deref(),
);
sentinel_cmd = sentinel_cmd.network(&network_name);
sentinel_cmd
.execute()
.await
.map_err(|e| crate::Error::Custom {
message: format!("Failed to start sentinel {}: {e}", i + 1),
})?;
sentinel_containers.push((sentinel_name, sentinel_port));
}
Ok(SentinelConnectionInfo {
name: self.name.clone(),
master_name: self.master_name.clone(),
master_host: "localhost".to_string(),
master_port: self.master_port,
replica_ports: (0..self.num_replicas)
.map(|i| self.replica_port_base + u16::try_from(i).unwrap_or(0))
.collect(),
sentinels: sentinel_containers
.into_iter()
.map(|(_, port)| SentinelInfo {
host: "localhost".to_string(),
port,
})
.collect(),
password: self.password.clone(),
network: network_name,
containers: {
let mut containers = vec![master_name];
containers.extend(replica_containers);
containers.extend(
(0..self.num_sentinels).map(|i| format!("{}-sentinel-{}", self.name, i + 1)),
);
containers
},
})
}
fn build_redis_command(&self, name: &str, port: u16, master: Option<&str>) -> RunCommand {
let image = if let Some(ref custom_image) = self.redis_image {
if let Some(ref tag) = self.redis_tag {
format!("{custom_image}:{tag}")
} else {
custom_image.clone()
}
} else {
format!("{DEFAULT_REDIS_IMAGE}:{DEFAULT_REDIS_TAG}")
};
let mut cmd = RunCommand::new(image).name(name).port(port, 6379).detach();
if let Some(ref platform) = self.platform {
cmd = cmd.platform(platform);
}
if self.persistence {
cmd = cmd.volume(format!("{name}-data"), "/data");
}
let mut args = Vec::new();
if let Some(master_name) = master {
args.push(format!("--replicaof {master_name} 6379"));
}
if let Some(ref password) = self.password {
args.push(format!("--requirepass {password}"));
if master.is_some() {
args.push(format!("--masterauth {password}"));
}
}
args.push("--protected-mode no".to_string());
if !args.is_empty() {
cmd = cmd.entrypoint("redis-server").cmd(args);
}
cmd
}
fn build_sentinel_command(
name: &str,
port: u16,
config: &str,
redis_image: Option<&str>,
redis_tag: Option<&str>,
platform: Option<&str>,
) -> RunCommand {
let image = if let Some(custom_image) = redis_image {
if let Some(tag) = redis_tag {
format!("{custom_image}:{tag}")
} else {
custom_image.to_string()
}
} else {
format!("{DEFAULT_REDIS_IMAGE}:{DEFAULT_REDIS_TAG}")
};
let mut cmd = RunCommand::new(image).name(name).port(port, 26379).detach();
if let Some(platform) = platform {
cmd = cmd.platform(platform);
}
let config_cmd = format!(
"echo '{}' > /tmp/sentinel.conf && redis-sentinel /tmp/sentinel.conf",
config.replace('\'', "'\\''").replace('\n', "\\n")
);
cmd = cmd.entrypoint("sh").cmd(vec!["-c".to_string(), config_cmd]);
cmd
}
fn build_sentinel_config(&self, master_container: &str) -> String {
let mut config = Vec::new();
config.push("port 26379".to_string());
config.push(format!(
"sentinel monitor {} {} 6379 {}",
self.master_name, master_container, self.quorum
));
if let Some(ref password) = self.password {
config.push(format!(
"sentinel auth-pass {} {}",
self.master_name, password
));
}
config.push(format!(
"sentinel down-after-milliseconds {} {}",
self.master_name, self.down_after_milliseconds
));
config.push(format!(
"sentinel failover-timeout {} {}",
self.master_name, self.failover_timeout
));
config.push(format!(
"sentinel parallel-syncs {} {}",
self.master_name, self.parallel_syncs
));
config.join("\n")
}
}
pub struct SentinelConnectionInfo {
pub name: String,
pub master_name: String,
pub master_host: String,
pub master_port: u16,
pub replica_ports: Vec<u16>,
pub sentinels: Vec<SentinelInfo>,
pub password: Option<String>,
pub network: String,
pub containers: Vec<String>,
}
pub struct SentinelInfo {
pub host: String,
pub port: u16,
}
impl SentinelConnectionInfo {
pub fn master_url(&self) -> String {
if let Some(ref password) = self.password {
format!(
"redis://default:{}@{}:{}",
password, self.master_host, self.master_port
)
} else {
format!("redis://{}:{}", self.master_host, self.master_port)
}
}
pub fn sentinel_urls(&self) -> Vec<String> {
self.sentinels
.iter()
.map(|s| format!("redis://{}:{}", s.host, s.port))
.collect()
}
pub async fn stop(self) -> Result<(), crate::Error> {
use crate::{NetworkRmCommand, RmCommand, StopCommand};
for container in &self.containers {
StopCommand::new(container)
.execute()
.await
.map_err(|e| crate::Error::Custom {
message: format!("Failed to stop {container}: {e}"),
})?;
RmCommand::new(container)
.force()
.volumes()
.execute()
.await
.map_err(|e| crate::Error::Custom {
message: format!("Failed to remove {container}: {e}"),
})?;
}
if self.network.starts_with(&self.name) {
NetworkRmCommand::new(&self.network)
.execute()
.await
.map_err(|e| crate::Error::Custom {
message: format!("Failed to remove network: {e}"),
})?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sentinel_template_defaults() {
let template = RedisSentinelTemplate::new("test-sentinel");
assert_eq!(template.name, "test-sentinel");
assert_eq!(template.master_name, "mymaster");
assert_eq!(template.num_replicas, 2);
assert_eq!(template.num_sentinels, 3);
assert_eq!(template.quorum, 2);
}
#[test]
fn test_sentinel_template_builder() {
let template = RedisSentinelTemplate::new("test-sentinel")
.master_name("primary")
.num_replicas(3)
.num_sentinels(5)
.quorum(3)
.password("secret")
.with_persistence();
assert_eq!(template.master_name, "primary");
assert_eq!(template.num_replicas, 3);
assert_eq!(template.num_sentinels, 5);
assert_eq!(template.quorum, 3);
assert_eq!(template.password, Some("secret".to_string()));
assert!(template.persistence);
}
#[test]
fn test_sentinel_config_generation() {
let template = RedisSentinelTemplate::new("test")
.master_name("mymaster")
.password("secret")
.quorum(2);
let config = template.build_sentinel_config("redis-master");
assert!(config.contains("sentinel monitor mymaster redis-master 6379 2"));
assert!(config.contains("sentinel auth-pass mymaster secret"));
assert!(config.contains("sentinel down-after-milliseconds mymaster 5000"));
}
}