vibe coded the shit out of a db migration system
This commit is contained in:
133
internal/backup/backup.go
Normal file
133
internal/backup/backup.go
Normal file
@@ -0,0 +1,133 @@
|
||||
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
|
||||
}
|
||||
@@ -2,31 +2,87 @@ package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Flags struct {
|
||||
MigrateDB bool
|
||||
EnvDoc bool
|
||||
ShowEnv bool
|
||||
GenEnv string
|
||||
EnvFile string
|
||||
// Utility flags
|
||||
EnvDoc bool
|
||||
ShowEnv bool
|
||||
GenEnv string
|
||||
EnvFile string
|
||||
|
||||
// Database reset (destructive)
|
||||
ResetDB bool
|
||||
|
||||
// Migration commands
|
||||
MigrateUp bool
|
||||
MigrateRollback bool
|
||||
MigrateStatus bool
|
||||
MigrateCreate string
|
||||
MigrateDryRun bool
|
||||
|
||||
// Backup control
|
||||
MigrateNoBackup bool
|
||||
}
|
||||
|
||||
func SetupFlags() *Flags {
|
||||
// Parse commandline args
|
||||
migrateDB := flag.Bool("migrate", false, "Reset all the database tables with the updated models")
|
||||
func SetupFlags() (*Flags, error) {
|
||||
// Utility flags
|
||||
envDoc := flag.Bool("envdoc", false, "Print all environment variables and their documentation")
|
||||
showEnv := flag.Bool("showenv", false, "Print all environment variable values and their documentation")
|
||||
genEnv := flag.String("genenv", "", "Generate a .env file with all environment variables (specify filename)")
|
||||
envfile := flag.String("envfile", ".env", "Specify a .env file to use for the configuration")
|
||||
|
||||
// Database reset (destructive)
|
||||
resetDB := flag.Bool("reset-db", false, "⚠️ DESTRUCTIVE: Drop and recreate all tables (dev only)")
|
||||
|
||||
// Migration commands
|
||||
migrateUp := flag.Bool("migrate-up", false, "Run pending database migrations (with automatic backup)")
|
||||
migrateRollback := flag.Bool("migrate-rollback", false, "Rollback the last migration group (with automatic backup)")
|
||||
migrateStatus := flag.Bool("migrate-status", false, "Show database migration status")
|
||||
migrateCreate := flag.String("migrate-create", "", "Create a new migration file with the given name")
|
||||
migrateDryRun := flag.Bool("migrate-dry-run", false, "Preview pending migrations without applying them")
|
||||
|
||||
// Backup control
|
||||
migrateNoBackup := flag.Bool("no-backup", false, "Skip automatic backups (dev only - faster but less safe)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
flags := &Flags{
|
||||
MigrateDB: *migrateDB,
|
||||
EnvDoc: *envDoc,
|
||||
ShowEnv: *showEnv,
|
||||
GenEnv: *genEnv,
|
||||
EnvFile: *envfile,
|
||||
// Validate: can't use multiple migration commands at once
|
||||
commands := 0
|
||||
if *migrateUp {
|
||||
commands++
|
||||
}
|
||||
return flags
|
||||
if *migrateRollback {
|
||||
commands++
|
||||
}
|
||||
if *migrateStatus {
|
||||
commands++
|
||||
}
|
||||
if *migrateDryRun {
|
||||
commands++
|
||||
}
|
||||
if *resetDB {
|
||||
commands++
|
||||
}
|
||||
|
||||
if commands > 1 {
|
||||
return nil, errors.New("cannot use multiple migration commands simultaneously")
|
||||
}
|
||||
|
||||
flags := &Flags{
|
||||
EnvDoc: *envDoc,
|
||||
ShowEnv: *showEnv,
|
||||
GenEnv: *genEnv,
|
||||
EnvFile: *envfile,
|
||||
ResetDB: *resetDB,
|
||||
MigrateUp: *migrateUp,
|
||||
MigrateRollback: *migrateRollback,
|
||||
MigrateStatus: *migrateStatus,
|
||||
MigrateCreate: *migrateCreate,
|
||||
MigrateDryRun: *migrateDryRun,
|
||||
MigrateNoBackup: *migrateNoBackup,
|
||||
}
|
||||
return flags, nil
|
||||
}
|
||||
|
||||
@@ -12,16 +12,22 @@ type Config struct {
|
||||
Port uint16 // ENV DB_PORT: Database port (default: 5432)
|
||||
DB string // ENV DB_NAME: Database name to connect to (required)
|
||||
SSL string // ENV DB_SSL: SSL mode for connection (default: disable)
|
||||
|
||||
// Backup configuration
|
||||
BackupDir string // ENV DB_BACKUP_DIR: Directory for database backups (default: backups)
|
||||
BackupRetention int // ENV DB_BACKUP_RETENTION: Number of backups to keep (default: 10)
|
||||
}
|
||||
|
||||
func ConfigFromEnv() (any, error) {
|
||||
cfg := &Config{
|
||||
User: env.String("DB_USER", ""),
|
||||
Password: env.String("DB_PASSWORD", ""),
|
||||
Host: env.String("DB_HOST", ""),
|
||||
Port: env.UInt16("DB_PORT", 5432),
|
||||
DB: env.String("DB_NAME", ""),
|
||||
SSL: env.String("DB_SSL", "disable"),
|
||||
User: env.String("DB_USER", ""),
|
||||
Password: env.String("DB_PASSWORD", ""),
|
||||
Host: env.String("DB_HOST", ""),
|
||||
Port: env.UInt16("DB_PORT", 5432),
|
||||
DB: env.String("DB_NAME", ""),
|
||||
SSL: env.String("DB_SSL", "disable"),
|
||||
BackupDir: env.String("DB_BACKUP_DIR", "backups"),
|
||||
BackupRetention: env.Int("DB_BACKUP_RETENTION", 10),
|
||||
}
|
||||
|
||||
// Validate SSL mode
|
||||
@@ -50,6 +56,9 @@ func ConfigFromEnv() (any, error) {
|
||||
if cfg.DB == "" {
|
||||
return nil, errors.New("Envar not set: DB_NAME")
|
||||
}
|
||||
if cfg.BackupRetention < 1 {
|
||||
return nil, errors.New("DB_BACKUP_RETENTION must be at least 1")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user