From af9ca8c4f5e7b908551fa718576ebd0b9e36b38f Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 11 May 2026 20:04:17 +0000 Subject: [PATCH 1/2] Change to build in pg-ephemeral tests --- Cargo.lock | 40 + Cargo.toml | 3 + pg-ephemeral/Cargo.toml | 15 +- pg-ephemeral/src/cli.rs | 12 + pg-ephemeral/src/cli/meta.rs | 59 + pg-ephemeral/src/lib.rs | 1 + pg-ephemeral/src/meta.rs | 7 + pg-ephemeral/src/meta/test.rs | 93 + pg-ephemeral/src/meta/test/backtrace.rs | 69 + pg-ephemeral/src/meta/test/base.rs | 862 ++++++++++ pg-ephemeral/src/meta/test/cache.rs | 1518 +++++++++++++++++ .../{tests => src/meta/test}/common.rs | 132 +- pg-ephemeral/src/meta/test/container.rs | 50 + .../{tests => src/meta/test}/examples.rs | 41 +- pg-ephemeral/src/meta/test/examples_boot.rs | 129 ++ pg-ephemeral/src/meta/test/integration.rs | 93 + pg-ephemeral/src/meta/test/labels.rs | 230 +++ pg-ephemeral/src/meta/test/seed.rs | 483 ++++++ pg-ephemeral/tests/backtrace.rs | 53 - pg-ephemeral/tests/base.rs | 689 -------- pg-ephemeral/tests/cache.rs | 1239 -------------- pg-ephemeral/tests/container.rs | 38 - pg-ephemeral/tests/examples_boot.rs | 112 -- pg-ephemeral/tests/integration.rs | 13 - pg-ephemeral/tests/labels.rs | 195 --- pg-ephemeral/tests/meta.rs | 20 + pg-ephemeral/tests/seed.rs | 379 ---- 27 files changed, 3750 insertions(+), 2825 deletions(-) create mode 100644 pg-ephemeral/src/cli/meta.rs create mode 100644 pg-ephemeral/src/meta.rs create mode 100644 pg-ephemeral/src/meta/test.rs create mode 100644 pg-ephemeral/src/meta/test/backtrace.rs create mode 100644 pg-ephemeral/src/meta/test/base.rs create mode 100644 pg-ephemeral/src/meta/test/cache.rs rename pg-ephemeral/{tests => src/meta/test}/common.rs (53%) create mode 100644 pg-ephemeral/src/meta/test/container.rs rename pg-ephemeral/{tests => src/meta/test}/examples.rs (57%) create mode 100644 pg-ephemeral/src/meta/test/examples_boot.rs create mode 100644 pg-ephemeral/src/meta/test/integration.rs create mode 100644 pg-ephemeral/src/meta/test/labels.rs create mode 100644 pg-ephemeral/src/meta/test/seed.rs delete mode 100644 pg-ephemeral/tests/backtrace.rs delete mode 100644 pg-ephemeral/tests/base.rs delete mode 100644 pg-ephemeral/tests/cache.rs delete mode 100644 pg-ephemeral/tests/container.rs delete mode 100644 pg-ephemeral/tests/examples_boot.rs delete mode 100644 pg-ephemeral/tests/integration.rs delete mode 100644 pg-ephemeral/tests/labels.rs create mode 100644 pg-ephemeral/tests/meta.rs delete mode 100644 pg-ephemeral/tests/seed.rs diff --git a/Cargo.lock b/Cargo.lock index bc892e9f..a0abda77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1307,6 +1307,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "escape8259" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5692dd7b5a1978a5aeb0ce83b7655c58ca8efdcb79d21036ea249da95afec2c6" + [[package]] name = "etcetera" version = "0.10.0" @@ -2155,6 +2161,25 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2386,6 +2411,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libtest-mimic" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14e6ba06f0ade6e504aff834d7c34298e5155c6baca353cc6a4aaff2f9fd7f33" +dependencies = [ + "anstream", + "anstyle", + "clap", + "escape8259", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3033,9 +3070,11 @@ dependencies = [ "git-proc", "hex", "humantime-serde", + "include_dir", "indexmap 2.14.0", "indoc", "libc", + "libtest-mimic", "log", "msqlx", "nom 8.0.0", @@ -3044,6 +3083,7 @@ dependencies = [ "pg-client", "rand 0.10.1", "rcgen", + "rustix", "semver", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index cba425f5..e1573224 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,9 +44,11 @@ google-cloud-sql-v1 = "2" hex = "0.4.3" ipnet = { version = "2", features = ["serde"] } http = "1.4" +include_dir = "0.7" indoc = "2" itertools = "0.14" libc = "0.2" +libtest-mimic = "0.8" log = "0.4" typed-reqwest = { path = "typed-reqwest" } mmigration = { path = "mmigration" } @@ -63,6 +65,7 @@ rand = "0.10" regex-lite = "0.1.9" reqwest = { version = "0.13", features = ["json", "query", "rustls"], default-features = false } rsa = { version = "0.9", features = ["pem"] } +rustix = { version = "1.1", features = ["fs"] } rustls = "0.23" rustls-pemfile = "2" semver = "1" diff --git a/pg-ephemeral/Cargo.toml b/pg-ephemeral/Cargo.toml index c6b679f2..27cb0e2c 100644 --- a/pg-ephemeral/Cargo.toml +++ b/pg-ephemeral/Cargo.toml @@ -25,7 +25,10 @@ env_logger.workspace = true git-proc.workspace = true hex.workspace = true humantime-serde = "1" +include_dir.workspace = true indexmap = { version = "2.13", features = ["serde"] } +indoc.workspace = true +libtest-mimic.workspace = true log.workspace = true nom.workspace = true nom-language.workspace = true @@ -33,6 +36,7 @@ ociman.workspace = true pg-client.workspace = true rand.workspace = true rcgen = "0.14" +rustix.workspace = true semver.workspace = true serde.workspace = true serde_json.workspace = true @@ -45,8 +49,17 @@ toml.workspace = true x509-parser = { version = "0.18", features = ["verify"] } [dev-dependencies] -indoc.workspace = true libc.workspace = true [[bin]] name = "pg-ephemeral" + +# Cargo-test entry point for the `meta test` suite. The wrapper proxies +# to `pg-ephemeral meta test`, so each test runs inside the actual +# production binary's process — exercising main(), CLI parsing, and any +# startup hooks that a normal cargo-test build would not touch. +# Deliberately uses no pg_ephemeral:: symbols so the wrapper itself +# links nothing from the library. +[[test]] +name = "meta" +harness = false diff --git a/pg-ephemeral/src/cli.rs b/pg-ephemeral/src/cli.rs index c4883c9d..289cce24 100644 --- a/pg-ephemeral/src/cli.rs +++ b/pg-ephemeral/src/cli.rs @@ -1,4 +1,5 @@ pub mod cache; +pub mod meta; pub mod platform; use crate::config::Config; @@ -227,6 +228,16 @@ pub enum Command { #[clap(subcommand)] command: platform::Command, }, + /// Diagnostics about this binary itself (test runner, info, …). + /// + /// Subcommands hosted under `meta` interrogate or test the production + /// binary. The `meta` namespace keeps the user-facing `test` verb + /// free for things like SQL / schema testing later. + #[command(name = "meta")] + Meta { + #[clap(subcommand)] + command: meta::Command, + }, } impl Default for Command { @@ -306,6 +317,7 @@ impl Command { .await?? } Self::Platform { command } => command.run(), + Self::Meta { command } => command.run(), } Ok(()) diff --git a/pg-ephemeral/src/cli/meta.rs b/pg-ephemeral/src/cli/meta.rs new file mode 100644 index 00000000..98841d37 --- /dev/null +++ b/pg-ephemeral/src/cli/meta.rs @@ -0,0 +1,59 @@ +//! `pg-ephemeral meta ` — diagnostics about this binary itself. +//! +//! The `meta` namespace hosts subcommands that interrogate or test the +//! production binary: `meta test` (full integration suite), `meta smoke` +//! (curated fast subset for downstream-user diagnosis), later potentially +//! `meta info`, `meta diagnose`, etc. The `meta` name keeps the +//! user-facing `test` verb free for things like SQL / schema testing +//! later. + +use libtest_mimic::{Arguments, Trial}; + +#[derive(Clone, Debug, clap::Parser)] +pub enum Command { + /// Run the full integration test suite hosted by this binary. + /// + /// Each trial runs inside the production binary's process — main(), + /// CLI parsing, and any startup hooks are exercised, unlike a normal + /// cargo-test library build. All arguments after `test` are forwarded + /// to libtest-mimic (e.g. `--list`, `--exact `, + /// `--filter `, `--nocapture`). + /// + /// Heavy — includes language integration (Ruby/Prisma image pulls, + /// container builds) and all example-boot checks. For a fast + /// downstream-diagnostic subset, use `meta smoke`. + Test { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Run the curated smoke subset of the integration suite. + /// + /// Verifies pg-ephemeral works on this host: binary integrity, + /// example schema parses, basic container lifecycle, label + /// round-trip, cache populate. Avoids the multi-hundred-MB image + /// pulls and minutes-long boot sequences in the full `meta test`. + /// All arguments after `smoke` are forwarded to libtest-mimic. + Smoke { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, +} + +impl Command { + /// Execute the meta subcommand. Each arm runs libtest-mimic and + /// terminates the process with libtest's conventional exit code (0 + /// on pass, 101 on fail) — it does not return. + pub fn run(&self) { + match self { + Self::Test { args } => run_trials("test", args, crate::meta::test::trials()), + Self::Smoke { args } => run_trials("smoke", args, crate::meta::test::smoke_trials()), + } + } +} + +fn run_trials(subcommand: &str, args: &[String], trials: Vec) { + let arg0 = format!("pg-ephemeral meta {subcommand}"); + let parse_args = std::iter::once(arg0).chain(args.iter().cloned()); + let arguments = Arguments::from_iter(parse_args); + libtest_mimic::run(&arguments, trials).exit(); +} diff --git a/pg-ephemeral/src/lib.rs b/pg-ephemeral/src/lib.rs index 98f2c2b9..db65ff87 100644 --- a/pg-ephemeral/src/lib.rs +++ b/pg-ephemeral/src/lib.rs @@ -5,6 +5,7 @@ pub mod container; pub mod definition; pub mod image; pub mod label; +pub mod meta; pub mod seed; pub use config::{Config, Instance}; diff --git a/pg-ephemeral/src/meta.rs b/pg-ephemeral/src/meta.rs new file mode 100644 index 00000000..f4d9b0d9 --- /dev/null +++ b/pg-ephemeral/src/meta.rs @@ -0,0 +1,7 @@ +//! Diagnostics about this binary itself. +//! +//! Today: the integration test trial registry exposed via +//! `pg-ephemeral meta test` (see [`test`]). Future room for `meta info`, +//! `meta diagnose`, etc. + +pub mod test; diff --git a/pg-ephemeral/src/meta/test.rs b/pg-ephemeral/src/meta/test.rs new file mode 100644 index 00000000..f555c908 --- /dev/null +++ b/pg-ephemeral/src/meta/test.rs @@ -0,0 +1,93 @@ +//! Integration test trial registry for `pg-ephemeral meta test` (full +//! suite) and `pg-ephemeral meta smoke` (curated fast subset for +//! downstream-user diagnosis). +//! +//! Trials live as library code (not `tests/*.rs` cargo-test targets) so +//! they run inside the actual production binary's process — exercising +//! main(), CLI parsing, and any startup hooks that a normal cargo-test +//! library build would not. The registry is driven by `libtest-mimic`, +//! with the wrapper at `tests/meta.rs` routing cargo-test invocations +//! through the `pg-ephemeral meta test` subcommand. +//! +//! Per-file submodules (e.g. [`backtrace`]) each expose their own +//! `pub fn trials() -> Vec`; this module aggregates them into a +//! single registry and offers two surfaces: +//! +//! - [`trials`] — every trial; used by `meta test`. +//! - [`smoke_trials`] — a small, fast, downstream-relevant subset +//! identified by name in [`SMOKE`]; used by `meta smoke`. +//! +//! When the host platform doesn't support container runtimes (per +//! [`ociman::testing::platform_not_supported`]), every trial is marked +//! ignored — surfacing as "ignored" in libtest output rather than +//! falsely passing as the prior `test_backend_setup!()` early-return +//! pattern did. Use `--include-ignored` to force-run on a host where +//! you've manually set up the runtime. + +pub mod backtrace; +pub mod base; +pub mod cache; +pub mod common; +pub mod container; +pub mod examples; +pub mod examples_boot; +pub mod integration; +pub mod labels; +pub mod seed; + +use libtest_mimic::Trial; + +/// The smoke subset — a small, fast set of trials that gives a downstream +/// user enough signal to know whether their pg-ephemeral installation +/// works on this host. Avoids the multi-hundred-MB image pulls and +/// minutes-long boot sequences in the full suite. +const SMOKE: &[&str] = &[ + "backtrace_contains_file_paths", + "every_example_database_toml_parses", + "set_superuser_password", + "labels_written_minimal_container", + "populate_cache", +]; + +/// Every registered trial. +#[must_use] +pub fn trials() -> Vec { + mark_ignored_when_unsupported(all_trials_raw()) +} + +/// The smoke subset of [`trials`] — filtered to trials whose name is +/// listed in [`SMOKE`]. +#[must_use] +pub fn smoke_trials() -> Vec { + mark_ignored_when_unsupported( + all_trials_raw() + .into_iter() + .filter(|trial| SMOKE.contains(&trial.name())) + .collect(), + ) +} + +fn all_trials_raw() -> Vec { + let mut trials = Vec::new(); + trials.extend(backtrace::trials()); + trials.extend(base::trials()); + trials.extend(cache::trials()); + trials.extend(container::trials()); + trials.extend(examples::trials()); + trials.extend(examples_boot::trials()); + trials.extend(integration::trials()); + trials.extend(labels::trials()); + trials.extend(seed::trials()); + trials +} + +fn mark_ignored_when_unsupported(trials: Vec) -> Vec { + if ociman::testing::platform_not_supported() { + trials + .into_iter() + .map(|trial| trial.with_ignored_flag(true)) + .collect() + } else { + trials + } +} diff --git a/pg-ephemeral/src/meta/test/backtrace.rs b/pg-ephemeral/src/meta/test/backtrace.rs new file mode 100644 index 00000000..51a05a82 --- /dev/null +++ b/pg-ephemeral/src/meta/test/backtrace.rs @@ -0,0 +1,69 @@ +//! Verifies that `pg-ephemeral platform test-backtrace` produces a panic +//! whose backtrace contains real file paths and function names — the +//! property that breaks if the release build strips debug info or the +//! panic handler is misconfigured. + +use libtest_mimic::{Failed, Trial}; + +const RUST_BACKTRACE: cmd_proc::EnvVariableName<'static> = + cmd_proc::EnvVariableName::from_static_or_panic("RUST_BACKTRACE"); + +#[must_use] +pub fn trials() -> Vec { + vec![Trial::test( + "backtrace_contains_file_paths", + backtrace_contains_file_paths, + )] +} + +fn backtrace_contains_file_paths() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let pg_ephemeral_bin = std::env::current_exe().unwrap(); + + let output = cmd_proc::Command::new(pg_ephemeral_bin) + .arguments(["platform", "test-backtrace"]) + .env(&RUST_BACKTRACE, "1") + .stdout_capture() + .stderr_capture() + .accept_nonzero_exit() + .run() + .await + .unwrap(); + + assert!( + !output.status.success(), + "test-backtrace should exit with non-zero status" + ); + + let stderr = std::str::from_utf8(&output.stderr).unwrap(); + + assert!( + stderr.contains("intentional panic for backtrace testing"), + "stderr should contain panic message, got:\n{stderr}" + ); + assert!( + stderr.contains("inner_function_for_backtrace_test"), + "backtrace should contain inner_function_for_backtrace_test, got:\n{stderr}" + ); + assert!( + stderr.contains("trigger_test_panic"), + "backtrace should contain trigger_test_panic, got:\n{stderr}" + ); + + let has_file_line_in_backtrace = stderr.lines().any(|line| { + !line.contains("panicked at") && line.contains("cli") && line.contains("at ") + }); + + assert!( + has_file_line_in_backtrace, + "backtrace frames should contain file paths with line numbers, got:\n{stderr}" + ); + + Ok(()) + }) +} diff --git a/pg-ephemeral/src/meta/test/base.rs b/pg-ephemeral/src/meta/test/base.rs new file mode 100644 index 00000000..52478044 --- /dev/null +++ b/pg-ephemeral/src/meta/test/base.rs @@ -0,0 +1,862 @@ +//! Verifies base feature surface: image pulls, container boot with and without +//! generated SSL, the `pg_env` / `database_url` shape exposed to spawned +//! children, and the `Config` TOML parser across single-instance, multi-instance, +//! seed-variant, SSL, image-digest, and image-error inputs. + +use libtest_mimic::{Failed, Trial}; + +use super::common::TestDir; + +#[must_use] +pub fn trials() -> Vec { + vec![ + Trial::test("pull_test_images", pull_test_images), + Trial::test("base_feature", base_feature), + Trial::test("ssl_generated", ssl_generated), + Trial::test("config_file", config_file), + Trial::test( + "config_file_no_explicit_instance", + config_file_no_explicit_instance, + ), + Trial::test("config_ssl", config_ssl), + Trial::test("run_env", run_env), + Trial::test("config_seeds_basic", config_seeds_basic), + Trial::test("config_seeds_command", config_seeds_command), + Trial::test("config_seeds_script", config_seeds_script), + Trial::test("config_seeds_mixed", config_seeds_mixed), + Trial::test( + "config_seeds_preserve_declaration_order", + config_seeds_preserve_declaration_order, + ), + Trial::test("config_seeds_duplicate_name", config_seeds_duplicate_name), + Trial::test( + "config_seeds_with_git_revision", + config_seeds_with_git_revision, + ), + Trial::test( + "config_image_with_sha256_digest", + config_image_with_sha256_digest, + ), + Trial::test("config_invalid_image_format", config_invalid_image_format), + Trial::test( + "config_invalid_image_nom_error", + config_invalid_image_nom_error, + ), + ] +} + +fn pull_test_images() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + + let default_image: ociman::image::Reference = (&crate::Image::default()).into(); + backend.pull_image(&default_image).await; + + for image in [ + &*super::common::POSTGRES_IMAGE, + &*super::common::RUBY_IMAGE, + &*super::common::NODE_IMAGE, + &*ociman::testing::ALPINE_LATEST_IMAGE, + ] { + backend.pull_image(image).await; + } + + Ok(()) + }) +} + +fn base_feature() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + + super::common::test_definition(backend) + .with_container(async |container| { + container + .with_connection(async |connection| { + let row = sqlx::query("SELECT true") + .fetch_one(connection) + .await + .unwrap(); + assert!(sqlx::Row::get::(&row, 0)); + }) + .await; + }) + .await + .unwrap(); + + Ok(()) + }) +} + +fn ssl_generated() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + + super::common::test_definition(backend) + .ssl_config(crate::definition::SslConfig::Generated { + hostname: "postgresql.example.com".parse().unwrap(), + }) + .with_container(async |container| { + container + .with_connection(async |connection| { + let row = sqlx::query("SELECT true") + .fetch_one(connection) + .await + .unwrap(); + assert!(sqlx::Row::get::(&row, 0)); + }) + .await; + }) + .await + .unwrap(); + + Ok(()) + }) +} + +const DATABASE_TOML: &str = include_str!("../../../tests/database.toml"); +const DATABASE_NO_EXPLICIT_INSTANCE_TOML: &str = + include_str!("../../../tests/database_no_explicit_instance.toml"); + +fn config_file() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let dir = TestDir::new("config-file"); + dir.write_file("database.toml", DATABASE_TOML); + let path = dir.path.join("database.toml"); + + assert_eq!( + crate::InstanceMap::from([ + ( + crate::InstanceName::from_static_or_panic("a"), + crate::Instance { + application_name: None, + backend: ociman::backend::Selection::Docker, + database: pg_client::Database::POSTGRES, + seeds: indexmap::IndexMap::new(), + ssl_config: None, + superuser: pg_client::User::POSTGRES, + image: "17.1".parse().unwrap(), + cross_container_access: false, + wait_available_timeout: std::time::Duration::from_secs(10), + } + ), + ( + crate::InstanceName::from_static_or_panic("b"), + crate::Instance { + application_name: None, + backend: ociman::backend::Selection::Podman, + database: pg_client::Database::POSTGRES, + seeds: indexmap::IndexMap::new(), + ssl_config: None, + superuser: pg_client::User::POSTGRES, + image: "17.2".parse().unwrap(), + cross_container_access: false, + wait_available_timeout: std::time::Duration::from_secs(10), + } + ) + ]), + crate::Config::load_toml_file(&path, &crate::config::InstanceDefinition::empty()) + .unwrap() + ); + + assert_eq!( + crate::InstanceMap::from([ + ( + crate::InstanceName::from_static_or_panic("a"), + crate::Instance { + application_name: None, + backend: ociman::backend::Selection::Docker, + database: pg_client::Database::POSTGRES, + seeds: indexmap::IndexMap::new(), + ssl_config: None, + superuser: pg_client::User::POSTGRES, + image: "18.0".parse().unwrap(), + cross_container_access: false, + wait_available_timeout: std::time::Duration::from_secs(10), + } + ), + ( + crate::InstanceName::from_static_or_panic("b"), + crate::Instance { + application_name: None, + backend: ociman::backend::Selection::Docker, + database: pg_client::Database::POSTGRES, + seeds: indexmap::IndexMap::new(), + ssl_config: None, + superuser: pg_client::User::POSTGRES, + image: "18.0".parse().unwrap(), + cross_container_access: false, + wait_available_timeout: std::time::Duration::from_secs(10), + } + ) + ]), + crate::Config::load_toml_file( + &path, + &crate::config::InstanceDefinition { + backend: Some(ociman::backend::Selection::Docker), + image: Some("18.0".parse().unwrap()), + seeds: indexmap::IndexMap::new(), + ssl_config: None, + wait_available_timeout: None, + } + ) + .unwrap() + ); + + Ok(()) + }) +} + +fn config_file_no_explicit_instance() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let dir = TestDir::new("config-file-no-explicit-instance"); + dir.write_file("database.toml", DATABASE_NO_EXPLICIT_INSTANCE_TOML); + let path = dir.path.join("database.toml"); + + assert_eq!( + crate::InstanceMap::from([( + crate::InstanceName::MAIN, + crate::Instance { + application_name: None, + backend: ociman::backend::Selection::Docker, + database: pg_client::Database::POSTGRES, + seeds: indexmap::IndexMap::new(), + ssl_config: None, + superuser: pg_client::User::POSTGRES, + image: "17.1".parse().unwrap(), + cross_container_access: false, + wait_available_timeout: std::time::Duration::from_secs(10), + } + ),]), + crate::Config::load_toml_file(&path, &crate::config::InstanceDefinition::empty()) + .unwrap() + ); + + assert_eq!( + crate::InstanceMap::from([( + crate::InstanceName::MAIN, + crate::Instance { + application_name: None, + backend: ociman::backend::Selection::Podman, + database: pg_client::Database::POSTGRES, + seeds: indexmap::IndexMap::new(), + ssl_config: None, + superuser: pg_client::User::POSTGRES, + image: "18.0".parse().unwrap(), + cross_container_access: false, + wait_available_timeout: std::time::Duration::from_secs(10), + } + ),]), + crate::Config::load_toml_file( + &path, + &crate::config::InstanceDefinition { + backend: Some(ociman::backend::Selection::Podman), + image: Some("18.0".parse().unwrap()), + seeds: indexmap::IndexMap::new(), + ssl_config: None, + wait_available_timeout: None, + } + ) + .unwrap() + ); + + Ok(()) + }) +} + +fn config_ssl() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let config_str = indoc::indoc! {r#" + backend = "docker" + image = "18.0" + + [ssl_config] + hostname = "postgresql.example.com" + + [instances.main] + "#}; + + assert_eq!( + crate::InstanceMap::from([( + crate::InstanceName::MAIN, + crate::Instance { + application_name: None, + backend: ociman::backend::Selection::Docker, + database: pg_client::Database::POSTGRES, + seeds: indexmap::IndexMap::new(), + ssl_config: Some(crate::definition::SslConfig::Generated { + hostname: "postgresql.example.com".parse().unwrap(), + }), + superuser: pg_client::User::POSTGRES, + image: "18.0".parse().unwrap(), + cross_container_access: false, + wait_available_timeout: std::time::Duration::from_secs(10), + } + )]), + crate::Config::load_toml(config_str) + .unwrap() + .instance_map(&crate::config::InstanceDefinition::empty()) + .unwrap() + ); + + Ok(()) + }) +} + +fn run_env() -> Result<(), Failed> { + const DATABASE_URL: cmd_proc::EnvVariableName<'static> = + cmd_proc::EnvVariableName::from_static_or_panic("DATABASE_URL"); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + + super::common::test_definition(backend) + .with_container(async |container| { + // Use sh -c to emit both PG* and DATABASE_URL + let output = cmd_proc::Command::new("sh") + .argument("-c") + .argument("(env | grep '^PG' | sort) && echo DATABASE_URL=$DATABASE_URL") + .envs(container.pg_env()) + .env(&DATABASE_URL, container.database_url()) + .stdout_capture() + .stderr_capture() + .run() + .await + .unwrap(); + + let actual = String::from_utf8(output.stdout).unwrap(); + + // Generate expected output from config + let pg_env = container.pg_env(); + let mut expected_lines: Vec = pg_env + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(); + expected_lines.sort(); + expected_lines.push(format!("DATABASE_URL={}", container.database_url())); + let expected = format!("{}\n", expected_lines.join("\n")); + + assert_eq!( + expected, actual, + "Environment variables mismatch.\nExpected:\n{expected}\nActual:\n{actual}" + ); + }) + .await + .unwrap(); + + Ok(()) + }) +} + +fn config_seeds_basic() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let toml = indoc::indoc! {r#" + backend = "docker" + image = "17.1" + + [instances.main.seeds.create-users-table] + type = "sql-file" + path = "tests/fixtures/create_users.sql" + + [instances.main.seeds.insert-test-data] + type = "sql-file" + path = "tests/fixtures/insert_users.sql" + "#}; + + let config = crate::Config::load_toml(toml) + .unwrap() + .instance_map(&crate::config::InstanceDefinition::empty()) + .unwrap(); + + let definition = config.get(&crate::InstanceName::MAIN).unwrap(); + + let expected_seeds: indexmap::IndexMap = [ + ( + "create-users-table".parse().unwrap(), + crate::Seed::SqlFile { + path: "tests/fixtures/create_users.sql".into(), + }, + ), + ( + "insert-test-data".parse().unwrap(), + crate::Seed::SqlFile { + path: "tests/fixtures/insert_users.sql".into(), + }, + ), + ] + .into(); + + assert_eq!(definition.seeds, expected_seeds); + + Ok(()) + }) +} + +fn config_seeds_command() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let toml = indoc::indoc! {r#" + backend = "docker" + image = "17.1" + + [instances.main.seeds.setup-schema] + type = "sql-file" + path = "tests/fixtures/schema.sql" + + [instances.main.seeds.run-migration] + type = "command" + command = "migrate" + arguments = ["up"] + cache.type = "command-hash" + "#}; + + let config = crate::Config::load_toml(toml) + .unwrap() + .instance_map(&crate::config::InstanceDefinition::empty()) + .unwrap(); + + let definition = config.get(&crate::InstanceName::MAIN).unwrap(); + + let expected_seeds: indexmap::IndexMap = [ + ( + "setup-schema".parse().unwrap(), + crate::Seed::SqlFile { + path: "tests/fixtures/schema.sql".into(), + }, + ), + ( + "run-migration".parse().unwrap(), + crate::Seed::Command { + command: crate::Command::new("migrate", ["up"]), + cache: crate::SeedCacheConfig::CommandHash, + }, + ), + ] + .into(); + + assert_eq!(definition.seeds, expected_seeds); + + Ok(()) + }) +} + +fn config_seeds_script() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let toml = indoc::indoc! {r#" + backend = "docker" + image = "17.1" + + [instances.main.seeds.initialize] + type = "script" + script = "echo 'Starting setup' && psql -c 'CREATE TABLE test (id INT)'" + "#}; + + let config = crate::Config::load_toml(toml) + .unwrap() + .instance_map(&crate::config::InstanceDefinition::empty()) + .unwrap(); + + let definition = config.get(&crate::InstanceName::MAIN).unwrap(); + + let expected_seeds: indexmap::IndexMap = [( + "initialize".parse().unwrap(), + crate::Seed::Script { + script: "echo 'Starting setup' && psql -c 'CREATE TABLE test (id INT)'".to_string(), + cache: crate::SeedCacheConfig::CommandHash, + }, + )] + .into(); + + assert_eq!(definition.seeds, expected_seeds); + + Ok(()) + }) +} + +fn config_seeds_mixed() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let toml = indoc::indoc! {r#" + backend = "docker" + image = "17.1" + + [instances.main.seeds.schema] + type = "sql-file" + path = "tests/fixtures/schema.sql" + + [instances.main.seeds.migrate] + type = "command" + command = "migrate" + arguments = ["up", "--verbose"] + cache.type = "command-hash" + + [instances.main.seeds.verify] + type = "script" + script = "psql -c 'SELECT COUNT(*) FROM users'" + "#}; + + let config = crate::Config::load_toml(toml) + .unwrap() + .instance_map(&crate::config::InstanceDefinition::empty()) + .unwrap(); + + let definition = config.get(&crate::InstanceName::MAIN).unwrap(); + + let expected_seeds: indexmap::IndexMap = [ + ( + "schema".parse().unwrap(), + crate::Seed::SqlFile { + path: "tests/fixtures/schema.sql".into(), + }, + ), + ( + "migrate".parse().unwrap(), + crate::Seed::Command { + command: crate::Command::new("migrate", ["up", "--verbose"]), + cache: crate::SeedCacheConfig::CommandHash, + }, + ), + ( + "verify".parse().unwrap(), + crate::Seed::Script { + script: "psql -c 'SELECT COUNT(*) FROM users'".to_string(), + cache: crate::SeedCacheConfig::CommandHash, + }, + ), + ] + .into(); + + assert_eq!(definition.seeds, expected_seeds); + + Ok(()) + }) +} + +fn config_seeds_preserve_declaration_order() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + // Seed names are intentionally in reverse-alphabetic order so that a + // declaration-order-preserving parser produces [z, m, a] while a + // TOML parser that materializes tables through a sorted map produces + // [a, m, z]. `IndexMap::PartialEq` is order-insensitive, so we compare + // the key sequence directly via iter(). + let toml = indoc::indoc! {r#" + backend = "docker" + image = "17.1" + + [instances.main.seeds.z-first] + type = "sql-file" + path = "first.sql" + + [instances.main.seeds.m-second] + type = "sql-file" + path = "second.sql" + + [instances.main.seeds.a-third] + type = "sql-file" + path = "third.sql" + "#}; + + let config = crate::Config::load_toml(toml) + .unwrap() + .instance_map(&crate::config::InstanceDefinition::empty()) + .unwrap(); + + let definition = config.get(&crate::InstanceName::MAIN).unwrap(); + + let seed_names: Vec<&str> = definition.seeds.keys().map(|name| name.as_ref()).collect(); + + assert_eq!(seed_names, vec!["z-first", "m-second", "a-third"]); + + Ok(()) + }) +} + +fn config_seeds_duplicate_name() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let toml = indoc::indoc! {r#" + backend = "docker" + image = "17.1" + + [instances.main.seeds.duplicate] + type = "sql-file" + path = "first.sql" + + [instances.main.seeds.duplicate] + type = "sql-file" + path = "second.sql" + "#}; + + let error = crate::Config::load_toml(toml).unwrap_err(); + + assert_eq!( + error.to_string(), + indoc::indoc! {" + Decoding as toml failed: TOML parse error at line 8, column 23 + | + 8 | [instances.main.seeds.duplicate] + | ^^^^^^^^^ + duplicate key + "} + ); + + Ok(()) + }) +} + +fn config_seeds_with_git_revision() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let toml = indoc::indoc! {r#" + backend = "docker" + image = "17.1" + + [instances.main.seeds.from-git] + type = "sql-file" + path = "tests/fixtures/schema.sql" + git_revision = "main" + + [instances.main.seeds.from-filesystem] + type = "sql-file" + path = "tests/fixtures/create_users.sql" + "#}; + + let config = crate::Config::load_toml(toml) + .unwrap() + .instance_map(&crate::config::InstanceDefinition::empty()) + .unwrap(); + + let definition = config.get(&crate::InstanceName::MAIN).unwrap(); + + let expected_seeds: indexmap::IndexMap = [ + ( + "from-git".parse().unwrap(), + crate::Seed::SqlFileGitRevision { + git_revision: "main".to_string(), + path: "tests/fixtures/schema.sql".into(), + }, + ), + ( + "from-filesystem".parse().unwrap(), + crate::Seed::SqlFile { + path: "tests/fixtures/create_users.sql".into(), + }, + ), + ] + .into(); + + assert_eq!(definition.seeds, expected_seeds); + + Ok(()) + }) +} + +fn config_image_with_sha256_digest() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let config_str = indoc::indoc! {r#" + backend = "docker" + image = "17.6@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + [instances.main] + "#}; + + let expected_image: crate::Image = + "17.6@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + .parse() + .unwrap(); + + assert_eq!( + crate::InstanceMap::from([( + crate::InstanceName::MAIN, + crate::Instance { + application_name: None, + backend: ociman::backend::Selection::Docker, + database: pg_client::Database::POSTGRES, + seeds: indexmap::IndexMap::new(), + ssl_config: None, + superuser: pg_client::User::POSTGRES, + image: expected_image.clone(), + cross_container_access: false, + wait_available_timeout: std::time::Duration::from_secs(10), + } + )]), + crate::Config::load_toml(config_str) + .unwrap() + .instance_map(&crate::config::InstanceDefinition::empty()) + .unwrap() + ); + + // Verify the ociman::image::Reference conversion includes the digest + let reference: ociman::image::Reference = (&expected_image).into(); + assert_eq!( + reference.to_string(), + "registry.hub.docker.com/library/postgres:17.6@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ); + + Ok(()) + }) +} + +fn config_invalid_image_format() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let config_str = indoc::indoc! {r#" + backend = "docker" + image = "17.6@sha256:tooshort" + + [instances.main] + "#}; + + let error = crate::Config::load_toml(config_str) + .unwrap_err() + .to_string(); + + let expected = indoc::indoc! {" + Decoding as toml failed: TOML parse error at line 2, column 9 + | + 2 | image = \"17.6@sha256:tooshort\" + | ^^^^^^^^^^^^^^^^^^^^^^ + 0: at line 1, in TakeWhileMN: + 17.6@sha256:tooshort + ^ + + 1: at line 1, in digest: + 17.6@sha256:tooshort + ^ + + 2: at line 1, in official release image: + 17.6@sha256:tooshort + ^ + + + "}; + + assert_eq!(error, expected); + + Ok(()) + }) +} + +fn config_invalid_image_nom_error() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + // This tests an image format that triggers nom's detailed error with caret + let config_str = indoc::indoc! {r#" + backend = "docker" + image = "INVALID" + + [instances.main] + "#}; + + let error = crate::Config::load_toml(config_str) + .unwrap_err() + .to_string(); + + let expected = indoc::indoc! {" + Decoding as toml failed: TOML parse error at line 2, column 9 + | + 2 | image = \"INVALID\" + | ^^^^^^^^^ + 0: at line 1, in TakeWhileMN: + INVALID + ^ + + 1: at line 1, in OS name: + INVALID + ^ + + 2: at line 1, in OS-only image: + INVALID + ^ + + 3: at line 1, in Alt: + INVALID + ^ + + + "}; + + assert_eq!(error, expected); + + Ok(()) + }) +} diff --git a/pg-ephemeral/src/meta/test/cache.rs b/pg-ephemeral/src/meta/test/cache.rs new file mode 100644 index 00000000..5cdd4213 --- /dev/null +++ b/pg-ephemeral/src/meta/test/cache.rs @@ -0,0 +1,1518 @@ +//! Exercises the cache image lifecycle (populate -> hit/miss decisions), +//! the `cache status` JSON surface, and the `Hit`/`Miss`/`Uncacheable` +//! classification across seed types and key strategies. + +use libtest_mimic::{Failed, Trial}; + +use super::common::{TestDir, TestGitRepo, run_pg_ephemeral}; + +#[must_use] +pub fn trials() -> Vec { + vec![ + Trial::test("populate_cache", populate_cache), + Trial::test( + "populate_cache_runs_seeds_in_declaration_order", + populate_cache_runs_seeds_in_declaration_order, + ), + Trial::test("cache_status", cache_status), + Trial::test("cache_status_deterministic", cache_status_deterministic), + Trial::test( + "cache_status_uncacheable_reason", + cache_status_uncacheable_reason, + ), + Trial::test( + "cache_status_change_with_content", + cache_status_change_with_content, + ), + Trial::test( + "cache_status_change_with_image", + cache_status_change_with_image, + ), + Trial::test( + "cache_status_chain_propagates", + cache_status_chain_propagates, + ), + Trial::test("cache_status_key_command", cache_status_key_command), + Trial::test( + "cache_status_key_script_on_command_seed", + cache_status_key_script_on_command_seed, + ), + Trial::test( + "cli_key_script_failure_reports_display", + cli_key_script_failure_reports_display, + ), + Trial::test( + "cache_status_key_script_failure_propagates", + cache_status_key_script_failure_propagates, + ), + Trial::test( + "cache_status_key_script_on_script_seed", + cache_status_key_script_on_script_seed, + ), + Trial::test("cache_status_change_with_ssl", cache_status_change_with_ssl), + Trial::test( + "cache_status_container_script", + cache_status_container_script, + ), + Trial::test( + "populate_cache_container_script", + populate_cache_container_script, + ), + Trial::test( + "container_script_with_pg_cron", + container_script_with_pg_cron, + ), + Trial::test( + "stale_connection_terminated_before_stop", + stale_connection_terminated_before_stop, + ), + Trial::test( + "cache_credentials_default_seed", + cache_credentials_default_seed, + ), + Trial::test( + "cache_credentials_explicit_seed_name", + cache_credentials_explicit_seed_name, + ), + Trial::test("cache_credentials_no_seeds", cache_credentials_no_seeds), + Trial::test( + "cache_credentials_uncacheable_tip", + cache_credentials_uncacheable_tip, + ), + Trial::test( + "cache_credentials_unknown_seed", + cache_credentials_unknown_seed, + ), + Trial::test("cache_credentials_miss_tip", cache_credentials_miss_tip), + ] +} + +fn populate_cache() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + let instance_name: crate::InstanceName = "populate-cache-test".parse().unwrap(); + + // Clean up any leftover images from previous runs + let name: ociman::reference::Name = format!("localhost/pg-ephemeral/{instance_name}") + .parse() + .unwrap(); + for reference in backend.image_references_by_name(&name).await { + backend.remove_image_force(&reference).await; + } + + let definition = crate::Definition::new(backend.clone(), crate::Image::default(), instance_name.clone()) + .wait_available_timeout(std::time::Duration::from_secs(30)) + .apply_script( + "schema-and-data".parse().unwrap(), + r##"psql -c "CREATE TABLE test_cache (id INTEGER PRIMARY KEY); INSERT INTO test_cache VALUES (42);""##, + crate::SeedCacheConfig::CommandHash, + ) + .unwrap(); + + // Verify cache status is Miss initially + let loaded_seeds = definition.load_seeds(&instance_name).await.unwrap(); + for seed in loaded_seeds.iter_seeds() { + assert!(!seed.cache_status().is_hit()); + } + + // Populate cache + definition.populate_cache(&loaded_seeds).await.unwrap(); + + // Verify cache status is now Hit + let loaded_seeds = definition.load_seeds(&instance_name).await.unwrap(); + for seed in loaded_seeds.iter_seeds() { + assert!(seed.cache_status().is_hit()); + } + + // Boot from the cached image using with_container (which handles cache hits properly) + // and verify the seed effect is present + definition + .with_container(async |container| { + container + .with_connection(async |connection| { + let row: (i32,) = sqlx::query_as("SELECT id FROM test_cache") + .fetch_one(&mut *connection) + .await + .unwrap(); + assert_eq!(row.0, 42); + }) + .await; + }) + .await + .unwrap(); + + // Clean up images + for reference in backend.image_references_by_name(&name).await { + backend.remove_image_force(&reference).await; + } + + Ok(()) + }) +} + +fn populate_cache_runs_seeds_in_declaration_order() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + let instance_name: crate::InstanceName = "populate-cache-order-test".parse().unwrap(); + + // Clean up any leftover images from previous runs + let name: ociman::reference::Name = format!("localhost/pg-ephemeral/{instance_name}") + .parse() + .unwrap(); + for reference in backend.image_references_by_name(&name).await { + backend.remove_image_force(&reference).await; + } + + // Seed names are declared in reverse alphabetic order (z -> m -> a) and each + // seed depends on the previous one having executed. If populate_cache ever + // sorts by name instead of honoring declaration order, the "m-insert" step + // would run before "z-create-table" and fail because the table does not + // exist yet. + let definition = crate::Definition::new( + backend.clone(), + crate::Image::default(), + instance_name.clone(), + ) + .wait_available_timeout(std::time::Duration::from_secs(30)) + .apply_script( + "z-create-table".parse().unwrap(), + r#"psql -c "CREATE TABLE order_test (value INTEGER)""#, + crate::SeedCacheConfig::CommandHash, + ) + .unwrap() + .apply_script( + "m-insert-row".parse().unwrap(), + r#"psql -c "INSERT INTO order_test VALUES (1)""#, + crate::SeedCacheConfig::CommandHash, + ) + .unwrap() + .apply_script( + "a-update-row".parse().unwrap(), + r#"psql -c "UPDATE order_test SET value = 2 WHERE value = 1""#, + crate::SeedCacheConfig::CommandHash, + ) + .unwrap(); + + // Populate cache - this will fail if seeds run in alphabetic order because + // a-update-row references a table that z-create-table has not yet created. + let loaded_seeds = definition.load_seeds(&instance_name).await.unwrap(); + definition.populate_cache(&loaded_seeds).await.unwrap(); + + // Boot from the cached image and verify all three seeds ran in declaration + // order: table created, row inserted with value 1, row updated to value 2. + definition + .with_container(async |container| { + container + .with_connection(async |connection| { + let row: (i32,) = sqlx::query_as("SELECT value FROM order_test") + .fetch_one(&mut *connection) + .await + .unwrap(); + assert_eq!(row.0, 2); + }) + .await; + }) + .await + .unwrap(); + + // Clean up images + for reference in backend.image_references_by_name(&name).await { + backend.remove_image_force(&reference).await; + } + + Ok(()) + }) +} + +fn cache_status() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + let repo = TestGitRepo::new("cache-test").await; + + repo.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); + repo.write_file("data.sql", "INSERT INTO users (id) VALUES (1);"); + let commit_hash = repo.commit("Initial").await; + + let config_content = indoc::formatdoc! {r#" + image = "17.1" + + [instances.main.seeds.a-schema] + type = "sql-file" + path = "schema.sql" + + [instances.main.seeds.b-data-from-git] + type = "sql-file" + path = "data.sql" + git_revision = "{commit_hash}" + + [instances.main.seeds.c-run-command] + type = "command" + command = "echo" + arguments = ["hello"] + cache.type = "command-hash" + + [instances.main.seeds.d-run-script] + type = "script" + script = "echo 'hello world'" + "#}; + repo.write_file("database.toml", &config_content); + + let expected = indoc::indoc! {r#" + { + "instance": "main", + "base_image": "17.1", + "version": "0.4.0", + "summary": { + "total": 4, + "hits": 0, + "misses": 4, + "uncacheable": 0 + }, + "seeds": [ + { + "name": "a-schema", + "type": "sql-file", + "status": "miss", + "cache_image": "pg-ephemeral/main:8ee3896ee958931123af048077d74fd9758b4dd494450f29e11f909f2ed8160a" + }, + { + "name": "b-data-from-git", + "type": "sql-file-git-revision", + "status": "miss", + "cache_image": "pg-ephemeral/main:a3c02b59a0dbc21abeed0aec932496906d944e049ab06a1cc524882f6b5c7698" + }, + { + "name": "c-run-command", + "type": "command", + "status": "miss", + "cache_image": "pg-ephemeral/main:c2a894f18fef10ca9f960eb49e93c3fdcb9d1a48311d19965fc57544359dffa7" + }, + { + "name": "d-run-script", + "type": "script", + "status": "miss", + "cache_image": "pg-ephemeral/main:7f2ce26f39977a2d7f8d09497b354576474f457d677c5101dc5d35886c8a8154" + } + ] + } + "#}; + + let stdout = run_pg_ephemeral(&["cache", "status", "--json"], &repo.path).await; + assert_eq!(stdout, expected); + + Ok(()) + }) +} + +fn cache_status_deterministic() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + let dir = TestDir::new("cache-deterministic-test"); + + dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); + + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.schema] + type = "sql-file" + path = "schema.sql" + "#}, + ); + + let expected = indoc::indoc! {r#" + { + "instance": "main", + "base_image": "17.1", + "version": "0.4.0", + "summary": { + "total": 1, + "hits": 0, + "misses": 1, + "uncacheable": 0 + }, + "seeds": [ + { + "name": "schema", + "type": "sql-file", + "status": "miss", + "cache_image": "pg-ephemeral/main:8ee3896ee958931123af048077d74fd9758b4dd494450f29e11f909f2ed8160a" + } + ] + } + "#}; + + let stdout = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + assert_eq!(stdout, expected); + + Ok(()) + }) +} + +fn cache_status_uncacheable_reason() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + let dir = TestDir::new("cache-uncacheable-reason-test"); + + // Same schema.sql + image as `cache_status_deterministic`, so the + // schema seed's reference hash is fixed and we can assert against an + // exact JSON. + dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); + + // chain: cacheable schema -> chain-breaker `nope` (cache=none) + // -> chain-broken `tail` (uncacheable because predecessor broke chain) + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.schema] + type = "sql-file" + path = "schema.sql" + + [instances.main.seeds.nope] + type = "script" + script = "true" + cache = { type = "none" } + + [instances.main.seeds.tail] + type = "sql-statement" + statement = "SELECT 1" + "#}, + ); + + let expected = serde_json::json!({ + "instance": "main", + "base_image": "17.1", + "version": "0.4.0", + "summary": { + "total": 3, + "hits": 0, + "misses": 1, + "uncacheable": 2, + }, + "seeds": [ + { + "name": "schema", + "type": "sql-file", + "status": "miss", + "cache_image": "pg-ephemeral/main:8ee3896ee958931123af048077d74fd9758b4dd494450f29e11f909f2ed8160a", + }, + { + "name": "nope", + "type": "script", + "status": "uncacheable", + "reason": "cache_strategy_none", + }, + { + "name": "tail", + "type": "sql-statement", + "status": "uncacheable", + "reason": "chain_broken_by_predecessor", + "broken_by": "nope", + }, + ], + }); + + let stdout = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + let actual: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(actual, expected); + + Ok(()) + }) +} + +fn cache_status_change_with_content() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + let dir = TestDir::new("cache-changes-test"); + + dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); + + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.schema] + type = "sql-file" + path = "schema.sql" + "#}, + ); + + let stdout1 = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + + dir.write_file( + "schema.sql", + "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);", + ); + + let stdout2 = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + // Cache reference should change when content changes + assert_ne!(stdout2, stdout1); + + Ok(()) + }) +} + +fn cache_status_change_with_image() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + let dir = TestDir::new("cache-image-test"); + + dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); + + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.schema] + type = "sql-file" + path = "schema.sql" + "#}, + ); + + let stdout1 = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.2" + + [instances.main.seeds.schema] + type = "sql-file" + path = "schema.sql" + "#}, + ); + + let stdout2 = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + // Cache reference should change when image changes + assert_ne!(stdout2, stdout1); + + Ok(()) + }) +} + +fn cache_status_chain_propagates() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + let dir = TestDir::new("cache-chain-test"); + + dir.write_file("first.sql", "CREATE TABLE first (id INTEGER);"); + dir.write_file("second.sql", "CREATE TABLE second (id INTEGER);"); + + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.a-first] + type = "sql-file" + path = "first.sql" + + [instances.main.seeds.b-second] + type = "sql-file" + path = "second.sql" + "#}, + ); + + let expected_before = indoc::indoc! {r#" + { + "instance": "main", + "base_image": "17.1", + "version": "0.4.0", + "summary": { + "total": 2, + "hits": 0, + "misses": 2, + "uncacheable": 0 + }, + "seeds": [ + { + "name": "a-first", + "type": "sql-file", + "status": "miss", + "cache_image": "pg-ephemeral/main:5982415ac9ad91019e69496c59dffc68df698668acabd8038291fa0467387a10" + }, + { + "name": "b-second", + "type": "sql-file", + "status": "miss", + "cache_image": "pg-ephemeral/main:b75441a4063765e42528ff76a6587fa1d8a4b9debf60cfaf9d58f08c0f8cac29" + } + ] + } + "#}; + + let stdout1 = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + assert_eq!(stdout1, expected_before); + + dir.write_file("first.sql", "CREATE TABLE first (id INTEGER, name TEXT);"); + + let stdout2 = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + // Cache reference should change when first seed changes, and propagate to second seed + assert_ne!(stdout2, expected_before); + + Ok(()) + }) +} + +fn cache_status_key_command() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + let dir = TestDir::new("cache-key-command-test"); + + dir.write_file("version.txt", "1.0.0"); + + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.run-migrations] + type = "command" + command = "migrate" + arguments = ["up"] + + [instances.main.seeds.run-migrations.cache] + type = "key-command" + command = "cat" + arguments = ["version.txt"] + "#}, + ); + + let expected_before = indoc::indoc! {r#" + { + "instance": "main", + "base_image": "17.1", + "version": "0.4.0", + "summary": { + "total": 1, + "hits": 0, + "misses": 1, + "uncacheable": 0 + }, + "seeds": [ + { + "name": "run-migrations", + "type": "command", + "status": "miss", + "cache_image": "pg-ephemeral/main:5b31c8c9895000f43d0cf14914d8dff86e1c0a3b01a954e05bc96b3511992f5c" + } + ] + } + "#}; + + let stdout1 = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + assert_eq!(stdout1, expected_before); + + // Change the version file - cache reference should change + dir.write_file("version.txt", "2.0.0"); + + let stdout2 = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + // Cache reference should change when key command output changes + assert_ne!(stdout2, expected_before); + + Ok(()) + }) +} + +/// Parse a TOML config in-line and return the cache reference of each seed in the +/// `main` instance. Panics on any error. +async fn seed_references(toml: &str) -> Vec { + let instance_name = crate::InstanceName::MAIN; + let instances = crate::Config::load_toml(toml) + .unwrap() + .instance_map(&crate::config::InstanceDefinition::empty()) + .unwrap(); + let definition = instances + .get(&instance_name) + .unwrap() + .definition(&instance_name) + .await + .unwrap(); + definition + .load_seeds(&instance_name) + .await + .unwrap() + .iter_seeds() + .map(|seed| seed.cache_status().reference().unwrap().to_string()) + .collect() +} + +fn cache_status_key_script_on_command_seed() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + + let baseline = seed_references(indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.run-migrations] + type = "command" + command = "migrate" + arguments = ["up"] + + [instances.main.seeds.run-migrations.cache] + type = "key-script" + script = "echo version-1" + "#}) + .await; + + // Changing the key-script output invalidates the cache. + let after_key_change = seed_references(indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.run-migrations] + type = "command" + command = "migrate" + arguments = ["up"] + + [instances.main.seeds.run-migrations.cache] + type = "key-script" + script = "echo version-2" + "#}) + .await; + assert_ne!(after_key_change, baseline); + + // Changing command arguments also invalidates the cache, even though the + // key-script output is unchanged. Regression guard for the bug where + // key-script output used to replace rather than supplement the command hash. + let after_args_change = seed_references(indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.run-migrations] + type = "command" + command = "migrate" + arguments = ["down"] + + [instances.main.seeds.run-migrations.cache] + type = "key-script" + script = "echo version-1" + "#}) + .await; + assert_ne!(after_args_change, baseline); + + Ok(()) + }) +} + +fn cli_key_script_failure_reports_display() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + let dir = TestDir::new("cli-key-script-failure-display-test"); + + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.run-migrations] + type = "command" + command = "migrate" + arguments = ["up"] + + [instances.main.seeds.run-migrations.cache] + type = "key-script" + script = "exit 1" + "#}, + ); + + let pg_ephemeral_bin = std::env::current_exe().unwrap(); + let output = cmd_proc::Command::new(pg_ephemeral_bin) + .arguments(["cache", "status"]) + .working_directory(&dir.path) + .stdout_capture() + .stderr_capture() + .accept_nonzero_exit() + .run() + .await + .unwrap(); + + assert!(!output.status.success()); + + // main() must print thiserror's Display-formatted source chain, not the Debug tuple-variant dump. + let stderr = String::from_utf8(output.stderr).unwrap(); + assert_eq!( + stderr, + indoc::indoc! {" + Error: Failed to load seed run-migrations: cache key script failed + caused by: command exited with exit status: 1 + "}, + ); + + Ok(()) + }) +} + +fn cache_status_key_script_failure_propagates() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + + let instance_name = crate::InstanceName::MAIN; + let instances = crate::Config::load_toml(indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.run-migrations] + type = "command" + command = "migrate" + arguments = ["up"] + + [instances.main.seeds.run-migrations.cache] + type = "key-script" + script = "exit 1" + "#}) + .unwrap() + .instance_map(&crate::config::InstanceDefinition::empty()) + .unwrap(); + let definition = instances + .get(&instance_name) + .unwrap() + .definition(&instance_name) + .await + .unwrap(); + + let error = definition.load_seeds(&instance_name).await.unwrap_err(); + + assert!( + matches!(error, crate::LoadError::KeyScript { .. }), + "expected LoadError::KeyScript, got: {error:?}" + ); + + Ok(()) + }) +} + +fn cache_status_key_script_on_script_seed() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + + let baseline = seed_references(indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.seed-data] + type = "script" + script = "psql -c 'SELECT 1'" + + [instances.main.seeds.seed-data.cache] + type = "key-script" + script = "echo version-1" + "#}) + .await; + + // Changing the key-script output invalidates the cache. + let after_key_change = seed_references(indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.seed-data] + type = "script" + script = "psql -c 'SELECT 1'" + + [instances.main.seeds.seed-data.cache] + type = "key-script" + script = "echo version-2" + "#}) + .await; + assert_ne!(after_key_change, baseline); + + // Changing the script body also invalidates the cache, even though the + // key-script output is unchanged. + let after_script_change = seed_references(indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.seed-data] + type = "script" + script = "psql -c 'SELECT 2'" + + [instances.main.seeds.seed-data.cache] + type = "key-script" + script = "echo version-1" + "#}) + .await; + assert_ne!(after_script_change, baseline); + + Ok(()) + }) +} + +fn cache_status_change_with_ssl() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + let dir = TestDir::new("cache-ssl-test"); + + dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); + + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.schema] + type = "sql-file" + path = "schema.sql" + "#}, + ); + + let output_no_ssl = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + + // Add SSL config + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [ssl_config] + hostname = "localhost" + + [instances.main.seeds.schema] + type = "sql-file" + path = "schema.sql" + "#}, + ); + + let output_with_ssl = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + + // Cache key should change when SSL config is added + assert_ne!(output_no_ssl, output_with_ssl); + + // Change SSL hostname + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [ssl_config] + hostname = "example.com" + + [instances.main.seeds.schema] + type = "sql-file" + path = "schema.sql" + "#}, + ); + + let output_different_ssl = + run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + + // Cache key should change when SSL hostname changes + assert_ne!(output_with_ssl, output_different_ssl); + + Ok(()) + }) +} + +fn cache_status_container_script() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + let dir = TestDir::new("cache-container-script-test"); + + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.install-ext] + type = "container-script" + script = "touch /container-script-marker" + "#}, + ); + + let stdout = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; + let output: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + + assert_eq!(output["seeds"][0]["name"], "install-ext"); + assert_eq!(output["seeds"][0]["type"], "container-script"); + assert_eq!(output["seeds"][0]["status"], "miss"); + assert!(output["seeds"][0]["cache_image"].is_string()); + + Ok(()) + }) +} + +fn populate_cache_container_script() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + let instance_name: crate::InstanceName = + "populate-cache-container-script-test".parse().unwrap(); + + // Clean up any leftover images from previous runs + let name: ociman::reference::Name = format!("localhost/pg-ephemeral/{instance_name}") + .parse() + .unwrap(); + for reference in backend.image_references_by_name(&name).await { + backend.remove_image_force(&reference).await; + } + + let definition = crate::Definition::new( + backend.clone(), + crate::Image::default(), + instance_name.clone(), + ) + .wait_available_timeout(std::time::Duration::from_secs(30)) + .apply_container_script( + "create-marker".parse().unwrap(), + "touch /container-script-marker", + ) + .unwrap(); + + // Verify cache status is Miss initially + let loaded_seeds = definition.load_seeds(&instance_name).await.unwrap(); + for seed in loaded_seeds.iter_seeds() { + assert!(!seed.cache_status().is_hit()); + } + + // Populate cache + definition.populate_cache(&loaded_seeds).await.unwrap(); + + // Verify cache status is now Hit + let loaded_seeds = definition.load_seeds(&instance_name).await.unwrap(); + for seed in loaded_seeds.iter_seeds() { + assert!(seed.cache_status().is_hit()); + } + + // Boot from the cached image and verify PG starts cleanly + definition + .with_container(async |container| { + container + .with_connection(async |connection| { + let row: (bool,) = sqlx::query_as("SELECT true") + .fetch_one(&mut *connection) + .await + .unwrap(); + assert!(row.0); + }) + .await; + }) + .await + .unwrap(); + + // Clean up images + for reference in backend.image_references_by_name(&name).await { + backend.remove_image_force(&reference).await; + } + + Ok(()) + }) +} + +fn container_script_with_pg_cron() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + let instance_name: crate::InstanceName = + "container-script-pg-cron-test".parse().unwrap(); + + // Clean up any leftover images from previous runs + let name: ociman::reference::Name = format!("localhost/pg-ephemeral/{instance_name}") + .parse() + .unwrap(); + for reference in backend.image_references_by_name(&name).await { + backend.remove_image_force(&reference).await; + } + + let definition = crate::Definition::new( + backend.clone(), + "17".parse().unwrap(), + instance_name.clone(), + ) + .wait_available_timeout(std::time::Duration::from_secs(30)) + .apply_container_script( + "install-pg-cron".parse().unwrap(), + "apt-get update && apt-get install -y --no-install-recommends postgresql-17-cron \ + && printf '#!/bin/bash\\necho \"shared_preload_libraries = '\"'\"'pg_cron'\"'\"'\" >> \"$PGDATA/postgresql.conf\"\\n' \ + > /docker-entrypoint-initdb.d/pg-cron.sh \ + && chmod +x /docker-entrypoint-initdb.d/pg-cron.sh", + ) + .unwrap() + .apply_script( + "enable-pg-cron".parse().unwrap(), + r#"psql -c "CREATE EXTENSION pg_cron""#, + crate::SeedCacheConfig::CommandHash, + ) + .unwrap(); + + definition + .with_container(async |container| { + container + .with_connection(async |connection| { + let row: (String,) = sqlx::query_as( + "SELECT extname::text FROM pg_extension WHERE extname = 'pg_cron'", + ) + .fetch_one(&mut *connection) + .await + .unwrap(); + assert_eq!(row.0, "pg_cron"); + }) + .await; + }) + .await + .unwrap(); + + // Clean up images + for reference in backend.image_references_by_name(&name).await { + backend.remove_image_force(&reference).await; + } + + Ok(()) + }) +} + +fn stale_connection_terminated_before_stop() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + + let definition = super::common::test_definition(backend); + + // with_container returns the JoinHandle; stop() runs before it returns. + // The closure intentionally yields the JoinHandle so it can be awaited + // after stop() has terminated the connection. + #[allow( + clippy::async_yields_async, + reason = "JoinHandle returned for post-stop awaiting" + )] + let sleep_handle = definition + .with_container(async |container| { + let config = container.client_config().to_sqlx_connect_options().unwrap(); + let mut connection = sqlx::ConnectOptions::connect(&config).await.unwrap(); + + tokio::spawn(async move { + sqlx::query("SELECT pg_sleep(3600)") + .execute(&mut connection) + .await + }) + }) + .await + .unwrap(); + + // stop() terminated all connections before shutting down. + // The sleep query must fail with a connection error, not succeed or hang for 3600s. + let error = sleep_handle.await.unwrap().unwrap_err(); + + match error { + sqlx::Error::Database(ref db_error) => { + assert_eq!(db_error.code().as_deref(), Some("57P01")); + } + _ => panic!("Expected database error 57P01 (admin_shutdown), got: {error}"), + } + + Ok(()) + }) +} + +async fn run_cli(args: &[&str], current_dir: &std::path::Path) -> (Option, String, String) { + let pg_ephemeral_bin = std::env::current_exe().unwrap(); + + let output = cmd_proc::Command::new(pg_ephemeral_bin) + .arguments(args) + .working_directory(current_dir) + .stdout_capture() + .stderr_capture() + .accept_nonzero_exit() + .run() + .await + .unwrap(); + + ( + output.status.code(), + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(output.stderr).unwrap(), + ) +} + +async fn cleanup_cache_images(backend: &ociman::Backend, instance_name: &crate::InstanceName) { + let name: ociman::reference::Name = format!("localhost/pg-ephemeral/{instance_name}") + .parse() + .unwrap(); + for reference in backend.image_references_by_name(&name).await { + backend.remove_image_force(&reference).await; + } +} + +fn cache_credentials_default_seed() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + let instance_name: crate::InstanceName = "credentials-default-test".parse().unwrap(); + cleanup_cache_images(&backend, &instance_name).await; + + let dir = TestDir::new("credentials-default-test"); + dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); + dir.write_file( + "database.toml", + &indoc::formatdoc! {r#" + image = "17.1" + + [instances.{instance_name}.seeds.schema] + type = "sql-file" + path = "schema.sql" + "#}, + ); + + run_pg_ephemeral( + &["cache", "--instance", instance_name.as_ref(), "populate"], + &dir.path, + ) + .await; + + let (code, stdout, stderr) = run_cli( + &["cache", "--instance", instance_name.as_ref(), "credentials"], + &dir.path, + ) + .await; + + let actual: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let password = actual["superuser"]["password"] + .as_str() + .unwrap() + .to_string(); + let cache_image = format!( + "pg-ephemeral/{instance_name}:8ee3896ee958931123af048077d74fd9758b4dd494450f29e11f909f2ed8160a", + ); + + assert_eq!( + (code, actual, stderr), + ( + Some(0), + serde_json::json!({ + "cache_image": cache_image, + "superuser": { + "user": "postgres", + "database": "postgres", + "password": password, + }, + }), + String::new(), + ), + ); + + cleanup_cache_images(&backend, &instance_name).await; + + Ok(()) + }) +} + +fn cache_credentials_explicit_seed_name() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + let instance_name: crate::InstanceName = "credentials-explicit-test".parse().unwrap(); + cleanup_cache_images(&backend, &instance_name).await; + + let dir = TestDir::new("credentials-explicit-test"); + dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); + dir.write_file( + "database.toml", + &indoc::formatdoc! {r#" + image = "17.1" + + [instances.{instance_name}.seeds.schema] + type = "sql-file" + path = "schema.sql" + + [instances.{instance_name}.seeds.fixtures] + type = "sql-statement" + statement = "INSERT INTO users (id) VALUES (1)" + "#}, + ); + + run_pg_ephemeral( + &["cache", "--instance", instance_name.as_ref(), "populate"], + &dir.path, + ) + .await; + + // Ask for the FIRST seed by name; the cache_image must be the schema-only + // hash, not the deeper fixtures-applied hash. + let (code, stdout, stderr) = run_cli( + &[ + "cache", + "--instance", + instance_name.as_ref(), + "credentials", + "--seed-name", + "schema", + ], + &dir.path, + ) + .await; + + let actual: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let password = actual["superuser"]["password"] + .as_str() + .unwrap() + .to_string(); + let cache_image = format!( + "pg-ephemeral/{instance_name}:8ee3896ee958931123af048077d74fd9758b4dd494450f29e11f909f2ed8160a", + ); + + assert_eq!( + (code, actual, stderr), + ( + Some(0), + serde_json::json!({ + "cache_image": cache_image, + "superuser": { + "user": "postgres", + "database": "postgres", + "password": password, + }, + }), + String::new(), + ), + ); + + cleanup_cache_images(&backend, &instance_name).await; + + Ok(()) + }) +} + +fn cache_credentials_no_seeds() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + let dir = TestDir::new("credentials-no-seeds-test"); + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [instances.main] + "#}, + ); + + let actual = run_cli(&["cache", "credentials"], &dir.path).await; + assert_eq!( + actual, + ( + Some(1), + String::new(), + "Error: Instance main has no seeds; cache credentials requires a cacheable seed\n" + .to_string(), + ), + ); + + Ok(()) + }) +} + +fn cache_credentials_uncacheable_tip() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + let dir = TestDir::new("credentials-uncacheable-test"); + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.tip] + type = "script" + script = "true" + cache = { type = "none" } + "#}, + ); + + let actual = run_cli(&["cache", "credentials"], &dir.path).await; + assert_eq!( + actual, + ( + Some(1), + String::new(), + "Error: Seed tip on instance main is uncacheable; cache credentials requires a cacheable seed\n" + .to_string(), + ), + ); + + Ok(()) + }) +} + +fn cache_credentials_unknown_seed() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + let dir = TestDir::new("credentials-unknown-seed-test"); + dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); + dir.write_file( + "database.toml", + indoc::indoc! {r#" + image = "17.1" + + [instances.main.seeds.schema] + type = "sql-file" + path = "schema.sql" + "#}, + ); + + let actual = run_cli( + &["cache", "credentials", "--seed-name", "does-not-exist"], + &dir.path, + ) + .await; + assert_eq!( + actual, + ( + Some(1), + String::new(), + "Error: Instance main has no seed named does-not-exist\n".to_string(), + ), + ); + + Ok(()) + }) +} + +fn cache_credentials_miss_tip() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + let instance_name: crate::InstanceName = "credentials-miss-test".parse().unwrap(); + cleanup_cache_images(&backend, &instance_name).await; + + let dir = TestDir::new("credentials-miss-test"); + dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); + dir.write_file( + "database.toml", + &indoc::formatdoc! {r#" + image = "17.1" + + [instances.{instance_name}.seeds.schema] + type = "sql-file" + path = "schema.sql" + "#}, + ); + + let actual = run_cli( + &["cache", "--instance", instance_name.as_ref(), "credentials"], + &dir.path, + ) + .await; + assert_eq!( + actual, + ( + Some(1), + String::new(), + format!( + "Error: Seed schema on instance {instance_name} is not yet cached; run `pg-ephemeral cache populate` first\n", + ), + ), + ); + + Ok(()) + }) +} diff --git a/pg-ephemeral/tests/common.rs b/pg-ephemeral/src/meta/test/common.rs similarity index 53% rename from pg-ephemeral/tests/common.rs rename to pg-ephemeral/src/meta/test/common.rs index 5964c7dd..0447be65 100644 --- a/pg-ephemeral/tests/common.rs +++ b/pg-ephemeral/src/meta/test/common.rs @@ -1,44 +1,34 @@ +//! Shared helpers for self-contained integration trials. + use git_proc::Build; const GIT_COMMITTER_DATE: cmd_proc::EnvVariableName = cmd_proc::EnvVariableName::from_static_or_panic("GIT_COMMITTER_DATE"); -/// Postgres image used by container.rs tests that bypass pg_ephemeral::Image. -#[allow(dead_code)] -pub static POSTGRES_IMAGE: std::sync::LazyLock = +pub(super) static POSTGRES_IMAGE: std::sync::LazyLock = std::sync::LazyLock::new(|| "docker.io/library/postgres:17".parse().unwrap()); -/// Ruby image used by integration test Dockerfiles. -#[allow(dead_code)] -pub static RUBY_IMAGE: std::sync::LazyLock = +pub(super) static RUBY_IMAGE: std::sync::LazyLock = std::sync::LazyLock::new(|| "docker.io/ruby:3.4-alpine".parse().unwrap()); -/// Node image used by integration test Dockerfiles. -#[allow(dead_code)] -pub static NODE_IMAGE: std::sync::LazyLock = +pub(super) static NODE_IMAGE: std::sync::LazyLock = std::sync::LazyLock::new(|| "docker.io/node:22-alpine".parse().unwrap()); /// Create a test definition with extended timeout. /// /// CI environments may be slow, so we use 30s instead of the default 10s. -#[allow(dead_code)] #[must_use] -pub fn test_definition(backend: ociman::Backend) -> pg_ephemeral::Definition { - pg_ephemeral::Definition::new( - backend, - pg_ephemeral::Image::default(), - "test".parse().unwrap(), - ) - .wait_available_timeout(std::time::Duration::from_secs(30)) +pub(super) fn test_definition(backend: ociman::Backend) -> crate::Definition { + crate::Definition::new(backend, crate::Image::default(), "test".parse().unwrap()) + .wait_available_timeout(std::time::Duration::from_secs(30)) } /// Run pg-ephemeral with the given arguments and assert success. /// /// Returns the captured stdout as a String. /// On failure, prints both stdout and stderr for debugging. -#[allow(dead_code)] -pub async fn run_pg_ephemeral(args: &[&str], current_dir: &std::path::Path) -> String { - let pg_ephemeral_bin = env!("CARGO_BIN_EXE_pg-ephemeral"); +pub(super) async fn run_pg_ephemeral(args: &[&str], current_dir: &std::path::Path) -> String { + let pg_ephemeral_bin = std::env::current_exe().unwrap(); let output = cmd_proc::Command::new(pg_ephemeral_bin) .arguments(args) @@ -54,8 +44,8 @@ pub async fn run_pg_ephemeral(args: &[&str], current_dir: &std::path::Path) -> S output.status.success(), "pg-ephemeral {} failed:\nstdout:\n{}\nstderr:\n{}", args.join(" "), - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) + std::str::from_utf8(&output.stdout).unwrap(), + std::str::from_utf8(&output.stderr).unwrap(), ); String::from_utf8(output.stdout).unwrap() @@ -64,16 +54,14 @@ pub async fn run_pg_ephemeral(args: &[&str], current_dir: &std::path::Path) -> S /// A temporary directory for testing. /// /// The directory is automatically cleaned up when dropped. -#[allow(dead_code)] -pub struct TestDir { - pub path: std::path::PathBuf, +pub(super) struct TestDir { + pub(super) path: std::path::PathBuf, } -#[allow(dead_code)] impl TestDir { /// Create a new temporary directory with the given name suffix. #[must_use] - pub fn new(name_suffix: &str) -> Self { + pub(super) fn new(name_suffix: &str) -> Self { let path = std::env::temp_dir().join(format!( "pg-ephemeral-{}-{}", name_suffix, @@ -84,7 +72,7 @@ impl TestDir { } /// Write a file to the directory. - pub fn write_file(&self, name: &str, content: &str) { + pub(super) fn write_file(&self, name: &str, content: &str) { std::fs::write(self.path.join(name), content).unwrap(); } } @@ -99,15 +87,13 @@ impl Drop for TestDir { /// /// Creates a temporary directory with an initialized git repository. /// The repository is automatically cleaned up when dropped. -#[allow(dead_code)] -pub struct TestGitRepo { - pub path: std::path::PathBuf, +pub(super) struct TestGitRepo { + pub(super) path: std::path::PathBuf, } impl TestGitRepo { /// Create a new temporary git repository with the given name suffix. - #[allow(dead_code)] - pub async fn new(name_suffix: &str) -> Self { + pub(super) async fn new(name_suffix: &str) -> Self { let path = std::env::temp_dir().join(format!( "pg-ephemeral-{}-{}", name_suffix, @@ -115,14 +101,12 @@ impl TestGitRepo { )); std::fs::create_dir_all(&path).unwrap(); - // Initialize git repository git_proc::init::new() .directory(&path) .status() .await .unwrap(); - // Configure git with hardcoded author (no environment reflection) git_proc::config::new("user.name") .repo_path(&path) .value("Test User") @@ -141,14 +125,12 @@ impl TestGitRepo { } /// Write a file to the repository. - #[allow(dead_code)] - pub fn write_file(&self, name: &str, content: &str) { + pub(super) fn write_file(&self, name: &str, content: &str) { std::fs::write(self.path.join(name), content).unwrap(); } /// Commit all files with the given message, returning the commit hash. - #[allow(dead_code)] - pub async fn commit(&self, message: &str) -> String { + pub(super) async fn commit(&self, message: &str) -> String { git_proc::add::new() .repo_path(&self.path) .pathspec(".") @@ -188,57 +170,23 @@ impl Drop for TestGitRepo { } } -#[allow(dead_code)] -pub async fn test_database_url_integration( - language: &str, - image_dir: &str, - base_image: &ociman::image::Reference, -) { - let backend = ociman::test_backend_setup!(); - - let definition = test_definition(backend.clone()).cross_container_access(true); - - definition - .with_container(async |container| { - let image_tag = - ociman::testing::test_reference(&format!("pg-ephemeral-{language}-test:latest")) - .to_string(); - - backend - .command() - .argument("build") - .argument("--build-arg") - .argument(format!("BASE_IMAGE={base_image}")) - .argument("--tag") - .argument(&image_tag) - .argument(image_dir) - .stdout_capture() - .bytes() - .await - .unwrap(); - - let database_url = container - .cross_container_client_config() - .await - .to_url_string(); - - let stdout = backend - .command() - .argument("run") - .argument("--rm") - .argument("--env") - .argument(format!("DATABASE_URL={database_url}")) - .argument(&image_tag) - .stdout_capture() - .string() - .await - .unwrap_or_else(|error| panic!("Failed to run {language} container: {error:?}")); - - assert!( - stdout.contains("SUCCESS: Connected to PostgreSQL successfully"), - "Expected success message not found in output.\nOutput: {stdout}" - ); - }) - .await - .unwrap() +/// Materialize an [`include_dir!`]-embedded directory tree under `dest`, +/// creating intermediate directories as needed and writing each embedded +/// file's bytes to its corresponding location. +/// +/// Used by trials whose original `tests/*.rs` form read fixture files from +/// `CARGO_MANIFEST_DIR`-relative paths. The fixtures travel inside the +/// production binary via `include_dir!`; this helper writes them to a +/// runtime-controlled location so the trial can `read_dir` / pass paths +/// to `pg-ephemeral` exactly as the source-tree-coupled version did. +pub(super) fn materialize(dir: &include_dir::Dir<'_>, dest: &std::path::Path) { + std::fs::create_dir_all(dest).unwrap(); + + for entry in dir.entries() { + let target = dest.join(entry.path().file_name().unwrap()); + match entry { + include_dir::DirEntry::Dir(subdir) => materialize(subdir, &target), + include_dir::DirEntry::File(file) => std::fs::write(&target, file.contents()).unwrap(), + } + } } diff --git a/pg-ephemeral/src/meta/test/container.rs b/pg-ephemeral/src/meta/test/container.rs new file mode 100644 index 00000000..8a198d86 --- /dev/null +++ b/pg-ephemeral/src/meta/test/container.rs @@ -0,0 +1,50 @@ +//! Container-level lifecycle tests (password rotation, etc.). + +use std::str::FromStr; + +use libtest_mimic::{Failed, Trial}; + +#[must_use] +pub fn trials() -> Vec { + vec![Trial::test( + "set_superuser_password", + set_superuser_password, + )] +} + +fn set_superuser_password() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + + let definition = + crate::Definition::new(backend, crate::Image::default(), "test".parse().unwrap()) + .wait_available_timeout(std::time::Duration::from_secs(30)); + + definition + .with_container(async |container| { + let new_password = + pg_client::config::Password::from_str("new_password_123").unwrap(); + container + .set_superuser_password(&new_password) + .await + .unwrap(); + + let mut new_client_config = container.client_config().clone(); + new_client_config.session.password = Some(new_password); + + new_client_config + .with_sqlx_connection(async |_| {}) + .await + .unwrap(); + }) + .await + .unwrap(); + + Ok(()) + }) +} diff --git a/pg-ephemeral/tests/examples.rs b/pg-ephemeral/src/meta/test/examples.rs similarity index 57% rename from pg-ephemeral/tests/examples.rs rename to pg-ephemeral/src/meta/test/examples.rs index 73aa8fba..c516ecd1 100644 --- a/pg-ephemeral/tests/examples.rs +++ b/pg-ephemeral/src/meta/test/examples.rs @@ -4,21 +4,34 @@ //! parses against the current `Config` schema. Catches renamed fields, //! removed seed types, and `deny_unknown_fields` regressions the moment //! someone touches `config.rs`, with no Docker required. +//! +//! The examples directory is embedded at build time via [`include_dir!`] +//! and materialized to a temp directory at trial run time, so the trial +//! works on installed binaries with no source tree present. -#[test] -fn every_example_database_toml_parses() { - let examples_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples"); +use include_dir::{Dir, include_dir}; +use libtest_mimic::{Failed, Trial}; - assert!( - examples_dir.is_dir(), - "examples directory missing: {}", - examples_dir.display() - ); +use super::common::{TestDir, materialize}; + +static EXAMPLES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/examples"); + +#[must_use] +pub fn trials() -> Vec { + vec![Trial::test( + "every_example_database_toml_parses", + every_example_database_toml_parses, + )] +} + +fn every_example_database_toml_parses() -> Result<(), Failed> { + let examples_dir = TestDir::new("meta-examples-parse"); + materialize(&EXAMPLES, &examples_dir.path); let mut checked: Vec = Vec::new(); let mut failures: Vec = Vec::new(); - let mut entries: Vec<_> = std::fs::read_dir(&examples_dir) + let mut entries: Vec<_> = std::fs::read_dir(&examples_dir.path) .unwrap() .map(Result::unwrap) .collect(); @@ -36,10 +49,8 @@ fn every_example_database_toml_parses() { continue; } - match pg_ephemeral::Config::load_toml_file( - &toml_path, - &pg_ephemeral::config::InstanceDefinition::empty(), - ) { + match crate::Config::load_toml_file(&toml_path, &crate::config::InstanceDefinition::empty()) + { Ok(_) => checked.push(toml_path), Err(error) => failures.push(format!("{}: {error}", toml_path.display())), } @@ -48,7 +59,7 @@ fn every_example_database_toml_parses() { assert!( !checked.is_empty(), "expected at least one example database.toml under {}", - examples_dir.display() + examples_dir.path.display() ); assert!( @@ -56,4 +67,6 @@ fn every_example_database_toml_parses() { "example database.toml files failed to parse:\n {}", failures.join("\n ") ); + + Ok(()) } diff --git a/pg-ephemeral/src/meta/test/examples_boot.rs b/pg-ephemeral/src/meta/test/examples_boot.rs new file mode 100644 index 00000000..25a69e9d --- /dev/null +++ b/pg-ephemeral/src/meta/test/examples_boot.rs @@ -0,0 +1,129 @@ +//! Boot check for every example under `pg-ephemeral/examples/`. +//! +//! For every example with a `database.toml`, load it, build a definition for +//! every declared instance, boot a container, and assert a trivial query +//! works. Catches semantic drift the parse-only [`super::examples`] trial +//! cannot see: e.g. a seed file that references a function the new image +//! removed, or a config that parses but produces a definition that fails +//! to boot. +//! +//! `06-container-script-pg-cron` is excluded because it does +//! `apt-get update && apt-get install` from inside the container, which +//! requires network access that not every CI environment provides. Set +//! `PG_EPHEMERAL_TEST_NETWORK=1` to opt into running it. + +use std::path::Path; + +use include_dir::{Dir, include_dir}; +use libtest_mimic::{Failed, Trial}; + +use super::common::{TestDir, materialize}; + +static EXAMPLES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/examples"); + +const NETWORK_OPT_IN_VAR: &str = "PG_EPHEMERAL_TEST_NETWORK"; +const NETWORK_GATED_EXAMPLES: &[&str] = &["06-container-script-pg-cron"]; + +#[must_use] +pub fn trials() -> Vec { + vec![Trial::test("every_example_boots", every_example_boots)] +} + +fn every_example_boots() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + + let examples_dir = TestDir::new("meta-examples-boot"); + materialize(&EXAMPLES, &examples_dir.path); + + let network_opt_in = std::env::var(NETWORK_OPT_IN_VAR).is_ok(); + + let mut entries: Vec<_> = std::fs::read_dir(&examples_dir.path) + .unwrap() + .map(Result::unwrap) + .collect(); + entries.sort_by_key(std::fs::DirEntry::file_name); + + let mut booted: Vec = Vec::new(); + + for entry in entries { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let dir_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap() + .to_string(); + + let toml_path = path.join("database.toml"); + if !toml_path.exists() { + continue; + } + + if NETWORK_GATED_EXAMPLES.contains(&dir_name.as_str()) && !network_opt_in { + log::info!( + "skipping {dir_name}: requires network access; set {NETWORK_OPT_IN_VAR}=1 to enable" + ); + continue; + } + + boot_example(&toml_path, &dir_name).await; + booted.push(dir_name); + } + + assert!( + !booted.is_empty(), + "expected at least one example to boot from {}", + examples_dir.path.display() + ); + + Ok(()) + }) +} + +async fn boot_example(toml_path: &Path, example_name: &str) { + let instance_map = + crate::Config::load_toml_file(toml_path, &crate::config::InstanceDefinition::empty()) + .unwrap_or_else(|error| panic!("{example_name}: parse failed: {error}")); + + for (instance_name, instance) in &instance_map { + let definition = instance + .definition(instance_name) + .await + .unwrap_or_else(|error| { + panic!("{example_name}/{instance_name}: definition build failed: {error}") + }) + .wait_available_timeout(std::time::Duration::from_secs(30)); + + definition + .with_container(async |container| { + container + .with_connection(async |connection| { + let row = sqlx::query("SELECT 1::int AS one") + .fetch_one(connection) + .await + .unwrap_or_else(|error| { + panic!("{example_name}/{instance_name}: SELECT 1 failed: {error}") + }); + let one: i32 = sqlx::Row::get(&row, "one"); + assert_eq!( + one, 1, + "{example_name}/{instance_name}: SELECT 1 returned {one}" + ); + }) + .await; + }) + .await + .unwrap_or_else(|error| { + panic!("{example_name}/{instance_name}: with_container failed: {error:?}") + }); + } +} diff --git a/pg-ephemeral/src/meta/test/integration.rs b/pg-ephemeral/src/meta/test/integration.rs new file mode 100644 index 00000000..89d1eca4 --- /dev/null +++ b/pg-ephemeral/src/meta/test/integration.rs @@ -0,0 +1,93 @@ +//! Cross-language integration trials: build a per-language container image +//! with the language's PostgreSQL client, run it against the live ephemeral +//! database, and assert the integration script reports success. + +use include_dir::{Dir, include_dir}; +use libtest_mimic::{Failed, Trial}; + +use super::common::{NODE_IMAGE, RUBY_IMAGE, TestDir, materialize, test_definition}; + +static RUBY_INTEGRATION: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/tests/integration/ruby"); +static PRISMA_INTEGRATION: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/tests/integration/prisma"); + +#[must_use] +pub fn trials() -> Vec { + vec![ + Trial::test("ruby_database_url", ruby_database_url), + Trial::test("prisma_database_url", prisma_database_url), + ] +} + +fn ruby_database_url() -> Result<(), Failed> { + run_database_url_integration("ruby", &RUBY_INTEGRATION, &RUBY_IMAGE) +} + +fn prisma_database_url() -> Result<(), Failed> { + run_database_url_integration("prisma", &PRISMA_INTEGRATION, &NODE_IMAGE) +} + +fn run_database_url_integration( + language: &str, + fixture: &Dir<'_>, + base_image: &ociman::image::Reference, +) -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + let definition = test_definition(backend.clone()).cross_container_access(true); + + let image_dir = TestDir::new(&format!("meta-integration-{language}")); + materialize(fixture, &image_dir.path); + + definition + .with_container(async |container| { + let image_tag = ociman::testing::test_reference(&format!( + "pg-ephemeral-{language}-test:latest" + )) + .to_string(); + + backend + .command() + .argument("build") + .argument("--build-arg") + .argument(format!("BASE_IMAGE={base_image}")) + .argument("--tag") + .argument(&image_tag) + .argument(&image_dir.path) + .stdout_capture() + .bytes() + .await + .unwrap(); + + let database_url = container + .cross_container_client_config() + .await + .to_url_string(); + + let stdout = backend + .command() + .argument("run") + .argument("--rm") + .argument("--env") + .argument(format!("DATABASE_URL={database_url}")) + .argument(&image_tag) + .stdout_capture() + .string() + .await + .unwrap(); + + assert!( + stdout.contains("SUCCESS: Connected to PostgreSQL successfully"), + "Expected success message not found in output.\nOutput: {stdout}" + ); + }) + .await + .unwrap(); + + Ok(()) + }) +} diff --git a/pg-ephemeral/src/meta/test/labels.rs b/pg-ephemeral/src/meta/test/labels.rs new file mode 100644 index 00000000..0e931d77 --- /dev/null +++ b/pg-ephemeral/src/meta/test/labels.rs @@ -0,0 +1,230 @@ +//! Verifies pg-ephemeral metadata labels round-trip on both running +//! containers and committed cache images. + +use libtest_mimic::{Failed, Trial}; + +use crate::label; + +#[must_use] +pub fn trials() -> Vec { + vec![ + Trial::test( + "labels_written_minimal_container", + labels_written_minimal_container, + ), + Trial::test( + "cache_image_raw_labels_present", + cache_image_raw_labels_present, + ), + Trial::test( + "cache_image_metadata_round_trip", + cache_image_metadata_round_trip, + ), + ] +} + +fn labels_written_minimal_container() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + let instance_name: crate::InstanceName = "test-labels".parse().unwrap(); + + let definition = + crate::Definition::new(backend, crate::Image::default(), instance_name.clone()) + .wait_available_timeout(std::time::Duration::from_secs(30)); + + definition + .with_container(async |container| { + let labels = container.labels().await.unwrap(); + + for key in [ + &label::VERSION_KEY, + &label::INSTANCE_KEY, + &label::IMAGE_KEY, + &label::SUPERUSER_USER_KEY, + &label::SUPERUSER_DATABASE_KEY, + &label::SUPERUSER_PASSWORD_KEY, + &label::SEEDS_KEY, + ] { + assert!( + labels.contains_key(key), + "expected label {key} to be present", + ); + } + + for key in [ + &label::SUPERUSER_APPLICATION_KEY, + &label::SSL_HOSTNAME_KEY, + &label::SSL_CA_CERT_PEM_KEY, + ] { + assert!( + !labels.contains_key(key), + "label {key} should not be present in minimal Definition", + ); + } + + assert_eq!( + labels.get(&label::VERSION_KEY).unwrap().as_str(), + crate::version().to_string(), + ); + assert_eq!( + labels.get(&label::INSTANCE_KEY).unwrap().as_str(), + instance_name.as_str(), + ); + assert_eq!( + labels.get(&label::SUPERUSER_USER_KEY).unwrap().as_str(), + pg_client::User::POSTGRES.as_ref(), + ); + assert_eq!( + labels.get(&label::SUPERUSER_DATABASE_KEY).unwrap().as_str(), + pg_client::Database::POSTGRES.as_ref(), + ); + + let stored_password = labels.get(&label::SUPERUSER_PASSWORD_KEY).unwrap().as_str(); + let actual_password = container + .client_config() + .session + .password + .as_ref() + .unwrap() + .as_ref(); + assert_eq!(stored_password, actual_password); + + let seeds_json = labels.get(&label::SEEDS_KEY).unwrap().as_str(); + let seed_entries: Vec = serde_json::from_str(seeds_json).unwrap(); + assert!(seed_entries.is_empty()); + }) + .await + .unwrap(); + + Ok(()) + }) +} + +fn cache_image_raw_labels_present() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + let instance_name: crate::InstanceName = "labels-cache-image-raw".parse().unwrap(); + let cache_reference = populate_cache_image(&backend, &instance_name).await; + + let labels = backend.image_labels(&cache_reference).await.unwrap(); + + for key in [ + &label::VERSION_KEY, + &label::INSTANCE_KEY, + &label::IMAGE_KEY, + &label::SUPERUSER_USER_KEY, + &label::SUPERUSER_DATABASE_KEY, + &label::SUPERUSER_PASSWORD_KEY, + &label::SEEDS_KEY, + ] { + assert!( + labels.contains_key(key), + "expected label {key} on cache image", + ); + } + + for key in [ + &label::SUPERUSER_APPLICATION_KEY, + &label::SSL_HOSTNAME_KEY, + &label::SSL_CA_CERT_PEM_KEY, + ] { + assert!( + !labels.contains_key(key), + "label {key} should not be present on minimal cache image", + ); + } + + assert_eq!( + labels.get(&label::VERSION_KEY).unwrap().as_str(), + crate::version().to_string(), + ); + assert_eq!( + labels.get(&label::INSTANCE_KEY).unwrap().as_str(), + instance_name.as_str(), + ); + + let seeds_json = labels.get(&label::SEEDS_KEY).unwrap().as_str(); + let seed_entries: Vec = serde_json::from_str(seeds_json).unwrap(); + assert_eq!(seed_entries.len(), 1); + assert_eq!(seed_entries[0].name.as_str(), "create-table"); + assert!( + seed_entries[0].hash.is_some(), + "cacheable seed should carry a hash on the cache image" + ); + + backend.remove_image_force(&cache_reference).await; + + Ok(()) + }) +} + +fn cache_image_metadata_round_trip() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + let instance_name: crate::InstanceName = "labels-cache-image-decoded".parse().unwrap(); + let cache_reference = populate_cache_image(&backend, &instance_name).await; + + let labels = backend.image_labels(&cache_reference).await.unwrap(); + let metadata = label::read_image(&labels).unwrap(); + + assert_eq!(&metadata.version, crate::version()); + assert_eq!(metadata.instance, instance_name); + assert_eq!(metadata.superuser.user, pg_client::User::POSTGRES); + assert_eq!(metadata.superuser.database, pg_client::Database::POSTGRES); + assert!(metadata.superuser.application.is_none()); + assert!(metadata.ssl.is_none()); + + assert_eq!(metadata.seeds.len(), 1); + assert_eq!(metadata.seeds[0].name.as_str(), "create-table"); + assert!( + metadata.seeds[0].hash.is_some(), + "cacheable seed should carry a hash on the cache image" + ); + + backend.remove_image_force(&cache_reference).await; + + Ok(()) + }) +} + +/// Builds a pg-ephemeral cache image so the raw-label and decoded-metadata +/// trials below each verify a fresh image. `instance_name` doubles as the +/// cache image namespace; callers pass distinct values to keep parallel +/// trials from racing on a shared image reference. +async fn populate_cache_image( + backend: &ociman::Backend, + instance_name: &crate::InstanceName, +) -> ociman::Reference { + let definition = crate::Definition::new( + backend.clone(), + crate::Image::default(), + instance_name.clone(), + ) + .wait_available_timeout(std::time::Duration::from_secs(30)) + .apply_script( + "create-table".parse().unwrap(), + r#"psql -c "CREATE TABLE labelled (id INT)""#, + crate::SeedCacheConfig::CommandHash, + ) + .unwrap(); + + let loaded_seeds = definition.load_seeds(instance_name).await.unwrap(); + let (last_cache_hit, uncached) = definition.populate_cache(&loaded_seeds).await.unwrap(); + assert!(uncached.is_empty(), "all seeds should be cacheable"); + last_cache_hit.unwrap() +} diff --git a/pg-ephemeral/src/meta/test/seed.rs b/pg-ephemeral/src/meta/test/seed.rs new file mode 100644 index 00000000..6f7810dd --- /dev/null +++ b/pg-ephemeral/src/meta/test/seed.rs @@ -0,0 +1,483 @@ +//! Verifies seed application across every seed type (file, statement, command, +//! script, csv-file, git-revision) — including the environment surface delivered +//! to command/script seeds and the column-reorder / header-mismatch behavior +//! of csv-file seeds. + +use include_dir::{Dir, include_dir}; +use libtest_mimic::{Failed, Trial}; + +use super::common::{TestDir, TestGitRepo, test_definition}; + +static FIXTURES: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/tests/fixtures"); + +/// Keys whose values change between the caching container and the final container +/// (password is regenerated, port is reassigned on each boot). +const UNSTABLE_ENV_KEYS: &[&str] = &["DATABASE_URL", "PGPASSWORD", "PGPORT"]; + +#[must_use] +pub fn trials() -> Vec { + vec![ + Trial::test( + "command_seed_receives_environment", + command_seed_receives_environment, + ), + Trial::test( + "script_seed_receives_environment", + script_seed_receives_environment, + ), + Trial::test( + "sql_statement_seed_multi_statement", + sql_statement_seed_multi_statement, + ), + Trial::test("csv_file_seed", csv_file_seed), + Trial::test("csv_file_seed_column_reorder", csv_file_seed_column_reorder), + Trial::test( + "csv_file_seed_header_mismatch", + csv_file_seed_header_mismatch, + ), + Trial::test("git_revision_seed", git_revision_seed), + ] +} + +async fn assert_environment_matches( + container: &crate::Container, + connection: &mut sqlx::postgres::PgConnection, +) { + // Read environment variables from database + let rows = sqlx::query("SELECT key, value FROM seed_env ORDER BY key") + .fetch_all(connection) + .await + .unwrap(); + + let actual: Vec<(String, String)> = rows + .iter() + .map(|row| { + ( + sqlx::Row::get::(row, "key"), + sqlx::Row::get::(row, "value"), + ) + }) + .collect(); + + // Generate expected output from config + let pg_env = container.pg_env(); + let mut expected: Vec<(String, String)> = pg_env + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + expected.push(("DATABASE_URL".to_string(), container.database_url())); + expected.sort(); + + // Verify all expected keys are present + let actual_keys: Vec<&str> = actual.iter().map(|(key, _)| key.as_str()).collect(); + let expected_keys: Vec<&str> = expected.iter().map(|(key, _)| key.as_str()).collect(); + assert_eq!(expected_keys, actual_keys); + + // Verify stable values match (password and port change between cache and boot) + let actual_stable: Vec<&(String, String)> = actual + .iter() + .filter(|(key, _)| !UNSTABLE_ENV_KEYS.contains(&key.as_str())) + .collect(); + let expected_stable: Vec<&(String, String)> = expected + .iter() + .filter(|(key, _)| !UNSTABLE_ENV_KEYS.contains(&key.as_str())) + .collect(); + assert_eq!(expected_stable, actual_stable); +} + +fn command_seed_receives_environment() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + let fixtures_dir = TestDir::new("seed-command-env-fixtures"); + super::common::materialize(&FIXTURES, &fixtures_dir.path); + + let definition = test_definition(backend) + .apply_file( + "create-table".parse().unwrap(), + fixtures_dir.path.join("create_seed_env_table.sql"), + ) + .unwrap() + .apply_command( + "capture-env".parse().unwrap(), + crate::Command::new( + "sh", + [ + "-c", + "(env | grep '^PG' && echo DATABASE_URL=$DATABASE_URL) | sed 's/=/,/' | psql -c \"\\copy seed_env FROM STDIN WITH (FORMAT csv)\"", + ], + ), + crate::SeedCacheConfig::None, + ) + .unwrap(); + + definition + .with_container(async |container| { + container + .with_connection(async |connection| { + assert_environment_matches(container, connection).await; + }) + .await; + }) + .await + .unwrap(); + + Ok(()) + }) +} + +fn script_seed_receives_environment() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + let fixtures_dir = TestDir::new("seed-script-env-fixtures"); + super::common::materialize(&FIXTURES, &fixtures_dir.path); + + let definition = test_definition(backend) + .apply_file( + "create-table".parse().unwrap(), + fixtures_dir.path.join("create_seed_env_table.sql"), + ) + .unwrap() + .apply_script( + "capture-env".parse().unwrap(), + "(env | grep '^PG' && echo DATABASE_URL=$DATABASE_URL) | sed 's/=/,/' | psql -c \"\\copy seed_env FROM STDIN WITH (FORMAT csv)\"", + crate::SeedCacheConfig::CommandHash, + ) + .unwrap(); + + definition + .with_container(async |container| { + container + .with_connection(async |connection| { + assert_environment_matches(container, connection).await; + }) + .await; + }) + .await + .unwrap(); + + Ok(()) + }) +} + +fn sql_statement_seed_multi_statement() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + + let definition = test_definition(backend) + .apply_sql_statement( + "schema-and-data".parse().unwrap(), + indoc::indoc! {r#" + CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL); + INSERT INTO users (id, name) VALUES (1, 'alice'); + INSERT INTO users (id, name) VALUES (2, 'bob'); + "#}, + ) + .unwrap(); + + definition + .with_container(async |container| { + container + .with_connection(async |connection| { + let rows: Vec<(i32, String)> = + sqlx::query_as("SELECT id, name FROM users ORDER BY id") + .fetch_all(&mut *connection) + .await + .unwrap(); + + assert_eq!(rows, vec![(1, "alice".to_string()), (2, "bob".to_string())]); + }) + .await; + }) + .await + .unwrap(); + + Ok(()) + }) +} + +fn csv_file_seed() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + let fixtures_dir = TestDir::new("seed-csv-file-fixtures"); + super::common::materialize(&FIXTURES, &fixtures_dir.path); + + let definition = test_definition(backend) + .apply_file( + "create-table".parse().unwrap(), + fixtures_dir.path.join("create_users_table.sql"), + ) + .unwrap() + .apply_csv_file( + "import-users".parse().unwrap(), + fixtures_dir.path.join("users.csv"), + pg_client::QualifiedTable { + schema: pg_client::identifier::Schema::PUBLIC, + table: "users".parse().unwrap(), + }, + ) + .unwrap(); + + definition + .with_container(async |container| { + container + .with_connection(async |connection| { + let rows: Vec<(i32, String)> = + sqlx::query_as("SELECT id, name FROM public.users ORDER BY id") + .fetch_all(&mut *connection) + .await + .unwrap(); + + assert_eq!( + rows, + vec![ + (1, "alice".to_string()), + (2, "bob".to_string()), + (3, "charlie".to_string()), + ] + ); + }) + .await; + }) + .await + .unwrap(); + + Ok(()) + }) +} + +fn csv_file_seed_column_reorder() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + let fixtures_dir = TestDir::new("seed-csv-reorder-fixtures"); + super::common::materialize(&FIXTURES, &fixtures_dir.path); + + let definition = test_definition(backend) + .apply_file( + "create-table".parse().unwrap(), + fixtures_dir.path.join("create_users_table_serial.sql"), + ) + .unwrap() + .apply_csv_file( + "import-users".parse().unwrap(), + fixtures_dir.path.join("users_name_only.csv"), + pg_client::QualifiedTable { + schema: pg_client::identifier::Schema::PUBLIC, + table: "users".parse().unwrap(), + }, + ) + .unwrap(); + + definition + .with_container(async |container| { + container + .with_connection(async |connection| { + let rows: Vec<(i32, String)> = + sqlx::query_as("SELECT id, name FROM public.users ORDER BY id") + .fetch_all(&mut *connection) + .await + .unwrap(); + + assert_eq!( + rows, + vec![ + (1, "alice".to_string()), + (2, "bob".to_string()), + (3, "charlie".to_string()), + ] + ); + }) + .await; + }) + .await + .unwrap(); + + Ok(()) + }) +} + +fn csv_file_seed_header_mismatch() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let backend = ociman::backend::resolve::auto().await.unwrap(); + let fixtures_dir = TestDir::new("seed-csv-header-mismatch-fixtures"); + super::common::materialize(&FIXTURES, &fixtures_dir.path); + + let definition = test_definition(backend) + .apply_file( + "create-table".parse().unwrap(), + fixtures_dir.path.join("create_users_table_serial.sql"), + ) + .unwrap() + .apply_csv_file( + "import-users".parse().unwrap(), + fixtures_dir.path.join("users_wrong_column.csv"), + pg_client::QualifiedTable { + schema: pg_client::identifier::Schema::PUBLIC, + table: "users".parse().unwrap(), + }, + ) + .unwrap(); + + let error = definition.with_container(async |_| {}).await.unwrap_err(); + + assert!( + format!("{error:?}").contains("wrong_column"), + "Expected error mentioning wrong_column, got: {error:?}" + ); + + Ok(()) + }) +} + +fn git_revision_seed() -> Result<(), Failed> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + runtime.block_on(async { + let _backend = ociman::backend::resolve::auto().await.unwrap(); + + let repo = TestGitRepo::new("git-revision-test").await; + + // Create seed.sql with table creation and insert for commit 1 + repo.write_file( + "seed.sql", + indoc::indoc! {r#" + CREATE TABLE users (id INTEGER PRIMARY KEY); + INSERT INTO users (id) VALUES (1); + "#}, + ); + let commit1_hash = repo.commit("Initial data").await; + + // Modify seed.sql to insert different data for commit 2 + repo.write_file( + "seed.sql", + indoc::indoc! {r#" + CREATE TABLE users (id INTEGER PRIMARY KEY); + INSERT INTO users (id) VALUES (2); + "#}, + ); + + // Commit v2 + let _ = repo.commit("Different data").await; + + // Create TOML config that references commit1 + let config_content = indoc::formatdoc! {r#" + image = "17.1" + + [instances.main.seeds.schema] + type = "sql-file" + path = "seed.sql" + git_revision = "{commit1_hash}" + "#}; + repo.write_file("database.toml", &config_content); + + // Get path to pg-ephemeral binary + let pg_ephemeral_bin = std::env::current_exe().unwrap(); + + // Create pipes for the integration protocol + let (result_read, result_write) = std::io::pipe().unwrap(); + let (control_read, control_write) = std::io::pipe().unwrap(); + + use std::os::fd::AsRawFd; + let result_write_fd = result_write.as_raw_fd(); + let control_read_fd = control_read.as_raw_fd(); + + // Start pg-ephemeral integration-server with pipe FDs + let mut cmd = cmd_proc::Command::new(&pg_ephemeral_bin) + .arguments([ + "integration-server", + "--result-fd", + &result_write_fd.to_string(), + "--control-fd", + &control_read_fd.to_string(), + ]) + .working_directory(&repo.path) + .build(); + + // SAFETY: Clear CLOEXEC on the pipe FDs so the child inherits them. + // This runs after fork() but before exec(); the borrowed FDs alias + // the pipe ends owned by the parent and are only used here to flip + // a flag — they outlive this closure on both sides of the fork. + unsafe { + cmd.pre_exec(move || { + for raw_fd in [result_write_fd, control_read_fd] { + let borrowed = std::os::fd::BorrowedFd::borrow_raw(raw_fd); + let flags = rustix::io::fcntl_getfd(borrowed)?; + rustix::io::fcntl_setfd(borrowed, flags - rustix::io::FdFlags::CLOEXEC)?; + } + Ok(()) + }); + } + + let mut server = cmd.spawn().unwrap(); + + // Close parent's copies of the child's pipe ends + drop(result_write); + drop(control_read); + + // Read the JSON output from the result pipe + use std::io::BufRead; + let mut reader = std::io::BufReader::new(result_read); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + + // Run psql command to query the data + let output = cmd_proc::Command::new(&pg_ephemeral_bin) + .arguments([ + "run-env", + "--", + "psql", + "--csv", + "--command=SELECT id FROM users ORDER BY id", + ]) + .working_directory(&repo.path) + .stdout_capture() + .stderr_capture() + .accept_nonzero_exit() + .run() + .await + .unwrap(); + + assert!(output.status.success(), "psql command failed"); + + // Verify we have the data from commit 1 (id=1), not commit 2 (id=2) + assert_eq!(std::str::from_utf8(&output.stdout).unwrap().trim(), "id\n1"); + + // Stop the server by closing the control pipe write end and wait for it to finish + drop(control_write); + server.wait().await.unwrap(); + + Ok(()) + }) +} diff --git a/pg-ephemeral/tests/backtrace.rs b/pg-ephemeral/tests/backtrace.rs deleted file mode 100644 index 7ca8cac8..00000000 --- a/pg-ephemeral/tests/backtrace.rs +++ /dev/null @@ -1,53 +0,0 @@ -const RUST_BACKTRACE: cmd_proc::EnvVariableName = - cmd_proc::EnvVariableName::from_static_or_panic("RUST_BACKTRACE"); - -#[tokio::test] -async fn test_backtrace_contains_file_paths() { - let pg_ephemeral_bin = env!("CARGO_BIN_EXE_pg-ephemeral"); - - let output = cmd_proc::Command::new(pg_ephemeral_bin) - .arguments(["platform", "test-backtrace"]) - .env(&RUST_BACKTRACE, "1") - .stdout_capture() - .stderr_capture() - .accept_nonzero_exit() - .run() - .await - .expect("failed to execute pg-ephemeral"); - - assert!( - !output.status.success(), - "test-backtrace should exit with non-zero status" - ); - - let stderr = String::from_utf8_lossy(&output.stderr); - - // Verify the panic message is present - assert!( - stderr.contains("intentional panic for backtrace testing"), - "stderr should contain panic message, got:\n{stderr}" - ); - - // Verify function names are present in backtrace - assert!( - stderr.contains("inner_function_for_backtrace_test"), - "backtrace should contain inner_function_for_backtrace_test, got:\n{stderr}" - ); - assert!( - stderr.contains("trigger_test_panic"), - "backtrace should contain trigger_test_panic, got:\n{stderr}" - ); - - // Verify file paths with line numbers are present in backtrace frames - // This checks that debug info is available, not just the panic message location - // Look for pattern like "at pg-ephemeral/src/cli.rs:123" or similar in stack frames - let has_file_line_in_backtrace = stderr.lines().any(|line| { - // Skip the panic message line itself - !line.contains("panicked at") && line.contains("cli.rs:") && line.contains("at ") - }); - - assert!( - has_file_line_in_backtrace, - "backtrace frames should contain file paths with line numbers (e.g., 'at cli.rs:123'), got:\n{stderr}" - ); -} diff --git a/pg-ephemeral/tests/base.rs b/pg-ephemeral/tests/base.rs deleted file mode 100644 index eeff658e..00000000 --- a/pg-ephemeral/tests/base.rs +++ /dev/null @@ -1,689 +0,0 @@ -mod common; - -#[tokio::test] -async fn pull_test_images() { - let backend = ociman::test_backend_setup!(); - - let default_image: ociman::image::Reference = (&pg_ephemeral::Image::default()).into(); - backend.pull_image(&default_image).await; - - for image in [ - &*common::POSTGRES_IMAGE, - &*common::RUBY_IMAGE, - &*common::NODE_IMAGE, - &*ociman::testing::ALPINE_LATEST_IMAGE, - ] { - backend.pull_image(image).await; - } -} - -#[tokio::test] -async fn test_base_feature() { - let backend = ociman::test_backend_setup!(); - - common::test_definition(backend) - .with_container(async |container| { - container - .with_connection(async |connection| { - let row = sqlx::query("SELECT true") - .fetch_one(connection) - .await - .unwrap(); - assert!(sqlx::Row::get::(&row, 0)) - }) - .await - }) - .await - .unwrap() -} - -#[tokio::test] -async fn test_ssl_generated() { - let backend = ociman::test_backend_setup!(); - - common::test_definition(backend) - .ssl_config(pg_ephemeral::definition::SslConfig::Generated { - hostname: "postgresql.example.com".parse().unwrap(), - }) - .with_container(async |container| { - container - .with_connection(async |connection| { - let row = sqlx::query("SELECT true") - .fetch_one(connection) - .await - .unwrap(); - assert!(sqlx::Row::get::(&row, 0)) - }) - .await - }) - .await - .unwrap() -} - -#[test] -fn test_config_file() { - assert_eq!( - pg_ephemeral::InstanceMap::from([ - ( - pg_ephemeral::InstanceName::from_static_or_panic("a"), - pg_ephemeral::Instance { - application_name: None, - backend: ociman::backend::Selection::Docker, - database: pg_client::Database::POSTGRES, - seeds: indexmap::IndexMap::new(), - ssl_config: None, - superuser: pg_client::User::POSTGRES, - image: "17.1".parse().unwrap(), - cross_container_access: false, - wait_available_timeout: std::time::Duration::from_secs(10), - } - ), - ( - pg_ephemeral::InstanceName::from_static_or_panic("b"), - pg_ephemeral::Instance { - application_name: None, - backend: ociman::backend::Selection::Podman, - database: pg_client::Database::POSTGRES, - seeds: indexmap::IndexMap::new(), - ssl_config: None, - superuser: pg_client::User::POSTGRES, - image: "17.2".parse().unwrap(), - cross_container_access: false, - wait_available_timeout: std::time::Duration::from_secs(10), - } - ) - ]), - pg_ephemeral::Config::load_toml_file( - "tests/database.toml", - &pg_ephemeral::config::InstanceDefinition::empty() - ) - .unwrap() - ); - - assert_eq!( - pg_ephemeral::InstanceMap::from([ - ( - pg_ephemeral::InstanceName::from_static_or_panic("a"), - pg_ephemeral::Instance { - application_name: None, - backend: ociman::backend::Selection::Docker, - database: pg_client::Database::POSTGRES, - seeds: indexmap::IndexMap::new(), - ssl_config: None, - superuser: pg_client::User::POSTGRES, - image: "18.0".parse().unwrap(), - cross_container_access: false, - wait_available_timeout: std::time::Duration::from_secs(10), - } - ), - ( - pg_ephemeral::InstanceName::from_static_or_panic("b"), - pg_ephemeral::Instance { - application_name: None, - backend: ociman::backend::Selection::Docker, - database: pg_client::Database::POSTGRES, - seeds: indexmap::IndexMap::new(), - ssl_config: None, - superuser: pg_client::User::POSTGRES, - image: "18.0".parse().unwrap(), - cross_container_access: false, - wait_available_timeout: std::time::Duration::from_secs(10), - } - ) - ]), - pg_ephemeral::Config::load_toml_file( - "tests/database.toml", - &pg_ephemeral::config::InstanceDefinition { - backend: Some(ociman::backend::Selection::Docker), - image: Some("18.0".parse().unwrap()), - seeds: indexmap::IndexMap::new(), - ssl_config: None, - wait_available_timeout: None, - } - ) - .unwrap() - ) -} - -#[test] -fn test_config_file_no_explicit_instance() { - assert_eq!( - pg_ephemeral::InstanceMap::from([( - pg_ephemeral::InstanceName::MAIN, - pg_ephemeral::Instance { - application_name: None, - backend: ociman::backend::Selection::Docker, - database: pg_client::Database::POSTGRES, - seeds: indexmap::IndexMap::new(), - ssl_config: None, - superuser: pg_client::User::POSTGRES, - image: "17.1".parse().unwrap(), - cross_container_access: false, - wait_available_timeout: std::time::Duration::from_secs(10), - } - ),]), - pg_ephemeral::Config::load_toml_file( - "tests/database_no_explicit_instance.toml", - &pg_ephemeral::config::InstanceDefinition::empty() - ) - .unwrap() - ); - - assert_eq!( - pg_ephemeral::InstanceMap::from([( - pg_ephemeral::InstanceName::MAIN, - pg_ephemeral::Instance { - application_name: None, - backend: ociman::backend::Selection::Podman, - database: pg_client::Database::POSTGRES, - seeds: indexmap::IndexMap::new(), - ssl_config: None, - superuser: pg_client::User::POSTGRES, - image: "18.0".parse().unwrap(), - cross_container_access: false, - wait_available_timeout: std::time::Duration::from_secs(10), - } - ),]), - pg_ephemeral::Config::load_toml_file( - "tests/database_no_explicit_instance.toml", - &pg_ephemeral::config::InstanceDefinition { - backend: Some(ociman::backend::Selection::Podman), - image: Some("18.0".parse().unwrap()), - seeds: indexmap::IndexMap::new(), - ssl_config: None, - wait_available_timeout: None, - } - ) - .unwrap() - ) -} - -#[test] -fn test_config_ssl() { - use indoc::indoc; - - let config_str = indoc! {r#" - backend = "docker" - image = "18.0" - - [ssl_config] - hostname = "postgresql.example.com" - - [instances.main] - "#}; - - assert_eq!( - pg_ephemeral::InstanceMap::from([( - pg_ephemeral::InstanceName::MAIN, - pg_ephemeral::Instance { - application_name: None, - backend: ociman::backend::Selection::Docker, - database: pg_client::Database::POSTGRES, - seeds: indexmap::IndexMap::new(), - ssl_config: Some(pg_ephemeral::definition::SslConfig::Generated { - hostname: "postgresql.example.com".parse().unwrap(), - }), - superuser: pg_client::User::POSTGRES, - image: "18.0".parse().unwrap(), - cross_container_access: false, - wait_available_timeout: std::time::Duration::from_secs(10), - } - )]), - pg_ephemeral::Config::load_toml(config_str) - .unwrap() - .instance_map(&pg_ephemeral::config::InstanceDefinition::empty()) - .unwrap() - ) -} - -#[tokio::test] -async fn test_run_env() { - const DATABASE_URL: cmd_proc::EnvVariableName = - cmd_proc::EnvVariableName::from_static_or_panic("DATABASE_URL"); - - let backend = ociman::test_backend_setup!(); - - common::test_definition(backend) - .with_container(async |container| { - // Use sh -c to emit both PG* and DATABASE_URL - let output = cmd_proc::Command::new("sh") - .argument("-c") - .argument("(env | grep '^PG' | sort) && echo DATABASE_URL=$DATABASE_URL") - .envs(container.pg_env().unwrap()) - .env( - &DATABASE_URL, - container - .database_url() - .parse::() - .unwrap(), - ) - .stdout_capture() - .stderr_capture() - .run() - .await - .unwrap(); - - let actual = String::from_utf8(output.stdout).unwrap(); - - // Generate expected output from config - let pg_env = container.pg_env().unwrap(); - let mut expected_lines: Vec = pg_env - .iter() - .map(|(key, value)| format!("{key}={value}")) - .collect(); - expected_lines.sort(); - expected_lines.push(format!("DATABASE_URL={}", container.database_url())); - let expected = format!("{}\n", expected_lines.join("\n")); - - assert_eq!( - expected, actual, - "Environment variables mismatch.\nExpected:\n{expected}\nActual:\n{actual}" - ); - }) - .await - .unwrap() -} - -#[test] -fn test_config_seeds_basic() { - let toml = indoc::indoc! {r#" - backend = "docker" - image = "17.1" - - [instances.main.seeds.create-users-table] - type = "sql-file" - path = "tests/fixtures/create_users.sql" - - [instances.main.seeds.insert-test-data] - type = "sql-file" - path = "tests/fixtures/insert_users.sql" - "#}; - - let config = pg_ephemeral::Config::load_toml(toml) - .unwrap() - .instance_map(&pg_ephemeral::config::InstanceDefinition::empty()) - .unwrap(); - - let definition = config.get(&pg_ephemeral::InstanceName::MAIN).unwrap(); - - let expected_seeds: indexmap::IndexMap = [ - ( - "create-users-table".parse().unwrap(), - pg_ephemeral::Seed::SqlFile { - path: "tests/fixtures/create_users.sql".into(), - }, - ), - ( - "insert-test-data".parse().unwrap(), - pg_ephemeral::Seed::SqlFile { - path: "tests/fixtures/insert_users.sql".into(), - }, - ), - ] - .into(); - - assert_eq!(definition.seeds, expected_seeds); -} - -#[test] -fn test_config_seeds_command() { - let toml = indoc::indoc! {r#" - backend = "docker" - image = "17.1" - - [instances.main.seeds.setup-schema] - type = "sql-file" - path = "tests/fixtures/schema.sql" - - [instances.main.seeds.run-migration] - type = "command" - command = "migrate" - arguments = ["up"] - cache.type = "command-hash" - "#}; - - let config = pg_ephemeral::Config::load_toml(toml) - .unwrap() - .instance_map(&pg_ephemeral::config::InstanceDefinition::empty()) - .unwrap(); - - let definition = config.get(&pg_ephemeral::InstanceName::MAIN).unwrap(); - - let expected_seeds: indexmap::IndexMap = [ - ( - "setup-schema".parse().unwrap(), - pg_ephemeral::Seed::SqlFile { - path: "tests/fixtures/schema.sql".into(), - }, - ), - ( - "run-migration".parse().unwrap(), - pg_ephemeral::Seed::Command { - command: pg_ephemeral::Command::new("migrate", ["up"]), - cache: pg_ephemeral::SeedCacheConfig::CommandHash, - }, - ), - ] - .into(); - - assert_eq!(definition.seeds, expected_seeds); -} - -#[test] -fn test_config_seeds_script() { - let toml = indoc::indoc! {r#" - backend = "docker" - image = "17.1" - - [instances.main.seeds.initialize] - type = "script" - script = "echo 'Starting setup' && psql -c 'CREATE TABLE test (id INT)'" - "#}; - - let config = pg_ephemeral::Config::load_toml(toml) - .unwrap() - .instance_map(&pg_ephemeral::config::InstanceDefinition::empty()) - .unwrap(); - - let definition = config.get(&pg_ephemeral::InstanceName::MAIN).unwrap(); - - let expected_seeds: indexmap::IndexMap = [( - "initialize".parse().unwrap(), - pg_ephemeral::Seed::Script { - script: "echo 'Starting setup' && psql -c 'CREATE TABLE test (id INT)'".to_string(), - cache: pg_ephemeral::SeedCacheConfig::CommandHash, - }, - )] - .into(); - - assert_eq!(definition.seeds, expected_seeds); -} - -#[test] -fn test_config_seeds_mixed() { - let toml = indoc::indoc! {r#" - backend = "docker" - image = "17.1" - - [instances.main.seeds.schema] - type = "sql-file" - path = "tests/fixtures/schema.sql" - - [instances.main.seeds.migrate] - type = "command" - command = "migrate" - arguments = ["up", "--verbose"] - cache.type = "command-hash" - - [instances.main.seeds.verify] - type = "script" - script = "psql -c 'SELECT COUNT(*) FROM users'" - "#}; - - let config = pg_ephemeral::Config::load_toml(toml) - .unwrap() - .instance_map(&pg_ephemeral::config::InstanceDefinition::empty()) - .unwrap(); - - let definition = config.get(&pg_ephemeral::InstanceName::MAIN).unwrap(); - - let expected_seeds: indexmap::IndexMap = [ - ( - "schema".parse().unwrap(), - pg_ephemeral::Seed::SqlFile { - path: "tests/fixtures/schema.sql".into(), - }, - ), - ( - "migrate".parse().unwrap(), - pg_ephemeral::Seed::Command { - command: pg_ephemeral::Command::new("migrate", ["up", "--verbose"]), - cache: pg_ephemeral::SeedCacheConfig::CommandHash, - }, - ), - ( - "verify".parse().unwrap(), - pg_ephemeral::Seed::Script { - script: "psql -c 'SELECT COUNT(*) FROM users'".to_string(), - cache: pg_ephemeral::SeedCacheConfig::CommandHash, - }, - ), - ] - .into(); - - assert_eq!(definition.seeds, expected_seeds); -} - -#[test] -fn test_config_seeds_preserve_declaration_order() { - // Seed names are intentionally in reverse-alphabetic order so that a - // declaration-order-preserving parser produces [z, m, a] while a - // TOML parser that materializes tables through a sorted map produces - // [a, m, z]. `IndexMap::PartialEq` is order-insensitive, so we compare - // the key sequence directly via iter(). - let toml = indoc::indoc! {r#" - backend = "docker" - image = "17.1" - - [instances.main.seeds.z-first] - type = "sql-file" - path = "first.sql" - - [instances.main.seeds.m-second] - type = "sql-file" - path = "second.sql" - - [instances.main.seeds.a-third] - type = "sql-file" - path = "third.sql" - "#}; - - let config = pg_ephemeral::Config::load_toml(toml) - .unwrap() - .instance_map(&pg_ephemeral::config::InstanceDefinition::empty()) - .unwrap(); - - let definition = config.get(&pg_ephemeral::InstanceName::MAIN).unwrap(); - - let seed_names: Vec<&str> = definition.seeds.keys().map(|name| name.as_ref()).collect(); - - assert_eq!(seed_names, vec!["z-first", "m-second", "a-third"]); -} - -#[test] -fn test_config_seeds_duplicate_name() { - let toml = indoc::indoc! {r#" - backend = "docker" - image = "17.1" - - [instances.main.seeds.duplicate] - type = "sql-file" - path = "first.sql" - - [instances.main.seeds.duplicate] - type = "sql-file" - path = "second.sql" - "#}; - - let error = pg_ephemeral::Config::load_toml(toml).unwrap_err(); - - assert_eq!( - error.to_string(), - indoc::indoc! {" - Decoding as toml failed: TOML parse error at line 8, column 23 - | - 8 | [instances.main.seeds.duplicate] - | ^^^^^^^^^ - duplicate key - "} - ); -} - -#[test] -fn test_config_seeds_with_git_revision() { - let toml = indoc::indoc! {r#" - backend = "docker" - image = "17.1" - - [instances.main.seeds.from-git] - type = "sql-file" - path = "tests/fixtures/schema.sql" - git_revision = "main" - - [instances.main.seeds.from-filesystem] - type = "sql-file" - path = "tests/fixtures/create_users.sql" - "#}; - - let config = pg_ephemeral::Config::load_toml(toml) - .unwrap() - .instance_map(&pg_ephemeral::config::InstanceDefinition::empty()) - .unwrap(); - - let definition = config.get(&pg_ephemeral::InstanceName::MAIN).unwrap(); - - let expected_seeds: indexmap::IndexMap = [ - ( - "from-git".parse().unwrap(), - pg_ephemeral::Seed::SqlFileGitRevision { - git_revision: "main".to_string(), - path: "tests/fixtures/schema.sql".into(), - }, - ), - ( - "from-filesystem".parse().unwrap(), - pg_ephemeral::Seed::SqlFile { - path: "tests/fixtures/create_users.sql".into(), - }, - ), - ] - .into(); - - assert_eq!(definition.seeds, expected_seeds); -} - -#[test] -fn test_config_image_with_sha256_digest() { - use indoc::indoc; - - let config_str = indoc! {r#" - backend = "docker" - image = "17.6@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - - [instances.main] - "#}; - - let expected_image: pg_ephemeral::Image = - "17.6@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - .parse() - .unwrap(); - - assert_eq!( - pg_ephemeral::InstanceMap::from([( - pg_ephemeral::InstanceName::MAIN, - pg_ephemeral::Instance { - application_name: None, - backend: ociman::backend::Selection::Docker, - database: pg_client::Database::POSTGRES, - seeds: indexmap::IndexMap::new(), - ssl_config: None, - superuser: pg_client::User::POSTGRES, - image: expected_image.clone(), - cross_container_access: false, - wait_available_timeout: std::time::Duration::from_secs(10), - } - )]), - pg_ephemeral::Config::load_toml(config_str) - .unwrap() - .instance_map(&pg_ephemeral::config::InstanceDefinition::empty()) - .unwrap() - ); - - // Verify the ociman::image::Reference conversion includes the digest - let reference: ociman::image::Reference = (&expected_image).into(); - assert_eq!( - reference.to_string(), - "registry.hub.docker.com/library/postgres:17.6@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - ); -} - -#[test] -fn test_config_invalid_image_format() { - use indoc::indoc; - - let config_str = indoc! {r#" - backend = "docker" - image = "17.6@sha256:tooshort" - - [instances.main] - "#}; - - let error = pg_ephemeral::Config::load_toml(config_str) - .unwrap_err() - .to_string(); - - let expected = indoc! {" - Decoding as toml failed: TOML parse error at line 2, column 9 - | - 2 | image = \"17.6@sha256:tooshort\" - | ^^^^^^^^^^^^^^^^^^^^^^ - 0: at line 1, in TakeWhileMN: - 17.6@sha256:tooshort - ^ - - 1: at line 1, in digest: - 17.6@sha256:tooshort - ^ - - 2: at line 1, in official release image: - 17.6@sha256:tooshort - ^ - - - "}; - - assert_eq!(error, expected); -} - -#[test] -fn test_config_invalid_image_nom_error() { - use indoc::indoc; - - // This tests an image format that triggers nom's detailed error with caret - let config_str = indoc! {r#" - backend = "docker" - image = "INVALID" - - [instances.main] - "#}; - - let error = pg_ephemeral::Config::load_toml(config_str) - .unwrap_err() - .to_string(); - - let expected = indoc! {" - Decoding as toml failed: TOML parse error at line 2, column 9 - | - 2 | image = \"INVALID\" - | ^^^^^^^^^ - 0: at line 1, in TakeWhileMN: - INVALID - ^ - - 1: at line 1, in OS name: - INVALID - ^ - - 2: at line 1, in OS-only image: - INVALID - ^ - - 3: at line 1, in Alt: - INVALID - ^ - - - "}; - - assert_eq!(error, expected); -} diff --git a/pg-ephemeral/tests/cache.rs b/pg-ephemeral/tests/cache.rs deleted file mode 100644 index c7d84b0c..00000000 --- a/pg-ephemeral/tests/cache.rs +++ /dev/null @@ -1,1239 +0,0 @@ -mod common; - -use common::{TestDir, TestGitRepo, run_pg_ephemeral}; - -#[tokio::test] -async fn test_populate_cache() { - let backend = ociman::test_backend_setup!(); - let instance_name: pg_ephemeral::InstanceName = "populate-cache-test".parse().unwrap(); - - // Clean up any leftover images from previous runs - let name: ociman::reference::Name = format!("localhost/pg-ephemeral/{instance_name}") - .parse() - .unwrap(); - for reference in backend.image_references_by_name(&name).await { - backend.remove_image_force(&reference).await; - } - - let definition = pg_ephemeral::Definition::new(backend.clone(), pg_ephemeral::Image::default(), instance_name.clone()) - .wait_available_timeout(std::time::Duration::from_secs(30)) - .apply_script( - "schema-and-data".parse().unwrap(), - r##"psql -c "CREATE TABLE test_cache (id INTEGER PRIMARY KEY); INSERT INTO test_cache VALUES (42);""##, - pg_ephemeral::SeedCacheConfig::CommandHash, - ) - .unwrap(); - - // Verify cache status is Miss initially - let loaded_seeds = definition.load_seeds(&instance_name).await.unwrap(); - for seed in loaded_seeds.iter_seeds() { - assert!(!seed.cache_status().is_hit()); - } - - // Populate cache - definition.populate_cache(&loaded_seeds).await.unwrap(); - - // Verify cache status is now Hit - let loaded_seeds = definition.load_seeds(&instance_name).await.unwrap(); - for seed in loaded_seeds.iter_seeds() { - assert!(seed.cache_status().is_hit()); - } - - // Boot from the cached image using with_container (which handles cache hits properly) - // and verify the seed effect is present - definition - .with_container(async |container| { - container - .with_connection(async |connection| { - let row: (i32,) = sqlx::query_as("SELECT id FROM test_cache") - .fetch_one(&mut *connection) - .await - .unwrap(); - assert_eq!(row.0, 42); - }) - .await; - }) - .await - .unwrap(); - - // Clean up images - for reference in backend.image_references_by_name(&name).await { - backend.remove_image_force(&reference).await; - } -} - -#[tokio::test] -async fn test_populate_cache_runs_seeds_in_declaration_order() { - let backend = ociman::test_backend_setup!(); - let instance_name: pg_ephemeral::InstanceName = "populate-cache-order-test".parse().unwrap(); - - // Clean up any leftover images from previous runs - let name: ociman::reference::Name = format!("localhost/pg-ephemeral/{instance_name}") - .parse() - .unwrap(); - for reference in backend.image_references_by_name(&name).await { - backend.remove_image_force(&reference).await; - } - - // Seed names are declared in reverse alphabetic order (z -> m -> a) and each - // seed depends on the previous one having executed. If populate_cache ever - // sorts by name instead of honoring declaration order, the "m-insert" step - // would run before "z-create-table" and fail because the table does not - // exist yet. - let definition = pg_ephemeral::Definition::new( - backend.clone(), - pg_ephemeral::Image::default(), - instance_name.clone(), - ) - .wait_available_timeout(std::time::Duration::from_secs(30)) - .apply_script( - "z-create-table".parse().unwrap(), - r#"psql -c "CREATE TABLE order_test (value INTEGER)""#, - pg_ephemeral::SeedCacheConfig::CommandHash, - ) - .unwrap() - .apply_script( - "m-insert-row".parse().unwrap(), - r#"psql -c "INSERT INTO order_test VALUES (1)""#, - pg_ephemeral::SeedCacheConfig::CommandHash, - ) - .unwrap() - .apply_script( - "a-update-row".parse().unwrap(), - r#"psql -c "UPDATE order_test SET value = 2 WHERE value = 1""#, - pg_ephemeral::SeedCacheConfig::CommandHash, - ) - .unwrap(); - - // Populate cache - this will fail if seeds run in alphabetic order because - // a-update-row references a table that z-create-table has not yet created. - let loaded_seeds = definition.load_seeds(&instance_name).await.unwrap(); - definition.populate_cache(&loaded_seeds).await.unwrap(); - - // Boot from the cached image and verify all three seeds ran in declaration - // order: table created, row inserted with value 1, row updated to value 2. - definition - .with_container(async |container| { - container - .with_connection(async |connection| { - let row: (i32,) = sqlx::query_as("SELECT value FROM order_test") - .fetch_one(&mut *connection) - .await - .unwrap(); - assert_eq!(row.0, 2); - }) - .await; - }) - .await - .unwrap(); - - // Clean up images - for reference in backend.image_references_by_name(&name).await { - backend.remove_image_force(&reference).await; - } -} - -#[tokio::test] -async fn test_cache_status() { - let _backend = ociman::test_backend_setup!(); - let repo = TestGitRepo::new("cache-test").await; - - repo.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); - repo.write_file("data.sql", "INSERT INTO users (id) VALUES (1);"); - let commit_hash = repo.commit("Initial").await; - - let config_content = indoc::formatdoc! {r#" - image = "17.1" - - [instances.main.seeds.a-schema] - type = "sql-file" - path = "schema.sql" - - [instances.main.seeds.b-data-from-git] - type = "sql-file" - path = "data.sql" - git_revision = "{commit_hash}" - - [instances.main.seeds.c-run-command] - type = "command" - command = "echo" - arguments = ["hello"] - cache.type = "command-hash" - - [instances.main.seeds.d-run-script] - type = "script" - script = "echo 'hello world'" - "#}; - repo.write_file("database.toml", &config_content); - - let expected = indoc::indoc! {r#" - { - "instance": "main", - "base_image": "17.1", - "version": "0.4.0", - "summary": { - "total": 4, - "hits": 0, - "misses": 4, - "uncacheable": 0 - }, - "seeds": [ - { - "name": "a-schema", - "type": "sql-file", - "status": "miss", - "cache_image": "pg-ephemeral/main:8ee3896ee958931123af048077d74fd9758b4dd494450f29e11f909f2ed8160a" - }, - { - "name": "b-data-from-git", - "type": "sql-file-git-revision", - "status": "miss", - "cache_image": "pg-ephemeral/main:a3c02b59a0dbc21abeed0aec932496906d944e049ab06a1cc524882f6b5c7698" - }, - { - "name": "c-run-command", - "type": "command", - "status": "miss", - "cache_image": "pg-ephemeral/main:c2a894f18fef10ca9f960eb49e93c3fdcb9d1a48311d19965fc57544359dffa7" - }, - { - "name": "d-run-script", - "type": "script", - "status": "miss", - "cache_image": "pg-ephemeral/main:7f2ce26f39977a2d7f8d09497b354576474f457d677c5101dc5d35886c8a8154" - } - ] - } - "#}; - - let stdout = run_pg_ephemeral(&["cache", "status", "--json"], &repo.path).await; - assert_eq!(stdout, expected); -} - -#[tokio::test] -async fn test_cache_status_deterministic() { - let _backend = ociman::test_backend_setup!(); - let dir = TestDir::new("cache-deterministic-test"); - - dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); - - dir.write_file( - "database.toml", - indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.schema] - type = "sql-file" - path = "schema.sql" - "#}, - ); - - let expected = indoc::indoc! {r#" - { - "instance": "main", - "base_image": "17.1", - "version": "0.4.0", - "summary": { - "total": 1, - "hits": 0, - "misses": 1, - "uncacheable": 0 - }, - "seeds": [ - { - "name": "schema", - "type": "sql-file", - "status": "miss", - "cache_image": "pg-ephemeral/main:8ee3896ee958931123af048077d74fd9758b4dd494450f29e11f909f2ed8160a" - } - ] - } - "#}; - - let stdout = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; - assert_eq!(stdout, expected); -} - -#[tokio::test] -async fn test_cache_status_uncacheable_reason() { - let _backend = ociman::test_backend_setup!(); - let dir = TestDir::new("cache-uncacheable-reason-test"); - - // Same schema.sql + image as `test_cache_status_deterministic`, so the - // schema seed's reference hash is fixed and we can assert against an - // exact JSON. - dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); - - // chain: cacheable schema -> chain-breaker `nope` (cache=none) - // -> chain-broken `tail` (uncacheable because predecessor broke chain) - dir.write_file( - "database.toml", - indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.schema] - type = "sql-file" - path = "schema.sql" - - [instances.main.seeds.nope] - type = "script" - script = "true" - cache = { type = "none" } - - [instances.main.seeds.tail] - type = "sql-statement" - statement = "SELECT 1" - "#}, - ); - - let expected = serde_json::json!({ - "instance": "main", - "base_image": "17.1", - "version": "0.4.0", - "summary": { - "total": 3, - "hits": 0, - "misses": 1, - "uncacheable": 2, - }, - "seeds": [ - { - "name": "schema", - "type": "sql-file", - "status": "miss", - "cache_image": "pg-ephemeral/main:8ee3896ee958931123af048077d74fd9758b4dd494450f29e11f909f2ed8160a", - }, - { - "name": "nope", - "type": "script", - "status": "uncacheable", - "reason": "cache_strategy_none", - }, - { - "name": "tail", - "type": "sql-statement", - "status": "uncacheable", - "reason": "chain_broken_by_predecessor", - "broken_by": "nope", - }, - ], - }); - - let stdout = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; - let actual: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - assert_eq!(actual, expected); -} - -#[tokio::test] -async fn test_cache_status_change_with_content() { - let _backend = ociman::test_backend_setup!(); - let dir = TestDir::new("cache-changes-test"); - - dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); - - dir.write_file( - "database.toml", - indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.schema] - type = "sql-file" - path = "schema.sql" - "#}, - ); - - let stdout1 = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; - - dir.write_file( - "schema.sql", - "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);", - ); - - let stdout2 = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; - // Cache reference should change when content changes - assert_ne!(stdout2, stdout1); -} - -#[tokio::test] -async fn test_cache_status_change_with_image() { - let _backend = ociman::test_backend_setup!(); - let dir = TestDir::new("cache-image-test"); - - dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); - - dir.write_file( - "database.toml", - indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.schema] - type = "sql-file" - path = "schema.sql" - "#}, - ); - - let stdout1 = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; - - dir.write_file( - "database.toml", - indoc::indoc! {r#" - image = "17.2" - - [instances.main.seeds.schema] - type = "sql-file" - path = "schema.sql" - "#}, - ); - - let stdout2 = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; - // Cache reference should change when image changes - assert_ne!(stdout2, stdout1); -} - -#[tokio::test] -async fn test_cache_status_chain_propagates() { - let _backend = ociman::test_backend_setup!(); - let dir = TestDir::new("cache-chain-test"); - - dir.write_file("first.sql", "CREATE TABLE first (id INTEGER);"); - dir.write_file("second.sql", "CREATE TABLE second (id INTEGER);"); - - dir.write_file( - "database.toml", - indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.a-first] - type = "sql-file" - path = "first.sql" - - [instances.main.seeds.b-second] - type = "sql-file" - path = "second.sql" - "#}, - ); - - let expected_before = indoc::indoc! {r#" - { - "instance": "main", - "base_image": "17.1", - "version": "0.4.0", - "summary": { - "total": 2, - "hits": 0, - "misses": 2, - "uncacheable": 0 - }, - "seeds": [ - { - "name": "a-first", - "type": "sql-file", - "status": "miss", - "cache_image": "pg-ephemeral/main:5982415ac9ad91019e69496c59dffc68df698668acabd8038291fa0467387a10" - }, - { - "name": "b-second", - "type": "sql-file", - "status": "miss", - "cache_image": "pg-ephemeral/main:b75441a4063765e42528ff76a6587fa1d8a4b9debf60cfaf9d58f08c0f8cac29" - } - ] - } - "#}; - - let stdout1 = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; - assert_eq!(stdout1, expected_before); - - dir.write_file("first.sql", "CREATE TABLE first (id INTEGER, name TEXT);"); - - let stdout2 = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; - // Cache reference should change when first seed changes, and propagate to second seed - assert_ne!(stdout2, expected_before); -} - -#[tokio::test] -async fn test_cache_status_key_command() { - let _backend = ociman::test_backend_setup!(); - let dir = TestDir::new("cache-key-command-test"); - - dir.write_file("version.txt", "1.0.0"); - - dir.write_file( - "database.toml", - indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.run-migrations] - type = "command" - command = "migrate" - arguments = ["up"] - - [instances.main.seeds.run-migrations.cache] - type = "key-command" - command = "cat" - arguments = ["version.txt"] - "#}, - ); - - let expected_before = indoc::indoc! {r#" - { - "instance": "main", - "base_image": "17.1", - "version": "0.4.0", - "summary": { - "total": 1, - "hits": 0, - "misses": 1, - "uncacheable": 0 - }, - "seeds": [ - { - "name": "run-migrations", - "type": "command", - "status": "miss", - "cache_image": "pg-ephemeral/main:5b31c8c9895000f43d0cf14914d8dff86e1c0a3b01a954e05bc96b3511992f5c" - } - ] - } - "#}; - - let stdout1 = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; - assert_eq!(stdout1, expected_before); - - // Change the version file - cache reference should change - dir.write_file("version.txt", "2.0.0"); - - let stdout2 = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; - // Cache reference should change when key command output changes - assert_ne!(stdout2, expected_before); -} - -/// Parse a TOML config in-line and return the cache reference of each seed in the -/// `main` instance. Panics on any error. -async fn seed_references(toml: &str) -> Vec { - let instance_name = pg_ephemeral::InstanceName::MAIN; - let instances = pg_ephemeral::Config::load_toml(toml) - .unwrap() - .instance_map(&pg_ephemeral::config::InstanceDefinition::empty()) - .unwrap(); - let definition = instances - .get(&instance_name) - .unwrap() - .definition(&instance_name) - .await - .unwrap(); - definition - .load_seeds(&instance_name) - .await - .unwrap() - .iter_seeds() - .map(|seed| seed.cache_status().reference().unwrap().to_string()) - .collect() -} - -#[tokio::test] -async fn test_cache_status_key_script_on_command_seed() { - let _backend = ociman::test_backend_setup!(); - - let baseline = seed_references(indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.run-migrations] - type = "command" - command = "migrate" - arguments = ["up"] - - [instances.main.seeds.run-migrations.cache] - type = "key-script" - script = "echo version-1" - "#}) - .await; - - // Changing the key-script output invalidates the cache. - let after_key_change = seed_references(indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.run-migrations] - type = "command" - command = "migrate" - arguments = ["up"] - - [instances.main.seeds.run-migrations.cache] - type = "key-script" - script = "echo version-2" - "#}) - .await; - assert_ne!(after_key_change, baseline); - - // Changing command arguments also invalidates the cache, even though the - // key-script output is unchanged. Regression guard for the bug where - // key-script output used to replace rather than supplement the command hash. - let after_args_change = seed_references(indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.run-migrations] - type = "command" - command = "migrate" - arguments = ["down"] - - [instances.main.seeds.run-migrations.cache] - type = "key-script" - script = "echo version-1" - "#}) - .await; - assert_ne!(after_args_change, baseline); -} - -#[tokio::test] -async fn test_cli_key_script_failure_reports_display() { - let _backend = ociman::test_backend_setup!(); - let dir = TestDir::new("cli-key-script-failure-display-test"); - - dir.write_file( - "database.toml", - indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.run-migrations] - type = "command" - command = "migrate" - arguments = ["up"] - - [instances.main.seeds.run-migrations.cache] - type = "key-script" - script = "exit 1" - "#}, - ); - - let pg_ephemeral_bin = env!("CARGO_BIN_EXE_pg-ephemeral"); - let output = cmd_proc::Command::new(pg_ephemeral_bin) - .arguments(["cache", "status"]) - .working_directory(&dir.path) - .stdout_capture() - .stderr_capture() - .accept_nonzero_exit() - .run() - .await - .unwrap(); - - assert!(!output.status.success()); - - // main() must print thiserror's Display-formatted source chain, not the Debug tuple-variant dump. - let stderr = String::from_utf8(output.stderr).unwrap(); - assert_eq!( - stderr, - indoc::indoc! {" - Error: Failed to load seed run-migrations: cache key script failed - caused by: command exited with exit status: 1 - "}, - ); -} - -#[tokio::test] -async fn test_cache_status_key_script_failure_propagates() { - let _backend = ociman::test_backend_setup!(); - - let instance_name = pg_ephemeral::InstanceName::MAIN; - let instances = pg_ephemeral::Config::load_toml(indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.run-migrations] - type = "command" - command = "migrate" - arguments = ["up"] - - [instances.main.seeds.run-migrations.cache] - type = "key-script" - script = "exit 1" - "#}) - .unwrap() - .instance_map(&pg_ephemeral::config::InstanceDefinition::empty()) - .unwrap(); - let definition = instances - .get(&instance_name) - .unwrap() - .definition(&instance_name) - .await - .unwrap(); - - let error = definition.load_seeds(&instance_name).await.unwrap_err(); - - assert!( - matches!(error, pg_ephemeral::LoadError::KeyScript { .. }), - "expected LoadError::KeyScript, got: {error:?}" - ); -} - -#[tokio::test] -async fn test_cache_status_key_script_on_script_seed() { - let _backend = ociman::test_backend_setup!(); - - let baseline = seed_references(indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.seed-data] - type = "script" - script = "psql -c 'SELECT 1'" - - [instances.main.seeds.seed-data.cache] - type = "key-script" - script = "echo version-1" - "#}) - .await; - - // Changing the key-script output invalidates the cache. - let after_key_change = seed_references(indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.seed-data] - type = "script" - script = "psql -c 'SELECT 1'" - - [instances.main.seeds.seed-data.cache] - type = "key-script" - script = "echo version-2" - "#}) - .await; - assert_ne!(after_key_change, baseline); - - // Changing the script body also invalidates the cache, even though the - // key-script output is unchanged. - let after_script_change = seed_references(indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.seed-data] - type = "script" - script = "psql -c 'SELECT 2'" - - [instances.main.seeds.seed-data.cache] - type = "key-script" - script = "echo version-1" - "#}) - .await; - assert_ne!(after_script_change, baseline); -} - -#[tokio::test] -async fn test_cache_status_change_with_ssl() { - let _backend = ociman::test_backend_setup!(); - let dir = TestDir::new("cache-ssl-test"); - - dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); - - dir.write_file( - "database.toml", - indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.schema] - type = "sql-file" - path = "schema.sql" - "#}, - ); - - let output_no_ssl = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; - - // Add SSL config - dir.write_file( - "database.toml", - indoc::indoc! {r#" - image = "17.1" - - [ssl_config] - hostname = "localhost" - - [instances.main.seeds.schema] - type = "sql-file" - path = "schema.sql" - "#}, - ); - - let output_with_ssl = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; - - // Cache key should change when SSL config is added - assert_ne!(output_no_ssl, output_with_ssl); - - // Change SSL hostname - dir.write_file( - "database.toml", - indoc::indoc! {r#" - image = "17.1" - - [ssl_config] - hostname = "example.com" - - [instances.main.seeds.schema] - type = "sql-file" - path = "schema.sql" - "#}, - ); - - let output_different_ssl = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; - - // Cache key should change when SSL hostname changes - assert_ne!(output_with_ssl, output_different_ssl); -} - -#[tokio::test] -async fn test_cache_status_container_script() { - let _backend = ociman::test_backend_setup!(); - let dir = TestDir::new("cache-container-script-test"); - - dir.write_file( - "database.toml", - indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.install-ext] - type = "container-script" - script = "touch /container-script-marker" - "#}, - ); - - let stdout = run_pg_ephemeral(&["cache", "status", "--json"], &dir.path).await; - let output: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - - assert_eq!(output["seeds"][0]["name"], "install-ext"); - assert_eq!(output["seeds"][0]["type"], "container-script"); - assert_eq!(output["seeds"][0]["status"], "miss"); - assert!(output["seeds"][0]["cache_image"].is_string()); -} - -#[tokio::test] -async fn test_populate_cache_container_script() { - let backend = ociman::test_backend_setup!(); - let instance_name: pg_ephemeral::InstanceName = - "populate-cache-container-script-test".parse().unwrap(); - - // Clean up any leftover images from previous runs - let name: ociman::reference::Name = format!("localhost/pg-ephemeral/{instance_name}") - .parse() - .unwrap(); - for reference in backend.image_references_by_name(&name).await { - backend.remove_image_force(&reference).await; - } - - let definition = pg_ephemeral::Definition::new( - backend.clone(), - pg_ephemeral::Image::default(), - instance_name.clone(), - ) - .wait_available_timeout(std::time::Duration::from_secs(30)) - .apply_container_script( - "create-marker".parse().unwrap(), - "touch /container-script-marker", - ) - .unwrap(); - - // Verify cache status is Miss initially - let loaded_seeds = definition.load_seeds(&instance_name).await.unwrap(); - for seed in loaded_seeds.iter_seeds() { - assert!(!seed.cache_status().is_hit()); - } - - // Populate cache - definition.populate_cache(&loaded_seeds).await.unwrap(); - - // Verify cache status is now Hit - let loaded_seeds = definition.load_seeds(&instance_name).await.unwrap(); - for seed in loaded_seeds.iter_seeds() { - assert!(seed.cache_status().is_hit()); - } - - // Boot from the cached image and verify PG starts cleanly - definition - .with_container(async |container| { - container - .with_connection(async |connection| { - let row: (bool,) = sqlx::query_as("SELECT true") - .fetch_one(&mut *connection) - .await - .unwrap(); - assert!(row.0); - }) - .await; - }) - .await - .unwrap(); - - // Clean up images - for reference in backend.image_references_by_name(&name).await { - backend.remove_image_force(&reference).await; - } -} - -#[tokio::test] -async fn test_container_script_with_pg_cron() { - let backend = ociman::test_backend_setup!(); - let instance_name: pg_ephemeral::InstanceName = - "container-script-pg-cron-test".parse().unwrap(); - - // Clean up any leftover images from previous runs - let name: ociman::reference::Name = format!("localhost/pg-ephemeral/{instance_name}") - .parse() - .unwrap(); - for reference in backend.image_references_by_name(&name).await { - backend.remove_image_force(&reference).await; - } - - let definition = pg_ephemeral::Definition::new( - backend.clone(), - "17".parse().unwrap(), - instance_name.clone(), - ) - .wait_available_timeout(std::time::Duration::from_secs(30)) - .apply_container_script( - "install-pg-cron".parse().unwrap(), - "apt-get update && apt-get install -y --no-install-recommends postgresql-17-cron \ - && printf '#!/bin/bash\\necho \"shared_preload_libraries = '\"'\"'pg_cron'\"'\"'\" >> \"$PGDATA/postgresql.conf\"\\n' \ - > /docker-entrypoint-initdb.d/pg-cron.sh \ - && chmod +x /docker-entrypoint-initdb.d/pg-cron.sh", - ) - .unwrap() - .apply_script( - "enable-pg-cron".parse().unwrap(), - r#"psql -c "CREATE EXTENSION pg_cron""#, - pg_ephemeral::SeedCacheConfig::CommandHash, - ) - .unwrap(); - - definition - .with_container(async |container| { - container - .with_connection(async |connection| { - let row: (String,) = sqlx::query_as( - "SELECT extname::text FROM pg_extension WHERE extname = 'pg_cron'", - ) - .fetch_one(&mut *connection) - .await - .unwrap(); - assert_eq!(row.0, "pg_cron"); - }) - .await; - }) - .await - .unwrap(); - - // Clean up images - for reference in backend.image_references_by_name(&name).await { - backend.remove_image_force(&reference).await; - } -} - -// JoinHandle is intentionally returned to be awaited after stop() terminates connections. -#[allow(clippy::async_yields_async)] -#[tokio::test] -async fn test_stale_connection_terminated_before_stop() { - let backend = ociman::test_backend_setup!(); - - let definition = common::test_definition(backend); - - // with_container returns the JoinHandle; stop() runs before it returns. - let sleep_handle = definition - .with_container(async |container| { - let config = container.client_config().to_sqlx_connect_options().unwrap(); - let mut connection = sqlx::ConnectOptions::connect(&config).await.unwrap(); - - tokio::spawn(async move { - sqlx::query("SELECT pg_sleep(3600)") - .execute(&mut connection) - .await - }) - }) - .await - .unwrap(); - - // stop() terminated all connections before shutting down. - // The sleep query must fail with a connection error, not succeed or hang for 3600s. - let error = sleep_handle.await.unwrap().unwrap_err(); - - match error { - sqlx::Error::Database(ref db_error) => { - assert_eq!(db_error.code().as_deref(), Some("57P01")); - } - _ => panic!("Expected database error 57P01 (admin_shutdown), got: {error}"), - } -} - -async fn run_cli(args: &[&str], current_dir: &std::path::Path) -> (Option, String, String) { - let pg_ephemeral_bin = env!("CARGO_BIN_EXE_pg-ephemeral"); - - let output = cmd_proc::Command::new(pg_ephemeral_bin) - .arguments(args) - .working_directory(current_dir) - .stdout_capture() - .stderr_capture() - .accept_nonzero_exit() - .run() - .await - .unwrap(); - - ( - output.status.code(), - String::from_utf8(output.stdout).unwrap(), - String::from_utf8(output.stderr).unwrap(), - ) -} - -async fn cleanup_cache_images( - backend: &ociman::Backend, - instance_name: &pg_ephemeral::InstanceName, -) { - let name: ociman::reference::Name = format!("localhost/pg-ephemeral/{instance_name}") - .parse() - .unwrap(); - for reference in backend.image_references_by_name(&name).await { - backend.remove_image_force(&reference).await; - } -} - -#[tokio::test] -async fn test_cache_credentials_default_seed() { - let backend = ociman::test_backend_setup!(); - let instance_name: pg_ephemeral::InstanceName = "credentials-default-test".parse().unwrap(); - cleanup_cache_images(&backend, &instance_name).await; - - let dir = TestDir::new("credentials-default-test"); - dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); - dir.write_file( - "database.toml", - &indoc::formatdoc! {r#" - image = "17.1" - - [instances.{instance_name}.seeds.schema] - type = "sql-file" - path = "schema.sql" - "#}, - ); - - run_pg_ephemeral( - &["cache", "--instance", instance_name.as_ref(), "populate"], - &dir.path, - ) - .await; - - let (code, stdout, stderr) = run_cli( - &["cache", "--instance", instance_name.as_ref(), "credentials"], - &dir.path, - ) - .await; - - let actual: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let password = actual["superuser"]["password"] - .as_str() - .expect("password missing") - .to_string(); - let cache_image = format!( - "pg-ephemeral/{instance_name}:8ee3896ee958931123af048077d74fd9758b4dd494450f29e11f909f2ed8160a", - ); - - assert_eq!( - (code, actual, stderr), - ( - Some(0), - serde_json::json!({ - "cache_image": cache_image, - "superuser": { - "user": "postgres", - "database": "postgres", - "password": password, - }, - }), - String::new(), - ), - ); - - cleanup_cache_images(&backend, &instance_name).await; -} - -#[tokio::test] -async fn test_cache_credentials_explicit_seed_name() { - let backend = ociman::test_backend_setup!(); - let instance_name: pg_ephemeral::InstanceName = "credentials-explicit-test".parse().unwrap(); - cleanup_cache_images(&backend, &instance_name).await; - - let dir = TestDir::new("credentials-explicit-test"); - dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); - dir.write_file( - "database.toml", - &indoc::formatdoc! {r#" - image = "17.1" - - [instances.{instance_name}.seeds.schema] - type = "sql-file" - path = "schema.sql" - - [instances.{instance_name}.seeds.fixtures] - type = "sql-statement" - statement = "INSERT INTO users (id) VALUES (1)" - "#}, - ); - - run_pg_ephemeral( - &["cache", "--instance", instance_name.as_ref(), "populate"], - &dir.path, - ) - .await; - - // Ask for the FIRST seed by name; the cache_image must be the schema-only - // hash, not the deeper fixtures-applied hash. - let (code, stdout, stderr) = run_cli( - &[ - "cache", - "--instance", - instance_name.as_ref(), - "credentials", - "--seed-name", - "schema", - ], - &dir.path, - ) - .await; - - let actual: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let password = actual["superuser"]["password"] - .as_str() - .expect("password missing") - .to_string(); - let cache_image = format!( - "pg-ephemeral/{instance_name}:8ee3896ee958931123af048077d74fd9758b4dd494450f29e11f909f2ed8160a", - ); - - assert_eq!( - (code, actual, stderr), - ( - Some(0), - serde_json::json!({ - "cache_image": cache_image, - "superuser": { - "user": "postgres", - "database": "postgres", - "password": password, - }, - }), - String::new(), - ), - ); - - cleanup_cache_images(&backend, &instance_name).await; -} - -#[tokio::test] -async fn test_cache_credentials_no_seeds() { - let _backend = ociman::test_backend_setup!(); - let dir = TestDir::new("credentials-no-seeds-test"); - dir.write_file( - "database.toml", - indoc::indoc! {r#" - image = "17.1" - - [instances.main] - "#}, - ); - - let actual = run_cli(&["cache", "credentials"], &dir.path).await; - assert_eq!( - actual, - ( - Some(1), - String::new(), - "Error: Instance main has no seeds; cache credentials requires a cacheable seed\n" - .to_string(), - ), - ); -} - -#[tokio::test] -async fn test_cache_credentials_uncacheable_tip() { - let _backend = ociman::test_backend_setup!(); - let dir = TestDir::new("credentials-uncacheable-test"); - dir.write_file( - "database.toml", - indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.tip] - type = "script" - script = "true" - cache = { type = "none" } - "#}, - ); - - let actual = run_cli(&["cache", "credentials"], &dir.path).await; - assert_eq!( - actual, - ( - Some(1), - String::new(), - "Error: Seed tip on instance main is uncacheable; cache credentials requires a cacheable seed\n" - .to_string(), - ), - ); -} - -#[tokio::test] -async fn test_cache_credentials_unknown_seed() { - let _backend = ociman::test_backend_setup!(); - let dir = TestDir::new("credentials-unknown-seed-test"); - dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); - dir.write_file( - "database.toml", - indoc::indoc! {r#" - image = "17.1" - - [instances.main.seeds.schema] - type = "sql-file" - path = "schema.sql" - "#}, - ); - - let actual = run_cli( - &["cache", "credentials", "--seed-name", "does-not-exist"], - &dir.path, - ) - .await; - assert_eq!( - actual, - ( - Some(1), - String::new(), - "Error: Instance main has no seed named does-not-exist\n".to_string(), - ), - ); -} - -#[tokio::test] -async fn test_cache_credentials_miss_tip() { - let backend = ociman::test_backend_setup!(); - let instance_name: pg_ephemeral::InstanceName = "credentials-miss-test".parse().unwrap(); - cleanup_cache_images(&backend, &instance_name).await; - - let dir = TestDir::new("credentials-miss-test"); - dir.write_file("schema.sql", "CREATE TABLE users (id INTEGER PRIMARY KEY);"); - dir.write_file( - "database.toml", - &indoc::formatdoc! {r#" - image = "17.1" - - [instances.{instance_name}.seeds.schema] - type = "sql-file" - path = "schema.sql" - "#}, - ); - - let actual = run_cli( - &["cache", "--instance", instance_name.as_ref(), "credentials"], - &dir.path, - ) - .await; - assert_eq!( - actual, - ( - Some(1), - String::new(), - format!( - "Error: Seed schema on instance {instance_name} is not yet cached; run `pg-ephemeral cache populate` first\n", - ), - ), - ); -} diff --git a/pg-ephemeral/tests/container.rs b/pg-ephemeral/tests/container.rs deleted file mode 100644 index cd652c71..00000000 --- a/pg-ephemeral/tests/container.rs +++ /dev/null @@ -1,38 +0,0 @@ -mod common; - -use std::str::FromStr; - -#[tokio::test] -async fn test_set_superuser_password() { - if ociman::testing::platform_not_supported() { - return; - } - - let backend = ociman::test_backend_setup!(); - - let definition = pg_ephemeral::Definition::new( - backend, - pg_ephemeral::Image::default(), - "test".parse().unwrap(), - ) - .wait_available_timeout(std::time::Duration::from_secs(30)); - - definition - .with_container(async |container| { - let new_password = pg_client::config::Password::from_str("new_password_123").unwrap(); - container - .set_superuser_password(&new_password) - .await - .unwrap(); - - let mut new_client_config = container.client_config().clone(); - new_client_config.session.password = Some(new_password); - - new_client_config - .with_sqlx_connection(async |_| {}) - .await - .unwrap(); - }) - .await - .unwrap(); -} diff --git a/pg-ephemeral/tests/examples_boot.rs b/pg-ephemeral/tests/examples_boot.rs deleted file mode 100644 index fbc18b0f..00000000 --- a/pg-ephemeral/tests/examples_boot.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Boot check for every example under `pg-ephemeral/examples/`. -//! -//! For every example with a `database.toml`, load it, build a definition for -//! every declared instance, boot a container, and assert a trivial query -//! works. Catches semantic drift the parse-only test in `examples.rs` cannot -//! see: e.g. a seed file that references a function the new image removed, -//! or a config that parses but produces a definition that fails to boot. -//! -//! Gated through `ociman::test_backend_setup!()` like every other -//! container-using test, so it skips on platforms without Docker/Podman. -//! -//! `06-container-script-pg-cron` is excluded because it does -//! `apt-get update && apt-get install` from inside the container, which -//! requires network access that not every CI environment provides. Set -//! `PG_EPHEMERAL_TEST_NETWORK=1` to opt into running it. - -use std::path::Path; - -const NETWORK_OPT_IN_VAR: &str = "PG_EPHEMERAL_TEST_NETWORK"; -const NETWORK_GATED_EXAMPLES: &[&str] = &["06-container-script-pg-cron"]; - -#[tokio::test] -async fn every_example_boots() { - let _backend = ociman::test_backend_setup!(); - - let examples_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples"); - let network_opt_in = std::env::var(NETWORK_OPT_IN_VAR).is_ok(); - - let mut entries: Vec<_> = std::fs::read_dir(&examples_dir) - .unwrap() - .map(Result::unwrap) - .collect(); - entries.sort_by_key(std::fs::DirEntry::file_name); - - let mut booted: Vec = Vec::new(); - - for entry in entries { - let path = entry.path(); - if !path.is_dir() { - continue; - } - - let dir_name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap() - .to_string(); - - let toml_path = path.join("database.toml"); - if !toml_path.exists() { - continue; - } - - if NETWORK_GATED_EXAMPLES.contains(&dir_name.as_str()) && !network_opt_in { - log::info!( - "skipping {dir_name}: requires network access; set {NETWORK_OPT_IN_VAR}=1 to enable" - ); - continue; - } - - boot_example(&toml_path, &dir_name).await; - booted.push(dir_name); - } - - assert!( - !booted.is_empty(), - "expected at least one example to boot from {}", - examples_dir.display() - ); -} - -async fn boot_example(toml_path: &Path, example_name: &str) { - let instance_map = pg_ephemeral::Config::load_toml_file( - toml_path, - &pg_ephemeral::config::InstanceDefinition::empty(), - ) - .unwrap_or_else(|error| panic!("{example_name}: parse failed: {error}")); - - for (instance_name, instance) in &instance_map { - let definition = instance - .definition(instance_name) - .await - .unwrap_or_else(|error| { - panic!("{example_name}/{instance_name}: definition build failed: {error}") - }) - // CI runners can be slow; match what `common::test_definition` uses. - .wait_available_timeout(std::time::Duration::from_secs(30)); - - definition - .with_container(async |container| { - container - .with_connection(async |connection| { - let row = sqlx::query("SELECT 1::int AS one") - .fetch_one(connection) - .await - .unwrap_or_else(|error| { - panic!("{example_name}/{instance_name}: SELECT 1 failed: {error}") - }); - let one: i32 = sqlx::Row::get(&row, "one"); - assert_eq!( - one, 1, - "{example_name}/{instance_name}: SELECT 1 returned {one}" - ); - }) - .await; - }) - .await - .unwrap_or_else(|error| { - panic!("{example_name}/{instance_name}: with_container failed: {error:?}") - }); - } -} diff --git a/pg-ephemeral/tests/integration.rs b/pg-ephemeral/tests/integration.rs deleted file mode 100644 index e064927f..00000000 --- a/pg-ephemeral/tests/integration.rs +++ /dev/null @@ -1,13 +0,0 @@ -mod common; - -#[tokio::test] -async fn test_ruby_database_url_integration() { - common::test_database_url_integration("ruby", "tests/integration/ruby", &common::RUBY_IMAGE) - .await -} - -#[tokio::test] -async fn test_prisma_database_url_integration() { - common::test_database_url_integration("prisma", "tests/integration/prisma", &common::NODE_IMAGE) - .await -} diff --git a/pg-ephemeral/tests/labels.rs b/pg-ephemeral/tests/labels.rs deleted file mode 100644 index 15b40512..00000000 --- a/pg-ephemeral/tests/labels.rs +++ /dev/null @@ -1,195 +0,0 @@ -mod common; - -use pg_ephemeral::label; - -#[tokio::test] -async fn test_labels_written_minimal_container() { - let backend = ociman::test_backend_setup!(); - let instance_name: pg_ephemeral::InstanceName = "test-labels".parse().unwrap(); - - let definition = pg_ephemeral::Definition::new( - backend, - pg_ephemeral::Image::default(), - instance_name.clone(), - ) - .wait_available_timeout(std::time::Duration::from_secs(30)); - - definition - .with_container(async |container| { - let labels = container.labels().await.unwrap(); - - // Always-present keys. - for key in [ - &label::VERSION_KEY, - &label::INSTANCE_KEY, - &label::IMAGE_KEY, - &label::SUPERUSER_USER_KEY, - &label::SUPERUSER_DATABASE_KEY, - &label::SUPERUSER_PASSWORD_KEY, - &label::SEEDS_KEY, - ] { - assert!( - labels.contains_key(key), - "expected label {key} to be present", - ); - } - - // Conditional keys NOT set in this minimal Definition. - for key in [ - &label::SUPERUSER_APPLICATION_KEY, - &label::SSL_HOSTNAME_KEY, - &label::SSL_CA_CERT_PEM_KEY, - ] { - assert!( - !labels.contains_key(key), - "label {key} should not be present in minimal Definition", - ); - } - - // Spot-check values. - assert_eq!( - labels.get(&label::VERSION_KEY).unwrap().as_str(), - pg_ephemeral::version().to_string(), - ); - assert_eq!( - labels.get(&label::INSTANCE_KEY).unwrap().as_str(), - instance_name.as_str(), - ); - assert_eq!( - labels.get(&label::SUPERUSER_USER_KEY).unwrap().as_str(), - pg_client::User::POSTGRES.as_ref(), - ); - assert_eq!( - labels.get(&label::SUPERUSER_DATABASE_KEY).unwrap().as_str(), - pg_client::Database::POSTGRES.as_ref(), - ); - - // Password label matches the Container's actual configured password. - let stored_password = labels.get(&label::SUPERUSER_PASSWORD_KEY).unwrap().as_str(); - let actual_password = container - .client_config() - .session - .password - .as_ref() - .unwrap() - .as_ref(); - assert_eq!(stored_password, actual_password); - - // Seeds label parses back to an empty list (no seeds in this Definition). - let seeds_json = labels.get(&label::SEEDS_KEY).unwrap().as_str(); - let seed_entries: Vec = serde_json::from_str(seeds_json).unwrap(); - assert!(seed_entries.is_empty()); - }) - .await - .unwrap(); -} - -/// Builds a pg-ephemeral cache image for the given backend so the raw-label -/// and decoded-metadata tests below can each verify a fresh image. -/// -/// `instance_name` doubles as the cache image namespace, so each caller passes -/// a distinct value to keep parallel test runs from racing on a shared image -/// reference. -async fn populate_cache_image( - backend: &ociman::Backend, - instance_name: &pg_ephemeral::InstanceName, -) -> ociman::Reference { - let definition = pg_ephemeral::Definition::new( - backend.clone(), - pg_ephemeral::Image::default(), - instance_name.clone(), - ) - .wait_available_timeout(std::time::Duration::from_secs(30)) - .apply_script( - "create-table".parse().unwrap(), - r#"psql -c "CREATE TABLE labelled (id INT)""#, - pg_ephemeral::SeedCacheConfig::CommandHash, - ) - .unwrap(); - - let loaded_seeds = definition.load_seeds(instance_name).await.unwrap(); - let (last_cache_hit, uncached) = definition.populate_cache(&loaded_seeds).await.unwrap(); - assert!(uncached.is_empty(), "all seeds should be cacheable"); - last_cache_hit.expect("populate_cache should produce a cache image") -} - -#[tokio::test] -async fn test_cache_image_raw_labels_present() { - let backend = ociman::test_backend_setup!(); - let instance_name: pg_ephemeral::InstanceName = "labels-cache-image-raw".parse().unwrap(); - let cache_reference = populate_cache_image(&backend, &instance_name).await; - - let labels = backend.image_labels(&cache_reference).await.unwrap(); - - for key in [ - &label::VERSION_KEY, - &label::INSTANCE_KEY, - &label::IMAGE_KEY, - &label::SUPERUSER_USER_KEY, - &label::SUPERUSER_DATABASE_KEY, - &label::SUPERUSER_PASSWORD_KEY, - &label::SEEDS_KEY, - ] { - assert!( - labels.contains_key(key), - "expected label {key} on cache image", - ); - } - - for key in [ - &label::SUPERUSER_APPLICATION_KEY, - &label::SSL_HOSTNAME_KEY, - &label::SSL_CA_CERT_PEM_KEY, - ] { - assert!( - !labels.contains_key(key), - "label {key} should not be present on minimal cache image", - ); - } - - assert_eq!( - labels.get(&label::VERSION_KEY).unwrap().as_str(), - pg_ephemeral::version().to_string(), - ); - assert_eq!( - labels.get(&label::INSTANCE_KEY).unwrap().as_str(), - instance_name.as_str(), - ); - - let seeds_json = labels.get(&label::SEEDS_KEY).unwrap().as_str(); - let seed_entries: Vec = serde_json::from_str(seeds_json).unwrap(); - assert_eq!(seed_entries.len(), 1); - assert_eq!(seed_entries[0].name.as_str(), "create-table"); - assert!( - seed_entries[0].hash.is_some(), - "cacheable seed should carry a hash on the cache image" - ); - - backend.remove_image_force(&cache_reference).await; -} - -#[tokio::test] -async fn test_cache_image_metadata_round_trip() { - let backend = ociman::test_backend_setup!(); - let instance_name: pg_ephemeral::InstanceName = "labels-cache-image-decoded".parse().unwrap(); - let cache_reference = populate_cache_image(&backend, &instance_name).await; - - let labels = backend.image_labels(&cache_reference).await.unwrap(); - let metadata = label::read_image(&labels).unwrap(); - - assert_eq!(&metadata.version, pg_ephemeral::version()); - assert_eq!(metadata.instance, instance_name); - assert_eq!(metadata.superuser.user, pg_client::User::POSTGRES); - assert_eq!(metadata.superuser.database, pg_client::Database::POSTGRES); - assert!(metadata.superuser.application.is_none()); - assert!(metadata.ssl.is_none()); - - assert_eq!(metadata.seeds.len(), 1); - assert_eq!(metadata.seeds[0].name.as_str(), "create-table"); - assert!( - metadata.seeds[0].hash.is_some(), - "cacheable seed should carry a hash on the cache image" - ); - - backend.remove_image_force(&cache_reference).await; -} diff --git a/pg-ephemeral/tests/meta.rs b/pg-ephemeral/tests/meta.rs new file mode 100644 index 00000000..eed60727 --- /dev/null +++ b/pg-ephemeral/tests/meta.rs @@ -0,0 +1,20 @@ +//! Cargo/nextest entry point for `pg-ephemeral`'s `meta test` suite. +//! +//! Cargo's test discovery requires a `[[test]]` target; this wrapper is the +//! smallest possible one. Its only job is to translate cargo's +//! ` [libtest-args]` invocation shape into +//! `pg-ephemeral meta test [libtest-args]`, so each test runs inside the +//! actual production binary's process — exercising main, CLI parsing, and +//! any startup hooks that a normal cargo-test library build would not. +//! +//! Intentionally references no `pg_ephemeral::*` symbols so the wrapper +//! itself links nothing from the library; only `std` is pulled in. + +fn main() { + let bin = env!("CARGO_BIN_EXE_pg-ephemeral"); + let mut command = std::process::Command::new(bin); + command.arg("meta").arg("test"); + command.args(std::env::args().skip(1)); + let status = command.status().unwrap(); + std::process::exit(status.code().unwrap_or(1)); +} diff --git a/pg-ephemeral/tests/seed.rs b/pg-ephemeral/tests/seed.rs deleted file mode 100644 index 6018b60d..00000000 --- a/pg-ephemeral/tests/seed.rs +++ /dev/null @@ -1,379 +0,0 @@ -mod common; - -/// Keys whose values change between the caching container and the final container -/// (password is regenerated, port is reassigned on each boot). -const UNSTABLE_ENV_KEYS: &[&str] = &["DATABASE_URL", "PGPASSWORD", "PGPORT"]; - -async fn assert_environment_matches( - container: &pg_ephemeral::Container, - connection: &mut sqlx::postgres::PgConnection, -) { - // Read environment variables from database - let rows = sqlx::query("SELECT key, value FROM seed_env ORDER BY key") - .fetch_all(connection) - .await - .unwrap(); - - let actual: Vec<(String, String)> = rows - .iter() - .map(|row| { - ( - sqlx::Row::get::(row, "key"), - sqlx::Row::get::(row, "value"), - ) - }) - .collect(); - - // Generate expected output from config - let pg_env = container.pg_env().unwrap(); - let mut expected: Vec<(String, String)> = pg_env - .iter() - .map(|(key, value)| (key.to_string(), value.to_string())) - .collect(); - expected.push(("DATABASE_URL".to_string(), container.database_url())); - expected.sort(); - - // Verify all expected keys are present - let actual_keys: Vec<&str> = actual.iter().map(|(k, _)| k.as_str()).collect(); - let expected_keys: Vec<&str> = expected.iter().map(|(k, _)| k.as_str()).collect(); - assert_eq!(expected_keys, actual_keys); - - // Verify stable values match (password and port change between cache and boot) - let actual_stable: Vec<&(String, String)> = actual - .iter() - .filter(|(k, _)| !UNSTABLE_ENV_KEYS.contains(&k.as_str())) - .collect(); - let expected_stable: Vec<&(String, String)> = expected - .iter() - .filter(|(k, _)| !UNSTABLE_ENV_KEYS.contains(&k.as_str())) - .collect(); - assert_eq!(expected_stable, actual_stable); -} - -#[tokio::test] -async fn test_command_seed_receives_environment() { - let backend = ociman::test_backend_setup!(); - - let definition = common::test_definition(backend) - .apply_file( - "create-table".parse().unwrap(), - "tests/fixtures/create_seed_env_table.sql".into(), - ) - .unwrap() - .apply_command( - "capture-env".parse().unwrap(), - pg_ephemeral::Command::new( - "sh", - [ - "-c", - "(env | grep '^PG' && echo DATABASE_URL=$DATABASE_URL) | sed 's/=/,/' | psql -c \"\\copy seed_env FROM STDIN WITH (FORMAT csv)\"", - ], - ), - pg_ephemeral::SeedCacheConfig::None, - ) - .unwrap(); - - definition - .with_container(async |container| { - container - .with_connection(async |connection| { - assert_environment_matches(container, connection).await; - }) - .await - }) - .await - .unwrap() -} - -#[tokio::test] -async fn test_script_seed_receives_environment() { - let backend = ociman::test_backend_setup!(); - - let definition = common::test_definition(backend) - .apply_file( - "create-table".parse().unwrap(), - "tests/fixtures/create_seed_env_table.sql".into(), - ) - .unwrap() - .apply_script( - "capture-env".parse().unwrap(), - "(env | grep '^PG' && echo DATABASE_URL=$DATABASE_URL) | sed 's/=/,/' | psql -c \"\\copy seed_env FROM STDIN WITH (FORMAT csv)\"", - pg_ephemeral::SeedCacheConfig::CommandHash, - ) - .unwrap(); - - definition - .with_container(async |container| { - container - .with_connection(async |connection| { - assert_environment_matches(container, connection).await; - }) - .await - }) - .await - .unwrap() -} - -#[tokio::test] -async fn test_sql_statement_seed_multi_statement() { - let backend = ociman::test_backend_setup!(); - - let definition = common::test_definition(backend) - .apply_sql_statement( - "schema-and-data".parse().unwrap(), - indoc::indoc! {r#" - CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL); - INSERT INTO users (id, name) VALUES (1, 'alice'); - INSERT INTO users (id, name) VALUES (2, 'bob'); - "#}, - ) - .unwrap(); - - definition - .with_container(async |container| { - container - .with_connection(async |connection| { - let rows: Vec<(i32, String)> = - sqlx::query_as("SELECT id, name FROM users ORDER BY id") - .fetch_all(&mut *connection) - .await - .unwrap(); - - assert_eq!(rows, vec![(1, "alice".to_string()), (2, "bob".to_string())]); - }) - .await - }) - .await - .unwrap() -} - -#[tokio::test] -async fn test_csv_file_seed() { - let backend = ociman::test_backend_setup!(); - - let definition = common::test_definition(backend) - .apply_file( - "create-table".parse().unwrap(), - "tests/fixtures/create_users_table.sql".into(), - ) - .unwrap() - .apply_csv_file( - "import-users".parse().unwrap(), - "tests/fixtures/users.csv".into(), - pg_client::QualifiedTable { - schema: pg_client::identifier::Schema::PUBLIC, - table: "users".parse().unwrap(), - }, - ) - .unwrap(); - - definition - .with_container(async |container| { - container - .with_connection(async |connection| { - let rows: Vec<(i32, String)> = - sqlx::query_as("SELECT id, name FROM public.users ORDER BY id") - .fetch_all(&mut *connection) - .await - .unwrap(); - - assert_eq!( - rows, - vec![ - (1, "alice".to_string()), - (2, "bob".to_string()), - (3, "charlie".to_string()), - ] - ); - }) - .await - }) - .await - .unwrap() -} - -#[tokio::test] -async fn test_csv_file_seed_column_reorder() { - let backend = ociman::test_backend_setup!(); - - let definition = common::test_definition(backend) - .apply_file( - "create-table".parse().unwrap(), - "tests/fixtures/create_users_table_serial.sql".into(), - ) - .unwrap() - .apply_csv_file( - "import-users".parse().unwrap(), - "tests/fixtures/users_name_only.csv".into(), - pg_client::QualifiedTable { - schema: pg_client::identifier::Schema::PUBLIC, - table: "users".parse().unwrap(), - }, - ) - .unwrap(); - - definition - .with_container(async |container| { - container - .with_connection(async |connection| { - let rows: Vec<(i32, String)> = - sqlx::query_as("SELECT id, name FROM public.users ORDER BY id") - .fetch_all(&mut *connection) - .await - .unwrap(); - - assert_eq!( - rows, - vec![ - (1, "alice".to_string()), - (2, "bob".to_string()), - (3, "charlie".to_string()), - ] - ); - }) - .await - }) - .await - .unwrap() -} - -#[tokio::test] -async fn test_csv_file_seed_header_mismatch() { - let backend = ociman::test_backend_setup!(); - - let definition = common::test_definition(backend) - .apply_file( - "create-table".parse().unwrap(), - "tests/fixtures/create_users_table_serial.sql".into(), - ) - .unwrap() - .apply_csv_file( - "import-users".parse().unwrap(), - "tests/fixtures/users_wrong_column.csv".into(), - pg_client::QualifiedTable { - schema: pg_client::identifier::Schema::PUBLIC, - table: "users".parse().unwrap(), - }, - ) - .unwrap(); - - let error = definition.with_container(async |_| {}).await.unwrap_err(); - - assert!( - format!("{error:?}").contains("wrong_column"), - "Expected error mentioning wrong_column, got: {error:?}" - ); -} - -#[tokio::test] -async fn test_git_revision_seed() { - let _backend = ociman::test_backend_setup!(); - - let repo = common::TestGitRepo::new("git-revision-test").await; - - // Create seed.sql with table creation and insert for commit 1 - repo.write_file( - "seed.sql", - indoc::indoc! {r#" - CREATE TABLE users (id INTEGER PRIMARY KEY); - INSERT INTO users (id) VALUES (1); - "#}, - ); - let commit1_hash = repo.commit("Initial data").await; - - // Modify seed.sql to insert different data for commit 2 - repo.write_file( - "seed.sql", - indoc::indoc! {r#" - CREATE TABLE users (id INTEGER PRIMARY KEY); - INSERT INTO users (id) VALUES (2); - "#}, - ); - - // Commit v2 - let _ = repo.commit("Different data").await; - - // Create TOML config that references commit1 - let config_content = indoc::formatdoc! {r#" - image = "17.1" - - [instances.main.seeds.schema] - type = "sql-file" - path = "seed.sql" - git_revision = "{commit1_hash}" - "#}; - repo.write_file("database.toml", &config_content); - - // Get path to pg-ephemeral binary using the canonical Cargo test environment variable - let pg_ephemeral_bin = env!("CARGO_BIN_EXE_pg-ephemeral"); - - // Create pipes for the integration protocol - let (result_read, result_write) = std::io::pipe().unwrap(); - let (control_read, control_write) = std::io::pipe().unwrap(); - - use std::os::fd::AsRawFd; - let result_write_fd = result_write.as_raw_fd(); - let control_read_fd = control_read.as_raw_fd(); - - // Start pg-ephemeral integration-server with pipe FDs - let mut cmd = cmd_proc::Command::new(pg_ephemeral_bin) - .arguments([ - "integration-server", - "--result-fd", - &result_write_fd.to_string(), - "--control-fd", - &control_read_fd.to_string(), - ]) - .working_directory(&repo.path) - .build(); - - // SAFETY: Clear CLOEXEC on the pipe FDs so the child inherits them. - // This runs after fork() but before exec(). - unsafe { - cmd.pre_exec(move || { - let flags = libc::fcntl(result_write_fd, libc::F_GETFD); - libc::fcntl(result_write_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC); - let flags = libc::fcntl(control_read_fd, libc::F_GETFD); - libc::fcntl(control_read_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC); - Ok(()) - }); - } - - let mut server = cmd.spawn().unwrap(); - - // Close parent's copies of the child's pipe ends - drop(result_write); - drop(control_read); - - // Read the JSON output from the result pipe - use std::io::BufRead; - let mut reader = std::io::BufReader::new(result_read); - let mut line = String::new(); - reader.read_line(&mut line).unwrap(); - - // Run psql command to query the data - let output = cmd_proc::Command::new(pg_ephemeral_bin) - .arguments([ - "run-env", - "--", - "psql", - "--csv", - "--command=SELECT id FROM users ORDER BY id", - ]) - .working_directory(&repo.path) - .stdout_capture() - .stderr_capture() - .accept_nonzero_exit() - .run() - .await - .unwrap(); - - assert!(output.status.success(), "psql command failed"); - - // Verify we have the data from commit 1 (id=1), not commit 2 (id=2) - assert_eq!(String::from_utf8(output.stdout).unwrap().trim(), "id\n1"); - - // Stop the server by closing the control pipe write end and wait for it to finish - drop(control_write); - server.wait().await.unwrap(); -} From 5ab997b94993f832ec4815c2173431c90654079e Mon Sep 17 00:00:00 2001 From: Markus Schirp Date: Sun, 10 May 2026 13:35:34 +0000 Subject: [PATCH 2/2] Add mreaper Parent-death-triggered cleanup of OCI containers via label sweep. v1 ships protocol types and the per-container sweep executor that calls ociman::Container::remove_force on each match. ociman additions to support mreaper: - serde::Serialize on backend::Selection - serde::Serialize + Deserialize on Backend (sent over the wire so the child does not re-resolve and risk picking a different backend than the parent) Workspace bumps semver to features = ["serde"] (Backend carries semver::Version). --- Cargo.lock | 18 +++ Cargo.toml | 4 +- mreaper/Cargo.toml | 22 +++ mreaper/src/dispatch.rs | 204 ++++++++++++++++++++++++ mreaper/src/framing.rs | 110 +++++++++++++ mreaper/src/lib.rs | 15 ++ mreaper/src/message.rs | 139 ++++++++++++++++ ociman/src/backend.rs | 6 +- pg-ephemeral/src/meta/test/backtrace.rs | 2 +- pg-ephemeral/src/meta/test/base.rs | 10 +- pg-ephemeral/src/meta/test/seed.rs | 2 +- 11 files changed, 523 insertions(+), 9 deletions(-) create mode 100644 mreaper/Cargo.toml create mode 100644 mreaper/src/dispatch.rs create mode 100644 mreaper/src/framing.rs create mode 100644 mreaper/src/lib.rs create mode 100644 mreaper/src/message.rs diff --git a/Cargo.lock b/Cargo.lock index a0abda77..5fd97e90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2596,6 +2596,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "mreaper" +version = "0.1.0" +dependencies = [ + "cmd-proc", + "log", + "ociman", + "semver", + "serde", + "serde_json", + "thiserror", + "tokio", +] + [[package]] name = "msqlx" version = "0.9.0-msqlx.1" @@ -3788,6 +3802,10 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" diff --git a/Cargo.toml b/Cargo.toml index e1573224..9b18f2bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "typed-reqwest", "mlambda", "mmigration", + "mreaper", "ociman", "pg-client", "pg-ephemeral", @@ -52,6 +53,7 @@ libtest-mimic = "0.8" log = "0.4" typed-reqwest = { path = "typed-reqwest" } mmigration = { path = "mmigration" } +mreaper = { version = "0.1.0", path = "mreaper" } nom = "8" nom-language = "0.1" ociman = { version = "0.5.1", path = "ociman" } @@ -68,7 +70,7 @@ rsa = { version = "0.9", features = ["pem"] } rustix = { version = "1.1", features = ["fs"] } rustls = "0.23" rustls-pemfile = "2" -semver = "1" +semver = { version = "1", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["arbitrary_precision", "indexmap"] } serde_path_to_error = "0.1" diff --git a/mreaper/Cargo.toml b/mreaper/Cargo.toml new file mode 100644 index 00000000..0a1a561d --- /dev/null +++ b/mreaper/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "mreaper" +version = "0.1.0" +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +description = "Parent-death-triggered cleanup of OCI containers via label sweep" +license = "MIT" +repository = "https://github.com/mbj/mrs/tree/main/mreaper" + +[lints] +workspace = true + +[dependencies] +cmd-proc.workspace = true +semver.workspace = true +log.workspace = true +ociman.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["process", "io-util"] } diff --git a/mreaper/src/dispatch.rs b/mreaper/src/dispatch.rs new file mode 100644 index 00000000..01456414 --- /dev/null +++ b/mreaper/src/dispatch.rs @@ -0,0 +1,204 @@ +//! Child-side: be the reaper if this process was invoked as one. + +use tokio::io::AsyncRead; + +use crate::framing; +use crate::message::{Message, Registration}; + +/// Environment variable the parent sets to summon a child process into +/// reaper-child mode. [`summon_if_invoked`] reads this; if set, the +/// child takes over as the reaper and never returns. If unset, the +/// child returns immediately and normal program flow continues. +pub(crate) const ENV_VAR: cmd_proc::EnvVariableName = + cmd_proc::EnvVariableName::from_static_or_panic("MREAPER_SUMMON"); + +/// Call at the top of `main()`. If this process was invoked as a reaper +/// child (per [`ENV_VAR`]), runs the reactor loop and never returns. +/// Otherwise returns immediately so the host's normal `main` flow +/// continues. +/// +/// Must be called from an async context (typical: `#[tokio::main]`). +pub async fn summon_if_invoked() { + if std::env::var_os(ENV_VAR.as_str()).is_none() { + return; + } + run_child().await; + std::process::exit(0); +} + +async fn run_child() { + let mut stdin = tokio::io::stdin(); + let state = accumulate(&mut stdin).await; + + match &state.termination { + Termination::Shutdown => {} + Termination::Eof => { + log::warn!("mreaper: parent terminated unexpectedly; running registered sweeps"); + } + Termination::ProtocolError(error) => { + log::error!("mreaper: protocol error reading from parent: {error}"); + log::warn!("mreaper: parent terminated unexpectedly; running registered sweeps"); + } + } + + for registration in state.sweeps { + if let Err(error) = registration.execute().await { + log::warn!("mreaper: sweep execution failed: {error}"); + } + } +} + +/// Snapshot of the parent → child message stream at end-of-stream. +struct State { + sweeps: Vec, + termination: Termination, +} + +/// Why the read loop ended. +#[derive(Debug)] +enum Termination { + /// Stream ended without an explicit [`Message::Shutdown`]. Typically + /// means the parent dropped the pipe (clean exit without sending + /// shutdown, or the parent crashed). + Eof, + /// Loop observed an explicit [`Message::Shutdown`] from the parent. + Shutdown, + /// Loop hit a framing or decoding error and gave up on the stream. + ProtocolError(framing::ReadError), +} + +/// Read messages from `reader` until EOF, [`Message::Shutdown`], or a +/// protocol error. Pure protocol handling — no sweep execution. +async fn accumulate(reader: &mut R) -> State { + let mut sweeps = Vec::new(); + + let termination = loop { + match framing::read_message(reader).await { + Ok(None) => break Termination::Eof, + Ok(Some(Message::Register(registration))) => sweeps.push(registration), + Ok(Some(Message::Shutdown)) => break Termination::Shutdown, + Err(error) => break Termination::ProtocolError(error), + } + }; + + State { + sweeps, + termination, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use ociman::label; + use tokio::io::AsyncWriteExt; + + fn dummy_backend() -> ociman::Backend { + ociman::Backend::Podman { + version: semver::Version::new(5, 4, 0), + } + } + + fn dummy_registration() -> Registration { + let key = label::Key::from_static_or_panic("mreaper-test.marker"); + let value = label::Value::from_static_or_panic("present"); + Registration::ContainerLabel(crate::message::ContainerLabel::new( + dummy_backend(), + &key, + &value, + )) + } + + #[tokio::test] + async fn empty_stream_terminates_on_eof() { + let (writer, mut reader) = tokio::io::duplex(1024); + drop(writer); + let state = accumulate(&mut reader).await; + assert!(state.sweeps.is_empty()); + assert!(matches!(state.termination, Termination::Eof)); + } + + #[tokio::test] + async fn shutdown_only_terminates_on_shutdown() { + let (mut writer, mut reader) = tokio::io::duplex(1024); + framing::write_message(&mut writer, &Message::Shutdown) + .await + .unwrap(); + drop(writer); + let state = accumulate(&mut reader).await; + assert!(state.sweeps.is_empty()); + assert!(matches!(state.termination, Termination::Shutdown)); + } + + #[tokio::test] + async fn one_register_then_eof() { + let (mut writer, mut reader) = tokio::io::duplex(1024); + framing::write_message(&mut writer, &Message::Register(dummy_registration())) + .await + .unwrap(); + drop(writer); + let state = accumulate(&mut reader).await; + assert_eq!(state.sweeps.len(), 1); + assert!(matches!(state.termination, Termination::Eof)); + } + + #[tokio::test] + async fn one_register_then_shutdown() { + let (mut writer, mut reader) = tokio::io::duplex(1024); + framing::write_message(&mut writer, &Message::Register(dummy_registration())) + .await + .unwrap(); + framing::write_message(&mut writer, &Message::Shutdown) + .await + .unwrap(); + drop(writer); + let state = accumulate(&mut reader).await; + assert_eq!(state.sweeps.len(), 1); + assert!(matches!(state.termination, Termination::Shutdown)); + } + + #[tokio::test] + async fn multiple_registers_preserved_in_order() { + let (mut writer, mut reader) = tokio::io::duplex(1024); + for _ in 0..3 { + framing::write_message(&mut writer, &Message::Register(dummy_registration())) + .await + .unwrap(); + } + drop(writer); + let state = accumulate(&mut reader).await; + assert_eq!(state.sweeps.len(), 3); + assert!(matches!(state.termination, Termination::Eof)); + } + + #[tokio::test] + async fn over_cap_length_yields_protocol_error() { + let (mut writer, mut reader) = tokio::io::duplex(8); + let oversize = u32::try_from(framing::MAX_PAYLOAD + 1).unwrap(); + writer.write_all(&oversize.to_le_bytes()).await.unwrap(); + drop(writer); + let state = accumulate(&mut reader).await; + assert!(state.sweeps.is_empty()); + assert!(matches!( + state.termination, + Termination::ProtocolError(framing::ReadError::PayloadTooLarge { .. }) + )); + } + + #[tokio::test] + async fn malformed_payload_yields_protocol_error() { + let (mut writer, mut reader) = tokio::io::duplex(1024); + // Length-prefix says 16 bytes of payload, then we deliver 16 bytes + // of garbage that won't parse as a Message. + writer.write_all(&16u32.to_le_bytes()).await.unwrap(); + writer.write_all(&[0xff; 16]).await.unwrap(); + drop(writer); + let state = accumulate(&mut reader).await; + assert!(state.sweeps.is_empty()); + assert!(matches!( + state.termination, + Termination::ProtocolError(framing::ReadError::Decode(_)) + )); + } +} diff --git a/mreaper/src/framing.rs b/mreaper/src/framing.rs new file mode 100644 index 00000000..c0a88507 --- /dev/null +++ b/mreaper/src/framing.rs @@ -0,0 +1,110 @@ +//! Wire framing for parent → child messages over the stdin pipe. +//! +//! Format: ``. The fixed 4-byte +//! prefix bounds reads — the reader does `read_exact(4)` then +//! `read_exact(len)`, never buffering until a delimiter. Sanity-capped +//! at [`MAX_PAYLOAD`] to keep a misbehaving sender from making the +//! child allocate gigabytes. + +use std::io; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +use crate::message::Message; + +/// Maximum payload bytes per message. Real messages are well under +/// 1 KB; this cap protects the child from a misbehaving sender. +pub const MAX_PAYLOAD: usize = 1024 * 1024; + +pub async fn write_message( + writer: &mut W, + message: &Message, +) -> Result<(), WriteError> { + let payload = serde_json::to_vec(message).map_err(WriteError::Encode)?; + let len = u32::try_from(payload.len()).map_err(|_| WriteError::PayloadTooLarge)?; + writer + .write_all(&len.to_le_bytes()) + .await + .map_err(WriteError::Io)?; + writer.write_all(&payload).await.map_err(WriteError::Io)?; + writer.flush().await.map_err(WriteError::Io)?; + Ok(()) +} + +/// Read one framed message. Returns `Ok(None)` on clean EOF (zero +/// bytes read at the start of a frame); `Err(_)` on partial frames, +/// over-cap lengths, IO failure, or malformed JSON. +pub async fn read_message( + reader: &mut R, +) -> Result, ReadError> { + let mut len_buf = [0u8; 4]; + match reader.read_exact(&mut len_buf).await { + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), + Err(error) => return Err(ReadError::Io(error)), + } + let len = u32::from_le_bytes(len_buf) as usize; + if len > MAX_PAYLOAD { + return Err(ReadError::PayloadTooLarge { len }); + } + let mut payload = vec![0u8; len]; + reader + .read_exact(&mut payload) + .await + .map_err(ReadError::Io)?; + let message = serde_json::from_slice(&payload).map_err(ReadError::Decode)?; + Ok(Some(message)) +} + +#[derive(Debug, thiserror::Error)] +pub enum WriteError { + #[error("payload exceeds u32::MAX bytes")] + PayloadTooLarge, + #[error("failed to encode message as JSON")] + Encode(#[source] serde_json::Error), + #[error("failed to write framed message")] + Io(#[source] io::Error), +} + +#[derive(Debug, thiserror::Error)] +pub enum ReadError { + #[error("payload length {len} exceeds maximum {MAX_PAYLOAD}")] + PayloadTooLarge { len: usize }, + #[error("failed to decode JSON payload")] + Decode(#[source] serde_json::Error), + #[error("failed to read framed message")] + Io(#[source] io::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn round_trip_shutdown_in_memory() { + let (mut writer, mut reader) = tokio::io::duplex(1024); + write_message(&mut writer, &Message::Shutdown) + .await + .unwrap(); + drop(writer); + let read_back = read_message(&mut reader).await.unwrap().unwrap(); + assert!(matches!(read_back, Message::Shutdown)); + } + + #[tokio::test] + async fn eof_returns_none() { + let (writer, mut reader) = tokio::io::duplex(1024); + drop(writer); + let result = read_message(&mut reader).await.unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn over_cap_length_errors() { + let (mut writer, mut reader) = tokio::io::duplex(8); + let oversize = u32::try_from(MAX_PAYLOAD + 1).unwrap(); + writer.write_all(&oversize.to_le_bytes()).await.unwrap(); + let result = read_message(&mut reader).await; + assert!(matches!(result, Err(ReadError::PayloadTooLarge { .. }))); + } +} diff --git a/mreaper/src/lib.rs b/mreaper/src/lib.rs new file mode 100644 index 00000000..262931f9 --- /dev/null +++ b/mreaper/src/lib.rs @@ -0,0 +1,15 @@ +//! Parent-death-triggered cleanup of OCI containers via label sweep. +//! +//! `mreaper` spawns a sibling reaper process at parent startup. The parent +//! registers sweep predicates (currently: container-label matchers) over the +//! reaper's stdin. When the parent dies for any reason — clean exit, panic, +//! `SIGKILL`, OOM, segfault — the reaper observes EOF on stdin and executes +//! the registered sweeps before exiting itself. +//! +//! v1 covers OCI containers via [`ociman::Backend`]. The reaper child is the +//! same binary as the parent, dispatched by the [`dispatch_if_internal`] +//! function which the host's `main()` must call at startup. + +pub mod dispatch; +pub mod framing; +pub mod message; diff --git a/mreaper/src/message.rs b/mreaper/src/message.rs new file mode 100644 index 00000000..313aa85c --- /dev/null +++ b/mreaper/src/message.rs @@ -0,0 +1,139 @@ +//! Wire messages exchanged over the parent → child pipe, plus their +//! execution semantics. +//! +//! The wire is NDJSON: one [`Message`] JSON object per line, terminated by +//! `\n`, written over the child's stdin. The child reads every line until +//! EOF, accumulates the registrations, then unconditionally executes them. +//! +//! There is intentionally no "stand down" message — the sweep is +//! idempotent (an already-cleaned label set returns no containers), and a +//! "skip work" toggle would be a footgun (forgotten toggle = leaked +//! container). [`Message::Shutdown`] is *not* a stand-down message; it +//! tells the child "this exit was intentional," so EOF after a `Shutdown` +//! is silent. EOF without a prior `Shutdown` is logged as a warning +//! ("parent terminated unexpectedly"). Either way the registered sweeps +//! run. +//! +//! Each cleanup message carries its own already-resolved [`ociman::Backend`] +//! — the reaper itself is backend-agnostic, and the parent's resolution is +//! authoritative (the child does not re-resolve, so env / PATH / config +//! drift between parent and child invocation cannot pick a different +//! backend). +//! +//! Wire fields use string forms for label key/value (since +//! [`ociman::label::Key`] / [`ociman::label::Value`] do not derive +//! [`serde::Serialize`]); the typed forms are recovered via [`std::str::FromStr`] +//! inside [`ContainerLabel::execute`]. The parent constructs `ContainerLabel` +//! via [`ContainerLabel::new`] which requires already-validated typed values, +//! so a child-side parse failure indicates an internal bug rather than user +//! data error. + +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +/// One NDJSON line over the parent → child pipe. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Message { + /// Register a sweep predicate. Multiple registrations accumulate; on + /// EOF every registered sweep runs. The inner [`Registration`] is + /// sub-tagged on `kind` so future registration types (process sweep, + /// volume sweep, …) extend without changing the outer envelope. + Register(Registration), + /// Mark the parent's exit as intentional. Sweeps still run on EOF, but + /// the child suppresses the "parent terminated unexpectedly" warning. + /// Send this once, just before dropping the handle, on the clean exit + /// path. + Shutdown, +} + +/// What is being registered. Sub-tagged on `kind`; new registration types +/// add new variants here without touching [`Message`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Registration { + ContainerLabel(ContainerLabel), +} + +impl Registration { + /// Dispatch to the inner variant's execution. Single point to add new + /// registration types. + pub async fn execute(&self) -> Result<(), ExecuteError> { + match self { + Self::ContainerLabel(sweep) => sweep.execute().await, + } + } +} + +/// Sweep predicate: list containers in `backend` whose label `label_key` +/// exactly equals `label_value`, then stop and remove each. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContainerLabel { + pub backend: ociman::Backend, + pub label_key: String, + pub label_value: String, +} + +impl ContainerLabel { + /// Construct a sweep from already-validated typed values. Use this on the + /// parent side; the typed `&Key` / `&Value` enforces that the child will + /// be able to round-trip them via `FromStr`. + #[must_use] + pub fn new( + backend: ociman::Backend, + label_key: &ociman::label::Key, + label_value: &ociman::label::Value, + ) -> Self { + Self { + backend, + label_key: label_key.as_str().to_owned(), + label_value: label_value.as_str().to_owned(), + } + } + + /// List containers matching the label and best-effort force-remove + /// each (`docker container rm --force`). Per-container failures are + /// logged and swallowed so a single failure does not abort the rest + /// of the sweep. Force-remove is appropriate here because the parent + /// already failed to coordinate a graceful shutdown — waiting on + /// SIGTERM grace periods only delays leaked-resource recovery. + pub async fn execute(&self) -> Result<(), ExecuteError> { + let label_key = + ociman::label::Key::from_str(&self.label_key).map_err(ExecuteError::ParseLabelKey)?; + let label_value = ociman::label::Value::from_str(&self.label_value) + .map_err(ExecuteError::ParseLabelValue)?; + + let containers = self + .backend + .list_containers_by_label(&label_key, Some(&label_value)) + .await + .map_err(ExecuteError::ListContainers)?; + + for mut container in containers { + if let Err(error) = container.remove_force().await { + log::warn!( + "mreaper: failed to force-remove container {id}: {error}", + id = container.id().as_str(), + ); + } + } + + Ok(()) + } +} + +/// Errors from [`ContainerLabel::execute`]. +/// +/// Per-container stop / remove failures are not represented here — those are +/// best-effort and logged at the call site. Variants here represent +/// failures that abort the entire sweep. +#[derive(Debug, thiserror::Error)] +pub enum ExecuteError { + #[error("failed to parse label key from wire form")] + ParseLabelKey(#[source] ociman::label::Error), + #[error("failed to parse label value from wire form")] + ParseLabelValue(#[source] ociman::label::Error), + #[error("failed to list containers by label")] + ListContainers(#[source] ociman::backend::ListContainersError), +} diff --git a/ociman/src/backend.rs b/ociman/src/backend.rs index b5d6d3b5..588c2759 100644 --- a/ociman/src/backend.rs +++ b/ociman/src/backend.rs @@ -1,6 +1,8 @@ use cmd_proc::*; -#[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Deserialize, clap::ValueEnum)] +#[derive( + Copy, Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, clap::ValueEnum, +)] #[serde(rename_all = "snake_case")] pub enum Selection { Auto, @@ -18,7 +20,7 @@ impl Selection { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] pub enum Backend { Docker { version: semver::Version }, Podman { version: semver::Version }, diff --git a/pg-ephemeral/src/meta/test/backtrace.rs b/pg-ephemeral/src/meta/test/backtrace.rs index 51a05a82..d24dc076 100644 --- a/pg-ephemeral/src/meta/test/backtrace.rs +++ b/pg-ephemeral/src/meta/test/backtrace.rs @@ -5,7 +5,7 @@ use libtest_mimic::{Failed, Trial}; -const RUST_BACKTRACE: cmd_proc::EnvVariableName<'static> = +const RUST_BACKTRACE: cmd_proc::EnvVariableName = cmd_proc::EnvVariableName::from_static_or_panic("RUST_BACKTRACE"); #[must_use] diff --git a/pg-ephemeral/src/meta/test/base.rs b/pg-ephemeral/src/meta/test/base.rs index 52478044..502e3918 100644 --- a/pg-ephemeral/src/meta/test/base.rs +++ b/pg-ephemeral/src/meta/test/base.rs @@ -334,7 +334,7 @@ fn config_ssl() -> Result<(), Failed> { } fn run_env() -> Result<(), Failed> { - const DATABASE_URL: cmd_proc::EnvVariableName<'static> = + const DATABASE_URL: cmd_proc::EnvVariableName = cmd_proc::EnvVariableName::from_static_or_panic("DATABASE_URL"); let runtime = tokio::runtime::Builder::new_current_thread() @@ -348,11 +348,13 @@ fn run_env() -> Result<(), Failed> { super::common::test_definition(backend) .with_container(async |container| { // Use sh -c to emit both PG* and DATABASE_URL + let database_url: cmd_proc::EnvVariableValue = + container.database_url().parse().unwrap(); let output = cmd_proc::Command::new("sh") .argument("-c") .argument("(env | grep '^PG' | sort) && echo DATABASE_URL=$DATABASE_URL") - .envs(container.pg_env()) - .env(&DATABASE_URL, container.database_url()) + .envs(container.pg_env().unwrap()) + .env(&DATABASE_URL, database_url) .stdout_capture() .stderr_capture() .run() @@ -362,7 +364,7 @@ fn run_env() -> Result<(), Failed> { let actual = String::from_utf8(output.stdout).unwrap(); // Generate expected output from config - let pg_env = container.pg_env(); + let pg_env = container.pg_env().unwrap(); let mut expected_lines: Vec = pg_env .iter() .map(|(key, value)| format!("{key}={value}")) diff --git a/pg-ephemeral/src/meta/test/seed.rs b/pg-ephemeral/src/meta/test/seed.rs index 6f7810dd..a84e5b49 100644 --- a/pg-ephemeral/src/meta/test/seed.rs +++ b/pg-ephemeral/src/meta/test/seed.rs @@ -60,7 +60,7 @@ async fn assert_environment_matches( .collect(); // Generate expected output from config - let pg_env = container.pg_env(); + let pg_env = container.pg_env().unwrap(); let mut expected: Vec<(String, String)> = pg_env .iter() .map(|(key, value)| (key.to_string(), value.to_string()))