Skip to main content

rightsize_modules/
postgres.rs

1//! A single-node PostgreSQL container. Defaults to a `test`/`test`/`test`
2//! user/password/database trio so [`PostgresGuard::connection_string`] is usable with
3//! zero configuration; call [`PostgresContainer::with_username`]/
4//! [`PostgresContainer::with_password`]/[`PostgresContainer::with_database`] before
5//! `start()` to override any of them.
6
7use rightsize::{Container, ContainerGuard, Result, Wait};
8
9/// A single-node PostgreSQL container.
10pub struct PostgresContainer {
11    container: Container,
12    username: String,
13    password: String,
14    database: String,
15}
16
17impl PostgresContainer {
18    const PORT: u16 = 5432;
19
20    /// Builds a container from the pinned default image (`postgres:18-alpine`) —
21    /// 18.4 was current stable at the time of that pin.
22    pub fn new() -> Self {
23        Self::with_image("postgres:18-alpine")
24    }
25
26    /// Builds a container from a caller-chosen image.
27    pub fn with_image(image: &str) -> Self {
28        let username = "test".to_string();
29        let password = "test".to_string();
30        let database = "test".to_string();
31        let container = Container::new(image)
32            .with_exposed_ports(&[Self::PORT])
33            .with_env("POSTGRES_USER", &username)
34            .with_env("POSTGRES_PASSWORD", &password)
35            .with_env("POSTGRES_DB", &database)
36            // The official postgres:*-alpine image bakes DOCKER_PG_LLVM_DEPS into its
37            // manifest with a literal tab character in the value (a package list built
38            // with `\t\t` continuation). msb 0.6.2's krun VMM builder panics with
39            // InvalidAscii on that boot-env value before the guest ever starts
40            // (reproduced with zero rightsize-set env vars — it's the image, not us).
41            // Docker is unaffected. Overriding the var here wins over the
42            // image default in both backends' env-merge order and is a no-op for the
43            // build the image already baked, so it's a safe, backend-portable fix
44            // rather than an msb-only special case.
45            .with_env("DOCKER_PG_LLVM_DEPS", "")
46            // The postgres entrypoint starts the server once to run initdb scripts
47            // against it, shuts it down, then starts it again for real — printing
48            // "database system is ready to accept connections" BOTH times. Waiting
49            // for the first occurrence races that restart: a client can connect to
50            // the init-time server just before it's torn down. times=2 waits for the
51            // second, durable listen.
52            .waiting_for(Wait::for_log_message(
53                ".*database system is ready to accept connections.*",
54                2,
55            ));
56        Self {
57            container,
58            username,
59            password,
60            database,
61        }
62    }
63
64    /// Overrides `POSTGRES_USER` (default `test`).
65    pub fn with_username(mut self, username: &str) -> Self {
66        self.username = username.to_string();
67        self.container = self.container.with_env("POSTGRES_USER", username);
68        self
69    }
70
71    /// Overrides `POSTGRES_PASSWORD` (default `test`).
72    pub fn with_password(mut self, password: &str) -> Self {
73        self.password = password.to_string();
74        self.container = self.container.with_env("POSTGRES_PASSWORD", password);
75        self
76    }
77
78    /// Overrides `POSTGRES_DB` (default `test`).
79    pub fn with_database(mut self, database: &str) -> Self {
80        self.database = database.to_string();
81        self.container = self.container.with_env("POSTGRES_DB", database);
82        self
83    }
84
85    /// Boots the container.
86    pub async fn start(self) -> Result<PostgresGuard> {
87        crate::register_default_backends();
88        let guard = self.container.start().await?;
89        Ok(PostgresGuard {
90            guard,
91            username: self.username,
92            password: self.password,
93            database: self.database,
94        })
95    }
96}
97
98impl Default for PostgresContainer {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104/// The running guard for a [`PostgresContainer`].
105pub struct PostgresGuard {
106    guard: ContainerGuard,
107    username: String,
108    password: String,
109    database: String,
110}
111
112impl PostgresGuard {
113    /// The configured database user (default `test`).
114    pub fn username(&self) -> &str {
115        &self.username
116    }
117
118    /// The configured database password (default `test`).
119    pub fn password(&self) -> &str {
120        &self.password
121    }
122
123    /// The configured database name (default `test`).
124    pub fn database_name(&self) -> &str {
125        &self.database
126    }
127
128    /// A `postgres://` connection string for the running container's
129    /// [`Self::database_name`].
130    pub fn connection_string(&self) -> String {
131        format!(
132            "postgres://{}:{}@{}:{}/{}",
133            self.username,
134            self.password,
135            self.guard.host(),
136            self.guard.get_mapped_port(PostgresContainer::PORT).unwrap(),
137            self.database,
138        )
139    }
140
141    /// Stops and removes the container, releasing its host port.
142    pub async fn stop(self) -> Result<()> {
143        self.guard.stop().await
144    }
145}
146
147impl std::ops::Deref for PostgresGuard {
148    type Target = ContainerGuard;
149    fn deref(&self) -> &ContainerGuard {
150        &self.guard
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn defaults_are_the_test_trio() {
160        let c = PostgresContainer::new();
161        assert_eq!(c.username, "test");
162        assert_eq!(c.password, "test");
163        assert_eq!(c.database, "test");
164    }
165
166    #[test]
167    fn builders_override_the_defaults() {
168        let c = PostgresContainer::new()
169            .with_username("alice")
170            .with_password("s3cret")
171            .with_database("app");
172        assert_eq!(c.username, "alice");
173        assert_eq!(c.password, "s3cret");
174        assert_eq!(c.database, "app");
175    }
176}