Skip to main content

rightsize_modules/
mysql.rs

1//! A single-node MySQL container. Defaults to a `test`/`test`/`test`
2//! user/password/database trio (plus `MYSQL_ROOT_PASSWORD=test`) so
3//! [`MySqlGuard::connection_string`] is usable with zero configuration; call
4//! [`MySqlContainer::with_username`]/[`MySqlContainer::with_password`]/
5//! [`MySqlContainer::with_database`] before `start()` to override any of them.
6//!
7//! ### Readiness — empirically pinned, not guessed
8//!
9//! The official entrypoint boots `mysqld` **twice**: once as a throwaway "temp
10//! server" to run init scripts, then for real. Both prints, plus the X Plugin's own
11//! "ready for connections" line, contain the substring `ready for connections`, and
12//! naively counting occurrences is a trap: the temp server's X Plugin binds `port:
13//! 33060` — whose digits *start with* `3306`, so an unanchored `port: 3306` search
14//! false-matches it too. Captured verbatim from a real `docker run mysql:8.4` boot
15//! with this module's env (`MYSQL_USER=test`, `MYSQL_DATABASE=test`,
16//! `MYSQL_ROOT_PASSWORD=test`):
17//!
18//! ```text
19//! [System] [MY-011323] [Server] X Plugin ready for connections. Socket: /var/run/mysqld/mysqlx.sock
20//! [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.
21//! ...(init scripts run, temp server shuts down)...
22//! [System] [MY-011323] [Server] X Plugin ready for connections. Bind-address: '::' port: 33060, socket: /var/run/mysqld/mysqlx.sock
23//! [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.
24//! ```
25//!
26//! Four lines contain `ready for connections`; only the last is the real server bound
27//! to 3306. The temp server prints `port: 0` (no port yet) and the X Plugin lines
28//! print `33060`, whose `3306` prefix would satisfy an unanchored match — so simple
29//! occurrence-counting is fragile here. The fix is a regex anchored on the real
30//! server's `port: 3306` with a trailing non-digit-or-end boundary
31//! (`.*mysqld: ready for connections.*port: 3306($|[^0-9]).*`). This module implements
32//! that same anchoring *logic* as a small custom [`WaitStrategy`] with hand-written
33//! character-boundary checks rather than a regex — predates `Wait::for_log_message`'s
34//! move to a real regex engine (`regex-lite`), and the hand-written version is
35//! unaffected by that swap since it was never routed through the shared matcher.
36
37use std::time::Duration;
38
39use rightsize::wait::{WaitStrategy, WaitTarget};
40use rightsize::{Container, ContainerGuard, Result};
41
42/// A single-node MySQL container.
43pub 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    /// Builds a container from the pinned default image (`mysql:8.4`).
54    pub fn new() -> Self {
55        Self::with_image("mysql:8.4")
56    }
57
58    /// Builds a container from a caller-chosen image.
59    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            // Anchored on the real server's line (see the module doc for the
70            // captured log excerpt and why an unanchored "port: 3306" search
71            // misfires on the temp-server boot).
72            .waiting_for(MySqlReadyForConnections::default());
73        // No with_memory_limit override: unlike SpringCloudConfig's Paketo JVM
74        // image, MySQL 8.4's InnoDB default footprint fits msb's default ~450M
75        // microVM RAM, so no module-level memory floor is warranted here.
76        Self {
77            container,
78            username,
79            password,
80            database,
81        }
82    }
83
84    /// Overrides `MYSQL_USER` (default `test`).
85    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    /// Overrides `MYSQL_PASSWORD` (default `test`).
92    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    /// Overrides `MYSQL_DATABASE` (default `test`).
99    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    /// Boots the container.
106    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
124/// The exact substring the real (non-temp, non-X-Plugin) server line contains, up to
125/// but not including the port number.
126const READY_PREFIX: &str = "mysqld: ready for connections";
127/// The port marker immediately preceded by `READY_PREFIX` somewhere earlier on the
128/// same line, per the captured log shape in the module doc.
129const PORT_MARKER: &str = "port: 3306";
130
131/// Ready when a log line contains `mysqld: ready for connections` and, later on that
132/// same line, `port: 3306` immediately followed by end-of-line or a non-digit — never
133/// matching the X Plugin's `port: 33060` (whose digits merely start with `3306`).
134struct MySqlReadyForConnections {
135    timeout: Duration,
136}
137
138impl Default for MySqlReadyForConnections {
139    fn default() -> Self {
140        // First boot initializes the datafiles and boots mysqld twice (a throwaway
141        // temp server for init scripts, then the real one). On a fast host that
142        // finishes well under 60s, but a loaded Windows CI runner's first boot
143        // overran even the previous 120s budget (123s observed) — 180s, matching
144        // ClickHouseContainer's own budget for the same "loaded Windows runner"
145        // reason, absorbs the slow case without masking a real hang.
146        Self {
147            timeout: Duration::from_secs(180),
148        }
149    }
150}
151
152impl MySqlReadyForConnections {
153    fn line_signals_ready(line: &str) -> bool {
154        if !line.contains(READY_PREFIX) {
155            return false;
156        }
157        let Some(idx) = line.find(PORT_MARKER) else {
158            return false;
159        };
160        match line[idx + PORT_MARKER.len()..].chars().next() {
161            None => true,                   // end of line right after "port: 3306"
162            Some(c) => !c.is_ascii_digit(), // anything but another digit (rules out 33060)
163        }
164    }
165
166    async fn is_ready(target: &dyn WaitTarget) -> bool {
167        let logs = target.current_logs().await;
168        logs.lines().any(Self::line_signals_ready)
169    }
170}
171
172#[async_trait::async_trait]
173impl WaitStrategy for MySqlReadyForConnections {
174    async fn wait_until_ready(&self, target: &dyn WaitTarget) -> Result<()> {
175        rightsize::wait::poll_until_ready(
176            target,
177            self.timeout,
178            "mysqld ready for connections on port 3306",
179            || Self::is_ready(target),
180        )
181        .await
182    }
183
184    fn with_startup_timeout(mut self: Box<Self>, timeout: Duration) -> Box<dyn WaitStrategy> {
185        self.timeout = timeout;
186        self
187    }
188}
189
190/// The running guard for a [`MySqlContainer`].
191pub struct MySqlGuard {
192    guard: ContainerGuard,
193    username: String,
194    password: String,
195    database: String,
196}
197
198impl MySqlGuard {
199    /// The configured database user (default `test`).
200    pub fn username(&self) -> &str {
201        &self.username
202    }
203
204    /// The configured database password (default `test`).
205    pub fn password(&self) -> &str {
206        &self.password
207    }
208
209    /// The configured database name (default `test`).
210    pub fn database_name(&self) -> &str {
211        &self.database
212    }
213
214    /// A `mysql://` connection string for the running container's
215    /// [`Self::database_name`].
216    pub fn connection_string(&self) -> String {
217        format!(
218            "mysql://{}:{}@{}:{}/{}",
219            self.username,
220            self.password,
221            self.guard.host(),
222            self.guard.get_mapped_port(MySqlContainer::PORT).unwrap(),
223            self.database,
224        )
225    }
226
227    /// Stops and removes the container, releasing its host port.
228    pub async fn stop(self) -> Result<()> {
229        self.guard.stop().await
230    }
231}
232
233impl std::ops::Deref for MySqlGuard {
234    type Target = ContainerGuard;
235    fn deref(&self) -> &ContainerGuard {
236        &self.guard
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn defaults_are_the_test_trio() {
246        let c = MySqlContainer::new();
247        assert_eq!(c.username, "test");
248        assert_eq!(c.password, "test");
249        assert_eq!(c.database, "test");
250    }
251
252    #[test]
253    fn builders_override_the_defaults() {
254        let c = MySqlContainer::new()
255            .with_username("alice")
256            .with_password("s3cret")
257            .with_database("app");
258        assert_eq!(c.username, "alice");
259        assert_eq!(c.password, "s3cret");
260        assert_eq!(c.database, "app");
261    }
262
263    // The exact captured log excerpt from the module doc: four "ready for
264    // connections" lines, only the last of which must be recognized as ready.
265    const CAPTURED_LOG: &str = "\
266[System] [MY-011323] [Server] X Plugin ready for connections. 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: 0  MySQL Community Server - GPL.
268[System] [MY-011323] [Server] X Plugin ready for connections. Bind-address: '::' port: 33060, socket: /var/run/mysqld/mysqlx.sock
269[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.";
270
271    #[test]
272    fn temp_server_and_x_plugin_lines_do_not_signal_ready() {
273        for line in CAPTURED_LOG.lines().take(3) {
274            assert!(
275                !MySqlReadyForConnections::line_signals_ready(line),
276                "must not misfire on: {line}"
277            );
278        }
279    }
280
281    #[test]
282    fn only_the_real_servers_port_3306_line_signals_ready() {
283        let real_line = CAPTURED_LOG.lines().nth(3).unwrap();
284        assert!(MySqlReadyForConnections::line_signals_ready(real_line));
285    }
286
287    #[test]
288    fn port_3306_at_end_of_line_with_no_trailing_text_also_matches() {
289        assert!(MySqlReadyForConnections::line_signals_ready(
290            "mysqld: ready for connections. port: 3306"
291        ));
292    }
293
294    #[tokio::test]
295    async fn is_ready_true_only_once_the_real_line_is_in_the_logs() {
296        struct FakeTarget(std::sync::Mutex<String>);
297        #[async_trait::async_trait]
298        impl WaitTarget for FakeTarget {
299            fn host(&self) -> &str {
300                "127.0.0.1"
301            }
302            fn mapped_port(&self, guest_port: u16) -> u16 {
303                guest_port
304            }
305            fn exposed_guest_ports(&self) -> Vec<u16> {
306                vec![3306]
307            }
308            async fn current_logs(&self) -> String {
309                self.0.lock().unwrap().clone()
310            }
311            fn describe(&self) -> String {
312                "fake-mysql".to_string()
313            }
314        }
315
316        let partial: String = CAPTURED_LOG.lines().take(3).collect::<Vec<_>>().join("\n");
317        let target = FakeTarget(std::sync::Mutex::new(partial));
318        assert!(!MySqlReadyForConnections::is_ready(&target).await);
319
320        *target.0.lock().unwrap() = CAPTURED_LOG.to_string();
321        assert!(MySqlReadyForConnections::is_ready(&target).await);
322    }
323}