initial commit
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user