use std::time::Duration;
use rightsize::wait::{WaitStrategy, WaitTarget};
use rightsize::{Container, ContainerGuard, Result};
pub struct MySqlContainer {
container: Container,
username: String,
password: String,
database: String,
}
impl MySqlContainer {
const PORT: u16 = 3306;
pub fn new() -> Self {
Self::with_image("mysql:8.4")
}
pub fn with_image(image: &str) -> Self {
let username = "test".to_string();
let password = "test".to_string();
let database = "test".to_string();
let container = Container::new(image)
.with_exposed_ports(&[Self::PORT])
.with_env("MYSQL_USER", &username)
.with_env("MYSQL_PASSWORD", &password)
.with_env("MYSQL_DATABASE", &database)
.with_env("MYSQL_ROOT_PASSWORD", "test")
.waiting_for(MySqlReadyForConnections::default());
Self {
container,
username,
password,
database,
}
}
pub fn with_username(mut self, username: &str) -> Self {
self.username = username.to_string();
self.container = self.container.with_env("MYSQL_USER", username);
self
}
pub fn with_password(mut self, password: &str) -> Self {
self.password = password.to_string();
self.container = self.container.with_env("MYSQL_PASSWORD", password);
self
}
pub fn with_database(mut self, database: &str) -> Self {
self.database = database.to_string();
self.container = self.container.with_env("MYSQL_DATABASE", database);
self
}
pub async fn start(self) -> Result<MySqlGuard> {
let guard = self.container.start().await?;
Ok(MySqlGuard {
guard,
username: self.username,
password: self.password,
database: self.database,
})
}
}
impl Default for MySqlContainer {
fn default() -> Self {
Self::new()
}
}
const READY_PREFIX: &str = "mysqld: ready for connections";
const PORT_MARKER: &str = "port: 3306";
struct MySqlReadyForConnections {
timeout: Duration,
}
impl Default for MySqlReadyForConnections {
fn default() -> Self {
Self {
timeout: Duration::from_secs(120),
}
}
}
impl MySqlReadyForConnections {
fn line_signals_ready(line: &str) -> bool {
if !line.contains(READY_PREFIX) {
return false;
}
let Some(idx) = line.find(PORT_MARKER) else {
return false;
};
match line[idx + PORT_MARKER.len()..].chars().next() {
None => true, Some(c) => !c.is_ascii_digit(), }
}
async fn is_ready(target: &dyn WaitTarget) -> bool {
let logs = target.current_logs().await;
logs.lines().any(Self::line_signals_ready)
}
}
#[async_trait::async_trait]
impl WaitStrategy for MySqlReadyForConnections {
async fn wait_until_ready(&self, target: &dyn WaitTarget) -> Result<()> {
rightsize::wait::poll_until_ready(
target,
self.timeout,
"mysqld ready for connections on port 3306",
|| Self::is_ready(target),
)
.await
}
fn with_startup_timeout(mut self: Box<Self>, timeout: Duration) -> Box<dyn WaitStrategy> {
self.timeout = timeout;
self
}
}
pub struct MySqlGuard {
guard: ContainerGuard,
username: String,
password: String,
database: String,
}
impl MySqlGuard {
pub fn username(&self) -> &str {
&self.username
}
pub fn password(&self) -> &str {
&self.password
}
pub fn database_name(&self) -> &str {
&self.database
}
pub fn connection_string(&self) -> String {
format!(
"mysql://{}:{}@{}:{}/{}",
self.username,
self.password,
self.guard.host(),
self.guard.get_mapped_port(MySqlContainer::PORT).unwrap(),
self.database,
)
}
pub async fn stop(self) -> Result<()> {
self.guard.stop().await
}
}
impl std::ops::Deref for MySqlGuard {
type Target = ContainerGuard;
fn deref(&self) -> &ContainerGuard {
&self.guard
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_the_test_trio() {
let c = MySqlContainer::new();
assert_eq!(c.username, "test");
assert_eq!(c.password, "test");
assert_eq!(c.database, "test");
}
#[test]
fn builders_override_the_defaults() {
let c = MySqlContainer::new()
.with_username("alice")
.with_password("s3cret")
.with_database("app");
assert_eq!(c.username, "alice");
assert_eq!(c.password, "s3cret");
assert_eq!(c.database, "app");
}
const CAPTURED_LOG: &str = "\
[System] [MY-011323] [Server] X Plugin ready for connections. Socket: /var/run/mysqld/mysqlx.sock
[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.
[System] [MY-011323] [Server] X Plugin ready for connections. Bind-address: '::' port: 33060, socket: /var/run/mysqld/mysqlx.sock
[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.";
#[test]
fn temp_server_and_x_plugin_lines_do_not_signal_ready() {
for line in CAPTURED_LOG.lines().take(3) {
assert!(
!MySqlReadyForConnections::line_signals_ready(line),
"must not misfire on: {line}"
);
}
}
#[test]
fn only_the_real_servers_port_3306_line_signals_ready() {
let real_line = CAPTURED_LOG.lines().nth(3).unwrap();
assert!(MySqlReadyForConnections::line_signals_ready(real_line));
}
#[test]
fn port_3306_at_end_of_line_with_no_trailing_text_also_matches() {
assert!(MySqlReadyForConnections::line_signals_ready(
"mysqld: ready for connections. port: 3306"
));
}
#[tokio::test]
async fn is_ready_true_only_once_the_real_line_is_in_the_logs() {
struct FakeTarget(std::sync::Mutex<String>);
#[async_trait::async_trait]
impl WaitTarget for FakeTarget {
fn host(&self) -> &str {
"127.0.0.1"
}
fn mapped_port(&self, guest_port: u16) -> u16 {
guest_port
}
fn exposed_guest_ports(&self) -> Vec<u16> {
vec![3306]
}
async fn current_logs(&self) -> String {
self.0.lock().unwrap().clone()
}
fn describe(&self) -> String {
"fake-mysql".to_string()
}
}
let partial: String = CAPTURED_LOG.lines().take(3).collect::<Vec<_>>().join("\n");
let target = FakeTarget(std::sync::Mutex::new(partial));
assert!(!MySqlReadyForConnections::is_ready(&target).await);
*target.0.lock().unwrap() = CAPTURED_LOG.to_string();
assert!(MySqlReadyForConnections::is_ready(&target).await);
}
}