lmrc_postgres/
operations.rs

1//! Idempotent PostgreSQL operations
2//!
3//! This module provides idempotent operations for PostgreSQL installation and management.
4//! All operations can be safely run multiple times without causing errors.
5
6use crate::config::PostgresConfig;
7use crate::diff::{ChangeType, ConfigChange, ConfigDiff};
8use crate::error::{Error, Result};
9use lmrc_ssh::SshClient;
10use tracing::{debug, info};
11
12/// Check if PostgreSQL is installed on the server
13///
14/// # Arguments
15///
16/// * `ssh` - SSH client connection to the server
17///
18/// # Returns
19///
20/// * `Ok(true)` if PostgreSQL is installed
21/// * `Ok(false)` if PostgreSQL is not installed
22/// * `Err(_)` on SSH or command execution errors
23pub async fn is_installed(ssh: &mut SshClient) -> Result<bool> {
24    debug!("Checking if PostgreSQL is installed");
25
26    match ssh.execute("which psql") {
27        Ok(output) => {
28            let installed = !output.stdout.trim().is_empty();
29            debug!("PostgreSQL installed: {}", installed);
30            Ok(installed)
31        }
32        Err(_) => {
33            debug!("PostgreSQL not found");
34            Ok(false)
35        }
36    }
37}
38
39/// Get installed PostgreSQL version
40///
41/// # Arguments
42///
43/// * `ssh` - SSH client connection to the server
44///
45/// # Returns
46///
47/// * `Ok(Some(version))` if PostgreSQL is installed
48/// * `Ok(None)` if PostgreSQL is not installed
49pub async fn get_installed_version(ssh: &mut SshClient) -> Result<Option<String>> {
50    debug!("Getting installed PostgreSQL version");
51
52    if !is_installed(ssh).await? {
53        return Ok(None);
54    }
55
56    let output = ssh
57        .execute("psql --version")
58        .map_err(|e| Error::ssh_execution(e.to_string(), "psql --version"))?;
59
60    // Parse version from output like "psql (PostgreSQL) 15.4"
61    let version = output
62        .stdout
63        .split_whitespace()
64        .nth(2)
65        .and_then(|v| v.split('.').next())
66        .map(|v| v.to_string());
67
68    debug!("Installed version: {:?}", version);
69    Ok(version)
70}
71
72/// Install PostgreSQL (idempotent)
73///
74/// This function will:
75/// - Check if PostgreSQL is already installed
76/// - If installed with the correct version, skip installation
77/// - If installed with a different version, return an error
78/// - If not installed, perform installation
79///
80/// # Arguments
81///
82/// * `ssh` - SSH connection to the server
83/// * `config` - PostgreSQL configuration
84pub async fn install(ssh: &mut SshClient, config: &PostgresConfig) -> Result<()> {
85    info!("Installing PostgreSQL {}", config.version);
86
87    // Check if already installed
88    if let Some(installed_version) = get_installed_version(ssh).await? {
89        if installed_version == config.version {
90            info!(
91                "PostgreSQL {} is already installed, skipping installation",
92                config.version
93            );
94            return Ok(());
95        } else {
96            return Err(Error::AlreadyInstalled(installed_version));
97        }
98    }
99
100    // Install prerequisites
101    info!("Installing prerequisites");
102    ssh.execute("DEBIAN_FRONTEND=noninteractive apt-get update -y")
103        .map_err(|e| Error::Installation(format!("Failed to update package list: {}", e)))?;
104
105    ssh.execute("DEBIAN_FRONTEND=noninteractive apt-get install -y gnupg2 wget lsb-release")
106        .map_err(|e| Error::Installation(format!("Failed to install prerequisites: {}", e)))?;
107
108    // Add PostgreSQL APT repository
109    info!("Adding PostgreSQL APT repository");
110    ssh.execute(
111        "wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -",
112    )
113    .map_err(|e| Error::Installation(format!("Failed to add GPG key: {}", e)))?;
114
115    ssh.execute(r#"echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list"#)
116        .map_err(|e| Error::Installation(format!("Failed to add repository: {}", e)))?;
117
118    // Update package list
119    info!("Updating package list");
120    ssh.execute("apt-get update -y")
121        .map_err(|e| Error::Installation(format!("Failed to update after adding repo: {}", e)))?;
122
123    // Install PostgreSQL
124    info!("Installing PostgreSQL {}", config.version);
125    let install_cmd = format!(
126        "DEBIAN_FRONTEND=noninteractive apt-get install -y postgresql-{}",
127        config.version
128    );
129    ssh.execute(&install_cmd)
130        .map_err(|e| Error::Installation(format!("Failed to install PostgreSQL: {}", e)))?;
131
132    // Start and enable service
133    info!("Starting PostgreSQL service");
134    ssh.execute("systemctl start postgresql")
135        .map_err(|e| Error::ServiceError(format!("Failed to start service: {}", e)))?;
136
137    ssh.execute("systemctl enable postgresql")
138        .map_err(|e| Error::ServiceError(format!("Failed to enable service: {}", e)))?;
139
140    // Wait for service to be ready
141    tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
142
143    // Verify installation
144    verify_service_running(ssh).await?;
145
146    info!("PostgreSQL {} installed successfully", config.version);
147    Ok(())
148}
149
150/// Uninstall PostgreSQL
151///
152/// # Arguments
153///
154/// * `ssh` - SSH connection to the server
155/// * `config` - PostgreSQL configuration (for version)
156/// * `purge` - If true, remove all data and configuration files
157pub async fn uninstall(ssh: &mut SshClient, config: &PostgresConfig, purge: bool) -> Result<()> {
158    info!("Uninstalling PostgreSQL {}", config.version);
159
160    if !is_installed(ssh).await? {
161        info!("PostgreSQL is not installed, nothing to uninstall");
162        return Ok(());
163    }
164
165    // Stop service
166    info!("Stopping PostgreSQL service");
167    let _ = ssh.execute("systemctl stop postgresql");
168
169    // Uninstall package
170    let uninstall_cmd = if purge {
171        format!("apt-get purge -y postgresql-{}", config.version)
172    } else {
173        format!("apt-get remove -y postgresql-{}", config.version)
174    };
175
176    ssh.execute(&uninstall_cmd)
177        .map_err(|e| Error::Uninstallation(format!("Failed to uninstall: {}", e)))?;
178
179    if purge {
180        info!("Removing PostgreSQL data and configuration files");
181        let _ = ssh.execute(&format!("rm -rf /etc/postgresql/{}", config.version));
182        let _ = ssh.execute(&format!("rm -rf /var/lib/postgresql/{}", config.version));
183    }
184
185    // Clean up packages
186    let _ = ssh.execute("apt-get autoremove -y");
187
188    info!("PostgreSQL uninstalled successfully");
189    Ok(())
190}
191
192/// Configure PostgreSQL database and user (idempotent)
193///
194/// This function will:
195/// - Create database if it doesn't exist
196/// - Create user if it doesn't exist
197/// - Grant permissions (idempotent)
198///
199/// # Arguments
200///
201/// * `ssh` - SSH connection to the server
202/// * `config` - PostgreSQL configuration
203pub async fn configure_database(ssh: &mut SshClient, config: &PostgresConfig) -> Result<()> {
204    info!("Configuring database and user");
205
206    // Create database (idempotent with 'IF NOT EXISTS')
207    info!("Creating database {}", config.database_name);
208    let create_db_cmd = format!(
209        r#"sudo -u postgres psql -c "CREATE DATABASE {} ENCODING 'UTF8';" || true"#,
210        config.database_name
211    );
212    ssh.execute(&create_db_cmd)
213        .map_err(|e| Error::Configuration(format!("Failed to create database: {}", e)))?;
214
215    // Create user (idempotent with '|| true')
216    info!("Creating user {}", config.username);
217    let create_user_cmd = format!(
218        r#"sudo -u postgres psql -c "CREATE USER {} WITH PASSWORD '{}';" || true"#,
219        config.username, config.password
220    );
221    ssh.execute(&create_user_cmd)
222        .map_err(|e| Error::Configuration(format!("Failed to create user: {}", e)))?;
223
224    // Update password (in case user already exists)
225    let update_password_cmd = format!(
226        r#"sudo -u postgres psql -c "ALTER USER {} WITH PASSWORD '{}';" || true"#,
227        config.username, config.password
228    );
229    ssh.execute(&update_password_cmd)
230        .map_err(|e| Error::Configuration(format!("Failed to update user password: {}", e)))?;
231
232    // Grant database privileges
233    info!("Granting privileges");
234    let grant_cmd = format!(
235        r#"sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE {} TO {};" || true"#,
236        config.database_name, config.username
237    );
238    ssh.execute(&grant_cmd)
239        .map_err(|e| Error::Configuration(format!("Failed to grant privileges: {}", e)))?;
240
241    // Grant schema permissions (PostgreSQL 15+)
242    let grant_schema_cmd = format!(
243        r#"sudo -u postgres psql -d {} -c "GRANT ALL ON SCHEMA public TO {};" || true"#,
244        config.database_name, config.username
245    );
246    ssh.execute(&grant_schema_cmd)
247        .map_err(|e| Error::Configuration(format!("Failed to grant schema privileges: {}", e)))?;
248
249    // Grant default privileges
250    let grant_default_cmd = format!(
251        r#"sudo -u postgres psql -d {} -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO {};" || true"#,
252        config.database_name, config.username
253    );
254    ssh.execute(&grant_default_cmd)
255        .map_err(|e| Error::Configuration(format!("Failed to grant default privileges: {}", e)))?;
256
257    info!("Database and user configured successfully");
258    Ok(())
259}
260
261/// Configure PostgreSQL server settings
262///
263/// # Arguments
264///
265/// * `ssh` - SSH connection to the server
266/// * `config` - PostgreSQL configuration
267pub async fn configure_server(ssh: &mut SshClient, config: &PostgresConfig) -> Result<()> {
268    info!("Configuring PostgreSQL server");
269
270    let config_dir = config.config_dir();
271
272    // Update listen_addresses
273    info!("Configuring listen addresses");
274    let listen_cmd = format!(
275        r#"sed -i "s/#listen_addresses = 'localhost'/listen_addresses = '*'/" {}/postgresql.conf"#,
276        config_dir
277    );
278    ssh.execute(&listen_cmd)
279        .map_err(|e| Error::Configuration(format!("Failed to update listen_addresses: {}", e)))?;
280
281    // Update port if not default
282    if config.port != 5432 {
283        let port_cmd = format!(
284            r#"sed -i "s/#port = 5432/port = {}/" {}/postgresql.conf"#,
285            config.port, config_dir
286        );
287        ssh.execute(&port_cmd)
288            .map_err(|e| Error::Configuration(format!("Failed to update port: {}", e)))?;
289    }
290
291    // Configure max_connections
292    if let Some(max_conn) = config.max_connections {
293        let max_conn_cmd = format!(
294            r#"sed -i "s/max_connections = [0-9]\\+/max_connections = {}/" {}/postgresql.conf"#,
295            max_conn, config_dir
296        );
297        ssh.execute(&max_conn_cmd).map_err(|e| {
298            Error::Configuration(format!("Failed to update max_connections: {}", e))
299        })?;
300    }
301
302    // Configure shared_buffers
303    if let Some(ref buffers) = config.shared_buffers {
304        let buffers_cmd = format!(
305            r#"sed -i "s/#shared_buffers = .*/shared_buffers = {}/" {}/postgresql.conf"#,
306            buffers, config_dir
307        );
308        ssh.execute(&buffers_cmd)
309            .map_err(|e| Error::Configuration(format!("Failed to update shared_buffers: {}", e)))?;
310    }
311
312    // Update pg_hba.conf for remote access
313    info!("Configuring remote access");
314    let hba_cmd = format!(
315        r#"echo "host    all             all             {}             md5" >> {}/pg_hba.conf"#,
316        config.listen_addresses, config_dir
317    );
318    ssh.execute(&hba_cmd)
319        .map_err(|e| Error::Configuration(format!("Failed to update pg_hba.conf: {}", e)))?;
320
321    // Restart service to apply changes
322    info!("Restarting PostgreSQL to apply configuration");
323    ssh.execute("systemctl restart postgresql")
324        .map_err(|e| Error::ServiceError(format!("Failed to restart service: {}", e)))?;
325
326    // Verify service is running
327    tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
328    verify_service_running(ssh).await?;
329
330    info!("PostgreSQL server configured successfully");
331    Ok(())
332}
333
334/// Detect configuration differences
335///
336/// # Arguments
337///
338/// * `ssh` - SSH connection to the server
339/// * `config` - Desired PostgreSQL configuration
340///
341/// # Returns
342///
343/// A `ConfigDiff` containing all detected changes
344pub async fn detect_diff(ssh: &mut SshClient, config: &PostgresConfig) -> Result<ConfigDiff> {
345    debug!("Detecting configuration differences");
346
347    let mut diff = ConfigDiff::new();
348    let config_file = config.postgresql_conf_path();
349
350    // Helper to get current config value
351    let get_config_value = |ssh: &mut SshClient, param: &str| -> Option<String> {
352        let cmd = format!(
353            r#"grep "^{} = " {} | sed "s/{} = //" | tr -d "'"#,
354            param, config_file, param
355        );
356        ssh.execute(&cmd).ok().and_then(|output| {
357            let val = output.stdout.trim();
358            if val.is_empty() {
359                None
360            } else {
361                Some(val.to_string())
362            }
363        })
364    };
365
366    // Helper macro to check a config parameter
367    macro_rules! check_param {
368        ($param_name:expr, $desired_value:expr) => {
369            if let Some(ref desired) = $desired_value {
370                let current = get_config_value(ssh, $param_name);
371                let desired_str = desired.to_string();
372                if current.as_deref() != Some(desired_str.as_str()) {
373                    let change_type = if current.is_some() {
374                        ChangeType::Modify
375                    } else {
376                        ChangeType::Add
377                    };
378                    diff.add_change(ConfigChange {
379                        parameter: $param_name.to_string(),
380                        current,
381                        desired: desired_str,
382                        change_type,
383                    });
384                }
385            }
386        };
387    }
388
389    // Check all PostgreSQL parameters
390    check_param!(
391        "max_connections",
392        config.max_connections.map(|v| v.to_string())
393    );
394    check_param!("shared_buffers", config.shared_buffers.clone());
395    check_param!("effective_cache_size", config.effective_cache_size.clone());
396    check_param!("work_mem", config.work_mem.clone());
397    check_param!("maintenance_work_mem", config.maintenance_work_mem.clone());
398    check_param!("wal_buffers", config.wal_buffers.clone());
399    check_param!(
400        "checkpoint_completion_target",
401        config.checkpoint_completion_target.map(|v| v.to_string())
402    );
403
404    // Check port
405    let current_port = get_config_value(ssh, "port");
406    let desired_port = config.port.to_string();
407    if current_port.as_deref() != Some(desired_port.as_str()) {
408        let change_type = if current_port.is_some() {
409            ChangeType::Modify
410        } else {
411            ChangeType::Add
412        };
413        diff.add_change(ConfigChange {
414            parameter: "port".to_string(),
415            current: current_port,
416            desired: desired_port,
417            change_type,
418        });
419    }
420
421    // Check listen_addresses
422    let current_listen = get_config_value(ssh, "listen_addresses");
423    if current_listen.as_deref() != Some(&config.listen_addresses) {
424        let change_type = if current_listen.is_some() {
425            ChangeType::Modify
426        } else {
427            ChangeType::Add
428        };
429        diff.add_change(ConfigChange {
430            parameter: "listen_addresses".to_string(),
431            current: current_listen,
432            desired: config.listen_addresses.clone(),
433            change_type,
434        });
435    }
436
437    // Check SSL setting
438    let current_ssl = get_config_value(ssh, "ssl");
439    let desired_ssl = if config.ssl { "on" } else { "off" }.to_string();
440    if current_ssl.as_deref() != Some(desired_ssl.as_str()) {
441        let change_type = if current_ssl.is_some() {
442            ChangeType::Modify
443        } else {
444            ChangeType::Add
445        };
446        diff.add_change(ConfigChange {
447            parameter: "ssl".to_string(),
448            current: current_ssl,
449            desired: desired_ssl,
450            change_type,
451        });
452    }
453
454    // Check extra_config parameters
455    for (key, desired_value) in &config.extra_config {
456        let current = get_config_value(ssh, key);
457        if current.as_deref() != Some(desired_value.as_str()) {
458            let change_type = if current.is_some() {
459                ChangeType::Modify
460            } else {
461                ChangeType::Add
462            };
463            diff.add_change(ConfigChange {
464                parameter: key.clone(),
465                current,
466                desired: desired_value.clone(),
467                change_type,
468            });
469        }
470    }
471
472    debug!("Diff detection complete: {} changes", diff.len());
473    Ok(diff)
474}
475
476/// Test database connection
477///
478/// # Arguments
479///
480/// * `ssh` - SSH connection to the server
481/// * `config` - PostgreSQL configuration
482pub async fn test_connection(ssh: &mut SshClient, config: &PostgresConfig) -> Result<()> {
483    debug!("Testing database connection");
484
485    let test_cmd = format!(
486        r#"PGPASSWORD='{}' psql -U {} -h localhost -p {} -d {} -c '\l' > /dev/null 2>&1"#,
487        config.password, config.username, config.port, config.database_name
488    );
489
490    ssh.execute(&test_cmd)
491        .map_err(|e| Error::ConnectionTest(format!("Connection test failed: {}", e)))?;
492
493    info!("Database connection test successful");
494    Ok(())
495}
496
497/// Verify PostgreSQL service is running
498async fn verify_service_running(ssh: &mut SshClient) -> Result<()> {
499    let status = ssh
500        .execute("systemctl is-active postgresql")
501        .map_err(|e| Error::ServiceError(format!("Failed to check service status: {}", e)))?;
502
503    if status.stdout.trim() != "active" {
504        return Err(Error::ServiceError(
505            "PostgreSQL service is not active".to_string(),
506        ));
507    }
508
509    debug!("PostgreSQL service is active");
510    Ok(())
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    // Note: These are unit tests that don't require SSH connection
518    // Integration tests with actual SSH connections should be in tests/
519
520    #[test]
521    fn test_config_paths() {
522        let config = PostgresConfig::builder()
523            .version("15")
524            .database_name("test")
525            .username("user")
526            .password("pass")
527            .build()
528            .unwrap();
529
530        assert_eq!(config.config_dir(), "/etc/postgresql/15/main");
531        assert_eq!(
532            config.postgresql_conf_path(),
533            "/etc/postgresql/15/main/postgresql.conf"
534        );
535    }
536}