aster_forge_test/
mysql.rs1use crate::database::connect_with_retry;
9use crate::state::{ContainerLease, ContainerStateLock, SharedContainerEndpoint};
10use crate::suite::TestContainerSuite;
11use sea_orm::{ConnectionTrait, Database};
12use testcontainers::core::{ContainerAsync, IntoContainerPort};
13use testcontainers::{GenericImage, ImageExt, ReuseDirective, runners::AsyncRunner};
14
15pub const MYSQL_TEST_TABLE_DEFINITION_CACHE: u64 = 32_768;
20pub const MYSQL_TEST_MAX_CONNECTIONS: u64 = 1_024;
22
23pub struct MysqlTestContainer {
25 root_url: String,
26 suite: TestContainerSuite,
27 stale_resources: Vec<String>,
28 _container: Option<ContainerAsync<GenericImage>>,
29 _lease: ContainerLease,
30}
31
32impl MysqlTestContainer {
33 pub async fn start(suite: &TestContainerSuite) -> Self {
40 let lock = ContainerStateLock::acquire(suite, "mysql");
41 let mut state = lock.load();
42 let stale_resources = state.prune_stale_before_current_execution();
43 state.register_current_process();
44 for resource in &stale_resources {
45 state.remember_current_process_resource(resource);
46 }
47 let endpoint_identity = format!(
48 "mysql:8.4/table-cache={MYSQL_TEST_TABLE_DEFINITION_CACHE}/max-connections={MYSQL_TEST_MAX_CONNECTIONS}/{}",
49 suite.container_name("mysql")
50 );
51
52 if let Some(port) = state
53 .endpoint()
54 .filter(|endpoint| endpoint.matches(&endpoint_identity))
55 .map(SharedContainerEndpoint::port)
56 {
57 let root_url = root_url(port);
58 if let Ok(root) = Database::connect(&root_url).await
59 && root.close().await.is_ok()
60 {
61 lock.save(&state);
62 drop(lock);
63 return Self {
64 root_url,
65 suite: suite.clone(),
66 stale_resources,
67 _container: None,
68 _lease: ContainerLease::new(suite.clone(), "mysql"),
69 };
70 }
71 state.clear_endpoint();
72 }
73
74 let container = GenericImage::new("mysql", "8.4")
75 .with_exposed_port(IntoContainerPort::tcp(3306))
76 .with_container_name(suite.container_name("mysql"))
77 .with_reuse(ReuseDirective::Always)
78 .with_env_var("MYSQL_ROOT_PASSWORD", "rootpass")
79 .start()
80 .await
81 .expect("failed to start MySQL test container");
82 let port = container
83 .get_host_port_ipv4(IntoContainerPort::tcp(3306))
84 .await
85 .expect("MySQL test port should be exposed");
86 let root_url = root_url(port);
87 let root = connect_with_retry(&root_url, "MySQL").await;
88 root.execute_unprepared(&format!(
89 "SET GLOBAL table_definition_cache = {MYSQL_TEST_TABLE_DEFINITION_CACHE}"
90 ))
91 .await
92 .expect("failed to configure MySQL test table definition cache");
93 root.execute_unprepared(&format!(
94 "SET GLOBAL max_connections = {MYSQL_TEST_MAX_CONNECTIONS}"
95 ))
96 .await
97 .expect("failed to configure MySQL test max connections");
98 root.close()
99 .await
100 .expect("failed to close MySQL readiness probe connection");
101 state.set_endpoint(SharedContainerEndpoint::new(endpoint_identity, port));
102 lock.save(&state);
103 drop(lock);
104
105 Self {
106 root_url,
107 suite: suite.clone(),
108 stale_resources,
109 _container: Some(container),
110 _lease: ContainerLease::new(suite.clone(), "mysql"),
111 }
112 }
113
114 #[must_use]
116 pub fn root_url(&self) -> &str {
117 &self.root_url
118 }
119
120 #[must_use]
122 pub fn container_identity(&self) -> &str {
123 &self.root_url
124 }
125
126 #[must_use]
128 pub fn database_url(&self, database: &str) -> String {
129 self.root_url.rsplit_once('/').map_or_else(
130 || self.root_url.clone(),
131 |(base, _)| format!("{base}/{database}"),
132 )
133 }
134
135 #[must_use]
137 pub fn stale_resources(&self) -> &[String] {
138 &self.stale_resources
139 }
140
141 pub fn remember_resource(&self, resource: &str) {
143 let lock = ContainerStateLock::acquire(&self.suite, "mysql");
144 let mut state = lock.load();
145 state.remember_current_process_resource(resource);
146 lock.save(&state);
147 }
148
149 pub fn forget_resource(&self, resource: &str) {
151 let lock = ContainerStateLock::acquire(&self.suite, "mysql");
152 let mut state = lock.load();
153 state.forget_resource(std::process::id(), resource);
154 lock.save(&state);
155 }
156
157 pub fn forget_resources(&self, resources: &[String]) {
159 if resources.is_empty() {
160 return;
161 }
162 let lock = ContainerStateLock::acquire(&self.suite, "mysql");
163 let mut state = lock.load();
164 for resource in resources {
165 state.forget_resource(std::process::id(), resource);
166 }
167 lock.save(&state);
168 }
169
170 pub fn remember_shared_resource(&self, resource: &str) {
176 let lock = ContainerStateLock::acquire(&self.suite, "mysql");
177 let mut state = lock.load();
178 state.remember_shared_resource(resource);
179 lock.save(&state);
180 }
181
182 pub fn forget_shared_resource(&self, resource: &str) {
184 let lock = ContainerStateLock::acquire(&self.suite, "mysql");
185 let mut state = lock.load();
186 state.forget_shared_resource(resource);
187 lock.save(&state);
188 }
189
190 pub async fn create_shared_database(&self, name: &str) {
199 assert_valid_database_name(name);
200 self.remember_shared_resource(name);
201
202 let root = connect_with_retry(&self.root_url, "MySQL").await;
203 root.execute_unprepared(&format!("CREATE DATABASE {}", quote_identifier(name)))
204 .await
205 .unwrap_or_else(|error| {
206 panic!("failed to create shared MySQL test database {name}: {error}")
207 });
208 root.close().await.unwrap_or_else(|error| {
209 panic!("failed to close MySQL shared database admin connection: {error}")
210 });
211 }
212
213 pub async fn drop_shared_database(&self, name: &str) {
219 assert_valid_database_name(name);
220 let root = connect_with_retry(&self.root_url, "MySQL").await;
221 root.execute_unprepared(&format!(
222 "DROP DATABASE IF EXISTS {}",
223 quote_identifier(name)
224 ))
225 .await
226 .unwrap_or_else(|error| {
227 panic!("failed to drop shared MySQL test database {name}: {error}")
228 });
229 root.close().await.unwrap_or_else(|error| {
230 panic!("failed to close MySQL shared database admin connection: {error}")
231 });
232 self.forget_shared_resource(name);
233 }
234}
235
236fn root_url(port: u16) -> String {
237 format!("mysql://root:rootpass@127.0.0.1:{port}/mysql")
238}
239
240fn quote_identifier(value: &str) -> String {
241 format!("`{}`", value.replace('`', "``"))
242}
243
244fn assert_valid_database_name(name: &str) {
245 assert!(
246 !name.is_empty()
247 && name.len() <= 64
248 && name
249 .bytes()
250 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_'),
251 "MySQL test database name must be 1-64 ASCII alphanumeric or '_' characters: {name:?}"
252 );
253}