Files
OpsLog/internal/backup/backup.go
T
rouggy e2bfe73bdb fix(backup): back up the contacts (logbook), not just the settings db
Since the logbook was split into its own SQLite file, backup.Run was still
snapshotting a.db (the settings/config database) — so the scheduled and manual
backups silently stopped including the QSOs. The operator was backing up config
and thinking it was their log.

Track the resolved logbook file path (a.logDbPath, set in connectLogbook) and
route the backup through a new runConfiguredBackup: the CONTACTS become the
primary "opslog-*" backup (the logbook file on SQLite, an ADIF export on MySQL),
and the settings/config db is snapshotted separately as "opslogcfg-*" so nothing
is lost. backup.Run takes a name prefix; the two sets rotate independently.
2026-08-06 23:22:04 +02:00

285 lines
8.4 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, prefix string) (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 prefix == "" {
prefix = "opslog"
}
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("%s-%s", prefix, 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 := rotateMatch(folder, rotation, prefix+"-", ".db", ".db.zip"); 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
}
// RunADIF writes a rotating ADIF export of the QSO logbook. It's used when the
// log lives on a shared MySQL server, where VACUUM INTO can't snapshot it — an
// ADIF export is a portable, re-importable backup of the actual contacts.
// writeADIF must write the full ADIF to the path it's handed.
func RunADIF(folder string, rotation int, doZip bool, writeADIF func(path string) error) (string, error) {
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)
}
base := "opslog-log-" + time.Now().Format("2006-01-02")
tmp := filepath.Join(folder, base+".adi.tmp")
_ = os.Remove(tmp)
if err := writeADIF(tmp); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("export adif: %w", err)
}
var dstPath string
if doZip {
dstPath = filepath.Join(folder, base+".adi.zip")
if err := copyZipped(tmp, dstPath, base+".adi"); err != nil {
_ = os.Remove(tmp)
return "", err
}
_ = os.Remove(tmp)
} else {
dstPath = filepath.Join(folder, base+".adi")
_ = os.Remove(dstPath)
if err := os.Rename(tmp, dstPath); err != nil {
if cerr := copyFile(tmp, dstPath); cerr != nil {
_ = os.Remove(tmp)
return "", cerr
}
_ = os.Remove(tmp)
}
}
if err := rotateMatch(folder, rotation, "opslog-log-", ".adi", ".adi.zip"); err != nil {
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()
}
// rotateMatch keeps the most recent `keep` files in folder whose name has the
// given prefix and one of the given suffixes, deleting older ones. Only matching
// files are touched — never unrelated user files in the same folder. The suffix
// filter keeps the .db family and the .adi family from rotating each other even
// though both share the "opslog-" prefix.
func rotateMatch(folder string, keep int, prefix string, suffixes ...string) 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, prefix) {
continue
}
ok := false
for _, sfx := range suffixes {
if strings.HasSuffix(name, sfx) {
ok = true
break
}
}
if !ok {
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 SQLite backup for today's date already
// exists in folder. Used by the auto-backup to skip when the user has already
// restarted the app once today.
func HasBackupToday(folder string) bool {
return hasBackupToday(folder, "opslog-", ".db", ".db.zip")
}
// HasADIFBackupToday is HasBackupToday for the ADIF log backup (MySQL mode).
func HasADIFBackupToday(folder string) bool {
return hasBackupToday(folder, "opslog-log-", ".adi", ".adi.zip")
}
func hasBackupToday(folder, prefix string, exts ...string) bool {
if folder == "" {
return false
}
stamp := time.Now().Format("2006-01-02")
for _, ext := range exts {
if _, err := os.Stat(filepath.Join(folder, prefix+stamp+ext)); err == nil {
return true
}
}
return false
}