464 lines
12 KiB
Go
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)
|
|
}
|