mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-14 14:18:42 +01:00
231 lines
6.5 KiB
Go
231 lines
6.5 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mattn/go-sqlite3"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/log"
|
|
)
|
|
|
|
const (
|
|
backupPrefix = "navidrome_backup"
|
|
backupRegexString = "^" + backupPrefix + "_(.+)\\.db$"
|
|
)
|
|
|
|
var backupRegex = regexp.MustCompile(backupRegexString)
|
|
|
|
const backupSuffixLayout = "2006.01.02_15.04.05"
|
|
|
|
func backupPath(t time.Time) string {
|
|
return filepath.Join(
|
|
conf.Server().Backup.Path.MustPath(),
|
|
fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)),
|
|
)
|
|
}
|
|
|
|
func openBackupFile(path string) (*sql.DB, error) {
|
|
Db() // Registers Driver, which the CLI commands haven't done at this point.
|
|
backupDb, err := sql.Open(Driver, path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening backup database in '%s': %w", path, err)
|
|
}
|
|
return backupDb, nil
|
|
}
|
|
|
|
func copyDatabase(ctx context.Context, dest, source *sql.DB) error {
|
|
// heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/
|
|
destConn, err := dest.Conn(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("getting destination connection: %w", err)
|
|
}
|
|
defer destConn.Close()
|
|
|
|
sourceConn, err := source.Conn(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("getting source connection: %w", err)
|
|
}
|
|
defer sourceConn.Close()
|
|
|
|
return destConn.Raw(func(rawDest any) error {
|
|
return sourceConn.Raw(func(rawSource any) error {
|
|
sqliteDest, destOk := rawDest.(*sqlite3.SQLiteConn)
|
|
if !destOk {
|
|
return fmt.Errorf("error trying to convert destination to sqlite connection")
|
|
}
|
|
sqliteSource, sourceOk := rawSource.(*sqlite3.SQLiteConn)
|
|
if !sourceOk {
|
|
return fmt.Errorf("error trying to convert source to sqlite connection")
|
|
}
|
|
|
|
backupOp, err := sqliteDest.Backup("main", sqliteSource, "main")
|
|
if err != nil {
|
|
return fmt.Errorf("error starting sqlite backup: %w", err)
|
|
}
|
|
defer backupOp.Close()
|
|
|
|
// Caution: -1 means that sqlite will hold a read lock until the operation finishes
|
|
// This will lock out other writes that could happen at the same time
|
|
done, err := backupOp.Step(-1)
|
|
if err != nil {
|
|
return fmt.Errorf("error during backup step: %w", err)
|
|
}
|
|
if !done {
|
|
return fmt.Errorf("backup not done with step -1")
|
|
}
|
|
|
|
err = backupOp.Finish()
|
|
if err != nil {
|
|
return fmt.Errorf("error finishing backup: %w", err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
})
|
|
}
|
|
|
|
// checkRestoreSource runs sanity checks on a file about to be restored over the live
|
|
// database. They catch the common ways a backup goes bad, not every kind of damage.
|
|
func checkRestoreSource(ctx context.Context, backupDb *sql.DB, path string, integrityCheck bool) error {
|
|
// SQLite opens an empty file as an empty database, and restoring it would wipe the live one.
|
|
// user and media_file exist since the first migration, so backups of any age have them.
|
|
var tables int
|
|
err := backupDb.QueryRowContext(ctx,
|
|
"SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN ('goose_db_version', 'user', 'media_file')").Scan(&tables)
|
|
if err != nil {
|
|
return fmt.Errorf("reading backup file '%s': %w", path, err)
|
|
}
|
|
if tables != 3 {
|
|
return fmt.Errorf("backup file '%s' is not a Navidrome database", path)
|
|
}
|
|
|
|
// The table can be there with nothing in it, or with only the row goose adds when it creates it.
|
|
var applied int
|
|
err = backupDb.QueryRowContext(ctx,
|
|
"SELECT count(*) FROM goose_db_version WHERE is_applied = 1 AND version_id > 0").Scan(&applied)
|
|
if err != nil {
|
|
return fmt.Errorf("reading backup file '%s': %w", path, err)
|
|
}
|
|
if applied == 0 {
|
|
return fmt.Errorf("backup file '%s' has no applied migrations", path)
|
|
}
|
|
|
|
// The queries above only read the first pages. Both checks walk the whole file, and
|
|
// integrity_check also compares indexes to their tables, which is much slower.
|
|
check := "PRAGMA quick_check(1)"
|
|
if integrityCheck {
|
|
check = "PRAGMA integrity_check(1)"
|
|
}
|
|
var result string
|
|
if err := backupDb.QueryRowContext(ctx, check).Scan(&result); err != nil {
|
|
return fmt.Errorf("checking backup file '%s': %w", path, err)
|
|
}
|
|
if result != "ok" {
|
|
return fmt.Errorf("backup file '%s' is damaged: %s", path, result)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func Backup(ctx context.Context) (string, error) {
|
|
destPath := backupPath(time.Now())
|
|
log.Debug(ctx, "Creating backup", "path", destPath)
|
|
backupDb, err := openBackupFile(destPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer backupDb.Close()
|
|
|
|
if err := copyDatabase(ctx, backupDb, Db()); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return destPath, nil
|
|
}
|
|
|
|
func Restore(ctx context.Context, path string, integrityCheck bool) error {
|
|
log.Debug(ctx, "Restoring backup", "path", path)
|
|
|
|
// The driver splits the DSN at '?', so such a path would open a different file.
|
|
if strings.ContainsRune(path, '?') {
|
|
return fmt.Errorf("backup path cannot contain '?': %s", path)
|
|
}
|
|
// The driver opens with SQLITE_OPEN_CREATE, so without this check a typo in the
|
|
// path would create an empty database and "restore" it over the live one.
|
|
if _, err := os.Stat(path); err != nil {
|
|
return fmt.Errorf("backup file not available: %w", err)
|
|
}
|
|
|
|
backupDb, err := openBackupFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer backupDb.Close()
|
|
|
|
if err := checkRestoreSource(ctx, backupDb, path, integrityCheck); err != nil {
|
|
return err
|
|
}
|
|
return copyDatabase(ctx, Db(), backupDb)
|
|
}
|
|
|
|
func Prune(ctx context.Context) (int, error) {
|
|
backupDir, err := conf.Server().Backup.Path.Path()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("backup directory not available: %w", err)
|
|
}
|
|
files, err := os.ReadDir(backupDir)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("unable to read database backup entries: %w", err)
|
|
}
|
|
|
|
var backupTimes []time.Time
|
|
|
|
for _, file := range files {
|
|
if !file.IsDir() {
|
|
submatch := backupRegex.FindStringSubmatch(file.Name())
|
|
if len(submatch) == 2 {
|
|
timestamp, err := time.Parse(backupSuffixLayout, submatch[1])
|
|
if err == nil {
|
|
backupTimes = append(backupTimes, timestamp)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(backupTimes) <= conf.Server().Backup.Count {
|
|
return 0, nil
|
|
}
|
|
|
|
slices.SortFunc(backupTimes, func(a, b time.Time) int {
|
|
return b.Compare(a)
|
|
})
|
|
|
|
pruneCount := 0
|
|
var errs []error
|
|
|
|
for _, timeToPrune := range backupTimes[conf.Server().Backup.Count:] {
|
|
log.Debug(ctx, "Pruning backup", "time", timeToPrune)
|
|
path := backupPath(timeToPrune)
|
|
err = os.Remove(path)
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
} else {
|
|
pruneCount++
|
|
}
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
err = errors.Join(errs...)
|
|
log.Error(ctx, "Failed to delete one or more files", "errors", err)
|
|
}
|
|
|
|
return pruneCount, err
|
|
}
|