rightsize_modules/
mysql.rs1use std::time::Duration;
38
39use rightsize::wait::{WaitStrategy, WaitTarget};
40use rightsize::{Container, ContainerGuard, Result};
41
42pub struct MySqlContainer {
44 container: Container,
45 username: String,
46 password: String,
47 database: String,
48}
49
50impl MySqlContainer {
51 const PORT: u16 = 3306;
52
53 pub fn new() -> Self {
55 Self::with_image("mysql:8.4")
56 }
57
58 pub fn with_image(image: &str) -> Self {
60 let username = "test".to_string();
61 let password = "test".to_string();
62 let database = "test".to_string();
63 let container = Container::new(image)
64 .with_exposed_ports(&[Self::PORT])
65 .with_env("MYSQL_USER", &username)
66 .with_env("MYSQL_PASSWORD", &password)
67 .with_env("MYSQL_DATABASE", &database)
68 .with_env("MYSQL_ROOT_PASSWORD", "test")
69 .waiting_for(MySqlReadyForConnections::default());
73 Self {
77 container,
78 username,
79 password,
80 database,
81 }
82 }
83
84 pub fn with_username(mut self, username: &str) -> Self {
86 self.username = username.to_string();
87 self.container = self.container.with_env("MYSQL_USER", username);
88 self
89 }
90
91 pub fn with_password(mut self, password: &str) -> Self {
93 self.password = password.to_string();
94 self.container = self.container.with_env("MYSQL_PASSWORD", password);
95 self
96 }
97
98 pub fn with_database(mut self, database: &str) -> Self {
100 self.database = database.to_string();
101 self.container = self.container.with_env("MYSQL_DATABASE", database);
102 self
103 }
104
105 pub async fn start(self) -> Result<MySqlGuard> {
107 crate::register_default_backends();
108 let guard = self.container.start().await?;
109 Ok(MySqlGuard {
110 guard,
111 username: self.username,
112 password: self.password,
113 database: self.database,
114 })
115 }
116}
117
118impl Default for MySqlContainer {
119 fn default() -> Self {
120 Self::new()
121 }
122}
123
124const READY_PREFIX: &str = "mysqld: ready for connections";
127const PORT_MARKER: &str = "port: 3306";
130
131struct MySqlReadyForConnections {
135 timeout: Duration,
136}
137
138impl Default for MySqlReadyForConnections {
139 fn default() -> Self {
140 Self {
145 timeout: Duration::from_secs(120),
146 }
147 }
148}
149
150impl MySqlReadyForConnections {
151 fn line_signals_ready(line: &str) -> bool {
152 if !line.contains(READY_PREFIX) {
153 return false;
154 }
155 let Some(idx) = line.find(PORT_MARKER) else {
156 return false;
157 };
158 match line[idx + PORT_MARKER.len()..].chars().next() {
159 None => true, Some(c) => !c.is_ascii_digit(), }
162 }
163
164 async fn is_ready(target: &dyn WaitTarget) -> bool {
165 let logs = target.current_logs().await;
166 logs.lines().any(Self::line_signals_ready)
167 }
168}
169
170#[async_trait::async_trait]
171impl WaitStrategy for MySqlReadyForConnections {
172 async fn wait_until_ready(&self, target: &dyn WaitTarget) -> Result<()> {
173 rightsize::wait::poll_until_ready(
174 target,
175 self.timeout,
176 "mysqld ready for connections on port 3306",
177 || Self::is_ready(target),
178 )
179 .await
180 }
181
182 fn with_startup_timeout(mut self: Box<Self>, timeout: Duration) -> Box<dyn WaitStrategy> {
183 self.timeout = timeout;
184 self
185 }
186}
187
188pub struct MySqlGuard {
190 guard: ContainerGuard,
191 username: String,
192 password: String,
193 database: String,
194}
195
196impl MySqlGuard {
197 pub fn username(&self) -> &str {
199 &self.username
200 }
201
202 pub fn password(&self) -> &str {
204 &self.password
205 }
206
207 pub fn database_name(&self) -> &str {
209 &self.database
210 }
211
212 pub fn connection_string(&self) -> String {
215 format!(
216 "mysql://{}:{}@{}:{}/{}",
217 self.username,
218 self.password,
219 self.guard.host(),
220 self.guard.get_mapped_port(MySqlContainer::PORT).unwrap(),
221 self.database,
222 )
223 }
224
225 pub async fn stop(self) -> Result<()> {
227 self.guard.stop().await
228 }
229}
230
231impl std::ops::Deref for MySqlGuard {
232 type Target = ContainerGuard;
233 fn deref(&self) -> &ContainerGuard {
234 &self.guard
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn defaults_are_the_test_trio() {
244 let c = MySqlContainer::new();
245 assert_eq!(c.username, "test");
246 assert_eq!(c.password, "test");
247 assert_eq!(c.database, "test");
248 }
249
250 #[test]
251 fn builders_override_the_defaults() {
252 let c = MySqlContainer::new()
253 .with_username("alice")
254 .with_password("s3cret")
255 .with_database("app");
256 assert_eq!(c.username, "alice");
257 assert_eq!(c.password, "s3cret");
258 assert_eq!(c.database, "app");
259 }
260
261 const CAPTURED_LOG: &str = "\
264[System] [MY-011323] [Server] X Plugin ready for connections. Socket: /var/run/mysqld/mysqlx.sock
265[System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.4.10' socket: '/var/run/mysqld/mysqld.sock' port: 0 MySQL Community Server - GPL.
266[System] [MY-011323] [Server] X Plugin ready for connections. Bind-address: '::' port: 33060, socket: /var/run/mysqld/mysqlx.sock
267[System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.4.10' socket: '/var/run/mysqld/mysqld.sock' port: 3306 MySQL Community Server - GPL.";
268
269 #[test]
270 fn temp_server_and_x_plugin_lines_do_not_signal_ready() {
271 for line in CAPTURED_LOG.lines().take(3) {
272 assert!(
273 !MySqlReadyForConnections::line_signals_ready(line),
274 "must not misfire on: {line}"
275 );
276 }
277 }
278
279 #[test]
280 fn only_the_real_servers_port_3306_line_signals_ready() {
281 let real_line = CAPTURED_LOG.lines().nth(3).unwrap();
282 assert!(MySqlReadyForConnections::line_signals_ready(real_line));
283 }
284
285 #[test]
286 fn port_3306_at_end_of_line_with_no_trailing_text_also_matches() {
287 assert!(MySqlReadyForConnections::line_signals_ready(
288 "mysqld: ready for connections. port: 3306"
289 ));
290 }
291
292 #[tokio::test]
293 async fn is_ready_true_only_once_the_real_line_is_in_the_logs() {
294 struct FakeTarget(std::sync::Mutex<String>);
295 #[async_trait::async_trait]
296 impl WaitTarget for FakeTarget {
297 fn host(&self) -> &str {
298 "127.0.0.1"
299 }
300 fn mapped_port(&self, guest_port: u16) -> u16 {
301 guest_port
302 }
303 fn exposed_guest_ports(&self) -> Vec<u16> {
304 vec![3306]
305 }
306 async fn current_logs(&self) -> String {
307 self.0.lock().unwrap().clone()
308 }
309 fn describe(&self) -> String {
310 "fake-mysql".to_string()
311 }
312 }
313
314 let partial: String = CAPTURED_LOG.lines().take(3).collect::<Vec<_>>().join("\n");
315 let target = FakeTarget(std::sync::Mutex::new(partial));
316 assert!(!MySqlReadyForConnections::is_ready(&target).await);
317
318 *target.0.lock().unwrap() = CAPTURED_LOG.to_string();
319 assert!(MySqlReadyForConnections::is_ready(&target).await);
320 }
321}