183 lines
4.6 KiB
Go
183 lines
4.6 KiB
Go
package lib
|
||
|
||
import (
|
||
"encoding/hex"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// BackupInfo describes a single backup file.
|
||
type BackupInfo struct {
|
||
Name string `json:"name"`
|
||
Size int64 `json:"size"`
|
||
CreatedAt int64 `json:"created_at"` // unix seconds
|
||
}
|
||
|
||
// BackupDir returns the backup directory for a given data dir, creating it if needed.
|
||
func BackupDir(dataDir string) string {
|
||
dir := filepath.Join(dataDir, "backups")
|
||
os.MkdirAll(dir, 0700)
|
||
return dir
|
||
}
|
||
|
||
// RunBackups backs up vaults whose ID falls in this hour's byte range.
|
||
// First byte of vault ID (0x00–0xFF) is mapped across the 168 hours of a week,
|
||
// so a POP with thousands of vaults spreads the work evenly.
|
||
// Retention: delete backups older than 3 months.
|
||
func RunBackups(dataDir string) {
|
||
backupDir := BackupDir(dataDir)
|
||
now := time.Now()
|
||
|
||
// Current hour of the week: Sun 0:00 = 0, Sat 23:00 = 167
|
||
weekHour := int(now.Weekday())*24 + now.Hour()
|
||
|
||
// This hour's first-byte range: [lo, hi)
|
||
lo := weekHour * 256 / 168
|
||
hi := (weekHour + 1) * 256 / 168
|
||
|
||
dbs, _ := filepath.Glob(filepath.Join(dataDir, "*.db"))
|
||
for _, dbPath := range dbs {
|
||
name := strings.TrimSuffix(filepath.Base(dbPath), ".db")
|
||
if len(name) < 2 {
|
||
continue
|
||
}
|
||
b, err := hex.DecodeString(name[:2])
|
||
if err != nil {
|
||
continue
|
||
}
|
||
slot := int(b[0])
|
||
if slot < lo || slot >= hi {
|
||
continue
|
||
}
|
||
|
||
// Skip if already backed up in the last 6 days
|
||
if hasRecentBackup(backupDir, name, 6*24*time.Hour) {
|
||
continue
|
||
}
|
||
|
||
createBackup(dbPath, backupDir, now)
|
||
}
|
||
|
||
pruneBackups(backupDir, 90*24*time.Hour)
|
||
}
|
||
|
||
// hasRecentBackup checks if a vault has a backup newer than maxAge.
|
||
func hasRecentBackup(backupDir, vaultID string, maxAge time.Duration) bool {
|
||
files, _ := filepath.Glob(filepath.Join(backupDir, vaultID+"_*.db"))
|
||
for _, f := range files {
|
||
info, err := os.Stat(f)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if time.Since(info.ModTime()) < maxAge {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// createBackup copies a DB using VACUUM INTO (consistent, compacted snapshot).
|
||
func createBackup(dbPath, backupDir string, now time.Time) error {
|
||
name := strings.TrimSuffix(filepath.Base(dbPath), ".db")
|
||
stamp := now.Format("20060102-150405")
|
||
dest := filepath.Join(backupDir, fmt.Sprintf("%s_%s.db", name, stamp))
|
||
|
||
src, err := OpenDB(dbPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer src.Close()
|
||
|
||
_, err = src.Conn.Exec(fmt.Sprintf("VACUUM INTO '%s'", dest))
|
||
return err
|
||
}
|
||
|
||
// pruneBackups deletes all backup files older than maxAge.
|
||
func pruneBackups(backupDir string, maxAge time.Duration) {
|
||
files, _ := filepath.Glob(filepath.Join(backupDir, "*.db"))
|
||
cutoff := time.Now().Add(-maxAge)
|
||
for _, f := range files {
|
||
info, err := os.Stat(f)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if info.ModTime().Before(cutoff) {
|
||
os.Remove(f)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ListBackups returns all backup files sorted newest first.
|
||
func ListBackups(dataDir string) []BackupInfo {
|
||
backupDir := BackupDir(dataDir)
|
||
files, _ := filepath.Glob(filepath.Join(backupDir, "*.db"))
|
||
|
||
var backups []BackupInfo
|
||
for _, f := range files {
|
||
info, err := os.Stat(f)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
backups = append(backups, BackupInfo{
|
||
Name: filepath.Base(f),
|
||
Size: info.Size(),
|
||
CreatedAt: info.ModTime().Unix(),
|
||
})
|
||
}
|
||
|
||
sort.Slice(backups, func(i, j int) bool {
|
||
return backups[i].CreatedAt > backups[j].CreatedAt
|
||
})
|
||
return backups
|
||
}
|
||
|
||
// RestoreBackup replaces the active DB with a backup file.
|
||
// The current DB is saved as a pre-restore backup first.
|
||
func RestoreBackup(dataDir, backupName string) error {
|
||
backupDir := BackupDir(dataDir)
|
||
backupPath := filepath.Join(backupDir, backupName)
|
||
|
||
if strings.Contains(backupName, "/") || strings.Contains(backupName, "\\") || strings.Contains(backupName, "..") {
|
||
return fmt.Errorf("invalid backup name")
|
||
}
|
||
if _, err := os.Stat(backupPath); err != nil {
|
||
return fmt.Errorf("backup not found")
|
||
}
|
||
|
||
parts := strings.SplitN(backupName, "_", 2)
|
||
if len(parts) < 2 {
|
||
return fmt.Errorf("invalid backup name format")
|
||
}
|
||
dbPath := filepath.Join(dataDir, parts[0]+".db")
|
||
|
||
// Save current DB as pre-restore backup
|
||
if _, err := os.Stat(dbPath); err == nil {
|
||
preRestore := filepath.Join(backupDir, fmt.Sprintf("%s_prerestore_%s.db", parts[0], time.Now().Format("20060102-150405")))
|
||
src, err := OpenDB(dbPath)
|
||
if err == nil {
|
||
src.Conn.Exec(fmt.Sprintf("VACUUM INTO '%s'", preRestore))
|
||
src.Close()
|
||
}
|
||
}
|
||
|
||
data, err := os.ReadFile(backupPath)
|
||
if err != nil {
|
||
return fmt.Errorf("read backup: %w", err)
|
||
}
|
||
return os.WriteFile(dbPath, data, 0600)
|
||
}
|
||
|
||
// StartBackupTimer checks for due backups every hour.
|
||
func StartBackupTimer(dataDir string) {
|
||
go func() {
|
||
for {
|
||
RunBackups(dataDir)
|
||
time.Sleep(1 * time.Hour)
|
||
}
|
||
}()
|
||
}
|