docker_wrapper/template/database/
postgres.rs

1//! PostgreSQL template for quick PostgreSQL container setup
2
3#![allow(clippy::doc_markdown)]
4#![allow(clippy::must_use_candidate)]
5#![allow(clippy::return_self_not_must_use)]
6#![allow(clippy::map_unwrap_or)]
7#![allow(clippy::format_push_string)]
8#![allow(clippy::uninlined_format_args)]
9
10use crate::template::{HealthCheck, Template, TemplateConfig, VolumeMount};
11use async_trait::async_trait;
12use std::collections::HashMap;
13
14/// PostgreSQL container template with sensible defaults
15pub struct PostgresTemplate {
16    config: TemplateConfig,
17}
18
19impl PostgresTemplate {
20    /// Create a new PostgreSQL template with default settings
21    pub fn new(name: impl Into<String>) -> Self {
22        let name = name.into();
23        let mut env = HashMap::new();
24
25        // Default PostgreSQL configuration
26        env.insert("POSTGRES_PASSWORD".to_string(), "postgres".to_string());
27        env.insert("POSTGRES_USER".to_string(), "postgres".to_string());
28        env.insert("POSTGRES_DB".to_string(), "postgres".to_string());
29
30        let config = TemplateConfig {
31            name: name.clone(),
32            image: "postgres".to_string(),
33            tag: "15-alpine".to_string(),
34            ports: vec![(5432, 5432)],
35            env,
36            volumes: Vec::new(),
37            network: None,
38            health_check: Some(HealthCheck {
39                test: vec![
40                    "pg_isready".to_string(),
41                    "-U".to_string(),
42                    "postgres".to_string(),
43                ],
44                interval: "10s".to_string(),
45                timeout: "5s".to_string(),
46                retries: 5,
47                start_period: "10s".to_string(),
48            }),
49            auto_remove: false,
50            memory_limit: None,
51            cpu_limit: None,
52            platform: None,
53        };
54
55        Self { config }
56    }
57
58    /// Set a custom PostgreSQL port
59    pub fn port(mut self, port: u16) -> Self {
60        self.config.ports = vec![(port, 5432)];
61        self
62    }
63
64    /// Set database name
65    pub fn database(mut self, db: impl Into<String>) -> Self {
66        self.config.env.insert("POSTGRES_DB".to_string(), db.into());
67        self
68    }
69
70    /// Set database user
71    pub fn user(mut self, user: impl Into<String>) -> Self {
72        let user = user.into();
73        self.config
74            .env
75            .insert("POSTGRES_USER".to_string(), user.clone());
76
77        // Update health check to use the correct user
78        if let Some(health) = &mut self.config.health_check {
79            health.test = vec!["pg_isready".to_string(), "-U".to_string(), user];
80        }
81        self
82    }
83
84    /// Set database password
85    pub fn password(mut self, password: impl Into<String>) -> Self {
86        self.config
87            .env
88            .insert("POSTGRES_PASSWORD".to_string(), password.into());
89        self
90    }
91
92    /// Enable persistence with a volume
93    pub fn with_persistence(mut self, volume_name: impl Into<String>) -> Self {
94        self.config.volumes.push(VolumeMount {
95            source: volume_name.into(),
96            target: "/var/lib/postgresql/data".to_string(),
97            read_only: false,
98        });
99        self
100    }
101
102    /// Mount initialization scripts directory
103    pub fn init_scripts(mut self, scripts_path: impl Into<String>) -> Self {
104        self.config.volumes.push(VolumeMount {
105            source: scripts_path.into(),
106            target: "/docker-entrypoint-initdb.d".to_string(),
107            read_only: true,
108        });
109        self
110    }
111
112    /// Set memory limit for PostgreSQL
113    pub fn memory_limit(mut self, limit: impl Into<String>) -> Self {
114        self.config.memory_limit = Some(limit.into());
115        self
116    }
117
118    /// Set shared memory size
119    pub fn shared_memory(mut self, size: impl Into<String>) -> Self {
120        self.config
121            .env
122            .insert("POSTGRES_SHARED_MEMORY".to_string(), size.into());
123        self
124    }
125
126    /// Enable PostgreSQL extensions
127    pub fn with_extension(mut self, extension: impl Into<String>) -> Self {
128        let ext = extension.into();
129        let current = self
130            .config
131            .env
132            .get("POSTGRES_EXTENSIONS")
133            .map(|s| format!("{},{}", s, ext))
134            .unwrap_or(ext);
135        self.config
136            .env
137            .insert("POSTGRES_EXTENSIONS".to_string(), current);
138        self
139    }
140
141    /// Use a specific PostgreSQL version
142    pub fn version(mut self, version: impl Into<String>) -> Self {
143        self.config.tag = format!("{}-alpine", version.into());
144        self
145    }
146
147    /// Connect to a specific network
148    pub fn network(mut self, network: impl Into<String>) -> Self {
149        self.config.network = Some(network.into());
150        self
151    }
152
153    /// Enable auto-remove when stopped
154    pub fn auto_remove(mut self) -> Self {
155        self.config.auto_remove = true;
156        self
157    }
158
159    /// Set additional PostgreSQL configuration
160    pub fn postgres_args(mut self, args: impl Into<String>) -> Self {
161        self.config
162            .env
163            .insert("POSTGRES_INITDB_ARGS".to_string(), args.into());
164        self
165    }
166
167    /// Enable SSL/TLS
168    pub fn with_ssl(mut self) -> Self {
169        self.config
170            .env
171            .insert("POSTGRES_SSL_MODE".to_string(), "require".to_string());
172        self
173    }
174
175    /// Set locale
176    pub fn locale(mut self, locale: impl Into<String>) -> Self {
177        let locale = locale.into();
178        self.config.env.insert(
179            "POSTGRES_INITDB_ARGS".to_string(),
180            format!("--locale={}", locale),
181        );
182        self
183    }
184
185    /// Use a custom image and tag
186    pub fn custom_image(mut self, image: impl Into<String>, tag: impl Into<String>) -> Self {
187        self.config.image = image.into();
188        self.config.tag = tag.into();
189        self
190    }
191
192    /// Set the platform for the container (e.g., "linux/arm64", "linux/amd64")
193    pub fn platform(mut self, platform: impl Into<String>) -> Self {
194        self.config.platform = Some(platform.into());
195        self
196    }
197}
198
199#[async_trait]
200impl Template for PostgresTemplate {
201    fn name(&self) -> &str {
202        &self.config.name
203    }
204
205    fn config(&self) -> &TemplateConfig {
206        &self.config
207    }
208
209    fn config_mut(&mut self) -> &mut TemplateConfig {
210        &mut self.config
211    }
212}
213
214/// Builder for PostgreSQL connection strings
215pub struct PostgresConnectionString {
216    host: String,
217    port: u16,
218    database: String,
219    user: String,
220    password: String,
221}
222
223impl PostgresConnectionString {
224    /// Create from a PostgresTemplate
225    pub fn from_template(template: &PostgresTemplate) -> Self {
226        let config = template.config();
227        let port = config.ports.first().map(|(h, _)| *h).unwrap_or(5432);
228
229        Self {
230            host: "localhost".to_string(),
231            port,
232            database: config
233                .env
234                .get("POSTGRES_DB")
235                .cloned()
236                .unwrap_or_else(|| "postgres".to_string()),
237            user: config
238                .env
239                .get("POSTGRES_USER")
240                .cloned()
241                .unwrap_or_else(|| "postgres".to_string()),
242            password: config
243                .env
244                .get("POSTGRES_PASSWORD")
245                .cloned()
246                .unwrap_or_else(|| "postgres".to_string()),
247        }
248    }
249
250    /// Get the connection string in PostgreSQL URL format
251    pub fn url(&self) -> String {
252        format!(
253            "postgresql://{}:{}@{}:{}/{}",
254            self.user, self.password, self.host, self.port, self.database
255        )
256    }
257
258    /// Get the connection string in key-value format
259    pub fn key_value(&self) -> String {
260        format!(
261            "host={} port={} dbname={} user={} password={}",
262            self.host, self.port, self.database, self.user, self.password
263        )
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::DockerCommand;
271
272    #[test]
273    fn test_postgres_template_basic() {
274        let template = PostgresTemplate::new("test-postgres");
275        assert_eq!(template.name(), "test-postgres");
276        assert_eq!(template.config().image, "postgres");
277        assert_eq!(template.config().tag, "15-alpine");
278        assert_eq!(template.config().ports, vec![(5432, 5432)]);
279    }
280
281    #[test]
282    fn test_postgres_template_custom_config() {
283        let template = PostgresTemplate::new("test-postgres")
284            .database("mydb")
285            .user("myuser")
286            .password("secret123")
287            .port(15432);
288
289        assert_eq!(
290            template.config().env.get("POSTGRES_DB"),
291            Some(&"mydb".to_string())
292        );
293        assert_eq!(
294            template.config().env.get("POSTGRES_USER"),
295            Some(&"myuser".to_string())
296        );
297        assert_eq!(
298            template.config().env.get("POSTGRES_PASSWORD"),
299            Some(&"secret123".to_string())
300        );
301        assert_eq!(template.config().ports, vec![(15432, 5432)]);
302    }
303
304    #[test]
305    fn test_postgres_template_with_persistence() {
306        let template = PostgresTemplate::new("test-postgres").with_persistence("postgres-data");
307
308        assert_eq!(template.config().volumes.len(), 1);
309        assert_eq!(template.config().volumes[0].source, "postgres-data");
310        assert_eq!(
311            template.config().volumes[0].target,
312            "/var/lib/postgresql/data"
313        );
314    }
315
316    #[test]
317    fn test_postgres_template_with_init_scripts() {
318        let template = PostgresTemplate::new("test-postgres").init_scripts("./init-scripts");
319
320        assert_eq!(template.config().volumes.len(), 1);
321        assert_eq!(template.config().volumes[0].source, "./init-scripts");
322        assert_eq!(
323            template.config().volumes[0].target,
324            "/docker-entrypoint-initdb.d"
325        );
326        assert!(template.config().volumes[0].read_only);
327    }
328
329    #[test]
330    fn test_postgres_connection_string() {
331        let template = PostgresTemplate::new("test-postgres")
332            .database("testdb")
333            .user("testuser")
334            .password("testpass")
335            .port(15432);
336
337        let conn = PostgresConnectionString::from_template(&template);
338
339        assert_eq!(
340            conn.url(),
341            "postgresql://testuser:testpass@localhost:15432/testdb"
342        );
343
344        assert_eq!(
345            conn.key_value(),
346            "host=localhost port=15432 dbname=testdb user=testuser password=testpass"
347        );
348    }
349
350    #[test]
351    fn test_postgres_build_command() {
352        let template = PostgresTemplate::new("test-postgres")
353            .database("mydb")
354            .port(15432);
355
356        let cmd = template.build_command();
357        let args = cmd.build_command_args();
358
359        // Check that basic args are present
360        assert!(args.contains(&"run".to_string()));
361        assert!(args.contains(&"--name".to_string()));
362        assert!(args.contains(&"test-postgres".to_string()));
363        assert!(args.contains(&"--publish".to_string()));
364        assert!(args.contains(&"15432:5432".to_string()));
365        assert!(args.contains(&"--env".to_string()));
366    }
367}