docker_wrapper/template/database/
mysql.rs1#![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
14pub struct MysqlTemplate {
16 config: TemplateConfig,
17}
18
19impl MysqlTemplate {
20 pub fn new(name: impl Into<String>) -> Self {
22 let name = name.into();
23 let mut env = HashMap::new();
24
25 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 pub fn port(mut self, port: u16) -> Self {
60 self.config.ports = vec![(port, 3306)];
61 self
62 }
63
64 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 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 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 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 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 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 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 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 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 pub fn memory_limit(mut self, limit: impl Into<String>) -> Self {
146 self.config.memory_limit = Some(limit.into());
147 self
148 }
149
150 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 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 pub fn version(mut self, version: impl Into<String>) -> Self {
188 self.config.tag = version.into();
189 self
190 }
191
192 pub fn network(mut self, network: impl Into<String>) -> Self {
194 self.config.network = Some(network.into());
195 self
196 }
197
198 pub fn auto_remove(mut self) -> Self {
200 self.config.auto_remove = true;
201 self
202 }
203
204 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 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 for (host, container) in &config.ports {
242 cmd = cmd.port(*host, *container);
243 }
244
245 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 if let Some(network) = &config.network {
256 cmd = cmd.network(network);
257 }
258
259 for (key, value) in &config.env {
261 if key != "MYSQL_COMMAND" {
263 cmd = cmd.env(key, value);
264 }
265 }
266
267 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 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 if config.auto_remove {
288 cmd = cmd.remove();
289 }
290
291 if let Some(platform) = &config.platform {
293 cmd = cmd.platform(platform);
294 }
295
296 if let Some(mysql_cmd) = config.env.get("MYSQL_COMMAND") {
298 let args: Vec<String> = mysql_cmd
300 .split_whitespace()
301 .map(|s| s.to_string())
302 .collect();
303 if !args.is_empty() {
304 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 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 if !self.is_running().await.unwrap_or(false) {
327 consecutive_successes = 0;
328 sleep(check_interval).await;
329 continue;
330 }
331
332 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 if let Ok(result) = self.exec(check_cmd).await {
357 if result.stdout.contains('1') {
359 consecutive_successes += 1;
360 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
383pub struct MysqlConnectionString {
385 host: String,
386 port: u16,
387 database: String,
388 user: String,
389 password: String,
390}
391
392impl MysqlConnectionString {
393 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 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 pub fn url(&self) -> String {
430 format!(
431 "mysql://{}:{}@{}:{}/{}",
432 self.user, self.password, self.host, self.port, self.database
433 )
434 }
435
436 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}