Files
owenandClaude Sonnet 5 ba1f7c4c03 Initial vibeproxy implementation
Self-contained HTTP/HTTPS/TCP/UDP reverse proxy manager written in Go,
with SQLite storage, a Bootstrap 5 admin UI (Go templates, no JS
framework), and a REST API under /api/v1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QTXFrNP1imCW7jtfL5SZrw
2026-08-17 10:28:59 -05:00

113 lines
2.9 KiB
Go

// Package backup handles SQLite backups: on-demand, scheduled, and
// restore. It uses SQLite's own online backup (via VACUUM INTO), which is
// safe to run against a live WAL-mode database without blocking writers
// for more than the duration of the copy.
package backup
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"time"
)
// Run performs a single backup of the database at db into dir, named with
// a timestamp, then prunes old backups beyond keep.
func Run(db *sql.DB, dir string, keep int) (string, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
name := fmt.Sprintf("vibeproxy-%s.db", time.Now().UTC().Format("20060102-150405"))
path := filepath.Join(dir, name)
if _, err := db.Exec(`VACUUM INTO ?`, path); err != nil {
return "", fmt.Errorf("backup failed: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return "", err
}
if keep > 0 {
if err := prune(dir, keep); err != nil {
return path, err
}
}
return path, nil
}
func prune(dir string, keep int) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
var names []string
for _, e := range entries {
if !e.IsDir() {
names = append(names, e.Name())
}
}
sort.Strings(names) // timestamped names sort chronologically
if len(names) <= keep {
return nil
}
for _, n := range names[:len(names)-keep] {
os.Remove(filepath.Join(dir, n))
}
return nil
}
// StartScheduledBackups runs Run on the given interval until ctx is done.
func StartScheduledBackups(ctx context.Context, db *sql.DB, log *slog.Logger, getDir func() string, getIntervalHours func() int, getKeep func() int, getEnabled func() bool) {
ticker := time.NewTicker(1 * time.Hour)
lastRun := time.Time{}
go func() {
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if !getEnabled() {
continue
}
interval := time.Duration(getIntervalHours()) * time.Hour
if interval <= 0 {
interval = 24 * time.Hour
}
if time.Since(lastRun) < interval {
continue
}
lastRun = time.Now()
path, err := Run(db, getDir(), getKeep())
if err != nil {
log.Error("scheduled backup failed", "err", err)
} else {
log.Info("scheduled backup complete", "path", path)
}
}
}
}()
}
// Restore replaces the live database file at dbPath with backupPath. The
// caller must have closed all connections to dbPath before calling this
// and reopen afterward.
func Restore(backupPath, dbPath string) error {
data, err := os.ReadFile(backupPath)
if err != nil {
return err
}
tmp := dbPath + ".restoring"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
// Remove any stale WAL/SHM files so SQLite doesn't try to replay a
// journal that belongs to the old database.
os.Remove(dbPath + "-wal")
os.Remove(dbPath + "-shm")
return os.Rename(tmp, dbPath)
}