package backup import ( "context" "fmt" "os" "os/exec" "path/filepath" "sort" "time" "git.haelnorr.com/h/oslstats/internal/config" "github.com/pkg/errors" ) // CreateBackup creates a compressed PostgreSQL dump before migrations // Returns backup filename and error // If pg_dump is not available, returns nil error with warning func CreateBackup(ctx context.Context, cfg *config.Config, operation string) (string, error) { // Check if pg_dump is available if _, err := exec.LookPath("pg_dump"); err != nil { fmt.Println("[WARN] pg_dump not found - skipping backup") fmt.Println("[WARN] Install PostgreSQL client tools for automatic backups:") fmt.Println("[WARN] Ubuntu/Debian: sudo apt-get install postgresql-client") fmt.Println("[WARN] macOS: brew install postgresql") fmt.Println("[WARN] Arch: sudo pacman -S postgresql-libs") return "", nil // Don't fail, just warn } // Ensure backup directory exists if err := os.MkdirAll(cfg.DB.BackupDir, 0755); err != nil { return "", errors.Wrap(err, "failed to create backup directory") } // Generate filename: YYYYMMDD_HHmmss_pre_{operation}.sql.gz timestamp := time.Now().Format("20060102_150405") filename := filepath.Join(cfg.DB.BackupDir, fmt.Sprintf("%s_pre_%s.sql.gz", timestamp, operation)) // Check if gzip is available useGzip := true if _, err := exec.LookPath("gzip"); err != nil { fmt.Println("[WARN] gzip not found - using uncompressed backup") useGzip = false filename = filepath.Join(cfg.DB.BackupDir, fmt.Sprintf("%s_pre_%s.sql", timestamp, operation)) } // Build pg_dump command var cmd *exec.Cmd if useGzip { // Use shell to pipe pg_dump through gzip pgDumpCmd := fmt.Sprintf( "pg_dump -h %s -p %d -U %s -d %s --no-owner --no-acl --clean --if-exists | gzip > %s", cfg.DB.Host, cfg.DB.Port, cfg.DB.User, cfg.DB.DB, filename, ) cmd = exec.CommandContext(ctx, "sh", "-c", pgDumpCmd) } else { cmd = exec.CommandContext(ctx, "pg_dump", "-h", cfg.DB.Host, "-p", fmt.Sprint(cfg.DB.Port), "-U", cfg.DB.User, "-d", cfg.DB.DB, "-f", filename, "--no-owner", "--no-acl", "--clean", "--if-exists", ) } // Set password via environment variable cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", cfg.DB.Password)) // Run backup if err := cmd.Run(); err != nil { return "", errors.Wrap(err, "pg_dump failed") } // Get file size for logging info, err := os.Stat(filename) if err != nil { return filename, errors.Wrap(err, "stat backup file") } sizeMB := float64(info.Size()) / 1024 / 1024 fmt.Printf("[INFO] Backup created: %s (%.2f MB)\n", filename, sizeMB) return filename, nil } // CleanOldBackups keeps only the N most recent backups func CleanOldBackups(cfg *config.Config, keepCount int) error { // Get all backup files (both .sql and .sql.gz) sqlFiles, err := filepath.Glob(filepath.Join(cfg.DB.BackupDir, "*.sql")) if err != nil { return errors.Wrap(err, "failed to list .sql backups") } gzFiles, err := filepath.Glob(filepath.Join(cfg.DB.BackupDir, "*.sql.gz")) if err != nil { return errors.Wrap(err, "failed to list .sql.gz backups") } files := append(sqlFiles, gzFiles...) if len(files) <= keepCount { return nil // Nothing to clean } // Sort files by modification time (newest first) sort.Slice(files, func(i, j int) bool { iInfo, _ := os.Stat(files[i]) jInfo, _ := os.Stat(files[j]) return iInfo.ModTime().After(jInfo.ModTime()) }) // Delete old backups for i := keepCount; i < len(files); i++ { if err := os.Remove(files[i]); err != nil { fmt.Printf("[WARN] Failed to remove old backup %s: %v\n", files[i], err) } else { fmt.Printf("[INFO] Removed old backup: %s\n", filepath.Base(files[i])) } } return nil }