lmrc_postgres/
backup.rs

1//! Configuration backup and rollback functionality
2//!
3//! This module provides:
4//! - Configuration file backup before changes
5//! - Rollback to previous configurations
6//! - Configuration history tracking
7//! - Automatic backup on configuration changes
8
9use crate::config::PostgresConfig;
10use crate::error::{Error, Result};
11use lmrc_ssh::SshClient;
12use tracing::{debug, info, warn};
13
14/// Backup metadata
15#[derive(Debug, Clone)]
16pub struct ConfigBackup {
17    /// Backup timestamp
18    pub timestamp: String,
19    /// PostgreSQL version
20    pub version: String,
21    /// Backup directory path
22    pub backup_dir: String,
23    /// Backed up files
24    pub files: Vec<String>,
25}
26
27/// Configuration history entry
28#[derive(Debug, Clone)]
29pub struct ConfigHistoryEntry {
30    /// Entry timestamp
31    pub timestamp: String,
32    /// Configuration changes made
33    pub changes: Vec<String>,
34    /// Backup ID
35    pub backup_id: String,
36}
37
38/// Create a backup of PostgreSQL configuration files
39///
40/// Backs up:
41/// - postgresql.conf
42/// - pg_hba.conf
43/// - pg_ident.conf (if exists)
44///
45/// Returns the backup directory path
46pub async fn backup_config(ssh: &mut SshClient, config: &PostgresConfig) -> Result<ConfigBackup> {
47    info!("Creating configuration backup");
48
49    let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S").to_string();
50    let backup_dir = format!("/var/backups/postgresql/{}", timestamp);
51    let config_dir = config.config_dir();
52
53    // Create backup directory
54    debug!("Creating backup directory: {}", backup_dir);
55    ssh.execute(&format!("mkdir -p {}", backup_dir))
56        .map_err(|e| Error::Configuration(format!("Failed to create backup directory: {}", e)))?;
57
58    let mut backed_up_files = Vec::new();
59
60    // Backup postgresql.conf
61    let postgresql_conf = format!("{}/postgresql.conf", config_dir);
62    if ssh.execute(&format!("test -f {}", postgresql_conf)).is_ok() {
63        ssh.execute(&format!(
64            "cp {} {}/postgresql.conf",
65            postgresql_conf, backup_dir
66        ))
67        .map_err(|e| Error::Configuration(format!("Failed to backup postgresql.conf: {}", e)))?;
68        backed_up_files.push("postgresql.conf".to_string());
69        debug!("✓ Backed up postgresql.conf");
70    }
71
72    // Backup pg_hba.conf
73    let pg_hba_conf = format!("{}/pg_hba.conf", config_dir);
74    if ssh.execute(&format!("test -f {}", pg_hba_conf)).is_ok() {
75        ssh.execute(&format!("cp {} {}/pg_hba.conf", pg_hba_conf, backup_dir))
76            .map_err(|e| Error::Configuration(format!("Failed to backup pg_hba.conf: {}", e)))?;
77        backed_up_files.push("pg_hba.conf".to_string());
78        debug!("✓ Backed up pg_hba.conf");
79    }
80
81    // Backup pg_ident.conf if it exists
82    let pg_ident_conf = format!("{}/pg_ident.conf", config_dir);
83    if ssh.execute(&format!("test -f {}", pg_ident_conf)).is_ok() {
84        ssh.execute(&format!(
85            "cp {} {}/pg_ident.conf",
86            pg_ident_conf, backup_dir
87        ))
88        .ok(); // Don't fail if this doesn't exist
89        backed_up_files.push("pg_ident.conf".to_string());
90        debug!("✓ Backed up pg_ident.conf");
91    }
92
93    // Save backup metadata
94    let metadata = format!(
95        "timestamp={}\nversion={}\nfiles={}\n",
96        timestamp,
97        config.version,
98        backed_up_files.join(",")
99    );
100    ssh.execute(&format!("echo '{}' > {}/backup.meta", metadata, backup_dir))
101        .map_err(|e| Error::Configuration(format!("Failed to save backup metadata: {}", e)))?;
102
103    info!(
104        "✓ Configuration backup created: {} ({} files)",
105        backup_dir,
106        backed_up_files.len()
107    );
108
109    Ok(ConfigBackup {
110        timestamp,
111        version: config.version.clone(),
112        backup_dir,
113        files: backed_up_files,
114    })
115}
116
117/// List available configuration backups
118pub async fn list_backups(ssh: &mut SshClient) -> Result<Vec<ConfigBackup>> {
119    debug!("Listing configuration backups");
120
121    let backup_base = "/var/backups/postgresql";
122
123    // Check if backup directory exists
124    if ssh.execute(&format!("test -d {}", backup_base)).is_err() {
125        return Ok(Vec::new());
126    }
127
128    // List backup directories
129    let output = ssh
130        .execute(&format!("ls -1 {}", backup_base))
131        .map_err(|e| Error::Configuration(format!("Failed to list backups: {}", e)))?;
132
133    let mut backups = Vec::new();
134
135    for line in output.stdout.lines() {
136        let timestamp = line.trim();
137        if timestamp.is_empty() {
138            continue;
139        }
140
141        let backup_dir = format!("{}/{}", backup_base, timestamp);
142
143        // Read metadata if available
144        let meta_result = ssh.execute(&format!("cat {}/backup.meta 2>/dev/null", backup_dir));
145
146        let (version, files) = if let Ok(meta_output) = meta_result {
147            let mut ver = String::new();
148            let mut file_list = Vec::new();
149
150            for meta_line in meta_output.stdout.lines() {
151                if let Some(val) = meta_line.strip_prefix("version=") {
152                    ver = val.to_string();
153                } else if let Some(val) = meta_line.strip_prefix("files=") {
154                    file_list = val.split(',').map(|s| s.to_string()).collect();
155                }
156            }
157
158            (ver, file_list)
159        } else {
160            (String::new(), Vec::new())
161        };
162
163        backups.push(ConfigBackup {
164            timestamp: timestamp.to_string(),
165            version,
166            backup_dir,
167            files,
168        });
169    }
170
171    debug!("Found {} backup(s)", backups.len());
172    Ok(backups)
173}
174
175/// Restore configuration from a backup
176pub async fn restore_backup(
177    ssh: &mut SshClient,
178    config: &PostgresConfig,
179    backup: &ConfigBackup,
180) -> Result<()> {
181    info!("Restoring configuration from backup: {}", backup.timestamp);
182
183    let config_dir = config.config_dir();
184
185    // Restore postgresql.conf
186    if backup.files.contains(&"postgresql.conf".to_string()) {
187        ssh.execute(&format!(
188            "cp {}/postgresql.conf {}/postgresql.conf",
189            backup.backup_dir, config_dir
190        ))
191        .map_err(|e| Error::Configuration(format!("Failed to restore postgresql.conf: {}", e)))?;
192        debug!("✓ Restored postgresql.conf");
193    }
194
195    // Restore pg_hba.conf
196    if backup.files.contains(&"pg_hba.conf".to_string()) {
197        ssh.execute(&format!(
198            "cp {}/pg_hba.conf {}/pg_hba.conf",
199            backup.backup_dir, config_dir
200        ))
201        .map_err(|e| Error::Configuration(format!("Failed to restore pg_hba.conf: {}", e)))?;
202        debug!("✓ Restored pg_hba.conf");
203    }
204
205    // Restore pg_ident.conf if it was backed up
206    if backup.files.contains(&"pg_ident.conf".to_string()) {
207        ssh.execute(&format!(
208            "cp {}/pg_ident.conf {}/pg_ident.conf",
209            backup.backup_dir, config_dir
210        ))
211        .ok(); // Don't fail if this doesn't exist
212        debug!("✓ Restored pg_ident.conf");
213    }
214
215    info!("✓ Configuration restored successfully");
216
217    // Note: Service needs to be reloaded
218    warn!("Configuration restored. Run 'systemctl reload postgresql' to apply changes.");
219
220    Ok(())
221}
222
223/// Rollback to the most recent backup
224pub async fn rollback_config(ssh: &mut SshClient, config: &PostgresConfig) -> Result<()> {
225    info!("Rolling back to most recent configuration backup");
226
227    let backups = list_backups(ssh).await?;
228
229    if backups.is_empty() {
230        return Err(Error::Configuration(
231            "No configuration backups found".to_string(),
232        ));
233    }
234
235    // Get the most recent backup
236    let latest_backup = &backups[backups.len() - 1];
237
238    restore_backup(ssh, config, latest_backup).await?;
239
240    info!("✓ Rolled back to backup: {}", latest_backup.timestamp);
241
242    Ok(())
243}
244
245/// Delete old backups, keeping only the most recent N backups
246pub async fn cleanup_old_backups(ssh: &mut SshClient, keep_count: usize) -> Result<usize> {
247    debug!("Cleaning up old backups, keeping {}", keep_count);
248
249    let backups = list_backups(ssh).await?;
250
251    if backups.len() <= keep_count {
252        debug!("No backups to clean up");
253        return Ok(0);
254    }
255
256    let to_delete = backups.len() - keep_count;
257    let mut deleted = 0;
258
259    // Delete oldest backups
260    for backup in backups.iter().take(to_delete) {
261        ssh.execute(&format!("rm -rf {}", backup.backup_dir))
262            .map_err(|e| Error::Configuration(format!("Failed to delete backup: {}", e)))?;
263        deleted += 1;
264        debug!("Deleted old backup: {}", backup.timestamp);
265    }
266
267    info!("✓ Cleaned up {} old backup(s)", deleted);
268    Ok(deleted)
269}
270
271/// Read current pg_hba.conf content
272pub async fn read_pg_hba(ssh: &mut SshClient, config: &PostgresConfig) -> Result<String> {
273    let pg_hba_path = config.pg_hba_conf_path();
274
275    let output = ssh
276        .execute(&format!("cat {}", pg_hba_path))
277        .map_err(|e| Error::Configuration(format!("Failed to read pg_hba.conf: {}", e)))?;
278
279    Ok(output.stdout)
280}
281
282/// Parse pg_hba.conf into structured rules
283pub fn parse_pg_hba_rules(content: &str) -> Vec<PgHbaRule> {
284    let mut rules = Vec::new();
285
286    for line in content.lines() {
287        let trimmed = line.trim();
288
289        // Skip empty lines and comments
290        if trimmed.is_empty() || trimmed.starts_with('#') {
291            continue;
292        }
293
294        // Parse rule: TYPE DATABASE USER ADDRESS METHOD
295        let parts: Vec<&str> = trimmed.split_whitespace().collect();
296        if parts.len() >= 4 {
297            rules.push(PgHbaRule {
298                rule_type: parts[0].to_string(),
299                database: parts[1].to_string(),
300                user: parts[2].to_string(),
301                address: if parts.len() >= 5 {
302                    Some(parts[3].to_string())
303                } else {
304                    None
305                },
306                method: parts[parts.len() - 1].to_string(),
307                raw_line: line.to_string(),
308            });
309        }
310    }
311
312    rules
313}
314
315/// pg_hba.conf rule
316#[derive(Debug, Clone, PartialEq)]
317pub struct PgHbaRule {
318    /// Connection type (local, host, hostssl, hostnossl)
319    pub rule_type: String,
320    /// Database name
321    pub database: String,
322    /// User name
323    pub user: String,
324    /// IP address/CIDR (for host connections)
325    pub address: Option<String>,
326    /// Authentication method
327    pub method: String,
328    /// Raw line from config
329    pub raw_line: String,
330}
331
332impl std::fmt::Display for PgHbaRule {
333    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
334        if let Some(ref addr) = self.address {
335            write!(
336                f,
337                "{:<10} {:<15} {:<15} {:<18} {}",
338                self.rule_type, self.database, self.user, addr, self.method
339            )
340        } else {
341            write!(
342                f,
343                "{:<10} {:<15} {:<15} {}",
344                self.rule_type, self.database, self.user, self.method
345            )
346        }
347    }
348}
349
350/// Compare two sets of pg_hba rules and return differences
351pub fn diff_pg_hba_rules(current: &[PgHbaRule], desired: &[PgHbaRule]) -> Vec<PgHbaDiff> {
352    let mut diffs = Vec::new();
353
354    // Check for removed or modified rules
355    for (idx, current_rule) in current.iter().enumerate() {
356        if let Some(desired_rule) = desired.get(idx) {
357            if current_rule != desired_rule {
358                diffs.push(PgHbaDiff::Modified {
359                    line_number: idx + 1,
360                    old: current_rule.clone(),
361                    new: desired_rule.clone(),
362                });
363            }
364        } else {
365            diffs.push(PgHbaDiff::Removed {
366                line_number: idx + 1,
367                rule: current_rule.clone(),
368            });
369        }
370    }
371
372    // Check for added rules
373    for (idx, desired_rule) in desired.iter().enumerate() {
374        if idx >= current.len() {
375            diffs.push(PgHbaDiff::Added {
376                line_number: idx + 1,
377                rule: desired_rule.clone(),
378            });
379        }
380    }
381
382    diffs
383}
384
385/// pg_hba.conf difference
386#[derive(Debug, Clone)]
387pub enum PgHbaDiff {
388    /// Rule added
389    Added { line_number: usize, rule: PgHbaRule },
390    /// Rule removed
391    Removed { line_number: usize, rule: PgHbaRule },
392    /// Rule modified
393    Modified {
394        line_number: usize,
395        old: PgHbaRule,
396        new: PgHbaRule,
397    },
398}
399
400impl std::fmt::Display for PgHbaDiff {
401    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402        match self {
403            PgHbaDiff::Added { line_number, rule } => {
404                write!(f, "[+] Line {}: {}", line_number, rule)
405            }
406            PgHbaDiff::Removed { line_number, rule } => {
407                write!(f, "[-] Line {}: {}", line_number, rule)
408            }
409            PgHbaDiff::Modified {
410                line_number,
411                old,
412                new,
413            } => {
414                write!(f, "[~] Line {}: {} → {}", line_number, old, new)
415            }
416        }
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn test_parse_pg_hba_rules() {
426        let content = r#"
427# Comment line
428local   all             postgres                                peer
429
430# IPv4 local connections:
431host    all             all             127.0.0.1/32            md5
432host    all             all             0.0.0.0/0               md5
433"#;
434
435        let rules = parse_pg_hba_rules(content);
436        assert_eq!(rules.len(), 3);
437
438        assert_eq!(rules[0].rule_type, "local");
439        assert_eq!(rules[0].database, "all");
440        assert_eq!(rules[0].user, "postgres");
441        assert_eq!(rules[0].method, "peer");
442        assert_eq!(rules[0].address, None);
443
444        assert_eq!(rules[1].rule_type, "host");
445        assert_eq!(rules[1].database, "all");
446        assert_eq!(rules[1].user, "all");
447        assert_eq!(rules[1].address, Some("127.0.0.1/32".to_string()));
448        assert_eq!(rules[1].method, "md5");
449    }
450
451    #[test]
452    fn test_diff_pg_hba_rules() {
453        let current = vec![
454            PgHbaRule {
455                rule_type: "local".to_string(),
456                database: "all".to_string(),
457                user: "postgres".to_string(),
458                address: None,
459                method: "peer".to_string(),
460                raw_line: "local   all             postgres                                peer"
461                    .to_string(),
462            },
463            PgHbaRule {
464                rule_type: "host".to_string(),
465                database: "all".to_string(),
466                user: "all".to_string(),
467                address: Some("127.0.0.1/32".to_string()),
468                method: "md5".to_string(),
469                raw_line: "host    all             all             127.0.0.1/32            md5"
470                    .to_string(),
471            },
472        ];
473
474        let desired = vec![
475            PgHbaRule {
476                rule_type: "local".to_string(),
477                database: "all".to_string(),
478                user: "postgres".to_string(),
479                address: None,
480                method: "trust".to_string(), // Changed
481                raw_line: "local   all             postgres                                trust"
482                    .to_string(),
483            },
484            PgHbaRule {
485                rule_type: "host".to_string(),
486                database: "all".to_string(),
487                user: "all".to_string(),
488                address: Some("127.0.0.1/32".to_string()),
489                method: "md5".to_string(),
490                raw_line: "host    all             all             127.0.0.1/32            md5"
491                    .to_string(),
492            },
493            PgHbaRule {
494                rule_type: "host".to_string(),
495                database: "all".to_string(),
496                user: "all".to_string(),
497                address: Some("0.0.0.0/0".to_string()),
498                method: "md5".to_string(),
499                raw_line: "host    all             all             0.0.0.0/0               md5"
500                    .to_string(),
501            },
502        ];
503
504        let diffs = diff_pg_hba_rules(&current, &desired);
505        assert_eq!(diffs.len(), 2);
506
507        // First should be modified
508        assert!(matches!(diffs[0], PgHbaDiff::Modified { .. }));
509
510        // Second should be added
511        assert!(matches!(diffs[1], PgHbaDiff::Added { .. }));
512    }
513
514    #[test]
515    fn test_pg_hba_rule_display() {
516        let rule = PgHbaRule {
517            rule_type: "host".to_string(),
518            database: "mydb".to_string(),
519            user: "myuser".to_string(),
520            address: Some("192.168.1.0/24".to_string()),
521            method: "md5".to_string(),
522            raw_line: "host    mydb            myuser          192.168.1.0/24          md5"
523                .to_string(),
524        };
525
526        let display = format!("{}", rule);
527        assert!(display.contains("host"));
528        assert!(display.contains("mydb"));
529        assert!(display.contains("192.168.1.0/24"));
530    }
531}