Some checks failed
CI/CD / Test (push) Has been cancelled
CI/CD / Integration Tests (push) Has been cancelled
CI/CD / Native Engine Tests (push) Has been cancelled
CI/CD / Lint (push) Has been cancelled
CI/CD / Build Binary (push) Has been cancelled
CI/CD / Test Release Build (push) Has been cancelled
CI/CD / Release Binaries (push) Has been cancelled
- Add 'dbbackup catalog prune' subcommand - Support --missing flag to remove entries for deleted backup files - Support --older-than duration (90d, 6m, 1y) for retention cleanup - Support --status flag to remove failed/corrupted entries - Add --dry-run flag to preview changes without deleting - Add --database filter for targeted pruning - Display detailed results with space freed estimates - Implement PruneAdvanced() in catalog package - Add parseDuration() helper for flexible time parsing Quick Win #2 from TODO list
911 lines
24 KiB
Go
911 lines
24 KiB
Go
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,
|
|
}
|
|
|
|
var catalogPruneCmd = &cobra.Command{
|
|
Use: "prune",
|
|
Short: "Remove old or invalid entries from catalog",
|
|
Long: `Clean up the catalog by removing entries that meet specified criteria.
|
|
|
|
This command can remove:
|
|
- Entries for backups that no longer exist on disk
|
|
- Entries older than a specified retention period
|
|
- Failed or corrupted backups
|
|
- Entries marked as deleted
|
|
|
|
Examples:
|
|
# Remove entries for missing backup files
|
|
dbbackup catalog prune --missing
|
|
|
|
# Remove entries older than 90 days
|
|
dbbackup catalog prune --older-than 90d
|
|
|
|
# Remove failed backups
|
|
dbbackup catalog prune --status failed
|
|
|
|
# Dry run (preview without deleting)
|
|
dbbackup catalog prune --missing --dry-run
|
|
|
|
# Combined: remove missing and old entries
|
|
dbbackup catalog prune --missing --older-than 30d`,
|
|
RunE: runCatalogPrune,
|
|
}
|
|
|
|
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)
|
|
catalogCmd.AddCommand(catalogPruneCmd)
|
|
|
|
// 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")
|
|
|
|
// Prune flags
|
|
catalogPruneCmd.Flags().Bool("missing", false, "Remove entries for missing backup files")
|
|
catalogPruneCmd.Flags().String("older-than", "", "Remove entries older than duration (e.g., 90d, 6m, 1y)")
|
|
catalogPruneCmd.Flags().String("status", "", "Remove entries with specific status (failed, corrupted, deleted)")
|
|
catalogPruneCmd.Flags().Bool("dry-run", false, "Preview changes without actually deleting")
|
|
catalogPruneCmd.Flags().StringVar(&catalogDatabase, "database", "", "Only prune entries for specific database")
|
|
}
|
|
|
|
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("[DIR] Syncing backups from: %s\n", absDir)
|
|
fmt.Printf("[STATS] 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(" [OK] Added: %d\n", result.Added)
|
|
fmt.Printf(" [SYNC] Updated: %d\n", result.Updated)
|
|
fmt.Printf(" [DEL] Removed: %d\n", result.Removed)
|
|
if result.Skipped > 0 {
|
|
fmt.Printf(" [SKIP] Skipped: %d (legacy files without metadata)\n", result.Skipped)
|
|
}
|
|
if result.Errors > 0 {
|
|
fmt.Printf(" [FAIL] Errors: %d\n", result.Errors)
|
|
}
|
|
fmt.Printf(" [TIME] Duration: %.2fs\n", result.Duration)
|
|
fmt.Printf("=====================================================\n")
|
|
|
|
// Show legacy backup warning
|
|
if result.LegacyWarning != "" {
|
|
fmt.Printf("\n[WARN] %s\n", result.LegacyWarning)
|
|
}
|
|
|
|
// 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 = "[OK] verified"
|
|
}
|
|
if entry.DrillSuccess != nil && *entry.DrillSuccess {
|
|
status = "[OK] 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("[STATS] Total Backups: %d\n", stats.TotalBackups)
|
|
fmt.Printf("[SAVE] Total Size: %s\n", stats.TotalSizeHuman)
|
|
fmt.Printf("[SIZE] Average Size: %s\n", catalog.FormatSize(stats.AvgSize))
|
|
fmt.Printf("[TIME] Average Duration: %.1fs\n", stats.AvgDuration)
|
|
fmt.Printf("[OK] Verified: %d\n", stats.VerifiedCount)
|
|
fmt.Printf("[TEST] 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[DIR] By Database:\n")
|
|
for db, count := range stats.ByDatabase {
|
|
fmt.Printf(" %-30s %d\n", db, count)
|
|
}
|
|
}
|
|
|
|
if len(stats.ByType) > 0 {
|
|
fmt.Printf("\n[PKG] By Type:\n")
|
|
for t, count := range stats.ByType {
|
|
fmt.Printf(" %-15s %d\n", t, count)
|
|
}
|
|
}
|
|
|
|
if len(stats.ByStatus) > 0 {
|
|
fmt.Printf("\n[LOG] 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("[OK] 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("[DIR] %s (%d gaps)\n", database, len(gaps))
|
|
|
|
for _, gap := range gaps {
|
|
totalGaps++
|
|
icon := "[INFO]"
|
|
switch gap.Severity {
|
|
case catalog.SeverityWarning:
|
|
icon = "[WARN]"
|
|
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("[DIR] %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(" [LOCK] Encrypted\n")
|
|
}
|
|
if entry.VerifyValid != nil && *entry.VerifyValid {
|
|
fmt.Printf(" [OK] Verified: %s\n", entry.VerifiedAt.Format("2006-01-02 15:04"))
|
|
}
|
|
if entry.DrillSuccess != nil && *entry.DrillSuccess {
|
|
fmt.Printf(" [TEST] 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("[DIR] Database: %s\n", entry.Database)
|
|
fmt.Printf("🔧 Type: %s\n", entry.DatabaseType)
|
|
fmt.Printf("[HOST] Host: %s:%d\n", entry.Host, entry.Port)
|
|
fmt.Printf("📂 Path: %s\n", entry.BackupPath)
|
|
fmt.Printf("[PKG] Backup Type: %s\n", entry.BackupType)
|
|
fmt.Printf("[SAVE] Size: %s (%d bytes)\n", catalog.FormatSize(entry.SizeBytes), entry.SizeBytes)
|
|
fmt.Printf("[HASH] SHA256: %s\n", entry.SHA256)
|
|
fmt.Printf("📅 Created: %s\n", entry.CreatedAt.Format("2006-01-02 15:04:05 MST"))
|
|
fmt.Printf("[TIME] Duration: %.2fs\n", entry.Duration)
|
|
fmt.Printf("[LOG] Status: %s\n", entry.Status)
|
|
|
|
if entry.Compression != "" {
|
|
fmt.Printf("[PKG] Compression: %s\n", entry.Compression)
|
|
}
|
|
if entry.Encrypted {
|
|
fmt.Printf("[LOCK] Encrypted: yes\n")
|
|
}
|
|
if entry.CloudLocation != "" {
|
|
fmt.Printf("[CLOUD] Cloud: %s\n", entry.CloudLocation)
|
|
}
|
|
if entry.RetentionPolicy != "" {
|
|
fmt.Printf("📆 Retention: %s\n", entry.RetentionPolicy)
|
|
}
|
|
|
|
fmt.Printf("\n[STATS] Verification:\n")
|
|
if entry.VerifiedAt != nil {
|
|
status := "[FAIL] Failed"
|
|
if entry.VerifyValid != nil && *entry.VerifyValid {
|
|
status = "[OK] Valid"
|
|
}
|
|
fmt.Printf(" Status: %s (checked %s)\n", status, entry.VerifiedAt.Format("2006-01-02 15:04"))
|
|
} else {
|
|
fmt.Printf(" Status: [WAIT] Not verified\n")
|
|
}
|
|
|
|
fmt.Printf("\n[TEST] DR Drill Test:\n")
|
|
if entry.DrillTestedAt != nil {
|
|
status := "[FAIL] Failed"
|
|
if entry.DrillSuccess != nil && *entry.DrillSuccess {
|
|
status = "[OK] Passed"
|
|
}
|
|
fmt.Printf(" Status: %s (tested %s)\n", status, entry.DrillTestedAt.Format("2006-01-02 15:04"))
|
|
} else {
|
|
fmt.Printf(" Status: [WAIT] Not tested\n")
|
|
}
|
|
|
|
if len(entry.Metadata) > 0 {
|
|
fmt.Printf("\n[NOTE] Additional Metadata:\n")
|
|
for k, v := range entry.Metadata {
|
|
fmt.Printf(" %s: %s\n", k, v)
|
|
}
|
|
}
|
|
|
|
fmt.Printf("\n=====================================================\n")
|
|
|
|
return nil
|
|
}
|
|
|
|
func runCatalogPrune(cmd *cobra.Command, args []string) error {
|
|
cat, err := openCatalog()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cat.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Parse flags
|
|
missing, _ := cmd.Flags().GetBool("missing")
|
|
olderThan, _ := cmd.Flags().GetString("older-than")
|
|
status, _ := cmd.Flags().GetString("status")
|
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
|
|
|
// Validate that at least one criterion is specified
|
|
if !missing && olderThan == "" && status == "" {
|
|
return fmt.Errorf("at least one prune criterion must be specified (--missing, --older-than, or --status)")
|
|
}
|
|
|
|
// Parse olderThan duration
|
|
var cutoffTime *time.Time
|
|
if olderThan != "" {
|
|
duration, err := parseDuration(olderThan)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid duration: %w", err)
|
|
}
|
|
t := time.Now().Add(-duration)
|
|
cutoffTime = &t
|
|
}
|
|
|
|
// Validate status
|
|
if status != "" && status != "failed" && status != "corrupted" && status != "deleted" {
|
|
return fmt.Errorf("invalid status: %s (must be: failed, corrupted, or deleted)", status)
|
|
}
|
|
|
|
pruneConfig := &catalog.PruneConfig{
|
|
CheckMissing: missing,
|
|
OlderThan: cutoffTime,
|
|
Status: status,
|
|
Database: catalogDatabase,
|
|
DryRun: dryRun,
|
|
}
|
|
|
|
fmt.Printf("=====================================================\n")
|
|
if dryRun {
|
|
fmt.Printf(" Catalog Prune (DRY RUN)\n")
|
|
} else {
|
|
fmt.Printf(" Catalog Prune\n")
|
|
}
|
|
fmt.Printf("=====================================================\n\n")
|
|
|
|
if catalogDatabase != "" {
|
|
fmt.Printf("[DIR] Database filter: %s\n", catalogDatabase)
|
|
}
|
|
if missing {
|
|
fmt.Printf("[CHK] Checking for missing backup files...\n")
|
|
}
|
|
if cutoffTime != nil {
|
|
fmt.Printf("[TIME] Removing entries older than: %s (%s)\n", cutoffTime.Format("2006-01-02"), olderThan)
|
|
}
|
|
if status != "" {
|
|
fmt.Printf("[LOG] Removing entries with status: %s\n", status)
|
|
}
|
|
fmt.Println()
|
|
|
|
result, err := cat.PruneAdvanced(ctx, pruneConfig)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if result.TotalChecked == 0 {
|
|
fmt.Printf("[INFO] No entries found matching criteria\n")
|
|
return nil
|
|
}
|
|
|
|
// Show results
|
|
fmt.Printf("=====================================================\n")
|
|
fmt.Printf(" Prune Results\n")
|
|
fmt.Printf("=====================================================\n")
|
|
fmt.Printf(" [CHK] Checked: %d entries\n", result.TotalChecked)
|
|
if dryRun {
|
|
fmt.Printf(" [WAIT] Would remove: %d entries\n", result.Removed)
|
|
} else {
|
|
fmt.Printf(" [DEL] Removed: %d entries\n", result.Removed)
|
|
}
|
|
fmt.Printf(" [TIME] Duration: %.2fs\n", result.Duration)
|
|
fmt.Printf("=====================================================\n")
|
|
|
|
if len(result.Details) > 0 {
|
|
fmt.Printf("\nRemoved entries:\n")
|
|
for _, detail := range result.Details {
|
|
fmt.Printf(" • %s\n", detail)
|
|
}
|
|
}
|
|
|
|
if result.SpaceFreed > 0 {
|
|
fmt.Printf("\n[SAVE] Estimated space freed: %s\n", catalog.FormatSize(result.SpaceFreed))
|
|
}
|
|
|
|
if dryRun {
|
|
fmt.Printf("\n[INFO] This was a dry run. Run without --dry-run to actually delete entries.\n")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// parseDuration extends time.ParseDuration to support days, months, years
|
|
func parseDuration(s string) (time.Duration, error) {
|
|
if len(s) < 2 {
|
|
return 0, fmt.Errorf("invalid duration: %s", s)
|
|
}
|
|
|
|
unit := s[len(s)-1]
|
|
value := s[:len(s)-1]
|
|
|
|
var multiplier time.Duration
|
|
switch unit {
|
|
case 'd': // days
|
|
multiplier = 24 * time.Hour
|
|
case 'w': // weeks
|
|
multiplier = 7 * 24 * time.Hour
|
|
case 'm': // months (approximate)
|
|
multiplier = 30 * 24 * time.Hour
|
|
case 'y': // years (approximate)
|
|
multiplier = 365 * 24 * time.Hour
|
|
default:
|
|
// Try standard time.ParseDuration
|
|
return time.ParseDuration(s)
|
|
}
|
|
|
|
var num int
|
|
_, err := fmt.Sscanf(value, "%d", &num)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid duration value: %s", value)
|
|
}
|
|
|
|
return time.Duration(num) * multiplier, nil
|
|
}
|
|
|
|
func truncateString(s string, maxLen int) string {
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
return s[:maxLen-3] + "..."
|
|
}
|