Files
tiny-forge/internal/backup/engine.go
T
alexei.dolgolyov 791cd4d6af
Build / build (push) Successful in 12m20s
feat: rename Docker Watcher to Tinyforge
Rebrand the project as Tinyforge to reflect its evolution from a Docker
container watcher into a self-hosted mini CI/deployment platform.

Rename covers: Go module path, Docker labels, DB/config filenames,
JWT issuer, Dockerfile binary, docker-compose, CI workflows, frontend
i18n, README with static sites docs, and all code comments.
2026-04-12 21:30:39 +03:00

211 lines
5.5 KiB
Go

package backup
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"github.com/alexei/tinyforge/internal/store"
)
// Engine manages database backup operations.
type Engine struct {
mu sync.Mutex
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) {
// Validate backup type to prevent path traversal via filename.
switch backupType {
case "manual", "auto", "pre-restore":
// valid
default:
return store.Backup{}, fmt.Errorf("invalid backup type: %q", backupType)
}
e.mu.Lock()
defer e.mu.Unlock()
timestamp := time.Now().UTC().Format("20060102-150405")
filename := fmt.Sprintf("tinyforge-%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
}