feat: configuration backup management with manual and auto backup
Add backup/restore functionality for the SQLite database. Users can trigger manual backups, configure automatic backups on an interval with retention policies, list/download/delete backups, and restore from any backup. - Backup engine using VACUUM INTO (safe with WAL mode) - Backup metadata tracked in DB, files stored in DATA_DIR/backups/ - Settings: backup_enabled, backup_interval_hours, backup_retention_count - API: POST/GET/DELETE /api/backups, download, restore endpoints - Autobackup via cron scheduler with configurable interval - Retention: prune on startup, after each backup (manual and auto) - Orphan cleanup: removes backup files without metadata on startup - Restore: replaces DB and triggers graceful server shutdown - Settings UI: /settings/backup with toggle, interval, retention config - Backup list with download, delete, restore actions - i18n: English and Russian translations
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
)
|
||||
|
||||
// Engine manages database backup operations.
|
||||
type Engine struct {
|
||||
store *store.Store
|
||||
dbPath string
|
||||
backupDir string
|
||||
}
|
||||
|
||||
// New creates a new backup engine. It ensures the backup directory exists.
|
||||
func New(st *store.Store, dbPath, dataDir string) (*Engine, error) {
|
||||
backupDir := filepath.Join(dataDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create backup directory: %w", err)
|
||||
}
|
||||
return &Engine{
|
||||
store: st,
|
||||
dbPath: dbPath,
|
||||
backupDir: backupDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BackupDir returns the path to the backup directory.
|
||||
func (e *Engine) BackupDir() string {
|
||||
return e.backupDir
|
||||
}
|
||||
|
||||
// CreateBackup creates a new database backup using VACUUM INTO.
|
||||
// Returns the backup metadata record.
|
||||
func (e *Engine) CreateBackup(backupType string) (store.Backup, error) {
|
||||
timestamp := time.Now().UTC().Format("20060102-150405")
|
||||
filename := fmt.Sprintf("docker-watcher-%s-%s.db", backupType, timestamp)
|
||||
destPath := filepath.Join(e.backupDir, filename)
|
||||
|
||||
// VACUUM INTO creates a clean, standalone copy of the database.
|
||||
// It is safe to use while the database is open and in WAL mode.
|
||||
_, err := e.store.DB().Exec(`VACUUM INTO ?`, destPath)
|
||||
if err != nil {
|
||||
return store.Backup{}, fmt.Errorf("vacuum into %s: %w", destPath, err)
|
||||
}
|
||||
|
||||
// Get file size.
|
||||
info, err := os.Stat(destPath)
|
||||
if err != nil {
|
||||
return store.Backup{}, fmt.Errorf("stat backup file: %w", err)
|
||||
}
|
||||
|
||||
// Store metadata.
|
||||
backup, err := e.store.CreateBackup(store.Backup{
|
||||
Filename: filename,
|
||||
SizeBytes: info.Size(),
|
||||
BackupType: backupType,
|
||||
})
|
||||
if err != nil {
|
||||
// Best effort: remove the file if metadata insert fails.
|
||||
os.Remove(destPath)
|
||||
return store.Backup{}, fmt.Errorf("store backup metadata: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("backup created", "id", backup.ID, "filename", filename, "size", info.Size(), "type", backupType)
|
||||
return backup, nil
|
||||
}
|
||||
|
||||
// ListBackups returns all backup records.
|
||||
func (e *Engine) ListBackups() ([]store.Backup, error) {
|
||||
return e.store.ListBackups()
|
||||
}
|
||||
|
||||
// GetBackup returns a single backup record.
|
||||
func (e *Engine) GetBackup(id string) (store.Backup, error) {
|
||||
return e.store.GetBackup(id)
|
||||
}
|
||||
|
||||
// FilePath returns the full filesystem path for a backup.
|
||||
func (e *Engine) FilePath(backup store.Backup) string {
|
||||
return filepath.Join(e.backupDir, backup.Filename)
|
||||
}
|
||||
|
||||
// DeleteBackup removes a backup file and its metadata record.
|
||||
func (e *Engine) DeleteBackup(id string) error {
|
||||
backup, err := e.store.GetBackup(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get backup: %w", err)
|
||||
}
|
||||
|
||||
// Remove file.
|
||||
filePath := filepath.Join(e.backupDir, backup.Filename)
|
||||
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove backup file: %w", err)
|
||||
}
|
||||
|
||||
// Remove metadata.
|
||||
if err := e.store.DeleteBackup(id); err != nil {
|
||||
return fmt.Errorf("delete backup metadata: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("backup deleted", "id", id, "filename", backup.Filename)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestorePath returns the path of a backup file for restore operations.
|
||||
// The caller is responsible for actually replacing the database.
|
||||
func (e *Engine) RestorePath(id string) (string, error) {
|
||||
backup, err := e.store.GetBackup(id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get backup: %w", err)
|
||||
}
|
||||
|
||||
filePath := filepath.Join(e.backupDir, backup.Filename)
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
return "", fmt.Errorf("backup file not found: %w", err)
|
||||
}
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
// Prune removes old backups exceeding the retention count.
|
||||
// Returns the number of backups pruned.
|
||||
func (e *Engine) Prune(retentionCount int) (int, error) {
|
||||
if retentionCount <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
count, err := e.store.CountBackups()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count backups: %w", err)
|
||||
}
|
||||
|
||||
excess := count - retentionCount
|
||||
if excess <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
oldest, err := e.store.GetOldestBackups(excess)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get oldest backups: %w", err)
|
||||
}
|
||||
|
||||
pruned := 0
|
||||
for _, b := range oldest {
|
||||
if err := e.DeleteBackup(b.ID); err != nil {
|
||||
slog.Warn("prune: failed to delete backup", "id", b.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
pruned++
|
||||
}
|
||||
|
||||
if pruned > 0 {
|
||||
slog.Info("backups pruned", "pruned", pruned, "retention", retentionCount)
|
||||
}
|
||||
return pruned, nil
|
||||
}
|
||||
|
||||
// CleanOrphans removes backup files in the backup directory that have no metadata record.
|
||||
func (e *Engine) CleanOrphans() (int, error) {
|
||||
entries, err := os.ReadDir(e.backupDir)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read backup directory: %w", err)
|
||||
}
|
||||
|
||||
backups, err := e.store.ListBackups()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("list backups: %w", err)
|
||||
}
|
||||
|
||||
tracked := make(map[string]bool, len(backups))
|
||||
for _, b := range backups {
|
||||
tracked[b.Filename] = true
|
||||
}
|
||||
|
||||
cleaned := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !tracked[entry.Name()] {
|
||||
filePath := filepath.Join(e.backupDir, entry.Name())
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
slog.Warn("clean orphan: failed to remove file", "file", entry.Name(), "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Info("removed orphaned backup file", "file", entry.Name())
|
||||
cleaned++
|
||||
}
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
Reference in New Issue
Block a user