use pubky_homeserver::ConnectionString;
use std::sync::OnceLock;
use testcontainers::{runners::AsyncRunner, ContainerAsync};
use testcontainers_modules::postgres::Postgres;
use tokio::sync::OnceCell;
static SHARED_PG: OnceCell<DockerPostgres> = OnceCell::const_new();
static SHARED_CONTAINER_ID: OnceLock<String> = OnceLock::new();
extern "C" fn cleanup_shared_container() {
if let Some(id) = SHARED_CONTAINER_ID.get() {
let _ = std::process::Command::new("docker")
.args(["rm", "-f", id])
.output();
}
}
pub struct DockerPostgres {
_container: ContainerAsync<Postgres>,
host: String,
port: u16,
}
#[deprecated(since = "0.9.0", note = "Renamed to `DockerPostgres`")]
pub type EmbeddedPostgres = DockerPostgres;
impl DockerPostgres {
pub async fn shared() -> &'static DockerPostgres {
SHARED_PG
.get_or_init(|| async {
let pg = DockerPostgres::start()
.await
.expect("Failed to start shared Docker postgres. Is Docker running?");
SHARED_CONTAINER_ID.get_or_init(|| {
let ret = unsafe { libc::atexit(cleanup_shared_container) };
if ret != 0 {
eprintln!("warning: failed to register atexit handler for Docker cleanup (returned {ret})");
}
pg.container_id().to_string()
});
pg
})
.await
}
pub async fn start() -> anyhow::Result<Self> {
let container = Postgres::default().start().await.map_err(|e| {
anyhow::anyhow!("Failed to start Postgres container. Is Docker running? Error: {e}")
})?;
let host = container.get_host().await?.to_string();
let port = container.get_host_port_ipv4(5432).await?;
Ok(Self {
_container: container,
host,
port,
})
}
pub fn connection_string(&self) -> anyhow::Result<ConnectionString> {
let url = format!(
"postgres://postgres:postgres@{}:{}/postgres",
self.host, self.port
);
ConnectionString::new(&url).map_err(|e| anyhow::anyhow!("Invalid connection string: {e}"))
}
pub fn container_id(&self) -> &str {
self._container.id()
}
}
#[cfg(test)]
mod tests {
use super::DockerPostgres;
use crate::EphemeralTestnet;
use pubky::Keypair;
const CONTAINER_ID_PREFIX: &str = "CONTAINER_ID=";
async fn wait_for_container_removal(container_id: &str, max_attempts: u32) {
for i in 0..max_attempts {
let output = std::process::Command::new("docker")
.args(["inspect", container_id])
.output()
.expect("docker inspect failed");
if !output.status.success() {
return; }
if i + 1 < max_attempts {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
panic!("Container {container_id} still exists after {max_attempts} attempts");
}
fn extract_container_id(s: &str) -> Option<String> {
s.lines()
.find_map(|line| line.trim().strip_prefix(CONTAINER_ID_PREFIX))
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
}
#[tokio::test]
async fn test_docker_postgres_with_testnet() {
let testnet = EphemeralTestnet::builder()
.with_docker_postgres()
.with_http_relay()
.build()
.await
.expect("Failed to start testnet with docker postgres");
assert!(!testnet.homeserver_app().public_key().to_string().is_empty());
let _ = testnet.http_relay();
let pubky = testnet.sdk().expect("Failed to create SDK");
let keypair = Keypair::random();
let signer = pubky.signer(keypair);
let session = signer
.signup(&testnet.homeserver_app().public_key(), None)
.await
.expect("Failed to signup user");
let path = "/pub/test.txt";
let data = b"Hello from docker postgres test!";
session
.storage()
.put(path, data.as_slice())
.await
.expect("Failed to store data");
let response = session
.storage()
.get(path)
.await
.expect("Failed to get data");
let bytes = response.bytes().await.expect("Failed to read bytes");
assert_eq!(bytes.as_ref(), data);
}
#[tokio::test]
async fn test_container_cleaned_up_on_drop() {
let pg = DockerPostgres::start()
.await
.expect("Failed to start docker postgres");
let container_id = pg.container_id().to_string();
let output = std::process::Command::new("docker")
.args(["inspect", "--format", "{{.State.Running}}", &container_id])
.output()
.expect("docker inspect failed");
let running = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(running, "true", "Container should be running before drop");
drop(pg);
wait_for_container_removal(&container_id, 15).await;
}
#[tokio::test]
async fn test_shared_container_cleaned_up_on_normal_exit() {
const SENTINEL: &str = "__DOCKER_PG_PRINT_CONTAINER_ID";
if std::env::var(SENTINEL).is_ok() {
let pg = DockerPostgres::shared().await;
println!("{}{}", CONTAINER_ID_PREFIX, pg.container_id());
return;
}
let exe = std::env::current_exe().expect("failed to get current exe");
let output = std::process::Command::new(exe)
.env(SENTINEL, "1")
.arg("docker_postgres::tests::test_shared_container_cleaned_up_on_normal_exit")
.arg("--exact")
.arg("--nocapture")
.output()
.expect("Failed to run child process");
assert!(
output.status.success(),
"Child process failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let container_id = extract_container_id(&stdout)
.unwrap_or_else(|| panic!("Child did not print a container ID. stdout: {stdout}"));
wait_for_container_removal(&container_id, 15).await;
}
#[tokio::test]
async fn test_shared_returns_same_instance() {
let pg1 = DockerPostgres::shared().await;
let pg2 = DockerPostgres::shared().await;
assert_eq!(
pg1.container_id(),
pg2.container_id(),
"shared() should return the same container on repeated calls"
);
}
#[tokio::test]
async fn test_docker_postgres_and_custom_connection_string_fails() {
use pubky_homeserver::ConnectionString;
let connection = ConnectionString::new("postgres://localhost:5432/test").unwrap();
let result = EphemeralTestnet::builder()
.postgres(connection)
.with_docker_postgres()
.build()
.await;
let err = result
.err()
.expect("Should fail when both postgres options are set")
.to_string();
assert!(
err.contains("Cannot use both docker postgres and a custom connection string"),
"Expected error about conflicting postgres options, got: {err}"
);
}
}