All checks were successful
CI/CD / Test (push) Successful in 1m17s
CI/CD / Lint (push) Successful in 1m12s
CI/CD / Integration Tests (push) Successful in 50s
CI/CD / Native Engine Tests (push) Successful in 50s
CI/CD / Build Binary (push) Successful in 47s
CI/CD / Test Release Build (push) Successful in 1m16s
CI/CD / Release Binaries (push) Has been skipped
- Add 'dbbackup chain' command to visualize backup dependencies - Display full backup → incremental backup relationships - Show backup sequence and timeline - Calculate total chain size and duration - Detect incomplete chains (incrementals without full backup) - Support --all flag to show all database chains - Support --verbose for detailed metadata - Support --format json for automation - Provides restore guidance (which backups are needed) - Warns about orphaned incremental backups Quick Win #8 from TODO list
299 lines
7.8 KiB
Go
299 lines
7.8 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"dbbackup/internal/catalog"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var chainCmd = &cobra.Command{
|
|
Use: "chain [database]",
|
|
Short: "Show backup chain (full → incremental)",
|
|
Long: `Display the backup chain showing the relationship between full and incremental backups.
|
|
|
|
This command helps understand:
|
|
- Which incremental backups depend on which full backup
|
|
- Backup sequence and timeline
|
|
- Gaps in the backup chain
|
|
- Total size of backup chain
|
|
|
|
The backup chain is crucial for:
|
|
- Point-in-Time Recovery (PITR)
|
|
- Understanding restore dependencies
|
|
- Identifying orphaned incremental backups
|
|
- Planning backup retention
|
|
|
|
Examples:
|
|
# Show chain for specific database
|
|
dbbackup chain mydb
|
|
|
|
# Show all backup chains
|
|
dbbackup chain --all
|
|
|
|
# JSON output for automation
|
|
dbbackup chain mydb --format json
|
|
|
|
# Show detailed chain with metadata
|
|
dbbackup chain mydb --verbose`,
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: runChain,
|
|
}
|
|
|
|
var (
|
|
chainFormat string
|
|
chainAll bool
|
|
chainVerbose bool
|
|
)
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(chainCmd)
|
|
chainCmd.Flags().StringVar(&chainFormat, "format", "table", "Output format (table, json)")
|
|
chainCmd.Flags().BoolVar(&chainAll, "all", false, "Show chains for all databases")
|
|
chainCmd.Flags().BoolVar(&chainVerbose, "verbose", false, "Show detailed information")
|
|
}
|
|
|
|
type BackupChain struct {
|
|
Database string `json:"database"`
|
|
FullBackup *catalog.Entry `json:"full_backup"`
|
|
Incrementals []*catalog.Entry `json:"incrementals"`
|
|
TotalSize int64 `json:"total_size"`
|
|
TotalBackups int `json:"total_backups"`
|
|
OldestBackup time.Time `json:"oldest_backup"`
|
|
NewestBackup time.Time `json:"newest_backup"`
|
|
ChainDuration time.Duration `json:"chain_duration"`
|
|
Incomplete bool `json:"incomplete"` // true if incrementals without full backup
|
|
}
|
|
|
|
func runChain(cmd *cobra.Command, args []string) error {
|
|
cat, err := openCatalog()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cat.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
var chains []*BackupChain
|
|
|
|
if chainAll || len(args) == 0 {
|
|
// Get all databases
|
|
databases, err := cat.ListDatabases(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, db := range databases {
|
|
chain, err := buildBackupChain(ctx, cat, db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if chain != nil && chain.TotalBackups > 0 {
|
|
chains = append(chains, chain)
|
|
}
|
|
}
|
|
|
|
if len(chains) == 0 {
|
|
fmt.Println("No backup chains found.")
|
|
fmt.Println("\nRun 'dbbackup catalog sync <directory>' to import backups into catalog.")
|
|
return nil
|
|
}
|
|
} else {
|
|
// Specific database
|
|
database := args[0]
|
|
chain, err := buildBackupChain(ctx, cat, database)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if chain == nil || chain.TotalBackups == 0 {
|
|
fmt.Printf("No backups found for database: %s\n", database)
|
|
return nil
|
|
}
|
|
|
|
chains = append(chains, chain)
|
|
}
|
|
|
|
// Output based on format
|
|
if chainFormat == "json" {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(chains)
|
|
}
|
|
|
|
// Table format
|
|
outputChainTable(chains)
|
|
return nil
|
|
}
|
|
|
|
func buildBackupChain(ctx context.Context, cat *catalog.SQLiteCatalog, database string) (*BackupChain, error) {
|
|
// Query all backups for this database, ordered by creation time
|
|
query := &catalog.SearchQuery{
|
|
Database: database,
|
|
Limit: 1000,
|
|
OrderBy: "created_at",
|
|
OrderDesc: false,
|
|
}
|
|
|
|
entries, err := cat.Search(ctx, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(entries) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
chain := &BackupChain{
|
|
Database: database,
|
|
Incrementals: []*catalog.Entry{},
|
|
}
|
|
|
|
var totalSize int64
|
|
var oldest, newest time.Time
|
|
|
|
// Find full backups and incrementals
|
|
for _, entry := range entries {
|
|
totalSize += entry.SizeBytes
|
|
|
|
if oldest.IsZero() || entry.CreatedAt.Before(oldest) {
|
|
oldest = entry.CreatedAt
|
|
}
|
|
if newest.IsZero() || entry.CreatedAt.After(newest) {
|
|
newest = entry.CreatedAt
|
|
}
|
|
|
|
// Check backup type
|
|
backupType := entry.BackupType
|
|
if backupType == "" {
|
|
backupType = "full" // default to full if not specified
|
|
}
|
|
|
|
if backupType == "full" {
|
|
// Use most recent full backup as base
|
|
if chain.FullBackup == nil || entry.CreatedAt.After(chain.FullBackup.CreatedAt) {
|
|
chain.FullBackup = entry
|
|
}
|
|
} else if backupType == "incremental" {
|
|
chain.Incrementals = append(chain.Incrementals, entry)
|
|
}
|
|
}
|
|
|
|
chain.TotalSize = totalSize
|
|
chain.TotalBackups = len(entries)
|
|
chain.OldestBackup = oldest
|
|
chain.NewestBackup = newest
|
|
if !oldest.IsZero() && !newest.IsZero() {
|
|
chain.ChainDuration = newest.Sub(oldest)
|
|
}
|
|
|
|
// Check if incomplete (incrementals without full backup)
|
|
if len(chain.Incrementals) > 0 && chain.FullBackup == nil {
|
|
chain.Incomplete = true
|
|
}
|
|
|
|
return chain, nil
|
|
}
|
|
|
|
func outputChainTable(chains []*BackupChain) {
|
|
fmt.Println()
|
|
fmt.Println("Backup Chains")
|
|
fmt.Println("=====================================================")
|
|
|
|
for _, chain := range chains {
|
|
fmt.Printf("\n[DIR] %s\n", chain.Database)
|
|
|
|
if chain.Incomplete {
|
|
fmt.Println(" [WARN] INCOMPLETE CHAIN - No full backup found!")
|
|
}
|
|
|
|
if chain.FullBackup != nil {
|
|
fmt.Printf(" [BASE] Full Backup:\n")
|
|
fmt.Printf(" Created: %s\n", chain.FullBackup.CreatedAt.Format("2006-01-02 15:04:05"))
|
|
fmt.Printf(" Size: %s\n", catalog.FormatSize(chain.FullBackup.SizeBytes))
|
|
if chainVerbose {
|
|
fmt.Printf(" Path: %s\n", chain.FullBackup.BackupPath)
|
|
if chain.FullBackup.SHA256 != "" {
|
|
fmt.Printf(" SHA256: %s\n", chain.FullBackup.SHA256[:16]+"...")
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(chain.Incrementals) > 0 {
|
|
fmt.Printf("\n [CHAIN] Incremental Backups: %d\n", len(chain.Incrementals))
|
|
for i, inc := range chain.Incrementals {
|
|
if chainVerbose || i < 5 {
|
|
fmt.Printf(" %d. %s - %s\n",
|
|
i+1,
|
|
inc.CreatedAt.Format("2006-01-02 15:04"),
|
|
catalog.FormatSize(inc.SizeBytes))
|
|
if chainVerbose && inc.BackupPath != "" {
|
|
fmt.Printf(" Path: %s\n", inc.BackupPath)
|
|
}
|
|
} else if i == 5 {
|
|
fmt.Printf(" ... and %d more (use --verbose to show all)\n", len(chain.Incrementals)-5)
|
|
break
|
|
}
|
|
}
|
|
} else if chain.FullBackup != nil {
|
|
fmt.Printf("\n [INFO] No incremental backups (full backup only)\n")
|
|
}
|
|
|
|
// Summary
|
|
fmt.Printf("\n [STATS] Chain Summary:\n")
|
|
fmt.Printf(" Total Backups: %d\n", chain.TotalBackups)
|
|
fmt.Printf(" Total Size: %s\n", catalog.FormatSize(chain.TotalSize))
|
|
if chain.ChainDuration > 0 {
|
|
fmt.Printf(" Span: %s (oldest: %s, newest: %s)\n",
|
|
formatChainDuration(chain.ChainDuration),
|
|
chain.OldestBackup.Format("2006-01-02"),
|
|
chain.NewestBackup.Format("2006-01-02"))
|
|
}
|
|
|
|
// Restore info
|
|
if chain.FullBackup != nil && len(chain.Incrementals) > 0 {
|
|
fmt.Printf("\n [INFO] To restore, you need:\n")
|
|
fmt.Printf(" 1. Full backup from %s\n", chain.FullBackup.CreatedAt.Format("2006-01-02"))
|
|
fmt.Printf(" 2. All %d incremental backup(s)\n", len(chain.Incrementals))
|
|
fmt.Printf(" (Apply in chronological order)\n")
|
|
}
|
|
}
|
|
|
|
fmt.Println()
|
|
fmt.Println("=====================================================")
|
|
fmt.Printf("Total: %d database chain(s)\n", len(chains))
|
|
fmt.Println()
|
|
|
|
// Warnings
|
|
incompleteCount := 0
|
|
for _, chain := range chains {
|
|
if chain.Incomplete {
|
|
incompleteCount++
|
|
}
|
|
}
|
|
if incompleteCount > 0 {
|
|
fmt.Printf("\n[WARN] %d incomplete chain(s) detected!\n", incompleteCount)
|
|
fmt.Println("Incremental backups without a full backup cannot be restored.")
|
|
fmt.Println("Run a full backup to establish a new base.")
|
|
}
|
|
}
|
|
|
|
func formatChainDuration(d time.Duration) string {
|
|
if d < time.Hour {
|
|
return fmt.Sprintf("%.0f minutes", d.Minutes())
|
|
}
|
|
if d < 24*time.Hour {
|
|
return fmt.Sprintf("%.1f hours", d.Hours())
|
|
}
|
|
days := int(d.Hours() / 24)
|
|
if days == 1 {
|
|
return "1 day"
|
|
}
|
|
return fmt.Sprintf("%d days", days)
|
|
}
|