Files
oslstats/internal/db/backup.go
2026-02-14 19:48:59 +11:00

133 lines
3.6 KiB
Go

package db
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"time"
"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, 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.BackupDir, 0o755); 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.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.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.Host,
cfg.Port,
cfg.User,
cfg.DB,
filename,
)
cmd = exec.CommandContext(ctx, "sh", "-c", pgDumpCmd)
} else {
cmd = exec.CommandContext(ctx, "pg_dump",
"-h", cfg.Host,
"-p", fmt.Sprint(cfg.Port),
"-U", cfg.User,
"-d", cfg.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.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, keepCount int) error {
// Get all backup files (both .sql and .sql.gz)
sqlFiles, err := filepath.Glob(filepath.Join(cfg.BackupDir, "*.sql"))
if err != nil {
return errors.Wrap(err, "failed to list .sql backups")
}
gzFiles, err := filepath.Glob(filepath.Join(cfg.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
}