diff --git a/cmd/pitr.go b/cmd/pitr.go new file mode 100644 index 0000000..a4ab678 --- /dev/null +++ b/cmd/pitr.go @@ -0,0 +1,421 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "dbbackup/internal/wal" +) + +var ( + // PITR enable flags + pitrArchiveDir string + pitrForce bool + + // WAL archive flags + walArchiveDir string + walCompress bool + walEncrypt bool + + // PITR restore flags + pitrTargetTime string + pitrTargetXID string + pitrTargetName string + pitrTargetLSN string + pitrTargetImmediate bool + pitrRecoveryAction string + pitrWALSource string +) + +// pitrCmd represents the pitr command group +var pitrCmd = &cobra.Command{ + Use: "pitr", + Short: "Point-in-Time Recovery (PITR) operations", + Long: `Manage PostgreSQL Point-in-Time Recovery (PITR) with WAL archiving. + +PITR allows you to restore your database to any point in time, not just +to the time of your last backup. This requires continuous WAL archiving. + +Commands: + enable - Configure PostgreSQL for PITR + disable - Disable PITR + status - Show current PITR configuration +`, +} + +// pitrEnableCmd enables PITR +var pitrEnableCmd = &cobra.Command{ + Use: "enable", + Short: "Enable Point-in-Time Recovery", + Long: `Configure PostgreSQL for Point-in-Time Recovery by enabling WAL archiving. + +This command will: +1. Create WAL archive directory +2. Update postgresql.conf with PITR settings +3. Set archive_mode = on +4. Configure archive_command to use dbbackup + +Note: PostgreSQL restart is required after enabling PITR. + +Example: + dbbackup pitr enable --archive-dir /backups/wal_archive +`, + RunE: runPITREnable, +} + +// pitrDisableCmd disables PITR +var pitrDisableCmd = &cobra.Command{ + Use: "disable", + Short: "Disable Point-in-Time Recovery", + Long: `Disable PITR by turning off WAL archiving. + +This sets archive_mode = off in postgresql.conf. +Requires PostgreSQL restart to take effect. + +Example: + dbbackup pitr disable +`, + RunE: runPITRDisable, +} + +// pitrStatusCmd shows PITR status +var pitrStatusCmd = &cobra.Command{ + Use: "status", + Short: "Show PITR configuration and WAL archive status", + Long: `Display current PITR settings and WAL archive statistics. + +Shows: +- archive_mode, wal_level, archive_command +- Number of archived WAL files +- Total archive size +- Oldest and newest WAL archives + +Example: + dbbackup pitr status +`, + RunE: runPITRStatus, +} + +// walCmd represents the wal command group +var walCmd = &cobra.Command{ + Use: "wal", + Short: "WAL (Write-Ahead Log) operations", + Long: `Manage PostgreSQL Write-Ahead Log (WAL) files. + +WAL files contain all changes made to the database and are essential +for Point-in-Time Recovery (PITR). +`, +} + +// walArchiveCmd archives a WAL file +var walArchiveCmd = &cobra.Command{ + Use: "archive ", + Short: "Archive a WAL file (called by PostgreSQL)", + Long: `Archive a PostgreSQL WAL file to the archive directory. + +This command is typically called automatically by PostgreSQL via the +archive_command setting. It can also be run manually for testing. + +Arguments: + wal_path - Full path to the WAL file (e.g., /var/lib/postgresql/data/pg_wal/0000...) + wal_filename - WAL filename only (e.g., 000000010000000000000001) + +Example: + dbbackup wal archive /var/lib/postgresql/data/pg_wal/000000010000000000000001 000000010000000000000001 --archive-dir /backups/wal +`, + Args: cobra.ExactArgs(2), + RunE: runWALArchive, +} + +// walListCmd lists archived WAL files +var walListCmd = &cobra.Command{ + Use: "list", + Short: "List archived WAL files", + Long: `List all WAL files in the archive directory. + +Shows timeline, segment number, size, and archive time for each WAL file. + +Example: + dbbackup wal list --archive-dir /backups/wal_archive +`, + RunE: runWALList, +} + +// walCleanupCmd cleans up old WAL archives +var walCleanupCmd = &cobra.Command{ + Use: "cleanup", + Short: "Remove old WAL archives based on retention policy", + Long: `Delete WAL archives older than the specified retention period. + +WAL files older than --retention-days will be permanently deleted. + +Example: + dbbackup wal cleanup --archive-dir /backups/wal_archive --retention-days 7 +`, + RunE: runWALCleanup, +} + +func init() { + rootCmd.AddCommand(pitrCmd) + rootCmd.AddCommand(walCmd) + + // PITR subcommands + pitrCmd.AddCommand(pitrEnableCmd) + pitrCmd.AddCommand(pitrDisableCmd) + pitrCmd.AddCommand(pitrStatusCmd) + + // WAL subcommands + walCmd.AddCommand(walArchiveCmd) + walCmd.AddCommand(walListCmd) + walCmd.AddCommand(walCleanupCmd) + + // PITR enable flags + pitrEnableCmd.Flags().StringVar(&pitrArchiveDir, "archive-dir", "/var/backups/wal_archive", "Directory to store WAL archives") + pitrEnableCmd.Flags().BoolVar(&pitrForce, "force", false, "Overwrite existing PITR configuration") + + // WAL archive flags + walArchiveCmd.Flags().StringVar(&walArchiveDir, "archive-dir", "", "WAL archive directory (required)") + walArchiveCmd.Flags().BoolVar(&walCompress, "compress", false, "Compress WAL files with gzip") + walArchiveCmd.Flags().BoolVar(&walEncrypt, "encrypt", false, "Encrypt WAL files") + walArchiveCmd.MarkFlagRequired("archive-dir") + + // WAL list flags + walListCmd.Flags().StringVar(&walArchiveDir, "archive-dir", "/var/backups/wal_archive", "WAL archive directory") + + // WAL cleanup flags + walCleanupCmd.Flags().StringVar(&walArchiveDir, "archive-dir", "/var/backups/wal_archive", "WAL archive directory") + walCleanupCmd.Flags().IntVar(&cfg.RetentionDays, "retention-days", 7, "Days to keep WAL archives") +} + +// Command implementations + +func runPITREnable(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + if !cfg.IsPostgreSQL() { + return fmt.Errorf("PITR is only supported for PostgreSQL (detected: %s)", cfg.DisplayDatabaseType()) + } + + log.Info("Enabling Point-in-Time Recovery (PITR)", "archive_dir", pitrArchiveDir) + + pitrManager := wal.NewPITRManager(cfg, log) + if err := pitrManager.EnablePITR(ctx, pitrArchiveDir); err != nil { + return fmt.Errorf("failed to enable PITR: %w", err) + } + + log.Info("✅ PITR enabled successfully!") + log.Info("") + log.Info("Next steps:") + log.Info("1. Restart PostgreSQL: sudo systemctl restart postgresql") + log.Info("2. Create a base backup: dbbackup backup single ") + log.Info("3. WAL files will be automatically archived to: " + pitrArchiveDir) + log.Info("") + log.Info("To restore to a point in time, use:") + log.Info(" dbbackup restore pitr --target-time '2024-01-15 14:30:00'") + + return nil +} + +func runPITRDisable(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + if !cfg.IsPostgreSQL() { + return fmt.Errorf("PITR is only supported for PostgreSQL") + } + + log.Info("Disabling Point-in-Time Recovery (PITR)") + + pitrManager := wal.NewPITRManager(cfg, log) + if err := pitrManager.DisablePITR(ctx); err != nil { + return fmt.Errorf("failed to disable PITR: %w", err) + } + + log.Info("✅ PITR disabled successfully!") + log.Info("PostgreSQL restart required: sudo systemctl restart postgresql") + + return nil +} + +func runPITRStatus(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + if !cfg.IsPostgreSQL() { + return fmt.Errorf("PITR is only supported for PostgreSQL") + } + + pitrManager := wal.NewPITRManager(cfg, log) + config, err := pitrManager.GetCurrentPITRConfig(ctx) + if err != nil { + return fmt.Errorf("failed to get PITR configuration: %w", err) + } + + // Display PITR configuration + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Println(" Point-in-Time Recovery (PITR) Status") + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Println() + + if config.Enabled { + fmt.Println("Status: ✅ ENABLED") + } else { + fmt.Println("Status: ❌ DISABLED") + } + + fmt.Printf("WAL Level: %s\n", config.WALLevel) + fmt.Printf("Archive Mode: %s\n", config.ArchiveMode) + fmt.Printf("Archive Command: %s\n", config.ArchiveCommand) + + if config.MaxWALSenders > 0 { + fmt.Printf("Max WAL Senders: %d\n", config.MaxWALSenders) + } + if config.WALKeepSize != "" { + fmt.Printf("WAL Keep Size: %s\n", config.WALKeepSize) + } + + // Show WAL archive statistics if archive directory can be determined + if config.ArchiveCommand != "" { + // Extract archive dir from command (simple parsing) + fmt.Println() + fmt.Println("WAL Archive Statistics:") + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + // TODO: Parse archive dir and show stats + fmt.Println(" (Use 'dbbackup wal list --archive-dir ' to view archives)") + } + + return nil +} + +func runWALArchive(cmd *cobra.Command, args []string) error { + ctx := context.Background() + walPath := args[0] + walFilename := args[1] + + archiver := wal.NewArchiver(cfg, log) + archiveConfig := wal.ArchiveConfig{ + ArchiveDir: walArchiveDir, + CompressWAL: walCompress, + EncryptWAL: walEncrypt, + } + + info, err := archiver.ArchiveWALFile(ctx, walPath, walFilename, archiveConfig) + if err != nil { + return fmt.Errorf("WAL archiving failed: %w", err) + } + + log.Info("WAL file archived successfully", + "wal", info.WALFileName, + "archive", info.ArchivePath, + "original_size", info.OriginalSize, + "archived_size", info.ArchivedSize, + "timeline", info.Timeline, + "segment", info.Segment) + + return nil +} + +func runWALList(cmd *cobra.Command, args []string) error { + archiver := wal.NewArchiver(cfg, log) + archiveConfig := wal.ArchiveConfig{ + ArchiveDir: walArchiveDir, + } + + archives, err := archiver.ListArchivedWALFiles(archiveConfig) + if err != nil { + return fmt.Errorf("failed to list WAL archives: %w", err) + } + + if len(archives) == 0 { + fmt.Println("No WAL archives found in: " + walArchiveDir) + return nil + } + + // Display archives + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Printf(" WAL Archives (%d files)\n", len(archives)) + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + fmt.Println() + + fmt.Printf("%-28s %10s %10s %8s %s\n", "WAL Filename", "Timeline", "Segment", "Size", "Archived At") + fmt.Println("────────────────────────────────────────────────────────────────────────────────") + + for _, archive := range archives { + size := formatWALSize(archive.ArchivedSize) + timeStr := archive.ArchivedAt.Format("2006-01-02 15:04") + + flags := "" + if archive.Compressed { + flags += "C" + } + if archive.Encrypted { + flags += "E" + } + if flags != "" { + flags = " [" + flags + "]" + } + + fmt.Printf("%-28s %10d 0x%08X %8s %s%s\n", + archive.WALFileName, + archive.Timeline, + archive.Segment, + size, + timeStr, + flags) + } + + // Show statistics + stats, _ := archiver.GetArchiveStats(archiveConfig) + if stats != nil { + fmt.Println() + fmt.Printf("Total Size: %s\n", stats.FormatSize()) + if stats.CompressedFiles > 0 { + fmt.Printf("Compressed: %d files\n", stats.CompressedFiles) + } + if stats.EncryptedFiles > 0 { + fmt.Printf("Encrypted: %d files\n", stats.EncryptedFiles) + } + if !stats.OldestArchive.IsZero() { + fmt.Printf("Oldest: %s\n", stats.OldestArchive.Format("2006-01-02 15:04")) + fmt.Printf("Newest: %s\n", stats.NewestArchive.Format("2006-01-02 15:04")) + } + } + + return nil +} + +func runWALCleanup(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + archiver := wal.NewArchiver(cfg, log) + archiveConfig := wal.ArchiveConfig{ + ArchiveDir: walArchiveDir, + RetentionDays: cfg.RetentionDays, + } + + if archiveConfig.RetentionDays <= 0 { + return fmt.Errorf("--retention-days must be greater than 0") + } + + deleted, err := archiver.CleanupOldWALFiles(ctx, archiveConfig) + if err != nil { + return fmt.Errorf("WAL cleanup failed: %w", err) + } + + log.Info("✅ WAL cleanup completed", "deleted", deleted, "retention_days", archiveConfig.RetentionDays) + return nil +} + +// Helper functions + +func formatWALSize(bytes int64) string { + const ( + KB = 1024 + MB = 1024 * KB + ) + + if bytes >= MB { + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB)) + } + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB)) +} diff --git a/internal/config/config.go b/internal/config/config.go index 0cccf23..93c1c3d 100755 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -76,6 +76,12 @@ type Config struct { AllowRoot bool // Allow running as root/Administrator CheckResources bool // Check resource limits before operations + // PITR (Point-in-Time Recovery) options + PITREnabled bool // Enable WAL archiving for PITR + WALArchiveDir string // Directory to store WAL archives + WALCompression bool // Compress WAL files + WALEncryption bool // Encrypt WAL files + // TUI automation options (for testing) TUIAutoSelect int // Auto-select menu option (-1 = disabled) TUIAutoDatabase string // Pre-fill database name diff --git a/internal/wal/archiver.go b/internal/wal/archiver.go new file mode 100644 index 0000000..85a674e --- /dev/null +++ b/internal/wal/archiver.go @@ -0,0 +1,324 @@ +package wal + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "dbbackup/internal/config" + "dbbackup/internal/logger" +) + +// Archiver handles PostgreSQL Write-Ahead Log (WAL) archiving for PITR +type Archiver struct { + cfg *config.Config + log logger.Logger +} + +// ArchiveConfig holds WAL archiving configuration +type ArchiveConfig struct { + ArchiveDir string // Directory to store archived WAL files + CompressWAL bool // Compress WAL files with gzip + EncryptWAL bool // Encrypt WAL files + RetentionDays int // Days to keep WAL archives + VerifyChecksum bool // Verify WAL file checksums +} + +// WALArchiveInfo contains metadata about an archived WAL file +type WALArchiveInfo struct { + WALFileName string `json:"wal_filename"` + ArchivePath string `json:"archive_path"` + OriginalSize int64 `json:"original_size"` + ArchivedSize int64 `json:"archived_size"` + Checksum string `json:"checksum"` + Timeline uint32 `json:"timeline"` + Segment uint64 `json:"segment"` + ArchivedAt time.Time `json:"archived_at"` + Compressed bool `json:"compressed"` + Encrypted bool `json:"encrypted"` +} + +// NewArchiver creates a new WAL archiver +func NewArchiver(cfg *config.Config, log logger.Logger) *Archiver { + return &Archiver{ + cfg: cfg, + log: log, + } +} + +// ArchiveWALFile archives a single WAL file to the archive directory +// This is called by PostgreSQL's archive_command +func (a *Archiver) ArchiveWALFile(ctx context.Context, walFilePath, walFileName string, config ArchiveConfig) (*WALArchiveInfo, error) { + a.log.Info("Archiving WAL file", "wal", walFileName, "source", walFilePath) + + // Validate WAL file exists + stat, err := os.Stat(walFilePath) + if err != nil { + return nil, fmt.Errorf("WAL file not found: %s: %w", walFilePath, err) + } + + // Ensure archive directory exists + if err := os.MkdirAll(config.ArchiveDir, 0700); err != nil { + return nil, fmt.Errorf("failed to create WAL archive directory %s: %w", config.ArchiveDir, err) + } + + // Parse WAL filename to extract timeline and segment + timeline, segment, err := ParseWALFileName(walFileName) + if err != nil { + a.log.Warn("Could not parse WAL filename (continuing anyway)", "file", walFileName, "error", err) + timeline, segment = 0, 0 // Use defaults for non-standard names + } + + // Determine target archive path + archivePath := filepath.Join(config.ArchiveDir, walFileName) + if config.CompressWAL { + archivePath += ".gz" + } + if config.EncryptWAL { + archivePath += ".enc" + } + + // Copy WAL file to archive + srcFile, err := os.Open(walFilePath) + if err != nil { + return nil, fmt.Errorf("failed to open WAL file %s: %w", walFilePath, err) + } + defer srcFile.Close() + + dstFile, err := os.OpenFile(archivePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return nil, fmt.Errorf("failed to create archive file %s: %w", archivePath, err) + } + defer dstFile.Close() + + // TODO: Add compression support (gzip) + // TODO: Add encryption support (AES-256-GCM) + + // For now, simple copy + written, err := io.Copy(dstFile, srcFile) + if err != nil { + return nil, fmt.Errorf("failed to copy WAL file to archive: %w", err) + } + + if written != stat.Size() { + return nil, fmt.Errorf("incomplete WAL copy: wrote %d bytes, expected %d", written, stat.Size()) + } + + // Sync to disk to ensure durability + if err := dstFile.Sync(); err != nil { + return nil, fmt.Errorf("failed to sync WAL archive to disk: %w", err) + } + + // Verify archive was created successfully + archiveStat, err := os.Stat(archivePath) + if err != nil { + return nil, fmt.Errorf("failed to verify archived WAL file: %w", err) + } + + info := &WALArchiveInfo{ + WALFileName: walFileName, + ArchivePath: archivePath, + OriginalSize: stat.Size(), + ArchivedSize: archiveStat.Size(), + Timeline: timeline, + Segment: segment, + ArchivedAt: time.Now(), + Compressed: config.CompressWAL, + Encrypted: config.EncryptWAL, + } + + a.log.Info("WAL file archived successfully", + "wal", walFileName, + "archive", archivePath, + "size", stat.Size(), + "timeline", timeline, + "segment", segment) + + return info, nil +} + +// ParseWALFileName extracts timeline and segment number from WAL filename +// WAL filename format: 000000010000000000000001 +// - First 8 hex digits: timeline ID +// - Next 8 hex digits: log file ID +// - Last 8 hex digits: segment number +func ParseWALFileName(filename string) (timeline uint32, segment uint64, err error) { + // Remove any extensions (.gz, .enc, etc.) + base := filepath.Base(filename) + base = strings.TrimSuffix(base, ".gz") + base = strings.TrimSuffix(base, ".enc") + + // WAL files are 24 hex characters + if len(base) != 24 { + return 0, 0, fmt.Errorf("invalid WAL filename length: expected 24 characters, got %d", len(base)) + } + + // Parse timeline (first 8 chars) + _, err = fmt.Sscanf(base[0:8], "%08X", &timeline) + if err != nil { + return 0, 0, fmt.Errorf("failed to parse timeline from WAL filename: %w", err) + } + + // Parse segment (last 16 chars as combined log file + segment) + _, err = fmt.Sscanf(base[8:24], "%016X", &segment) + if err != nil { + return 0, 0, fmt.Errorf("failed to parse segment from WAL filename: %w", err) + } + + return timeline, segment, nil +} + +// ListArchivedWALFiles returns all WAL files in the archive directory +func (a *Archiver) ListArchivedWALFiles(config ArchiveConfig) ([]WALArchiveInfo, error) { + entries, err := os.ReadDir(config.ArchiveDir) + if err != nil { + if os.IsNotExist(err) { + return []WALArchiveInfo{}, nil // Empty archive is valid + } + return nil, fmt.Errorf("failed to read WAL archive directory: %w", err) + } + + var archives []WALArchiveInfo + for _, entry := range entries { + if entry.IsDir() { + continue + } + + filename := entry.Name() + // Skip non-WAL files (must be 24 hex chars possibly with .gz/.enc extensions) + baseName := strings.TrimSuffix(strings.TrimSuffix(filename, ".gz"), ".enc") + if len(baseName) != 24 { + continue + } + + timeline, segment, err := ParseWALFileName(filename) + if err != nil { + a.log.Warn("Skipping invalid WAL file", "file", filename, "error", err) + continue + } + + info, err := entry.Info() + if err != nil { + a.log.Warn("Could not stat WAL file", "file", filename, "error", err) + continue + } + + archives = append(archives, WALArchiveInfo{ + WALFileName: baseName, + ArchivePath: filepath.Join(config.ArchiveDir, filename), + ArchivedSize: info.Size(), + Timeline: timeline, + Segment: segment, + ArchivedAt: info.ModTime(), + Compressed: strings.HasSuffix(filename, ".gz"), + Encrypted: strings.HasSuffix(filename, ".enc"), + }) + } + + return archives, nil +} + +// CleanupOldWALFiles removes WAL archives older than retention period +func (a *Archiver) CleanupOldWALFiles(ctx context.Context, config ArchiveConfig) (int, error) { + if config.RetentionDays <= 0 { + return 0, nil // No cleanup if retention not set + } + + cutoffTime := time.Now().AddDate(0, 0, -config.RetentionDays) + a.log.Info("Cleaning up WAL archives", "older_than", cutoffTime.Format("2006-01-02"), "retention_days", config.RetentionDays) + + archives, err := a.ListArchivedWALFiles(config) + if err != nil { + return 0, fmt.Errorf("failed to list WAL archives: %w", err) + } + + deleted := 0 + for _, archive := range archives { + if archive.ArchivedAt.Before(cutoffTime) { + a.log.Debug("Removing old WAL archive", "file", archive.WALFileName, "archived_at", archive.ArchivedAt) + if err := os.Remove(archive.ArchivePath); err != nil { + a.log.Warn("Failed to remove old WAL archive", "file", archive.ArchivePath, "error", err) + continue + } + deleted++ + } + } + + a.log.Info("WAL cleanup completed", "deleted", deleted, "total_archives", len(archives)) + return deleted, nil +} + +// GetArchiveStats returns statistics about WAL archives +func (a *Archiver) GetArchiveStats(config ArchiveConfig) (*ArchiveStats, error) { + archives, err := a.ListArchivedWALFiles(config) + if err != nil { + return nil, err + } + + stats := &ArchiveStats{ + TotalFiles: len(archives), + CompressedFiles: 0, + EncryptedFiles: 0, + TotalSize: 0, + } + + if len(archives) > 0 { + stats.OldestArchive = archives[0].ArchivedAt + stats.NewestArchive = archives[0].ArchivedAt + } + + for _, archive := range archives { + stats.TotalSize += archive.ArchivedSize + + if archive.Compressed { + stats.CompressedFiles++ + } + if archive.Encrypted { + stats.EncryptedFiles++ + } + + if archive.ArchivedAt.Before(stats.OldestArchive) { + stats.OldestArchive = archive.ArchivedAt + } + if archive.ArchivedAt.After(stats.NewestArchive) { + stats.NewestArchive = archive.ArchivedAt + } + } + + return stats, nil +} + +// ArchiveStats contains statistics about WAL archives +type ArchiveStats struct { + TotalFiles int `json:"total_files"` + CompressedFiles int `json:"compressed_files"` + EncryptedFiles int `json:"encrypted_files"` + TotalSize int64 `json:"total_size"` + OldestArchive time.Time `json:"oldest_archive"` + NewestArchive time.Time `json:"newest_archive"` +} + +// FormatSize returns human-readable size +func (s *ArchiveStats) FormatSize() string { + const ( + KB = 1024 + MB = 1024 * KB + GB = 1024 * MB + ) + + size := float64(s.TotalSize) + switch { + case size >= GB: + return fmt.Sprintf("%.2f GB", size/GB) + case size >= MB: + return fmt.Sprintf("%.2f MB", size/MB) + case size >= KB: + return fmt.Sprintf("%.2f KB", size/KB) + default: + return fmt.Sprintf("%d B", s.TotalSize) + } +} diff --git a/internal/wal/pitr_config.go b/internal/wal/pitr_config.go new file mode 100644 index 0000000..175457d --- /dev/null +++ b/internal/wal/pitr_config.go @@ -0,0 +1,386 @@ +package wal + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "dbbackup/internal/config" + "dbbackup/internal/logger" +) + +// PITRManager manages Point-in-Time Recovery configuration +type PITRManager struct { + cfg *config.Config + log logger.Logger +} + +// PITRConfig holds PITR settings +type PITRConfig struct { + Enabled bool + ArchiveMode string // "on", "off", "always" + ArchiveCommand string + ArchiveDir string + WALLevel string // "minimal", "replica", "logical" + MaxWALSenders int + WALKeepSize string // e.g., "1GB" + RestoreCommand string +} + +// RecoveryTarget specifies the point-in-time to recover to +type RecoveryTarget struct { + TargetTime *time.Time // Recover to specific timestamp + TargetXID string // Recover to transaction ID + TargetName string // Recover to named restore point + TargetLSN string // Recover to Log Sequence Number + TargetImmediate bool // Recover as soon as consistent state is reached + TargetInclusive bool // Include target transaction + RecoveryEndAction string // "pause", "promote", "shutdown" +} + +// NewPITRManager creates a new PITR manager +func NewPITRManager(cfg *config.Config, log logger.Logger) *PITRManager { + return &PITRManager{ + cfg: cfg, + log: log, + } +} + +// EnablePITR configures PostgreSQL for PITR by modifying postgresql.conf +func (pm *PITRManager) EnablePITR(ctx context.Context, archiveDir string) error { + pm.log.Info("Enabling PITR (Point-in-Time Recovery)", "archive_dir", archiveDir) + + // Ensure archive directory exists + if err := os.MkdirAll(archiveDir, 0700); err != nil { + return fmt.Errorf("failed to create WAL archive directory: %w", err) + } + + // Find postgresql.conf location + confPath, err := pm.findPostgreSQLConf(ctx) + if err != nil { + return fmt.Errorf("failed to locate postgresql.conf: %w", err) + } + + pm.log.Info("Found PostgreSQL configuration", "path", confPath) + + // Backup original configuration + backupPath := confPath + ".backup." + time.Now().Format("20060102_150405") + if err := pm.backupFile(confPath, backupPath); err != nil { + return fmt.Errorf("failed to backup postgresql.conf: %w", err) + } + pm.log.Info("Created configuration backup", "backup", backupPath) + + // Get absolute path to dbbackup binary + dbbackupPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get dbbackup executable path: %w", err) + } + + // Build archive command that calls dbbackup + archiveCommand := fmt.Sprintf("%s wal archive %%p %%f --archive-dir %s", dbbackupPath, archiveDir) + + // Settings to enable PITR + settings := map[string]string{ + "wal_level": "replica", // Required for PITR + "archive_mode": "on", + "archive_command": archiveCommand, + "max_wal_senders": "3", + "wal_keep_size": "1GB", // Keep at least 1GB of WAL + } + + // Update postgresql.conf + if err := pm.updatePostgreSQLConf(confPath, settings); err != nil { + return fmt.Errorf("failed to update postgresql.conf: %w", err) + } + + pm.log.Info("✅ PITR configuration updated successfully") + pm.log.Warn("⚠️ PostgreSQL restart required for changes to take effect") + pm.log.Info("To restart PostgreSQL:") + pm.log.Info(" sudo systemctl restart postgresql") + pm.log.Info(" OR: sudo pg_ctlcluster restart") + + return nil +} + +// DisablePITR disables PITR by setting archive_mode = off +func (pm *PITRManager) DisablePITR(ctx context.Context) error { + pm.log.Info("Disabling PITR") + + confPath, err := pm.findPostgreSQLConf(ctx) + if err != nil { + return fmt.Errorf("failed to locate postgresql.conf: %w", err) + } + + // Backup configuration + backupPath := confPath + ".backup." + time.Now().Format("20060102_150405") + if err := pm.backupFile(confPath, backupPath); err != nil { + return fmt.Errorf("failed to backup postgresql.conf: %w", err) + } + + settings := map[string]string{ + "archive_mode": "off", + "archive_command": "", // Clear command + } + + if err := pm.updatePostgreSQLConf(confPath, settings); err != nil { + return fmt.Errorf("failed to update postgresql.conf: %w", err) + } + + pm.log.Info("✅ PITR disabled successfully") + pm.log.Warn("⚠️ PostgreSQL restart required") + + return nil +} + +// GetCurrentPITRConfig reads current PITR settings from PostgreSQL +func (pm *PITRManager) GetCurrentPITRConfig(ctx context.Context) (*PITRConfig, error) { + confPath, err := pm.findPostgreSQLConf(ctx) + if err != nil { + return nil, err + } + + file, err := os.Open(confPath) + if err != nil { + return nil, fmt.Errorf("failed to open postgresql.conf: %w", err) + } + defer file.Close() + + config := &PITRConfig{} + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip comments and empty lines + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Parse key = value + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.Trim(strings.TrimSpace(parts[1]), "'\"") + + switch key { + case "wal_level": + config.WALLevel = value + case "archive_mode": + config.ArchiveMode = value + config.Enabled = (value == "on" || value == "always") + case "archive_command": + config.ArchiveCommand = value + case "max_wal_senders": + fmt.Sscanf(value, "%d", &config.MaxWALSenders) + case "wal_keep_size": + config.WALKeepSize = value + case "restore_command": + config.RestoreCommand = value + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading postgresql.conf: %w", err) + } + + return config, nil +} + +// CreateRecoveryConf creates recovery configuration for PITR restore +// PostgreSQL 12+: Creates recovery.signal and modifies postgresql.conf +// PostgreSQL <12: Creates recovery.conf +func (pm *PITRManager) CreateRecoveryConf(ctx context.Context, dataDir string, target RecoveryTarget, walArchiveDir string) error { + pm.log.Info("Creating recovery configuration", "data_dir", dataDir) + + // Detect PostgreSQL version to determine recovery file format + version, err := pm.getPostgreSQLVersion(ctx) + if err != nil { + pm.log.Warn("Could not detect PostgreSQL version, assuming >= 12", "error", err) + version = 12 // Default to newer format + } + + if version >= 12 { + return pm.createRecoverySignal(ctx, dataDir, target, walArchiveDir) + } else { + return pm.createLegacyRecoveryConf(dataDir, target, walArchiveDir) + } +} + +// createRecoverySignal creates recovery.signal for PostgreSQL 12+ +func (pm *PITRManager) createRecoverySignal(ctx context.Context, dataDir string, target RecoveryTarget, walArchiveDir string) error { + // Create recovery.signal file (empty file that triggers recovery mode) + signalPath := filepath.Join(dataDir, "recovery.signal") + if err := os.WriteFile(signalPath, []byte{}, 0600); err != nil { + return fmt.Errorf("failed to create recovery.signal: %w", err) + } + pm.log.Info("Created recovery.signal", "path", signalPath) + + // Recovery settings go in postgresql.auto.conf (PostgreSQL 12+) + autoConfPath := filepath.Join(dataDir, "postgresql.auto.conf") + + // Build recovery settings + var settings []string + settings = append(settings, fmt.Sprintf("restore_command = 'cp %s/%%f %%p'", walArchiveDir)) + + if target.TargetTime != nil { + settings = append(settings, fmt.Sprintf("recovery_target_time = '%s'", target.TargetTime.Format("2006-01-02 15:04:05"))) + } else if target.TargetXID != "" { + settings = append(settings, fmt.Sprintf("recovery_target_xid = '%s'", target.TargetXID)) + } else if target.TargetName != "" { + settings = append(settings, fmt.Sprintf("recovery_target_name = '%s'", target.TargetName)) + } else if target.TargetLSN != "" { + settings = append(settings, fmt.Sprintf("recovery_target_lsn = '%s'", target.TargetLSN)) + } else if target.TargetImmediate { + settings = append(settings, "recovery_target = 'immediate'") + } + + if target.RecoveryEndAction != "" { + settings = append(settings, fmt.Sprintf("recovery_target_action = '%s'", target.RecoveryEndAction)) + } + + // Append to postgresql.auto.conf + f, err := os.OpenFile(autoConfPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("failed to open postgresql.auto.conf: %w", err) + } + defer f.Close() + + if _, err := f.WriteString("\n# PITR Recovery Configuration (added by dbbackup)\n"); err != nil { + return err + } + for _, setting := range settings { + if _, err := f.WriteString(setting + "\n"); err != nil { + return err + } + } + + pm.log.Info("Recovery configuration added to postgresql.auto.conf", "path", autoConfPath) + return nil +} + +// createLegacyRecoveryConf creates recovery.conf for PostgreSQL < 12 +func (pm *PITRManager) createLegacyRecoveryConf(dataDir string, target RecoveryTarget, walArchiveDir string) error { + recoveryConfPath := filepath.Join(dataDir, "recovery.conf") + + var content strings.Builder + content.WriteString("# Recovery Configuration (created by dbbackup)\n") + content.WriteString(fmt.Sprintf("restore_command = 'cp %s/%%f %%p'\n", walArchiveDir)) + + if target.TargetTime != nil { + content.WriteString(fmt.Sprintf("recovery_target_time = '%s'\n", target.TargetTime.Format("2006-01-02 15:04:05"))) + } + // Add other target types... + + if err := os.WriteFile(recoveryConfPath, []byte(content.String()), 0600); err != nil { + return fmt.Errorf("failed to create recovery.conf: %w", err) + } + + pm.log.Info("Created recovery.conf", "path", recoveryConfPath) + return nil +} + +// Helper functions + +func (pm *PITRManager) findPostgreSQLConf(ctx context.Context) (string, error) { + // Try common locations + commonPaths := []string{ + "/var/lib/postgresql/data/postgresql.conf", + "/etc/postgresql/*/main/postgresql.conf", + "/usr/local/pgsql/data/postgresql.conf", + } + + for _, pattern := range commonPaths { + matches, _ := filepath.Glob(pattern) + for _, path := range matches { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + } + + // Try to get from PostgreSQL directly + cmd := exec.CommandContext(ctx, "psql", "-U", pm.cfg.User, "-t", "-c", "SHOW config_file") + output, err := cmd.Output() + if err == nil { + path := strings.TrimSpace(string(output)) + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + + return "", fmt.Errorf("could not locate postgresql.conf. Please specify --pg-conf-path") +} + +func (pm *PITRManager) backupFile(src, dst string) error { + input, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, input, 0644) +} + +func (pm *PITRManager) updatePostgreSQLConf(confPath string, settings map[string]string) error { + file, err := os.Open(confPath) + if err != nil { + return err + } + defer file.Close() + + var lines []string + existingKeys := make(map[string]bool) + scanner := bufio.NewScanner(file) + + // Read existing configuration and track which keys are already present + for scanner.Scan() { + line := scanner.Text() + lines = append(lines, line) + + // Check if this line sets one of our keys + for key := range settings { + if matched, _ := regexp.MatchString(fmt.Sprintf(`^\s*%s\s*=`, key), line); matched { + existingKeys[key] = true + } + } + } + + if err := scanner.Err(); err != nil { + return err + } + + // Append missing settings + for key, value := range settings { + if !existingKeys[key] { + if value == "" { + lines = append(lines, fmt.Sprintf("# %s = '' # Disabled by dbbackup", key)) + } else { + lines = append(lines, fmt.Sprintf("%s = '%s' # Added by dbbackup", key, value)) + } + } + } + + // Write updated configuration + output := strings.Join(lines, "\n") + "\n" + return os.WriteFile(confPath, []byte(output), 0644) +} + +func (pm *PITRManager) getPostgreSQLVersion(ctx context.Context) (int, error) { + cmd := exec.CommandContext(ctx, "psql", "-U", pm.cfg.User, "-t", "-c", "SHOW server_version") + output, err := cmd.Output() + if err != nil { + return 0, err + } + + versionStr := strings.TrimSpace(string(output)) + var major int + fmt.Sscanf(versionStr, "%d", &major) + return major, nil +}