Files
OpsLog/internal/backup/backup.go
T
2026-06-10 20:27:44 +02:00

216 lines
6.3 KiB
Go

// Package backup creates rotating copies of the SQLite logbook so a single
// disk failure or accidental delete doesn't wipe the user's QSO history.
// The strategy is intentionally simple: a daily snapshot of the .db file
// (optionally zipped), keeping the last N. SQLite's atomic-commit format
// lets us copy the raw file safely as long as we copy after a checkpoint.
package backup
import (
"archive/zip"
"context"
"database/sql"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Settings holds the user-tweakable backup configuration. Stored as flat
// key/value pairs in the settings table (so we don't need a schema change
// every time we add a field).
type Settings struct {
Enabled bool `json:"enabled"`
Folder string `json:"folder"` // empty → DefaultFolder
Rotation int `json:"rotation"` // how many backups to keep; 0/neg = 5
Zip bool `json:"zip"` // compress with deflate
LastBackupAt string `json:"last_backup_at"`// RFC3339; empty if never
}
// DefaultFolder returns the folder used when Settings.Folder is empty.
// It sits next to the database file (in <appdata>/hamlog/backups) which
// keeps it portable when the user moves the data directory.
func DefaultFolder(dataDir string) string {
return filepath.Join(dataDir, "backups")
}
// Run executes one backup pass: VACUUM INTO a fresh snapshot → optionally
// zip → rotate. Returns the path of the file that was written so the caller
// can surface it to the UI.
//
// VACUUM INTO produces a transactionally-consistent copy in a single SQL
// statement (no torn-copy window while the app keeps writing), and compacts
// the destination as a bonus. It replaces the old "checkpoint + raw io.Copy",
// which could capture a half-written page during a concurrent write.
func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation int, doZip bool) (string, error) {
if dbConn == nil {
return "", fmt.Errorf("nil db connection")
}
if rotation <= 0 {
rotation = 5
}
if folder == "" {
return "", fmt.Errorf("backup folder not set")
}
if err := os.MkdirAll(folder, 0o755); err != nil {
return "", fmt.Errorf("create backup folder: %w", err)
}
stamp := time.Now().Format("2006-01-02")
base := fmt.Sprintf("opslog-%s", stamp)
// VACUUM INTO requires a non-existent target → use a temp file, then
// move/zip it into place.
tmp := filepath.Join(folder, base+".vacuum.tmp")
_ = os.Remove(tmp)
if _, err := dbConn.ExecContext(ctx, `VACUUM INTO ?;`, tmp); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("vacuum into: %w", err)
}
var dstPath string
if doZip {
dstPath = filepath.Join(folder, base+".db.zip")
// Inner name = the live DB's base so unzip restores e.g. "opslog.db".
if err := copyZipped(tmp, dstPath, filepath.Base(dbPath)); err != nil {
_ = os.Remove(tmp)
return "", err
}
_ = os.Remove(tmp)
} else {
dstPath = filepath.Join(folder, base+".db")
_ = os.Remove(dstPath)
if err := os.Rename(tmp, dstPath); err != nil {
// Rename can fail across filesystems — fall back to a copy.
if cerr := copyFile(tmp, dstPath); cerr != nil {
_ = os.Remove(tmp)
return "", cerr
}
_ = os.Remove(tmp)
}
}
if err := rotate(folder, rotation); err != nil {
// Rotation errors are non-fatal — the backup itself succeeded.
return dstPath, fmt.Errorf("rotate: %w (backup OK at %s)", err, dstPath)
}
return dstPath, nil
}
// copyFile performs a plain file copy. We don't use os.Rename because
// the source is the live database; we want a fresh standalone file.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open source: %w", err)
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return fmt.Errorf("create dest: %w", err)
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(dst)
return fmt.Errorf("copy: %w", err)
}
return out.Close()
}
// copyZipped writes a single-entry deflate zip containing the database.
// innerName is the entry name inside the zip (the live DB's base name) so
// unzip restores e.g. "opslog.db" wherever the user extracts.
func copyZipped(src, dst, innerName string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open source: %w", err)
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return fmt.Errorf("create dest: %w", err)
}
zw := zip.NewWriter(out)
w, err := zw.Create(innerName)
if err != nil {
zw.Close()
out.Close()
os.Remove(dst)
return fmt.Errorf("zip entry: %w", err)
}
if _, err := io.Copy(w, in); err != nil {
zw.Close()
out.Close()
os.Remove(dst)
return fmt.Errorf("zip write: %w", err)
}
if err := zw.Close(); err != nil {
out.Close()
os.Remove(dst)
return fmt.Errorf("zip close: %w", err)
}
return out.Close()
}
// rotate keeps the most recent `keep` backups in folder and deletes the
// rest. Only files matching the opslog-*.db / opslog-*.db.zip pattern
// are touched — never user files that happen to live in the same folder.
func rotate(folder string, keep int) error {
entries, err := os.ReadDir(folder)
if err != nil {
return err
}
type item struct {
path string
mod time.Time
}
matches := make([]item, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, "opslog-") {
continue
}
if !(strings.HasSuffix(name, ".db") || strings.HasSuffix(name, ".db.zip")) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
matches = append(matches, item{path: filepath.Join(folder, name), mod: info.ModTime()})
}
if len(matches) <= keep {
return nil
}
sort.Slice(matches, func(i, j int) bool { return matches[i].mod.After(matches[j].mod) })
var firstErr error
for _, m := range matches[keep:] {
if err := os.Remove(m.path); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// HasBackupToday returns true if a backup for today's date already exists
// in folder. Used by the startup auto-backup to skip when the user has
// already restarted the app once today.
func HasBackupToday(folder string) bool {
if folder == "" {
return false
}
stamp := time.Now().Format("2006-01-02")
for _, ext := range []string{".db", ".db.zip"} {
if _, err := os.Stat(filepath.Join(folder, "opslog-"+stamp+ext)); err == nil {
return true
}
}
return false
}