reuse db container between tests

Container is now never stopped. Since there's only ever going to be one,
this is probably fine.
This commit is contained in:
azdle 2025-07-22 12:08:37 -05:00
parent ee36efecad
commit 741268ef1e

View file

@ -1,11 +1,13 @@
use anyhow::Result; use anyhow::{bail, Result};
use bollard::query_parameters::CreateImageOptionsBuilder; use bollard::query_parameters::{CreateImageOptionsBuilder, ListContainersOptionsBuilder};
use bollard::secret::{ use bollard::secret::{
ContainerCreateBody, ContainerInspectResponse, ContainerState, CreateImageInfo, Health, ContainerCreateBody, ContainerInspectResponse, ContainerState, CreateImageInfo, Health,
HealthConfig, HealthStatusEnum, HostConfig, PortBinding, HealthConfig, HealthStatusEnum, HostConfig, PortBinding,
}; };
use bollard::Docker; use bollard::Docker;
use futures_util::{FutureExt, StreamExt as _}; use futures_util::{FutureExt, StreamExt as _};
use rand::distr::slice::Choose;
use rand::{rng, Rng};
use sqlx::migrate::MigrateDatabase; use sqlx::migrate::MigrateDatabase;
use sqlx::{Connection, PgConnection}; use sqlx::{Connection, PgConnection};
use std::net::SocketAddr; use std::net::SocketAddr;
@ -39,7 +41,6 @@ impl TestServer {
// TODO: allow per-test DBs in some cases // TODO: allow per-test DBs in some cases
// let db = TestDb::spawn().await; // let db = TestDb::spawn().await;
// TODO: share test server between test file, somehow?
let db = get_shared_db().await; let db = get_shared_db().await;
let url = dbg!(db.get_url()); let url = dbg!(db.get_url());
@ -97,11 +98,6 @@ impl TestServer {
self.server_task_handle.abort(); self.server_task_handle.abort();
let _ = self.server_task_handle.await; let _ = self.server_task_handle.await;
// Shutdown server if this is the last one.
if let Some(db) = Arc::into_inner(self.db) {
db.stop().await;
}
Ok(()) Ok(())
} }
} }
@ -109,14 +105,12 @@ impl TestServer {
const TEST_DB_IMAGE_NAME: &str = "postgres"; const TEST_DB_IMAGE_NAME: &str = "postgres";
const TEST_DB_SUPERUSER: &str = "postgres"; const TEST_DB_SUPERUSER: &str = "postgres";
const TEST_DB_SUPERUSER_PASS: &str = "password"; const TEST_DB_SUPERUSER_PASS: &str = "password";
const TEST_DB_APP_USER: &str = "app";
const TEST_DB_APP_PASS: &str = "apppass";
const TEST_DB_APP_NAME: &str = "ztoa";
pub struct TestDb { pub struct TestDb {
docker: Docker,
// image_id: String,
container: bollard::secret::ContainerInspectResponse, container: bollard::secret::ContainerInspectResponse,
user: String,
pass: String,
name: String,
} }
impl TestDb { impl TestDb {
@ -129,126 +123,148 @@ impl TestDb {
trace!("version: {version:?}"); trace!("version: {version:?}");
let mut image_id = None; // check for existing container
let mut found_containers = docker
.list_containers(Some(
ListContainersOptionsBuilder::new()
.filters(
&([(
"label".to_string(),
vec!["zero-to-axum_test-db".to_string()],
)]
.into()),
)
.build(),
))
.await
.unwrap();
// check for image let container_id;
if let Ok(image) = docker.inspect_image(TEST_DB_IMAGE_NAME).await { if let Some(container) = found_containers.pop() {
image_id = Some(image.id.unwrap()); container_id = container.id.unwrap();
} } else {
// build container
let mut image_id = None;
// build docker image from docker file // check for image
// let mut image_id = None; if let Ok(image) = docker.inspect_image(TEST_DB_IMAGE_NAME).await {
// { image_id = Some(image.id.unwrap());
// let filename = "Dockerfile.db"; }
// let image_options = bollard::query_parameters::BuildImageOptionsBuilder::default()
// .dockerfile(filename)
// .rm(true)
// .build();
// let archive_bytes = { // build docker image from docker file
// let mut archive = tar::Builder::new(Vec::new()); // let mut image_id = None;
// archive.append_path(filename).unwrap(); // {
// archive.into_inner().unwrap() // let filename = "Dockerfile.db";
// }; // let image_options = bollard::query_parameters::BuildImageOptionsBuilder::default()
// .dockerfile(filename)
// .rm(true)
// .build();
// let mut image_build_stream = docker.build_image( // let archive_bytes = {
// image_options, // let mut archive = tar::Builder::new(Vec::new());
// None, // archive.append_path(filename).unwrap();
// Some(http_body_util::Either::Left(http_body_util::Full::new( // archive.into_inner().unwrap()
// archive_bytes.into(), // };
// ))),
// );
// while let Some(msg) = image_build_stream.next().await { // let mut image_build_stream = docker.build_image(
// info!("Message: {msg:?}"); // image_options,
// None,
// Some(http_body_util::Either::Left(http_body_util::Full::new(
// archive_bytes.into(),
// ))),
// );
// if let Ok(BuildInfo { // while let Some(msg) = image_build_stream.next().await {
// aux: Some(ImageId { id: Some(id) }), // info!("Message: {msg:?}");
// ..
// }) = msg
// {
// trace!("Image ID: {id}");
// image_id = Some(id);
// }
// }
// }
// let image_id = image_id.expect("get image id for built docker image");
// pull image // if let Ok(BuildInfo {
if image_id.is_none() { // aux: Some(ImageId { id: Some(id) }),
let image_opts = CreateImageOptionsBuilder::new() // ..
.from_image(TEST_DB_IMAGE_NAME) // }) = msg
.build(); // {
// trace!("Image ID: {id}");
// image_id = Some(id);
// }
// }
// }
// let image_id = image_id.expect("get image id for built docker image");
trace!(?image_opts, "pull image"); // pull image
let mut image_create_stream = docker.create_image(Some(image_opts), None, None); if image_id.is_none() {
let image_opts = CreateImageOptionsBuilder::new()
.from_image(TEST_DB_IMAGE_NAME)
.build();
while let Some(msg) = image_create_stream.next().await { trace!(?image_opts, "pull image");
trace!("Message: {msg:?}"); let mut image_create_stream = docker.create_image(Some(image_opts), None, None);
if let Ok(CreateImageInfo { id: Some(id), .. }) = msg { while let Some(msg) = image_create_stream.next().await {
trace!("Image ID: {id}"); trace!("Message: {msg:?}");
image_id = Some(id);
if let Ok(CreateImageInfo { id: Some(id), .. }) = msg {
trace!("Image ID: {id}");
image_id = Some(id);
}
} }
} }
}
let image_id = image_id.expect("get image id for built docker image"); let image_id = image_id.expect("get image id for built docker image");
// create and start docker container // create and start docker container
let container_id; {
{ let container_config = ContainerCreateBody {
let container_config = ContainerCreateBody { image: Some(image_id.clone()),
image: Some(image_id.clone()), exposed_ports: Some([("5432/tcp".to_string(), [].into())].into()),
exposed_ports: Some([("5432/tcp".to_string(), [].into())].into()), host_config: Some(HostConfig {
host_config: Some(HostConfig { port_bindings: Some(
port_bindings: Some( [(
[( "5432/tcp".to_string(),
"5432/tcp".to_string(), Some(vec![PortBinding {
Some(vec![PortBinding { host_ip: Some("127.0.0.1".to_string()),
host_ip: Some("127.0.0.1".to_string()), host_port: None, // auto-assign
host_port: None, // auto-assign }]),
}]), )]
)] .into(),
.into(), ),
), ..Default::default()
}),
env: Some(vec![
format!("POSTGRES_USER={TEST_DB_SUPERUSER}"),
format!("POSTGRES_PASSWORD={TEST_DB_SUPERUSER_PASS}"),
]),
healthcheck: Some(HealthConfig {
test: Some(vec!["pg_isready -U postgres || exit 1".to_string()]),
// nano seconds
interval: Some(1 * 1000 * 1000 * 1000),
timeout: Some(5 * 1000 * 1000 * 1000),
retries: Some(5 * 1000 * 1000 * 1000),
..Default::default()
}),
labels: Some([("zero-to-axum_test-db".to_string(), String::new())].into()),
..Default::default() ..Default::default()
}), };
env: Some(vec![
format!("POSTGRES_USER={TEST_DB_SUPERUSER}"),
format!("POSTGRES_PASSWORD={TEST_DB_SUPERUSER_PASS}"),
]),
healthcheck: Some(HealthConfig {
test: Some(vec!["pg_isready -U postgres || exit 1".to_string()]),
// nano seconds
interval: Some(1 * 1000 * 1000 * 1000),
timeout: Some(5 * 1000 * 1000 * 1000),
retries: Some(5 * 1000 * 1000 * 1000),
..Default::default()
}),
..Default::default()
};
trace!("create container"); trace!("create container");
bollard::secret::ContainerCreateResponse { bollard::secret::ContainerCreateResponse {
id: container_id, id: container_id,
.. ..
} = docker } = docker
.create_container( .create_container(
None::<bollard::query_parameters::CreateContainerOptions>, None::<bollard::query_parameters::CreateContainerOptions>,
container_config, container_config,
) )
.await .await
.unwrap(); .unwrap();
trace!("start container"); trace!("start container");
docker docker
.start_container( .start_container(
&container_id, &container_id,
None::<bollard::query_parameters::StartContainerOptions>, None::<bollard::query_parameters::StartContainerOptions>,
) )
.await .await
.unwrap(); .unwrap();
}
} }
// wait for container to be started // wait for container to be started
@ -284,30 +300,48 @@ impl TestDb {
sleep(Duration::from_secs(2)).await; sleep(Duration::from_secs(2)).await;
}; };
let lowercase_alpha = Choose::new(b"abcdefghijklmnopqrstuvwxyz").unwrap();
let db = TestDb { let db = TestDb {
docker,
// image_id,
container, container,
user: rng()
.sample_iter(lowercase_alpha)
.take(16)
.map(|i| char::from(*i))
.collect(),
pass: rng()
.sample_iter(lowercase_alpha)
.take(32)
.map(|i| char::from(*i))
.collect(),
name: rng()
.sample_iter(lowercase_alpha)
.take(8)
.map(|i| char::from(*i))
.collect(),
}; };
// setup app db // setup app db
{ {
let mut conn = PgConnection::connect(&db.get_superuser_url()) let mut conn = PgConnection::connect(&dbg!(db.get_superuser_url()))
.await .await
.unwrap(); .unwrap();
// create application user // create application user
// Note: In general, string formtting a query is bad practice, but it's required here. // Note: In general, string formtting a query is bad practice, but it's required here.
sqlx::query(&format!( dbg!(
"CREATE USER {TEST_DB_APP_USER} WITH PASSWORD '{TEST_DB_APP_PASS}';" sqlx::query(&dbg!(format!(
)) "CREATE USER {} WITH PASSWORD '{}';",
.execute(&mut conn) db.user, db.pass
.await )))
.execute(&mut conn)
.await
)
.unwrap(); .unwrap();
// grant privs to app user // grant privs to app user
// Note: In general, string formtting a query is bad practice, but it's required here. // Note: In general, string formtting a query is bad practice, but it's required here.
sqlx::query(&format!("ALTER USER {TEST_DB_APP_USER} CREATEDB;")) sqlx::query(&format!("ALTER USER {} CREATEDB;", db.user))
.execute(&mut conn) .execute(&mut conn)
.await .await
.unwrap(); .unwrap();
@ -348,7 +382,10 @@ impl TestDb {
.unwrap(); .unwrap();
let host_ip = binding.host_ip.as_ref().unwrap().clone(); let host_ip = binding.host_ip.as_ref().unwrap().clone();
let host_port = binding.host_port.as_ref().unwrap().clone(); let host_port = binding.host_port.as_ref().unwrap().clone();
format!("postgres://{TEST_DB_APP_USER}:{TEST_DB_APP_PASS}@{host_ip}:{host_port}/{TEST_DB_APP_NAME}") format!(
"postgres://{}:{}@{host_ip}:{host_port}/{}",
self.user, self.pass, self.name
)
} }
/// Get the superuser-authenticated URL for accessing the `postgres` db from the host. /// Get the superuser-authenticated URL for accessing the `postgres` db from the host.
@ -372,33 +409,4 @@ impl TestDb {
let host_port = binding.host_port.as_ref().unwrap().clone(); let host_port = binding.host_port.as_ref().unwrap().clone();
format!("postgres://{TEST_DB_SUPERUSER}:{TEST_DB_SUPERUSER_PASS}@{host_ip}:{host_port}/postgres") format!("postgres://{TEST_DB_SUPERUSER}:{TEST_DB_SUPERUSER_PASS}@{host_ip}:{host_port}/postgres")
} }
pub async fn stop(&self) {
self.docker
.stop_container(
&self.container.id.as_deref().unwrap(),
#[allow(deprecated)] // is deprecated, but also required
None::<bollard::container::StopContainerOptions>,
)
.await
.unwrap();
self.docker
.remove_container(
&self.container.id.as_deref().unwrap(),
None::<bollard::query_parameters::RemoveContainerOptions>,
)
.await
.unwrap();
// // TODO: images seem like they might be reused, this is probably a bad idea, but it seems to work?
// let _ = self
// .docker
// .remove_image(
// &self.image_id,
// None::<bollard::query_parameters::RemoveImageOptions>,
// None,
// )
// .await;
}
} }