package security import ( "fmt" "os" "path/filepath" "sort" "time" "dbbackup/internal/logger" ) // RetentionPolicy defines backup retention rules type RetentionPolicy struct { RetentionDays int MinBackups int // Minimum backups to keep regardless of age log logger.Logger } // NewRetentionPolicy creates a new retention policy func NewRetentionPolicy(retentionDays, minBackups int, log logger.Logger) *RetentionPolicy { return &RetentionPolicy{ RetentionDays: retentionDays, MinBackups: minBackups, log: log, } } // ArchiveInfo holds information about a backup archive type ArchiveInfo struct { Path string ModTime time.Time Size int64 Database string } // CleanupOldBackups removes backups older than retention period func (rp *RetentionPolicy) CleanupOldBackups(backupDir string) (int, int64, error) { if rp.RetentionDays <= 0 { return 0, 0, nil // Retention disabled } archives, err := rp.scanBackupArchives(backupDir) if err != nil { return 0, 0, fmt.Errorf("failed to scan backup directory: %w", err) } if len(archives) <= rp.MinBackups { rp.log.Debug("Keeping all backups (below minimum threshold)", "count", len(archives), "min_backups", rp.MinBackups) return 0, 0, nil } cutoffTime := time.Now().AddDate(0, 0, -rp.RetentionDays) // Sort by modification time (oldest first) sort.Slice(archives, func(i, j int) bool { return archives[i].ModTime.Before(archives[j].ModTime) }) var deletedCount int var freedSpace int64 for i, archive := range archives { // Keep minimum number of backups remaining := len(archives) - i if remaining <= rp.MinBackups { rp.log.Debug("Stopped cleanup to maintain minimum backups", "remaining", remaining, "min_backups", rp.MinBackups) break } // Delete if older than retention period if archive.ModTime.Before(cutoffTime) { rp.log.Info("Removing old backup", "file", filepath.Base(archive.Path), "age_days", int(time.Since(archive.ModTime).Hours()/24), "size_mb", archive.Size/1024/1024) if err := os.Remove(archive.Path); err != nil { rp.log.Warn("Failed to remove old backup", "file", archive.Path, "error", err) continue } // Also remove checksum file if exists checksumPath := archive.Path + ".sha256" if _, err := os.Stat(checksumPath); err == nil { os.Remove(checksumPath) } // Also remove metadata file if exists metadataPath := archive.Path + ".meta" if _, err := os.Stat(metadataPath); err == nil { os.Remove(metadataPath) } deletedCount++ freedSpace += archive.Size } } if deletedCount > 0 { rp.log.Info("Cleanup completed", "deleted_backups", deletedCount, "freed_space_mb", freedSpace/1024/1024, "retention_days", rp.RetentionDays) } return deletedCount, freedSpace, nil } // scanBackupArchives scans directory for backup archives func (rp *RetentionPolicy) scanBackupArchives(backupDir string) ([]ArchiveInfo, error) { var archives []ArchiveInfo entries, err := os.ReadDir(backupDir) if err != nil { return nil, err } for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() // Skip non-backup files if !isBackupArchive(name) { continue } path := filepath.Join(backupDir, name) info, err := entry.Info() if err != nil { rp.log.Warn("Failed to get file info", "file", name, "error", err) continue } archives = append(archives, ArchiveInfo{ Path: path, ModTime: info.ModTime(), Size: info.Size(), Database: extractDatabaseName(name), }) } return archives, nil } // isBackupArchive checks if filename is a backup archive func isBackupArchive(name string) bool { return (filepath.Ext(name) == ".dump" || filepath.Ext(name) == ".sql" || filepath.Ext(name) == ".gz" || filepath.Ext(name) == ".tar") && name != ".sha256" && name != ".meta" } // extractDatabaseName extracts database name from archive filename func extractDatabaseName(filename string) string { base := filepath.Base(filename) // Remove extensions for { oldBase := base base = removeExtension(base) if base == oldBase { break } } // Remove timestamp patterns if len(base) > 20 { // Typically: db_name_20240101_120000 underscoreCount := 0 for i := len(base) - 1; i >= 0; i-- { if base[i] == '_' { underscoreCount++ if underscoreCount >= 2 { return base[:i] } } } } return base } // removeExtension removes one extension from filename func removeExtension(name string) string { if ext := filepath.Ext(name); ext != "" { return name[:len(name)-len(ext)] } return name }