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    // Use 0.0.0.0/0 for all addresses, or use specific CIDR if listen_addresses contains a network
315    let hba_network = if config.listen_addresses == "0.0.0.0" || config.listen_addresses == "*" {
316        "0.0.0.0/0"
317    } else if config.listen_addresses.contains('/') {
318        config.listen_addresses.as_str()
319    } else {
320        // If it's a specific IP, add /32
321        &format!("{}/32", config.listen_addresses)
322    };
323
324    let hba_cmd = format!(
325        r#"echo "host    all             all             {}             md5" >> {}/pg_hba.conf"#,
326        hba_network, config_dir
327    );
328    ssh.execute(&hba_cmd)
329        .map_err(|e| Error::Configuration(format!("Failed to update pg_hba.conf: {}", e)))?;
330
331    // Restart service to apply changes
332    info!("Restarting PostgreSQL to apply configuration");
333    ssh.execute("systemctl restart postgresql")
334        .map_err(|e| Error::ServiceError(format!("Failed to restart service: {}", e)))?;
335
336    // Verify service is running
337    tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
338    verify_service_running(ssh).await?;
339
340    info!("PostgreSQL server configured successfully");
341    Ok(())
342}
343
344/// Detect configuration differences
345///
346/// # Arguments
347///
348/// * `ssh` - SSH connection to the server
349/// * `config` - Desired PostgreSQL configuration
350///
351/// # Returns
352///
353/// A `ConfigDiff` containing all detected changes
354pub async fn detect_diff(ssh: &mut SshClient, config: &PostgresConfig) -> Result<ConfigDiff> {
355    debug!("Detecting configuration differences");
356
357    let mut diff = ConfigDiff::new();
358    let config_file = config.postgresql_conf_path();
359
360    // Helper to get current config value
361    let get_config_value = |ssh: &mut SshClient, param: &str| -> Option<String> {
362        let cmd = format!(
363            r#"grep "^{} = " {} | sed "s/{} = //" | tr -d "'"#,
364            param, config_file, param
365        );
366        ssh.execute(&cmd).ok().and_then(|output| {
367            let val = output.stdout.trim();
368            if val.is_empty() {
369                None
370            } else {
371                Some(val.to_string())
372            }
373        })
374    };
375
376    // Helper macro to check a config parameter
377    macro_rules! check_param {
378        ($param_name:expr, $desired_value:expr) => {
379            if let Some(ref desired) = $desired_value {
380                let current = get_config_value(ssh, $param_name);
381                let desired_str = desired.to_string();
382                if current.as_deref() != Some(desired_str.as_str()) {
383                    let change_type = if current.is_some() {
384                        ChangeType::Modify
385                    } else {
386                        ChangeType::Add
387                    };
388                    diff.add_change(ConfigChange {
389                        parameter: $param_name.to_string(),
390                        current,
391                        desired: desired_str,
392                        change_type,
393                    });
394                }
395            }
396        };
397    }
398
399    // Check all PostgreSQL parameters
400    check_param!(
401        "max_connections",
402        config.max_connections.map(|v| v.to_string())
403    );
404    check_param!("shared_buffers", config.shared_buffers.clone());
405    check_param!("effective_cache_size", config.effective_cache_size.clone());
406    check_param!("work_mem", config.work_mem.clone());
407    check_param!("maintenance_work_mem", config.maintenance_work_mem.clone());
408    check_param!("wal_buffers", config.wal_buffers.clone());
409    check_param!(
410        "checkpoint_completion_target",
411        config.checkpoint_completion_target.map(|v| v.to_string())
412    );
413
414    // Check port
415    let current_port = get_config_value(ssh, "port");
416    let desired_port = config.port.to_string();
417    if current_port.as_deref() != Some(desired_port.as_str()) {
418        let change_type = if current_port.is_some() {
419            ChangeType::Modify
420        } else {
421            ChangeType::Add
422        };
423        diff.add_change(ConfigChange {
424            parameter: "port".to_string(),
425            current: current_port,
426            desired: desired_port,
427            change_type,
428        });
429    }
430
431    // Check listen_addresses
432    let current_listen = get_config_value(ssh, "listen_addresses");
433    if current_listen.as_deref() != Some(&config.listen_addresses) {
434        let change_type = if current_listen.is_some() {
435            ChangeType::Modify
436        } else {
437            ChangeType::Add
438        };
439        diff.add_change(ConfigChange {
440            parameter: "listen_addresses".to_string(),
441            current: current_listen,
442            desired: config.listen_addresses.clone(),
443            change_type,
444        });
445    }
446
447    // Check SSL setting
448    let current_ssl = get_config_value(ssh, "ssl");
449    let desired_ssl = if config.ssl { "on" } else { "off" }.to_string();
450    if current_ssl.as_deref() != Some(desired_ssl.as_str()) {
451        let change_type = if current_ssl.is_some() {
452            ChangeType::Modify
453        } else {
454            ChangeType::Add
455        };
456        diff.add_change(ConfigChange {
457            parameter: "ssl".to_string(),
458            current: current_ssl,
459            desired: desired_ssl,
460            change_type,
461        });
462    }
463
464    // Check extra_config parameters
465    for (key, desired_value) in &config.extra_config {
466        let current = get_config_value(ssh, key);
467        if current.as_deref() != Some(desired_value.as_str()) {
468            let change_type = if current.is_some() {
469                ChangeType::Modify
470            } else {
471                ChangeType::Add
472            };
473            diff.add_change(ConfigChange {
474                parameter: key.clone(),
475                current,
476                desired: desired_value.clone(),
477                change_type,
478            });
479        }
480    }
481
482    debug!("Diff detection complete: {} changes", diff.len());
483    Ok(diff)
484}
485
486/// Test database connection
487///
488/// # Arguments
489///
490/// * `ssh` - SSH connection to the server
491/// * `config` - PostgreSQL configuration
492pub async fn test_connection(ssh: &mut SshClient, config: &PostgresConfig) -> Result<()> {
493    debug!("Testing database connection");
494
495    let test_cmd = format!(
496        r#"PGPASSWORD='{}' psql -U {} -h localhost -p {} -d {} -c '\l' > /dev/null 2>&1"#,
497        config.password, config.username, config.port, config.database_name
498    );
499
500    ssh.execute(&test_cmd)
501        .map_err(|e| Error::ConnectionTest(format!("Connection test failed: {}", e)))?;
502
503    info!("Database connection test successful");
504    Ok(())
505}
506
507/// Verify PostgreSQL service is running
508async fn verify_service_running(ssh: &mut SshClient) -> Result<()> {
509    let status = ssh
510        .execute("systemctl is-active postgresql")
511        .map_err(|e| Error::ServiceError(format!("Failed to check service status: {}", e)))?;
512
513    if status.stdout.trim() != "active" {
514        return Err(Error::ServiceError(
515            "PostgreSQL service is not active".to_string(),
516        ));
517    }
518
519    debug!("PostgreSQL service is active");
520    Ok(())
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    // Note: These are unit tests that don't require SSH connection
528    // Integration tests with actual SSH connections should be in tests/
529
530    #[test]
531    fn test_config_paths() {
532        let config = PostgresConfig::builder()
533            .version("15")
534            .database_name("test")
535            .username("user")
536            .password("pass")
537            .build()
538            .unwrap();
539
540        assert_eq!(config.config_dir(), "/etc/postgresql/15/main");
541        assert_eq!(
542            config.postgresql_conf_path(),
543            "/etc/postgresql/15/main/postgresql.conf"
544        );
545    }
546}