vault1984/oss/app/lib/backup.go

183 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (0x000xFF) 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)
}
}()
}