docker_wrapper/template/redis/
enterprise.rs1#![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
20pub 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 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 pub fn cluster_name(mut self, name: impl Into<String>) -> Self {
71 self.cluster_name = name.into();
72 self
73 }
74
75 pub fn admin_username(mut self, username: impl Into<String>) -> Self {
77 self.admin_username = username.into();
78 self
79 }
80
81 pub fn admin_password(mut self, password: impl Into<String>) -> Self {
83 self.admin_password = password.into();
84 self
85 }
86
87 pub fn accept_eula(mut self) -> Self {
89 self.accept_eula = true;
90 self
91 }
92
93 pub fn license_file(mut self, path: impl Into<String>) -> Self {
95 self.license_file = Some(path.into());
96 self
97 }
98
99 pub fn ui_port(mut self, port: u16) -> Self {
101 self.ui_port = port;
102 self
103 }
104
105 pub fn api_port(mut self, port: u16) -> Self {
107 self.api_port = port;
108 self
109 }
110
111 pub fn database_port_start(mut self, port: u16) -> Self {
113 self.database_port_start = port;
114 self
115 }
116
117 pub fn persistent_path(mut self, path: impl Into<String>) -> Self {
119 self.persistent_path = Some(path.into());
120 self
121 }
122
123 pub fn ephemeral_path(mut self, path: impl Into<String>) -> Self {
125 self.ephemeral_path = Some(path.into());
126 self
127 }
128
129 pub fn memory_limit(mut self, limit: impl Into<String>) -> Self {
131 self.memory_limit = Some(limit.into());
132 self
133 }
134
135 pub fn with_database(mut self, name: impl Into<String>) -> Self {
137 self.initial_database = Some(name.into());
138 self
139 }
140
141 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 pub fn platform(mut self, platform: impl Into<String>) -> Self {
162 self.platform = Some(platform.into());
163 self
164 }
165
166 pub fn bootstrap_timeout(mut self, timeout: Duration) -> Self {
168 self.bootstrap_timeout = timeout;
169 self
170 }
171
172 pub fn bootstrap_retries(mut self, retries: u32) -> Self {
174 self.bootstrap_retries = retries;
175 self
176 }
177
178 pub fn api_ready_timeout(mut self, timeout: Duration) -> Self {
180 self.api_ready_timeout = timeout;
181 self
182 }
183
184 pub async fn start(self) -> Result<RedisEnterpriseConnectionInfo, crate::Error> {
194 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 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 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 for i in 0..10 {
218 let port = self.database_port_start + i;
219 cmd = cmd.port(port, port);
220 }
221
222 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 if let Some(ref limit) = self.memory_limit {
238 cmd = cmd.memory(limit);
239 }
240
241 cmd = cmd.cap_add("SYS_RESOURCE");
243
244 if let Some(ref platform) = self.platform {
246 cmd = cmd.platform(platform);
247 }
248
249 cmd.execute().await.map_err(|e| crate::Error::Custom {
251 message: format!("Failed to start Redis Enterprise container: {e}"),
252 })?;
253
254 self.wait_for_api_ready(&container_name).await?;
256
257 self.bootstrap_cluster(&container_name).await?;
259
260 self.verify_cluster_ready(&container_name).await?;
262
263 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 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 if let Ok(text) = response.text().await {
302 if text.contains("no_cluster")
303 || text.contains("error_code")
304 || status.is_success()
305 {
306 tokio::time::sleep(Duration::from_secs(2)).await;
308 return Ok(());
309 }
310 }
311 } else {
312 }
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 async fn bootstrap_cluster(&self, _container_name: &str) -> Result<(), crate::Error> {
328 let client = Client::builder()
330 .danger_accept_invalid_certs(true) .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 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 if status.is_success() || status.as_u16() == 409 {
363 return Ok(());
365 }
366
367 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 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 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 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 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 }
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 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 if let Some(ref _license) = self.license_file {
489 json.push_str("");
492 }
493
494 json.push('}');
495 json
496 }
497
498 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
542pub struct RedisEnterpriseConnectionInfo {
544 pub name: String,
546 pub container_name: String,
548 pub cluster_name: String,
550 pub ui_url: String,
552 pub api_url: String,
554 pub username: String,
556 pub password: String,
558 pub database_port: Option<u16>,
560}
561
562impl RedisEnterpriseConnectionInfo {
563 pub fn ui_url(&self) -> &str {
565 &self.ui_url
566 }
567
568 pub fn api_url(&self) -> &str {
570 &self.api_url
571 }
572
573 pub fn redis_url(&self) -> Option<String> {
575 self.database_port
576 .map(|port| format!("redis://localhost:{port}"))
577 }
578
579 pub async fn stop(self) -> Result<(), crate::Error> {
585 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 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}