Files
2026-08-16 21:18:45 -05:00

464 lines
12 KiB
Go

// Package backup creates and restores SQLite database backups.
//
// Backups use SQLite's VACUUM INTO, which writes a transactionally consistent
// copy of the database while it is being written to. Copying the .db file with
// the filesystem would capture a torn snapshot whose committed data lives in a
// write-ahead log the copy does not include.
package backup
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/owen/vibedns/internal/database"
)
// pendingSuffix marks a restore staged for the next start.
const pendingSuffix = ".restore-pending"
// Info describes one backup file.
type Info struct {
Name string `json:"name"`
Path string `json:"path"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt time.Time `json:"created_at"`
}
// SizeMB renders the size for the UI.
func (i Info) SizeMB() float64 { return float64(i.SizeBytes) / (1024 * 1024) }
// Manager runs manual and scheduled backups.
type Manager struct {
db *database.DB
log *slog.Logger
mu sync.RWMutex
enabled bool
dir string
interval time.Duration
retention int
running sync.Mutex // serialises backup runs
lastRun time.Time
lastError string
wg sync.WaitGroup
once sync.Once
}
// Config controls the backup schedule.
type Config struct {
Enabled bool
Directory string
IntervalHours int
Retention int
}
// New creates a backup manager.
func New(db *database.DB, log *slog.Logger, cfg Config) *Manager {
m := &Manager{db: db, log: log}
m.SetConfig(cfg)
return m
}
// SetConfig replaces the backup configuration.
func (m *Manager) SetConfig(cfg Config) {
if cfg.IntervalHours < 1 {
cfg.IntervalHours = 24
}
if cfg.Retention < 1 {
cfg.Retention = 7
}
m.mu.Lock()
m.enabled = cfg.Enabled
m.dir = strings.TrimSpace(cfg.Directory)
m.interval = time.Duration(cfg.IntervalHours) * time.Hour
m.retention = cfg.Retention
m.mu.Unlock()
}
// Directory returns the configured backup directory.
func (m *Manager) Directory() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.dir
}
// Run creates a backup now and prunes old ones.
func (m *Manager) Run(ctx context.Context) (Info, error) {
m.running.Lock()
defer m.running.Unlock()
m.mu.RLock()
dir, retention := m.dir, m.retention
m.mu.RUnlock()
if dir == "" {
return Info{}, errors.New("no backup directory is configured")
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return Info{}, fmt.Errorf("create backup directory %s: %w", dir, err)
}
name := fmt.Sprintf("vibedns-%s.db", time.Now().UTC().Format("20060102-150405"))
path := filepath.Join(dir, name)
// VACUUM INTO fails if the target exists, which is exactly the behaviour we
// want: a backup must never silently overwrite another.
if _, err := os.Stat(path); err == nil {
return Info{}, fmt.Errorf("a backup named %s already exists", name)
}
// Checkpointing first keeps the WAL small and the copy quick.
if err := m.db.Checkpoint(ctx); err != nil {
m.log.Warn("could not checkpoint the write-ahead log before backup", "error", err)
}
if _, err := m.db.ExecContext(ctx, `VACUUM INTO ?`, path); err != nil {
m.recordError(err)
return Info{}, fmt.Errorf("write backup to %s: %w", path, err)
}
if err := os.Chmod(path, 0o600); err != nil {
m.log.Warn("could not restrict backup file permissions", "path", path, "error", err)
}
fi, err := os.Stat(path)
if err != nil {
m.recordError(err)
return Info{}, fmt.Errorf("verify backup %s: %w", path, err)
}
m.mu.Lock()
m.lastRun = time.Now()
m.lastError = ""
m.mu.Unlock()
info := Info{Name: name, Path: path, SizeBytes: fi.Size(), CreatedAt: fi.ModTime()}
m.log.Info("database backup created", "path", path, "bytes", info.SizeBytes)
if removed, err := Prune(dir, retention); err != nil {
m.log.Warn("could not prune old backups", "error", err)
} else if removed > 0 {
m.log.Info("pruned old backups", "removed", removed, "retention", retention)
}
return info, nil
}
func (m *Manager) recordError(err error) {
m.mu.Lock()
m.lastError = err.Error()
m.mu.Unlock()
}
// List returns the backups in the configured directory, newest first.
func (m *Manager) List() ([]Info, error) {
return List(m.Directory())
}
// List returns the backups in dir, newest first.
func List(dir string) ([]Info, error) {
if dir == "" {
return nil, nil
}
entries, err := os.ReadDir(dir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read backup directory %s: %w", dir, err)
}
var out []Info
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") {
continue
}
fi, err := e.Info()
if err != nil {
continue
}
out = append(out, Info{
Name: e.Name(),
Path: filepath.Join(dir, e.Name()),
SizeBytes: fi.Size(),
CreatedAt: fi.ModTime(),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out, nil
}
// Prune deletes all but the newest `keep` backups.
func Prune(dir string, keep int) (int, error) {
if keep < 1 {
return 0, nil
}
backups, err := List(dir)
if err != nil {
return 0, err
}
if len(backups) <= keep {
return 0, nil
}
removed := 0
for _, b := range backups[keep:] {
if err := os.Remove(b.Path); err != nil {
return removed, fmt.Errorf("remove old backup %s: %w", b.Name, err)
}
removed++
}
return removed, nil
}
// Resolve validates that name refers to a backup inside dir and returns its
// full path. It exists to keep a crafted name from escaping the directory.
func Resolve(dir, name string) (string, error) {
if dir == "" {
return "", errors.New("no backup directory is configured")
}
clean := filepath.Base(filepath.Clean("/" + name))
if clean == "." || clean == "/" || clean == "" {
return "", fmt.Errorf("%q is not a valid backup name", name)
}
if !strings.HasSuffix(clean, ".db") {
return "", fmt.Errorf("%q is not a backup file", name)
}
path := filepath.Join(dir, clean)
if _, err := os.Stat(path); err != nil {
return "", fmt.Errorf("backup %s was not found", clean)
}
return path, nil
}
// Delete removes one backup by name.
func Delete(dir, name string) error {
path, err := Resolve(dir, name)
if err != nil {
return err
}
if err := os.Remove(path); err != nil {
return fmt.Errorf("delete backup %s: %w", name, err)
}
return nil
}
// Start launches the scheduled backup loop.
func (m *Manager) Start(ctx context.Context) {
m.once.Do(func() {
m.wg.Add(1)
go m.loop(ctx)
})
}
// Stop waits for the scheduler to exit.
func (m *Manager) Stop() { m.wg.Wait() }
func (m *Manager) loop(ctx context.Context) {
defer m.wg.Done()
// Check every few minutes rather than sleeping for the whole interval, so
// a settings change takes effect promptly.
const tick = 5 * time.Minute
t := time.NewTicker(tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
m.mu.RLock()
enabled, interval, last := m.enabled, m.interval, m.lastRun
m.mu.RUnlock()
if !enabled {
continue
}
if !last.IsZero() && time.Since(last) < interval {
continue
}
if _, err := m.Run(ctx); err != nil {
m.log.Error("scheduled backup failed", "error", err)
}
}
}
}
// Status describes the backup subsystem for the settings page.
type Status struct {
Enabled bool `json:"enabled"`
Directory string `json:"directory"`
IntervalHours int `json:"interval_hours"`
Retention int `json:"retention"`
LastRun time.Time `json:"last_run"`
LastError string `json:"last_error,omitempty"`
Count int `json:"count"`
TotalBytes int64 `json:"total_bytes"`
}
// Status returns the current backup status.
func (m *Manager) Status() Status {
m.mu.RLock()
s := Status{
Enabled: m.enabled,
Directory: m.dir,
IntervalHours: int(m.interval / time.Hour),
Retention: m.retention,
LastRun: m.lastRun,
LastError: m.lastError,
}
m.mu.RUnlock()
if backups, err := List(s.Directory); err == nil {
s.Count = len(backups)
for _, b := range backups {
s.TotalBytes += b.SizeBytes
}
}
return s
}
// --- Restore ------------------------------------------------------------
// StageRestore validates a backup and stages it to replace the live database
// on the next start.
//
// Overwriting the database file underneath a running process would leave open
// connections reading a file that no longer exists, so the swap is deferred to
// startup, where nothing is holding the database open.
func StageRestore(dbPath, backupPath string) error {
if err := Verify(backupPath); err != nil {
return err
}
pending := dbPath + pendingSuffix
if err := copyFile(backupPath, pending, 0o600); err != nil {
return fmt.Errorf("stage restore: %w", err)
}
return nil
}
// PendingRestore reports whether a restore is staged.
func PendingRestore(dbPath string) (string, bool) {
p := dbPath + pendingSuffix
if _, err := os.Stat(p); err == nil {
return p, true
}
return "", false
}
// CancelRestore discards a staged restore.
func CancelRestore(dbPath string) error {
p := dbPath + pendingSuffix
if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("cancel staged restore: %w", err)
}
return nil
}
// ApplyPendingRestore swaps a staged backup into place. It must be called
// before the database is opened.
//
// The database being replaced is preserved alongside it, so a restore that
// turns out to be the wrong choice is still recoverable.
func ApplyPendingRestore(dbPath string, log *slog.Logger) (bool, error) {
pending := dbPath + pendingSuffix
if _, err := os.Stat(pending); err != nil {
return false, nil
}
if _, err := os.Stat(dbPath); err == nil {
safety := fmt.Sprintf("%s.pre-restore-%s", dbPath, time.Now().UTC().Format("20060102-150405"))
if err := os.Rename(dbPath, safety); err != nil {
return false, fmt.Errorf("preserve the current database before restoring: %w", err)
}
log.Info("previous database preserved", "path", safety)
}
// The WAL and shared-memory sidecars belong to the replaced database and
// would corrupt the restored one.
for _, suffix := range []string{"-wal", "-shm"} {
if err := os.Remove(dbPath + suffix); err != nil && !errors.Is(err, os.ErrNotExist) {
return false, fmt.Errorf("remove stale %s file: %w", suffix, err)
}
}
if err := os.Rename(pending, dbPath); err != nil {
return false, fmt.Errorf("move the staged database into place: %w", err)
}
if err := os.Chmod(dbPath, 0o600); err != nil {
log.Warn("could not restrict restored database permissions", "error", err)
}
log.Info("database restored from backup", "path", dbPath)
return true, nil
}
// Verify checks that a file is a usable vibedns database.
func Verify(path string) error {
fi, err := os.Stat(path)
if err != nil {
return fmt.Errorf("backup file %s cannot be read: %w", filepath.Base(path), err)
}
if fi.Size() < 512 {
return fmt.Errorf("backup file %s is too small to be a database", filepath.Base(path))
}
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=query_only(1)")
if err != nil {
return fmt.Errorf("backup file %s could not be opened: %w", filepath.Base(path), err)
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var check string
if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&check); err != nil {
return fmt.Errorf("backup file %s failed its integrity check: %w", filepath.Base(path), err)
}
if check != "ok" {
return fmt.Errorf("backup file %s failed its integrity check: %s", filepath.Base(path), check)
}
var n int
err = db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'`).Scan(&n)
if err != nil || n == 0 {
return fmt.Errorf("%s does not look like a vibedns database: no migration table was found",
filepath.Base(path))
}
return nil
}
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
tmp := dst + ".tmp"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Sync(); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, dst)
}