- Add .golangci.yml with minimal linters (govet, ineffassign) - Run gofmt -s and goimports on all files to fix formatting - Disable fieldalignment and copylocks checks in govet
834 lines
26 KiB
Go
Executable File
834 lines
26 KiB
Go
Executable File
package cmd
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"os/signal"
|
||
"path/filepath"
|
||
"strings"
|
||
"syscall"
|
||
"time"
|
||
|
||
"dbbackup/internal/backup"
|
||
"dbbackup/internal/cloud"
|
||
"dbbackup/internal/database"
|
||
"dbbackup/internal/pitr"
|
||
"dbbackup/internal/restore"
|
||
"dbbackup/internal/security"
|
||
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
var (
|
||
restoreConfirm bool
|
||
restoreDryRun bool
|
||
restoreForce bool
|
||
restoreClean bool
|
||
restoreCreate bool
|
||
restoreJobs int
|
||
restoreTarget string
|
||
restoreVerbose bool
|
||
restoreNoProgress bool
|
||
restoreWorkdir string
|
||
restoreCleanCluster bool
|
||
|
||
// Encryption flags
|
||
restoreEncryptionKeyFile string
|
||
restoreEncryptionKeyEnv string = "DBBACKUP_ENCRYPTION_KEY"
|
||
|
||
// PITR restore flags (additional to pitr.go)
|
||
pitrBaseBackup string
|
||
pitrWALArchive string
|
||
pitrTargetDir string
|
||
pitrInclusive bool
|
||
pitrSkipExtract bool
|
||
pitrAutoStart bool
|
||
pitrMonitor bool
|
||
)
|
||
|
||
// restoreCmd represents the restore command
|
||
var restoreCmd = &cobra.Command{
|
||
Use: "restore",
|
||
Short: "Restore databases from backup archives",
|
||
Long: `Restore databases from backup archives.
|
||
|
||
By default, restore runs in dry-run mode showing what would be restored.
|
||
Use --confirm flag to perform actual restoration.
|
||
|
||
Examples:
|
||
# Preview restore (dry-run)
|
||
dbbackup restore single mydb.dump.gz
|
||
|
||
# Restore single database
|
||
dbbackup restore single mydb.dump.gz --confirm
|
||
|
||
# Restore to different database name
|
||
dbbackup restore single mydb.dump.gz --target mydb_restored --confirm
|
||
|
||
# Restore cluster backup
|
||
dbbackup restore cluster cluster_backup_20240101_120000.tar.gz --confirm
|
||
|
||
# List backup archives
|
||
dbbackup restore list
|
||
`,
|
||
Run: func(cmd *cobra.Command, args []string) {
|
||
cmd.Help()
|
||
},
|
||
}
|
||
|
||
// restoreSingleCmd restores a single database
|
||
var restoreSingleCmd = &cobra.Command{
|
||
Use: "single [archive-file]",
|
||
Short: "Restore a single database from archive",
|
||
Long: `Restore a single database from a backup archive.
|
||
|
||
Supported formats:
|
||
- PostgreSQL: .dump, .dump.gz, .sql, .sql.gz
|
||
- MySQL: .sql, .sql.gz
|
||
|
||
Safety features:
|
||
- Dry-run by default (use --confirm to execute)
|
||
- Archive validation before restore
|
||
- Disk space verification
|
||
- Optional database backup before restore
|
||
|
||
Examples:
|
||
# Preview restore
|
||
dbbackup restore single mydb.dump.gz
|
||
|
||
# Restore to original database
|
||
dbbackup restore single mydb.dump.gz --confirm
|
||
|
||
# Restore to different database
|
||
dbbackup restore single mydb.dump.gz --target mydb_test --confirm
|
||
|
||
# Clean target database before restore
|
||
dbbackup restore single mydb.sql.gz --clean --confirm
|
||
|
||
# Create database if it doesn't exist
|
||
dbbackup restore single mydb.sql --create --confirm
|
||
`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runRestoreSingle,
|
||
}
|
||
|
||
// restoreClusterCmd restores a full cluster
|
||
var restoreClusterCmd = &cobra.Command{
|
||
Use: "cluster [archive-file]",
|
||
Short: "Restore full cluster from tar.gz archive",
|
||
Long: `Restore a complete database cluster from a tar.gz archive.
|
||
|
||
This command restores all databases that were backed up together
|
||
in a cluster backup operation.
|
||
|
||
Safety features:
|
||
- Dry-run by default (use --confirm to execute)
|
||
- Archive validation and listing
|
||
- Disk space verification
|
||
- Sequential database restoration
|
||
|
||
Examples:
|
||
# Preview cluster restore
|
||
dbbackup restore cluster cluster_backup_20240101_120000.tar.gz
|
||
|
||
# Restore full cluster
|
||
dbbackup restore cluster cluster_backup_20240101_120000.tar.gz --confirm
|
||
|
||
# Use parallel decompression
|
||
dbbackup restore cluster cluster_backup.tar.gz --jobs 4 --confirm
|
||
|
||
# Use alternative working directory (for VMs with small system disk)
|
||
dbbackup restore cluster cluster_backup.tar.gz --workdir /mnt/storage/restore_tmp --confirm
|
||
|
||
# Disaster recovery: drop all existing databases first (clean slate)
|
||
dbbackup restore cluster cluster_backup.tar.gz --clean-cluster --confirm
|
||
`,
|
||
Args: cobra.ExactArgs(1),
|
||
RunE: runRestoreCluster,
|
||
}
|
||
|
||
// restoreListCmd lists available backup archives
|
||
var restoreListCmd = &cobra.Command{
|
||
Use: "list",
|
||
Short: "List available backup archives",
|
||
Long: `List all backup archives in the backup directory.
|
||
|
||
Shows information about each archive:
|
||
- Filename and path
|
||
- Archive format (PostgreSQL dump, MySQL SQL, cluster)
|
||
- File size
|
||
- Last modification time
|
||
- Database name (if detectable)
|
||
`,
|
||
RunE: runRestoreList,
|
||
}
|
||
|
||
// restorePITRCmd performs Point-in-Time Recovery
|
||
var restorePITRCmd = &cobra.Command{
|
||
Use: "pitr",
|
||
Short: "Point-in-Time Recovery (PITR) restore",
|
||
Long: `Restore PostgreSQL database to a specific point in time using WAL archives.
|
||
|
||
PITR allows restoring to any point in time, not just the backup moment.
|
||
Requires a base backup and continuous WAL archives.
|
||
|
||
Recovery Target Types:
|
||
--target-time Restore to specific timestamp
|
||
--target-xid Restore to transaction ID
|
||
--target-lsn Restore to Log Sequence Number
|
||
--target-name Restore to named restore point
|
||
--target-immediate Restore to earliest consistent point
|
||
|
||
Examples:
|
||
# Restore to specific time
|
||
dbbackup restore pitr \\
|
||
--base-backup /backups/base.tar.gz \\
|
||
--wal-archive /backups/wal/ \\
|
||
--target-time "2024-11-26 12:00:00" \\
|
||
--target-dir /var/lib/postgresql/14/main
|
||
|
||
# Restore to transaction ID
|
||
dbbackup restore pitr \\
|
||
--base-backup /backups/base.tar.gz \\
|
||
--wal-archive /backups/wal/ \\
|
||
--target-xid 1000000 \\
|
||
--target-dir /var/lib/postgresql/14/main \\
|
||
--auto-start
|
||
|
||
# Restore to LSN
|
||
dbbackup restore pitr \\
|
||
--base-backup /backups/base.tar.gz \\
|
||
--wal-archive /backups/wal/ \\
|
||
--target-lsn "0/3000000" \\
|
||
--target-dir /var/lib/postgresql/14/main
|
||
|
||
# Restore to earliest consistent point
|
||
dbbackup restore pitr \\
|
||
--base-backup /backups/base.tar.gz \\
|
||
--wal-archive /backups/wal/ \\
|
||
--target-immediate \\
|
||
--target-dir /var/lib/postgresql/14/main
|
||
`,
|
||
RunE: runRestorePITR,
|
||
}
|
||
|
||
func init() {
|
||
rootCmd.AddCommand(restoreCmd)
|
||
restoreCmd.AddCommand(restoreSingleCmd)
|
||
restoreCmd.AddCommand(restoreClusterCmd)
|
||
restoreCmd.AddCommand(restoreListCmd)
|
||
restoreCmd.AddCommand(restorePITRCmd)
|
||
|
||
// Single restore flags
|
||
restoreSingleCmd.Flags().BoolVar(&restoreConfirm, "confirm", false, "Confirm and execute restore (required)")
|
||
restoreSingleCmd.Flags().BoolVar(&restoreDryRun, "dry-run", false, "Show what would be done without executing")
|
||
restoreSingleCmd.Flags().BoolVar(&restoreForce, "force", false, "Skip safety checks and confirmations")
|
||
restoreSingleCmd.Flags().BoolVar(&restoreClean, "clean", false, "Drop and recreate target database")
|
||
restoreSingleCmd.Flags().BoolVar(&restoreCreate, "create", false, "Create target database if it doesn't exist")
|
||
restoreSingleCmd.Flags().StringVar(&restoreTarget, "target", "", "Target database name (defaults to original)")
|
||
restoreSingleCmd.Flags().BoolVar(&restoreVerbose, "verbose", false, "Show detailed restore progress")
|
||
restoreSingleCmd.Flags().BoolVar(&restoreNoProgress, "no-progress", false, "Disable progress indicators")
|
||
restoreSingleCmd.Flags().StringVar(&restoreEncryptionKeyFile, "encryption-key-file", "", "Path to encryption key file (required for encrypted backups)")
|
||
restoreSingleCmd.Flags().StringVar(&restoreEncryptionKeyEnv, "encryption-key-env", "DBBACKUP_ENCRYPTION_KEY", "Environment variable containing encryption key")
|
||
|
||
// Cluster restore flags
|
||
restoreClusterCmd.Flags().BoolVar(&restoreConfirm, "confirm", false, "Confirm and execute restore (required)")
|
||
restoreClusterCmd.Flags().BoolVar(&restoreDryRun, "dry-run", false, "Show what would be done without executing")
|
||
restoreClusterCmd.Flags().BoolVar(&restoreForce, "force", false, "Skip safety checks and confirmations")
|
||
restoreClusterCmd.Flags().BoolVar(&restoreCleanCluster, "clean-cluster", false, "Drop all existing user databases before restore (disaster recovery)")
|
||
restoreClusterCmd.Flags().IntVar(&restoreJobs, "jobs", 0, "Number of parallel decompression jobs (0 = auto)")
|
||
restoreClusterCmd.Flags().StringVar(&restoreWorkdir, "workdir", "", "Working directory for extraction (use when system disk is small, e.g. /mnt/storage/restore_tmp)")
|
||
restoreClusterCmd.Flags().BoolVar(&restoreVerbose, "verbose", false, "Show detailed restore progress")
|
||
restoreClusterCmd.Flags().BoolVar(&restoreNoProgress, "no-progress", false, "Disable progress indicators")
|
||
restoreClusterCmd.Flags().StringVar(&restoreEncryptionKeyFile, "encryption-key-file", "", "Path to encryption key file (required for encrypted backups)")
|
||
restoreClusterCmd.Flags().StringVar(&restoreEncryptionKeyEnv, "encryption-key-env", "DBBACKUP_ENCRYPTION_KEY", "Environment variable containing encryption key")
|
||
|
||
// PITR restore flags
|
||
restorePITRCmd.Flags().StringVar(&pitrBaseBackup, "base-backup", "", "Path to base backup file (.tar.gz) (required)")
|
||
restorePITRCmd.Flags().StringVar(&pitrWALArchive, "wal-archive", "", "Path to WAL archive directory (required)")
|
||
restorePITRCmd.Flags().StringVar(&pitrTargetTime, "target-time", "", "Restore to timestamp (YYYY-MM-DD HH:MM:SS)")
|
||
restorePITRCmd.Flags().StringVar(&pitrTargetXID, "target-xid", "", "Restore to transaction ID")
|
||
restorePITRCmd.Flags().StringVar(&pitrTargetLSN, "target-lsn", "", "Restore to LSN (e.g., 0/3000000)")
|
||
restorePITRCmd.Flags().StringVar(&pitrTargetName, "target-name", "", "Restore to named restore point")
|
||
restorePITRCmd.Flags().BoolVar(&pitrTargetImmediate, "target-immediate", false, "Restore to earliest consistent point")
|
||
restorePITRCmd.Flags().StringVar(&pitrRecoveryAction, "target-action", "promote", "Action after recovery (promote|pause|shutdown)")
|
||
restorePITRCmd.Flags().StringVar(&pitrTargetDir, "target-dir", "", "PostgreSQL data directory (required)")
|
||
restorePITRCmd.Flags().StringVar(&pitrWALSource, "timeline", "latest", "Timeline to follow (latest or timeline ID)")
|
||
restorePITRCmd.Flags().BoolVar(&pitrInclusive, "inclusive", true, "Include target transaction/time")
|
||
restorePITRCmd.Flags().BoolVar(&pitrSkipExtract, "skip-extraction", false, "Skip base backup extraction (data dir exists)")
|
||
restorePITRCmd.Flags().BoolVar(&pitrAutoStart, "auto-start", false, "Automatically start PostgreSQL after setup")
|
||
restorePITRCmd.Flags().BoolVar(&pitrMonitor, "monitor", false, "Monitor recovery progress (requires --auto-start)")
|
||
|
||
restorePITRCmd.MarkFlagRequired("base-backup")
|
||
restorePITRCmd.MarkFlagRequired("wal-archive")
|
||
restorePITRCmd.MarkFlagRequired("target-dir")
|
||
}
|
||
|
||
// runRestoreSingle restores a single database
|
||
func runRestoreSingle(cmd *cobra.Command, args []string) error {
|
||
archivePath := args[0]
|
||
|
||
// Check if this is a cloud URI
|
||
var cleanupFunc func() error
|
||
|
||
if cloud.IsCloudURI(archivePath) {
|
||
log.Info("Detected cloud URI, downloading backup...", "uri", archivePath)
|
||
|
||
// Download from cloud
|
||
result, err := restore.DownloadFromCloudURI(cmd.Context(), archivePath, restore.DownloadOptions{
|
||
VerifyChecksum: true,
|
||
KeepLocal: false, // Delete after restore
|
||
})
|
||
if err != nil {
|
||
return fmt.Errorf("failed to download from cloud: %w", err)
|
||
}
|
||
|
||
archivePath = result.LocalPath
|
||
cleanupFunc = result.Cleanup
|
||
|
||
// Ensure cleanup happens on exit
|
||
defer func() {
|
||
if cleanupFunc != nil {
|
||
if err := cleanupFunc(); err != nil {
|
||
log.Warn("Failed to cleanup temp files", "error", err)
|
||
}
|
||
}
|
||
}()
|
||
|
||
log.Info("Download completed", "local_path", archivePath)
|
||
} else {
|
||
// Convert to absolute path for local files
|
||
if !filepath.IsAbs(archivePath) {
|
||
absPath, err := filepath.Abs(archivePath)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid archive path: %w", err)
|
||
}
|
||
archivePath = absPath
|
||
}
|
||
|
||
// Check if file exists
|
||
if _, err := os.Stat(archivePath); err != nil {
|
||
return fmt.Errorf("backup archive not found at %s. Check path or use cloud:// URI for remote backups: %w", archivePath, err)
|
||
}
|
||
}
|
||
|
||
// Check if backup is encrypted and decrypt if necessary
|
||
if backup.IsBackupEncrypted(archivePath) {
|
||
log.Info("Encrypted backup detected, decrypting...")
|
||
key, err := loadEncryptionKey(restoreEncryptionKeyFile, restoreEncryptionKeyEnv)
|
||
if err != nil {
|
||
return fmt.Errorf("encrypted backup requires encryption key: %w", err)
|
||
}
|
||
// Decrypt in-place (same path)
|
||
if err := backup.DecryptBackupFile(archivePath, archivePath, key, log); err != nil {
|
||
return fmt.Errorf("decryption failed: %w", err)
|
||
}
|
||
log.Info("Decryption completed successfully")
|
||
}
|
||
|
||
// Detect format
|
||
format := restore.DetectArchiveFormat(archivePath)
|
||
if format == restore.FormatUnknown {
|
||
return fmt.Errorf("unknown archive format: %s", archivePath)
|
||
}
|
||
|
||
log.Info("Archive information",
|
||
"file", filepath.Base(archivePath),
|
||
"format", format.String(),
|
||
"compressed", format.IsCompressed())
|
||
|
||
// Extract database name from filename if target not specified
|
||
targetDB := restoreTarget
|
||
if targetDB == "" {
|
||
targetDB = extractDBNameFromArchive(archivePath)
|
||
if targetDB == "" {
|
||
return fmt.Errorf("cannot determine database name, please specify --target")
|
||
}
|
||
} else {
|
||
// If target was explicitly provided, also strip common file extensions
|
||
// in case user included them in the target name
|
||
targetDB = stripFileExtensions(targetDB)
|
||
}
|
||
|
||
// Safety checks
|
||
safety := restore.NewSafety(cfg, log)
|
||
|
||
if !restoreForce {
|
||
log.Info("Validating archive...")
|
||
if err := safety.ValidateArchive(archivePath); err != nil {
|
||
return fmt.Errorf("archive validation failed: %w", err)
|
||
}
|
||
|
||
log.Info("Checking disk space...")
|
||
multiplier := 3.0 // Assume 3x expansion for safety
|
||
if err := safety.CheckDiskSpace(archivePath, multiplier); err != nil {
|
||
return fmt.Errorf("disk space check failed: %w", err)
|
||
}
|
||
|
||
// Verify tools
|
||
dbType := "postgres"
|
||
if format.IsMySQL() {
|
||
dbType = "mysql"
|
||
}
|
||
if err := safety.VerifyTools(dbType); err != nil {
|
||
return fmt.Errorf("tool verification failed: %w", err)
|
||
}
|
||
}
|
||
|
||
// Dry-run mode or confirmation required
|
||
isDryRun := restoreDryRun || !restoreConfirm
|
||
|
||
if isDryRun {
|
||
fmt.Println("\n🔍 DRY-RUN MODE - No changes will be made")
|
||
fmt.Printf("\nWould restore:\n")
|
||
fmt.Printf(" Archive: %s\n", archivePath)
|
||
fmt.Printf(" Format: %s\n", format.String())
|
||
fmt.Printf(" Target Database: %s\n", targetDB)
|
||
fmt.Printf(" Clean Before Restore: %v\n", restoreClean)
|
||
fmt.Printf(" Create If Missing: %v\n", restoreCreate)
|
||
fmt.Println("\nTo execute this restore, add --confirm flag")
|
||
return nil
|
||
}
|
||
|
||
// Create database instance
|
||
db, err := database.New(cfg, log)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create database instance: %w", err)
|
||
}
|
||
defer db.Close()
|
||
|
||
// Create restore engine
|
||
engine := restore.New(cfg, log, db)
|
||
|
||
// Setup signal handling
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
defer cancel()
|
||
|
||
sigChan := make(chan os.Signal, 1)
|
||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||
defer signal.Stop(sigChan) // Ensure signal cleanup on exit
|
||
|
||
go func() {
|
||
<-sigChan
|
||
log.Warn("Restore interrupted by user")
|
||
cancel()
|
||
}()
|
||
|
||
// Execute restore
|
||
log.Info("Starting restore...", "database", targetDB)
|
||
|
||
// Audit log: restore start
|
||
user := security.GetCurrentUser()
|
||
startTime := time.Now()
|
||
auditLogger.LogRestoreStart(user, targetDB, archivePath)
|
||
|
||
if err := engine.RestoreSingle(ctx, archivePath, targetDB, restoreClean, restoreCreate); err != nil {
|
||
auditLogger.LogRestoreFailed(user, targetDB, err)
|
||
return fmt.Errorf("restore failed: %w", err)
|
||
}
|
||
|
||
// Audit log: restore success
|
||
auditLogger.LogRestoreComplete(user, targetDB, time.Since(startTime))
|
||
|
||
log.Info("✅ Restore completed successfully", "database", targetDB)
|
||
return nil
|
||
}
|
||
|
||
// runRestoreCluster restores a full cluster
|
||
func runRestoreCluster(cmd *cobra.Command, args []string) error {
|
||
archivePath := args[0]
|
||
|
||
// Convert to absolute path
|
||
if !filepath.IsAbs(archivePath) {
|
||
absPath, err := filepath.Abs(archivePath)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid archive path: %w", err)
|
||
}
|
||
archivePath = absPath
|
||
}
|
||
|
||
// Check if file exists
|
||
if _, err := os.Stat(archivePath); err != nil {
|
||
return fmt.Errorf("archive not found: %s", archivePath)
|
||
}
|
||
|
||
// Check if backup is encrypted and decrypt if necessary
|
||
if backup.IsBackupEncrypted(archivePath) {
|
||
log.Info("Encrypted cluster backup detected, decrypting...")
|
||
key, err := loadEncryptionKey(restoreEncryptionKeyFile, restoreEncryptionKeyEnv)
|
||
if err != nil {
|
||
return fmt.Errorf("encrypted backup requires encryption key: %w", err)
|
||
}
|
||
// Decrypt in-place (same path)
|
||
if err := backup.DecryptBackupFile(archivePath, archivePath, key, log); err != nil {
|
||
return fmt.Errorf("decryption failed: %w", err)
|
||
}
|
||
log.Info("Cluster decryption completed successfully")
|
||
}
|
||
|
||
// Verify it's a cluster backup
|
||
format := restore.DetectArchiveFormat(archivePath)
|
||
if !format.IsClusterBackup() {
|
||
return fmt.Errorf("not a cluster backup: %s (format: %s)", archivePath, format.String())
|
||
}
|
||
|
||
log.Info("Cluster archive information",
|
||
"file", filepath.Base(archivePath),
|
||
"format", format.String())
|
||
|
||
// Safety checks
|
||
safety := restore.NewSafety(cfg, log)
|
||
|
||
if !restoreForce {
|
||
log.Info("Validating archive...")
|
||
if err := safety.ValidateArchive(archivePath); err != nil {
|
||
return fmt.Errorf("archive validation failed: %w", err)
|
||
}
|
||
|
||
// Determine where to check disk space
|
||
checkDir := cfg.BackupDir
|
||
if restoreWorkdir != "" {
|
||
checkDir = restoreWorkdir
|
||
|
||
// Verify workdir exists or create it
|
||
if _, err := os.Stat(restoreWorkdir); os.IsNotExist(err) {
|
||
log.Warn("Working directory does not exist, will be created", "path", restoreWorkdir)
|
||
if err := os.MkdirAll(restoreWorkdir, 0755); err != nil {
|
||
return fmt.Errorf("cannot create working directory: %w", err)
|
||
}
|
||
}
|
||
|
||
log.Warn("⚠️ Using alternative working directory for extraction")
|
||
log.Warn(" This is recommended when system disk space is limited")
|
||
log.Warn(" Location: " + restoreWorkdir)
|
||
}
|
||
|
||
log.Info("Checking disk space...")
|
||
multiplier := 4.0 // Cluster needs more space for extraction
|
||
if err := safety.CheckDiskSpaceAt(archivePath, checkDir, multiplier); err != nil {
|
||
return fmt.Errorf("disk space check failed: %w", err)
|
||
}
|
||
|
||
// Verify tools (assume PostgreSQL for cluster backups)
|
||
if err := safety.VerifyTools("postgres"); err != nil {
|
||
return fmt.Errorf("tool verification failed: %w", err)
|
||
}
|
||
} // Create database instance for pre-checks
|
||
db, err := database.New(cfg, log)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create database instance: %w", err)
|
||
}
|
||
defer db.Close()
|
||
|
||
// Check existing databases if --clean-cluster is enabled
|
||
var existingDBs []string
|
||
if restoreCleanCluster {
|
||
ctx := context.Background()
|
||
if err := db.Connect(ctx); err != nil {
|
||
return fmt.Errorf("failed to connect to database: %w", err)
|
||
}
|
||
|
||
allDBs, err := db.ListDatabases(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to list databases: %w", err)
|
||
}
|
||
|
||
// Filter out system databases (keep postgres, template0, template1)
|
||
systemDBs := map[string]bool{
|
||
"postgres": true,
|
||
"template0": true,
|
||
"template1": true,
|
||
}
|
||
|
||
for _, dbName := range allDBs {
|
||
if !systemDBs[dbName] {
|
||
existingDBs = append(existingDBs, dbName)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Dry-run mode or confirmation required
|
||
isDryRun := restoreDryRun || !restoreConfirm
|
||
|
||
if isDryRun {
|
||
fmt.Println("\n🔍 DRY-RUN MODE - No changes will be made")
|
||
fmt.Printf("\nWould restore cluster:\n")
|
||
fmt.Printf(" Archive: %s\n", archivePath)
|
||
fmt.Printf(" Parallel Jobs: %d (0 = auto)\n", restoreJobs)
|
||
if restoreWorkdir != "" {
|
||
fmt.Printf(" Working Directory: %s (alternative extraction location)\n", restoreWorkdir)
|
||
}
|
||
if restoreCleanCluster {
|
||
fmt.Printf(" Clean Cluster: true (will drop %d existing database(s))\n", len(existingDBs))
|
||
if len(existingDBs) > 0 {
|
||
fmt.Printf("\n⚠️ Databases to be dropped:\n")
|
||
for _, dbName := range existingDBs {
|
||
fmt.Printf(" - %s\n", dbName)
|
||
}
|
||
}
|
||
}
|
||
fmt.Println("\nTo execute this restore, add --confirm flag")
|
||
return nil
|
||
}
|
||
|
||
// Warning for clean-cluster
|
||
if restoreCleanCluster && len(existingDBs) > 0 {
|
||
log.Warn("🔥 Clean cluster mode enabled")
|
||
log.Warn(fmt.Sprintf(" %d existing database(s) will be DROPPED before restore!", len(existingDBs)))
|
||
for _, dbName := range existingDBs {
|
||
log.Warn(" - " + dbName)
|
||
}
|
||
}
|
||
|
||
// Create restore engine
|
||
engine := restore.New(cfg, log, db)
|
||
|
||
// Setup signal handling
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
defer cancel()
|
||
|
||
sigChan := make(chan os.Signal, 1)
|
||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||
defer signal.Stop(sigChan) // Ensure signal cleanup on exit
|
||
|
||
go func() {
|
||
<-sigChan
|
||
log.Warn("Restore interrupted by user")
|
||
cancel()
|
||
}()
|
||
|
||
// Drop existing databases if clean-cluster is enabled
|
||
if restoreCleanCluster && len(existingDBs) > 0 {
|
||
log.Info("Dropping existing databases before restore...")
|
||
for _, dbName := range existingDBs {
|
||
log.Info("Dropping database", "name", dbName)
|
||
// Use CLI-based drop to avoid connection issues
|
||
dropCmd := exec.CommandContext(ctx, "psql",
|
||
"-h", cfg.Host,
|
||
"-p", fmt.Sprintf("%d", cfg.Port),
|
||
"-U", cfg.User,
|
||
"-d", "postgres",
|
||
"-c", fmt.Sprintf("DROP DATABASE IF EXISTS \"%s\"", dbName),
|
||
)
|
||
if err := dropCmd.Run(); err != nil {
|
||
log.Warn("Failed to drop database", "name", dbName, "error", err)
|
||
// Continue with other databases
|
||
}
|
||
}
|
||
log.Info("Database cleanup completed")
|
||
}
|
||
|
||
// Execute cluster restore
|
||
log.Info("Starting cluster restore...")
|
||
|
||
// Audit log: restore start
|
||
user := security.GetCurrentUser()
|
||
startTime := time.Now()
|
||
auditLogger.LogRestoreStart(user, "all_databases", archivePath)
|
||
|
||
if err := engine.RestoreCluster(ctx, archivePath); err != nil {
|
||
auditLogger.LogRestoreFailed(user, "all_databases", err)
|
||
return fmt.Errorf("cluster restore failed: %w", err)
|
||
}
|
||
|
||
// Audit log: restore success
|
||
auditLogger.LogRestoreComplete(user, "all_databases", time.Since(startTime))
|
||
|
||
log.Info("✅ Cluster restore completed successfully")
|
||
return nil
|
||
}
|
||
|
||
// runRestoreList lists available backup archives
|
||
func runRestoreList(cmd *cobra.Command, args []string) error {
|
||
backupDir := cfg.BackupDir
|
||
|
||
// Check if backup directory exists
|
||
if _, err := os.Stat(backupDir); err != nil {
|
||
return fmt.Errorf("backup directory not found: %s", backupDir)
|
||
}
|
||
|
||
// List all backup files
|
||
files, err := os.ReadDir(backupDir)
|
||
if err != nil {
|
||
return fmt.Errorf("cannot read backup directory: %w", err)
|
||
}
|
||
|
||
var archives []archiveInfo
|
||
|
||
for _, file := range files {
|
||
if file.IsDir() {
|
||
continue
|
||
}
|
||
|
||
name := file.Name()
|
||
format := restore.DetectArchiveFormat(name)
|
||
|
||
if format == restore.FormatUnknown {
|
||
continue // Skip non-backup files
|
||
}
|
||
|
||
info, _ := file.Info()
|
||
archives = append(archives, archiveInfo{
|
||
Name: name,
|
||
Format: format,
|
||
Size: info.Size(),
|
||
Modified: info.ModTime(),
|
||
DBName: extractDBNameFromArchive(name),
|
||
})
|
||
}
|
||
|
||
if len(archives) == 0 {
|
||
fmt.Println("No backup archives found in:", backupDir)
|
||
return nil
|
||
}
|
||
|
||
// Print header
|
||
fmt.Printf("\n📦 Available backup archives in %s\n\n", backupDir)
|
||
fmt.Printf("%-40s %-25s %-12s %-20s %s\n",
|
||
"FILENAME", "FORMAT", "SIZE", "MODIFIED", "DATABASE")
|
||
fmt.Println(strings.Repeat("-", 120))
|
||
|
||
// Print archives
|
||
for _, archive := range archives {
|
||
fmt.Printf("%-40s %-25s %-12s %-20s %s\n",
|
||
truncate(archive.Name, 40),
|
||
truncate(archive.Format.String(), 25),
|
||
formatSize(archive.Size),
|
||
archive.Modified.Format("2006-01-02 15:04:05"),
|
||
archive.DBName)
|
||
}
|
||
|
||
fmt.Printf("\nTotal: %d archive(s)\n", len(archives))
|
||
fmt.Println("\nTo restore: dbbackup restore single <filename> --confirm")
|
||
fmt.Println(" dbbackup restore cluster <filename> --confirm")
|
||
|
||
return nil
|
||
}
|
||
|
||
// archiveInfo holds information about a backup archive
|
||
type archiveInfo struct {
|
||
Name string
|
||
Format restore.ArchiveFormat
|
||
Size int64
|
||
Modified time.Time
|
||
DBName string
|
||
}
|
||
|
||
// stripFileExtensions removes common backup file extensions from a name
|
||
func stripFileExtensions(name string) string {
|
||
// Remove extensions (handle double extensions like .sql.gz.sql.gz)
|
||
for {
|
||
oldName := name
|
||
name = strings.TrimSuffix(name, ".tar.gz")
|
||
name = strings.TrimSuffix(name, ".dump.gz")
|
||
name = strings.TrimSuffix(name, ".sql.gz")
|
||
name = strings.TrimSuffix(name, ".dump")
|
||
name = strings.TrimSuffix(name, ".sql")
|
||
// If no change, we're done
|
||
if name == oldName {
|
||
break
|
||
}
|
||
}
|
||
return name
|
||
}
|
||
|
||
// extractDBNameFromArchive extracts database name from archive filename
|
||
func extractDBNameFromArchive(filename string) string {
|
||
base := filepath.Base(filename)
|
||
|
||
// Remove extensions
|
||
base = stripFileExtensions(base)
|
||
|
||
// Remove timestamp patterns (YYYYMMDD_HHMMSS)
|
||
parts := strings.Split(base, "_")
|
||
for i := len(parts) - 1; i >= 0; i-- {
|
||
// Check if part looks like a date
|
||
if len(parts[i]) == 8 || len(parts[i]) == 6 {
|
||
// Could be date or time, remove it
|
||
parts = parts[:i]
|
||
} else {
|
||
break
|
||
}
|
||
}
|
||
|
||
if len(parts) > 0 {
|
||
return parts[0]
|
||
}
|
||
|
||
return base
|
||
}
|
||
|
||
// formatSize formats file size
|
||
func formatSize(bytes int64) string {
|
||
const unit = 1024
|
||
if bytes < unit {
|
||
return fmt.Sprintf("%d B", bytes)
|
||
}
|
||
div, exp := int64(unit), 0
|
||
for n := bytes / unit; n >= unit; n /= unit {
|
||
div *= unit
|
||
exp++
|
||
}
|
||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||
}
|
||
|
||
// truncate truncates string to max length
|
||
func truncate(s string, max int) string {
|
||
if len(s) <= max {
|
||
return s
|
||
}
|
||
return s[:max-3] + "..."
|
||
}
|
||
|
||
// runRestorePITR performs Point-in-Time Recovery
|
||
func runRestorePITR(cmd *cobra.Command, args []string) error {
|
||
ctx := cmd.Context()
|
||
|
||
// Parse recovery target
|
||
target, err := pitr.ParseRecoveryTarget(
|
||
pitrTargetTime,
|
||
pitrTargetXID,
|
||
pitrTargetLSN,
|
||
pitrTargetName,
|
||
pitrTargetImmediate,
|
||
pitrRecoveryAction,
|
||
pitrWALSource,
|
||
pitrInclusive,
|
||
)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid recovery target: %w", err)
|
||
}
|
||
|
||
// Display recovery target info
|
||
log.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||
log.Info(" Point-in-Time Recovery (PITR)")
|
||
log.Info("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||
log.Info("")
|
||
log.Info(target.String())
|
||
log.Info("")
|
||
|
||
// Create restore orchestrator
|
||
orchestrator := pitr.NewRestoreOrchestrator(cfg, log)
|
||
|
||
// Prepare restore options
|
||
opts := &pitr.RestoreOptions{
|
||
BaseBackupPath: pitrBaseBackup,
|
||
WALArchiveDir: pitrWALArchive,
|
||
Target: target,
|
||
TargetDataDir: pitrTargetDir,
|
||
SkipExtraction: pitrSkipExtract,
|
||
AutoStart: pitrAutoStart,
|
||
MonitorProgress: pitrMonitor,
|
||
}
|
||
|
||
// Perform PITR restore
|
||
if err := orchestrator.RestorePointInTime(ctx, opts); err != nil {
|
||
return fmt.Errorf("PITR restore failed: %w", err)
|
||
}
|
||
|
||
log.Info("✅ PITR restore completed successfully")
|
||
return nil
|
||
}
|