Files
dbbackup/cmd/restore_preview.go
Alexander Renz 14e893f433
Some checks failed
CI/CD / Test (push) Successful in 1m13s
CI/CD / Integration Tests (push) Has been cancelled
CI/CD / Build & Release (push) Has been cancelled
CI/CD / Lint (push) Has been cancelled
feat: Add backup diff command (Quick Win #2)
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
2026-01-30 12:59:32 +01:00

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")
}