mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-03 00:47:48 +01:00
* fix(db): fail restore when the backup file does not exist instead of wiping the database `navidrome backup restore -b <file>` passed the flag value straight to the SQLite driver, which opens databases with SQLITE_OPEN_CREATE by default. If the file was not found (for example a file name relative to the working directory instead of the backup directory), the driver silently created an empty database and the backup API copied that emptiness over the live database, reporting 'Restore complete' with an empty instance afterwards. Two changes: - db.Restore now opens the backup file read-only, so a missing file is an error and nothing gets created or overwritten. - A relative --backup-file is resolved against Backup.Path, the same folder 'backup create' writes to; absolute paths keep working as before. Fixes #6083 * fix(db): stat the backup file instead of opening it read-only The read-only DSN added in the previous commit works for the reported case but breaks on other paths: 'file:' + path is parsed as a URI, so a '#' truncates the path and a '%' sequence is percent-decoded, and a read-only open of a WAL database leaves '-shm'/'-wal' sidecars next to the backup. Those sidecars then matched the unanchored prune regex, so 'backup prune -k 3' right after a restore deleted real backups and kept one. Stat the file before opening it and keep passing the plain path to the driver. Paths containing '?' are rejected, since go-sqlite3 splits the DSN there and would otherwise open (and create) a different file. The prune regex is anchored so sidecars are never counted as backups. Also fixes the restore/backup/prune error logs, which printed BasePath (the web URL prefix) instead of the backup location. --------- Co-authored-by: Deluan <deluan@navidrome.org>
199 lines
5.1 KiB
Go
199 lines
5.1 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/db"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var (
|
|
backupCount int
|
|
backupDir string
|
|
force bool
|
|
restorePath string
|
|
)
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(backupRoot)
|
|
|
|
backupCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory to manually make backup")
|
|
backupRoot.AddCommand(backupCmd)
|
|
|
|
pruneCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory holding Navidrome backups")
|
|
pruneCmd.Flags().IntVarP(&backupCount, "keep-count", "k", -1, "specify the number of backups to keep. 0 remove ALL backups, and negative values mean to use the default from configuration")
|
|
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
|
|
backupRoot.AddCommand(pruneCmd)
|
|
|
|
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)")
|
|
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
|
|
_ = restoreCommand.MarkFlagRequired("backup-file")
|
|
backupRoot.AddCommand(restoreCommand)
|
|
}
|
|
|
|
var (
|
|
backupRoot = &cobra.Command{
|
|
Use: "backup",
|
|
Aliases: []string{"bkp"},
|
|
Short: "Create, restore and prune database backups",
|
|
Long: "Create, restore and prune database backups",
|
|
}
|
|
|
|
backupCmd = &cobra.Command{
|
|
Use: "create",
|
|
Short: "Create a backup database",
|
|
Long: "Manually backup Navidrome database. This will ignore BackupCount",
|
|
Run: func(cmd *cobra.Command, _ []string) {
|
|
runBackup(cmd.Context())
|
|
},
|
|
}
|
|
|
|
pruneCmd = &cobra.Command{
|
|
Use: "prune",
|
|
Short: "Prune database backups",
|
|
Long: "Manually prune database backups according to backup rules",
|
|
Run: func(cmd *cobra.Command, _ []string) {
|
|
runPrune(cmd.Context())
|
|
},
|
|
}
|
|
|
|
restoreCommand = &cobra.Command{
|
|
Use: "restore",
|
|
Short: "Restore Navidrome database",
|
|
Long: "Restore Navidrome database from a backup. This must be done offline",
|
|
Run: func(cmd *cobra.Command, _ []string) {
|
|
runRestore(cmd.Context())
|
|
},
|
|
}
|
|
)
|
|
|
|
func runBackup(ctx context.Context) {
|
|
if backupDir != "" {
|
|
conf.Server.Backup.Path = conf.NewDir(backupDir)
|
|
}
|
|
|
|
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
|
var path string
|
|
|
|
if idx == -1 {
|
|
path = conf.Server.DbPath
|
|
} else {
|
|
path = conf.Server.DbPath[:idx]
|
|
}
|
|
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
log.Fatal("No existing database", "path", path)
|
|
return
|
|
}
|
|
|
|
start := time.Now()
|
|
path, err := db.Backup(ctx)
|
|
if err != nil {
|
|
log.Fatal("Error backing up database", "backupPath", conf.Server.Backup.Path, err)
|
|
}
|
|
|
|
elapsed := time.Since(start)
|
|
log.Info("Backup complete", "elapsed", elapsed, "path", path)
|
|
}
|
|
|
|
func runPrune(ctx context.Context) {
|
|
if backupDir != "" {
|
|
conf.Server.Backup.Path = conf.NewDir(backupDir)
|
|
}
|
|
|
|
if backupCount != -1 {
|
|
conf.Server.Backup.Count = backupCount
|
|
}
|
|
|
|
if conf.Server.Backup.Count == 0 && !force {
|
|
fmt.Println("Warning: pruning ALL backups")
|
|
fmt.Printf("Please enter YES (all caps) to continue: ")
|
|
var input string
|
|
_, err := fmt.Scanln(&input)
|
|
|
|
if input != "YES" || err != nil {
|
|
log.Warn("Prune cancelled")
|
|
return
|
|
}
|
|
}
|
|
|
|
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
|
var path string
|
|
|
|
if idx == -1 {
|
|
path = conf.Server.DbPath
|
|
} else {
|
|
path = conf.Server.DbPath[:idx]
|
|
}
|
|
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
log.Fatal("No existing database", "path", path)
|
|
return
|
|
}
|
|
|
|
start := time.Now()
|
|
count, err := db.Prune(ctx)
|
|
if err != nil {
|
|
log.Fatal("Error pruning database", "backupPath", conf.Server.Backup.Path, err)
|
|
}
|
|
|
|
elapsed := time.Since(start)
|
|
|
|
log.Info("Prune complete", "elapsed", elapsed, "successfully pruned", count)
|
|
}
|
|
|
|
func runRestore(ctx context.Context) {
|
|
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
|
var path string
|
|
|
|
if idx == -1 {
|
|
path = conf.Server.DbPath
|
|
} else {
|
|
path = conf.Server.DbPath[:idx]
|
|
}
|
|
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
log.Fatal("No existing database", "path", path)
|
|
return
|
|
}
|
|
|
|
// A relative --backup-file is resolved against Backup.Path, the same folder
|
|
// `backup create` writes to. Without this, the value was treated as relative
|
|
// to the working directory, where the file does not exist.
|
|
if !filepath.IsAbs(restorePath) {
|
|
backupPath, err := conf.Server.Backup.Path.Path()
|
|
if err != nil {
|
|
log.Fatal("Backup directory not available", "backupPath", conf.Server.Backup.Path, err)
|
|
return
|
|
}
|
|
restorePath = filepath.Join(backupPath, restorePath)
|
|
}
|
|
|
|
if !force {
|
|
fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
|
|
fmt.Printf("Please enter YES (all caps) to continue: ")
|
|
var input string
|
|
_, err := fmt.Scanln(&input)
|
|
|
|
if input != "YES" || err != nil {
|
|
log.Warn("Restore cancelled")
|
|
return
|
|
}
|
|
}
|
|
|
|
start := time.Now()
|
|
err := db.Restore(ctx, restorePath)
|
|
if err != nil {
|
|
log.Fatal("Error restoring database", "backupFile", restorePath, err)
|
|
}
|
|
|
|
elapsed := time.Since(start)
|
|
log.Info("Restore complete", "elapsed", elapsed)
|
|
}
|