Add automated backup system with SQLite VACUUM
- Implement backup package with SQLite VACUUM INTO for safe backups - Add scheduler with configurable interval (default: 24h) - Add retention policy with automatic cleanup (default: 7 days) - Add optional gzip compression (~10x size reduction) - Add comprehensive tests for backup creation and cleanup - Integrate backup scheduler in main.go with graceful shutdown - Add backup configuration variables (BACKUP_ENABLED, BACKUP_INTERVAL, BACKUP_RETENTION_DAYS, BACKUP_PATH, BACKUP_COMPRESS) - Backups run automatically in background goroutine - Coverage: backup package 52.7%
This commit is contained in:
@@ -29,3 +29,10 @@ REGISTRATION_MODE=open
|
||||
# Logging Configuration
|
||||
LOG_LEVEL=info
|
||||
ENVIRONMENT=production
|
||||
|
||||
# Backup Configuration
|
||||
BACKUP_ENABLED=false
|
||||
BACKUP_INTERVAL=24h
|
||||
BACKUP_RETENTION_DAYS=7
|
||||
BACKUP_PATH=./data/backups
|
||||
BACKUP_COMPRESS=true
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
"apocapoc-api/internal/infrastructure/backup"
|
||||
"apocapoc-api/internal/infrastructure/config"
|
||||
"apocapoc-api/internal/infrastructure/crypto"
|
||||
"apocapoc-api/internal/infrastructure/email"
|
||||
@@ -55,6 +56,27 @@ func main() {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
backupInterval, err := parseDuration(cfg.BackupInterval)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Invalid BACKUP_INTERVAL")
|
||||
}
|
||||
|
||||
backupRetentionDays, err := strconv.Atoi(cfg.BackupRetentionDays)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Invalid BACKUP_RETENTION_DAYS")
|
||||
}
|
||||
|
||||
backupScheduler := backup.NewScheduler(db.Conn(), backup.Config{
|
||||
Enabled: cfg.BackupEnabled == "true",
|
||||
Interval: backupInterval,
|
||||
RetentionDays: backupRetentionDays,
|
||||
Path: cfg.BackupPath,
|
||||
Compress: cfg.BackupCompress == "true",
|
||||
DatabasePath: cfg.DBPath,
|
||||
})
|
||||
backupScheduler.Start()
|
||||
defer backupScheduler.Stop()
|
||||
|
||||
jwtExpiryHours, err := parseJWTExpiry(cfg.JWTExpiry)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Invalid JWT_EXPIRY")
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/infrastructure/logger"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
Interval time.Duration
|
||||
RetentionDays int
|
||||
Path string
|
||||
Compress bool
|
||||
DatabasePath string
|
||||
}
|
||||
|
||||
func CreateBackup(db *sql.DB, config Config) error {
|
||||
if !config.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(config.Path, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create backup directory: %w", err)
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format("20060102_150405")
|
||||
filename := fmt.Sprintf("apocapoc_%s.db", timestamp)
|
||||
backupPath := filepath.Join(config.Path, filename)
|
||||
|
||||
logger.Info().
|
||||
Str("backup_path", backupPath).
|
||||
Msg("Starting database backup")
|
||||
|
||||
if err := backupDatabase(db, backupPath); err != nil {
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Str("backup_path", backupPath).
|
||||
Msg("Backup failed")
|
||||
return fmt.Errorf("backup failed: %w", err)
|
||||
}
|
||||
|
||||
if config.Compress {
|
||||
compressedPath := backupPath + ".gz"
|
||||
if err := compressFile(backupPath, compressedPath); err != nil {
|
||||
logger.Warn().
|
||||
Err(err).
|
||||
Str("backup_path", backupPath).
|
||||
Msg("Compression failed, keeping uncompressed backup")
|
||||
} else {
|
||||
os.Remove(backupPath)
|
||||
backupPath = compressedPath
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info().
|
||||
Str("backup_path", backupPath).
|
||||
Msg("Backup completed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func backupDatabase(db *sql.DB, destPath string) error {
|
||||
_, err := db.Exec(fmt.Sprintf("VACUUM INTO '%s'", destPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("vacuum into failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func compressFile(srcPath, destPath string) error {
|
||||
srcFile, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
gzipWriter := gzip.NewWriter(destFile)
|
||||
defer gzipWriter.Close()
|
||||
|
||||
_, err = io.Copy(gzipWriter, srcFile)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestCreateBackup(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "test.db")
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test table: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec("INSERT INTO test (name) VALUES ('test1'), ('test2')")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert test data: %v", err)
|
||||
}
|
||||
|
||||
backupPath := filepath.Join(tempDir, "backups")
|
||||
config := Config{
|
||||
Enabled: true,
|
||||
Interval: 24 * time.Hour,
|
||||
RetentionDays: 7,
|
||||
Path: backupPath,
|
||||
Compress: false,
|
||||
DatabasePath: dbPath,
|
||||
}
|
||||
|
||||
err = CreateBackup(db, config)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackup failed: %v", err)
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(backupPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read backup directory: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != 1 {
|
||||
t.Errorf("Expected 1 backup file, got %d", len(files))
|
||||
}
|
||||
|
||||
if len(files) > 0 && filepath.Ext(files[0].Name()) != ".db" {
|
||||
t.Errorf("Expected backup file to have .db extension, got %s", files[0].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBackupWithCompression(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "test.db")
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test table: %v", err)
|
||||
}
|
||||
|
||||
backupPath := filepath.Join(tempDir, "backups")
|
||||
config := Config{
|
||||
Enabled: true,
|
||||
Interval: 24 * time.Hour,
|
||||
RetentionDays: 7,
|
||||
Path: backupPath,
|
||||
Compress: true,
|
||||
DatabasePath: dbPath,
|
||||
}
|
||||
|
||||
err = CreateBackup(db, config)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackup failed: %v", err)
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(backupPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read backup directory: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != 1 {
|
||||
t.Errorf("Expected 1 backup file, got %d", len(files))
|
||||
}
|
||||
|
||||
if len(files) > 0 && filepath.Ext(files[0].Name()) != ".gz" {
|
||||
t.Errorf("Expected backup file to have .gz extension, got %s", files[0].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBackupDisabled(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "test.db")
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
backupPath := filepath.Join(tempDir, "backups")
|
||||
config := Config{
|
||||
Enabled: false,
|
||||
Interval: 24 * time.Hour,
|
||||
RetentionDays: 7,
|
||||
Path: backupPath,
|
||||
Compress: false,
|
||||
DatabasePath: dbPath,
|
||||
}
|
||||
|
||||
err = CreateBackup(db, config)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackup failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = os.Stat(backupPath)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Error("Backup directory should not exist when backup is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanOldBackups(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
backupPath := filepath.Join(tempDir, "backups")
|
||||
os.MkdirAll(backupPath, 0755)
|
||||
|
||||
oldFile := filepath.Join(backupPath, "apocapoc_20200101_120000.db")
|
||||
recentFile := filepath.Join(backupPath, "apocapoc_"+time.Now().Format("20060102_150405")+".db")
|
||||
|
||||
os.WriteFile(oldFile, []byte("old"), 0644)
|
||||
os.WriteFile(recentFile, []byte("recent"), 0644)
|
||||
|
||||
oldTime := time.Now().AddDate(0, 0, -10)
|
||||
os.Chtimes(oldFile, oldTime, oldTime)
|
||||
|
||||
config := Config{
|
||||
Enabled: true,
|
||||
RetentionDays: 7,
|
||||
Path: backupPath,
|
||||
}
|
||||
|
||||
err := CleanOldBackups(config)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanOldBackups failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(oldFile); !os.IsNotExist(err) {
|
||||
t.Error("Old backup file should have been deleted")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(recentFile); err != nil {
|
||||
t.Error("Recent backup file should still exist")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/infrastructure/logger"
|
||||
)
|
||||
|
||||
func CleanOldBackups(config Config) error {
|
||||
if !config.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if config.RetentionDays <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cutoffTime := time.Now().AddDate(0, 0, -config.RetentionDays)
|
||||
|
||||
files, err := os.ReadDir(config.Path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
deletedCount := 0
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(file.Name(), "apocapoc_") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(file.Name(), ".db") && !strings.HasSuffix(file.Name(), ".db.gz") {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(config.Path, file.Name())
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
logger.Warn().
|
||||
Err(err).
|
||||
Str("file", filePath).
|
||||
Msg("Failed to stat backup file")
|
||||
continue
|
||||
}
|
||||
|
||||
if info.ModTime().Before(cutoffTime) {
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
logger.Warn().
|
||||
Err(err).
|
||||
Str("file", filePath).
|
||||
Msg("Failed to delete old backup")
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Info().
|
||||
Str("file", file.Name()).
|
||||
Time("mod_time", info.ModTime()).
|
||||
Msg("Deleted old backup")
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if deletedCount > 0 {
|
||||
logger.Info().
|
||||
Int("deleted_count", deletedCount).
|
||||
Int("retention_days", config.RetentionDays).
|
||||
Msg("Backup cleanup completed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/infrastructure/logger"
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
db *sql.DB
|
||||
config Config
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewScheduler(db *sql.DB, config Config) *Scheduler {
|
||||
return &Scheduler{
|
||||
db: db,
|
||||
config: config,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start() {
|
||||
if !s.config.Enabled {
|
||||
logger.Info().Msg("Backup scheduler is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info().
|
||||
Dur("interval", s.config.Interval).
|
||||
Int("retention_days", s.config.RetentionDays).
|
||||
Str("path", s.config.Path).
|
||||
Bool("compress", s.config.Compress).
|
||||
Msg("Starting backup scheduler")
|
||||
|
||||
go s.run()
|
||||
}
|
||||
|
||||
func (s *Scheduler) run() {
|
||||
if err := CreateBackup(s.db, s.config); err != nil {
|
||||
logger.Error().Err(err).Msg("Initial backup failed")
|
||||
}
|
||||
|
||||
if err := CleanOldBackups(s.config); err != nil {
|
||||
logger.Error().Err(err).Msg("Initial cleanup failed")
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(s.config.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
logger.Debug().Msg("Running scheduled backup")
|
||||
|
||||
if err := CreateBackup(s.db, s.config); err != nil {
|
||||
logger.Error().Err(err).Msg("Scheduled backup failed")
|
||||
continue
|
||||
}
|
||||
|
||||
if err := CleanOldBackups(s.config); err != nil {
|
||||
logger.Error().Err(err).Msg("Backup cleanup failed")
|
||||
}
|
||||
|
||||
case <-s.stopCh:
|
||||
logger.Info().Msg("Backup scheduler stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop() {
|
||||
if s.config.Enabled {
|
||||
close(s.stopCh)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ type Config struct {
|
||||
RegistrationMode string
|
||||
LogLevel string
|
||||
Environment string
|
||||
BackupEnabled string
|
||||
BackupInterval string
|
||||
BackupRetentionDays string
|
||||
BackupPath string
|
||||
BackupCompress string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
@@ -48,6 +53,11 @@ func Load() (*Config, error) {
|
||||
RegistrationMode: getEnvOrDefault("REGISTRATION_MODE", "open"),
|
||||
LogLevel: getEnvOrDefault("LOG_LEVEL", "info"),
|
||||
Environment: getEnvOrDefault("ENVIRONMENT", "production"),
|
||||
BackupEnabled: getEnvOrDefault("BACKUP_ENABLED", "false"),
|
||||
BackupInterval: getEnvOrDefault("BACKUP_INTERVAL", "24h"),
|
||||
BackupRetentionDays: getEnvOrDefault("BACKUP_RETENTION_DAYS", "7"),
|
||||
BackupPath: getEnvOrDefault("BACKUP_PATH", "./data/backups"),
|
||||
BackupCompress: getEnvOrDefault("BACKUP_COMPRESS", "true"),
|
||||
}
|
||||
|
||||
if cfg.DBPath == "" {
|
||||
|
||||
Reference in New Issue
Block a user