feat: Add enterprise DBA features for production reliability
New features implemented: 1. Backup Catalog (internal/catalog/) - SQLite-based backup tracking - Gap detection and RPO monitoring - Search and statistics - Filesystem sync 2. DR Drill Testing (internal/drill/) - Automated restore testing in Docker containers - Database validation with custom queries - Catalog integration for drill-tested status 3. Smart Notifications (internal/notify/) - Event batching with configurable intervals - Time-based escalation policies - HTML/text/Slack templates 4. Compliance Reports (internal/report/) - SOC2, GDPR, HIPAA, PCI-DSS, ISO27001 frameworks - Evidence collection from catalog - JSON, Markdown, HTML output formats 5. RTO/RPO Calculator (internal/rto/) - Recovery objective analysis - RTO breakdown by phase - Recommendations for improvement 6. Replica-Aware Backup (internal/replica/) - Topology detection for PostgreSQL/MySQL - Automatic replica selection - Configurable selection strategies 7. Parallel Table Backup (internal/parallel/) - Concurrent table dumps - Worker pool with progress tracking - Large table optimization 8. MySQL/MariaDB PITR (internal/pitr/) - Binary log parsing and replay - Point-in-time recovery support - Transaction filtering CLI commands added: catalog, drill, report, rto All changes support the goal: reliable 3 AM database recovery.
This commit is contained in:
725
cmd/catalog.go
Normal file
725
cmd/catalog.go
Normal file
@@ -0,0 +1,725 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"dbbackup/internal/catalog"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
catalogDBPath string
|
||||
catalogFormat string
|
||||
catalogLimit int
|
||||
catalogDatabase string
|
||||
catalogStartDate string
|
||||
catalogEndDate string
|
||||
catalogInterval string
|
||||
catalogVerbose bool
|
||||
)
|
||||
|
||||
// catalogCmd represents the catalog command group
|
||||
var catalogCmd = &cobra.Command{
|
||||
Use: "catalog",
|
||||
Short: "Backup catalog management",
|
||||
Long: `Manage the backup catalog - a SQLite database tracking all backups.
|
||||
|
||||
The catalog provides:
|
||||
- Searchable history of all backups
|
||||
- Gap detection for backup schedules
|
||||
- Statistics and reporting
|
||||
- Integration with DR drill testing
|
||||
|
||||
Examples:
|
||||
# Sync backups from a directory
|
||||
dbbackup catalog sync /backups
|
||||
|
||||
# List all backups
|
||||
dbbackup catalog list
|
||||
|
||||
# Show catalog statistics
|
||||
dbbackup catalog stats
|
||||
|
||||
# Detect gaps in backup schedule
|
||||
dbbackup catalog gaps mydb --interval 24h
|
||||
|
||||
# Search backups
|
||||
dbbackup catalog search --database mydb --after 2024-01-01`,
|
||||
}
|
||||
|
||||
// catalogSyncCmd syncs backups from directory
|
||||
var catalogSyncCmd = &cobra.Command{
|
||||
Use: "sync [directory]",
|
||||
Short: "Sync backups from directory into catalog",
|
||||
Long: `Scan a directory for backup files and import them into the catalog.
|
||||
|
||||
This command:
|
||||
- Finds all .meta.json files
|
||||
- Imports backup metadata into SQLite catalog
|
||||
- Detects removed backups
|
||||
- Updates changed entries
|
||||
|
||||
Examples:
|
||||
# Sync from backup directory
|
||||
dbbackup catalog sync /backups
|
||||
|
||||
# Sync with verbose output
|
||||
dbbackup catalog sync /backups --verbose`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: runCatalogSync,
|
||||
}
|
||||
|
||||
// catalogListCmd lists backups
|
||||
var catalogListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List backups in catalog",
|
||||
Long: `List all backups in the catalog with optional filtering.
|
||||
|
||||
Examples:
|
||||
# List all backups
|
||||
dbbackup catalog list
|
||||
|
||||
# List backups for specific database
|
||||
dbbackup catalog list --database mydb
|
||||
|
||||
# List last 10 backups
|
||||
dbbackup catalog list --limit 10
|
||||
|
||||
# Output as JSON
|
||||
dbbackup catalog list --format json`,
|
||||
RunE: runCatalogList,
|
||||
}
|
||||
|
||||
// catalogStatsCmd shows statistics
|
||||
var catalogStatsCmd = &cobra.Command{
|
||||
Use: "stats",
|
||||
Short: "Show catalog statistics",
|
||||
Long: `Display comprehensive backup statistics.
|
||||
|
||||
Shows:
|
||||
- Total backup count and size
|
||||
- Backups by database
|
||||
- Backups by type and status
|
||||
- Verification and drill test coverage
|
||||
|
||||
Examples:
|
||||
# Show overall stats
|
||||
dbbackup catalog stats
|
||||
|
||||
# Stats for specific database
|
||||
dbbackup catalog stats --database mydb
|
||||
|
||||
# Output as JSON
|
||||
dbbackup catalog stats --format json`,
|
||||
RunE: runCatalogStats,
|
||||
}
|
||||
|
||||
// catalogGapsCmd detects schedule gaps
|
||||
var catalogGapsCmd = &cobra.Command{
|
||||
Use: "gaps [database]",
|
||||
Short: "Detect gaps in backup schedule",
|
||||
Long: `Analyze backup history and detect schedule gaps.
|
||||
|
||||
This helps identify:
|
||||
- Missed backups
|
||||
- Schedule irregularities
|
||||
- RPO violations
|
||||
|
||||
Examples:
|
||||
# Check all databases for gaps (24h expected interval)
|
||||
dbbackup catalog gaps
|
||||
|
||||
# Check specific database with custom interval
|
||||
dbbackup catalog gaps mydb --interval 6h
|
||||
|
||||
# Check gaps in date range
|
||||
dbbackup catalog gaps --after 2024-01-01 --before 2024-02-01`,
|
||||
RunE: runCatalogGaps,
|
||||
}
|
||||
|
||||
// catalogSearchCmd searches backups
|
||||
var catalogSearchCmd = &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "Search backups in catalog",
|
||||
Long: `Search for backups matching specific criteria.
|
||||
|
||||
Examples:
|
||||
# Search by database name (supports wildcards)
|
||||
dbbackup catalog search --database "prod*"
|
||||
|
||||
# Search by date range
|
||||
dbbackup catalog search --after 2024-01-01 --before 2024-02-01
|
||||
|
||||
# Search verified backups only
|
||||
dbbackup catalog search --verified
|
||||
|
||||
# Search encrypted backups
|
||||
dbbackup catalog search --encrypted`,
|
||||
RunE: runCatalogSearch,
|
||||
}
|
||||
|
||||
// catalogInfoCmd shows entry details
|
||||
var catalogInfoCmd = &cobra.Command{
|
||||
Use: "info [backup-path]",
|
||||
Short: "Show detailed info for a backup",
|
||||
Long: `Display detailed information about a specific backup.
|
||||
|
||||
Examples:
|
||||
# Show info by path
|
||||
dbbackup catalog info /backups/mydb_20240115.dump.gz`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runCatalogInfo,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(catalogCmd)
|
||||
|
||||
// Default catalog path
|
||||
defaultCatalogPath := filepath.Join(getDefaultConfigDir(), "catalog.db")
|
||||
|
||||
// Global catalog flags
|
||||
catalogCmd.PersistentFlags().StringVar(&catalogDBPath, "catalog-db", defaultCatalogPath,
|
||||
"Path to catalog SQLite database")
|
||||
catalogCmd.PersistentFlags().StringVar(&catalogFormat, "format", "table",
|
||||
"Output format: table, json, csv")
|
||||
|
||||
// Add subcommands
|
||||
catalogCmd.AddCommand(catalogSyncCmd)
|
||||
catalogCmd.AddCommand(catalogListCmd)
|
||||
catalogCmd.AddCommand(catalogStatsCmd)
|
||||
catalogCmd.AddCommand(catalogGapsCmd)
|
||||
catalogCmd.AddCommand(catalogSearchCmd)
|
||||
catalogCmd.AddCommand(catalogInfoCmd)
|
||||
|
||||
// Sync flags
|
||||
catalogSyncCmd.Flags().BoolVarP(&catalogVerbose, "verbose", "v", false, "Show detailed output")
|
||||
|
||||
// List flags
|
||||
catalogListCmd.Flags().IntVar(&catalogLimit, "limit", 50, "Maximum entries to show")
|
||||
catalogListCmd.Flags().StringVar(&catalogDatabase, "database", "", "Filter by database name")
|
||||
|
||||
// Stats flags
|
||||
catalogStatsCmd.Flags().StringVar(&catalogDatabase, "database", "", "Show stats for specific database")
|
||||
|
||||
// Gaps flags
|
||||
catalogGapsCmd.Flags().StringVar(&catalogInterval, "interval", "24h", "Expected backup interval")
|
||||
catalogGapsCmd.Flags().StringVar(&catalogStartDate, "after", "", "Start date (YYYY-MM-DD)")
|
||||
catalogGapsCmd.Flags().StringVar(&catalogEndDate, "before", "", "End date (YYYY-MM-DD)")
|
||||
|
||||
// Search flags
|
||||
catalogSearchCmd.Flags().StringVar(&catalogDatabase, "database", "", "Filter by database name (supports wildcards)")
|
||||
catalogSearchCmd.Flags().StringVar(&catalogStartDate, "after", "", "Backups after date (YYYY-MM-DD)")
|
||||
catalogSearchCmd.Flags().StringVar(&catalogEndDate, "before", "", "Backups before date (YYYY-MM-DD)")
|
||||
catalogSearchCmd.Flags().IntVar(&catalogLimit, "limit", 100, "Maximum results")
|
||||
catalogSearchCmd.Flags().Bool("verified", false, "Only verified backups")
|
||||
catalogSearchCmd.Flags().Bool("encrypted", false, "Only encrypted backups")
|
||||
catalogSearchCmd.Flags().Bool("drill-tested", false, "Only drill-tested backups")
|
||||
}
|
||||
|
||||
func getDefaultConfigDir() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".dbbackup")
|
||||
}
|
||||
|
||||
func openCatalog() (*catalog.SQLiteCatalog, error) {
|
||||
return catalog.NewSQLiteCatalog(catalogDBPath)
|
||||
}
|
||||
|
||||
func runCatalogSync(cmd *cobra.Command, args []string) error {
|
||||
dir := args[0]
|
||||
|
||||
// Validate directory
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("directory not found: %s", dir)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("not a directory: %s", dir)
|
||||
}
|
||||
|
||||
absDir, _ := filepath.Abs(dir)
|
||||
|
||||
cat, err := openCatalog()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cat.Close()
|
||||
|
||||
fmt.Printf("📁 Syncing backups from: %s\n", absDir)
|
||||
fmt.Printf("📊 Catalog database: %s\n\n", catalogDBPath)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := cat.SyncFromDirectory(ctx, absDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
cat.SetLastSync(ctx)
|
||||
|
||||
// Show results
|
||||
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||
fmt.Printf(" Sync Results\n")
|
||||
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||
fmt.Printf(" ✅ Added: %d\n", result.Added)
|
||||
fmt.Printf(" 🔄 Updated: %d\n", result.Updated)
|
||||
fmt.Printf(" 🗑️ Removed: %d\n", result.Removed)
|
||||
if result.Errors > 0 {
|
||||
fmt.Printf(" ❌ Errors: %d\n", result.Errors)
|
||||
}
|
||||
fmt.Printf(" ⏱️ Duration: %.2fs\n", result.Duration)
|
||||
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||
|
||||
// Show details if verbose
|
||||
if catalogVerbose && len(result.Details) > 0 {
|
||||
fmt.Printf("\nDetails:\n")
|
||||
for _, detail := range result.Details {
|
||||
fmt.Printf(" %s\n", detail)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCatalogList(cmd *cobra.Command, args []string) error {
|
||||
cat, err := openCatalog()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cat.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
query := &catalog.SearchQuery{
|
||||
Database: catalogDatabase,
|
||||
Limit: catalogLimit,
|
||||
OrderBy: "created_at",
|
||||
OrderDesc: true,
|
||||
}
|
||||
|
||||
entries, err := cat.Search(ctx, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("No backups in catalog. Run 'dbbackup catalog sync <directory>' to import backups.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if catalogFormat == "json" {
|
||||
data, _ := json.MarshalIndent(entries, "", " ")
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Table format
|
||||
fmt.Printf("%-30s %-12s %-10s %-20s %-10s %s\n",
|
||||
"DATABASE", "TYPE", "SIZE", "CREATED", "STATUS", "PATH")
|
||||
fmt.Println(strings.Repeat("─", 120))
|
||||
|
||||
for _, entry := range entries {
|
||||
dbName := truncateString(entry.Database, 28)
|
||||
backupPath := truncateString(filepath.Base(entry.BackupPath), 40)
|
||||
|
||||
status := string(entry.Status)
|
||||
if entry.VerifyValid != nil && *entry.VerifyValid {
|
||||
status = "✓ verified"
|
||||
}
|
||||
if entry.DrillSuccess != nil && *entry.DrillSuccess {
|
||||
status = "✓ tested"
|
||||
}
|
||||
|
||||
fmt.Printf("%-30s %-12s %-10s %-20s %-10s %s\n",
|
||||
dbName,
|
||||
entry.DatabaseType,
|
||||
catalog.FormatSize(entry.SizeBytes),
|
||||
entry.CreatedAt.Format("2006-01-02 15:04"),
|
||||
status,
|
||||
backupPath,
|
||||
)
|
||||
}
|
||||
|
||||
fmt.Printf("\nShowing %d of %d total backups\n", len(entries), len(entries))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCatalogStats(cmd *cobra.Command, args []string) error {
|
||||
cat, err := openCatalog()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cat.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
var stats *catalog.Stats
|
||||
if catalogDatabase != "" {
|
||||
stats, err = cat.StatsByDatabase(ctx, catalogDatabase)
|
||||
} else {
|
||||
stats, err = cat.Stats(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if catalogFormat == "json" {
|
||||
data, _ := json.MarshalIndent(stats, "", " ")
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Table format
|
||||
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||
if catalogDatabase != "" {
|
||||
fmt.Printf(" Catalog Statistics: %s\n", catalogDatabase)
|
||||
} else {
|
||||
fmt.Printf(" Catalog Statistics\n")
|
||||
}
|
||||
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n")
|
||||
|
||||
fmt.Printf("📊 Total Backups: %d\n", stats.TotalBackups)
|
||||
fmt.Printf("💾 Total Size: %s\n", stats.TotalSizeHuman)
|
||||
fmt.Printf("📏 Average Size: %s\n", catalog.FormatSize(stats.AvgSize))
|
||||
fmt.Printf("⏱️ Average Duration: %.1fs\n", stats.AvgDuration)
|
||||
fmt.Printf("✅ Verified: %d\n", stats.VerifiedCount)
|
||||
fmt.Printf("🧪 Drill Tested: %d\n", stats.DrillTestedCount)
|
||||
|
||||
if stats.OldestBackup != nil {
|
||||
fmt.Printf("📅 Oldest Backup: %s\n", stats.OldestBackup.Format("2006-01-02 15:04"))
|
||||
}
|
||||
if stats.NewestBackup != nil {
|
||||
fmt.Printf("📅 Newest Backup: %s\n", stats.NewestBackup.Format("2006-01-02 15:04"))
|
||||
}
|
||||
|
||||
if len(stats.ByDatabase) > 0 && catalogDatabase == "" {
|
||||
fmt.Printf("\n📁 By Database:\n")
|
||||
for db, count := range stats.ByDatabase {
|
||||
fmt.Printf(" %-30s %d\n", db, count)
|
||||
}
|
||||
}
|
||||
|
||||
if len(stats.ByType) > 0 {
|
||||
fmt.Printf("\n📦 By Type:\n")
|
||||
for t, count := range stats.ByType {
|
||||
fmt.Printf(" %-15s %d\n", t, count)
|
||||
}
|
||||
}
|
||||
|
||||
if len(stats.ByStatus) > 0 {
|
||||
fmt.Printf("\n📋 By Status:\n")
|
||||
for s, count := range stats.ByStatus {
|
||||
fmt.Printf(" %-15s %d\n", s, count)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCatalogGaps(cmd *cobra.Command, args []string) error {
|
||||
cat, err := openCatalog()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cat.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Parse interval
|
||||
interval, err := time.ParseDuration(catalogInterval)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid interval: %w", err)
|
||||
}
|
||||
|
||||
config := &catalog.GapDetectionConfig{
|
||||
ExpectedInterval: interval,
|
||||
Tolerance: interval / 4, // 25% tolerance
|
||||
RPOThreshold: interval * 2, // 2x interval = critical
|
||||
}
|
||||
|
||||
// Parse date range
|
||||
if catalogStartDate != "" {
|
||||
t, err := time.Parse("2006-01-02", catalogStartDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid start date: %w", err)
|
||||
}
|
||||
config.StartDate = &t
|
||||
}
|
||||
if catalogEndDate != "" {
|
||||
t, err := time.Parse("2006-01-02", catalogEndDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid end date: %w", err)
|
||||
}
|
||||
config.EndDate = &t
|
||||
}
|
||||
|
||||
var allGaps map[string][]*catalog.Gap
|
||||
|
||||
if len(args) > 0 {
|
||||
// Specific database
|
||||
database := args[0]
|
||||
gaps, err := cat.DetectGaps(ctx, database, config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(gaps) > 0 {
|
||||
allGaps = map[string][]*catalog.Gap{database: gaps}
|
||||
}
|
||||
} else {
|
||||
// All databases
|
||||
allGaps, err = cat.DetectAllGaps(ctx, config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if catalogFormat == "json" {
|
||||
data, _ := json.MarshalIndent(allGaps, "", " ")
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(allGaps) == 0 {
|
||||
fmt.Printf("✅ No backup gaps detected (expected interval: %s)\n", interval)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||
fmt.Printf(" Backup Gaps Detected (expected interval: %s)\n", interval)
|
||||
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n")
|
||||
|
||||
totalGaps := 0
|
||||
criticalGaps := 0
|
||||
|
||||
for database, gaps := range allGaps {
|
||||
fmt.Printf("📁 %s (%d gaps)\n", database, len(gaps))
|
||||
|
||||
for _, gap := range gaps {
|
||||
totalGaps++
|
||||
icon := "ℹ️"
|
||||
switch gap.Severity {
|
||||
case catalog.SeverityWarning:
|
||||
icon = "⚠️"
|
||||
case catalog.SeverityCritical:
|
||||
icon = "🚨"
|
||||
criticalGaps++
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s\n", icon, gap.Description)
|
||||
fmt.Printf(" Gap: %s → %s (%s)\n",
|
||||
gap.GapStart.Format("2006-01-02 15:04"),
|
||||
gap.GapEnd.Format("2006-01-02 15:04"),
|
||||
catalog.FormatDuration(gap.Duration))
|
||||
fmt.Printf(" Expected at: %s\n", gap.ExpectedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||
fmt.Printf("Total: %d gaps detected", totalGaps)
|
||||
if criticalGaps > 0 {
|
||||
fmt.Printf(" (%d critical)", criticalGaps)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCatalogSearch(cmd *cobra.Command, args []string) error {
|
||||
cat, err := openCatalog()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cat.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
query := &catalog.SearchQuery{
|
||||
Database: catalogDatabase,
|
||||
Limit: catalogLimit,
|
||||
OrderBy: "created_at",
|
||||
OrderDesc: true,
|
||||
}
|
||||
|
||||
// Parse date range
|
||||
if catalogStartDate != "" {
|
||||
t, err := time.Parse("2006-01-02", catalogStartDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid start date: %w", err)
|
||||
}
|
||||
query.StartDate = &t
|
||||
}
|
||||
if catalogEndDate != "" {
|
||||
t, err := time.Parse("2006-01-02", catalogEndDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid end date: %w", err)
|
||||
}
|
||||
query.EndDate = &t
|
||||
}
|
||||
|
||||
// Boolean filters
|
||||
if verified, _ := cmd.Flags().GetBool("verified"); verified {
|
||||
t := true
|
||||
query.Verified = &t
|
||||
}
|
||||
if encrypted, _ := cmd.Flags().GetBool("encrypted"); encrypted {
|
||||
t := true
|
||||
query.Encrypted = &t
|
||||
}
|
||||
if drillTested, _ := cmd.Flags().GetBool("drill-tested"); drillTested {
|
||||
t := true
|
||||
query.DrillTested = &t
|
||||
}
|
||||
|
||||
entries, err := cat.Search(ctx, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("No matching backups found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if catalogFormat == "json" {
|
||||
data, _ := json.MarshalIndent(entries, "", " ")
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d matching backups:\n\n", len(entries))
|
||||
|
||||
for _, entry := range entries {
|
||||
fmt.Printf("📁 %s\n", entry.Database)
|
||||
fmt.Printf(" Path: %s\n", entry.BackupPath)
|
||||
fmt.Printf(" Type: %s | Size: %s | Created: %s\n",
|
||||
entry.DatabaseType,
|
||||
catalog.FormatSize(entry.SizeBytes),
|
||||
entry.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
if entry.Encrypted {
|
||||
fmt.Printf(" 🔒 Encrypted\n")
|
||||
}
|
||||
if entry.VerifyValid != nil && *entry.VerifyValid {
|
||||
fmt.Printf(" ✅ Verified: %s\n", entry.VerifiedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
if entry.DrillSuccess != nil && *entry.DrillSuccess {
|
||||
fmt.Printf(" 🧪 Drill Tested: %s\n", entry.DrillTestedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCatalogInfo(cmd *cobra.Command, args []string) error {
|
||||
backupPath := args[0]
|
||||
|
||||
cat, err := openCatalog()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cat.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Try absolute path
|
||||
absPath, _ := filepath.Abs(backupPath)
|
||||
entry, err := cat.GetByPath(ctx, absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
// Try as provided
|
||||
entry, err = cat.GetByPath(ctx, backupPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
return fmt.Errorf("backup not found in catalog: %s", backupPath)
|
||||
}
|
||||
|
||||
if catalogFormat == "json" {
|
||||
data, _ := json.MarshalIndent(entry, "", " ")
|
||||
fmt.Println(string(data))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||
fmt.Printf(" Backup Details\n")
|
||||
fmt.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n")
|
||||
|
||||
fmt.Printf("📁 Database: %s\n", entry.Database)
|
||||
fmt.Printf("🔧 Type: %s\n", entry.DatabaseType)
|
||||
fmt.Printf("🖥️ Host: %s:%d\n", entry.Host, entry.Port)
|
||||
fmt.Printf("📂 Path: %s\n", entry.BackupPath)
|
||||
fmt.Printf("📦 Backup Type: %s\n", entry.BackupType)
|
||||
fmt.Printf("💾 Size: %s (%d bytes)\n", catalog.FormatSize(entry.SizeBytes), entry.SizeBytes)
|
||||
fmt.Printf("🔐 SHA256: %s\n", entry.SHA256)
|
||||
fmt.Printf("📅 Created: %s\n", entry.CreatedAt.Format("2006-01-02 15:04:05 MST"))
|
||||
fmt.Printf("⏱️ Duration: %.2fs\n", entry.Duration)
|
||||
fmt.Printf("📋 Status: %s\n", entry.Status)
|
||||
|
||||
if entry.Compression != "" {
|
||||
fmt.Printf("📦 Compression: %s\n", entry.Compression)
|
||||
}
|
||||
if entry.Encrypted {
|
||||
fmt.Printf("🔒 Encrypted: yes\n")
|
||||
}
|
||||
if entry.CloudLocation != "" {
|
||||
fmt.Printf("☁️ Cloud: %s\n", entry.CloudLocation)
|
||||
}
|
||||
if entry.RetentionPolicy != "" {
|
||||
fmt.Printf("📆 Retention: %s\n", entry.RetentionPolicy)
|
||||
}
|
||||
|
||||
fmt.Printf("\n📊 Verification:\n")
|
||||
if entry.VerifiedAt != nil {
|
||||
status := "❌ Failed"
|
||||
if entry.VerifyValid != nil && *entry.VerifyValid {
|
||||
status = "✅ Valid"
|
||||
}
|
||||
fmt.Printf(" Status: %s (checked %s)\n", status, entry.VerifiedAt.Format("2006-01-02 15:04"))
|
||||
} else {
|
||||
fmt.Printf(" Status: ⏳ Not verified\n")
|
||||
}
|
||||
|
||||
fmt.Printf("\n🧪 DR Drill Test:\n")
|
||||
if entry.DrillTestedAt != nil {
|
||||
status := "❌ Failed"
|
||||
if entry.DrillSuccess != nil && *entry.DrillSuccess {
|
||||
status = "✅ Passed"
|
||||
}
|
||||
fmt.Printf(" Status: %s (tested %s)\n", status, entry.DrillTestedAt.Format("2006-01-02 15:04"))
|
||||
} else {
|
||||
fmt.Printf(" Status: ⏳ Not tested\n")
|
||||
}
|
||||
|
||||
if len(entry.Metadata) > 0 {
|
||||
fmt.Printf("\n📝 Additional Metadata:\n")
|
||||
for k, v := range entry.Metadata {
|
||||
fmt.Printf(" %s: %s\n", k, v)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user