docker_wrapper/template/redis/
basic.rs

1//! Basic Redis template for quick Redis container setup
2
3#![allow(clippy::doc_markdown)]
4#![allow(clippy::must_use_candidate)]
5#![allow(clippy::return_self_not_must_use)]
6#![allow(clippy::needless_borrows_for_generic_args)]
7#![allow(clippy::unnecessary_get_then_check)]
8
9use super::common::{
10    default_redis_health_check, redis_config_volume, redis_connection_string, redis_data_volume,
11    DEFAULT_REDIS_IMAGE, DEFAULT_REDIS_TAG, REDIS_STACK_IMAGE, REDIS_STACK_TAG,
12};
13use crate::template::{HasConnectionString, Template, TemplateConfig};
14use async_trait::async_trait;
15use std::collections::HashMap;
16
17/// Redis container template with sensible defaults
18pub struct RedisTemplate {
19    config: TemplateConfig,
20    use_redis_stack: bool,
21}
22
23impl RedisTemplate {
24    /// Create a new Redis template with default settings
25    pub fn new(name: impl Into<String>) -> Self {
26        let name = name.into();
27        let env = HashMap::new();
28
29        // Default Redis configuration
30        let config = TemplateConfig {
31            name: name.clone(),
32            image: DEFAULT_REDIS_IMAGE.to_string(),
33            tag: DEFAULT_REDIS_TAG.to_string(),
34            ports: vec![(6379, 6379)],
35            env,
36            volumes: Vec::new(),
37            network: None,
38            health_check: Some(default_redis_health_check()),
39            auto_remove: false,
40            memory_limit: None,
41            cpu_limit: None,
42            platform: None,
43        };
44
45        Self {
46            config,
47            use_redis_stack: false,
48        }
49    }
50
51    /// Set a custom Redis port
52    pub fn port(mut self, port: u16) -> Self {
53        self.config.ports = vec![(port, 6379)];
54        self
55    }
56
57    /// Set Redis password
58    pub fn password(mut self, password: impl Into<String>) -> Self {
59        // Redis uses command args for password, we'll handle this in build_command
60        self.config
61            .env
62            .insert("REDIS_PASSWORD".to_string(), password.into());
63        self
64    }
65
66    /// Enable persistence with a volume
67    pub fn with_persistence(mut self, volume_name: impl Into<String>) -> Self {
68        self.config.volumes.push(redis_data_volume(volume_name));
69        self
70    }
71
72    /// Set custom Redis configuration file
73    pub fn config_file(mut self, config_path: impl Into<String>) -> Self {
74        self.config.volumes.push(redis_config_volume(config_path));
75        self
76    }
77
78    /// Set memory limit for Redis
79    pub fn memory_limit(mut self, limit: impl Into<String>) -> Self {
80        self.config.memory_limit = Some(limit.into());
81        self
82    }
83
84    /// Enable Redis cluster mode
85    pub fn cluster_mode(mut self) -> Self {
86        self.config
87            .env
88            .insert("REDIS_CLUSTER".to_string(), "yes".to_string());
89        self
90    }
91
92    /// Set max memory policy
93    pub fn maxmemory_policy(mut self, policy: impl Into<String>) -> Self {
94        self.config
95            .env
96            .insert("REDIS_MAXMEMORY_POLICY".to_string(), policy.into());
97        self
98    }
99
100    /// Use a specific Redis version
101    pub fn version(mut self, version: impl Into<String>) -> Self {
102        self.config.tag = format!("{}-alpine", version.into());
103        self
104    }
105
106    /// Connect to a specific network
107    pub fn network(mut self, network: impl Into<String>) -> Self {
108        self.config.network = Some(network.into());
109        self
110    }
111
112    /// Enable auto-remove when stopped
113    pub fn auto_remove(mut self) -> Self {
114        self.config.auto_remove = true;
115        self
116    }
117
118    /// Use Redis Stack image instead of basic Redis
119    pub fn with_redis_stack(mut self) -> Self {
120        self.use_redis_stack = true;
121        self
122    }
123
124    /// Use a custom image and tag
125    pub fn custom_image(mut self, image: impl Into<String>, tag: impl Into<String>) -> Self {
126        self.config.image = image.into();
127        self.config.tag = tag.into();
128        self
129    }
130
131    /// Set the platform for the container (e.g., "linux/arm64", "linux/amd64")
132    pub fn platform(mut self, platform: impl Into<String>) -> Self {
133        self.config.platform = Some(platform.into());
134        self
135    }
136}
137
138#[async_trait]
139impl Template for RedisTemplate {
140    fn name(&self) -> &str {
141        &self.config.name
142    }
143
144    fn config(&self) -> &TemplateConfig {
145        &self.config
146    }
147
148    fn config_mut(&mut self) -> &mut TemplateConfig {
149        &mut self.config
150    }
151
152    async fn wait_for_ready(&self) -> crate::template::Result<()> {
153        use std::time::Duration;
154        use tokio::time::{sleep, timeout};
155
156        // Custom Redis readiness check
157        // Use 60 second timeout for slower systems (especially Windows)
158        let wait_timeout = Duration::from_secs(60);
159        let check_interval = Duration::from_millis(500);
160
161        timeout(wait_timeout, async {
162            loop {
163                // Check if container is running - keep retrying if not yet started
164                // Don't fail immediately as the container may still be starting up
165                if !self.is_running().await.unwrap_or(false) {
166                    sleep(check_interval).await;
167                    continue;
168                }
169
170                // Try to ping Redis
171                let password = self.config.env.get("REDIS_PASSWORD");
172                let mut ping_cmd = vec!["redis-cli", "-h", "localhost"];
173
174                // Add auth if password is set
175                let auth_args;
176                if let Some(pass) = password {
177                    auth_args = vec!["-a", pass.as_str()];
178                    ping_cmd.extend(&auth_args);
179                }
180
181                ping_cmd.push("ping");
182
183                // Execute ping command
184                if let Ok(result) = self.exec(ping_cmd).await {
185                    if result.stdout.trim() == "PONG" {
186                        return Ok(());
187                    }
188                }
189
190                sleep(check_interval).await;
191            }
192        })
193        .await
194        .map_err(|_| {
195            crate::template::TemplateError::InvalidConfig(format!(
196                "Redis container {} failed to become ready within timeout",
197                self.config().name
198            ))
199        })?
200    }
201
202    fn build_command(&self) -> crate::RunCommand {
203        let config = self.config();
204
205        // Choose image based on Redis Stack preference
206        let image_tag = if self.use_redis_stack {
207            format!("{REDIS_STACK_IMAGE}:{REDIS_STACK_TAG}")
208        } else {
209            format!("{}:{}", config.image, config.tag)
210        };
211
212        let mut cmd = crate::RunCommand::new(image_tag)
213            .name(&config.name)
214            .detach();
215
216        // Add port mappings
217        for (host, container) in &config.ports {
218            cmd = cmd.port(*host, *container);
219        }
220
221        // Add volume mounts
222        for mount in &config.volumes {
223            if mount.read_only {
224                cmd = cmd.volume_ro(&mount.source, &mount.target);
225            } else {
226                cmd = cmd.volume(&mount.source, &mount.target);
227            }
228        }
229
230        // Add network
231        if let Some(network) = &config.network {
232            cmd = cmd.network(network);
233        }
234
235        // Add health check
236        if let Some(health) = &config.health_check {
237            cmd = cmd
238                .health_cmd(&health.test.join(" "))
239                .health_interval(&health.interval)
240                .health_timeout(&health.timeout)
241                .health_retries(health.retries)
242                .health_start_period(&health.start_period);
243        }
244
245        // Add resource limits
246        if let Some(memory) = &config.memory_limit {
247            cmd = cmd.memory(memory);
248        }
249
250        if let Some(cpu) = &config.cpu_limit {
251            cmd = cmd.cpus(cpu);
252        }
253
254        // Auto-remove
255        if config.auto_remove {
256            cmd = cmd.remove();
257        }
258
259        // Handle Redis-specific command args
260        if let Some(password) = config.env.get("REDIS_PASSWORD") {
261            if self.use_redis_stack {
262                // For Redis Stack, use environment variable instead of command override
263                cmd = cmd.env("REDIS_ARGS", format!("--requirepass {password}"));
264            } else {
265                // For basic Redis, override entrypoint to bypass docker-entrypoint.sh and directly run redis-server
266                cmd = cmd.entrypoint("redis-server").cmd(vec![
267                    "--requirepass".to_string(),
268                    password.clone(),
269                    "--protected-mode".to_string(),
270                    "yes".to_string(),
271                ]);
272            }
273        }
274
275        // If custom config file is mounted
276        let has_config = config
277            .volumes
278            .iter()
279            .any(|v| v.target == "/usr/local/etc/redis/redis.conf");
280        if has_config && config.env.get("REDIS_PASSWORD").is_none() {
281            cmd = cmd.cmd(vec![
282                "redis-server".to_string(),
283                "/usr/local/etc/redis/redis.conf".to_string(),
284            ]);
285        }
286
287        cmd
288    }
289}
290
291impl HasConnectionString for RedisTemplate {
292    /// Returns the Redis connection string in URL format.
293    ///
294    /// Format: `redis://[:password@]host:port`
295    ///
296    /// # Example
297    ///
298    /// ```rust
299    /// use docker_wrapper::template::{RedisTemplate, HasConnectionString};
300    ///
301    /// let template = RedisTemplate::new("my-redis").port(6380);
302    /// assert_eq!(template.connection_string(), "redis://localhost:6380");
303    ///
304    /// let template_with_pass = RedisTemplate::new("my-redis")
305    ///     .port(6380)
306    ///     .password("secret");
307    /// assert_eq!(template_with_pass.connection_string(), "redis://:secret@localhost:6380");
308    /// ```
309    fn connection_string(&self) -> String {
310        let port = self.config.ports.first().map_or(6379, |(h, _)| *h);
311        let password = self.config.env.get("REDIS_PASSWORD").map(String::as_str);
312        redis_connection_string("localhost", port, password)
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::DockerCommand;
320
321    #[test]
322    fn test_redis_template_basic() {
323        let template = RedisTemplate::new("test-redis");
324        assert_eq!(template.name(), "test-redis");
325        assert_eq!(template.config().image, "redis");
326        assert_eq!(template.config().tag, "7-alpine");
327        assert_eq!(template.config().ports, vec![(6379, 6379)]);
328    }
329
330    #[test]
331    fn test_redis_template_with_password() {
332        let template = RedisTemplate::new("test-redis").password("secret123");
333
334        assert_eq!(
335            template.config().env.get("REDIS_PASSWORD"),
336            Some(&"secret123".to_string())
337        );
338    }
339
340    #[test]
341    fn test_redis_template_with_persistence() {
342        let template = RedisTemplate::new("test-redis").with_persistence("redis-data");
343
344        assert_eq!(template.config().volumes.len(), 1);
345        assert_eq!(template.config().volumes[0].source, "redis-data");
346        assert_eq!(template.config().volumes[0].target, "/data");
347    }
348
349    #[test]
350    fn test_redis_template_custom_port() {
351        let template = RedisTemplate::new("test-redis").port(16379);
352
353        assert_eq!(template.config().ports, vec![(16379, 6379)]);
354    }
355
356    #[test]
357    fn test_redis_build_command() {
358        let template = RedisTemplate::new("test-redis")
359            .password("mypass")
360            .port(16379);
361
362        let cmd = template.build_command();
363        let args = cmd.build_command_args();
364
365        // Check that basic args are present
366        assert!(args.contains(&"run".to_string()));
367        assert!(args.contains(&"--name".to_string()));
368        assert!(args.contains(&"test-redis".to_string()));
369        assert!(args.contains(&"--publish".to_string()));
370        assert!(args.contains(&"16379:6379".to_string()));
371    }
372
373    #[test]
374    fn test_redis_connection_string() {
375        use crate::template::HasConnectionString;
376
377        let template = RedisTemplate::new("test-redis").port(6380);
378        assert_eq!(template.connection_string(), "redis://localhost:6380");
379    }
380
381    #[test]
382    fn test_redis_connection_string_with_password() {
383        use crate::template::HasConnectionString;
384
385        let template = RedisTemplate::new("test-redis")
386            .port(6380)
387            .password("secret");
388        assert_eq!(
389            template.connection_string(),
390            "redis://:secret@localhost:6380"
391        );
392    }
393
394    #[test]
395    fn test_redis_connection_string_default_port() {
396        use crate::template::HasConnectionString;
397
398        let template = RedisTemplate::new("test-redis");
399        assert_eq!(template.connection_string(), "redis://localhost:6379");
400    }
401}