diff --git a/Cargo.lock b/Cargo.lock index c120193..07f6d1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -549,7 +549,7 @@ dependencies = [ [[package]] name = "ephemeral-postgres" -version = "0.3.1" +version = "0.4.0" dependencies = [ "sqlx", "testcontainers-modules", @@ -558,6 +558,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "ephemeral-postgres-run" +version = "0.4.0" +dependencies = [ + "ephemeral-postgres", + "ephemeral-postgres-tests", + "thiserror", + "tokio", +] + [[package]] name = "ephemeral-postgres-tests" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index e9d79a7..9fce807 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,33 @@ [workspace] resolver = "3" -members = ["ephemeral-postgres", "ephemeral-postgres-tests"] +members = ["ephemeral-postgres", "ephemeral-postgres-run", "ephemeral-postgres-tests"] [workspace.dependencies] -ephemeral-postgres = { path = "ephemeral-postgres" } +ephemeral-postgres = { path = "ephemeral-postgres", version = "0.4.0" } +ephemeral-postgres-tests = { path = "ephemeral-postgres-tests" } sqlx = { version = "0.9", features = ["chrono", "derive", "json", "migrate", "postgres", "runtime-tokio", "tls-native-tls", "uuid"] } testcontainers-modules = { version = "0.15", default-features = false, features = ["postgres", "watchdog"] } thiserror = "2.0" tokio = { version = "1.53", features = ["time"] } uuid = { version = "1.24", features = ["serde", "v4"] } +[workspace.lints.rust] +warnings = "deny" + [workspace.lints.clippy] -ignored_unit_patterns = "warn" -must_use_candidate = "warn" +all = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } + +expect_used = "deny" +panic = "deny" +unwrap_used = "deny" + +[workspace.metadata.heisenberg] +test_crates = ["ephemeral-postgres-tests"] + +[[workspace.metadata.heisenberg.configurations]] +name = "x86_64-unknown-linux-gnu" +target = "x86_64-unknown-linux-gnu" +default_features = true +features = [] diff --git a/Makefile b/Makefile index 4d75cfc..94bf059 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +CARGO_HOME ?= $(HOME)/.cargo + # ----------------------------------------------------------------------------- # Real targets # ----------------------------------------------------------------------------- @@ -15,7 +17,7 @@ package-lock.json: package.json .PHONY: clippy clippy: - cargo clippy --workspace --tests -- -D warnings + cargo clippy --workspace --tests .PHONY: coverage coverage: node_modules @@ -24,12 +26,22 @@ coverage: node_modules cargo llvm-cov report --json --output-path target/llvm-cov.json npx rust-coverage-check target/llvm-cov.json \ --workspace-root $(CURDIR) \ - --gated ephemeral-postgres=100 + --gated ephemeral-postgres=100 \ + --gated ephemeral-postgres-run=100 .PHONY: fmt fmt: cargo fmt +.PHONY: heisenberg +heisenberg: + cargo fetch + heisenberg check \ + --cargo-home "$(CARGO_HOME)" \ + --sysroot "$(shell rustc --print sysroot)" \ + --configuration x86_64-unknown-linux-gnu \ + --workspace "$(CURDIR)" + .PHONY: rust-test rust-test: cargo test --workspace diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..67fe3c0 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-unwrap-in-tests = true +avoid-breaking-exported-api = false diff --git a/ephemeral-postgres-run/Cargo.toml b/ephemeral-postgres-run/Cargo.toml new file mode 100644 index 0000000..b8adb3c --- /dev/null +++ b/ephemeral-postgres-run/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "ephemeral-postgres-run" +version = "0.4.0" +edition = "2024" +description = "Runs a command next to one ephemeral PostgreSQL server that all of its processes share." +license = "Apache-2.0" +repository = "https://github.com/intentee/ephemeral-postgres" +documentation = "https://docs.rs/ephemeral-postgres-run" +readme = "../README.md" +keywords = ["postgres", "postgresql", "testing", "testcontainers"] +categories = ["development-tools::testing", "database"] +autotests = false + +[dependencies] +ephemeral-postgres = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["macros", "process", "rt"] } + +[dev-dependencies] +ephemeral-postgres-tests = { workspace = true } + +[lints] +workspace = true + +[[test]] +name = "integration" +path = "tests/main.rs" diff --git a/ephemeral-postgres-run/src/constants.rs b/ephemeral-postgres-run/src/constants.rs new file mode 100644 index 0000000..004f85b --- /dev/null +++ b/ephemeral-postgres-run/src/constants.rs @@ -0,0 +1 @@ +pub const SERVER_URL_VARIABLE: &str = "EPHEMERAL_POSTGRES_URL"; diff --git a/ephemeral-postgres-run/src/ephemeral_postgres_run_error.rs b/ephemeral-postgres-run/src/ephemeral_postgres_run_error.rs new file mode 100644 index 0000000..48ccf90 --- /dev/null +++ b/ephemeral-postgres-run/src/ephemeral_postgres_run_error.rs @@ -0,0 +1,41 @@ +use std::process::ExitStatus; + +use ephemeral_postgres::ephemeral_postgres_error::EphemeralPostgresError; + +#[derive(Debug, thiserror::Error)] +pub enum EphemeralPostgresRunError { + #[error("failed to start the shared postgres cluster")] + ClusterStart { + #[source] + source: EphemeralPostgresError, + }, + + #[error("failed to run `{command}`")] + CommandSpawn { + command: String, + #[source] + source: std::io::Error, + }, + + #[error("`{command}` ended without an exit code ({status})")] + CommandTerminated { command: String, status: ExitStatus }, + + #[error("missing the command to run after `--`")] + MissingCommand, + + #[error( + "missing the postgres image name; usage: ephemeral-postgres-run -- [arguments...]" + )] + MissingImageName, + + #[error( + "missing the postgres image tag; usage: ephemeral-postgres-run -- [arguments...]" + )] + MissingImageTag, + + #[error("missing `--` between the postgres image and the command")] + MissingSeparator, + + #[error("expected `--` after the postgres image, found `{found}`")] + UnexpectedArgument { found: String }, +} diff --git a/ephemeral-postgres-run/src/lib.rs b/ephemeral-postgres-run/src/lib.rs new file mode 100644 index 0000000..29e4306 --- /dev/null +++ b/ephemeral-postgres-run/src/lib.rs @@ -0,0 +1,4 @@ +pub mod constants; +pub mod ephemeral_postgres_run_error; +pub mod run; +pub mod run_arguments; diff --git a/ephemeral-postgres-run/src/main.rs b/ephemeral-postgres-run/src/main.rs new file mode 100644 index 0000000..d8bb10f --- /dev/null +++ b/ephemeral-postgres-run/src/main.rs @@ -0,0 +1,27 @@ +use std::env; +use std::error::Error; +use std::process; + +use ephemeral_postgres_run::run::run; + +fn report(error: &dyn Error) { + eprintln!("ephemeral-postgres-run: {error}"); + + let mut source = error.source(); + + while let Some(cause) = source { + eprintln!(" caused by: {cause}"); + source = cause.source(); + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + match run(env::args().skip(1).collect()).await { + Ok(code) => process::exit(code), + Err(error) => { + report(&error); + process::exit(1); + } + } +} diff --git a/ephemeral-postgres-run/src/run.rs b/ephemeral-postgres-run/src/run.rs new file mode 100644 index 0000000..59cf194 --- /dev/null +++ b/ephemeral-postgres-run/src/run.rs @@ -0,0 +1,36 @@ +use tokio::process::Command; + +use ephemeral_postgres::cluster::Cluster; +use ephemeral_postgres::cluster_params::ClusterParams; + +use crate::constants::SERVER_URL_VARIABLE; +use crate::ephemeral_postgres_run_error::EphemeralPostgresRunError; +use crate::run_arguments::RunArguments; + +/// # Errors +/// +/// Fails when the arguments are invalid, the cluster cannot start, or the command cannot run or +/// ends without an exit code. +pub async fn run(arguments: Vec) -> Result { + let RunArguments { + command, + command_arguments, + image, + } = RunArguments::parse(arguments)?; + let cluster = Cluster::start(ClusterParams::new(image)) + .await + .map_err(|source| EphemeralPostgresRunError::ClusterStart { source })?; + let status = Command::new(&command) + .args(&command_arguments) + .env(SERVER_URL_VARIABLE, cluster.base_url()) + .status() + .await + .map_err(|source| EphemeralPostgresRunError::CommandSpawn { + command: command.clone(), + source, + })?; + + status + .code() + .ok_or(EphemeralPostgresRunError::CommandTerminated { command, status }) +} diff --git a/ephemeral-postgres-run/src/run_arguments.rs b/ephemeral-postgres-run/src/run_arguments.rs new file mode 100644 index 0000000..8e1faf4 --- /dev/null +++ b/ephemeral-postgres-run/src/run_arguments.rs @@ -0,0 +1,44 @@ +use ephemeral_postgres::postgres_image::PostgresImage; + +use crate::ephemeral_postgres_run_error::EphemeralPostgresRunError; + +const SEPARATOR: &str = "--"; + +pub struct RunArguments { + pub command: String, + pub command_arguments: Vec, + pub image: PostgresImage, +} + +impl RunArguments { + /// # Errors + /// + /// Fails when the image name, the image tag, the `--` separator or the command is missing, or + /// when another argument stands in place of the separator. + pub fn parse(arguments: Vec) -> Result { + let mut arguments = arguments.into_iter(); + let image_name = arguments + .next() + .ok_or(EphemeralPostgresRunError::MissingImageName)?; + let image_tag = arguments + .next() + .ok_or(EphemeralPostgresRunError::MissingImageTag)?; + let separator = arguments + .next() + .ok_or(EphemeralPostgresRunError::MissingSeparator)?; + + if separator != SEPARATOR { + return Err(EphemeralPostgresRunError::UnexpectedArgument { found: separator }); + } + + let command = arguments + .next() + .ok_or(EphemeralPostgresRunError::MissingCommand)?; + + Ok(Self { + command, + command_arguments: arguments.collect(), + image: PostgresImage::new(image_name, image_tag), + }) + } +} diff --git a/ephemeral-postgres-run/tests/binary_exits_with_the_command_exit_code.rs b/ephemeral-postgres-run/tests/binary_exits_with_the_command_exit_code.rs new file mode 100644 index 0000000..772b9b6 --- /dev/null +++ b/ephemeral-postgres-run/tests/binary_exits_with_the_command_exit_code.rs @@ -0,0 +1,16 @@ +use std::process::Command; + +use ephemeral_postgres::postgres_image::PostgresImage; +use ephemeral_postgres_tests::postgres_test_image::postgres_test_image; + +#[test] +fn exits_with_the_exit_code_of_the_command() { + let PostgresImage { name, tag } = postgres_test_image(); + + let status = Command::new(env!("CARGO_BIN_EXE_ephemeral-postgres-run")) + .args([name.as_str(), tag.as_str(), "--", "sh", "-c", "exit 5"]) + .status() + .unwrap(); + + assert_eq!(status.code(), Some(5)); +} diff --git a/ephemeral-postgres-run/tests/binary_reports_a_failure_with_its_causes.rs b/ephemeral-postgres-run/tests/binary_reports_a_failure_with_its_causes.rs new file mode 100644 index 0000000..6b0c0de --- /dev/null +++ b/ephemeral-postgres-run/tests/binary_reports_a_failure_with_its_causes.rs @@ -0,0 +1,20 @@ +use std::process::Command; + +use ephemeral_postgres::postgres_image::PostgresImage; +use ephemeral_postgres_tests::postgres_test_image::postgres_test_image; + +#[test] +fn exits_with_a_failure_when_the_command_cannot_run() { + let PostgresImage { name, tag } = postgres_test_image(); + + let output = Command::new(env!("CARGO_BIN_EXE_ephemeral-postgres-run")) + .args([name.as_str(), tag.as_str(), "--", "/nonexistent/command"]) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(1)); + assert_eq!( + String::from_utf8(output.stderr).unwrap(), + "ephemeral-postgres-run: failed to run `/nonexistent/command`\n caused by: No such file or directory (os error 2)\n" + ); +} diff --git a/ephemeral-postgres-run/tests/binary_reports_a_usage_error.rs b/ephemeral-postgres-run/tests/binary_reports_a_usage_error.rs new file mode 100644 index 0000000..b13c2bf --- /dev/null +++ b/ephemeral-postgres-run/tests/binary_reports_a_usage_error.rs @@ -0,0 +1,14 @@ +use std::process::Command; + +#[test] +fn exits_with_a_failure_and_the_usage_when_arguments_are_missing() { + let output = Command::new(env!("CARGO_BIN_EXE_ephemeral-postgres-run")) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(1)); + assert_eq!( + String::from_utf8(output.stderr).unwrap(), + "ephemeral-postgres-run: missing the postgres image name; usage: ephemeral-postgres-run -- [arguments...]\n" + ); +} diff --git a/ephemeral-postgres-run/tests/main.rs b/ephemeral-postgres-run/tests/main.rs new file mode 100644 index 0000000..f20a751 --- /dev/null +++ b/ephemeral-postgres-run/tests/main.rs @@ -0,0 +1,12 @@ +mod binary_exits_with_the_command_exit_code; +mod binary_reports_a_failure_with_its_causes; +mod binary_reports_a_usage_error; +mod run_arguments_reject_a_missing_command; +mod run_arguments_reject_a_missing_image_name; +mod run_arguments_reject_a_missing_image_tag; +mod run_arguments_reject_a_missing_separator; +mod run_arguments_reject_an_argument_in_place_of_the_separator; +mod run_reports_a_cluster_that_cannot_start; +mod run_reports_a_command_terminated_by_a_signal; +mod run_reports_a_command_that_cannot_be_spawned; +mod run_returns_the_command_exit_code_with_the_server_url; diff --git a/ephemeral-postgres-run/tests/run_arguments_reject_a_missing_command.rs b/ephemeral-postgres-run/tests/run_arguments_reject_a_missing_command.rs new file mode 100644 index 0000000..1f5f885 --- /dev/null +++ b/ephemeral-postgres-run/tests/run_arguments_reject_a_missing_command.rs @@ -0,0 +1,14 @@ +use ephemeral_postgres_run::ephemeral_postgres_run_error::EphemeralPostgresRunError; +use ephemeral_postgres_run::run_arguments::RunArguments; + +#[test] +fn rejects_a_separator_without_a_command() { + assert!(matches!( + RunArguments::parse(vec![ + "postgres".to_owned(), + "18".to_owned(), + "--".to_owned(), + ]), + Err(EphemeralPostgresRunError::MissingCommand) + )); +} diff --git a/ephemeral-postgres-run/tests/run_arguments_reject_a_missing_image_name.rs b/ephemeral-postgres-run/tests/run_arguments_reject_a_missing_image_name.rs new file mode 100644 index 0000000..9ad21a5 --- /dev/null +++ b/ephemeral-postgres-run/tests/run_arguments_reject_a_missing_image_name.rs @@ -0,0 +1,10 @@ +use ephemeral_postgres_run::ephemeral_postgres_run_error::EphemeralPostgresRunError; +use ephemeral_postgres_run::run_arguments::RunArguments; + +#[test] +fn rejects_arguments_without_an_image_name() { + assert!(matches!( + RunArguments::parse(Vec::new()), + Err(EphemeralPostgresRunError::MissingImageName) + )); +} diff --git a/ephemeral-postgres-run/tests/run_arguments_reject_a_missing_image_tag.rs b/ephemeral-postgres-run/tests/run_arguments_reject_a_missing_image_tag.rs new file mode 100644 index 0000000..ec0eda4 --- /dev/null +++ b/ephemeral-postgres-run/tests/run_arguments_reject_a_missing_image_tag.rs @@ -0,0 +1,10 @@ +use ephemeral_postgres_run::ephemeral_postgres_run_error::EphemeralPostgresRunError; +use ephemeral_postgres_run::run_arguments::RunArguments; + +#[test] +fn rejects_arguments_without_an_image_tag() { + assert!(matches!( + RunArguments::parse(vec!["postgres".to_owned()]), + Err(EphemeralPostgresRunError::MissingImageTag) + )); +} diff --git a/ephemeral-postgres-run/tests/run_arguments_reject_a_missing_separator.rs b/ephemeral-postgres-run/tests/run_arguments_reject_a_missing_separator.rs new file mode 100644 index 0000000..0e302cf --- /dev/null +++ b/ephemeral-postgres-run/tests/run_arguments_reject_a_missing_separator.rs @@ -0,0 +1,10 @@ +use ephemeral_postgres_run::ephemeral_postgres_run_error::EphemeralPostgresRunError; +use ephemeral_postgres_run::run_arguments::RunArguments; + +#[test] +fn rejects_arguments_that_end_after_the_image() { + assert!(matches!( + RunArguments::parse(vec!["postgres".to_owned(), "18".to_owned()]), + Err(EphemeralPostgresRunError::MissingSeparator) + )); +} diff --git a/ephemeral-postgres-run/tests/run_arguments_reject_an_argument_in_place_of_the_separator.rs b/ephemeral-postgres-run/tests/run_arguments_reject_an_argument_in_place_of_the_separator.rs new file mode 100644 index 0000000..e689881 --- /dev/null +++ b/ephemeral-postgres-run/tests/run_arguments_reject_an_argument_in_place_of_the_separator.rs @@ -0,0 +1,14 @@ +use ephemeral_postgres_run::ephemeral_postgres_run_error::EphemeralPostgresRunError; +use ephemeral_postgres_run::run_arguments::RunArguments; + +#[test] +fn rejects_a_command_that_is_not_preceded_by_the_separator() { + assert!(matches!( + RunArguments::parse(vec![ + "postgres".to_owned(), + "18".to_owned(), + "true".to_owned(), + ]), + Err(EphemeralPostgresRunError::UnexpectedArgument { found }) if found == "true" + )); +} diff --git a/ephemeral-postgres-run/tests/run_reports_a_cluster_that_cannot_start.rs b/ephemeral-postgres-run/tests/run_reports_a_cluster_that_cannot_start.rs new file mode 100644 index 0000000..f0d3700 --- /dev/null +++ b/ephemeral-postgres-run/tests/run_reports_a_cluster_that_cannot_start.rs @@ -0,0 +1,21 @@ +use ephemeral_postgres::ephemeral_postgres_error::EphemeralPostgresError; +use ephemeral_postgres_run::ephemeral_postgres_run_error::EphemeralPostgresRunError; +use ephemeral_postgres_run::run::run; + +#[tokio::test] +async fn reports_an_image_that_cannot_start_a_cluster() { + let result = run(vec![ + "postgres".to_owned(), + "tag-that-does-not-exist-9999".to_owned(), + "--".to_owned(), + "true".to_owned(), + ]) + .await; + + assert!(matches!( + result, + Err(EphemeralPostgresRunError::ClusterStart { + source: EphemeralPostgresError::ContainerStart { .. } + }) + )); +} diff --git a/ephemeral-postgres-run/tests/run_reports_a_command_terminated_by_a_signal.rs b/ephemeral-postgres-run/tests/run_reports_a_command_terminated_by_a_signal.rs new file mode 100644 index 0000000..8a5c52f --- /dev/null +++ b/ephemeral-postgres-run/tests/run_reports_a_command_terminated_by_a_signal.rs @@ -0,0 +1,29 @@ +use std::os::unix::process::ExitStatusExt; + +use ephemeral_postgres::postgres_image::PostgresImage; +use ephemeral_postgres_run::ephemeral_postgres_run_error::EphemeralPostgresRunError; +use ephemeral_postgres_run::run::run; +use ephemeral_postgres_tests::postgres_test_image::postgres_test_image; + +const SIGKILL: i32 = 9; + +#[tokio::test] +async fn reports_a_command_that_ends_without_an_exit_code() { + let PostgresImage { name, tag } = postgres_test_image(); + + let result = run(vec![ + name, + tag, + "--".to_owned(), + "sh".to_owned(), + "-c".to_owned(), + "kill -KILL $$".to_owned(), + ]) + .await; + + assert!(matches!( + result, + Err(EphemeralPostgresRunError::CommandTerminated { command, status }) + if command == "sh" && status.signal() == Some(SIGKILL) + )); +} diff --git a/ephemeral-postgres-run/tests/run_reports_a_command_that_cannot_be_spawned.rs b/ephemeral-postgres-run/tests/run_reports_a_command_that_cannot_be_spawned.rs new file mode 100644 index 0000000..36616ac --- /dev/null +++ b/ephemeral-postgres-run/tests/run_reports_a_command_that_cannot_be_spawned.rs @@ -0,0 +1,25 @@ +use std::io::ErrorKind; + +use ephemeral_postgres::postgres_image::PostgresImage; +use ephemeral_postgres_run::ephemeral_postgres_run_error::EphemeralPostgresRunError; +use ephemeral_postgres_run::run::run; +use ephemeral_postgres_tests::postgres_test_image::postgres_test_image; + +#[tokio::test] +async fn reports_a_command_that_does_not_exist() { + let PostgresImage { name, tag } = postgres_test_image(); + + let result = run(vec![ + name, + tag, + "--".to_owned(), + "/nonexistent/command".to_owned(), + ]) + .await; + + assert!(matches!( + result, + Err(EphemeralPostgresRunError::CommandSpawn { command, source }) + if command == "/nonexistent/command" && source.kind() == ErrorKind::NotFound + )); +} diff --git a/ephemeral-postgres-run/tests/run_returns_the_command_exit_code_with_the_server_url.rs b/ephemeral-postgres-run/tests/run_returns_the_command_exit_code_with_the_server_url.rs new file mode 100644 index 0000000..5670499 --- /dev/null +++ b/ephemeral-postgres-run/tests/run_returns_the_command_exit_code_with_the_server_url.rs @@ -0,0 +1,22 @@ +use ephemeral_postgres::postgres_image::PostgresImage; +use ephemeral_postgres_run::run::run; +use ephemeral_postgres_tests::postgres_test_image::postgres_test_image; + +#[tokio::test] +async fn returns_the_exit_code_of_a_command_that_sees_the_server_url() { + let PostgresImage { name, tag } = postgres_test_image(); + + let code = run(vec![ + name, + tag, + "--".to_owned(), + "sh".to_owned(), + "-c".to_owned(), + "case \"$EPHEMERAL_POSTGRES_URL\" in postgres://postgres@127.0.0.1:*) exit 7;; esac; exit 1" + .to_owned(), + ]) + .await + .unwrap(); + + assert_eq!(code, 7); +} diff --git a/ephemeral-postgres-tests/tests/cluster_connect_errors_when_server_unreachable.rs b/ephemeral-postgres-tests/tests/cluster_connect_errors_when_server_unreachable.rs new file mode 100644 index 0000000..574704f --- /dev/null +++ b/ephemeral-postgres-tests/tests/cluster_connect_errors_when_server_unreachable.rs @@ -0,0 +1,19 @@ +use std::time::Duration; + +use ephemeral_postgres::cluster::Cluster; +use ephemeral_postgres::cluster_connection_params::ClusterConnectionParams; +use ephemeral_postgres::ephemeral_postgres_error::EphemeralPostgresError; + +#[tokio::test] +async fn connect_errors_when_the_server_never_becomes_ready() { + let result = Cluster::connect(ClusterConnectionParams { + readiness_timeout: Duration::ZERO, + ..ClusterConnectionParams::new("postgres://postgres@127.0.0.1:1") + }) + .await; + + assert!(matches!( + result, + Err(EphemeralPostgresError::ReadinessTimeout { .. }) + )); +} diff --git a/ephemeral-postgres-tests/tests/cluster_connects_to_a_running_server.rs b/ephemeral-postgres-tests/tests/cluster_connects_to_a_running_server.rs new file mode 100644 index 0000000..3646bd4 --- /dev/null +++ b/ephemeral-postgres-tests/tests/cluster_connects_to_a_running_server.rs @@ -0,0 +1,26 @@ +use sqlx::Row; + +use ephemeral_postgres::cluster::Cluster; +use ephemeral_postgres::cluster_connection_params::ClusterConnectionParams; +use ephemeral_postgres::cluster_params::ClusterParams; +use ephemeral_postgres_tests::postgres_test_image::postgres_test_image; + +#[tokio::test] +async fn connected_cluster_creates_databases_on_the_running_server() { + let started = Cluster::start(ClusterParams::new(postgres_test_image())) + .await + .unwrap(); + let connected = Cluster::connect(ClusterConnectionParams::new(started.base_url())) + .await + .unwrap(); + let database = connected.create_database().await.unwrap(); + + let value: i32 = sqlx::query("SELECT 1::int AS value") + .fetch_one(database.pool()) + .await + .unwrap() + .get("value"); + + assert_eq!(value, 1); + assert_eq!(connected.base_url(), started.base_url()); +} diff --git a/ephemeral-postgres-tests/tests/cluster_finalize_errors_when_mapped_port_unavailable.rs b/ephemeral-postgres-tests/tests/cluster_finalize_errors_when_mapped_port_unavailable.rs index 3c40212..02bf079 100644 --- a/ephemeral-postgres-tests/tests/cluster_finalize_errors_when_mapped_port_unavailable.rs +++ b/ephemeral-postgres-tests/tests/cluster_finalize_errors_when_mapped_port_unavailable.rs @@ -1,8 +1,9 @@ +use testcontainers_modules::testcontainers::runners::AsyncRunner; + use ephemeral_postgres::cluster::Cluster; use ephemeral_postgres::cluster_params::ClusterParams; use ephemeral_postgres::ephemeral_postgres_error::EphemeralPostgresError; use ephemeral_postgres_tests::postgres_test_image::postgres_test_image; -use testcontainers_modules::testcontainers::runners::AsyncRunner; #[tokio::test] async fn finalize_errors_when_mapped_port_unavailable() { diff --git a/ephemeral-postgres-tests/tests/database_create_with_id_errors_on_duplicate.rs b/ephemeral-postgres-tests/tests/database_create_with_id_errors_on_duplicate.rs index b0512dd..d93e8d2 100644 --- a/ephemeral-postgres-tests/tests/database_create_with_id_errors_on_duplicate.rs +++ b/ephemeral-postgres-tests/tests/database_create_with_id_errors_on_duplicate.rs @@ -1,8 +1,9 @@ +use uuid::Uuid; + use ephemeral_postgres::cluster::Cluster; use ephemeral_postgres::cluster_params::ClusterParams; use ephemeral_postgres::ephemeral_postgres_error::EphemeralPostgresError; use ephemeral_postgres_tests::postgres_test_image::postgres_test_image; -use uuid::Uuid; #[tokio::test] async fn create_with_id_errors_on_duplicate() { diff --git a/ephemeral-postgres-tests/tests/database_isolates_state.rs b/ephemeral-postgres-tests/tests/database_isolates_state.rs index 2e8caf9..be056b7 100644 --- a/ephemeral-postgres-tests/tests/database_isolates_state.rs +++ b/ephemeral-postgres-tests/tests/database_isolates_state.rs @@ -1,7 +1,8 @@ +use sqlx::Row; + use ephemeral_postgres::cluster::Cluster; use ephemeral_postgres::cluster_params::ClusterParams; use ephemeral_postgres_tests::postgres_test_image::postgres_test_image; -use sqlx::Row; #[tokio::test] async fn database_isolates_state_from_a_separate_database() { diff --git a/ephemeral-postgres-tests/tests/database_provides_working_pool.rs b/ephemeral-postgres-tests/tests/database_provides_working_pool.rs index 53208ed..3387843 100644 --- a/ephemeral-postgres-tests/tests/database_provides_working_pool.rs +++ b/ephemeral-postgres-tests/tests/database_provides_working_pool.rs @@ -1,7 +1,8 @@ +use sqlx::Row; + use ephemeral_postgres::cluster::Cluster; use ephemeral_postgres::cluster_params::ClusterParams; use ephemeral_postgres_tests::postgres_test_image::postgres_test_image; -use sqlx::Row; #[tokio::test] async fn database_provides_pool_that_executes_queries() { diff --git a/ephemeral-postgres-tests/tests/main.rs b/ephemeral-postgres-tests/tests/main.rs index 8887129..1573a3c 100644 --- a/ephemeral-postgres-tests/tests/main.rs +++ b/ephemeral-postgres-tests/tests/main.rs @@ -1,3 +1,5 @@ +mod cluster_connect_errors_when_server_unreachable; +mod cluster_connects_to_a_running_server; mod cluster_errors_when_image_invalid; mod cluster_errors_when_readiness_times_out; mod cluster_finalize_errors_when_mapped_port_unavailable; diff --git a/ephemeral-postgres/Cargo.toml b/ephemeral-postgres/Cargo.toml index 6a6d3f7..1f92d23 100644 --- a/ephemeral-postgres/Cargo.toml +++ b/ephemeral-postgres/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ephemeral-postgres" -version = "0.3.1" +version = "0.4.0" edition = "2024" description = "Ephemeral PostgreSQL instances for Rust integration tests, backed by testcontainers (Docker)." license = "Apache-2.0" diff --git a/ephemeral-postgres/README.md b/ephemeral-postgres/README.md index c6e6ff5..7d6fc5c 100644 --- a/ephemeral-postgres/README.md +++ b/ephemeral-postgres/README.md @@ -1,29 +1,20 @@ # ephemeral-postgres -Ephemeral PostgreSQL instances for Rust integration tests, backed by -[testcontainers](https://crates.io/crates/testcontainers) (Docker). - -Each test starts its own PostgreSQL container and carves out isolated databases from it, so -tests never share state. Containers are removed automatically when the cluster is dropped. +Ephemeral PostgreSQL databases for Rust integration tests. ## Requirements -A running Docker daemon. Docker is needed only at **test runtime** to start containers — it is -not required to build the crate or its documentation. +A running Docker daemon. ## Install ```toml [dev-dependencies] -ephemeral-postgres = "0.3" +ephemeral-postgres = "0.4" ``` ## Usage -You choose the image — `ephemeral-postgres` never assumes one. Pass any -postgres-protocol-compatible image (the official `postgres`, `postgis/postgis`, -`timescale/timescaledb`, a private-registry mirror, …) as a name and tag. - ```rust use ephemeral_postgres::cluster::Cluster; use ephemeral_postgres::cluster_params::ClusterParams; @@ -47,42 +38,30 @@ async fn each_test_gets_an_isolated_database() { } ``` -- `Cluster::start(params)` starts one PostgreSQL container from the image you provide. -- `create_database()` / `create_database_with_id(uuid)` create freshly-isolated databases that - share the container. -- `database.pool()` returns an `sqlx::PgPool` connected to that database. - -## Cleanup - -Cleanup is automatic and tied to ownership — you never close a cluster manually: +## Sharing one server across test processes -- The container is stopped and removed as soon as the last `Arc` is dropped. Each - `Database` holds an `Arc`, so the container outlives the databases carved from it and - disappears once the cluster and all of its databases go out of scope at the end of the test. -- Keep the cluster in a local binding for the duration of the test. Never store a `Cluster` or - `Database` in a `static`, `OnceLock`, or `lazy_static`: statics are never dropped, so the - container would leak for the whole lifetime of the test process. -- If the test process is interrupted before those values drop — Ctrl-C, or a test runner killing - it on timeout — a watchdog still stops and removes the containers on `SIGINT`, `SIGTERM`, and - `SIGQUIT`. - -## Configuration - -`ClusterParams::new(image)` waits up to 30 seconds for the server to accept connections. Override -the readiness timeout with struct-update syntax: +```sh +cargo install ephemeral-postgres-run --version 0.4.0 --locked +ephemeral-postgres-run postgres 18 -- cargo nextest run +``` ```rust -use std::time::Duration; +use std::env; use ephemeral_postgres::cluster::Cluster; -use ephemeral_postgres::cluster_params::ClusterParams; -use ephemeral_postgres::postgres_image::PostgresImage; +use ephemeral_postgres::cluster_connection_params::ClusterConnectionParams; + +#[tokio::test] +async fn each_test_gets_an_isolated_database_on_the_shared_server() { + let cluster = Cluster::connect(ClusterConnectionParams::new( + env::var("EPHEMERAL_POSTGRES_URL").unwrap(), + )) + .await + .unwrap(); + let database = cluster.create_database().await.unwrap(); -let cluster = Cluster::start(ClusterParams { - readiness_timeout: Duration::from_secs(60), - ..ClusterParams::new(PostgresImage::new("postgres", "18")) -}) -.await?; + sqlx::query("SELECT 1").execute(database.pool()).await.unwrap(); +} ``` ## License diff --git a/ephemeral-postgres/src/cluster.rs b/ephemeral-postgres/src/cluster.rs index 397e714..5235d49 100644 --- a/ephemeral-postgres/src/cluster.rs +++ b/ephemeral-postgres/src/cluster.rs @@ -10,7 +10,9 @@ use testcontainers_modules::testcontainers::ContainerAsync; use testcontainers_modules::testcontainers::runners::AsyncRunner; use uuid::Uuid; +use crate::cluster_connection_params::ClusterConnectionParams; use crate::cluster_params::ClusterParams; +use crate::cluster_server::ClusterServer; use crate::database::Database; use crate::ephemeral_postgres_error::EphemeralPostgresError; use crate::postgres_container::PostgresContainer; @@ -21,23 +23,29 @@ const POSTGRES_HOST: &str = "127.0.0.1"; pub struct Cluster { admin_pool: PgPool, base_url: String, - container: Arc, + server: ClusterServer, } impl Cluster { - pub async fn start(params: ClusterParams) -> Result { - let ClusterParams { - image, + /// # Errors + /// + /// Fails when the server does not accept connections within the readiness timeout. + pub async fn connect(params: ClusterConnectionParams) -> Result { + let ClusterConnectionParams { + base_url, readiness_timeout, } = params; + let admin_pool = wait_until_postgres_admin_pool_ready( + &format!("{base_url}/postgres"), + readiness_timeout, + ) + .await?; - let container = image - .into_container_request() - .start() - .await - .map_err(|source| EphemeralPostgresError::ContainerStart { source })?; - - Self::from_started_container(container, readiness_timeout).await + Ok(Self { + admin_pool, + base_url, + server: ClusterServer::External, + }) } #[doc(hidden)] @@ -58,14 +66,45 @@ impl Cluster { Ok(Self { admin_pool, base_url, - container: Arc::new(container), + server: ClusterServer::Container(Arc::new(container)), }) } + /// # Errors + /// + /// Fails when the container cannot start, or when the server does not accept connections + /// within the readiness timeout. + pub async fn start(params: ClusterParams) -> Result { + let ClusterParams { + image, + readiness_timeout, + } = params; + + let container = image + .into_container_request() + .start() + .await + .map_err(|source| EphemeralPostgresError::ContainerStart { source })?; + + Self::from_started_container(container, readiness_timeout).await + } + + #[must_use] + pub fn base_url(&self) -> &str { + &self.base_url + } + + /// # Errors + /// + /// Fails when the database cannot be created or connected to. pub async fn create_database(&self) -> Result { self.create_database_with_id(Uuid::new_v4()).await } + /// # Errors + /// + /// Fails when the database cannot be created, including when a database with this id already + /// exists, or connected to. pub async fn create_database_with_id( &self, database_id: Uuid, @@ -100,15 +139,10 @@ impl Cluster { })?; Ok(Database::new( - Arc::clone(&self.container), + self.server.clone(), database_url, db_name, pool, )) } - - #[must_use] - pub fn base_url(&self) -> &str { - &self.base_url - } } diff --git a/ephemeral-postgres/src/cluster_connection_params.rs b/ephemeral-postgres/src/cluster_connection_params.rs new file mode 100644 index 0000000..7ec235d --- /dev/null +++ b/ephemeral-postgres/src/cluster_connection_params.rs @@ -0,0 +1,18 @@ +use std::time::Duration; + +use crate::constants::READINESS_TIMEOUT; + +pub struct ClusterConnectionParams { + pub base_url: String, + pub readiness_timeout: Duration, +} + +impl ClusterConnectionParams { + #[must_use] + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + readiness_timeout: READINESS_TIMEOUT, + } + } +} diff --git a/ephemeral-postgres/src/cluster_params.rs b/ephemeral-postgres/src/cluster_params.rs index fac5784..9333fdf 100644 --- a/ephemeral-postgres/src/cluster_params.rs +++ b/ephemeral-postgres/src/cluster_params.rs @@ -1,9 +1,8 @@ use std::time::Duration; +use crate::constants::READINESS_TIMEOUT; use crate::postgres_image::PostgresImage; -const READINESS_TIMEOUT: Duration = Duration::from_secs(30); - pub struct ClusterParams { pub image: PostgresImage, pub readiness_timeout: Duration, @@ -11,7 +10,7 @@ pub struct ClusterParams { impl ClusterParams { #[must_use] - pub fn new(image: PostgresImage) -> Self { + pub const fn new(image: PostgresImage) -> Self { Self { image, readiness_timeout: READINESS_TIMEOUT, diff --git a/ephemeral-postgres/src/cluster_server.rs b/ephemeral-postgres/src/cluster_server.rs new file mode 100644 index 0000000..ebf5a1f --- /dev/null +++ b/ephemeral-postgres/src/cluster_server.rs @@ -0,0 +1,10 @@ +use std::sync::Arc; + +use crate::postgres_container::PostgresContainer; + +#[doc(hidden)] +#[derive(Clone)] +pub enum ClusterServer { + Container(Arc), + External, +} diff --git a/ephemeral-postgres/src/constants.rs b/ephemeral-postgres/src/constants.rs new file mode 100644 index 0000000..8ff4c10 --- /dev/null +++ b/ephemeral-postgres/src/constants.rs @@ -0,0 +1,3 @@ +use std::time::Duration; + +pub const READINESS_TIMEOUT: Duration = Duration::from_secs(30); diff --git a/ephemeral-postgres/src/database.rs b/ephemeral-postgres/src/database.rs index b719a9c..2c0d0e0 100644 --- a/ephemeral-postgres/src/database.rs +++ b/ephemeral-postgres/src/database.rs @@ -1,16 +1,10 @@ -use std::sync::Arc; - use sqlx::PgPool; -use crate::postgres_container::PostgresContainer; +use crate::cluster_server::ClusterServer; pub struct Database { - #[expect( - dead_code, - reason = "container Arc keeps the postgres container alive while this database is in use" - )] - container: Arc, - database_url: String, + _server: ClusterServer, + url: String, db_name: String, pool: PgPool, } @@ -18,32 +12,32 @@ pub struct Database { impl Database { #[doc(hidden)] #[must_use] - pub fn new( - container: Arc, + pub const fn new( + server: ClusterServer, database_url: String, db_name: String, pool: PgPool, ) -> Self { Self { - container, - database_url, + _server: server, + url: database_url, db_name, pool, } } #[must_use] - pub fn db_name(&self) -> &str { - &self.db_name + pub fn database_url(&self) -> &str { + &self.url } #[must_use] - pub fn database_url(&self) -> &str { - &self.database_url + pub fn db_name(&self) -> &str { + &self.db_name } #[must_use] - pub fn pool(&self) -> &PgPool { + pub const fn pool(&self) -> &PgPool { &self.pool } } diff --git a/ephemeral-postgres/src/ephemeral_postgres_error.rs b/ephemeral-postgres/src/ephemeral_postgres_error.rs index 5a5da18..41ec6d7 100644 --- a/ephemeral-postgres/src/ephemeral_postgres_error.rs +++ b/ephemeral-postgres/src/ephemeral_postgres_error.rs @@ -17,6 +17,11 @@ pub enum EphemeralPostgresError { source: TestcontainersError, }, + #[error( + "readiness timeout {timeout:?} puts the deadline past the latest representable instant" + )] + ReadinessDeadlineOverflow { timeout: Duration }, + #[error("postgres admin pool did not become ready within {timeout:?}")] ReadinessTimeout { timeout: Duration }, diff --git a/ephemeral-postgres/src/lib.rs b/ephemeral-postgres/src/lib.rs index 3362882..e70b622 100644 --- a/ephemeral-postgres/src/lib.rs +++ b/ephemeral-postgres/src/lib.rs @@ -1,5 +1,8 @@ pub mod cluster; +pub mod cluster_connection_params; pub mod cluster_params; +pub mod cluster_server; +mod constants; pub mod database; pub mod ephemeral_postgres_error; pub mod postgres_container; diff --git a/ephemeral-postgres/src/postgres_container.rs b/ephemeral-postgres/src/postgres_container.rs index 6824b1a..22c64d8 100644 --- a/ephemeral-postgres/src/postgres_container.rs +++ b/ephemeral-postgres/src/postgres_container.rs @@ -12,10 +12,13 @@ pub struct PostgresContainer { impl PostgresContainer { #[must_use] - pub fn new(container: ContainerAsync) -> Self { + pub const fn new(container: ContainerAsync) -> Self { Self { container } } + /// # Errors + /// + /// Fails when the container exposes no host port for postgres. pub async fn mapped_host_port(&self) -> Result { self.container .get_host_port_ipv4(POSTGRES_CONTAINER_PORT) diff --git a/ephemeral-postgres/src/wait_until_postgres_admin_pool_ready.rs b/ephemeral-postgres/src/wait_until_postgres_admin_pool_ready.rs index 04450d1..5170d22 100644 --- a/ephemeral-postgres/src/wait_until_postgres_admin_pool_ready.rs +++ b/ephemeral-postgres/src/wait_until_postgres_admin_pool_ready.rs @@ -17,7 +17,9 @@ pub async fn wait_until_postgres_admin_pool_ready( admin_url: &str, timeout: Duration, ) -> Result { - let deadline = Instant::now() + timeout; + let deadline = Instant::now() + .checked_add(timeout) + .ok_or(EphemeralPostgresError::ReadinessDeadlineOverflow { timeout })?; let mut last_connection_outcome = LastConnectionOutcome::NoConnectionErrorYet; loop { @@ -58,9 +60,21 @@ mod tests { use super::ADMIN_POOL_POLL_INTERVAL; use super::wait_until_postgres_admin_pool_ready; + use crate::ephemeral_postgres_error::EphemeralPostgresError; const UNREACHABLE_ADMIN_URL: &str = "postgres://postgres@127.0.0.1:1/postgres"; + #[tokio::test] + async fn rejects_a_timeout_whose_deadline_overflows() { + let result = + wait_until_postgres_admin_pool_ready(UNREACHABLE_ADMIN_URL, Duration::MAX).await; + + assert!(matches!( + result, + Err(EphemeralPostgresError::ReadinessDeadlineOverflow { timeout }) if timeout == Duration::MAX + )); + } + #[tokio::test] async fn times_out_without_a_cause_when_no_connection_is_attempted() { let result = diff --git a/rust-toolchain.toml b/rust-toolchain.toml index fb5449a..38ab2c6 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.93.0" +channel = "1.95.0" components = ["clippy", "rustfmt"]