docker_wrapper/template/database/
mysql.rs

1//! MySQL template for quick MySQL 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/// MySQL container template with sensible defaults
15pub struct MysqlTemplate {
16    config: TemplateConfig,
17}
18
19impl MysqlTemplate {
20    /// Create a new MySQL template with default settings
21    pub fn new(name: impl Into<String>) -> Self {
22        let name = name.into();
23        let mut env = HashMap::new();
24
25        // Default MySQL configuration
26        env.insert("MYSQL_ROOT_PASSWORD".to_string(), "mysql".to_string());
27        env.insert("MYSQL_DATABASE".to_string(), "mysql".to_string());
28
29        let config = TemplateConfig {
30            name: name.clone(),
31            image: "mysql".to_string(),
32            tag: "8.0".to_string(),
33            ports: vec![(3306, 3306)],
34            env,
35            volumes: Vec::new(),
36            network: None,
37            health_check: Some(HealthCheck {
38                test: vec![
39                    "mysqladmin".to_string(),
40                    "ping".to_string(),
41                    "-h".to_string(),
42                    "localhost".to_string(),
43                ],
44                interval: "10s".to_string(),
45                timeout: "5s".to_string(),
46                retries: 5,
47                start_period: "30s".to_string(),
48            }),
49            auto_remove: false,
50            memory_limit: None,
51            cpu_limit: None,
52            platform: None,
53        };
54
55        Self { config }
56    }
57
58    /// Set a custom MySQL port
59    pub fn port(mut self, port: u16) -> Self {
60        self.config.ports = vec![(port, 3306)];
61        self
62    }
63
64    /// Set root password
65    pub fn root_password(mut self, password: impl Into<String>) -> Self {
66        self.config
67            .env
68            .insert("MYSQL_ROOT_PASSWORD".to_string(), password.into());
69        self
70    }
71
72    /// Set database name
73    pub fn database(mut self, db: impl Into<String>) -> Self {
74        self.config
75            .env
76            .insert("MYSQL_DATABASE".to_string(), db.into());
77        self
78    }
79
80    /// Set database user (non-root)
81    pub fn user(mut self, user: impl Into<String>) -> Self {
82        self.config
83            .env
84            .insert("MYSQL_USER".to_string(), user.into());
85        self
86    }
87
88    /// Set database user password
89    pub fn password(mut self, password: impl Into<String>) -> Self {
90        self.config
91            .env
92            .insert("MYSQL_PASSWORD".to_string(), password.into());
93        self
94    }
95
96    /// Allow empty password for root (development only!)
97    pub fn allow_empty_password(mut self) -> Self {
98        self.config.env.remove("MYSQL_ROOT_PASSWORD");
99        self.config
100            .env
101            .insert("MYSQL_ALLOW_EMPTY_PASSWORD".to_string(), "yes".to_string());
102        self
103    }
104
105    /// Set random root password
106    pub fn random_root_password(mut self) -> Self {
107        self.config.env.remove("MYSQL_ROOT_PASSWORD");
108        self.config
109            .env
110            .insert("MYSQL_RANDOM_ROOT_PASSWORD".to_string(), "yes".to_string());
111        self
112    }
113
114    /// Enable persistence with a volume
115    pub fn with_persistence(mut self, volume_name: impl Into<String>) -> Self {
116        self.config.volumes.push(VolumeMount {
117            source: volume_name.into(),
118            target: "/var/lib/mysql".to_string(),
119            read_only: false,
120        });
121        self
122    }
123
124    /// Mount initialization scripts directory
125    pub fn init_scripts(mut self, scripts_path: impl Into<String>) -> Self {
126        self.config.volumes.push(VolumeMount {
127            source: scripts_path.into(),
128            target: "/docker-entrypoint-initdb.d".to_string(),
129            read_only: true,
130        });
131        self
132    }
133
134    /// Mount custom MySQL configuration
135    pub fn config_file(mut self, config_path: impl Into<String>) -> Self {
136        self.config.volumes.push(VolumeMount {
137            source: config_path.into(),
138            target: "/etc/mysql/conf.d/custom.cnf".to_string(),
139            read_only: true,
140        });
141        self
142    }
143
144    /// Set memory limit for MySQL
145    pub fn memory_limit(mut self, limit: impl Into<String>) -> Self {
146        self.config.memory_limit = Some(limit.into());
147        self
148    }
149
150    /// Set character set
151    pub fn character_set(mut self, charset: impl Into<String>) -> Self {
152        let charset = charset.into();
153        self.config
154            .env
155            .insert("MYSQL_CHARSET".to_string(), charset.clone());
156        let current_cmd = self
157            .config
158            .env
159            .get("MYSQL_COMMAND")
160            .map(|s| format!("{} --character-set-server={}", s, charset))
161            .unwrap_or_else(|| format!("--character-set-server={}", charset));
162        self.config
163            .env
164            .insert("MYSQL_COMMAND".to_string(), current_cmd);
165        self
166    }
167
168    /// Set collation
169    pub fn collation(mut self, collation: impl Into<String>) -> Self {
170        let collation = collation.into();
171        self.config
172            .env
173            .insert("MYSQL_COLLATION".to_string(), collation.clone());
174        let current_cmd = self
175            .config
176            .env
177            .get("MYSQL_COMMAND")
178            .map(|s| format!("{} --collation-server={}", s, collation))
179            .unwrap_or_else(|| format!("--collation-server={}", collation));
180        self.config
181            .env
182            .insert("MYSQL_COMMAND".to_string(), current_cmd);
183        self
184    }
185
186    /// Use a specific MySQL version
187    pub fn version(mut self, version: impl Into<String>) -> Self {
188        self.config.tag = version.into();
189        self
190    }
191
192    /// Connect to a specific network
193    pub fn network(mut self, network: impl Into<String>) -> Self {
194        self.config.network = Some(network.into());
195        self
196    }
197
198    /// Enable auto-remove when stopped
199    pub fn auto_remove(mut self) -> Self {
200        self.config.auto_remove = true;
201        self
202    }
203
204    /// Use a custom image and tag
205    pub fn custom_image(mut self, image: impl Into<String>, tag: impl Into<String>) -> Self {
206        self.config.image = image.into();
207        self.config.tag = tag.into();
208        self
209    }
210
211    /// Set the platform for the container (e.g., "linux/arm64", "linux/amd64")
212    pub fn platform(mut self, platform: impl Into<String>) -> Self {
213        self.config.platform = Some(platform.into());
214        self
215    }
216}
217
218#[async_trait]
219impl Template for MysqlTemplate {
220    fn name(&self) -> &str {
221        &self.config.name
222    }
223
224    fn config(&self) -> &TemplateConfig {
225        &self.config
226    }
227
228    fn config_mut(&mut self) -> &mut TemplateConfig {
229        &mut self.config
230    }
231
232    fn build_command(&self) -> crate::RunCommand {
233        let config = self.config();
234        let image_tag = format!("{}:{}", config.image, config.tag);
235
236        let mut cmd = crate::RunCommand::new(image_tag)
237            .name(&config.name)
238            .detach();
239
240        // Add port mappings
241        for (host, container) in &config.ports {
242            cmd = cmd.port(*host, *container);
243        }
244
245        // Add volume mounts
246        for mount in &config.volumes {
247            if mount.read_only {
248                cmd = cmd.volume_ro(&mount.source, &mount.target);
249            } else {
250                cmd = cmd.volume(&mount.source, &mount.target);
251            }
252        }
253
254        // Add network
255        if let Some(network) = &config.network {
256            cmd = cmd.network(network);
257        }
258
259        // Add environment variables
260        for (key, value) in &config.env {
261            // Skip MYSQL_COMMAND as it's not a real env var
262            if key != "MYSQL_COMMAND" {
263                cmd = cmd.env(key, value);
264            }
265        }
266
267        // Add health check
268        if let Some(health) = &config.health_check {
269            cmd = cmd
270                .health_cmd(&health.test.join(" "))
271                .health_interval(&health.interval)
272                .health_timeout(&health.timeout)
273                .health_retries(health.retries)
274                .health_start_period(&health.start_period);
275        }
276
277        // Add resource limits
278        if let Some(memory) = &config.memory_limit {
279            cmd = cmd.memory(memory);
280        }
281
282        if let Some(cpu) = &config.cpu_limit {
283            cmd = cmd.cpus(cpu);
284        }
285
286        // Auto-remove
287        if config.auto_remove {
288            cmd = cmd.remove();
289        }
290
291        // Add platform if specified
292        if let Some(platform) = &config.platform {
293            cmd = cmd.platform(platform);
294        }
295
296        // Add MySQL-specific command args for charset and collation
297        if let Some(mysql_cmd) = config.env.get("MYSQL_COMMAND") {
298            // Parse the command string to get individual arguments
299            let args: Vec<String> = mysql_cmd
300                .split_whitespace()
301                .map(|s| s.to_string())
302                .collect();
303            if !args.is_empty() {
304                // Override the default command with mysqld and our custom args
305                cmd = cmd.cmd(std::iter::once("mysqld".to_string()).chain(args).collect());
306            }
307        }
308
309        cmd
310    }
311
312    async fn wait_for_ready(&self) -> crate::template::Result<()> {
313        use std::time::Duration;
314        use tokio::time::{sleep, timeout};
315
316        // Custom MySQL readiness check - increased timeout for charset/collation configs
317        // MySQL 8.0 can take 90+ seconds to initialize on slower CI systems
318        let wait_timeout = Duration::from_secs(120);
319        let check_interval = Duration::from_millis(1000);
320
321        timeout(wait_timeout, async {
322            let mut consecutive_successes = 0;
323            loop {
324                // Check if container is running - keep retrying if not yet started
325                // Don't fail immediately as the container may still be starting up
326                if !self.is_running().await.unwrap_or(false) {
327                    consecutive_successes = 0;
328                    sleep(check_interval).await;
329                    continue;
330                }
331
332                // Try to connect to MySQL using the mysql client with an actual query
333                // Use 127.0.0.1 instead of localhost to force TCP connection
334                // (localhost uses Unix socket which may not be ready even when TCP is)
335                let password = self
336                    .config
337                    .env
338                    .get("MYSQL_ROOT_PASSWORD")
339                    .or_else(|| self.config.env.get("MYSQL_PASSWORD"))
340                    .map(|s| s.as_str())
341                    .unwrap_or("mysql");
342
343                let password_arg = format!("-p{}", password);
344                let check_cmd = vec![
345                    "mysql",
346                    "-h",
347                    "127.0.0.1",
348                    "-u",
349                    "root",
350                    &password_arg,
351                    "-e",
352                    "SELECT 1",
353                ];
354
355                // Execute readiness check
356                if let Ok(result) = self.exec(check_cmd).await {
357                    // If we got output containing '1', MySQL responded successfully
358                    if result.stdout.contains('1') {
359                        consecutive_successes += 1;
360                        // Require 2 consecutive successes to ensure stability
361                        if consecutive_successes >= 2 {
362                            return Ok(());
363                        }
364                        sleep(Duration::from_millis(500)).await;
365                        continue;
366                    }
367                }
368
369                consecutive_successes = 0;
370                sleep(check_interval).await;
371            }
372        })
373        .await
374        .map_err(|_| {
375            crate::template::TemplateError::InvalidConfig(format!(
376                "MySQL container {} failed to become ready within timeout",
377                self.config().name
378            ))
379        })?
380    }
381}
382
383/// Builder for MySQL connection strings
384pub struct MysqlConnectionString {
385    host: String,
386    port: u16,
387    database: String,
388    user: String,
389    password: String,
390}
391
392impl MysqlConnectionString {
393    /// Create from a MysqlTemplate
394    pub fn from_template(template: &MysqlTemplate) -> Self {
395        let config = template.config();
396        let port = config.ports.first().map(|(h, _)| *h).unwrap_or(3306);
397
398        // Determine user and password
399        let (user, password) = if let Some(user) = config.env.get("MYSQL_USER") {
400            let password = config
401                .env
402                .get("MYSQL_PASSWORD")
403                .cloned()
404                .unwrap_or_default();
405            (user.clone(), password)
406        } else {
407            let password = config
408                .env
409                .get("MYSQL_ROOT_PASSWORD")
410                .cloned()
411                .unwrap_or_else(|| "mysql".to_string());
412            ("root".to_string(), password)
413        };
414
415        Self {
416            host: "localhost".to_string(),
417            port,
418            database: config
419                .env
420                .get("MYSQL_DATABASE")
421                .cloned()
422                .unwrap_or_else(|| "mysql".to_string()),
423            user,
424            password,
425        }
426    }
427
428    /// Get the connection string in MySQL URL format
429    pub fn url(&self) -> String {
430        format!(
431            "mysql://{}:{}@{}:{}/{}",
432            self.user, self.password, self.host, self.port, self.database
433        )
434    }
435
436    /// Get the connection string for JDBC
437    pub fn jdbc(&self) -> String {
438        format!(
439            "jdbc:mysql://{}:{}/{}?user={}&password={}",
440            self.host, self.port, self.database, self.user, self.password
441        )
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn test_mysql_template_basic() {
451        let template = MysqlTemplate::new("test-mysql");
452        assert_eq!(template.name(), "test-mysql");
453        assert_eq!(template.config().image, "mysql");
454        assert_eq!(template.config().tag, "8.0");
455        assert_eq!(template.config().ports, vec![(3306, 3306)]);
456    }
457
458    #[test]
459    fn test_mysql_template_custom_config() {
460        let template = MysqlTemplate::new("test-mysql")
461            .database("mydb")
462            .user("myuser")
463            .password("secret123")
464            .port(13306);
465
466        assert_eq!(
467            template.config().env.get("MYSQL_DATABASE"),
468            Some(&"mydb".to_string())
469        );
470        assert_eq!(
471            template.config().env.get("MYSQL_USER"),
472            Some(&"myuser".to_string())
473        );
474        assert_eq!(
475            template.config().env.get("MYSQL_PASSWORD"),
476            Some(&"secret123".to_string())
477        );
478        assert_eq!(template.config().ports, vec![(13306, 3306)]);
479    }
480
481    #[test]
482    fn test_mysql_template_with_persistence() {
483        let template = MysqlTemplate::new("test-mysql").with_persistence("mysql-data");
484
485        assert_eq!(template.config().volumes.len(), 1);
486        assert_eq!(template.config().volumes[0].source, "mysql-data");
487        assert_eq!(template.config().volumes[0].target, "/var/lib/mysql");
488    }
489
490    #[test]
491    fn test_mysql_connection_string() {
492        let template = MysqlTemplate::new("test-mysql")
493            .database("testdb")
494            .user("testuser")
495            .password("testpass")
496            .port(13306);
497
498        let conn = MysqlConnectionString::from_template(&template);
499
500        assert_eq!(
501            conn.url(),
502            "mysql://testuser:testpass@localhost:13306/testdb"
503        );
504
505        assert_eq!(
506            conn.jdbc(),
507            "jdbc:mysql://localhost:13306/testdb?user=testuser&password=testpass"
508        );
509    }
510}