package pitr import ( "fmt" "os" "path/filepath" "strings" "dbbackup/internal/logger" ) // RecoveryConfigGenerator generates PostgreSQL recovery configuration files type RecoveryConfigGenerator struct { log logger.Logger } // NewRecoveryConfigGenerator creates a new recovery config generator func NewRecoveryConfigGenerator(log logger.Logger) *RecoveryConfigGenerator { return &RecoveryConfigGenerator{ log: log, } } // RecoveryConfig holds all recovery configuration parameters type RecoveryConfig struct { // Core recovery settings Target *RecoveryTarget WALArchiveDir string RestoreCommand string // PostgreSQL version PostgreSQLVersion int // Major version (12, 13, 14, etc.) // Additional settings PrimaryConnInfo string // For standby mode PrimarySlotName string // Replication slot name RecoveryMinApplyDelay string // Min delay for replay // Paths DataDir string // PostgreSQL data directory } // GenerateRecoveryConfig writes recovery configuration files // PostgreSQL 12+: postgresql.auto.conf + recovery.signal // PostgreSQL < 12: recovery.conf func (rcg *RecoveryConfigGenerator) GenerateRecoveryConfig(config *RecoveryConfig) error { rcg.log.Info("Generating recovery configuration", "pg_version", config.PostgreSQLVersion, "target_type", config.Target.Type, "data_dir", config.DataDir) if config.PostgreSQLVersion >= 12 { return rcg.generateModernRecoveryConfig(config) } return rcg.generateLegacyRecoveryConfig(config) } // generateModernRecoveryConfig generates config for PostgreSQL 12+ // Uses postgresql.auto.conf and recovery.signal func (rcg *RecoveryConfigGenerator) generateModernRecoveryConfig(config *RecoveryConfig) error { // Create recovery.signal file (empty file that triggers recovery mode) recoverySignalPath := filepath.Join(config.DataDir, "recovery.signal") rcg.log.Info("Creating recovery.signal file", "path", recoverySignalPath) signalFile, err := os.Create(recoverySignalPath) if err != nil { return fmt.Errorf("failed to create recovery.signal: %w", err) } signalFile.Close() // Generate postgresql.auto.conf with recovery settings autoConfPath := filepath.Join(config.DataDir, "postgresql.auto.conf") rcg.log.Info("Generating postgresql.auto.conf", "path", autoConfPath) var sb strings.Builder sb.WriteString("# PostgreSQL recovery configuration\n") sb.WriteString("# Generated by dbbackup for Point-in-Time Recovery\n") sb.WriteString(fmt.Sprintf("# Target: %s\n", config.Target.Summary())) sb.WriteString("\n") // Restore command if config.RestoreCommand == "" { config.RestoreCommand = rcg.generateRestoreCommand(config.WALArchiveDir) } sb.WriteString(FormatConfigLine("restore_command", config.RestoreCommand)) sb.WriteString("\n") // Recovery target parameters targetConfig := config.Target.ToPostgreSQLConfig() for key, value := range targetConfig { sb.WriteString(FormatConfigLine(key, value)) sb.WriteString("\n") } // Optional: Primary connection info (for standby mode) if config.PrimaryConnInfo != "" { sb.WriteString("\n# Standby configuration\n") sb.WriteString(FormatConfigLine("primary_conninfo", config.PrimaryConnInfo)) sb.WriteString("\n") if config.PrimarySlotName != "" { sb.WriteString(FormatConfigLine("primary_slot_name", config.PrimarySlotName)) sb.WriteString("\n") } } // Optional: Recovery delay if config.RecoveryMinApplyDelay != "" { sb.WriteString(FormatConfigLine("recovery_min_apply_delay", config.RecoveryMinApplyDelay)) sb.WriteString("\n") } // Write the configuration file if err := os.WriteFile(autoConfPath, []byte(sb.String()), 0600); err != nil { return fmt.Errorf("failed to write postgresql.auto.conf: %w", err) } rcg.log.Info("Recovery configuration generated successfully", "signal", recoverySignalPath, "config", autoConfPath) return nil } // generateLegacyRecoveryConfig generates config for PostgreSQL < 12 // Uses recovery.conf file func (rcg *RecoveryConfigGenerator) generateLegacyRecoveryConfig(config *RecoveryConfig) error { recoveryConfPath := filepath.Join(config.DataDir, "recovery.conf") rcg.log.Info("Generating recovery.conf (legacy)", "path", recoveryConfPath) var sb strings.Builder sb.WriteString("# PostgreSQL recovery configuration\n") sb.WriteString("# Generated by dbbackup for Point-in-Time Recovery\n") sb.WriteString(fmt.Sprintf("# Target: %s\n", config.Target.Summary())) sb.WriteString("\n") // Restore command if config.RestoreCommand == "" { config.RestoreCommand = rcg.generateRestoreCommand(config.WALArchiveDir) } sb.WriteString(FormatConfigLine("restore_command", config.RestoreCommand)) sb.WriteString("\n") // Recovery target parameters targetConfig := config.Target.ToPostgreSQLConfig() for key, value := range targetConfig { sb.WriteString(FormatConfigLine(key, value)) sb.WriteString("\n") } // Optional: Primary connection info (for standby mode) if config.PrimaryConnInfo != "" { sb.WriteString("\n# Standby configuration\n") sb.WriteString(FormatConfigLine("standby_mode", "on")) sb.WriteString("\n") sb.WriteString(FormatConfigLine("primary_conninfo", config.PrimaryConnInfo)) sb.WriteString("\n") if config.PrimarySlotName != "" { sb.WriteString(FormatConfigLine("primary_slot_name", config.PrimarySlotName)) sb.WriteString("\n") } } // Optional: Recovery delay if config.RecoveryMinApplyDelay != "" { sb.WriteString(FormatConfigLine("recovery_min_apply_delay", config.RecoveryMinApplyDelay)) sb.WriteString("\n") } // Write the configuration file if err := os.WriteFile(recoveryConfPath, []byte(sb.String()), 0600); err != nil { return fmt.Errorf("failed to write recovery.conf: %w", err) } rcg.log.Info("Recovery configuration generated successfully", "file", recoveryConfPath) return nil } // generateRestoreCommand creates a restore_command for fetching WAL files func (rcg *RecoveryConfigGenerator) generateRestoreCommand(walArchiveDir string) string { // The restore_command is executed by PostgreSQL to fetch WAL files // %f = WAL filename, %p = full path to copy WAL file to // Try multiple extensions (.gz.enc, .enc, .gz, plain) // This handles compressed and/or encrypted WAL files return fmt.Sprintf(`bash -c 'for ext in .gz.enc .enc .gz ""; do [ -f "%s/%%f$ext" ] && { [ -z "$ext" ] && cp "%s/%%f$ext" "%%p" || case "$ext" in *.gz.enc) gpg -d "%s/%%f$ext" | gunzip > "%%p" ;; *.enc) gpg -d "%s/%%f$ext" > "%%p" ;; *.gz) gunzip -c "%s/%%f$ext" > "%%p" ;; esac; exit 0; }; done; exit 1'`, walArchiveDir, walArchiveDir, walArchiveDir, walArchiveDir, walArchiveDir) } // ValidateDataDirectory validates that the target directory is suitable for recovery func (rcg *RecoveryConfigGenerator) ValidateDataDirectory(dataDir string) error { rcg.log.Info("Validating data directory", "path", dataDir) // Check if directory exists stat, err := os.Stat(dataDir) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("data directory does not exist: %s", dataDir) } return fmt.Errorf("failed to access data directory: %w", err) } if !stat.IsDir() { return fmt.Errorf("data directory is not a directory: %s", dataDir) } // Check for PG_VERSION file (indicates PostgreSQL data directory) pgVersionPath := filepath.Join(dataDir, "PG_VERSION") if _, err := os.Stat(pgVersionPath); err != nil { if os.IsNotExist(err) { rcg.log.Warn("PG_VERSION file not found - may not be a PostgreSQL data directory", "path", dataDir) } } // Check if PostgreSQL is running (postmaster.pid exists) postmasterPid := filepath.Join(dataDir, "postmaster.pid") if _, err := os.Stat(postmasterPid); err == nil { return fmt.Errorf("PostgreSQL is currently running in data directory %s (postmaster.pid exists). Stop PostgreSQL before running recovery", dataDir) } // Check write permissions testFile := filepath.Join(dataDir, ".dbbackup_test_write") if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil { return fmt.Errorf("data directory is not writable: %w", err) } os.Remove(testFile) rcg.log.Info("Data directory validation passed", "path", dataDir) return nil } // DetectPostgreSQLVersion detects the PostgreSQL version from the data directory func (rcg *RecoveryConfigGenerator) DetectPostgreSQLVersion(dataDir string) (int, error) { pgVersionPath := filepath.Join(dataDir, "PG_VERSION") content, err := os.ReadFile(pgVersionPath) if err != nil { return 0, fmt.Errorf("failed to read PG_VERSION: %w", err) } versionStr := strings.TrimSpace(string(content)) // Parse major version (e.g., "14" or "14.2") parts := strings.Split(versionStr, ".") if len(parts) == 0 { return 0, fmt.Errorf("invalid PG_VERSION format: %s", versionStr) } var majorVersion int if _, err := fmt.Sscanf(parts[0], "%d", &majorVersion); err != nil { return 0, fmt.Errorf("failed to parse PostgreSQL version from '%s': %w", versionStr, err) } rcg.log.Info("Detected PostgreSQL version", "version", majorVersion, "full", versionStr) return majorVersion, nil } // CleanupRecoveryFiles removes recovery configuration files (for cleanup after recovery) func (rcg *RecoveryConfigGenerator) CleanupRecoveryFiles(dataDir string, pgVersion int) error { rcg.log.Info("Cleaning up recovery files", "data_dir", dataDir) if pgVersion >= 12 { // Remove recovery.signal recoverySignal := filepath.Join(dataDir, "recovery.signal") if err := os.Remove(recoverySignal); err != nil && !os.IsNotExist(err) { rcg.log.Warn("Failed to remove recovery.signal", "error", err) } // Note: postgresql.auto.conf is kept as it may contain other settings rcg.log.Info("Removed recovery.signal file") } else { // Remove recovery.conf recoveryConf := filepath.Join(dataDir, "recovery.conf") if err := os.Remove(recoveryConf); err != nil && !os.IsNotExist(err) { rcg.log.Warn("Failed to remove recovery.conf", "error", err) } rcg.log.Info("Removed recovery.conf file") } // Remove recovery.done if it exists (created by PostgreSQL after successful recovery) recoveryDone := filepath.Join(dataDir, "recovery.done") if err := os.Remove(recoveryDone); err != nil && !os.IsNotExist(err) { rcg.log.Warn("Failed to remove recovery.done", "error", err) } return nil } // BackupExistingConfig backs up existing recovery configuration (if any) func (rcg *RecoveryConfigGenerator) BackupExistingConfig(dataDir string) error { timestamp := fmt.Sprintf("%d", os.Getpid()) // Backup recovery.signal if exists (PG 12+) recoverySignal := filepath.Join(dataDir, "recovery.signal") if _, err := os.Stat(recoverySignal); err == nil { backup := filepath.Join(dataDir, fmt.Sprintf("recovery.signal.bak.%s", timestamp)) if err := os.Rename(recoverySignal, backup); err != nil { return fmt.Errorf("failed to backup recovery.signal: %w", err) } rcg.log.Info("Backed up existing recovery.signal", "backup", backup) } // Backup recovery.conf if exists (PG < 12) recoveryConf := filepath.Join(dataDir, "recovery.conf") if _, err := os.Stat(recoveryConf); err == nil { backup := filepath.Join(dataDir, fmt.Sprintf("recovery.conf.bak.%s", timestamp)) if err := os.Rename(recoveryConf, backup); err != nil { return fmt.Errorf("failed to backup recovery.conf: %w", err) } rcg.log.Info("Backed up existing recovery.conf", "backup", backup) } return nil }