- Replace all emoji characters with ASCII equivalents throughout codebase - Replace Unicode box-drawing characters (═║╔╗╚╝━─) with ASCII (+|-=) - Replace checkmarks (✓✗) with [OK]/[FAIL] markers - 59 files updated, 741 lines changed - Improves terminal compatibility and reduces visual noise
481 lines
14 KiB
Go
481 lines
14 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"dbbackup/internal/cloud"
|
|
"dbbackup/internal/metadata"
|
|
"dbbackup/internal/retention"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var cleanupCmd = &cobra.Command{
|
|
Use: "cleanup [backup-directory]",
|
|
Short: "Clean up old backups based on retention policy",
|
|
Long: `Remove old backup files based on retention policy while maintaining minimum backup count.
|
|
|
|
The retention policy ensures:
|
|
1. Backups older than --retention-days are eligible for deletion
|
|
2. At least --min-backups most recent backups are always kept
|
|
3. Both conditions must be met for deletion
|
|
|
|
GFS (Grandfather-Father-Son) Mode:
|
|
When --gfs flag is enabled, a tiered retention policy is applied:
|
|
- Yearly: Keep one backup per year on the first eligible day
|
|
- Monthly: Keep one backup per month on the specified day
|
|
- Weekly: Keep one backup per week on the specified weekday
|
|
- Daily: Keep most recent daily backups
|
|
|
|
Examples:
|
|
# Clean up backups older than 30 days (keep at least 5)
|
|
dbbackup cleanup /backups --retention-days 30 --min-backups 5
|
|
|
|
# Dry run to see what would be deleted
|
|
dbbackup cleanup /backups --retention-days 7 --dry-run
|
|
|
|
# Clean up specific database backups only
|
|
dbbackup cleanup /backups --pattern "mydb_*.dump"
|
|
|
|
# GFS retention: 7 daily, 4 weekly, 12 monthly, 3 yearly
|
|
dbbackup cleanup /backups --gfs --gfs-daily 7 --gfs-weekly 4 --gfs-monthly 12 --gfs-yearly 3
|
|
|
|
# GFS with custom weekly day (Saturday) and monthly day (15th)
|
|
dbbackup cleanup /backups --gfs --gfs-weekly-day Saturday --gfs-monthly-day 15
|
|
|
|
# Aggressive cleanup (keep only 3 most recent)
|
|
dbbackup cleanup /backups --retention-days 1 --min-backups 3`,
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: runCleanup,
|
|
}
|
|
|
|
var (
|
|
retentionDays int
|
|
minBackups int
|
|
dryRun bool
|
|
cleanupPattern string
|
|
|
|
// GFS retention policy flags
|
|
gfsEnabled bool
|
|
gfsDaily int
|
|
gfsWeekly int
|
|
gfsMonthly int
|
|
gfsYearly int
|
|
gfsWeeklyDay string
|
|
gfsMonthlyDay int
|
|
)
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(cleanupCmd)
|
|
cleanupCmd.Flags().IntVar(&retentionDays, "retention-days", 30, "Delete backups older than this many days")
|
|
cleanupCmd.Flags().IntVar(&minBackups, "min-backups", 5, "Always keep at least this many backups")
|
|
cleanupCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would be deleted without actually deleting")
|
|
cleanupCmd.Flags().StringVar(&cleanupPattern, "pattern", "", "Only clean up backups matching this pattern (e.g., 'mydb_*.dump')")
|
|
|
|
// GFS retention policy flags
|
|
cleanupCmd.Flags().BoolVar(&gfsEnabled, "gfs", false, "Enable GFS (Grandfather-Father-Son) retention policy")
|
|
cleanupCmd.Flags().IntVar(&gfsDaily, "gfs-daily", 7, "Number of daily backups to keep (GFS mode)")
|
|
cleanupCmd.Flags().IntVar(&gfsWeekly, "gfs-weekly", 4, "Number of weekly backups to keep (GFS mode)")
|
|
cleanupCmd.Flags().IntVar(&gfsMonthly, "gfs-monthly", 12, "Number of monthly backups to keep (GFS mode)")
|
|
cleanupCmd.Flags().IntVar(&gfsYearly, "gfs-yearly", 3, "Number of yearly backups to keep (GFS mode)")
|
|
cleanupCmd.Flags().StringVar(&gfsWeeklyDay, "gfs-weekly-day", "Sunday", "Day of week for weekly backups (e.g., 'Sunday')")
|
|
cleanupCmd.Flags().IntVar(&gfsMonthlyDay, "gfs-monthly-day", 1, "Day of month for monthly backups (1-28)")
|
|
}
|
|
|
|
func runCleanup(cmd *cobra.Command, args []string) error {
|
|
backupPath := args[0]
|
|
|
|
// Check if this is a cloud URI
|
|
if isCloudURIPath(backupPath) {
|
|
return runCloudCleanup(cmd.Context(), backupPath)
|
|
}
|
|
|
|
// Local cleanup
|
|
backupDir := backupPath
|
|
|
|
// Validate directory exists
|
|
if !dirExists(backupDir) {
|
|
return fmt.Errorf("backup directory does not exist: %s", backupDir)
|
|
}
|
|
|
|
// Check if GFS mode is enabled
|
|
if gfsEnabled {
|
|
return runGFSCleanup(backupDir)
|
|
}
|
|
|
|
// Create retention policy
|
|
policy := retention.Policy{
|
|
RetentionDays: retentionDays,
|
|
MinBackups: minBackups,
|
|
DryRun: dryRun,
|
|
}
|
|
|
|
fmt.Printf("[CLEANUP] Cleanup Policy:\n")
|
|
fmt.Printf(" Directory: %s\n", backupDir)
|
|
fmt.Printf(" Retention: %d days\n", policy.RetentionDays)
|
|
fmt.Printf(" Min backups: %d\n", policy.MinBackups)
|
|
if cleanupPattern != "" {
|
|
fmt.Printf(" Pattern: %s\n", cleanupPattern)
|
|
}
|
|
if dryRun {
|
|
fmt.Printf(" Mode: DRY RUN (no files will be deleted)\n")
|
|
}
|
|
fmt.Println()
|
|
|
|
var result *retention.CleanupResult
|
|
var err error
|
|
|
|
// Apply policy
|
|
if cleanupPattern != "" {
|
|
result, err = retention.CleanupByPattern(backupDir, cleanupPattern, policy)
|
|
} else {
|
|
result, err = retention.ApplyPolicy(backupDir, policy)
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("cleanup failed: %w", err)
|
|
}
|
|
|
|
// Display results
|
|
fmt.Printf("[RESULTS] Results:\n")
|
|
fmt.Printf(" Total backups: %d\n", result.TotalBackups)
|
|
fmt.Printf(" Eligible for deletion: %d\n", result.EligibleForDeletion)
|
|
|
|
if len(result.Deleted) > 0 {
|
|
fmt.Printf("\n")
|
|
if dryRun {
|
|
fmt.Printf("[DRY-RUN] Would delete %d backup(s):\n", len(result.Deleted))
|
|
} else {
|
|
fmt.Printf("[OK] Deleted %d backup(s):\n", len(result.Deleted))
|
|
}
|
|
for _, file := range result.Deleted {
|
|
fmt.Printf(" - %s\n", filepath.Base(file))
|
|
}
|
|
}
|
|
|
|
if len(result.Kept) > 0 && len(result.Kept) <= 10 {
|
|
fmt.Printf("\n[KEPT] Kept %d backup(s):\n", len(result.Kept))
|
|
for _, file := range result.Kept {
|
|
fmt.Printf(" - %s\n", filepath.Base(file))
|
|
}
|
|
} else if len(result.Kept) > 10 {
|
|
fmt.Printf("\n[KEPT] Kept %d backup(s)\n", len(result.Kept))
|
|
}
|
|
|
|
if !dryRun && result.SpaceFreed > 0 {
|
|
fmt.Printf("\n[FREED] Space freed: %s\n", metadata.FormatSize(result.SpaceFreed))
|
|
}
|
|
|
|
if len(result.Errors) > 0 {
|
|
fmt.Printf("\n[WARN] Errors:\n")
|
|
for _, err := range result.Errors {
|
|
fmt.Printf(" - %v\n", err)
|
|
}
|
|
}
|
|
|
|
fmt.Println(strings.Repeat("-", 50))
|
|
|
|
if dryRun {
|
|
fmt.Println("[OK] Dry run completed (no files were deleted)")
|
|
} else if len(result.Deleted) > 0 {
|
|
fmt.Println("[OK] Cleanup completed successfully")
|
|
} else {
|
|
fmt.Println("[INFO] No backups eligible for deletion")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func dirExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return info.IsDir()
|
|
}
|
|
|
|
// isCloudURIPath checks if a path is a cloud URI
|
|
func isCloudURIPath(s string) bool {
|
|
return cloud.IsCloudURI(s)
|
|
}
|
|
|
|
// runCloudCleanup applies retention policy to cloud storage
|
|
func runCloudCleanup(ctx context.Context, uri string) error {
|
|
// Parse cloud URI
|
|
cloudURI, err := cloud.ParseCloudURI(uri)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid cloud URI: %w", err)
|
|
}
|
|
|
|
fmt.Printf("[CLOUD] Cloud Cleanup Policy:\n")
|
|
fmt.Printf(" URI: %s\n", uri)
|
|
fmt.Printf(" Provider: %s\n", cloudURI.Provider)
|
|
fmt.Printf(" Bucket: %s\n", cloudURI.Bucket)
|
|
if cloudURI.Path != "" {
|
|
fmt.Printf(" Prefix: %s\n", cloudURI.Path)
|
|
}
|
|
fmt.Printf(" Retention: %d days\n", retentionDays)
|
|
fmt.Printf(" Min backups: %d\n", minBackups)
|
|
if dryRun {
|
|
fmt.Printf(" Mode: DRY RUN (no files will be deleted)\n")
|
|
}
|
|
fmt.Println()
|
|
|
|
// Create cloud backend
|
|
cfg := cloudURI.ToConfig()
|
|
backend, err := cloud.NewBackend(cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create cloud backend: %w", err)
|
|
}
|
|
|
|
// List all backups
|
|
backups, err := backend.List(ctx, cloudURI.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to list cloud backups: %w", err)
|
|
}
|
|
|
|
if len(backups) == 0 {
|
|
fmt.Println("No backups found in cloud storage")
|
|
return nil
|
|
}
|
|
|
|
fmt.Printf("Found %d backup(s) in cloud storage\n\n", len(backups))
|
|
|
|
// Filter backups based on pattern if specified
|
|
var filteredBackups []cloud.BackupInfo
|
|
if cleanupPattern != "" {
|
|
for _, backup := range backups {
|
|
matched, _ := filepath.Match(cleanupPattern, backup.Name)
|
|
if matched {
|
|
filteredBackups = append(filteredBackups, backup)
|
|
}
|
|
}
|
|
fmt.Printf("Pattern matched %d backup(s)\n\n", len(filteredBackups))
|
|
} else {
|
|
filteredBackups = backups
|
|
}
|
|
|
|
// Sort by modification time (oldest first)
|
|
// Already sorted by backend.List
|
|
|
|
// Calculate retention date
|
|
cutoffDate := time.Now().AddDate(0, 0, -retentionDays)
|
|
|
|
// Determine which backups to delete
|
|
var toDelete []cloud.BackupInfo
|
|
var toKeep []cloud.BackupInfo
|
|
|
|
for _, backup := range filteredBackups {
|
|
if backup.LastModified.Before(cutoffDate) {
|
|
toDelete = append(toDelete, backup)
|
|
} else {
|
|
toKeep = append(toKeep, backup)
|
|
}
|
|
}
|
|
|
|
// Ensure we keep minimum backups
|
|
totalBackups := len(filteredBackups)
|
|
if totalBackups-len(toDelete) < minBackups {
|
|
// Need to keep more backups
|
|
keepCount := minBackups - len(toKeep)
|
|
if keepCount > len(toDelete) {
|
|
keepCount = len(toDelete)
|
|
}
|
|
|
|
// Move oldest from toDelete to toKeep
|
|
for i := len(toDelete) - 1; i >= len(toDelete)-keepCount && i >= 0; i-- {
|
|
toKeep = append(toKeep, toDelete[i])
|
|
toDelete = toDelete[:i]
|
|
}
|
|
}
|
|
|
|
// Display results
|
|
fmt.Printf("[RESULTS] Results:\n")
|
|
fmt.Printf(" Total backups: %d\n", totalBackups)
|
|
fmt.Printf(" Eligible for deletion: %d\n", len(toDelete))
|
|
fmt.Printf(" Will keep: %d\n", len(toKeep))
|
|
fmt.Println()
|
|
|
|
if len(toDelete) > 0 {
|
|
if dryRun {
|
|
fmt.Printf("[DRY-RUN] Would delete %d backup(s):\n", len(toDelete))
|
|
} else {
|
|
fmt.Printf("[DELETE] Deleting %d backup(s):\n", len(toDelete))
|
|
}
|
|
|
|
var totalSize int64
|
|
var deletedCount int
|
|
|
|
for _, backup := range toDelete {
|
|
fmt.Printf(" - %s (%s, %s old)\n",
|
|
backup.Name,
|
|
cloud.FormatSize(backup.Size),
|
|
formatBackupAge(backup.LastModified))
|
|
|
|
totalSize += backup.Size
|
|
|
|
if !dryRun {
|
|
if err := backend.Delete(ctx, backup.Key); err != nil {
|
|
fmt.Printf(" [FAIL] Error: %v\n", err)
|
|
} else {
|
|
deletedCount++
|
|
// Also try to delete metadata
|
|
backend.Delete(ctx, backup.Key+".meta.json")
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Printf("\n[FREED] Space %s: %s\n",
|
|
map[bool]string{true: "would be freed", false: "freed"}[dryRun],
|
|
cloud.FormatSize(totalSize))
|
|
|
|
if !dryRun && deletedCount > 0 {
|
|
fmt.Printf("[OK] Successfully deleted %d backup(s)\n", deletedCount)
|
|
}
|
|
} else {
|
|
fmt.Println("No backups eligible for deletion")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// formatBackupAge returns a human-readable age string from a time.Time
|
|
func formatBackupAge(t time.Time) string {
|
|
d := time.Since(t)
|
|
days := int(d.Hours() / 24)
|
|
|
|
if days == 0 {
|
|
return "today"
|
|
} else if days == 1 {
|
|
return "1 day"
|
|
} else if days < 30 {
|
|
return fmt.Sprintf("%d days", days)
|
|
} else if days < 365 {
|
|
months := days / 30
|
|
if months == 1 {
|
|
return "1 month"
|
|
}
|
|
return fmt.Sprintf("%d months", months)
|
|
} else {
|
|
years := days / 365
|
|
if years == 1 {
|
|
return "1 year"
|
|
}
|
|
return fmt.Sprintf("%d years", years)
|
|
}
|
|
}
|
|
|
|
// runGFSCleanup applies GFS (Grandfather-Father-Son) retention policy
|
|
func runGFSCleanup(backupDir string) error {
|
|
// Create GFS policy
|
|
policy := retention.GFSPolicy{
|
|
Enabled: true,
|
|
Daily: gfsDaily,
|
|
Weekly: gfsWeekly,
|
|
Monthly: gfsMonthly,
|
|
Yearly: gfsYearly,
|
|
WeeklyDay: retention.ParseWeekday(gfsWeeklyDay),
|
|
MonthlyDay: gfsMonthlyDay,
|
|
DryRun: dryRun,
|
|
}
|
|
|
|
fmt.Printf("📅 GFS Retention Policy:\n")
|
|
fmt.Printf(" Directory: %s\n", backupDir)
|
|
fmt.Printf(" Daily: %d backups\n", policy.Daily)
|
|
fmt.Printf(" Weekly: %d backups (on %s)\n", policy.Weekly, gfsWeeklyDay)
|
|
fmt.Printf(" Monthly: %d backups (day %d)\n", policy.Monthly, policy.MonthlyDay)
|
|
fmt.Printf(" Yearly: %d backups\n", policy.Yearly)
|
|
if cleanupPattern != "" {
|
|
fmt.Printf(" Pattern: %s\n", cleanupPattern)
|
|
}
|
|
if dryRun {
|
|
fmt.Printf(" Mode: DRY RUN (no files will be deleted)\n")
|
|
}
|
|
fmt.Println()
|
|
|
|
// Apply GFS policy
|
|
result, err := retention.ApplyGFSPolicy(backupDir, policy)
|
|
if err != nil {
|
|
return fmt.Errorf("GFS cleanup failed: %w", err)
|
|
}
|
|
|
|
// Display tier breakdown
|
|
fmt.Printf("[STATS] Backup Classification:\n")
|
|
fmt.Printf(" Yearly: %d\n", result.YearlyKept)
|
|
fmt.Printf(" Monthly: %d\n", result.MonthlyKept)
|
|
fmt.Printf(" Weekly: %d\n", result.WeeklyKept)
|
|
fmt.Printf(" Daily: %d\n", result.DailyKept)
|
|
fmt.Printf(" Total kept: %d\n", result.TotalKept)
|
|
fmt.Println()
|
|
|
|
// Display deletions
|
|
if len(result.Deleted) > 0 {
|
|
if dryRun {
|
|
fmt.Printf("[SEARCH] Would delete %d backup(s):\n", len(result.Deleted))
|
|
} else {
|
|
fmt.Printf("[OK] Deleted %d backup(s):\n", len(result.Deleted))
|
|
}
|
|
for _, file := range result.Deleted {
|
|
fmt.Printf(" - %s\n", filepath.Base(file))
|
|
}
|
|
}
|
|
|
|
// Display kept backups (limited display)
|
|
if len(result.Kept) > 0 && len(result.Kept) <= 15 {
|
|
fmt.Printf("\n[PKG] Kept %d backup(s):\n", len(result.Kept))
|
|
for _, file := range result.Kept {
|
|
// Show tier classification
|
|
info, _ := os.Stat(file)
|
|
if info != nil {
|
|
tiers := retention.ClassifyBackup(info.ModTime(), policy)
|
|
tierStr := formatTiers(tiers)
|
|
fmt.Printf(" - %s [%s]\n", filepath.Base(file), tierStr)
|
|
} else {
|
|
fmt.Printf(" - %s\n", filepath.Base(file))
|
|
}
|
|
}
|
|
} else if len(result.Kept) > 15 {
|
|
fmt.Printf("\n[PKG] Kept %d backup(s)\n", len(result.Kept))
|
|
}
|
|
|
|
if !dryRun && result.SpaceFreed > 0 {
|
|
fmt.Printf("\n[SAVE] Space freed: %s\n", metadata.FormatSize(result.SpaceFreed))
|
|
}
|
|
|
|
if len(result.Errors) > 0 {
|
|
fmt.Printf("\n[WARN] Errors:\n")
|
|
for _, err := range result.Errors {
|
|
fmt.Printf(" - %v\n", err)
|
|
}
|
|
}
|
|
|
|
fmt.Println(strings.Repeat("-", 50))
|
|
|
|
if dryRun {
|
|
fmt.Println("[OK] GFS dry run completed (no files were deleted)")
|
|
} else if len(result.Deleted) > 0 {
|
|
fmt.Println("[OK] GFS cleanup completed successfully")
|
|
} else {
|
|
fmt.Println("[INFO] No backups eligible for deletion under GFS policy")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// formatTiers formats a list of tiers as a comma-separated string
|
|
func formatTiers(tiers []retention.Tier) string {
|
|
if len(tiers) == 0 {
|
|
return "none"
|
|
}
|
|
parts := make([]string, len(tiers))
|
|
for i, t := range tiers {
|
|
parts[i] = t.String()
|
|
}
|
|
return strings.Join(parts, ",")
|
|
}
|