package cmd import ( "context" "database/sql" "fmt" "os" "path/filepath" "time" "github.com/spf13/cobra" "dbbackup/internal/pitr" "dbbackup/internal/wal" ) var ( // PITR enable flags pitrArchiveDir string pitrForce bool // WAL archive flags walArchiveDir string walCompress bool walEncrypt bool walEncryptionKeyFile string walEncryptionKeyEnv string = "DBBACKUP_ENCRYPTION_KEY" // WAL cleanup flags walRetentionDays int // PITR restore flags pitrTargetTime string pitrTargetXID string pitrTargetName string pitrTargetLSN string pitrTargetImmediate bool pitrRecoveryAction string pitrWALSource string // MySQL PITR flags mysqlBinlogDir string mysqlArchiveDir string mysqlArchiveInterval string mysqlRequireRowFormat bool mysqlRequireGTID bool mysqlWatchMode bool ) // 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, } // walTimelineCmd shows timeline history var walTimelineCmd = &cobra.Command{ Use: "timeline", Short: "Show timeline branching history", Long: `Display PostgreSQL timeline history and branching structure. Timelines track recovery points and allow parallel recovery paths. A new timeline is created each time you perform point-in-time recovery. Shows: - Timeline hierarchy and parent relationships - Timeline switch points (LSN) - WAL segment ranges per timeline - Reason for timeline creation Example: dbbackup wal timeline --archive-dir /backups/wal_archive `, RunE: runWALTimeline, } // ============================================================================ // MySQL/MariaDB Binlog Commands // ============================================================================ // binlogCmd represents the binlog command group (MySQL equivalent of WAL) var binlogCmd = &cobra.Command{ Use: "binlog", Short: "Binary log operations for MySQL/MariaDB", Long: `Manage MySQL/MariaDB binary log files for Point-in-Time Recovery. Binary logs contain all changes made to the database and are essential for Point-in-Time Recovery (PITR) with MySQL and MariaDB. Commands: list - List available binlog files archive - Archive binlog files watch - Watch for new binlog files and archive them validate - Validate binlog chain integrity position - Show current binlog position `, } // binlogListCmd lists binary log files var binlogListCmd = &cobra.Command{ Use: "list", Short: "List binary log files", Long: `List all available binary log files from the MySQL data directory and/or the archive directory. Shows: filename, size, timestamps, server_id, and format for each binlog. Examples: dbbackup binlog list --binlog-dir /var/lib/mysql dbbackup binlog list --archive-dir /backups/binlog_archive `, RunE: runBinlogList, } // binlogArchiveCmd archives binary log files var binlogArchiveCmd = &cobra.Command{ Use: "archive", Short: "Archive binary log files", Long: `Archive MySQL binary log files to a backup location. This command copies completed binlog files (not the currently active one) to the archive directory, optionally with compression and encryption. Examples: dbbackup binlog archive --binlog-dir /var/lib/mysql --archive-dir /backups/binlog dbbackup binlog archive --compress --archive-dir /backups/binlog `, RunE: runBinlogArchive, } // binlogWatchCmd watches for new binlogs and archives them var binlogWatchCmd = &cobra.Command{ Use: "watch", Short: "Watch for new binlog files and archive them automatically", Long: `Continuously monitor the binlog directory for new files and archive them automatically when they are closed. This runs as a background process and provides continuous binlog archiving for PITR capability. Example: dbbackup binlog watch --binlog-dir /var/lib/mysql --archive-dir /backups/binlog --interval 30s `, RunE: runBinlogWatch, } // binlogValidateCmd validates binlog chain var binlogValidateCmd = &cobra.Command{ Use: "validate", Short: "Validate binlog chain integrity", Long: `Check the binary log chain for gaps or inconsistencies. Validates: - Sequential numbering of binlog files - No missing files in the chain - Server ID consistency - GTID continuity (if enabled) Example: dbbackup binlog validate --binlog-dir /var/lib/mysql dbbackup binlog validate --archive-dir /backups/binlog `, RunE: runBinlogValidate, } // binlogPositionCmd shows current binlog position var binlogPositionCmd = &cobra.Command{ Use: "position", Short: "Show current binary log position", Long: `Display the current MySQL binary log position. This connects to MySQL and runs SHOW MASTER STATUS to get: - Current binlog filename - Current byte position - Executed GTID set (if GTID mode is enabled) Example: dbbackup binlog position `, RunE: runBinlogPosition, } // mysqlPitrStatusCmd shows MySQL-specific PITR status var mysqlPitrStatusCmd = &cobra.Command{ Use: "mysql-status", Short: "Show MySQL/MariaDB PITR status", Long: `Display MySQL/MariaDB-specific PITR configuration and status. Shows: - Binary log configuration (log_bin, binlog_format) - GTID mode status - Archive directory and statistics - Current binlog position - Recovery windows available Example: dbbackup pitr mysql-status `, RunE: runMySQLPITRStatus, } // mysqlPitrEnableCmd enables MySQL PITR var mysqlPitrEnableCmd = &cobra.Command{ Use: "mysql-enable", Short: "Enable PITR for MySQL/MariaDB", Long: `Configure MySQL/MariaDB for Point-in-Time Recovery. This validates MySQL settings and sets up binlog archiving: - Checks binary logging is enabled (log_bin=ON) - Validates binlog_format (ROW recommended) - Creates archive directory - Saves PITR configuration Prerequisites in my.cnf: [mysqld] log_bin = mysql-bin binlog_format = ROW server_id = 1 Example: dbbackup pitr mysql-enable --archive-dir /backups/binlog_archive `, RunE: runMySQLPITREnable, } func init() { rootCmd.AddCommand(pitrCmd) rootCmd.AddCommand(walCmd) rootCmd.AddCommand(binlogCmd) // PITR subcommands pitrCmd.AddCommand(pitrEnableCmd) pitrCmd.AddCommand(pitrDisableCmd) pitrCmd.AddCommand(pitrStatusCmd) pitrCmd.AddCommand(mysqlPitrStatusCmd) pitrCmd.AddCommand(mysqlPitrEnableCmd) // WAL subcommands (PostgreSQL) walCmd.AddCommand(walArchiveCmd) walCmd.AddCommand(walListCmd) walCmd.AddCommand(walCleanupCmd) walCmd.AddCommand(walTimelineCmd) // Binlog subcommands (MySQL/MariaDB) binlogCmd.AddCommand(binlogListCmd) binlogCmd.AddCommand(binlogArchiveCmd) binlogCmd.AddCommand(binlogWatchCmd) binlogCmd.AddCommand(binlogValidateCmd) binlogCmd.AddCommand(binlogPositionCmd) // 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.Flags().StringVar(&walEncryptionKeyFile, "encryption-key-file", "", "Path to encryption key file (32 bytes)") walArchiveCmd.Flags().StringVar(&walEncryptionKeyEnv, "encryption-key-env", "DBBACKUP_ENCRYPTION_KEY", "Environment variable containing encryption key") 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(&walRetentionDays, "retention-days", 7, "Days to keep WAL archives") // WAL timeline flags walTimelineCmd.Flags().StringVar(&walArchiveDir, "archive-dir", "/var/backups/wal_archive", "WAL archive directory") // MySQL binlog flags binlogListCmd.Flags().StringVar(&mysqlBinlogDir, "binlog-dir", "/var/lib/mysql", "MySQL binary log directory") binlogListCmd.Flags().StringVar(&mysqlArchiveDir, "archive-dir", "", "Binlog archive directory") binlogArchiveCmd.Flags().StringVar(&mysqlBinlogDir, "binlog-dir", "/var/lib/mysql", "MySQL binary log directory") binlogArchiveCmd.Flags().StringVar(&mysqlArchiveDir, "archive-dir", "/var/backups/binlog_archive", "Binlog archive directory") binlogArchiveCmd.Flags().BoolVar(&walCompress, "compress", false, "Compress binlog files") binlogArchiveCmd.Flags().BoolVar(&walEncrypt, "encrypt", false, "Encrypt binlog files") binlogArchiveCmd.Flags().StringVar(&walEncryptionKeyFile, "encryption-key-file", "", "Path to encryption key file") binlogArchiveCmd.MarkFlagRequired("archive-dir") binlogWatchCmd.Flags().StringVar(&mysqlBinlogDir, "binlog-dir", "/var/lib/mysql", "MySQL binary log directory") binlogWatchCmd.Flags().StringVar(&mysqlArchiveDir, "archive-dir", "/var/backups/binlog_archive", "Binlog archive directory") binlogWatchCmd.Flags().StringVar(&mysqlArchiveInterval, "interval", "30s", "Check interval for new binlogs") binlogWatchCmd.Flags().BoolVar(&walCompress, "compress", false, "Compress binlog files") binlogWatchCmd.MarkFlagRequired("archive-dir") binlogValidateCmd.Flags().StringVar(&mysqlBinlogDir, "binlog-dir", "/var/lib/mysql", "MySQL binary log directory") binlogValidateCmd.Flags().StringVar(&mysqlArchiveDir, "archive-dir", "", "Binlog archive directory") // MySQL PITR enable flags mysqlPitrEnableCmd.Flags().StringVar(&mysqlArchiveDir, "archive-dir", "/var/backups/binlog_archive", "Binlog archive directory") mysqlPitrEnableCmd.Flags().IntVar(&walRetentionDays, "retention-days", 7, "Days to keep archived binlogs") mysqlPitrEnableCmd.Flags().BoolVar(&mysqlRequireRowFormat, "require-row-format", true, "Require ROW binlog format") mysqlPitrEnableCmd.Flags().BoolVar(&mysqlRequireGTID, "require-gtid", false, "Require GTID mode enabled") mysqlPitrEnableCmd.MarkFlagRequired("archive-dir") } // 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("[OK] 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("[OK] 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: [OK] ENABLED") } else { fmt.Println("Status: [FAIL] 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] // Load encryption key if encryption is enabled var encryptionKey []byte if walEncrypt { key, err := loadEncryptionKey(walEncryptionKeyFile, walEncryptionKeyEnv) if err != nil { return fmt.Errorf("failed to load WAL encryption key: %w", err) } encryptionKey = key } archiver := wal.NewArchiver(cfg, log) archiveConfig := wal.ArchiveConfig{ ArchiveDir: walArchiveDir, CompressWAL: walCompress, EncryptWAL: walEncrypt, EncryptionKey: encryptionKey, } 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: walRetentionDays, } 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("[OK] WAL cleanup completed", "deleted", deleted, "retention_days", archiveConfig.RetentionDays) return nil } func runWALTimeline(cmd *cobra.Command, args []string) error { ctx := context.Background() // Create timeline manager tm := wal.NewTimelineManager(log) // Parse timeline history history, err := tm.ParseTimelineHistory(ctx, walArchiveDir) if err != nil { return fmt.Errorf("failed to parse timeline history: %w", err) } // Validate consistency if err := tm.ValidateTimelineConsistency(ctx, history); err != nil { log.Warn("Timeline consistency issues detected", "error", err) } // Display timeline tree fmt.Println(tm.FormatTimelineTree(history)) // Display timeline details if len(history.Timelines) > 0 { fmt.Println("\nTimeline Details:") fmt.Println("=================") for _, tl := range history.Timelines { fmt.Printf("\nTimeline %d:\n", tl.TimelineID) if tl.ParentTimeline > 0 { fmt.Printf(" Parent: Timeline %d\n", tl.ParentTimeline) fmt.Printf(" Switch LSN: %s\n", tl.SwitchPoint) } if tl.Reason != "" { fmt.Printf(" Reason: %s\n", tl.Reason) } if tl.FirstWALSegment > 0 { fmt.Printf(" WAL Range: 0x%016X - 0x%016X\n", tl.FirstWALSegment, tl.LastWALSegment) segmentCount := tl.LastWALSegment - tl.FirstWALSegment + 1 fmt.Printf(" Segments: %d files (~%d MB)\n", segmentCount, segmentCount*16) } if !tl.CreatedAt.IsZero() { fmt.Printf(" Created: %s\n", tl.CreatedAt.Format("2006-01-02 15:04:05")) } if tl.TimelineID == history.CurrentTimeline { fmt.Printf(" Status: [CURR] CURRENT\n") } } } 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)) } // ============================================================================ // MySQL/MariaDB Binlog Command Implementations // ============================================================================ func runBinlogList(cmd *cobra.Command, args []string) error { ctx := context.Background() if !cfg.IsMySQL() { return fmt.Errorf("binlog commands are only supported for MySQL/MariaDB (detected: %s)", cfg.DisplayDatabaseType()) } binlogDir := mysqlBinlogDir if binlogDir == "" && mysqlArchiveDir != "" { binlogDir = mysqlArchiveDir } if binlogDir == "" { return fmt.Errorf("please specify --binlog-dir or --archive-dir") } bmConfig := pitr.BinlogManagerConfig{ BinlogDir: binlogDir, ArchiveDir: mysqlArchiveDir, } bm, err := pitr.NewBinlogManager(bmConfig) if err != nil { return fmt.Errorf("initializing binlog manager: %w", err) } // List binlogs from source directory binlogs, err := bm.DiscoverBinlogs(ctx) if err != nil { return fmt.Errorf("discovering binlogs: %w", err) } // Also list archived binlogs if archive dir is specified var archived []pitr.BinlogArchiveInfo if mysqlArchiveDir != "" { archived, _ = bm.ListArchivedBinlogs(ctx) } if len(binlogs) == 0 && len(archived) == 0 { fmt.Println("No binary log files found") return nil } fmt.Println("=============================================================") fmt.Printf(" Binary Log Files (%s)\n", bm.ServerType()) fmt.Println("=============================================================") fmt.Println() if len(binlogs) > 0 { fmt.Println("Source Directory:") fmt.Printf("%-24s %10s %-19s %-19s %s\n", "Filename", "Size", "Start Time", "End Time", "Format") fmt.Println("--------------------------------------------------------------------------------") var totalSize int64 for _, b := range binlogs { size := formatWALSize(b.Size) totalSize += b.Size startTime := "unknown" endTime := "unknown" if !b.StartTime.IsZero() { startTime = b.StartTime.Format("2006-01-02 15:04:05") } if !b.EndTime.IsZero() { endTime = b.EndTime.Format("2006-01-02 15:04:05") } format := b.Format if format == "" { format = "-" } fmt.Printf("%-24s %10s %-19s %-19s %s\n", b.Name, size, startTime, endTime, format) } fmt.Printf("\nTotal: %d files, %s\n", len(binlogs), formatWALSize(totalSize)) } if len(archived) > 0 { fmt.Println() fmt.Println("Archived Binlogs:") fmt.Printf("%-24s %10s %-19s %s\n", "Original", "Size", "Archived At", "Flags") fmt.Println("--------------------------------------------------------------------------------") var totalSize int64 for _, a := range archived { size := formatWALSize(a.Size) totalSize += a.Size archivedTime := a.ArchivedAt.Format("2006-01-02 15:04:05") flags := "" if a.Compressed { flags += "C" } if a.Encrypted { flags += "E" } if flags != "" { flags = "[" + flags + "]" } fmt.Printf("%-24s %10s %-19s %s\n", a.OriginalFile, size, archivedTime, flags) } fmt.Printf("\nTotal archived: %d files, %s\n", len(archived), formatWALSize(totalSize)) } return nil } func runBinlogArchive(cmd *cobra.Command, args []string) error { ctx := context.Background() if !cfg.IsMySQL() { return fmt.Errorf("binlog commands are only supported for MySQL/MariaDB") } if mysqlBinlogDir == "" { return fmt.Errorf("--binlog-dir is required") } // Load encryption key if needed var encryptionKey []byte if walEncrypt { key, err := loadEncryptionKey(walEncryptionKeyFile, walEncryptionKeyEnv) if err != nil { return fmt.Errorf("failed to load encryption key: %w", err) } encryptionKey = key } bmConfig := pitr.BinlogManagerConfig{ BinlogDir: mysqlBinlogDir, ArchiveDir: mysqlArchiveDir, Compression: walCompress, Encryption: walEncrypt, EncryptionKey: encryptionKey, } bm, err := pitr.NewBinlogManager(bmConfig) if err != nil { return fmt.Errorf("initializing binlog manager: %w", err) } // Discover binlogs binlogs, err := bm.DiscoverBinlogs(ctx) if err != nil { return fmt.Errorf("discovering binlogs: %w", err) } // Get already archived archived, _ := bm.ListArchivedBinlogs(ctx) archivedSet := make(map[string]struct{}) for _, a := range archived { archivedSet[a.OriginalFile] = struct{}{} } // Need to connect to MySQL to get current position // For now, skip the active binlog by looking at which one was modified most recently var latestModTime int64 var latestBinlog string for _, b := range binlogs { if b.ModTime.Unix() > latestModTime { latestModTime = b.ModTime.Unix() latestBinlog = b.Name } } var newArchives []pitr.BinlogArchiveInfo for i := range binlogs { b := &binlogs[i] // Skip if already archived if _, exists := archivedSet[b.Name]; exists { log.Info("Skipping already archived", "binlog", b.Name) continue } // Skip the most recently modified (likely active) if b.Name == latestBinlog { log.Info("Skipping active binlog", "binlog", b.Name) continue } log.Info("Archiving binlog", "binlog", b.Name, "size", formatWALSize(b.Size)) archiveInfo, err := bm.ArchiveBinlog(ctx, b) if err != nil { log.Error("Failed to archive binlog", "binlog", b.Name, "error", err) continue } newArchives = append(newArchives, *archiveInfo) } // Update metadata if len(newArchives) > 0 { allArchived, _ := bm.ListArchivedBinlogs(ctx) bm.SaveArchiveMetadata(allArchived) } log.Info("[OK] Binlog archiving completed", "archived", len(newArchives)) return nil } func runBinlogWatch(cmd *cobra.Command, args []string) error { ctx := context.Background() if !cfg.IsMySQL() { return fmt.Errorf("binlog commands are only supported for MySQL/MariaDB") } interval, err := time.ParseDuration(mysqlArchiveInterval) if err != nil { return fmt.Errorf("invalid interval: %w", err) } bmConfig := pitr.BinlogManagerConfig{ BinlogDir: mysqlBinlogDir, ArchiveDir: mysqlArchiveDir, Compression: walCompress, } bm, err := pitr.NewBinlogManager(bmConfig) if err != nil { return fmt.Errorf("initializing binlog manager: %w", err) } log.Info("Starting binlog watcher", "binlog_dir", mysqlBinlogDir, "archive_dir", mysqlArchiveDir, "interval", interval) // Watch for new binlogs err = bm.WatchBinlogs(ctx, interval, func(b *pitr.BinlogFile) { log.Info("New binlog detected, archiving", "binlog", b.Name) archiveInfo, err := bm.ArchiveBinlog(ctx, b) if err != nil { log.Error("Failed to archive binlog", "binlog", b.Name, "error", err) return } log.Info("Binlog archived successfully", "binlog", b.Name, "archive", archiveInfo.ArchivePath, "size", formatWALSize(archiveInfo.Size)) // Update metadata allArchived, _ := bm.ListArchivedBinlogs(ctx) bm.SaveArchiveMetadata(allArchived) }) if err != nil && err != context.Canceled { return err } return nil } func runBinlogValidate(cmd *cobra.Command, args []string) error { ctx := context.Background() if !cfg.IsMySQL() { return fmt.Errorf("binlog commands are only supported for MySQL/MariaDB") } binlogDir := mysqlBinlogDir if binlogDir == "" { binlogDir = mysqlArchiveDir } if binlogDir == "" { return fmt.Errorf("please specify --binlog-dir or --archive-dir") } bmConfig := pitr.BinlogManagerConfig{ BinlogDir: binlogDir, ArchiveDir: mysqlArchiveDir, } bm, err := pitr.NewBinlogManager(bmConfig) if err != nil { return fmt.Errorf("initializing binlog manager: %w", err) } // Discover binlogs binlogs, err := bm.DiscoverBinlogs(ctx) if err != nil { return fmt.Errorf("discovering binlogs: %w", err) } if len(binlogs) == 0 { fmt.Println("No binlog files found to validate") return nil } // Validate chain validation, err := bm.ValidateBinlogChain(ctx, binlogs) if err != nil { return fmt.Errorf("validating binlog chain: %w", err) } fmt.Println("=============================================================") fmt.Println(" Binlog Chain Validation") fmt.Println("=============================================================") fmt.Println() if validation.Valid { fmt.Println("Status: [OK] VALID - Binlog chain is complete") } else { fmt.Println("Status: [FAIL] INVALID - Binlog chain has gaps") } fmt.Printf("Files: %d binlog files\n", validation.LogCount) fmt.Printf("Total Size: %s\n", formatWALSize(validation.TotalSize)) if validation.StartPos != nil { fmt.Printf("Start: %s\n", validation.StartPos.String()) } if validation.EndPos != nil { fmt.Printf("End: %s\n", validation.EndPos.String()) } if len(validation.Gaps) > 0 { fmt.Println() fmt.Println("Gaps Found:") for _, gap := range validation.Gaps { fmt.Printf(" • After %s, before %s: %s\n", gap.After, gap.Before, gap.Reason) } } if len(validation.Warnings) > 0 { fmt.Println() fmt.Println("Warnings:") for _, w := range validation.Warnings { fmt.Printf(" ⚠ %s\n", w) } } if len(validation.Errors) > 0 { fmt.Println() fmt.Println("Errors:") for _, e := range validation.Errors { fmt.Printf(" [FAIL] %s\n", e) } } if !validation.Valid { os.Exit(1) } return nil } func runBinlogPosition(cmd *cobra.Command, args []string) error { ctx := context.Background() if !cfg.IsMySQL() { return fmt.Errorf("binlog commands are only supported for MySQL/MariaDB") } // Connect to MySQL dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/", cfg.User, cfg.Password, cfg.Host, cfg.Port) db, err := sql.Open("mysql", dsn) if err != nil { return fmt.Errorf("connecting to MySQL: %w", err) } defer db.Close() if err := db.PingContext(ctx); err != nil { return fmt.Errorf("pinging MySQL: %w", err) } // Get binlog position using raw query rows, err := db.QueryContext(ctx, "SHOW MASTER STATUS") if err != nil { return fmt.Errorf("getting master status: %w", err) } defer rows.Close() fmt.Println("=============================================================") fmt.Println(" Current Binary Log Position") fmt.Println("=============================================================") fmt.Println() if rows.Next() { var file string var position uint64 var binlogDoDB, binlogIgnoreDB, executedGtidSet sql.NullString cols, _ := rows.Columns() switch len(cols) { case 5: err = rows.Scan(&file, &position, &binlogDoDB, &binlogIgnoreDB, &executedGtidSet) case 4: err = rows.Scan(&file, &position, &binlogDoDB, &binlogIgnoreDB) default: err = rows.Scan(&file, &position) } if err != nil { return fmt.Errorf("scanning master status: %w", err) } fmt.Printf("File: %s\n", file) fmt.Printf("Position: %d\n", position) if executedGtidSet.Valid && executedGtidSet.String != "" { fmt.Printf("GTID Set: %s\n", executedGtidSet.String) } // Compact format for use in restore commands fmt.Println() fmt.Printf("Position String: %s:%d\n", file, position) } else { fmt.Println("Binary logging appears to be disabled.") fmt.Println("Enable binary logging by adding to my.cnf:") fmt.Println(" [mysqld]") fmt.Println(" log_bin = mysql-bin") fmt.Println(" server_id = 1") } return nil } func runMySQLPITRStatus(cmd *cobra.Command, args []string) error { ctx := context.Background() if !cfg.IsMySQL() { return fmt.Errorf("this command is only for MySQL/MariaDB (use 'pitr status' for PostgreSQL)") } // Connect to MySQL dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/", cfg.User, cfg.Password, cfg.Host, cfg.Port) db, err := sql.Open("mysql", dsn) if err != nil { return fmt.Errorf("connecting to MySQL: %w", err) } defer db.Close() if err := db.PingContext(ctx); err != nil { return fmt.Errorf("pinging MySQL: %w", err) } pitrConfig := pitr.MySQLPITRConfig{ Host: cfg.Host, Port: cfg.Port, User: cfg.User, Password: cfg.Password, BinlogDir: mysqlBinlogDir, ArchiveDir: mysqlArchiveDir, } mysqlPitr, err := pitr.NewMySQLPITR(db, pitrConfig) if err != nil { return fmt.Errorf("initializing MySQL PITR: %w", err) } status, err := mysqlPitr.Status(ctx) if err != nil { return fmt.Errorf("getting PITR status: %w", err) } fmt.Println("=============================================================") fmt.Printf(" MySQL/MariaDB PITR Status (%s)\n", status.DatabaseType) fmt.Println("=============================================================") fmt.Println() if status.Enabled { fmt.Println("PITR Status: [OK] ENABLED") } else { fmt.Println("PITR Status: [FAIL] NOT CONFIGURED") } // Get binary logging status var logBin string db.QueryRowContext(ctx, "SELECT @@log_bin").Scan(&logBin) if logBin == "1" || logBin == "ON" { fmt.Println("Binary Logging: [OK] ENABLED") } else { fmt.Println("Binary Logging: [FAIL] DISABLED") } fmt.Printf("Binlog Format: %s\n", status.LogLevel) // Check GTID mode var gtidMode string if status.DatabaseType == pitr.DatabaseMariaDB { db.QueryRowContext(ctx, "SELECT @@gtid_current_pos").Scan(>idMode) if gtidMode != "" { fmt.Println("GTID Mode: [OK] ENABLED") } else { fmt.Println("GTID Mode: [FAIL] DISABLED") } } else { db.QueryRowContext(ctx, "SELECT @@gtid_mode").Scan(>idMode) if gtidMode == "ON" { fmt.Println("GTID Mode: [OK] ENABLED") } else { fmt.Printf("GTID Mode: %s\n", gtidMode) } } if status.Position != nil { fmt.Printf("Current Position: %s\n", status.Position.String()) } if status.ArchiveDir != "" { fmt.Println() fmt.Println("Archive Statistics:") fmt.Printf(" Directory: %s\n", status.ArchiveDir) fmt.Printf(" File Count: %d\n", status.ArchiveCount) fmt.Printf(" Total Size: %s\n", formatWALSize(status.ArchiveSize)) if !status.LastArchived.IsZero() { fmt.Printf(" Last Archive: %s\n", status.LastArchived.Format("2006-01-02 15:04:05")) } } // Show requirements fmt.Println() fmt.Println("PITR Requirements:") if logBin == "1" || logBin == "ON" { fmt.Println(" [OK] Binary logging enabled") } else { fmt.Println(" [FAIL] Binary logging must be enabled (log_bin = mysql-bin)") } if status.LogLevel == "ROW" { fmt.Println(" [OK] Row-based logging (recommended)") } else { fmt.Printf(" ⚠ binlog_format = %s (ROW recommended for PITR)\n", status.LogLevel) } return nil } func runMySQLPITREnable(cmd *cobra.Command, args []string) error { ctx := context.Background() if !cfg.IsMySQL() { return fmt.Errorf("this command is only for MySQL/MariaDB (use 'pitr enable' for PostgreSQL)") } // Connect to MySQL dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/", cfg.User, cfg.Password, cfg.Host, cfg.Port) db, err := sql.Open("mysql", dsn) if err != nil { return fmt.Errorf("connecting to MySQL: %w", err) } defer db.Close() if err := db.PingContext(ctx); err != nil { return fmt.Errorf("pinging MySQL: %w", err) } pitrConfig := pitr.MySQLPITRConfig{ Host: cfg.Host, Port: cfg.Port, User: cfg.User, Password: cfg.Password, BinlogDir: mysqlBinlogDir, ArchiveDir: mysqlArchiveDir, RequireRowFormat: mysqlRequireRowFormat, RequireGTID: mysqlRequireGTID, } mysqlPitr, err := pitr.NewMySQLPITR(db, pitrConfig) if err != nil { return fmt.Errorf("initializing MySQL PITR: %w", err) } enableConfig := pitr.PITREnableConfig{ ArchiveDir: mysqlArchiveDir, RetentionDays: walRetentionDays, Compression: walCompress, } log.Info("Enabling MySQL PITR", "archive_dir", mysqlArchiveDir) if err := mysqlPitr.Enable(ctx, enableConfig); err != nil { return fmt.Errorf("enabling PITR: %w", err) } log.Info("[OK] MySQL PITR enabled successfully!") log.Info("") log.Info("Next steps:") log.Info("1. Start binlog archiving: dbbackup binlog watch --archive-dir " + mysqlArchiveDir) log.Info("2. Create a base backup: dbbackup backup single ") log.Info("3. Binlogs will be archived to: " + mysqlArchiveDir) 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 } // getMySQLBinlogDir attempts to determine the binlog directory from MySQL func getMySQLBinlogDir(ctx context.Context, db *sql.DB) (string, error) { var logBinBasename string err := db.QueryRowContext(ctx, "SELECT @@log_bin_basename").Scan(&logBinBasename) if err != nil { return "", err } return filepath.Dir(logBinBasename), nil }