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        let guard = self.container.start().await?;
88        Ok(PostgresGuard {
89            guard,
90            username: self.username,
91            password: self.password,
92            database: self.database,
93        })
94    }
95}
96
97impl Default for PostgresContainer {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103/// The running guard for a [`PostgresContainer`].
104pub struct PostgresGuard {
105    guard: ContainerGuard,
106    username: String,
107    password: String,
108    database: String,
109}
110
111impl PostgresGuard {
112    /// The configured database user (default `test`).
113    pub fn username(&self) -> &str {
114        &self.username
115    }
116
117    /// The configured database password (default `test`).
118    pub fn password(&self) -> &str {
119        &self.password
120    }
121
122    /// The configured database name (default `test`).
123    pub fn database_name(&self) -> &str {
124        &self.database
125    }
126
127    /// A `postgres://` connection string for the running container's
128    /// [`Self::database_name`].
129    pub fn connection_string(&self) -> String {
130        format!(
131            "postgres://{}:{}@{}:{}/{}",
132            self.username,
133            self.password,
134            self.guard.host(),
135            self.guard.get_mapped_port(PostgresContainer::PORT).unwrap(),
136            self.database,
137        )
138    }
139
140    /// Stops and removes the container, releasing its host port.
141    pub async fn stop(self) -> Result<()> {
142        self.guard.stop().await
143    }
144}
145
146impl std::ops::Deref for PostgresGuard {
147    type Target = ContainerGuard;
148    fn deref(&self) -> &ContainerGuard {
149        &self.guard
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn defaults_are_the_test_trio() {
159        let c = PostgresContainer::new();
160        assert_eq!(c.username, "test");
161        assert_eq!(c.password, "test");
162        assert_eq!(c.database, "test");
163    }
164
165    #[test]
166    fn builders_override_the_defaults() {
167        let c = PostgresContainer::new()
168            .with_username("alice")
169            .with_password("s3cret")
170            .with_database("app");
171        assert_eq!(c.username, "alice");
172        assert_eq!(c.password, "s3cret");
173        assert_eq!(c.database, "app");
174    }
175}