feat: Week 3 Phase 4 - Point-in-Time Restore
- Created internal/pitr/recovery_target.go (330 lines) - ParseRecoveryTarget: Parse all target types (time/xid/lsn/name/immediate) - Validate: Full validation for each target type - ToPostgreSQLConfig: Convert to postgresql.conf format - Support timestamp, XID, LSN, restore point name, immediate recovery - Created internal/pitr/recovery_config.go (320 lines) - RecoveryConfigGenerator for PostgreSQL 12+ and legacy - Generate recovery.signal + postgresql.auto.conf (PG 12+) - Generate recovery.conf (PG < 12) - Auto-detect PostgreSQL version from PG_VERSION - Validate data directory before restore - Backup existing recovery config - Smart restore_command with multi-extension support (.gz.enc, .enc, .gz) - Created internal/pitr/restore.go (400 lines) - RestoreOrchestrator for complete PITR workflow - Extract base backup (.tar.gz, .tar, directory) - Generate recovery configuration - Optional auto-start PostgreSQL - Optional recovery progress monitoring - Comprehensive validation - Clear user instructions - Added 'restore pitr' command to cmd/restore.go - All recovery target flags (--target-time, --target-xid, --target-lsn, --target-name, --target-immediate) - Action control (--target-action: promote/pause/shutdown) - Timeline selection (--timeline) - Auto-start and monitoring options - Skip extraction for existing data directories Features: - Support all PostgreSQL recovery targets - PostgreSQL version detection (12+ vs legacy) - Comprehensive validation before restore - User-friendly output with clear next steps - Safe defaults (promote after recovery) Total new code: ~1050 lines Build: ✅ Successful Tests: ✅ Help and validation working Example usage: dbbackup restore pitr \ --base-backup /backups/base.tar.gz \ --wal-archive /backups/wal/ \ --target-time "2024-11-26 12:00:00" \ --target-dir /var/lib/postgresql/14/main
This commit is contained in:
314
internal/pitr/recovery_config.go
Normal file
314
internal/pitr/recovery_config.go
Normal file
@@ -0,0 +1,314 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user