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