diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 64fb056..f19db71 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -10,6 +10,20 @@ jobs: rust: name: rust runs-on: ubuntu-latest + env: + EPHEMERAL_POSTGRES_URL: postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable + services: + postgres: + image: postgres:18@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a + env: + POSTGRES_HOST_AUTH_METHOD: trust + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 steps: - name: checkout uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index 07f6d1c..2571579 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -26,61 +17,6 @@ dependencies = [ "libc", ] -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "astral-tokio-tar" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463" -dependencies = [ - "futures-core", - "libc", - "portable-atomic", - "rustc-hash", - "rustix", - "tokio", - "tokio-stream", - "xattr", -] - -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "async-trait" -version = "0.1.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - [[package]] name = "atoi" version = "2.0.0" @@ -90,61 +26,12 @@ dependencies = [ "num-traits", ] -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "sync_wrapper", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", -] - [[package]] name = "base64" version = "0.22.1" @@ -178,89 +65,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "bollard" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" -dependencies = [ - "async-stream", - "base64", - "bitflags", - "bollard-buildkit-proto", - "bollard-stubs", - "bytes", - "futures-core", - "futures-util", - "hex", - "home", - "http", - "http-body-util", - "hyper", - "hyper-named-pipe", - "hyper-rustls", - "hyper-util", - "hyperlocal", - "log", - "num", - "pin-project-lite", - "rand 0.9.5", - "rustls", - "rustls-native-certs", - "rustls-pki-types", - "serde", - "serde_derive", - "serde_json", - "serde_urlencoded", - "thiserror", - "time", - "tokio", - "tokio-stream", - "tokio-util", - "tonic", - "tower-service", - "url", - "winapi", -] - -[[package]] -name = "bollard-buildkit-proto" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" -dependencies = [ - "prost", - "prost-types", - "tonic", - "tonic-prost", - "ureq", -] - -[[package]] -name = "bollard-stubs" -version = "1.52.1-rc.29.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" -dependencies = [ - "base64", - "bollard-buildkit-proto", - "bytes", - "prost", - "serde", - "serde_json", - "serde_repr", - "time", -] - -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -303,7 +107,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -314,7 +118,6 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", - "serde", "windows-link", ] @@ -333,21 +136,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "conquer-once" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d008a441c0f269f36ca13712528069a86a3e60dffee1d98b976eb3b0b2160b4" -dependencies = [ - "conquer-util", -] - -[[package]] -name = "conquer-util" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e763eef8846b13b380f37dfecda401770b0ca4e56e95170237bd7c25c7db3582" - [[package]] name = "core-foundation" version = "0.10.1" @@ -440,49 +228,6 @@ dependencies = [ "cmov", ] -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.119", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "serde_core", -] - [[package]] name = "digest" version = "0.10.7" @@ -515,29 +260,12 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "docker_credential" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" -dependencies = [ - "base64", - "serde", - "serde_json", -] - [[package]] name = "dotenvy" version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - [[package]] name = "either" version = "1.16.0" @@ -549,34 +277,13 @@ dependencies = [ [[package]] name = "ephemeral-postgres" -version = "0.4.0" +version = "0.5.0" dependencies = [ + "percent-encoding", "sqlx", - "testcontainers-modules", - "thiserror", - "tokio", - "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" -dependencies = [ - "ephemeral-postgres", - "sqlx", - "testcontainers-modules", - "tokio", - "uuid", + "url", ] [[package]] @@ -592,7 +299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -602,7 +309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -622,17 +329,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "ferroid" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" -dependencies = [ - "portable-atomic", - "rand 0.10.2", - "web-time", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -650,12 +346,6 @@ dependencies = [ "spin", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.2.0" @@ -686,21 +376,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futures" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.33" @@ -745,17 +420,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" -[[package]] -name = "futures-macro" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "futures-sink" version = "0.3.33" @@ -774,10 +438,8 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ - "futures-channel", "futures-core", "futures-io", - "futures-macro", "futures-sink", "futures-task", "memchr", @@ -795,29 +457,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -826,35 +465,10 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", -] - -[[package]] -name = "h2" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", + "r-efi", + "rand_core", ] -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - [[package]] name = "hashbrown" version = "0.16.1" @@ -912,187 +526,34 @@ dependencies = [ ] [[package]] -name = "home" -version = "0.5.12" +name = "hybrid-array" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ - "windows-sys 0.61.2", + "typenum", ] [[package]] -name = "http" -version = "1.4.2" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ - "bytes", - "itoa", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", ] [[package]] -name = "http-body" -version = "1.1.0" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hybrid-array" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-named-pipe" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" -dependencies = [ - "hex", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "libc", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "hyperlocal" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" -dependencies = [ - "hex", - "http-body-util", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ "cc", ] @@ -1179,12 +640,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -1206,17 +661,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - [[package]] name = "indexmap" version = "2.14.0" @@ -1225,17 +669,6 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", ] [[package]] @@ -1298,12 +731,6 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "md-5" version = "0.11.0" @@ -1320,12 +747,6 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "mio" version = "1.2.2" @@ -1334,7 +755,7 @@ checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1354,75 +775,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1510,57 +862,12 @@ dependencies = [ "windows-link", ] -[[package]] -name = "parse-display" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" -dependencies = [ - "parse-display-derive", - "regex", - "regex-syntax", -] - -[[package]] -name = "parse-display-derive" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" -dependencies = [ - "proc-macro2", - "quote", - "regex", - "regex-syntax", - "structmeta", - "syn 2.0.119", -] - [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1573,12 +880,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "portable-atomic" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" - [[package]] name = "potential_utf" version = "0.1.5" @@ -1588,21 +889,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -1612,38 +898,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "prost" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "prost-types" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" -dependencies = [ - "prost", -] - [[package]] name = "quote" version = "1.0.47" @@ -1653,28 +907,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.2" @@ -1682,27 +920,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", + "getrandom", + "rand_core", ] [[package]] @@ -1720,75 +939,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "ref-cast" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - [[package]] name = "rustix" version = "1.1.4" @@ -1799,53 +949,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" -dependencies = [ - "log", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", + "windows-sys", ] [[package]] @@ -1854,43 +958,13 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - [[package]] name = "schannel" version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", + "windows-sys", ] [[package]] @@ -1958,66 +1032,11 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_repr" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_with" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" -dependencies = [ - "base64", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.119", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", ] [[package]] @@ -2059,26 +1078,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signal-hook" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "slab" version = "0.4.12" @@ -2101,7 +1100,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -2146,7 +1145,7 @@ dependencies = [ "futures-util", "hashbrown 0.16.1", "hashlink", - "indexmap 2.14.0", + "indexmap", "log", "memchr", "native-tls", @@ -2254,7 +1253,7 @@ dependencies = [ "log", "md-5", "memchr", - "rand 0.10.2", + "rand", "serde", "serde_json", "sha2 0.11.0", @@ -2310,41 +1309,6 @@ dependencies = [ "unicode-properties", ] -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "structmeta" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" -dependencies = [ - "proc-macro2", - "quote", - "structmeta-derive", - "syn 2.0.119", -] - -[[package]] -name = "structmeta-derive" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.119" @@ -2367,12 +1331,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - [[package]] name = "synstructure" version = "0.13.2" @@ -2391,52 +1349,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom", "once_cell", "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "testcontainers" -version = "0.27.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" -dependencies = [ - "astral-tokio-tar", - "async-trait", - "bollard", - "bytes", - "conquer-once", - "docker_credential", - "either", - "etcetera", - "ferroid", - "futures", - "http", - "itertools", - "log", - "memchr", - "parse-display", - "pin-project-lite", - "serde", - "serde_json", - "serde_with", - "signal-hook", - "thiserror", - "tokio", - "tokio-stream", - "tokio-util", - "url", -] - -[[package]] -name = "testcontainers-modules" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5985fde5befe4ffa77a052e035e16c2da86e8bae301baa9f9904ad3c494d357" -dependencies = [ - "testcontainers", + "windows-sys", ] [[package]] @@ -2459,36 +1375,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "time" -version = "0.3.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -2524,10 +1410,9 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -2541,16 +1426,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - [[package]] name = "tokio-stream" version = "0.1.19" @@ -2562,91 +1437,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tonic" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" -dependencies = [ - "async-trait", - "axum", - "base64", - "bytes", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "socket2", - "sync_wrapper", - "tokio", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic-prost" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" -dependencies = [ - "bytes", - "prost", - "tonic", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "indexmap 2.14.0", - "pin-project-lite", - "slab", - "sync_wrapper", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - [[package]] name = "tracing" version = "0.1.44" @@ -2679,12 +1469,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "typenum" version = "1.20.1" @@ -2718,39 +1502,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "ureq-proto", - "utf8-zero", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -2761,15 +1512,8 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -2782,9 +1526,7 @@ version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.4.3", "js-sys", - "serde_core", "wasm-bindgen", ] @@ -2800,30 +1542,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -2869,44 +1593,12 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "whoami" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.62.2" @@ -2966,15 +1658,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2984,92 +1667,12 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - [[package]] name = "yoke" version = "0.8.3" @@ -3093,26 +1696,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "zerofrom" version = "0.1.8" @@ -3134,12 +1717,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 9fce807..1c020bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,13 @@ [workspace] resolver = "3" -members = ["ephemeral-postgres", "ephemeral-postgres-run", "ephemeral-postgres-tests"] +members = ["ephemeral-postgres"] [workspace.dependencies] -ephemeral-postgres = { path = "ephemeral-postgres", version = "0.4.0" } -ephemeral-postgres-tests = { path = "ephemeral-postgres-tests" } +percent-encoding = "2.3" 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"] } +tokio = { version = "1.53", features = ["rt"] } +url = "2.5" [workspace.lints.rust] warnings = "deny" @@ -23,9 +21,6 @@ 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" diff --git a/Makefile b/Makefile index 94bf059..2767ed7 100644 --- a/Makefile +++ b/Makefile @@ -26,8 +26,7 @@ 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-run=100 + --gated ephemeral-postgres=100 .PHONY: fmt fmt: diff --git a/ephemeral-postgres-run/Cargo.toml b/ephemeral-postgres-run/Cargo.toml deleted file mode 100644 index b8adb3c..0000000 --- a/ephemeral-postgres-run/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[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 deleted file mode 100644 index 004f85b..0000000 --- a/ephemeral-postgres-run/src/constants.rs +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 48ccf90..0000000 --- a/ephemeral-postgres-run/src/ephemeral_postgres_run_error.rs +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index 29e4306..0000000 --- a/ephemeral-postgres-run/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index d8bb10f..0000000 --- a/ephemeral-postgres-run/src/main.rs +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 59cf194..0000000 --- a/ephemeral-postgres-run/src/run.rs +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 8e1faf4..0000000 --- a/ephemeral-postgres-run/src/run_arguments.rs +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 772b9b6..0000000 --- a/ephemeral-postgres-run/tests/binary_exits_with_the_command_exit_code.rs +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 6b0c0de..0000000 --- a/ephemeral-postgres-run/tests/binary_reports_a_failure_with_its_causes.rs +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index b13c2bf..0000000 --- a/ephemeral-postgres-run/tests/binary_reports_a_usage_error.rs +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index f20a751..0000000 --- a/ephemeral-postgres-run/tests/main.rs +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index 1f5f885..0000000 --- a/ephemeral-postgres-run/tests/run_arguments_reject_a_missing_command.rs +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 9ad21a5..0000000 --- a/ephemeral-postgres-run/tests/run_arguments_reject_a_missing_image_name.rs +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index ec0eda4..0000000 --- a/ephemeral-postgres-run/tests/run_arguments_reject_a_missing_image_tag.rs +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 0e302cf..0000000 --- a/ephemeral-postgres-run/tests/run_arguments_reject_a_missing_separator.rs +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index e689881..0000000 --- a/ephemeral-postgres-run/tests/run_arguments_reject_an_argument_in_place_of_the_separator.rs +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index f0d3700..0000000 --- a/ephemeral-postgres-run/tests/run_reports_a_cluster_that_cannot_start.rs +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 8a5c52f..0000000 --- a/ephemeral-postgres-run/tests/run_reports_a_command_terminated_by_a_signal.rs +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 36616ac..0000000 --- a/ephemeral-postgres-run/tests/run_reports_a_command_that_cannot_be_spawned.rs +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 5670499..0000000 --- a/ephemeral-postgres-run/tests/run_returns_the_command_exit_code_with_the_server_url.rs +++ /dev/null @@ -1,22 +0,0 @@ -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/Cargo.toml b/ephemeral-postgres-tests/Cargo.toml deleted file mode 100644 index e55a548..0000000 --- a/ephemeral-postgres-tests/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "ephemeral-postgres-tests" -version = "0.1.0" -edition = "2024" -license = "Apache-2.0" -publish = false -autotests = false - -[dependencies] -ephemeral-postgres = { workspace = true } - -[dev-dependencies] -sqlx = { workspace = true } -testcontainers-modules = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt"] } -uuid = { workspace = true } - -[lints] -workspace = true - -[[test]] -name = "integration" -path = "tests/main.rs" diff --git a/ephemeral-postgres-tests/src/lib.rs b/ephemeral-postgres-tests/src/lib.rs deleted file mode 100644 index 51bca49..0000000 --- a/ephemeral-postgres-tests/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod postgres_test_image; diff --git a/ephemeral-postgres-tests/src/postgres_test_image.rs b/ephemeral-postgres-tests/src/postgres_test_image.rs deleted file mode 100644 index eaa673a..0000000 --- a/ephemeral-postgres-tests/src/postgres_test_image.rs +++ /dev/null @@ -1,10 +0,0 @@ -use ephemeral_postgres::postgres_image::PostgresImage; - -const POSTGRES_TEST_IMAGE_NAME: &str = "postgres"; -const POSTGRES_TEST_IMAGE_TAG: &str = - "18@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a"; - -#[must_use] -pub fn postgres_test_image() -> PostgresImage { - PostgresImage::new(POSTGRES_TEST_IMAGE_NAME, POSTGRES_TEST_IMAGE_TAG) -} 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 deleted file mode 100644 index 574704f..0000000 --- a/ephemeral-postgres-tests/tests/cluster_connect_errors_when_server_unreachable.rs +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 3646bd4..0000000 --- a/ephemeral-postgres-tests/tests/cluster_connects_to_a_running_server.rs +++ /dev/null @@ -1,26 +0,0 @@ -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_errors_when_image_invalid.rs b/ephemeral-postgres-tests/tests/cluster_errors_when_image_invalid.rs deleted file mode 100644 index 5266119..0000000 --- a/ephemeral-postgres-tests/tests/cluster_errors_when_image_invalid.rs +++ /dev/null @@ -1,18 +0,0 @@ -use ephemeral_postgres::cluster::Cluster; -use ephemeral_postgres::cluster_params::ClusterParams; -use ephemeral_postgres::ephemeral_postgres_error::EphemeralPostgresError; -use ephemeral_postgres::postgres_image::PostgresImage; - -#[tokio::test] -async fn errors_when_image_tag_cannot_be_resolved() { - let result = Cluster::start(ClusterParams::new(PostgresImage::new( - "postgres", - "tag-that-does-not-exist-9999", - ))) - .await; - - assert!(matches!( - result, - Err(EphemeralPostgresError::ContainerStart { .. }) - )); -} diff --git a/ephemeral-postgres-tests/tests/cluster_errors_when_readiness_times_out.rs b/ephemeral-postgres-tests/tests/cluster_errors_when_readiness_times_out.rs deleted file mode 100644 index 0f641d6..0000000 --- a/ephemeral-postgres-tests/tests/cluster_errors_when_readiness_times_out.rs +++ /dev/null @@ -1,20 +0,0 @@ -use std::time::Duration; - -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; - -#[tokio::test] -async fn errors_when_readiness_timeout_is_too_short() { - let result = Cluster::start(ClusterParams { - readiness_timeout: Duration::ZERO, - ..ClusterParams::new(postgres_test_image()) - }) - .await; - - assert!(matches!( - result, - Err(EphemeralPostgresError::ReadinessTimeout { .. }) - )); -} 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 deleted file mode 100644 index 02bf079..0000000 --- a/ephemeral-postgres-tests/tests/cluster_finalize_errors_when_mapped_port_unavailable.rs +++ /dev/null @@ -1,31 +0,0 @@ -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; - -#[tokio::test] -async fn finalize_errors_when_mapped_port_unavailable() { - let ClusterParams { - image, - readiness_timeout, - } = ClusterParams::new(postgres_test_image()); - - let container = image - .into_container_request() - .start() - .await - .expect("postgres container should start"); - container - .stop_with_timeout(Some(0)) - .await - .expect("postgres container should stop before finalizing the cluster"); - - let result = Cluster::from_started_container(container, readiness_timeout).await; - - assert!(matches!( - result, - Err(EphemeralPostgresError::MappedPort { .. }) - )); -} diff --git a/ephemeral-postgres-tests/tests/cluster_open_database_pool_errors_when_database_absent.rs b/ephemeral-postgres-tests/tests/cluster_open_database_pool_errors_when_database_absent.rs deleted file mode 100644 index 89c0395..0000000 --- a/ephemeral-postgres-tests/tests/cluster_open_database_pool_errors_when_database_absent.rs +++ /dev/null @@ -1,18 +0,0 @@ -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; - -#[tokio::test] -async fn open_database_pool_errors_when_database_does_not_exist() { - let cluster = Cluster::start(ClusterParams::new(postgres_test_image())) - .await - .unwrap(); - - let result = cluster.open_database_pool("test_absent".to_owned()).await; - - assert!(matches!( - result, - Err(EphemeralPostgresError::OpenPool { .. }) - )); -} 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 deleted file mode 100644 index d93e8d2..0000000 --- a/ephemeral-postgres-tests/tests/database_create_with_id_errors_on_duplicate.rs +++ /dev/null @@ -1,24 +0,0 @@ -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; - -#[tokio::test] -async fn create_with_id_errors_on_duplicate() { - let cluster = Cluster::start(ClusterParams::new(postgres_test_image())) - .await - .unwrap(); - let database_id = Uuid::new_v4(); - - let first = cluster.create_database_with_id(database_id).await.unwrap(); - assert_eq!(first.db_name(), format!("test_{}", database_id.simple())); - - let result = cluster.create_database_with_id(database_id).await; - - assert!(matches!( - result, - Err(EphemeralPostgresError::CreateDatabase { .. }) - )); -} diff --git a/ephemeral-postgres-tests/tests/database_exposes_db_name_and_url.rs b/ephemeral-postgres-tests/tests/database_exposes_db_name_and_url.rs deleted file mode 100644 index 4bda7b0..0000000 --- a/ephemeral-postgres-tests/tests/database_exposes_db_name_and_url.rs +++ /dev/null @@ -1,18 +0,0 @@ -use ephemeral_postgres::cluster::Cluster; -use ephemeral_postgres::cluster_params::ClusterParams; -use ephemeral_postgres_tests::postgres_test_image::postgres_test_image; - -#[tokio::test] -async fn db_name_is_unique_hex_and_database_url_extends_cluster_base_url() { - let cluster = Cluster::start(ClusterParams::new(postgres_test_image())) - .await - .unwrap(); - let database = cluster.create_database().await.unwrap(); - - let db_name = database.db_name(); - assert!(db_name.starts_with("test_")); - assert!(db_name.len() > "test_".len()); - - let expected_url = format!("{}/{}", cluster.base_url(), db_name); - assert_eq!(database.database_url(), expected_url); -} diff --git a/ephemeral-postgres-tests/tests/database_isolates_state.rs b/ephemeral-postgres-tests/tests/database_isolates_state.rs deleted file mode 100644 index be056b7..0000000 --- a/ephemeral-postgres-tests/tests/database_isolates_state.rs +++ /dev/null @@ -1,42 +0,0 @@ -use sqlx::Row; - -use ephemeral_postgres::cluster::Cluster; -use ephemeral_postgres::cluster_params::ClusterParams; -use ephemeral_postgres_tests::postgres_test_image::postgres_test_image; - -#[tokio::test] -async fn database_isolates_state_from_a_separate_database() { - let cluster = Cluster::start(ClusterParams::new(postgres_test_image())) - .await - .unwrap(); - let first = cluster.create_database().await.unwrap(); - let second = cluster.create_database().await.unwrap(); - - sqlx::query("CREATE TABLE isolation_check (value INT)") - .execute(first.pool()) - .await - .unwrap(); - sqlx::query("INSERT INTO isolation_check (value) VALUES (1)") - .execute(first.pool()) - .await - .unwrap(); - - let stored_value: i32 = sqlx::query("SELECT value FROM isolation_check") - .fetch_one(first.pool()) - .await - .unwrap() - .get("value"); - assert_eq!(stored_value, 1); - - let lookup_in_second_database = sqlx::query("SELECT value FROM isolation_check") - .fetch_one(second.pool()) - .await; - - let error = lookup_in_second_database.expect_err( - "table created in one ephemeral database must not be visible to a different database", - ); - let sqlx::Error::Database(database_error) = error else { - panic!("expected sqlx::Error::Database, got: {error:#?}"); - }; - assert_eq!(database_error.code().as_deref(), Some("42P01")); -} diff --git a/ephemeral-postgres-tests/tests/database_provides_working_pool.rs b/ephemeral-postgres-tests/tests/database_provides_working_pool.rs deleted file mode 100644 index 3387843..0000000 --- a/ephemeral-postgres-tests/tests/database_provides_working_pool.rs +++ /dev/null @@ -1,21 +0,0 @@ -use sqlx::Row; - -use ephemeral_postgres::cluster::Cluster; -use ephemeral_postgres::cluster_params::ClusterParams; -use ephemeral_postgres_tests::postgres_test_image::postgres_test_image; - -#[tokio::test] -async fn database_provides_pool_that_executes_queries() { - let cluster = Cluster::start(ClusterParams::new(postgres_test_image())) - .await - .unwrap(); - let database = cluster.create_database().await.unwrap(); - - let result: i32 = sqlx::query("SELECT 1::int AS value") - .fetch_one(database.pool()) - .await - .unwrap() - .get("value"); - - assert_eq!(result, 1); -} diff --git a/ephemeral-postgres-tests/tests/main.rs b/ephemeral-postgres-tests/tests/main.rs deleted file mode 100644 index 1573a3c..0000000 --- a/ephemeral-postgres-tests/tests/main.rs +++ /dev/null @@ -1,10 +0,0 @@ -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; -mod cluster_open_database_pool_errors_when_database_absent; -mod database_create_with_id_errors_on_duplicate; -mod database_exposes_db_name_and_url; -mod database_isolates_state; -mod database_provides_working_pool; diff --git a/ephemeral-postgres/Cargo.toml b/ephemeral-postgres/Cargo.toml index 1f92d23..4b40628 100644 --- a/ephemeral-postgres/Cargo.toml +++ b/ephemeral-postgres/Cargo.toml @@ -1,24 +1,29 @@ [package] name = "ephemeral-postgres" -version = "0.4.0" +version = "0.5.0" edition = "2024" -description = "Ephemeral PostgreSQL instances for Rust integration tests, backed by testcontainers (Docker)." +description = "Isolated, self-cleaning PostgreSQL databases for Rust integration tests, on a server that the environment provides." license = "Apache-2.0" repository = "https://github.com/intentee/ephemeral-postgres" documentation = "https://docs.rs/ephemeral-postgres" readme = "README.md" -keywords = ["postgres", "postgresql", "testing", "testcontainers", "sqlx"] +keywords = ["postgres", "postgresql", "testing", "sqlx"] categories = ["development-tools::testing", "database"] +autotests = false [dependencies] +percent-encoding = { workspace = true } sqlx = { workspace = true } -testcontainers-modules = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } -uuid = { workspace = true } +url = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt"] } [lints] workspace = true + +[[test]] +name = "integration" +path = "tests/main.rs" diff --git a/ephemeral-postgres/README.md b/ephemeral-postgres/README.md index 7d6fc5c..3d492bd 100644 --- a/ephemeral-postgres/README.md +++ b/ephemeral-postgres/README.md @@ -4,61 +4,28 @@ Ephemeral PostgreSQL databases for Rust integration tests. ## Requirements -A running Docker daemon. +A PostgreSQL 15 or newer server provided by your environment, and its URL in +`EPHEMERAL_POSTGRES_URL`: + +```sh +export EPHEMERAL_POSTGRES_URL='postgres://postgres@127.0.0.1:5432/postgres?sslmode=disable' +``` ## Install ```toml [dev-dependencies] -ephemeral-postgres = "0.4" +ephemeral-postgres = "0.5" ``` ## Usage ```rust -use ephemeral_postgres::cluster::Cluster; -use ephemeral_postgres::cluster_params::ClusterParams; -use ephemeral_postgres::postgres_image::PostgresImage; -use sqlx::Row; - -#[tokio::test] -async fn each_test_gets_an_isolated_database() { - let cluster = Cluster::start(ClusterParams::new(PostgresImage::new("postgres", "18"))) - .await - .unwrap(); - let database = cluster.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); -} -``` - -## Sharing one server across test processes - -```sh -cargo install ephemeral-postgres-run --version 0.4.0 --locked -ephemeral-postgres-run postgres 18 -- cargo nextest run -``` - -```rust -use std::env; - -use ephemeral_postgres::cluster::Cluster; -use ephemeral_postgres::cluster_connection_params::ClusterConnectionParams; +use ephemeral_postgres::ephemeral_database::EphemeralDatabase; #[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(); +async fn each_test_gets_its_own_database() { + let database = EphemeralDatabase::create().await.unwrap(); sqlx::query("SELECT 1").execute(database.pool()).await.unwrap(); } diff --git a/ephemeral-postgres/src/cluster.rs b/ephemeral-postgres/src/cluster.rs deleted file mode 100644 index 5235d49..0000000 --- a/ephemeral-postgres/src/cluster.rs +++ /dev/null @@ -1,148 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use sqlx::AssertSqlSafe; -use sqlx::PgPool; -use sqlx::postgres::PgPoolOptions; -use sqlx::query; -use testcontainers_modules::postgres::Postgres; -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; -use crate::wait_until_postgres_admin_pool_ready::wait_until_postgres_admin_pool_ready; - -const POSTGRES_HOST: &str = "127.0.0.1"; - -pub struct Cluster { - admin_pool: PgPool, - base_url: String, - server: ClusterServer, -} - -impl Cluster { - /// # 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?; - - Ok(Self { - admin_pool, - base_url, - server: ClusterServer::External, - }) - } - - #[doc(hidden)] - pub async fn from_started_container( - container: ContainerAsync, - readiness_timeout: Duration, - ) -> Result { - let container = PostgresContainer::new(container); - let port = container.mapped_host_port().await?; - - let base_url = format!("postgres://postgres@{POSTGRES_HOST}:{port}"); - let admin_pool = wait_until_postgres_admin_pool_ready( - &format!("{base_url}/postgres"), - readiness_timeout, - ) - .await?; - - Ok(Self { - admin_pool, - base_url, - 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, - ) -> Result { - let db_name = format!("test_{}", database_id.simple()); - - query(AssertSqlSafe(format!("CREATE DATABASE \"{db_name}\""))) - .execute(&self.admin_pool) - .await - .map_err(|source| EphemeralPostgresError::CreateDatabase { - db_name: db_name.clone(), - source, - })?; - - self.open_database_pool(db_name).await - } - - #[doc(hidden)] - pub async fn open_database_pool( - &self, - db_name: String, - ) -> Result { - let database_url = format!("{}/{db_name}", self.base_url); - - let pool = PgPoolOptions::new() - .max_connections(5) - .connect(&database_url) - .await - .map_err(|source| EphemeralPostgresError::OpenPool { - db_name: db_name.clone(), - source, - })?; - - Ok(Database::new( - self.server.clone(), - database_url, - db_name, - pool, - )) - } -} diff --git a/ephemeral-postgres/src/cluster_connection_params.rs b/ephemeral-postgres/src/cluster_connection_params.rs deleted file mode 100644 index 7ec235d..0000000 --- a/ephemeral-postgres/src/cluster_connection_params.rs +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 9333fdf..0000000 --- a/ephemeral-postgres/src/cluster_params.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::time::Duration; - -use crate::constants::READINESS_TIMEOUT; -use crate::postgres_image::PostgresImage; - -pub struct ClusterParams { - pub image: PostgresImage, - pub readiness_timeout: Duration, -} - -impl ClusterParams { - #[must_use] - 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 deleted file mode 100644 index ebf5a1f..0000000 --- a/ephemeral-postgres/src/cluster_server.rs +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 8ff4c10..0000000 --- a/ephemeral-postgres/src/constants.rs +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 2c0d0e0..0000000 --- a/ephemeral-postgres/src/database.rs +++ /dev/null @@ -1,43 +0,0 @@ -use sqlx::PgPool; - -use crate::cluster_server::ClusterServer; - -pub struct Database { - _server: ClusterServer, - url: String, - db_name: String, - pool: PgPool, -} - -impl Database { - #[doc(hidden)] - #[must_use] - pub const fn new( - server: ClusterServer, - database_url: String, - db_name: String, - pool: PgPool, - ) -> Self { - Self { - _server: server, - url: database_url, - db_name, - pool, - } - } - - #[must_use] - pub fn database_url(&self) -> &str { - &self.url - } - - #[must_use] - pub fn db_name(&self) -> &str { - &self.db_name - } - - #[must_use] - pub const fn pool(&self) -> &PgPool { - &self.pool - } -} diff --git a/ephemeral-postgres/src/database_exists.rs b/ephemeral-postgres/src/database_exists.rs new file mode 100644 index 0000000..c1b70e0 --- /dev/null +++ b/ephemeral-postgres/src/database_exists.rs @@ -0,0 +1,10 @@ +use sqlx::PgPool; +use sqlx::query_scalar; + +pub async fn database_exists(pool: &PgPool, database_name: &str) -> bool { + query_scalar("SELECT EXISTS (SELECT FROM pg_database WHERE datname = $1)") + .bind(database_name) + .fetch_one(pool) + .await + .unwrap() +} diff --git a/ephemeral-postgres/src/database_identity.rs b/ephemeral-postgres/src/database_identity.rs new file mode 100644 index 0000000..2460a1f --- /dev/null +++ b/ephemeral-postgres/src/database_identity.rs @@ -0,0 +1,94 @@ +use sqlx::AssertSqlSafe; +use sqlx::Connection; +use sqlx::PgConnection; +use sqlx::postgres::PgConnectOptions; +use sqlx::query; +use sqlx::raw_sql; +use tokio::runtime::Builder; + +use crate::ephemeral_postgres_error::EphemeralPostgresError; +use crate::lease_stage::LeaseStage; + +const NAME_PREFIX: &str = "ephemeral_postgres_"; + +pub struct DatabaseIdentity { + pub oid: i32, +} + +impl DatabaseIdentity { + pub async fn create( + &self, + connection: &mut PgConnection, + ) -> Result<(), EphemeralPostgresError> { + raw_sql(AssertSqlSafe(format!( + "CREATE DATABASE \"{}\" OID {} TEMPLATE template0", + self.name(), + self.oid + ))) + .execute(connection) + .await + .map_err(|source| EphemeralPostgresError::CreateDatabase { + database_name: self.name(), + source, + })?; + + Ok(()) + } + + pub async fn drop_database(&self, connection: &mut PgConnection) -> Result<(), sqlx::Error> { + raw_sql(AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS \"{}\" WITH (FORCE)", + self.name() + ))) + .execute(connection) + .await?; + + Ok(()) + } + + #[must_use] + pub fn name(&self) -> String { + format!("{NAME_PREFIX}{}", self.oid) + } + + pub fn release_in_own_runtime(&self, admin_options: &PgConnectOptions, stage: &LeaseStage) { + if let Err(error) = Builder::new_current_thread() + .enable_all() + .build() + .map_err(EphemeralPostgresError::CleanupRuntime) + .and_then(|runtime| runtime.block_on(self.release(admin_options, stage))) + { + eprintln!("ephemeral-postgres: {error:?}"); + } + } + + async fn release( + &self, + admin_options: &PgConnectOptions, + stage: &LeaseStage, + ) -> Result<(), EphemeralPostgresError> { + let mut connection = PgConnection::connect_with(admin_options) + .await + .map_err(|source| EphemeralPostgresError::AdminConnection { source })?; + + if matches!(stage, LeaseStage::Created) { + self.drop_database(&mut connection) + .await + .map_err(|source| EphemeralPostgresError::DropDatabase { + database_name: self.name(), + source, + })?; + } + + query("DELETE FROM ephemeral_postgres.databases WHERE database_oid = $1") + .bind(self.oid) + .execute(&mut connection) + .await + .map_err(|source| EphemeralPostgresError::UnregisterDatabase { + database_name: self.name(), + source, + })?; + + Ok(()) + } +} diff --git a/ephemeral-postgres/src/database_lease.rs b/ephemeral-postgres/src/database_lease.rs new file mode 100644 index 0000000..ea4231c --- /dev/null +++ b/ephemeral-postgres/src/database_lease.rs @@ -0,0 +1,455 @@ +use std::thread; + +use sqlx::Connection; +use sqlx::PgConnection; +use sqlx::PgPool; +use sqlx::postgres::PgConnectOptions; +use sqlx::postgres::PgPoolOptions; + +use crate::database_identity::DatabaseIdentity; +use crate::database_registry::DatabaseRegistry; +use crate::ephemeral_postgres_error::EphemeralPostgresError; +use crate::lease_stage::LeaseStage; + +pub struct DatabaseLease { + pub admin_options: PgConnectOptions, + pub connection: PgConnection, + pub identity: DatabaseIdentity, + pub stage: LeaseStage, +} + +impl DatabaseLease { + pub async fn acquire(admin_options: PgConnectOptions) -> Result { + let mut connection = PgConnection::connect_with( + &admin_options + .clone() + .options([("idle_session_timeout", "0")]), + ) + .await + .map_err(|source| EphemeralPostgresError::AdminConnection { source })?; + let mut registry = DatabaseRegistry { + connection: &mut connection, + }; + + registry.prepare().await?; + registry.reclaim_orphans().await?; + + registry.reserve().await.map(|identity| Self { + admin_options, + connection, + identity, + stage: LeaseStage::Reserved, + }) + } + + pub async fn create_with_pool(&mut self) -> Result { + self.identity.create(&mut self.connection).await?; + self.stage = LeaseStage::Created; + + self.open_pool().await + } + + #[must_use] + pub fn database_options(&self) -> PgConnectOptions { + self.admin_options.clone().database(&self.identity.name()) + } + + pub async fn open_pool(&self) -> Result { + PgPoolOptions::new() + .max_connections(5) + .connect_with(self.database_options()) + .await + .map_err(|source| EphemeralPostgresError::OpenPool { + database_name: self.identity.name(), + source, + }) + } +} + +impl Drop for DatabaseLease { + fn drop(&mut self) { + let Self { + admin_options, + identity, + stage, + .. + } = self; + + thread::scope(|scope| { + scope.spawn(|| identity.release_in_own_runtime(admin_options, stage)); + }); + } +} + +#[cfg(test)] +mod tests { + use sqlx::AssertSqlSafe; + use sqlx::Connection; + use sqlx::PgConnection; + use sqlx::error::DatabaseError; + use sqlx::query; + use sqlx::query_scalar; + use sqlx::raw_sql; + + use super::DatabaseLease; + use crate::database_exists::database_exists; + use crate::database_registry::DatabaseRegistry; + use crate::ephemeral_postgres_error::EphemeralPostgresError; + use crate::held_advisory_lock::HeldAdvisoryLock; + use crate::server_environment::ServerEnvironment; + + async fn registry_host() -> DatabaseLease { + let mut host = DatabaseLease::acquire( + ServerEnvironment::current() + .server_connect_options() + .unwrap(), + ) + .await + .unwrap(); + + host.create_with_pool().await.unwrap(); + + host + } + + #[tokio::test] + async fn rejects_a_registry_it_cannot_prepare() { + let host = registry_host().await; + + raw_sql(AssertSqlSafe(format!( + "ALTER DATABASE \"{}\" SET default_transaction_read_only = on", + host.identity.name() + ))) + .execute(&host.open_pool().await.unwrap()) + .await + .unwrap(); + + assert!(matches!( + DatabaseLease::acquire(host.database_options()).await, + Err(EphemeralPostgresError::PrepareRegistry { source }) + if source.as_database_error().and_then(DatabaseError::code).as_deref() == Some("25006") + )); + } + + #[tokio::test] + async fn rejects_a_registry_it_cannot_read() { + let host = registry_host().await; + + raw_sql( + "CREATE SCHEMA ephemeral_postgres; \ + CREATE TABLE ephemeral_postgres.databases (unrelated integer)", + ) + .execute(&host.open_pool().await.unwrap()) + .await + .unwrap(); + + assert!(matches!( + DatabaseLease::acquire(host.database_options()).await, + Err(EphemeralPostgresError::ReclaimOrphans { source }) + if source.as_database_error().and_then(DatabaseError::code).as_deref() == Some("42703") + )); + } + + #[tokio::test] + async fn rejects_a_registry_that_refuses_new_rows() { + let host = registry_host().await; + + raw_sql( + "CREATE SCHEMA ephemeral_postgres; \ + CREATE TABLE ephemeral_postgres.databases \ + (database_oid integer PRIMARY KEY, blocker integer NOT NULL)", + ) + .execute(&host.open_pool().await.unwrap()) + .await + .unwrap(); + + assert!(matches!( + DatabaseLease::acquire(host.database_options()).await, + Err(EphemeralPostgresError::ReserveDatabase { source }) + if source.as_database_error().and_then(DatabaseError::code).as_deref() == Some("23502") + )); + } + + #[tokio::test] + async fn rejects_a_pool_against_a_database_it_has_not_created() { + let host = registry_host().await; + let lease = DatabaseLease::acquire(host.database_options()) + .await + .unwrap(); + + assert!(matches!( + lease.open_pool().await, + Err(EphemeralPostgresError::OpenPool { source, .. }) + if source.as_database_error().and_then(DatabaseError::code).as_deref() == Some("3D000") + )); + } + + #[tokio::test] + async fn rejects_a_taken_name_and_leaves_the_database_that_holds_it() { + let host = registry_host().await; + let host_pool = host.open_pool().await.unwrap(); + let mut lease = DatabaseLease::acquire(host.database_options()) + .await + .unwrap(); + let database_name = lease.identity.name(); + + raw_sql(AssertSqlSafe(format!( + "CREATE DATABASE \"{database_name}\"" + ))) + .execute(&host_pool) + .await + .unwrap(); + + assert!(matches!( + lease.create_with_pool().await, + Err(EphemeralPostgresError::CreateDatabase { database_name: rejected, source }) + if rejected == database_name + && source.as_database_error().and_then(DatabaseError::code).as_deref() == Some("42P04") + )); + + drop(lease); + + assert!(database_exists(&host_pool, &database_name).await); + + raw_sql(AssertSqlSafe(format!("DROP DATABASE \"{database_name}\""))) + .execute(&host_pool) + .await + .unwrap(); + } + + #[tokio::test] + async fn reclaims_a_database_whose_owner_session_ended() { + let host = registry_host().await; + let host_pool = host.open_pool().await.unwrap(); + let mut lease = DatabaseLease::acquire(host.database_options()) + .await + .unwrap(); + + lease.create_with_pool().await.unwrap(); + + let database_name = lease.identity.name(); + let lease_backend: i32 = query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut lease.connection) + .await + .unwrap(); + let lease_lock = HeldAdvisoryLock::held_by(&host_pool, lease_backend).await; + let mut registry_connection = PgConnection::connect_with(&host.database_options()) + .await + .unwrap(); + + query("SELECT pg_terminate_backend($1)") + .bind(lease_backend) + .execute(&host_pool) + .await + .unwrap(); + lease_lock + .wait_until_released(&mut registry_connection) + .await; + DatabaseRegistry { + connection: &mut registry_connection, + } + .reclaim_orphans() + .await + .unwrap(); + + assert!(!database_exists(&host_pool, &database_name).await); + } + + #[tokio::test] + async fn keeps_a_database_whose_oid_differs_from_the_registered_one() { + let host = registry_host().await; + let host_pool = host.open_pool().await.unwrap(); + let mut owner_connection = PgConnection::connect_with(&host.database_options()) + .await + .unwrap(); + let owner_backend: i32 = query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut owner_connection) + .await + .unwrap(); + let mut owner_registry = DatabaseRegistry { + connection: &mut owner_connection, + }; + + owner_registry.prepare().await.unwrap(); + + let database_name = owner_registry.reserve().await.unwrap().name(); + let owner_lock = HeldAdvisoryLock::held_by(&host_pool, owner_backend).await; + let mut registry_connection = PgConnection::connect_with(&host.database_options()) + .await + .unwrap(); + + drop(owner_connection); + owner_lock + .wait_until_released(&mut registry_connection) + .await; + raw_sql(AssertSqlSafe(format!( + "CREATE DATABASE \"{database_name}\"" + ))) + .execute(&host_pool) + .await + .unwrap(); + DatabaseRegistry { + connection: &mut registry_connection, + } + .reclaim_orphans() + .await + .unwrap(); + + assert!(database_exists(&host_pool, &database_name).await); + + raw_sql(AssertSqlSafe(format!("DROP DATABASE \"{database_name}\""))) + .execute(&host_pool) + .await + .unwrap(); + } + + #[tokio::test] + async fn rejects_reclaiming_the_database_it_is_connected_to() { + let host = registry_host().await; + let mut registry_connection = PgConnection::connect_with(&host.database_options()) + .await + .unwrap(); + let mut registry = DatabaseRegistry { + connection: &mut registry_connection, + }; + + registry.prepare().await.unwrap(); + query("INSERT INTO ephemeral_postgres.databases (database_oid) VALUES ($1)") + .bind(host.identity.oid) + .execute(&mut *registry.connection) + .await + .unwrap(); + + assert!(matches!( + registry.reclaim_orphans().await, + Err(EphemeralPostgresError::ReclaimOrphans { source }) + if source.as_database_error().and_then(DatabaseError::code).as_deref() == Some("55006") + )); + } + + #[tokio::test] + async fn rejects_an_orphan_it_cannot_unregister() { + let host = registry_host().await; + let mut registry_connection = PgConnection::connect_with(&host.database_options()) + .await + .unwrap(); + let mut registry = DatabaseRegistry { + connection: &mut registry_connection, + }; + + registry.prepare().await.unwrap(); + raw_sql( + "CREATE FUNCTION refuse_unregistering() RETURNS trigger LANGUAGE plpgsql \ + AS $$ BEGIN RAISE EXCEPTION 'refused'; END $$; \ + CREATE TRIGGER refuse_unregistering BEFORE DELETE ON ephemeral_postgres.databases \ + FOR EACH ROW EXECUTE FUNCTION refuse_unregistering(); \ + INSERT INTO ephemeral_postgres.databases (database_oid) VALUES (16384)", + ) + .execute(&mut *registry.connection) + .await + .unwrap(); + + assert!(matches!( + registry.reclaim_orphans().await, + Err(EphemeralPostgresError::ReclaimOrphans { source }) + if source.as_database_error().and_then(DatabaseError::code).as_deref() == Some("P0001") + )); + } + + #[tokio::test] + async fn leaves_its_database_to_the_next_reclaim_when_cleanup_cannot_connect() { + let host = registry_host().await; + let host_pool = host.open_pool().await.unwrap(); + let mut lease = DatabaseLease::acquire(host.database_options()) + .await + .unwrap(); + + lease.create_with_pool().await.unwrap(); + + let database_name = lease.identity.name(); + let lease_backend: i32 = query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut lease.connection) + .await + .unwrap(); + let lease_lock = HeldAdvisoryLock::held_by(&host_pool, lease_backend).await; + let mut registry_connection = PgConnection::connect_with(&host.database_options()) + .await + .unwrap(); + + lease.admin_options = host.database_options().port(1); + drop(lease); + lease_lock + .wait_until_released(&mut registry_connection) + .await; + + assert!(database_exists(&host_pool, &database_name).await); + + DatabaseRegistry { + connection: &mut registry_connection, + } + .reclaim_orphans() + .await + .unwrap(); + + assert!(!database_exists(&host_pool, &database_name).await); + } + + #[tokio::test] + async fn leaves_its_database_to_the_next_reclaim_when_cleanup_cannot_drop_it() { + let host = registry_host().await; + let host_pool = host.open_pool().await.unwrap(); + let mut lease = DatabaseLease::acquire(host.database_options()) + .await + .unwrap(); + + lease.create_with_pool().await.unwrap(); + + let database_name = lease.identity.name(); + let lease_backend: i32 = query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut lease.connection) + .await + .unwrap(); + let lease_lock = HeldAdvisoryLock::held_by(&host_pool, lease_backend).await; + let mut registry_connection = PgConnection::connect_with(&host.database_options()) + .await + .unwrap(); + + lease.admin_options = lease.database_options(); + drop(lease); + lease_lock + .wait_until_released(&mut registry_connection) + .await; + + assert!(database_exists(&host_pool, &database_name).await); + + DatabaseRegistry { + connection: &mut registry_connection, + } + .reclaim_orphans() + .await + .unwrap(); + + assert!(!database_exists(&host_pool, &database_name).await); + } + + #[tokio::test] + async fn removes_its_database_even_when_it_cannot_unregister_it() { + let host = registry_host().await; + let host_pool = host.open_pool().await.unwrap(); + let mut lease = DatabaseLease::acquire(host.database_options()) + .await + .unwrap(); + + lease.create_with_pool().await.unwrap(); + + let database_name = lease.identity.name(); + + query("DROP TABLE ephemeral_postgres.databases") + .execute(&host_pool) + .await + .unwrap(); + drop(lease); + + assert!(!database_exists(&host_pool, &database_name).await); + } +} diff --git a/ephemeral-postgres/src/database_registry.rs b/ephemeral-postgres/src/database_registry.rs new file mode 100644 index 0000000..9a32d13 --- /dev/null +++ b/ephemeral-postgres/src/database_registry.rs @@ -0,0 +1,93 @@ +use sqlx::AssertSqlSafe; +use sqlx::PgConnection; +use sqlx::query; +use sqlx::query_as; +use sqlx::query_scalar; +use sqlx::raw_sql; + +use crate::database_identity::DatabaseIdentity; +use crate::ephemeral_postgres_error::EphemeralPostgresError; +use crate::orphaned_database::OrphanedDatabase; + +const FIRST_ASSIGNABLE_OID: i32 = 16384; +const LOCK_NAMESPACE: i32 = i32::from_be_bytes(*b"epgr"); +const SETUP_LOCK: i32 = 0; + +pub struct DatabaseRegistry<'connection> { + pub connection: &'connection mut PgConnection, +} + +impl DatabaseRegistry<'_> { + pub async fn prepare(&mut self) -> Result<(), EphemeralPostgresError> { + raw_sql(AssertSqlSafe(format!( + "SELECT pg_advisory_xact_lock({LOCK_NAMESPACE}, {SETUP_LOCK}); \ + CREATE SCHEMA IF NOT EXISTS ephemeral_postgres; \ + CREATE TABLE IF NOT EXISTS ephemeral_postgres.databases (database_oid integer PRIMARY KEY)" + ))) + .execute(&mut *self.connection) + .await + .map_err(|source| EphemeralPostgresError::PrepareRegistry { source })?; + + Ok(()) + } + + pub async fn reclaim_orphans(&mut self) -> Result<(), EphemeralPostgresError> { + let orphans: Vec = query_as( + "WITH abandoned AS MATERIALIZED \ + (SELECT database_oid FROM ephemeral_postgres.databases \ + WHERE pg_try_advisory_lock($1, database_oid)) \ + SELECT abandoned.database_oid, catalog.datname AS database_name \ + FROM abandoned \ + LEFT JOIN pg_database AS catalog ON catalog.oid = abandoned.database_oid::oid", + ) + .bind(LOCK_NAMESPACE) + .fetch_all(&mut *self.connection) + .await + .map_err(|source| EphemeralPostgresError::ReclaimOrphans { source })?; + + for OrphanedDatabase { + database_name, + database_oid, + } in orphans + { + let identity = DatabaseIdentity { oid: database_oid }; + + if database_name == Some(identity.name()) { + identity + .drop_database(self.connection) + .await + .map_err(|source| EphemeralPostgresError::ReclaimOrphans { source })?; + } + + query( + "WITH unregistered AS \ + (DELETE FROM ephemeral_postgres.databases WHERE database_oid = $2 RETURNING database_oid) \ + SELECT pg_advisory_unlock($1, database_oid) FROM unregistered", + ) + .bind(LOCK_NAMESPACE) + .bind(database_oid) + .execute(&mut *self.connection) + .await + .map_err(|source| EphemeralPostgresError::ReclaimOrphans { source })?; + } + + Ok(()) + } + + pub async fn reserve(&mut self) -> Result { + query_scalar( + "WITH reserved AS \ + (INSERT INTO ephemeral_postgres.databases (database_oid) \ + VALUES (($1::float8 + floor(random() * ($2::float8 - $1::float8 + 1)))::integer) \ + RETURNING database_oid) \ + SELECT database_oid FROM reserved WHERE pg_try_advisory_lock($3, database_oid)", + ) + .bind(FIRST_ASSIGNABLE_OID) + .bind(i32::MAX) + .bind(LOCK_NAMESPACE) + .fetch_one(&mut *self.connection) + .await + .map(|oid| DatabaseIdentity { oid }) + .map_err(|source| EphemeralPostgresError::ReserveDatabase { source }) + } +} diff --git a/ephemeral-postgres/src/ephemeral_database.rs b/ephemeral-postgres/src/ephemeral_database.rs new file mode 100644 index 0000000..987fae8 --- /dev/null +++ b/ephemeral-postgres/src/ephemeral_database.rs @@ -0,0 +1,97 @@ +use sqlx::PgPool; +use sqlx::postgres::PgConnectOptions; + +use crate::database_lease::DatabaseLease; +use crate::ephemeral_postgres_error::EphemeralPostgresError; +use crate::server_environment::ServerEnvironment; + +pub struct EphemeralDatabase { + connect_options: PgConnectOptions, + pool: PgPool, + _lease: DatabaseLease, +} + +impl EphemeralDatabase { + /// # Errors + /// + /// Fails when `EPHEMERAL_POSTGRES_URL` does not describe the server explicitly and + /// unambiguously, or when the database cannot be created on that server. + pub async fn create() -> Result { + Self::create_in(ServerEnvironment::current()).await + } + + async fn create_in(environment: ServerEnvironment) -> Result { + let mut lease = DatabaseLease::acquire(environment.server_connect_options()?).await?; + + lease.create_with_pool().await.map(|pool| Self { + connect_options: lease.database_options(), + pool, + _lease: lease, + }) + } + + #[must_use] + pub const fn connect_options(&self) -> &PgConnectOptions { + &self.connect_options + } + + #[must_use] + pub const fn pool(&self) -> &PgPool { + &self.pool + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::ffi::OsString; + use std::io::ErrorKind; + + use super::EphemeralDatabase; + use crate::database_exists::database_exists; + use crate::ephemeral_postgres_error::EphemeralPostgresError; + use crate::server_environment::ServerEnvironment; + + #[tokio::test] + async fn removes_its_database_when_dropped() { + let database = EphemeralDatabase::create().await.unwrap(); + let database_name = database + .connect_options() + .get_database() + .unwrap() + .to_owned(); + + drop(database); + + let observer = EphemeralDatabase::create().await.unwrap(); + + assert!(!database_exists(observer.pool(), &database_name).await); + } + + #[tokio::test] + async fn rejects_an_environment_without_a_server_url() { + assert!(matches!( + EphemeralDatabase::create_in(ServerEnvironment { + variables: BTreeMap::new(), + }) + .await, + Err(EphemeralPostgresError::MissingServerUrl { variable }) + if variable == "EPHEMERAL_POSTGRES_URL" + )); + } + + #[tokio::test] + async fn rejects_a_server_it_cannot_reach() { + assert!(matches!( + EphemeralDatabase::create_in(ServerEnvironment { + variables: BTreeMap::from([( + OsString::from("EPHEMERAL_POSTGRES_URL"), + OsString::from("postgres://postgres@127.0.0.1:1/postgres?sslmode=disable"), + )]), + }) + .await, + Err(EphemeralPostgresError::AdminConnection { source: sqlx::Error::Io(io_error) }) + if io_error.kind() == ErrorKind::ConnectionRefused + )); + } +} diff --git a/ephemeral-postgres/src/ephemeral_postgres_error.rs b/ephemeral-postgres/src/ephemeral_postgres_error.rs index 41ec6d7..56276ae 100644 --- a/ephemeral-postgres/src/ephemeral_postgres_error.rs +++ b/ephemeral-postgres/src/ephemeral_postgres_error.rs @@ -1,48 +1,125 @@ -use std::time::Duration; - -use testcontainers_modules::testcontainers::TestcontainersError; +use std::str::Utf8Error; #[derive(Debug, thiserror::Error)] pub enum EphemeralPostgresError { - #[error("failed to start ephemeral postgres container")] - ContainerStart { + #[error("failed to connect to the admin database")] + AdminConnection { + #[source] + source: sqlx::Error, + }, + + #[error("the server url holds more than one path segment: `{path}`")] + AmbiguousDatabasePath { path: String }, + + #[error("failed to start the runtime that removes an ephemeral database")] + CleanupRuntime(#[source] std::io::Error), + + #[error("{variables:?} would describe the server in addition to the server url; unset them")] + ConflictingEnvironment { variables: Vec<&'static str> }, + + #[error("failed to create ephemeral database {database_name}")] + CreateDatabase { + database_name: String, + #[source] + source: sqlx::Error, + }, + + #[error("failed to drop ephemeral database {database_name}")] + DropDatabase { + database_name: String, #[source] - source: TestcontainersError, + source: sqlx::Error, }, - #[error("failed to resolve ephemeral postgres mapped port {container_port}")] - MappedPort { - container_port: u16, + #[error("the database name in the server url is not valid UTF-8")] + DatabaseNameNotUnicode { #[source] - source: TestcontainersError, + source: Utf8Error, }, - #[error( - "readiness timeout {timeout:?} puts the deadline past the latest representable instant" - )] - ReadinessDeadlineOverflow { timeout: Duration }, + #[error("the server url names no database")] + MissingDatabase, + + #[error("the server url names no host")] + MissingHost, + + #[error("the server url names no port")] + MissingPort, + + #[error("{variable} is not set")] + MissingServerUrl { variable: &'static str }, - #[error("postgres admin pool did not become ready within {timeout:?}")] - ReadinessTimeout { timeout: Duration }, + #[error("the server url sets no sslmode")] + MissingSslMode, - #[error("postgres admin pool did not become ready within {timeout:?}")] - ReadinessTimeoutAfterConnectionError { - timeout: Duration, + #[error("the server url names no user")] + MissingUsername, + + #[error("failed to open a pool against ephemeral database {database_name}")] + OpenPool { + database_name: String, #[source] source: sqlx::Error, }, - #[error("failed to create ephemeral database {db_name}")] - CreateDatabase { - db_name: String, + #[error("the password in the server url is not valid UTF-8")] + PasswordNotUnicode { + #[source] + source: Utf8Error, + }, + + #[error("failed to prepare the ephemeral database registry")] + PrepareRegistry { #[source] source: sqlx::Error, }, - #[error("failed to open pool against ephemeral database {db_name}")] - OpenPool { - db_name: String, + #[error("failed to reclaim ephemeral databases whose owners are gone")] + ReclaimOrphans { + #[source] + source: sqlx::Error, + }, + + #[error("the server url sets sslmode more than once")] + RepeatedSslMode, + + #[error("failed to reserve an ephemeral database")] + ReserveDatabase { + #[source] + source: sqlx::Error, + }, + + #[error("{variable} is not valid unicode")] + ServerUrlNotUnicode { variable: &'static str }, + + #[error("failed to unregister ephemeral database {database_name}")] + UnregisterDatabase { + database_name: String, #[source] source: sqlx::Error, }, + + #[error("the server url has a fragment")] + UnexpectedFragment, + + #[error("the server url has an unsupported query parameter `{name}`")] + UnexpectedQueryParameter { name: String }, + + #[error("the server url sslmode `{value}` is not one of the libpq modes")] + UnknownSslMode { value: String }, + + #[error("failed to parse the server url")] + UnparsableServerUrl { + #[source] + source: url::ParseError, + }, + + #[error("the server url scheme is `{scheme}`, expected `postgres`")] + UnsupportedScheme { scheme: String }, + + #[error("the user in the server url is not valid UTF-8")] + UsernameNotUnicode { + #[source] + source: Utf8Error, + }, } diff --git a/ephemeral-postgres/src/held_advisory_lock.rs b/ephemeral-postgres/src/held_advisory_lock.rs new file mode 100644 index 0000000..07ea928 --- /dev/null +++ b/ephemeral-postgres/src/held_advisory_lock.rs @@ -0,0 +1,39 @@ +use sqlx::FromRow; +use sqlx::PgConnection; +use sqlx::PgPool; +use sqlx::query; +use sqlx::query_as; + +#[derive(FromRow)] +pub struct HeldAdvisoryLock { + pub class_id: i32, + pub object_id: i32, +} + +impl HeldAdvisoryLock { + pub async fn held_by(pool: &PgPool, backend_pid: i32) -> Self { + query_as( + "SELECT classid::int4 AS class_id, objid::int4 AS object_id FROM pg_locks \ + WHERE locktype = 'advisory' AND objsubid = 2 AND pid = $1", + ) + .bind(backend_pid) + .fetch_one(pool) + .await + .unwrap() + } + + pub async fn wait_until_released(&self, connection: &mut PgConnection) { + query("SELECT pg_advisory_lock($1, $2)") + .bind(self.class_id) + .bind(self.object_id) + .execute(&mut *connection) + .await + .unwrap(); + query("SELECT pg_advisory_unlock($1, $2)") + .bind(self.class_id) + .bind(self.object_id) + .execute(connection) + .await + .unwrap(); + } +} diff --git a/ephemeral-postgres/src/lease_stage.rs b/ephemeral-postgres/src/lease_stage.rs new file mode 100644 index 0000000..4019f0c --- /dev/null +++ b/ephemeral-postgres/src/lease_stage.rs @@ -0,0 +1,4 @@ +pub enum LeaseStage { + Created, + Reserved, +} diff --git a/ephemeral-postgres/src/lib.rs b/ephemeral-postgres/src/lib.rs index e70b622..3a22384 100644 --- a/ephemeral-postgres/src/lib.rs +++ b/ephemeral-postgres/src/lib.rs @@ -1,10 +1,15 @@ -pub mod cluster; -pub mod cluster_connection_params; -pub mod cluster_params; -pub mod cluster_server; -mod constants; -pub mod database; +mod database_identity; +mod database_lease; +mod database_registry; +pub mod ephemeral_database; pub mod ephemeral_postgres_error; -pub mod postgres_container; -pub mod postgres_image; -mod wait_until_postgres_admin_pool_ready; +mod lease_stage; +mod orphaned_database; +mod server_connect_options; +mod server_environment; +mod ssl_mode_spelling; + +#[cfg(test)] +mod database_exists; +#[cfg(test)] +mod held_advisory_lock; diff --git a/ephemeral-postgres/src/orphaned_database.rs b/ephemeral-postgres/src/orphaned_database.rs new file mode 100644 index 0000000..de619f6 --- /dev/null +++ b/ephemeral-postgres/src/orphaned_database.rs @@ -0,0 +1,7 @@ +use sqlx::FromRow; + +#[derive(FromRow)] +pub struct OrphanedDatabase { + pub database_name: Option, + pub database_oid: i32, +} diff --git a/ephemeral-postgres/src/postgres_container.rs b/ephemeral-postgres/src/postgres_container.rs deleted file mode 100644 index 22c64d8..0000000 --- a/ephemeral-postgres/src/postgres_container.rs +++ /dev/null @@ -1,31 +0,0 @@ -use testcontainers_modules::postgres::Postgres; -use testcontainers_modules::testcontainers::ContainerAsync; - -use crate::ephemeral_postgres_error::EphemeralPostgresError; - -const POSTGRES_CONTAINER_PORT: u16 = 5432; - -#[doc(hidden)] -pub struct PostgresContainer { - container: ContainerAsync, -} - -impl PostgresContainer { - #[must_use] - 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) - .await - .map_err(|source| EphemeralPostgresError::MappedPort { - container_port: POSTGRES_CONTAINER_PORT, - source, - }) - } -} diff --git a/ephemeral-postgres/src/postgres_image.rs b/ephemeral-postgres/src/postgres_image.rs deleted file mode 100644 index ca4e74a..0000000 --- a/ephemeral-postgres/src/postgres_image.rs +++ /dev/null @@ -1,30 +0,0 @@ -use testcontainers_modules::postgres::Postgres; -use testcontainers_modules::testcontainers::ContainerRequest; -use testcontainers_modules::testcontainers::ImageExt; - -const INITDB_ARGS_ENV_VAR: &str = "POSTGRES_INITDB_ARGS"; -const INITDB_ARGS: &str = "--no-sync"; - -pub struct PostgresImage { - pub name: String, - pub tag: String, -} - -impl PostgresImage { - #[must_use] - pub fn new(name: impl Into, tag: impl Into) -> Self { - Self { - name: name.into(), - tag: tag.into(), - } - } - - #[doc(hidden)] - pub fn into_container_request(self) -> ContainerRequest { - Postgres::default() - .with_host_auth() - .with_name(self.name) - .with_tag(self.tag) - .with_env_var(INITDB_ARGS_ENV_VAR, INITDB_ARGS) - } -} diff --git a/ephemeral-postgres/src/server_connect_options.rs b/ephemeral-postgres/src/server_connect_options.rs new file mode 100644 index 0000000..300cb12 --- /dev/null +++ b/ephemeral-postgres/src/server_connect_options.rs @@ -0,0 +1,329 @@ +use std::borrow::Cow; +use std::str::Utf8Error; + +use percent_encoding::percent_decode_str; +use sqlx::postgres::PgConnectOptions; +use sqlx::postgres::PgSslMode; +use url::Url; + +use crate::ephemeral_postgres_error::EphemeralPostgresError; +use crate::ssl_mode_spelling::SslModeSpelling; + +const SCHEME: &str = "postgres"; +const SSL_MODE_PARAMETER: &str = "sslmode"; +const SSL_MODE_SPELLINGS: [SslModeSpelling; 6] = [ + SslModeSpelling { + mode: PgSslMode::Allow, + spelling: "allow", + }, + SslModeSpelling { + mode: PgSslMode::Disable, + spelling: "disable", + }, + SslModeSpelling { + mode: PgSslMode::Prefer, + spelling: "prefer", + }, + SslModeSpelling { + mode: PgSslMode::Require, + spelling: "require", + }, + SslModeSpelling { + mode: PgSslMode::VerifyCa, + spelling: "verify-ca", + }, + SslModeSpelling { + mode: PgSslMode::VerifyFull, + spelling: "verify-full", + }, +]; + +fn decoded(encoded: &str) -> Result, Utf8Error> { + percent_decode_str(encoded).decode_utf8() +} + +fn database(server_url: &Url) -> Result, EphemeralPostgresError> { + let segments: Vec<&str> = server_url + .path_segments() + .map_or_else(Vec::new, Iterator::collect); + + match segments.as_slice() { + [] | [""] => Err(EphemeralPostgresError::MissingDatabase), + [segment] => decoded(segment) + .map_err(|source| EphemeralPostgresError::DatabaseNameNotUnicode { source }), + _ => Err(EphemeralPostgresError::AmbiguousDatabasePath { + path: server_url.path().to_owned(), + }), + } +} + +fn host(server_url: &Url) -> Result<&str, EphemeralPostgresError> { + match server_url.host_str() { + None | Some("") => Err(EphemeralPostgresError::MissingHost), + Some(host) => Ok(host), + } +} + +fn password(server_url: &Url) -> Result>, EphemeralPostgresError> { + server_url + .password() + .map(|encoded| { + decoded(encoded).map_err(|source| EphemeralPostgresError::PasswordNotUnicode { source }) + }) + .transpose() +} + +fn ssl_mode(server_url: &Url) -> Result { + let mut values = Vec::new(); + + for (name, value) in server_url.query_pairs() { + if name != SSL_MODE_PARAMETER { + return Err(EphemeralPostgresError::UnexpectedQueryParameter { + name: name.into_owned(), + }); + } + + values.push(value); + } + + match values.as_slice() { + [] => Err(EphemeralPostgresError::MissingSslMode), + [value] => SSL_MODE_SPELLINGS + .into_iter() + .find(|SslModeSpelling { spelling, .. }| spelling == value) + .map(|SslModeSpelling { mode, .. }| mode) + .ok_or_else(|| EphemeralPostgresError::UnknownSslMode { + value: value.clone().into_owned(), + }), + _ => Err(EphemeralPostgresError::RepeatedSslMode), + } +} + +fn username(server_url: &Url) -> Result, EphemeralPostgresError> { + match server_url.username() { + "" => Err(EphemeralPostgresError::MissingUsername), + encoded => { + decoded(encoded).map_err(|source| EphemeralPostgresError::UsernameNotUnicode { source }) + } + } +} + +pub fn server_connect_options( + server_url: &str, +) -> Result { + let parsed = Url::parse(server_url) + .map_err(|source| EphemeralPostgresError::UnparsableServerUrl { source })?; + + if parsed.scheme() != SCHEME { + return Err(EphemeralPostgresError::UnsupportedScheme { + scheme: parsed.scheme().to_owned(), + }); + } + + if parsed.fragment().is_some() { + return Err(EphemeralPostgresError::UnexpectedFragment); + } + + let options = PgConnectOptions::new_without_pgpass() + .host(host(&parsed)?) + .port(parsed.port().ok_or(EphemeralPostgresError::MissingPort)?) + .username(&username(&parsed)?) + .database(&database(&parsed)?) + .ssl_mode(ssl_mode(&parsed)?); + + Ok(match password(&parsed)? { + Some(password) => options.password(&password), + None => options, + }) +} + +#[cfg(test)] +mod tests { + use std::mem::discriminant; + + use sqlx::ConnectOptions; + use sqlx::postgres::PgSslMode; + use url::ParseError; + + use super::server_connect_options; + use crate::ephemeral_postgres_error::EphemeralPostgresError; + + #[test] + fn takes_every_part_from_the_url() { + let options = server_connect_options( + "postgres://us%40er@db.example:6543/my%20db?sslmode=verify-full", + ) + .unwrap(); + + assert_eq!(options.get_host(), "db.example"); + assert_eq!(options.get_port(), 6543); + assert_eq!(options.get_username(), "us@er"); + assert_eq!(options.get_database(), Some("my db")); + assert_eq!( + discriminant(&options.get_ssl_mode()), + discriminant(&PgSslMode::VerifyFull) + ); + } + + #[test] + fn decodes_the_password() { + let options = + server_connect_options("postgres://user:se%2Fcret@db.example:6543/db?sslmode=disable") + .unwrap(); + + assert_eq!(options.to_url_lossy().password(), Some("se%2Fcret")); + } + + #[test] + fn rejects_an_unparsable_url() { + assert!(matches!( + server_connect_options("not a url"), + Err(EphemeralPostgresError::UnparsableServerUrl { source }) + if source == ParseError::RelativeUrlWithoutBase + )); + } + + #[test] + fn rejects_another_scheme() { + assert!(matches!( + server_connect_options("postgresql://user@db.example:6543/db?sslmode=disable"), + Err(EphemeralPostgresError::UnsupportedScheme { scheme }) if scheme == "postgresql" + )); + } + + #[test] + fn rejects_a_fragment() { + assert_eq!( + discriminant( + &server_connect_options("postgres://user@db.example:6543/db?sslmode=disable#part") + .err() + .unwrap() + ), + discriminant(&EphemeralPostgresError::UnexpectedFragment) + ); + } + + #[test] + fn rejects_a_missing_host() { + assert_eq!( + discriminant( + &server_connect_options("postgres:///db?sslmode=disable") + .err() + .unwrap() + ), + discriminant(&EphemeralPostgresError::MissingHost) + ); + } + + #[test] + fn rejects_a_missing_port() { + assert_eq!( + discriminant( + &server_connect_options("postgres://user@db.example/db?sslmode=disable") + .err() + .unwrap() + ), + discriminant(&EphemeralPostgresError::MissingPort) + ); + } + + #[test] + fn rejects_a_missing_username() { + assert_eq!( + discriminant( + &server_connect_options("postgres://db.example:6543/db?sslmode=disable") + .err() + .unwrap() + ), + discriminant(&EphemeralPostgresError::MissingUsername) + ); + } + + #[test] + fn rejects_a_username_that_is_not_utf8() { + assert!(matches!( + server_connect_options("postgres://%FF@db.example:6543/db?sslmode=disable"), + Err(EphemeralPostgresError::UsernameNotUnicode { source }) if source.valid_up_to() == 0 + )); + } + + #[test] + fn rejects_a_password_that_is_not_utf8() { + assert!(matches!( + server_connect_options("postgres://user:%FF@db.example:6543/db?sslmode=disable"), + Err(EphemeralPostgresError::PasswordNotUnicode { source }) if source.valid_up_to() == 0 + )); + } + + #[test] + fn rejects_a_missing_database() { + assert_eq!( + discriminant( + &server_connect_options("postgres://user@db.example:6543/?sslmode=disable") + .err() + .unwrap() + ), + discriminant(&EphemeralPostgresError::MissingDatabase) + ); + } + + #[test] + fn rejects_a_path_with_several_segments() { + assert!(matches!( + server_connect_options("postgres://user@db.example:6543/db/extra?sslmode=disable"), + Err(EphemeralPostgresError::AmbiguousDatabasePath { path }) if path == "/db/extra" + )); + } + + #[test] + fn rejects_a_database_name_that_is_not_utf8() { + assert!(matches!( + server_connect_options("postgres://user@db.example:6543/%FF?sslmode=disable"), + Err(EphemeralPostgresError::DatabaseNameNotUnicode { source }) if source.valid_up_to() == 0 + )); + } + + #[test] + fn rejects_an_unexpected_query_parameter() { + assert!(matches!( + server_connect_options( + "postgres://user@db.example:6543/db?sslmode=disable&application_name=tests" + ), + Err(EphemeralPostgresError::UnexpectedQueryParameter { name }) if name == "application_name" + )); + } + + #[test] + fn rejects_a_missing_ssl_mode() { + assert_eq!( + discriminant( + &server_connect_options("postgres://user@db.example:6543/db") + .err() + .unwrap() + ), + discriminant(&EphemeralPostgresError::MissingSslMode) + ); + } + + #[test] + fn rejects_a_repeated_ssl_mode() { + assert_eq!( + discriminant( + &server_connect_options( + "postgres://user@db.example:6543/db?sslmode=disable&sslmode=disable" + ) + .err() + .unwrap() + ), + discriminant(&EphemeralPostgresError::RepeatedSslMode) + ); + } + + #[test] + fn rejects_an_ssl_mode_libpq_does_not_spell_that_way() { + assert!(matches!( + server_connect_options("postgres://user@db.example:6543/db?sslmode=DISABLE"), + Err(EphemeralPostgresError::UnknownSslMode { value }) if value == "DISABLE" + )); + } +} diff --git a/ephemeral-postgres/src/server_environment.rs b/ephemeral-postgres/src/server_environment.rs new file mode 100644 index 0000000..2ff452c --- /dev/null +++ b/ephemeral-postgres/src/server_environment.rs @@ -0,0 +1,138 @@ +use std::collections::BTreeMap; +use std::env; +use std::ffi::OsStr; +use std::ffi::OsString; + +use sqlx::postgres::PgConnectOptions; + +use crate::ephemeral_postgres_error::EphemeralPostgresError; +use crate::server_connect_options::server_connect_options; + +const CONFLICTING_VARIABLES: [&str; 12] = [ + "PGAPPNAME", + "PGDATABASE", + "PGHOST", + "PGHOSTADDR", + "PGOPTIONS", + "PGPASSWORD", + "PGPORT", + "PGSSLCERT", + "PGSSLKEY", + "PGSSLMODE", + "PGSSLROOTCERT", + "PGUSER", +]; +const SERVER_URL_VARIABLE: &str = "EPHEMERAL_POSTGRES_URL"; + +pub struct ServerEnvironment { + pub variables: BTreeMap, +} + +impl ServerEnvironment { + #[must_use] + pub fn current() -> Self { + Self { + variables: env::vars_os().collect(), + } + } + + pub fn server_connect_options(&self) -> Result { + let conflicting: Vec<&'static str> = CONFLICTING_VARIABLES + .into_iter() + .filter(|variable| self.variables.contains_key(OsStr::new(variable))) + .collect(); + + if !conflicting.is_empty() { + return Err(EphemeralPostgresError::ConflictingEnvironment { + variables: conflicting, + }); + } + + let server_url = self + .variables + .get(OsStr::new(SERVER_URL_VARIABLE)) + .ok_or(EphemeralPostgresError::MissingServerUrl { + variable: SERVER_URL_VARIABLE, + })? + .to_str() + .ok_or(EphemeralPostgresError::ServerUrlNotUnicode { + variable: SERVER_URL_VARIABLE, + })?; + + server_connect_options(server_url) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + use super::ServerEnvironment; + use crate::ephemeral_postgres_error::EphemeralPostgresError; + + const SERVER_URL: &str = "postgres://user@db.example:6543/db?sslmode=disable"; + + #[test] + fn reads_the_server_url() { + let options = ServerEnvironment { + variables: BTreeMap::from([( + OsString::from("EPHEMERAL_POSTGRES_URL"), + OsString::from(SERVER_URL), + )]), + } + .server_connect_options() + .unwrap(); + + assert_eq!(options.get_host(), "db.example"); + } + + #[test] + fn rejects_libpq_variables_that_would_also_describe_the_server() { + let result = ServerEnvironment { + variables: BTreeMap::from([ + ( + OsString::from("EPHEMERAL_POSTGRES_URL"), + OsString::from(SERVER_URL), + ), + (OsString::from("PGPASSWORD"), OsString::from("secret")), + (OsString::from("PGHOST"), OsString::from("elsewhere")), + ]), + } + .server_connect_options(); + + assert!(matches!( + result, + Err(EphemeralPostgresError::ConflictingEnvironment { variables }) + if variables == vec!["PGHOST", "PGPASSWORD"] + )); + } + + #[test] + fn rejects_a_missing_server_url() { + assert!(matches!( + ServerEnvironment { + variables: BTreeMap::new(), + } + .server_connect_options(), + Err(EphemeralPostgresError::MissingServerUrl { variable }) + if variable == "EPHEMERAL_POSTGRES_URL" + )); + } + + #[test] + fn rejects_a_server_url_that_is_not_unicode() { + assert!(matches!( + ServerEnvironment { + variables: BTreeMap::from([( + OsString::from("EPHEMERAL_POSTGRES_URL"), + OsString::from_vec(vec![0xff]), + )]), + } + .server_connect_options(), + Err(EphemeralPostgresError::ServerUrlNotUnicode { variable }) + if variable == "EPHEMERAL_POSTGRES_URL" + )); + } +} diff --git a/ephemeral-postgres/src/ssl_mode_spelling.rs b/ephemeral-postgres/src/ssl_mode_spelling.rs new file mode 100644 index 0000000..5708906 --- /dev/null +++ b/ephemeral-postgres/src/ssl_mode_spelling.rs @@ -0,0 +1,6 @@ +use sqlx::postgres::PgSslMode; + +pub struct SslModeSpelling { + pub mode: PgSslMode, + pub spelling: &'static str, +} diff --git a/ephemeral-postgres/src/wait_until_postgres_admin_pool_ready.rs b/ephemeral-postgres/src/wait_until_postgres_admin_pool_ready.rs deleted file mode 100644 index 5170d22..0000000 --- a/ephemeral-postgres/src/wait_until_postgres_admin_pool_ready.rs +++ /dev/null @@ -1,98 +0,0 @@ -use std::time::Duration; - -use sqlx::PgPool; -use sqlx::postgres::PgPoolOptions; -use tokio::time::Instant; - -use crate::ephemeral_postgres_error::EphemeralPostgresError; - -const ADMIN_POOL_POLL_INTERVAL: Duration = Duration::from_millis(50); - -enum LastConnectionOutcome { - NoConnectionErrorYet, - Failed(sqlx::Error), -} - -pub async fn wait_until_postgres_admin_pool_ready( - admin_url: &str, - timeout: Duration, -) -> Result { - let deadline = Instant::now() - .checked_add(timeout) - .ok_or(EphemeralPostgresError::ReadinessDeadlineOverflow { timeout })?; - let mut last_connection_outcome = LastConnectionOutcome::NoConnectionErrorYet; - - loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return Err(match last_connection_outcome { - LastConnectionOutcome::NoConnectionErrorYet => { - EphemeralPostgresError::ReadinessTimeout { timeout } - } - LastConnectionOutcome::Failed(source) => { - EphemeralPostgresError::ReadinessTimeoutAfterConnectionError { timeout, source } - } - }); - } - - match PgPoolOptions::new() - .max_connections(5) - .acquire_timeout(remaining) - .connect(admin_url) - .await - { - Ok(pool) => return Ok(pool), - Err(connection_error) => { - last_connection_outcome = LastConnectionOutcome::Failed(connection_error); - } - } - - let sleep_for = - ADMIN_POOL_POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now())); - tokio::time::sleep(sleep_for).await; - } -} - -#[cfg(test)] -mod tests { - use std::error::Error; - use std::time::Duration; - - 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 = - wait_until_postgres_admin_pool_ready(UNREACHABLE_ADMIN_URL, Duration::ZERO).await; - - assert!(result.unwrap_err().source().is_none()); - } - - #[tokio::test] - async fn times_out_reporting_the_last_connection_error_as_cause_after_polling() { - let budget_spanning_several_polls = ADMIN_POOL_POLL_INTERVAL * 4; - - let result = wait_until_postgres_admin_pool_ready( - UNREACHABLE_ADMIN_URL, - budget_spanning_several_polls, - ) - .await; - - assert!(result.unwrap_err().source().is_some()); - } -} diff --git a/ephemeral-postgres/tests/ephemeral_database_isolates_state.rs b/ephemeral-postgres/tests/ephemeral_database_isolates_state.rs new file mode 100644 index 0000000..72b72ae --- /dev/null +++ b/ephemeral-postgres/tests/ephemeral_database_isolates_state.rs @@ -0,0 +1,40 @@ +use sqlx::Row; +use sqlx::error::DatabaseError; +use sqlx::query; + +use ephemeral_postgres::ephemeral_database::EphemeralDatabase; + +#[tokio::test] +async fn isolates_state_from_another_ephemeral_database() { + let first = EphemeralDatabase::create().await.unwrap(); + let second = EphemeralDatabase::create().await.unwrap(); + + query("CREATE TABLE isolation_check (value INT)") + .execute(first.pool()) + .await + .unwrap(); + query("INSERT INTO isolation_check (value) VALUES (1)") + .execute(first.pool()) + .await + .unwrap(); + + let stored_value: i32 = query("SELECT value FROM isolation_check") + .fetch_one(first.pool()) + .await + .unwrap() + .get("value"); + assert_eq!(stored_value, 1); + + let error = query("SELECT value FROM isolation_check") + .fetch_one(second.pool()) + .await + .expect_err("a table created in one ephemeral database must not be visible in another"); + + assert_eq!( + error + .as_database_error() + .and_then(DatabaseError::code) + .as_deref(), + Some("42P01") + ); +} diff --git a/ephemeral-postgres/tests/ephemeral_database_provides_a_pool_to_its_own_database.rs b/ephemeral-postgres/tests/ephemeral_database_provides_a_pool_to_its_own_database.rs new file mode 100644 index 0000000..a497994 --- /dev/null +++ b/ephemeral-postgres/tests/ephemeral_database_provides_a_pool_to_its_own_database.rs @@ -0,0 +1,18 @@ +use sqlx::query_scalar; + +use ephemeral_postgres::ephemeral_database::EphemeralDatabase; + +#[tokio::test] +async fn provides_a_pool_connected_to_its_own_database() { + let database = EphemeralDatabase::create().await.unwrap(); + + let current_database: String = query_scalar("SELECT current_database()") + .fetch_one(database.pool()) + .await + .unwrap(); + + assert_eq!( + database.connect_options().get_database(), + Some(current_database.as_str()) + ); +} diff --git a/ephemeral-postgres/tests/main.rs b/ephemeral-postgres/tests/main.rs new file mode 100644 index 0000000..69490a2 --- /dev/null +++ b/ephemeral-postgres/tests/main.rs @@ -0,0 +1,2 @@ +mod ephemeral_database_isolates_state; +mod ephemeral_database_provides_a_pool_to_its_own_database;