docker_wrapper/template/redis/
enterprise.rs

1//! Redis Enterprise template for production-grade Redis deployments
2//!
3//! This template provides a complete Redis Enterprise setup with cluster
4//! initialization, making it easy to spin up a fully configured Redis
5//! Enterprise cluster for development and testing.
6
7#![allow(clippy::doc_markdown)]
8#![allow(clippy::must_use_candidate)]
9#![allow(clippy::return_self_not_must_use)]
10#![allow(clippy::needless_borrows_for_generic_args)]
11
12use crate::{DockerCommand, RmCommand, RunCommand, StopCommand};
13use std::time::Duration;
14
15#[cfg(feature = "template-redis-enterprise")]
16use reqwest::Client;
17#[cfg(feature = "template-redis-enterprise")]
18use serde_json::Value;
19
20/// Redis Enterprise template for production-grade deployments
21pub struct RedisEnterpriseTemplate {
22    name: String,
23    cluster_name: String,
24    admin_username: String,
25    admin_password: String,
26    accept_eula: bool,
27    license_file: Option<String>,
28    ui_port: u16,
29    api_port: u16,
30    database_port_start: u16,
31    persistent_path: Option<String>,
32    ephemeral_path: Option<String>,
33    memory_limit: Option<String>,
34    initial_database: Option<String>,
35    image: String,
36    tag: String,
37    platform: Option<String>,
38    bootstrap_timeout: Duration,
39    bootstrap_retries: u32,
40    api_ready_timeout: Duration,
41}
42
43impl RedisEnterpriseTemplate {
44    /// Create a new Redis Enterprise template
45    pub fn new(name: impl Into<String>) -> Self {
46        Self {
47            name: name.into(),
48            cluster_name: "Development Cluster".to_string(),
49            admin_username: "admin@redis.local".to_string(),
50            admin_password: "Redis123!".to_string(),
51            accept_eula: false,
52            license_file: None,
53            ui_port: 8443,
54            api_port: 9443,
55            database_port_start: 12000,
56            persistent_path: None,
57            ephemeral_path: None,
58            memory_limit: None,
59            initial_database: None,
60            image: "redislabs/redis".to_string(),
61            tag: "latest".to_string(),
62            platform: None,
63            bootstrap_timeout: Duration::from_secs(60),
64            bootstrap_retries: 3,
65            api_ready_timeout: Duration::from_secs(30),
66        }
67    }
68
69    /// Set the cluster name
70    pub fn cluster_name(mut self, name: impl Into<String>) -> Self {
71        self.cluster_name = name.into();
72        self
73    }
74
75    /// Set the admin username (email format required)
76    pub fn admin_username(mut self, username: impl Into<String>) -> Self {
77        self.admin_username = username.into();
78        self
79    }
80
81    /// Set the admin password (must be strong)
82    pub fn admin_password(mut self, password: impl Into<String>) -> Self {
83        self.admin_password = password.into();
84        self
85    }
86
87    /// Accept the End User License Agreement
88    pub fn accept_eula(mut self) -> Self {
89        self.accept_eula = true;
90        self
91    }
92
93    /// Set a license file path
94    pub fn license_file(mut self, path: impl Into<String>) -> Self {
95        self.license_file = Some(path.into());
96        self
97    }
98
99    /// Set the UI port (default: 8443)
100    pub fn ui_port(mut self, port: u16) -> Self {
101        self.ui_port = port;
102        self
103    }
104
105    /// Set the API port (default: 9443)
106    pub fn api_port(mut self, port: u16) -> Self {
107        self.api_port = port;
108        self
109    }
110
111    /// Set the starting port for database endpoints (default: 12000)
112    pub fn database_port_start(mut self, port: u16) -> Self {
113        self.database_port_start = port;
114        self
115    }
116
117    /// Set custom persistent storage path
118    pub fn persistent_path(mut self, path: impl Into<String>) -> Self {
119        self.persistent_path = Some(path.into());
120        self
121    }
122
123    /// Set custom ephemeral storage path
124    pub fn ephemeral_path(mut self, path: impl Into<String>) -> Self {
125        self.ephemeral_path = Some(path.into());
126        self
127    }
128
129    /// Set memory limit for the container
130    pub fn memory_limit(mut self, limit: impl Into<String>) -> Self {
131        self.memory_limit = Some(limit.into());
132        self
133    }
134
135    /// Create an initial database after cluster setup
136    pub fn with_database(mut self, name: impl Into<String>) -> Self {
137        self.initial_database = Some(name.into());
138        self
139    }
140
141    /// Use a custom Redis Enterprise image and tag
142    ///
143    /// # Example
144    /// ```
145    /// # use docker_wrapper::RedisEnterpriseTemplate;
146    /// let template = RedisEnterpriseTemplate::new("my-redis")
147    ///     .custom_image("my-registry/redis-enterprise", "latest")
148    ///     .platform("linux/arm64")
149    ///     .accept_eula();
150    /// ```
151    pub fn custom_image(mut self, image: impl Into<String>, tag: impl Into<String>) -> Self {
152        self.image = image.into();
153        self.tag = tag.into();
154        self
155    }
156
157    /// Set the platform for the container (e.g., "linux/arm64", "linux/amd64")
158    ///
159    /// This is especially useful for ARM-based Redis Enterprise images
160    /// on Apple Silicon Macs or ARM servers.
161    pub fn platform(mut self, platform: impl Into<String>) -> Self {
162        self.platform = Some(platform.into());
163        self
164    }
165
166    /// Set the bootstrap timeout (default: 60 seconds)
167    pub fn bootstrap_timeout(mut self, timeout: Duration) -> Self {
168        self.bootstrap_timeout = timeout;
169        self
170    }
171
172    /// Set the number of bootstrap retries (default: 3)
173    pub fn bootstrap_retries(mut self, retries: u32) -> Self {
174        self.bootstrap_retries = retries;
175        self
176    }
177
178    /// Set the API ready timeout (default: 30 seconds)
179    pub fn api_ready_timeout(mut self, timeout: Duration) -> Self {
180        self.api_ready_timeout = timeout;
181        self
182    }
183
184    /// Start the Redis Enterprise container and initialize the cluster
185    ///
186    /// # Errors
187    ///
188    /// Returns an error if:
189    /// - EULA is not accepted
190    /// - Container fails to start
191    /// - Cluster initialization fails
192    /// - API is not accessible
193    pub async fn start(self) -> Result<RedisEnterpriseConnectionInfo, crate::Error> {
194        // Validate EULA acceptance
195        if !self.accept_eula {
196            return Err(crate::Error::Custom {
197                message: "EULA must be accepted to start Redis Enterprise. Call .accept_eula() on the template".to_string(),
198            });
199        }
200
201        // Validate password strength (basic check)
202        if self.admin_password.len() < 8 {
203            return Err(crate::Error::Custom {
204                message: "Admin password must be at least 8 characters".to_string(),
205            });
206        }
207
208        // Start the Redis Enterprise container
209        let container_name = format!("{}-enterprise", self.name);
210        let mut cmd = RunCommand::new(format!("{}:{}", self.image, self.tag))
211            .name(&container_name)
212            .port(self.ui_port, 8443)
213            .port(self.api_port, 9443)
214            .detach();
215
216        // Add database ports range
217        for i in 0..10 {
218            let port = self.database_port_start + i;
219            cmd = cmd.port(port, port);
220        }
221
222        // Add volumes for persistence
223        let persistent = self
224            .persistent_path
225            .clone()
226            .unwrap_or_else(|| format!("{}-persistent", self.name));
227        let ephemeral = self
228            .ephemeral_path
229            .clone()
230            .unwrap_or_else(|| format!("{}-ephemeral", self.name));
231
232        cmd = cmd
233            .volume(&persistent, "/var/opt/redislabs/persist")
234            .volume(&ephemeral, "/var/opt/redislabs/tmp");
235
236        // Add memory limit if specified
237        if let Some(ref limit) = self.memory_limit {
238            cmd = cmd.memory(limit);
239        }
240
241        // Set capabilities for Redis Enterprise
242        cmd = cmd.cap_add("SYS_RESOURCE");
243
244        // Add platform if specified
245        if let Some(ref platform) = self.platform {
246            cmd = cmd.platform(platform);
247        }
248
249        // Execute container start
250        cmd.execute().await.map_err(|e| crate::Error::Custom {
251            message: format!("Failed to start Redis Enterprise container: {e}"),
252        })?;
253
254        // Wait for API to be ready with health checks
255        self.wait_for_api_ready(&container_name).await?;
256
257        // Bootstrap the cluster with retries
258        self.bootstrap_cluster(&container_name).await?;
259
260        // Verify cluster is ready
261        self.verify_cluster_ready(&container_name).await?;
262
263        // Create initial database if requested
264        if let Some(ref db_name) = self.initial_database {
265            self.create_database(&container_name, db_name).await?;
266        }
267
268        Ok(RedisEnterpriseConnectionInfo {
269            name: self.name.clone(),
270            container_name,
271            cluster_name: self.cluster_name.clone(),
272            ui_url: format!("https://localhost:{}", self.ui_port),
273            api_url: format!("https://localhost:{}", self.api_port),
274            username: self.admin_username.clone(),
275            password: self.admin_password.clone(),
276            database_port: if self.initial_database.is_some() {
277                Some(self.database_port_start)
278            } else {
279                None
280            },
281        })
282    }
283
284    /// Wait for the API to be ready using reqwest
285    async fn wait_for_api_ready(&self, _container_name: &str) -> Result<(), crate::Error> {
286        let client = Client::builder()
287            .danger_accept_invalid_certs(true)
288            .timeout(Duration::from_secs(10))
289            .build()
290            .map_err(|e| crate::Error::Custom {
291                message: format!("Failed to build HTTP client: {e}"),
292            })?;
293
294        let start = std::time::Instant::now();
295        let url = format!("https://localhost:{}/", self.api_port);
296
297        while start.elapsed() < self.api_ready_timeout {
298            if let Ok(response) = client.get(&url).send().await {
299                let status = response.status();
300                // API is ready when it returns any response
301                if let Ok(text) = response.text().await {
302                    if text.contains("no_cluster")
303                        || text.contains("error_code")
304                        || status.is_success()
305                    {
306                        // Give it a bit more time to fully initialize
307                        tokio::time::sleep(Duration::from_secs(2)).await;
308                        return Ok(());
309                    }
310                }
311            } else {
312                // API might not be fully ready yet, continue waiting
313            }
314
315            tokio::time::sleep(Duration::from_secs(2)).await;
316        }
317
318        Err(crate::Error::Custom {
319            message: format!(
320                "API did not become ready within {} seconds",
321                self.api_ready_timeout.as_secs()
322            ),
323        })
324    }
325
326    /// Bootstrap the cluster with retries using reqwest
327    async fn bootstrap_cluster(&self, _container_name: &str) -> Result<(), crate::Error> {
328        // Build HTTP client that accepts self-signed certificates
329        let client = Client::builder()
330            .danger_accept_invalid_certs(true) // Redis Enterprise uses self-signed certs
331            .timeout(Duration::from_secs(30))
332            .build()
333            .map_err(|e| crate::Error::Custom {
334                message: format!("Failed to build HTTP client: {e}"),
335            })?;
336
337        // Parse the bootstrap JSON into a Value for proper serialization
338        let bootstrap_json_str = self.build_bootstrap_json();
339        let bootstrap_json: Value =
340            serde_json::from_str(&bootstrap_json_str).map_err(|e| crate::Error::Custom {
341                message: format!("Invalid bootstrap JSON: {e}"),
342            })?;
343
344        let url = format!(
345            "https://localhost:{}/v1/bootstrap/create_cluster",
346            self.api_port
347        );
348
349        for attempt in 1..=self.bootstrap_retries {
350            let response = client
351                .post(&url)
352                .header("Content-Type", "application/json")
353                .json(&bootstrap_json)
354                .send()
355                .await;
356
357            match response {
358                Ok(res) => {
359                    let status = res.status();
360
361                    // Check for success
362                    if status.is_success() || status.as_u16() == 409 {
363                        // 200-299 = success, 409 = already bootstrapped
364                        return Ok(());
365                    }
366
367                    // Check for validation errors (don't retry these)
368                    if status.as_u16() == 400 {
369                        let error_body = res.text().await.unwrap_or_default();
370
371                        if error_body.contains("invalid_schema") {
372                            return Err(crate::Error::Custom {
373                                message: format!("Bootstrap validation failed: {error_body}"),
374                            });
375                        }
376
377                        return Err(crate::Error::Custom {
378                            message: format!("Bootstrap failed with bad request: {error_body}"),
379                        });
380                    }
381
382                    // Other error status codes - may retry
383                    if attempt == self.bootstrap_retries {
384                        let error_body = res.text().await.unwrap_or_default();
385                        return Err(crate::Error::Custom {
386                            message: format!("Bootstrap failed with status {status}: {error_body}"),
387                        });
388                    }
389
390                    // Log non-fatal errors for debugging
391                    if let Ok(error_text) = res.text().await {
392                        eprintln!(
393                            "Bootstrap attempt {attempt} failed with status {status}: {error_text}"
394                        );
395                    }
396                }
397                Err(e) => {
398                    eprintln!("Bootstrap attempt {attempt} failed with network error: {e}");
399
400                    if attempt == self.bootstrap_retries {
401                        return Err(crate::Error::Custom {
402                            message: format!(
403                                "Failed to connect to cluster after {} attempts: {}",
404                                self.bootstrap_retries, e
405                            ),
406                        });
407                    }
408                }
409            }
410
411            // Exponential backoff between retries
412            if attempt < self.bootstrap_retries {
413                let wait_time = Duration::from_secs(5 * u64::from(attempt));
414                tokio::time::sleep(wait_time).await;
415            }
416        }
417
418        Err(crate::Error::Custom {
419            message: format!(
420                "Failed to bootstrap cluster after {} attempts",
421                self.bootstrap_retries
422            ),
423        })
424    }
425
426    /// Verify the cluster is ready using reqwest
427    async fn verify_cluster_ready(&self, _container_name: &str) -> Result<(), crate::Error> {
428        let client = Client::builder()
429            .danger_accept_invalid_certs(true)
430            .timeout(Duration::from_secs(10))
431            .build()
432            .map_err(|e| crate::Error::Custom {
433                message: format!("Failed to build HTTP client: {e}"),
434            })?;
435
436        let url = format!("https://localhost:{}/v1/cluster", self.api_port);
437        let start = std::time::Instant::now();
438
439        while start.elapsed() < Duration::from_secs(10) {
440            if let Ok(response) = client
441                .get(&url)
442                .basic_auth(&self.admin_username, Some(&self.admin_password))
443                .send()
444                .await
445            {
446                if response.status().is_success() {
447                    if let Ok(text) = response.text().await {
448                        if text.contains(&format!(r#""name":"{}""#, self.cluster_name)) {
449                            return Ok(());
450                        }
451                    }
452                }
453            } else {
454                // API might not be ready yet, continue waiting
455            }
456
457            tokio::time::sleep(Duration::from_secs(1)).await;
458        }
459
460        Err(crate::Error::Custom {
461            message: "Cluster verification failed - cluster may not be fully initialized"
462                .to_string(),
463        })
464    }
465
466    /// Build the bootstrap JSON payload
467    fn build_bootstrap_json(&self) -> String {
468        let mut json = format!(
469            r#"{{
470                "action": "create_cluster",
471                "cluster": {{
472                    "name": "{}"
473                }},
474                "node": {{
475                    "paths": {{
476                        "persistent_path": "/var/opt/redislabs/persist",
477                        "ephemeral_path": "/var/opt/redislabs/tmp"
478                    }}
479                }},
480                "credentials": {{
481                    "username": "{}",
482                    "password": "{}"
483                }}"#,
484            self.cluster_name, self.admin_username, self.admin_password
485        );
486
487        // Add license if provided
488        if let Some(ref _license) = self.license_file {
489            // In a real implementation, we would read the license file
490            // For now, we'll skip this
491            json.push_str("");
492        }
493
494        json.push('}');
495        json
496    }
497
498    /// Create a database in the cluster using reqwest
499    async fn create_database(
500        &self,
501        _container_name: &str,
502        db_name: &str,
503    ) -> Result<(), crate::Error> {
504        let client = Client::builder()
505            .danger_accept_invalid_certs(true)
506            .timeout(Duration::from_secs(30))
507            .build()
508            .map_err(|e| crate::Error::Custom {
509                message: format!("Failed to build HTTP client: {e}"),
510            })?;
511
512        let create_db_json = serde_json::json!({
513            "name": db_name,
514            "port": self.database_port_start,
515            "memory_size": 104_857_600
516        });
517
518        let url = format!("https://localhost:{}/v1/bdbs", self.api_port);
519        let response = client
520            .post(&url)
521            .basic_auth(&self.admin_username, Some(&self.admin_password))
522            .header("Content-Type", "application/json")
523            .json(&create_db_json)
524            .send()
525            .await
526            .map_err(|e| crate::Error::Custom {
527                message: format!("Failed to send database creation request: {e}"),
528            })?;
529
530        if !response.status().is_success() {
531            let status = response.status();
532            let error_body = response.text().await.unwrap_or_default();
533            return Err(crate::Error::Custom {
534                message: format!("Failed to create database with status {status}: {error_body}"),
535            });
536        }
537
538        Ok(())
539    }
540}
541
542/// Connection information for Redis Enterprise
543pub struct RedisEnterpriseConnectionInfo {
544    /// Name of the deployment
545    pub name: String,
546    /// Container name
547    pub container_name: String,
548    /// Cluster name
549    pub cluster_name: String,
550    /// UI URL for web interface
551    pub ui_url: String,
552    /// API URL for REST API
553    pub api_url: String,
554    /// Admin username
555    pub username: String,
556    /// Admin password
557    pub password: String,
558    /// Database port if initial database was created
559    pub database_port: Option<u16>,
560}
561
562impl RedisEnterpriseConnectionInfo {
563    /// Get the management UI URL
564    pub fn ui_url(&self) -> &str {
565        &self.ui_url
566    }
567
568    /// Get the REST API URL
569    pub fn api_url(&self) -> &str {
570        &self.api_url
571    }
572
573    /// Get Redis connection URL if a database was created
574    pub fn redis_url(&self) -> Option<String> {
575        self.database_port
576            .map(|port| format!("redis://localhost:{port}"))
577    }
578
579    /// Stop and clean up the Redis Enterprise cluster
580    ///
581    /// # Errors
582    ///
583    /// Returns an error if container cleanup fails
584    pub async fn stop(self) -> Result<(), crate::Error> {
585        // Stop the container
586        StopCommand::new(&self.container_name)
587            .execute()
588            .await
589            .map_err(|e| crate::Error::Custom {
590                message: format!("Failed to stop container: {e}"),
591            })?;
592
593        // Remove the container
594        RmCommand::new(&self.container_name)
595            .force()
596            .volumes()
597            .execute()
598            .await
599            .map_err(|e| crate::Error::Custom {
600                message: format!("Failed to remove container: {e}"),
601            })?;
602
603        Ok(())
604    }
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    #[test]
612    fn test_redis_enterprise_template_defaults() {
613        let template = RedisEnterpriseTemplate::new("test-enterprise");
614        assert_eq!(template.name, "test-enterprise");
615        assert_eq!(template.cluster_name, "Development Cluster");
616        assert_eq!(template.ui_port, 8443);
617        assert_eq!(template.api_port, 9443);
618        assert!(!template.accept_eula);
619    }
620
621    #[test]
622    fn test_redis_enterprise_template_builder() {
623        let template = RedisEnterpriseTemplate::new("test-enterprise")
624            .cluster_name("Production Cluster")
625            .admin_username("admin@company.com")
626            .admin_password("SuperSecure123!")
627            .accept_eula()
628            .ui_port(18443)
629            .api_port(19443)
630            .with_database("cache-db");
631
632        assert_eq!(template.cluster_name, "Production Cluster");
633        assert_eq!(template.admin_username, "admin@company.com");
634        assert_eq!(template.admin_password, "SuperSecure123!");
635        assert!(template.accept_eula);
636        assert_eq!(template.ui_port, 18443);
637        assert_eq!(template.api_port, 19443);
638        assert_eq!(template.initial_database, Some("cache-db".to_string()));
639    }
640
641    #[test]
642    fn test_bootstrap_json_generation() {
643        let template = RedisEnterpriseTemplate::new("test")
644            .cluster_name("Test Cluster")
645            .admin_username("test@redis.local")
646            .admin_password("TestPass123!");
647
648        let json = template.build_bootstrap_json();
649
650        assert!(json.contains(r#""name": "Test Cluster""#));
651        assert!(json.contains(r#""username": "test@redis.local""#));
652        assert!(json.contains(r#""password": "TestPass123!""#));
653        assert!(json.contains(r#""action": "create_cluster""#));
654    }
655}