vibe coded the shit out of a db migration system

This commit is contained in:
2026-01-24 16:21:28 +11:00
parent bd3816de82
commit 818f107143
15 changed files with 1649 additions and 40 deletions

133
internal/backup/backup.go Normal file
View 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
}

View File

@@ -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
}

View File

@@ -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
}