docker_wrapper/template/database/
mongodb.rs

1//! MongoDB template for quick MongoDB 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::map_unwrap_or)]
7#![allow(clippy::format_push_string)]
8#![allow(clippy::uninlined_format_args)]
9
10use crate::template::{HealthCheck, Template, TemplateConfig, VolumeMount};
11use async_trait::async_trait;
12use std::collections::HashMap;
13
14/// MongoDB container template with sensible defaults
15pub struct MongodbTemplate {
16    config: TemplateConfig,
17}
18
19impl MongodbTemplate {
20    /// Create a new MongoDB template with default settings
21    pub fn new(name: impl Into<String>) -> Self {
22        let name = name.into();
23        let env = HashMap::new();
24
25        let config = TemplateConfig {
26            name: name.clone(),
27            image: "mongo".to_string(),
28            tag: "7.0".to_string(),
29            ports: vec![(27017, 27017)],
30            env,
31            volumes: Vec::new(),
32            network: None,
33            health_check: Some(HealthCheck {
34                test: vec![
35                    "mongosh".to_string(),
36                    "--eval".to_string(),
37                    "db.adminCommand('ping')".to_string(),
38                ],
39                interval: "10s".to_string(),
40                timeout: "5s".to_string(),
41                retries: 5,
42                start_period: "20s".to_string(),
43            }),
44            auto_remove: false,
45            memory_limit: None,
46            cpu_limit: None,
47            platform: None,
48        };
49
50        Self { config }
51    }
52
53    /// Set a custom MongoDB port
54    pub fn port(mut self, port: u16) -> Self {
55        self.config.ports = vec![(port, 27017)];
56        self
57    }
58
59    /// Set root username
60    pub fn root_username(mut self, username: impl Into<String>) -> Self {
61        self.config
62            .env
63            .insert("MONGO_INITDB_ROOT_USERNAME".to_string(), username.into());
64        self
65    }
66
67    /// Set root password
68    pub fn root_password(mut self, password: impl Into<String>) -> Self {
69        self.config
70            .env
71            .insert("MONGO_INITDB_ROOT_PASSWORD".to_string(), password.into());
72        self
73    }
74
75    /// Set initial database name
76    pub fn database(mut self, db: impl Into<String>) -> Self {
77        self.config
78            .env
79            .insert("MONGO_INITDB_DATABASE".to_string(), db.into());
80        self
81    }
82
83    /// Enable persistence with a volume
84    pub fn with_persistence(mut self, volume_name: impl Into<String>) -> Self {
85        self.config.volumes.push(VolumeMount {
86            source: volume_name.into(),
87            target: "/data/db".to_string(),
88            read_only: false,
89        });
90        self
91    }
92
93    /// Mount initialization scripts directory
94    pub fn init_scripts(mut self, scripts_path: impl Into<String>) -> Self {
95        self.config.volumes.push(VolumeMount {
96            source: scripts_path.into(),
97            target: "/docker-entrypoint-initdb.d".to_string(),
98            read_only: true,
99        });
100        self
101    }
102
103    /// Mount custom MongoDB configuration
104    pub fn config_file(mut self, config_path: impl Into<String>) -> Self {
105        self.config.volumes.push(VolumeMount {
106            source: config_path.into(),
107            target: "/etc/mongo/mongod.conf".to_string(),
108            read_only: true,
109        });
110        self
111    }
112
113    /// Set memory limit for MongoDB
114    pub fn memory_limit(mut self, limit: impl Into<String>) -> Self {
115        self.config.memory_limit = Some(limit.into());
116        self
117    }
118
119    /// Set WiredTiger cache size
120    pub fn cache_size(mut self, size: impl Into<String>) -> Self {
121        self.config
122            .env
123            .insert("MONGO_WIREDTIGER_CACHE_SIZE_GB".to_string(), size.into());
124        self
125    }
126
127    /// Enable replica set mode
128    pub fn replica_set(mut self, name: impl Into<String>) -> Self {
129        self.config
130            .env
131            .insert("MONGO_REPLICA_SET".to_string(), name.into());
132        self
133    }
134
135    /// Enable authentication
136    pub fn with_auth(mut self) -> Self {
137        self.config
138            .env
139            .insert("MONGO_AUTH".to_string(), "yes".to_string());
140        self
141    }
142
143    /// Use a specific MongoDB version
144    pub fn version(mut self, version: impl Into<String>) -> Self {
145        self.config.tag = version.into();
146        self
147    }
148
149    /// Connect to a specific network
150    pub fn network(mut self, network: impl Into<String>) -> Self {
151        self.config.network = Some(network.into());
152        self
153    }
154
155    /// Enable auto-remove when stopped
156    pub fn auto_remove(mut self) -> Self {
157        self.config.auto_remove = true;
158        self
159    }
160
161    /// Set journal commit interval
162    pub fn journal_commit_interval(mut self, ms: u32) -> Self {
163        self.config
164            .env
165            .insert("MONGO_JOURNAL_COMMIT_INTERVAL".to_string(), ms.to_string());
166        self
167    }
168
169    /// Enable quiet logging
170    pub fn quiet(mut self) -> Self {
171        self.config
172            .env
173            .insert("MONGO_QUIET".to_string(), "yes".to_string());
174        self
175    }
176
177    /// Use a custom image and tag
178    pub fn custom_image(mut self, image: impl Into<String>, tag: impl Into<String>) -> Self {
179        self.config.image = image.into();
180        self.config.tag = tag.into();
181        self
182    }
183
184    /// Set the platform for the container (e.g., "linux/arm64", "linux/amd64")
185    pub fn platform(mut self, platform: impl Into<String>) -> Self {
186        self.config.platform = Some(platform.into());
187        self
188    }
189}
190
191#[async_trait]
192impl Template for MongodbTemplate {
193    fn name(&self) -> &str {
194        &self.config.name
195    }
196
197    fn config(&self) -> &TemplateConfig {
198        &self.config
199    }
200
201    fn config_mut(&mut self) -> &mut TemplateConfig {
202        &mut self.config
203    }
204
205    fn build_command(&self) -> crate::RunCommand {
206        let config = self.config();
207        let image_tag = format!("{}:{}", config.image, config.tag);
208
209        let mut cmd = crate::RunCommand::new(image_tag)
210            .name(&config.name)
211            .detach();
212
213        // Add port mappings
214        for (host, container) in &config.ports {
215            cmd = cmd.port(*host, *container);
216        }
217
218        // Add volume mounts
219        for mount in &config.volumes {
220            if mount.read_only {
221                cmd = cmd.volume_ro(&mount.source, &mount.target);
222            } else {
223                cmd = cmd.volume(&mount.source, &mount.target);
224            }
225        }
226
227        // Add network
228        if let Some(network) = &config.network {
229            cmd = cmd.network(network);
230        }
231
232        // Add environment variables (except MONGO_REPLICA_SET which is handled as command arg)
233        for (key, value) in &config.env {
234            if key != "MONGO_REPLICA_SET" {
235                cmd = cmd.env(key, value);
236            }
237        }
238
239        // Add health check
240        if let Some(health) = &config.health_check {
241            cmd = cmd
242                .health_cmd(&health.test.join(" "))
243                .health_interval(&health.interval)
244                .health_timeout(&health.timeout)
245                .health_retries(health.retries)
246                .health_start_period(&health.start_period);
247        }
248
249        // Add resource limits
250        if let Some(memory) = &config.memory_limit {
251            cmd = cmd.memory(memory);
252        }
253
254        if let Some(cpu) = &config.cpu_limit {
255            cmd = cmd.cpus(cpu);
256        }
257
258        // Auto-remove
259        if config.auto_remove {
260            cmd = cmd.remove();
261        }
262
263        // Add platform if specified
264        if let Some(platform) = &config.platform {
265            cmd = cmd.platform(platform);
266        }
267
268        // Add MongoDB-specific command for replica set
269        if let Some(replica_set) = config.env.get("MONGO_REPLICA_SET") {
270            // Start mongod with --replSet parameter
271            cmd = cmd.cmd(vec![
272                "mongod".to_string(),
273                "--replSet".to_string(),
274                replica_set.clone(),
275                "--bind_ip_all".to_string(),
276            ]);
277        }
278
279        cmd
280    }
281
282    async fn wait_for_ready(&self) -> crate::template::Result<()> {
283        use std::time::Duration;
284        use tokio::time::{sleep, timeout};
285
286        // Custom MongoDB readiness check
287        let wait_timeout = Duration::from_secs(30);
288        let check_interval = Duration::from_millis(500);
289
290        timeout(wait_timeout, async {
291            loop {
292                // First check if container is running
293                if !self.is_running().await? {
294                    return Err(crate::template::TemplateError::NotRunning(
295                        self.config().name.clone(),
296                    ));
297                }
298
299                // Try to connect to MongoDB using mongosh (or mongo for older versions)
300                let check_cmd = if self.config.tag.starts_with("4.") {
301                    // Use mongo for version 4.x
302                    vec![
303                        "mongo",
304                        "--host",
305                        "localhost",
306                        "--eval",
307                        "db.runCommand({ ping: 1 })",
308                        "--quiet",
309                    ]
310                } else {
311                    // Use mongosh for version 5.0+
312                    vec![
313                        "mongosh",
314                        "--host",
315                        "localhost",
316                        "--eval",
317                        "db.runCommand({ ping: 1 })",
318                        "--quiet",
319                    ]
320                };
321
322                // Execute readiness check
323                if let Ok(result) = self.exec(check_cmd).await {
324                    // MongoDB ping returns { ok: 1 } on success
325                    if result.stdout.contains("ok") && result.stdout.contains('1') {
326                        return Ok(());
327                    }
328                }
329
330                sleep(check_interval).await;
331            }
332        })
333        .await
334        .map_err(|_| {
335            crate::template::TemplateError::InvalidConfig(format!(
336                "MongoDB container {} failed to become ready within timeout",
337                self.config().name
338            ))
339        })?
340    }
341}
342
343/// Builder for MongoDB connection strings
344pub struct MongodbConnectionString {
345    host: String,
346    port: u16,
347    database: Option<String>,
348    username: Option<String>,
349    password: Option<String>,
350    replica_set: Option<String>,
351}
352
353impl MongodbConnectionString {
354    /// Create from a MongodbTemplate
355    pub fn from_template(template: &MongodbTemplate) -> Self {
356        let config = template.config();
357        let port = config.ports.first().map(|(h, _)| *h).unwrap_or(27017);
358
359        Self {
360            host: "localhost".to_string(),
361            port,
362            database: config.env.get("MONGO_INITDB_DATABASE").cloned(),
363            username: config.env.get("MONGO_INITDB_ROOT_USERNAME").cloned(),
364            password: config.env.get("MONGO_INITDB_ROOT_PASSWORD").cloned(),
365            replica_set: config.env.get("MONGO_REPLICA_SET").cloned(),
366        }
367    }
368
369    /// Get the connection string in MongoDB URL format
370    pub fn url(&self) -> String {
371        let mut url = String::from("mongodb://");
372
373        // Add credentials if present
374        if let (Some(user), Some(pass)) = (&self.username, &self.password) {
375            url.push_str(&format!("{}:{}@", user, pass));
376        }
377
378        // Add host and port
379        url.push_str(&format!("{}:{}", self.host, self.port));
380
381        // Add database if present
382        if let Some(db) = &self.database {
383            url.push_str(&format!("/{}", db));
384        }
385
386        // Add replica set if present
387        if let Some(rs) = &self.replica_set {
388            if self.database.is_none() {
389                url.push('/');
390            }
391            url.push_str(&format!("?replicaSet={}", rs));
392        }
393
394        url
395    }
396
397    /// Get the connection string for MongoDB SRV (Atlas-style)
398    pub fn srv_url(&self) -> String {
399        let mut url = String::from("mongodb+srv://");
400
401        // Add credentials if present
402        if let (Some(user), Some(pass)) = (&self.username, &self.password) {
403            url.push_str(&format!("{}:{}@", user, pass));
404        }
405
406        // For SRV, we only use the host
407        url.push_str(&self.host);
408
409        // Add database if present
410        if let Some(db) = &self.database {
411            url.push_str(&format!("/{}", db));
412        }
413
414        url
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn test_mongodb_template_basic() {
424        let template = MongodbTemplate::new("test-mongo");
425        assert_eq!(template.name(), "test-mongo");
426        assert_eq!(template.config().image, "mongo");
427        assert_eq!(template.config().tag, "7.0");
428        assert_eq!(template.config().ports, vec![(27017, 27017)]);
429    }
430
431    #[test]
432    fn test_mongodb_template_with_auth() {
433        let template = MongodbTemplate::new("test-mongo")
434            .root_username("admin")
435            .root_password("secret123")
436            .database("mydb")
437            .with_auth();
438
439        assert_eq!(
440            template.config().env.get("MONGO_INITDB_ROOT_USERNAME"),
441            Some(&"admin".to_string())
442        );
443        assert_eq!(
444            template.config().env.get("MONGO_INITDB_ROOT_PASSWORD"),
445            Some(&"secret123".to_string())
446        );
447        assert_eq!(
448            template.config().env.get("MONGO_INITDB_DATABASE"),
449            Some(&"mydb".to_string())
450        );
451        assert_eq!(
452            template.config().env.get("MONGO_AUTH"),
453            Some(&"yes".to_string())
454        );
455    }
456
457    #[test]
458    fn test_mongodb_template_with_persistence() {
459        let template = MongodbTemplate::new("test-mongo").with_persistence("mongo-data");
460
461        assert_eq!(template.config().volumes.len(), 1);
462        assert_eq!(template.config().volumes[0].source, "mongo-data");
463        assert_eq!(template.config().volumes[0].target, "/data/db");
464    }
465
466    #[test]
467    fn test_mongodb_connection_string() {
468        let template = MongodbTemplate::new("test-mongo")
469            .root_username("admin")
470            .root_password("pass")
471            .database("testdb")
472            .port(27018);
473
474        let conn = MongodbConnectionString::from_template(&template);
475
476        assert_eq!(conn.url(), "mongodb://admin:pass@localhost:27018/testdb");
477    }
478
479    #[test]
480    fn test_mongodb_connection_string_no_auth() {
481        let template = MongodbTemplate::new("test-mongo");
482        let conn = MongodbConnectionString::from_template(&template);
483
484        assert_eq!(conn.url(), "mongodb://localhost:27017");
485    }
486
487    #[test]
488    fn test_mongodb_connection_string_replica_set() {
489        let template = MongodbTemplate::new("test-mongo").replica_set("rs0");
490
491        let conn = MongodbConnectionString::from_template(&template);
492
493        assert_eq!(conn.url(), "mongodb://localhost:27017/?replicaSet=rs0");
494    }
495}