Files
dbbackup/cmd/pitr.go
Alexander Renz 3e41d88445
All checks were successful
CI/CD / Test (push) Successful in 1m13s
CI/CD / Lint (push) Successful in 1m20s
CI/CD / Build & Release (push) Successful in 3m10s
v3.42.11: Replace all Unicode emojis with ASCII text
- Replace all emoji characters with ASCII equivalents throughout codebase
- Replace Unicode box-drawing characters (═║╔╗╚╝━─) with ASCII (+|-=)
- Replace checkmarks (✓✗) with [OK]/[FAIL] markers
- 59 files updated, 741 lines changed
- Improves terminal compatibility and reduces visual noise
2026-01-08 09:42:01 +01:00

1325 lines
38 KiB
Go

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 <wal_path> <wal_filename>",
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 <database>")
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 <backup> --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 <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(&gtidMode)
if gtidMode != "" {
fmt.Println("GTID Mode: [OK] ENABLED")
} else {
fmt.Println("GTID Mode: [FAIL] DISABLED")
}
} else {
db.QueryRowContext(ctx, "SELECT @@gtid_mode").Scan(&gtidMode)
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 <database>")
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 <backup> --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
}