Files
OpsLog/internal/backup/backup.go
T
2026-05-28 18:35:22 +02:00

201 lines
5.9 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: checkpoint WAL → copy the database file
// → optionally zip → rotate. Returns the path of the file that was
// written so the caller can surface it to the UI.
//
// dbConn is used to issue the WAL checkpoint so the on-disk file is
// self-consistent before we copy it. It's the same *sql.DB the app uses;
// SQLite tolerates concurrent reads during the copy.
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)
}
// Flush WAL into the main file so a raw copy is a complete database.
// TRUNCATE removes the -wal file's contents after checkpointing.
if _, err := dbConn.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE);`); err != nil {
return "", fmt.Errorf("wal_checkpoint: %w", err)
}
stamp := time.Now().Format("2006-01-02")
base := fmt.Sprintf("hamlog-%s", stamp)
var dstPath string
if doZip {
dstPath = filepath.Join(folder, base+".db.zip")
if err := copyZipped(dbPath, dstPath); err != nil {
return "", err
}
} else {
dstPath = filepath.Join(folder, base+".db")
if err := copyFile(dbPath, dstPath); err != nil {
return "", err
}
}
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.
// The inner filename is just the base of the source so unzip restores
// "hamlog.db" wherever the user extracts.
func copyZipped(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)
}
zw := zip.NewWriter(out)
w, err := zw.Create(filepath.Base(src))
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 hamlog-*.db / hamlog-*.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, "hamlog-") {
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, "hamlog-"+stamp+ext)); err == nil {
return true
}
}
return false
}