Skip to main content

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    redis_tls_connection_string, redis_tls_server_args, redis_tls_volume, DEFAULT_REDIS_IMAGE,
12    DEFAULT_REDIS_TAG, DEFAULT_REDIS_TLS_PORT, REDIS_STACK_IMAGE, REDIS_STACK_TAG,
13};
14use crate::template::{HasConnectionString, Template, TemplateConfig};
15use async_trait::async_trait;
16use std::collections::HashMap;
17
18/// Redis container template with sensible defaults
19pub struct RedisTemplate {
20    config: TemplateConfig,
21    use_redis_stack: bool,
22    stack_tag: String,
23    /// Host directory containing TLS certificate material, mounted read-only
24    /// into the container when TLS is enabled.
25    tls_certs_dir: Option<String>,
26    /// Host-side port mapped to the container TLS port when TLS is enabled.
27    tls_port: u16,
28    /// When true, the plaintext port is disabled (`--port 0`) and only TLS is
29    /// served.
30    tls_only: bool,
31}
32
33impl RedisTemplate {
34    /// Create a new Redis template with default settings
35    pub fn new(name: impl Into<String>) -> Self {
36        let name = name.into();
37        let env = HashMap::new();
38
39        // Default Redis configuration
40        let config = TemplateConfig {
41            name: name.clone(),
42            image: DEFAULT_REDIS_IMAGE.to_string(),
43            tag: DEFAULT_REDIS_TAG.to_string(),
44            ports: vec![(6379, 6379)],
45            env,
46            volumes: Vec::new(),
47            network: None,
48            health_check: Some(default_redis_health_check()),
49            auto_remove: false,
50            memory_limit: None,
51            cpu_limit: None,
52            platform: None,
53        };
54
55        Self {
56            config,
57            use_redis_stack: false,
58            stack_tag: REDIS_STACK_TAG.to_string(),
59            tls_certs_dir: None,
60            tls_port: DEFAULT_REDIS_TLS_PORT,
61            tls_only: false,
62        }
63    }
64
65    /// Set a custom Redis port
66    pub fn port(mut self, port: u16) -> Self {
67        self.config.ports = vec![(port, 6379)];
68        self
69    }
70
71    /// Set Redis password
72    pub fn password(mut self, password: impl Into<String>) -> Self {
73        // Redis uses command args for password, we'll handle this in build_command
74        self.config
75            .env
76            .insert("REDIS_PASSWORD".to_string(), password.into());
77        self
78    }
79
80    /// Enable persistence with a volume
81    pub fn with_persistence(mut self, volume_name: impl Into<String>) -> Self {
82        self.config.volumes.push(redis_data_volume(volume_name));
83        self
84    }
85
86    /// Set custom Redis configuration file
87    pub fn config_file(mut self, config_path: impl Into<String>) -> Self {
88        self.config.volumes.push(redis_config_volume(config_path));
89        self
90    }
91
92    /// Set memory limit for Redis
93    pub fn memory_limit(mut self, limit: impl Into<String>) -> Self {
94        self.config.memory_limit = Some(limit.into());
95        self
96    }
97
98    /// Enable Redis cluster mode
99    pub fn cluster_mode(mut self) -> Self {
100        self.config
101            .env
102            .insert("REDIS_CLUSTER".to_string(), "yes".to_string());
103        self
104    }
105
106    /// Set max memory policy
107    pub fn maxmemory_policy(mut self, policy: impl Into<String>) -> Self {
108        self.config
109            .env
110            .insert("REDIS_MAXMEMORY_POLICY".to_string(), policy.into());
111        self
112    }
113
114    /// Use a specific Redis version
115    pub fn version(mut self, version: impl Into<String>) -> Self {
116        self.config.tag = format!("{}-alpine", version.into());
117        self
118    }
119
120    /// Connect to a specific network
121    pub fn network(mut self, network: impl Into<String>) -> Self {
122        self.config.network = Some(network.into());
123        self
124    }
125
126    /// Set the container network mode (e.g. `"host"`, `"bridge"`, `"none"`).
127    ///
128    /// This is a thin alias over [`network`](Self::network) that reads better
129    /// when selecting a Docker network *mode* rather than a named network. See
130    /// [`host_network`](Self::host_network) for the host-mode caveats.
131    pub fn network_mode(mut self, mode: impl Into<String>) -> Self {
132        self.config.network = Some(mode.into());
133        self
134    }
135
136    /// Run the container with `--network host`.
137    ///
138    /// In host networking mode the container shares the host's network
139    /// namespace: the Redis port is reachable directly on the host with no
140    /// published port mapping, so no `-p` flag is emitted and the host-side
141    /// port equals the container port (6379 by default).
142    ///
143    /// # Platform support
144    ///
145    /// Host networking is a **Linux-only** Docker feature. On Docker Desktop
146    /// for macOS and Windows the daemon runs inside a Linux VM, so
147    /// `--network host` binds ports inside that VM rather than on your machine:
148    /// the option is effectively a **no-op** there and the Redis port will not
149    /// be reachable from the host. This method does not return an error on
150    /// non-Linux hosts (the Docker CLI accepts the flag regardless of backend);
151    /// only use host mode against a native Linux daemon.
152    ///
153    /// # Example
154    ///
155    /// ```rust
156    /// use docker_wrapper::template::{RedisTemplate, Template};
157    /// use docker_wrapper::DockerCommand;
158    ///
159    /// // Linux only: Redis is reachable on localhost:6379 with no -p mapping.
160    /// let template = RedisTemplate::new("host-redis").host_network();
161    /// let args = template.build_command().build_command_args();
162    /// assert!(args.contains(&"--network".to_string()));
163    /// assert!(args.contains(&"host".to_string()));
164    /// // Host mode publishes no ports.
165    /// assert!(!args.contains(&"--publish".to_string()));
166    /// ```
167    pub fn host_network(mut self) -> Self {
168        self.config.network = Some("host".to_string());
169        self
170    }
171
172    /// Returns true when the template is configured for host networking.
173    fn uses_host_network(&self) -> bool {
174        self.config.network.as_deref() == Some("host")
175    }
176
177    /// Enable auto-remove when stopped
178    pub fn auto_remove(mut self) -> Self {
179        self.config.auto_remove = true;
180        self
181    }
182
183    /// Use Redis Stack image instead of basic Redis.
184    ///
185    /// Uses the `redis/redis-stack` image pinned to a known-good default tag
186    /// (`7.4.0-v3`) rather than `latest`, so that runs are reproducible. Call
187    /// [`stack_version`](Self::stack_version) to pin a different tag.
188    pub fn with_redis_stack(mut self) -> Self {
189        self.use_redis_stack = true;
190        self
191    }
192
193    /// Pin the Redis Stack image tag (e.g. `"7.4.0-v3"`).
194    ///
195    /// Only affects the image used when [`Self::with_redis_stack`] is enabled.
196    /// The default is a known-good pinned tag rather than `latest`, so that runs
197    /// are reproducible. For full control over both the image name and tag, use
198    /// [`Self::custom_image`] instead.
199    ///
200    /// # Example
201    ///
202    /// ```rust
203    /// use docker_wrapper::template::RedisTemplate;
204    ///
205    /// let template = RedisTemplate::new("my-redis")
206    ///     .with_redis_stack()
207    ///     .stack_version("7.4.0-v3");
208    /// ```
209    pub fn stack_version(mut self, tag: impl Into<String>) -> Self {
210        self.stack_tag = tag.into();
211        self
212    }
213
214    /// Build the image reference used when Redis Stack is enabled.
215    fn stack_image(&self) -> String {
216        format!("{REDIS_STACK_IMAGE}:{}", self.stack_tag)
217    }
218
219    /// Use a custom image and tag
220    pub fn custom_image(mut self, image: impl Into<String>, tag: impl Into<String>) -> Self {
221        self.config.image = image.into();
222        self.config.tag = tag.into();
223        self
224    }
225
226    /// Set the platform for the container (e.g., "linux/arm64", "linux/amd64")
227    pub fn platform(mut self, platform: impl Into<String>) -> Self {
228        self.config.platform = Some(platform.into());
229        self
230    }
231
232    /// Enable TLS, bind-mounting the given host certificate directory.
233    ///
234    /// The directory is mounted read-only into the container and Redis is
235    /// started with `--tls-port`, `--tls-cert-file`, `--tls-key-file` and
236    /// `--tls-ca-cert-file`. The directory **must** contain these files:
237    ///
238    /// - `redis.crt` -- the server certificate
239    /// - `redis.key` -- the server private key
240    /// - `ca.crt` -- the CA certificate used to verify client certificates
241    ///
242    /// By default the plaintext port stays open alongside TLS; call
243    /// [`tls_only`](Self::tls_only) to disable plaintext (`--port 0`). The TLS
244    /// port is published on the host (6380 by default, override with
245    /// [`tls_port`](Self::tls_port)).
246    ///
247    /// # Generating throwaway certificates
248    ///
249    /// For local testing you can generate a self-signed CA and server
250    /// certificate with `openssl`:
251    ///
252    /// ```sh
253    /// openssl genrsa -out ca.key 2048
254    /// openssl req -x509 -new -nodes -key ca.key -sha256 -days 365 \
255    ///   -subj "/CN=test-ca" -out ca.crt
256    /// openssl genrsa -out redis.key 2048
257    /// openssl req -new -key redis.key -subj "/CN=localhost" -out redis.csr
258    /// openssl x509 -req -in redis.csr -CA ca.crt -CAkey ca.key \
259    ///   -CAcreateserial -days 365 -sha256 -out redis.crt
260    /// ```
261    ///
262    /// # Example
263    ///
264    /// ```rust
265    /// use docker_wrapper::template::{RedisTemplate, Template};
266    /// use docker_wrapper::DockerCommand;
267    ///
268    /// let template = RedisTemplate::new("tls-redis").tls("/path/to/certs");
269    /// let args = template.build_command().build_command_args();
270    /// assert!(args.contains(&"--tls-port".to_string()));
271    /// assert!(args.contains(&"6380".to_string()));
272    /// ```
273    pub fn tls(mut self, certs_dir: impl Into<String>) -> Self {
274        self.tls_certs_dir = Some(certs_dir.into());
275        self
276    }
277
278    /// Set the host-side port published for TLS connections (default 6380).
279    ///
280    /// Only has an effect when TLS is enabled via [`tls`](Self::tls).
281    pub fn tls_port(mut self, port: u16) -> Self {
282        self.tls_port = port;
283        self
284    }
285
286    /// Disable the plaintext port and serve only TLS.
287    ///
288    /// Sets `--port 0` (which tells Redis to stop listening on the plaintext
289    /// port) and skips publishing the plaintext port mapping. Only has an
290    /// effect when TLS is enabled via [`tls`](Self::tls).
291    pub fn tls_only(mut self) -> Self {
292        self.tls_only = true;
293        self
294    }
295
296    /// Returns true when TLS has been enabled on this template.
297    fn tls_enabled(&self) -> bool {
298        self.tls_certs_dir.is_some()
299    }
300
301    /// Returns the TLS connection string in URL format, or `None` when TLS is
302    /// not enabled.
303    ///
304    /// Format: `rediss://[:password@]host:port`, where `port` is the published
305    /// TLS port (6380 by default, see [`tls_port`](Self::tls_port)).
306    ///
307    /// # Example
308    ///
309    /// ```rust
310    /// use docker_wrapper::template::RedisTemplate;
311    ///
312    /// let template = RedisTemplate::new("my-redis").tls("/certs");
313    /// assert_eq!(
314    ///     template.tls_connection_string().as_deref(),
315    ///     Some("rediss://localhost:6380")
316    /// );
317    ///
318    /// let plaintext = RedisTemplate::new("my-redis");
319    /// assert_eq!(plaintext.tls_connection_string(), None);
320    /// ```
321    pub fn tls_connection_string(&self) -> Option<String> {
322        if !self.tls_enabled() {
323            return None;
324        }
325        let password = self.config.env.get("REDIS_PASSWORD").map(String::as_str);
326        Some(redis_tls_connection_string(
327            "localhost",
328            self.tls_port,
329            password,
330        ))
331    }
332}
333
334#[async_trait]
335impl Template for RedisTemplate {
336    fn name(&self) -> &str {
337        &self.config.name
338    }
339
340    fn config(&self) -> &TemplateConfig {
341        &self.config
342    }
343
344    fn config_mut(&mut self) -> &mut TemplateConfig {
345        &mut self.config
346    }
347
348    async fn wait_for_ready(&self) -> crate::template::Result<()> {
349        use std::time::Duration;
350        use tokio::time::{sleep, timeout};
351
352        // Custom Redis readiness check
353        // Use 60 second timeout for slower systems (especially Windows)
354        let wait_timeout = Duration::from_secs(60);
355        let check_interval = Duration::from_millis(500);
356
357        timeout(wait_timeout, async {
358            loop {
359                // Check if container is running - keep retrying if not yet started
360                // Don't fail immediately as the container may still be starting up
361                if !self.is_running().await.unwrap_or(false) {
362                    sleep(check_interval).await;
363                    continue;
364                }
365
366                // Try to ping Redis
367                let password = self.config.env.get("REDIS_PASSWORD");
368                let mut ping_cmd = vec!["redis-cli", "-h", "localhost"];
369
370                // Add auth if password is set
371                let auth_args;
372                if let Some(pass) = password {
373                    auth_args = vec!["-a", pass.as_str()];
374                    ping_cmd.extend(&auth_args);
375                }
376
377                ping_cmd.push("ping");
378
379                // Execute ping command
380                if let Ok(result) = self.exec(ping_cmd).await {
381                    if result.stdout.trim() == "PONG" {
382                        return Ok(());
383                    }
384                }
385
386                sleep(check_interval).await;
387            }
388        })
389        .await
390        .map_err(|_| {
391            crate::template::TemplateError::InvalidConfig(format!(
392                "Redis container {} failed to become ready within timeout",
393                self.config().name
394            ))
395        })?
396    }
397
398    fn build_command(&self) -> crate::RunCommand {
399        let config = self.config();
400
401        // Choose image based on Redis Stack preference
402        let image_tag = if self.use_redis_stack {
403            self.stack_image()
404        } else {
405            format!("{}:{}", config.image, config.tag)
406        };
407
408        let mut cmd = crate::RunCommand::new(image_tag)
409            .name(&config.name)
410            .detach();
411
412        // Add port mappings. In host networking mode the container shares the
413        // host's network namespace, so published ports are ignored by Docker
414        // (and emit a warning); skip them entirely.
415        if !self.uses_host_network() {
416            // Publish the plaintext port unless TLS-only mode disabled it.
417            if !(self.tls_enabled() && self.tls_only) {
418                for (host, container) in &config.ports {
419                    cmd = cmd.port(*host, *container);
420                }
421            }
422            // Publish the TLS port when TLS is enabled.
423            if self.tls_enabled() {
424                cmd = cmd.port(self.tls_port, DEFAULT_REDIS_TLS_PORT);
425            }
426        }
427
428        // Add volume mounts
429        for mount in &config.volumes {
430            if mount.read_only {
431                cmd = cmd.volume_ro(&mount.source, &mount.target);
432            } else {
433                cmd = cmd.volume(&mount.source, &mount.target);
434            }
435        }
436
437        // Mount the TLS certificate directory read-only when TLS is enabled.
438        if let Some(ref certs_dir) = self.tls_certs_dir {
439            let mount = redis_tls_volume(certs_dir.clone());
440            cmd = cmd.volume_ro(&mount.source, &mount.target);
441        }
442
443        // Add network
444        if let Some(network) = &config.network {
445            cmd = cmd.network(network);
446        }
447
448        // Add health check
449        if let Some(health) = &config.health_check {
450            cmd = cmd
451                .health_cmd(&health.test.join(" "))
452                .health_interval(&health.interval)
453                .health_timeout(&health.timeout)
454                .health_retries(health.retries)
455                .health_start_period(&health.start_period);
456        }
457
458        // Add resource limits
459        if let Some(memory) = &config.memory_limit {
460            cmd = cmd.memory(memory);
461        }
462
463        if let Some(cpu) = &config.cpu_limit {
464            cmd = cmd.cpus(cpu);
465        }
466
467        // Auto-remove
468        if config.auto_remove {
469            cmd = cmd.remove();
470        }
471
472        // Handle Redis-specific command args. Password and TLS both require
473        // overriding the redis-server flags; compose them together so they can
474        // coexist.
475        let password = config.env.get("REDIS_PASSWORD");
476        if password.is_some() || self.tls_enabled() {
477            // Flags shared by both the Stack (REDIS_ARGS) and basic
478            // (entrypoint override) paths.
479            let mut server_flags: Vec<String> = Vec::new();
480            if let Some(password) = password {
481                server_flags.push("--requirepass".to_string());
482                server_flags.push(password.clone());
483                server_flags.push("--protected-mode".to_string());
484                server_flags.push("yes".to_string());
485            }
486            if self.tls_enabled() {
487                if self.tls_only {
488                    // Disable the plaintext listener.
489                    server_flags.push("--port".to_string());
490                    server_flags.push("0".to_string());
491                }
492                server_flags.extend(redis_tls_server_args(DEFAULT_REDIS_TLS_PORT));
493            }
494
495            if self.use_redis_stack {
496                // For Redis Stack, pass flags via the REDIS_ARGS environment
497                // variable instead of overriding the entrypoint.
498                cmd = cmd.env("REDIS_ARGS", server_flags.join(" "));
499            } else {
500                // For basic Redis, override the entrypoint to bypass
501                // docker-entrypoint.sh and run redis-server directly.
502                cmd = cmd.entrypoint("redis-server").cmd(server_flags);
503            }
504        }
505
506        // If a custom config file is mounted (and neither password nor TLS
507        // overrode the command), launch redis-server with that config file.
508        let has_config = config
509            .volumes
510            .iter()
511            .any(|v| v.target == "/usr/local/etc/redis/redis.conf");
512        if has_config && password.is_none() && !self.tls_enabled() {
513            cmd = cmd.cmd(vec![
514                "redis-server".to_string(),
515                "/usr/local/etc/redis/redis.conf".to_string(),
516            ]);
517        }
518
519        cmd
520    }
521}
522
523impl HasConnectionString for RedisTemplate {
524    /// Returns the Redis connection string in URL format.
525    ///
526    /// Format: `redis://[:password@]host:port`
527    ///
528    /// When the template is configured for TLS-only access (see
529    /// [`tls_only`](RedisTemplate::tls_only)) the plaintext port is disabled, so
530    /// this returns the `rediss://` TLS endpoint instead. When TLS is enabled
531    /// *alongside* plaintext, this still returns the plaintext URL; use
532    /// [`tls_connection_string`](RedisTemplate::tls_connection_string) for the
533    /// TLS endpoint.
534    ///
535    /// # Example
536    ///
537    /// ```rust
538    /// use docker_wrapper::template::{RedisTemplate, HasConnectionString};
539    ///
540    /// let template = RedisTemplate::new("my-redis").port(6380);
541    /// assert_eq!(template.connection_string(), "redis://localhost:6380");
542    ///
543    /// let template_with_pass = RedisTemplate::new("my-redis")
544    ///     .port(6380)
545    ///     .password("secret");
546    /// assert_eq!(template_with_pass.connection_string(), "redis://:secret@localhost:6380");
547    ///
548    /// // TLS-only falls back to the rediss:// endpoint.
549    /// let tls = RedisTemplate::new("my-redis").tls("/certs").tls_only();
550    /// assert_eq!(tls.connection_string(), "rediss://localhost:6380");
551    /// ```
552    fn connection_string(&self) -> String {
553        // In TLS-only mode the plaintext port is closed, so the only usable
554        // endpoint is the TLS one.
555        if self.tls_enabled() && self.tls_only {
556            if let Some(tls) = self.tls_connection_string() {
557                return tls;
558            }
559        }
560        let port = self.config.ports.first().map_or(6379, |(h, _)| *h);
561        let password = self.config.env.get("REDIS_PASSWORD").map(String::as_str);
562        redis_connection_string("localhost", port, password)
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use crate::DockerCommand;
570
571    #[test]
572    fn test_redis_template_basic() {
573        let template = RedisTemplate::new("test-redis");
574        assert_eq!(template.name(), "test-redis");
575        assert_eq!(template.config().image, "redis");
576        assert_eq!(template.config().tag, "7-alpine");
577        assert_eq!(template.config().ports, vec![(6379, 6379)]);
578    }
579
580    #[test]
581    fn test_redis_template_with_password() {
582        let template = RedisTemplate::new("test-redis").password("secret123");
583
584        assert_eq!(
585            template.config().env.get("REDIS_PASSWORD"),
586            Some(&"secret123".to_string())
587        );
588    }
589
590    #[test]
591    fn test_redis_template_with_persistence() {
592        let template = RedisTemplate::new("test-redis").with_persistence("redis-data");
593
594        assert_eq!(template.config().volumes.len(), 1);
595        assert_eq!(template.config().volumes[0].source, "redis-data");
596        assert_eq!(template.config().volumes[0].target, "/data");
597    }
598
599    #[test]
600    fn test_redis_template_custom_port() {
601        let template = RedisTemplate::new("test-redis").port(16379);
602
603        assert_eq!(template.config().ports, vec![(16379, 6379)]);
604    }
605
606    #[test]
607    fn test_redis_build_command() {
608        let template = RedisTemplate::new("test-redis")
609            .password("mypass")
610            .port(16379);
611
612        let cmd = template.build_command();
613        let args = cmd.build_command_args();
614
615        // Check that basic args are present
616        assert!(args.contains(&"run".to_string()));
617        assert!(args.contains(&"--name".to_string()));
618        assert!(args.contains(&"test-redis".to_string()));
619        assert!(args.contains(&"--publish".to_string()));
620        assert!(args.contains(&"16379:6379".to_string()));
621    }
622
623    #[test]
624    fn test_redis_host_network() {
625        let template = RedisTemplate::new("test-redis").host_network();
626        assert_eq!(template.config().network.as_deref(), Some("host"));
627
628        let cmd = template.build_command();
629        let args = cmd.build_command_args();
630
631        // --network host is wired and no ports are published.
632        let network_pos = args.iter().position(|a| a == "--network").unwrap();
633        assert_eq!(args[network_pos + 1], "host");
634        assert!(!args.contains(&"--publish".to_string()));
635    }
636
637    #[test]
638    fn test_redis_network_mode_host() {
639        let template = RedisTemplate::new("test-redis").network_mode("host");
640        assert_eq!(template.config().network.as_deref(), Some("host"));
641
642        let cmd = template.build_command();
643        let args = cmd.build_command_args();
644        assert!(!args.contains(&"--publish".to_string()));
645    }
646
647    #[test]
648    fn test_redis_network_mode_named_still_publishes() {
649        // A non-host network mode is just a named network and still publishes
650        // ports as usual.
651        let template = RedisTemplate::new("test-redis")
652            .port(16379)
653            .network_mode("my-net");
654
655        let cmd = template.build_command();
656        let args = cmd.build_command_args();
657        assert!(args.contains(&"--publish".to_string()));
658        assert!(args.contains(&"16379:6379".to_string()));
659    }
660
661    #[test]
662    fn test_redis_stack_default_pinned_tag() {
663        // Redis Stack defaults to a pinned, known-good tag (not latest) for
664        // reproducible runs.
665        let template = RedisTemplate::new("test-redis").with_redis_stack();
666
667        assert_eq!(
668            template.stack_image(),
669            format!("{REDIS_STACK_IMAGE}:7.4.0-v3")
670        );
671
672        let cmd = template.build_command();
673        let args = cmd.build_command_args();
674        assert!(args.contains(&"redis/redis-stack:7.4.0-v3".to_string()));
675        assert!(!args.iter().any(|a| a == "redis/redis-stack:latest"));
676    }
677
678    #[test]
679    fn test_redis_stack_version_override() {
680        let template = RedisTemplate::new("test-redis")
681            .with_redis_stack()
682            .stack_version("7.2.0-v9");
683
684        assert_eq!(template.stack_image(), "redis/redis-stack:7.2.0-v9");
685
686        let cmd = template.build_command();
687        let args = cmd.build_command_args();
688        assert!(args.contains(&"redis/redis-stack:7.2.0-v9".to_string()));
689    }
690
691    #[test]
692    fn test_redis_stack_version_ignored_without_stack() {
693        // stack_version only affects the image when Redis Stack is enabled.
694        let template = RedisTemplate::new("test-redis").stack_version("7.2.0-v9");
695
696        let cmd = template.build_command();
697        let args = cmd.build_command_args();
698        assert!(args.contains(&"redis:7-alpine".to_string()));
699        assert!(!args.iter().any(|a| a.starts_with("redis/redis-stack")));
700    }
701
702    #[test]
703    fn test_redis_connection_string() {
704        use crate::template::HasConnectionString;
705
706        let template = RedisTemplate::new("test-redis").port(6380);
707        assert_eq!(template.connection_string(), "redis://localhost:6380");
708    }
709
710    #[test]
711    fn test_redis_connection_string_with_password() {
712        use crate::template::HasConnectionString;
713
714        let template = RedisTemplate::new("test-redis")
715            .port(6380)
716            .password("secret");
717        assert_eq!(
718            template.connection_string(),
719            "redis://:secret@localhost:6380"
720        );
721    }
722
723    #[test]
724    fn test_redis_connection_string_default_port() {
725        use crate::template::HasConnectionString;
726
727        let template = RedisTemplate::new("test-redis");
728        assert_eq!(template.connection_string(), "redis://localhost:6379");
729    }
730
731    #[test]
732    fn test_redis_tls_args_and_volume() {
733        let template = RedisTemplate::new("test-redis").tls("/tmp/certs");
734
735        let cmd = template.build_command();
736        let args = cmd.build_command_args();
737
738        // TLS server flags are present.
739        assert!(args.contains(&"--tls-port".to_string()));
740        assert!(args.contains(&"6380".to_string()));
741        assert!(args.contains(&"--tls-cert-file".to_string()));
742        assert!(args.contains(&"/tls/redis.crt".to_string()));
743        assert!(args.contains(&"--tls-key-file".to_string()));
744        assert!(args.contains(&"/tls/redis.key".to_string()));
745        assert!(args.contains(&"--tls-ca-cert-file".to_string()));
746        assert!(args.contains(&"/tls/ca.crt".to_string()));
747
748        // Certs are mounted read-only at /tls.
749        assert!(args.contains(&"/tmp/certs:/tls:ro".to_string()));
750
751        // The TLS port is published, and plaintext stays open by default.
752        assert!(args.contains(&"6380:6380".to_string()));
753        assert!(args.contains(&"6379:6379".to_string()));
754
755        // Plaintext is not disabled by default.
756        assert!(!args.windows(2).any(|w| w == ["--port", "0"]));
757    }
758
759    #[test]
760    fn test_redis_tls_custom_port() {
761        let template = RedisTemplate::new("test-redis")
762            .tls("/tmp/certs")
763            .tls_port(7000);
764
765        let cmd = template.build_command();
766        let args = cmd.build_command_args();
767
768        // The host TLS port maps to the container TLS port.
769        assert!(args.contains(&"7000:6380".to_string()));
770    }
771
772    #[test]
773    fn test_redis_tls_only_disables_plaintext() {
774        let template = RedisTemplate::new("test-redis")
775            .tls("/tmp/certs")
776            .tls_only();
777
778        let cmd = template.build_command();
779        let args = cmd.build_command_args();
780
781        // Plaintext is disabled via --port 0 and not published.
782        assert!(args.windows(2).any(|w| w == ["--port", "0"]));
783        assert!(!args.contains(&"6379:6379".to_string()));
784
785        // The TLS port is still published.
786        assert!(args.contains(&"6380:6380".to_string()));
787    }
788
789    #[test]
790    fn test_redis_tls_with_password() {
791        let template = RedisTemplate::new("test-redis")
792            .tls("/tmp/certs")
793            .password("secret");
794
795        let cmd = template.build_command();
796        let args = cmd.build_command_args();
797
798        // Both password and TLS flags coexist.
799        assert!(args.windows(2).any(|w| w == ["--requirepass", "secret"]));
800        assert!(args.contains(&"--tls-port".to_string()));
801    }
802
803    #[test]
804    fn test_redis_tls_connection_string() {
805        let template = RedisTemplate::new("test-redis").tls("/tmp/certs");
806        assert_eq!(
807            template.tls_connection_string().as_deref(),
808            Some("rediss://localhost:6380")
809        );
810
811        let with_pass = RedisTemplate::new("test-redis")
812            .tls("/tmp/certs")
813            .tls_port(7000)
814            .password("secret");
815        assert_eq!(
816            with_pass.tls_connection_string().as_deref(),
817            Some("rediss://:secret@localhost:7000")
818        );
819    }
820
821    #[test]
822    fn test_redis_tls_connection_string_none_without_tls() {
823        let template = RedisTemplate::new("test-redis");
824        assert_eq!(template.tls_connection_string(), None);
825    }
826
827    #[test]
828    fn test_redis_tls_only_connection_string_falls_back_to_tls() {
829        use crate::template::HasConnectionString;
830
831        let template = RedisTemplate::new("test-redis")
832            .tls("/tmp/certs")
833            .tls_only();
834        // Plaintext is closed, so connection_string() returns the TLS endpoint.
835        assert_eq!(template.connection_string(), "rediss://localhost:6380");
836    }
837}