Compare two backups and show what changed Features: - Flexible input: file paths, catalog IDs, or database:latest/previous - Shows size delta with growth rate calculation - Duration comparison - Compression analysis - Growth projections (time to 10GB) - JSON output for automation - Database growth rate per day Examples: dbbackup diff backup1.dump.gz backup2.dump.gz dbbackup diff 123 456 dbbackup diff mydb:latest mydb:previous Perfect for: - Tracking database growth over time - Capacity planning - Identifying sudden size changes - Backup efficiency analysis
329 lines
9.8 KiB
Go
329 lines
9.8 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dustin/go-humanize"
|
|
"github.com/spf13/cobra"
|
|
|
|
"dbbackup/internal/restore"
|
|
)
|
|
|
|
var (
|
|
previewCompareSchema bool
|
|
previewEstimate bool
|
|
)
|
|
|
|
var restorePreviewCmd = &cobra.Command{
|
|
Use: "preview [archive-file]",
|
|
Short: "Preview backup contents before restoring",
|
|
Long: `Show detailed information about what a backup contains before actually restoring it.
|
|
|
|
This command analyzes backup archives and provides:
|
|
- Database name, version, and size information
|
|
- Table count and largest tables
|
|
- Estimated restore time based on system resources
|
|
- Required disk space
|
|
- Schema comparison with current database (optional)
|
|
- Resource recommendations
|
|
|
|
Use this to:
|
|
- See what you'll get before committing to a long restore
|
|
- Estimate restore time and resource requirements
|
|
- Identify schema changes since backup was created
|
|
- Verify backup contains expected data
|
|
|
|
Examples:
|
|
# Preview a backup
|
|
dbbackup restore preview mydb.dump.gz
|
|
|
|
# Preview with restore time estimation
|
|
dbbackup restore preview mydb.dump.gz --estimate
|
|
|
|
# Preview with schema comparison to current database
|
|
dbbackup restore preview mydb.dump.gz --compare-schema
|
|
|
|
# Preview cluster backup
|
|
dbbackup restore preview cluster_backup.tar.gz
|
|
`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runRestorePreview,
|
|
}
|
|
|
|
func init() {
|
|
restoreCmd.AddCommand(restorePreviewCmd)
|
|
|
|
restorePreviewCmd.Flags().BoolVar(&previewCompareSchema, "compare-schema", false, "Compare backup schema with current database")
|
|
restorePreviewCmd.Flags().BoolVar(&previewEstimate, "estimate", true, "Estimate restore time and resource requirements")
|
|
restorePreviewCmd.Flags().BoolVar(&restoreVerbose, "verbose", false, "Show detailed analysis")
|
|
}
|
|
|
|
func runRestorePreview(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
|
|
stat, err := os.Stat(archivePath)
|
|
if err != nil {
|
|
return fmt.Errorf("archive not found: %s", archivePath)
|
|
}
|
|
|
|
fmt.Printf("\n%s\n", strings.Repeat("=", 70))
|
|
fmt.Printf("BACKUP PREVIEW: %s\n", filepath.Base(archivePath))
|
|
fmt.Printf("%s\n\n", strings.Repeat("=", 70))
|
|
|
|
// Get file info
|
|
fileSize := stat.Size()
|
|
fmt.Printf("File Information:\n")
|
|
fmt.Printf(" Path: %s\n", archivePath)
|
|
fmt.Printf(" Size: %s (%d bytes)\n", humanize.Bytes(uint64(fileSize)), fileSize)
|
|
fmt.Printf(" Modified: %s\n", stat.ModTime().Format("2006-01-02 15:04:05"))
|
|
fmt.Printf(" Age: %s\n", humanize.Time(stat.ModTime()))
|
|
fmt.Println()
|
|
|
|
// Detect format
|
|
format := restore.DetectArchiveFormat(archivePath)
|
|
fmt.Printf("Format Detection:\n")
|
|
fmt.Printf(" Type: %s\n", format.String())
|
|
|
|
if format.IsCompressed() {
|
|
fmt.Printf(" Compressed: Yes\n")
|
|
} else {
|
|
fmt.Printf(" Compressed: No\n")
|
|
}
|
|
fmt.Println()
|
|
|
|
// Run diagnosis
|
|
diagnoser := restore.NewDiagnoser(log, restoreVerbose)
|
|
result, err := diagnoser.DiagnoseFile(archivePath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to analyze backup: %w", err)
|
|
}
|
|
|
|
// Database information
|
|
fmt.Printf("Database Information:\n")
|
|
|
|
if format.IsClusterBackup() {
|
|
// For cluster backups, extract database list
|
|
fmt.Printf(" Type: Cluster Backup (multiple databases)\n")
|
|
|
|
// Try to list databases
|
|
if dbList, err := listDatabasesInCluster(archivePath); err == nil && len(dbList) > 0 {
|
|
fmt.Printf(" Databases: %d\n", len(dbList))
|
|
fmt.Printf("\n Database List:\n")
|
|
for _, db := range dbList {
|
|
fmt.Printf(" - %s\n", db)
|
|
}
|
|
} else {
|
|
fmt.Printf(" Databases: Multiple (use --list-databases to see all)\n")
|
|
}
|
|
} else {
|
|
// Single database backup
|
|
dbName := extractDatabaseName(archivePath, result)
|
|
fmt.Printf(" Database: %s\n", dbName)
|
|
|
|
if result.Details != nil && result.Details.TableCount > 0 {
|
|
fmt.Printf(" Tables: %d\n", result.Details.TableCount)
|
|
|
|
if len(result.Details.TableList) > 0 {
|
|
fmt.Printf("\n Largest Tables (top 5):\n")
|
|
displayCount := 5
|
|
if len(result.Details.TableList) < displayCount {
|
|
displayCount = len(result.Details.TableList)
|
|
}
|
|
for i := 0; i < displayCount; i++ {
|
|
fmt.Printf(" - %s\n", result.Details.TableList[i])
|
|
}
|
|
if len(result.Details.TableList) > 5 {
|
|
fmt.Printf(" ... and %d more\n", len(result.Details.TableList)-5)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
fmt.Println()
|
|
|
|
// Size estimation
|
|
if result.Details != nil && result.Details.ExpandedSize > 0 {
|
|
fmt.Printf("Size Estimates:\n")
|
|
fmt.Printf(" Compressed: %s\n", humanize.Bytes(uint64(fileSize)))
|
|
fmt.Printf(" Uncompressed: %s\n", humanize.Bytes(uint64(result.Details.ExpandedSize)))
|
|
|
|
if result.Details.CompressionRatio > 0 {
|
|
fmt.Printf(" Ratio: %.1f%% (%.2fx compression)\n",
|
|
result.Details.CompressionRatio*100,
|
|
float64(result.Details.ExpandedSize)/float64(fileSize))
|
|
}
|
|
|
|
// Estimate disk space needed (uncompressed + indexes + temp space)
|
|
estimatedDisk := int64(float64(result.Details.ExpandedSize) * 1.5) // 1.5x for indexes and temp
|
|
fmt.Printf(" Disk needed: %s (including indexes and temporary space)\n",
|
|
humanize.Bytes(uint64(estimatedDisk)))
|
|
fmt.Println()
|
|
}
|
|
|
|
// Restore time estimation
|
|
if previewEstimate {
|
|
fmt.Printf("Restore Estimates:\n")
|
|
|
|
// Apply current profile
|
|
profile := cfg.GetCurrentProfile()
|
|
if profile != nil {
|
|
fmt.Printf(" Profile: %s (P:%d J:%d)\n",
|
|
profile.Name, profile.ClusterParallelism, profile.Jobs)
|
|
}
|
|
|
|
// Estimate extraction time
|
|
extractionSpeed := int64(500 * 1024 * 1024) // 500 MB/s typical
|
|
extractionTime := time.Duration(fileSize/extractionSpeed) * time.Second
|
|
|
|
fmt.Printf(" Extract time: ~%s\n", formatDuration(extractionTime))
|
|
|
|
// Estimate restore time (depends on data size and parallelism)
|
|
if result.Details != nil && result.Details.ExpandedSize > 0 {
|
|
// Rough estimate: 50MB/s per job for PostgreSQL restore
|
|
restoreSpeed := int64(50 * 1024 * 1024)
|
|
if profile != nil {
|
|
restoreSpeed *= int64(profile.Jobs)
|
|
}
|
|
restoreTime := time.Duration(result.Details.ExpandedSize/restoreSpeed) * time.Second
|
|
|
|
fmt.Printf(" Restore time: ~%s\n", formatDuration(restoreTime))
|
|
|
|
// Validation time (10% of restore)
|
|
validationTime := restoreTime / 10
|
|
fmt.Printf(" Validation: ~%s\n", formatDuration(validationTime))
|
|
|
|
// Total
|
|
totalTime := extractionTime + restoreTime + validationTime
|
|
fmt.Printf(" Total (RTO): ~%s\n", formatDuration(totalTime))
|
|
}
|
|
|
|
fmt.Println()
|
|
}
|
|
|
|
// Validation status
|
|
fmt.Printf("Validation Status:\n")
|
|
if result.IsValid {
|
|
fmt.Printf(" Status: ✓ VALID - Backup appears intact\n")
|
|
} else {
|
|
fmt.Printf(" Status: ✗ INVALID - Backup has issues\n")
|
|
}
|
|
|
|
if result.IsTruncated {
|
|
fmt.Printf(" Truncation: ✗ File appears truncated\n")
|
|
}
|
|
if result.IsCorrupted {
|
|
fmt.Printf(" Corruption: ✗ Corruption detected\n")
|
|
}
|
|
|
|
if len(result.Errors) > 0 {
|
|
fmt.Printf("\n Errors:\n")
|
|
for _, err := range result.Errors {
|
|
fmt.Printf(" - %s\n", err)
|
|
}
|
|
}
|
|
|
|
if len(result.Warnings) > 0 {
|
|
fmt.Printf("\n Warnings:\n")
|
|
for _, warn := range result.Warnings {
|
|
fmt.Printf(" - %s\n", warn)
|
|
}
|
|
}
|
|
fmt.Println()
|
|
|
|
// Schema comparison
|
|
if previewCompareSchema {
|
|
fmt.Printf("Schema Comparison:\n")
|
|
fmt.Printf(" Status: Not yet implemented\n")
|
|
fmt.Printf(" (Compare with current database schema)\n")
|
|
fmt.Println()
|
|
}
|
|
|
|
// Recommendations
|
|
fmt.Printf("Recommendations:\n")
|
|
|
|
if !result.IsValid {
|
|
fmt.Printf(" - ✗ DO NOT restore this backup - validation failed\n")
|
|
fmt.Printf(" - Run 'dbbackup restore diagnose %s' for detailed analysis\n", filepath.Base(archivePath))
|
|
} else {
|
|
fmt.Printf(" - ✓ Backup is valid and ready to restore\n")
|
|
|
|
// Resource recommendations
|
|
if result.Details != nil && result.Details.ExpandedSize > 0 {
|
|
estimatedRAM := result.Details.ExpandedSize / (1024 * 1024 * 1024) / 10 // Rough: 10% of data size
|
|
if estimatedRAM < 4 {
|
|
estimatedRAM = 4
|
|
}
|
|
fmt.Printf(" - Recommended RAM: %dGB or more\n", estimatedRAM)
|
|
|
|
// Disk space
|
|
estimatedDisk := int64(float64(result.Details.ExpandedSize) * 1.5)
|
|
fmt.Printf(" - Ensure %s free disk space\n", humanize.Bytes(uint64(estimatedDisk)))
|
|
}
|
|
|
|
// Profile recommendation
|
|
if result.Details != nil && result.Details.TableCount > 100 {
|
|
fmt.Printf(" - Use 'conservative' profile for databases with many tables\n")
|
|
} else {
|
|
fmt.Printf(" - Use 'turbo' profile for fastest restore\n")
|
|
}
|
|
}
|
|
|
|
fmt.Printf("\n%s\n", strings.Repeat("=", 70))
|
|
|
|
if result.IsValid {
|
|
fmt.Printf("Ready to restore? Run:\n")
|
|
if format.IsClusterBackup() {
|
|
fmt.Printf(" dbbackup restore cluster %s --confirm\n", filepath.Base(archivePath))
|
|
} else {
|
|
fmt.Printf(" dbbackup restore single %s --confirm\n", filepath.Base(archivePath))
|
|
}
|
|
} else {
|
|
fmt.Printf("Fix validation errors before attempting restore.\n")
|
|
}
|
|
fmt.Printf("%s\n\n", strings.Repeat("=", 70))
|
|
|
|
if !result.IsValid {
|
|
return fmt.Errorf("backup validation failed")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func extractDatabaseName(archivePath string, result *restore.DiagnoseResult) string {
|
|
// Try to extract from filename
|
|
baseName := filepath.Base(archivePath)
|
|
baseName = strings.TrimSuffix(baseName, ".gz")
|
|
baseName = strings.TrimSuffix(baseName, ".dump")
|
|
baseName = strings.TrimSuffix(baseName, ".sql")
|
|
baseName = strings.TrimSuffix(baseName, ".tar")
|
|
|
|
// Remove timestamp patterns
|
|
parts := strings.Split(baseName, "_")
|
|
if len(parts) > 0 {
|
|
return parts[0]
|
|
}
|
|
|
|
return "unknown"
|
|
}
|
|
|
|
func listDatabasesInCluster(archivePath string) ([]string, error) {
|
|
// This would extract and list databases from tar.gz
|
|
// For now, return empty to indicate it needs implementation
|
|
return nil, fmt.Errorf("not implemented")
|
|
}
|