mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
Config: Add “daily” and “weekly” backup schedule options #4243
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -1,22 +1,81 @@
|
|||||||
package photoprism
|
package backup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/photoprism/photoprism/internal/entity"
|
"github.com/photoprism/photoprism/internal/entity"
|
||||||
|
"github.com/photoprism/photoprism/internal/get"
|
||||||
"github.com/photoprism/photoprism/internal/query"
|
"github.com/photoprism/photoprism/internal/query"
|
||||||
"github.com/photoprism/photoprism/pkg/clean"
|
"github.com/photoprism/photoprism/pkg/clean"
|
||||||
"github.com/photoprism/photoprism/pkg/fs"
|
"github.com/photoprism/photoprism/pkg/fs"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Albums creates a YAML file backup of all albums.
|
||||||
|
func Albums(backupPath string, force bool) (count int, err error) {
|
||||||
|
// Make sure only one backup/restore operation is running at a time.
|
||||||
|
backupAlbumsMutex.Lock()
|
||||||
|
defer backupAlbumsMutex.Unlock()
|
||||||
|
|
||||||
|
// Get albums from database.
|
||||||
|
albums, queryErr := query.Albums(0, 1000000)
|
||||||
|
|
||||||
|
if queryErr != nil {
|
||||||
|
return count, queryErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fs.PathExists(backupPath) {
|
||||||
|
backupPath = get.Config().BackupAlbumsPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("backup: album backups will be stored in %s", clean.Log(backupPath))
|
||||||
|
log.Infof("backup: saving album metadata in YAML backup files")
|
||||||
|
|
||||||
|
var latest time.Time
|
||||||
|
|
||||||
|
// Ignore the last modification timestamp if the force flag is set.
|
||||||
|
if !force {
|
||||||
|
latest = backupAlbumsTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save albums to YAML backup files.
|
||||||
|
for _, a := range albums {
|
||||||
|
// Album modification timestamp.
|
||||||
|
changed := a.UpdatedAt
|
||||||
|
|
||||||
|
// Skip albums that have already been saved to YAML backup files.
|
||||||
|
if !force && !backupAlbumsTime.IsZero() && !changed.IsZero() && !backupAlbumsTime.Before(changed) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember the lastest modification timestamp.
|
||||||
|
if changed.After(latest) {
|
||||||
|
latest = changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write album metadata to YAML backup file.
|
||||||
|
if saveErr := a.SaveBackupYaml(backupPath); saveErr != nil {
|
||||||
|
err = saveErr
|
||||||
|
} else {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set backupAlbumsTime to latest modification timestamp,
|
||||||
|
// so that already saved albums can be skipped next time.
|
||||||
|
backupAlbumsTime = latest
|
||||||
|
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
// RestoreAlbums restores all album YAML file backups.
|
// RestoreAlbums restores all album YAML file backups.
|
||||||
func RestoreAlbums(backupPath string, force bool) (count int, result error) {
|
func RestoreAlbums(backupPath string, force bool) (count int, result error) {
|
||||||
// Make sure only one backup/restore operation is running at a time.
|
// Make sure only one backup/restore operation is running at a time.
|
||||||
backupAlbumsMutex.Lock()
|
backupAlbumsMutex.Lock()
|
||||||
defer backupAlbumsMutex.Unlock()
|
defer backupAlbumsMutex.Unlock()
|
||||||
|
|
||||||
c := Config()
|
c := get.Config()
|
||||||
|
|
||||||
if !c.BackupAlbums() && !force {
|
if !c.BackupAlbums() && !force {
|
||||||
log.Debugf("albums: metadata backup files are disabled")
|
log.Debugf("albums: metadata backup files are disabled")
|
||||||
35
internal/backup/albums_test.go
Normal file
35
internal/backup/albums_test.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/pkg/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlbums(t *testing.T) {
|
||||||
|
backupPath, err := filepath.Abs("./testdata/albums")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = os.MkdirAll(backupPath, fs.ModeDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := Albums(backupPath, true)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 30, count)
|
||||||
|
|
||||||
|
if err = os.RemoveAll(backupPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
internal/backup/backup.go
Normal file
31
internal/backup/backup.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
Package backup provides backup and restore functions for databases and albums.
|
||||||
|
|
||||||
|
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
|
||||||
|
<https://docs.photoprism.app/license/agpl>
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
The AGPL is supplemented by our Trademark and Brand Guidelines,
|
||||||
|
which describe how our Brand Assets may be used:
|
||||||
|
<https://www.photoprism.app/trademark>
|
||||||
|
|
||||||
|
Feel free to send an email to hello@photoprism.app if you have questions,
|
||||||
|
want to support our work, or just want to say hello.
|
||||||
|
|
||||||
|
Additional information can be found in our Developer Guide:
|
||||||
|
<https://docs.photoprism.app/developer-guide/>
|
||||||
|
*/
|
||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/photoprism/photoprism/internal/event"
|
||||||
|
)
|
||||||
|
|
||||||
|
var log = event.Log
|
||||||
29
internal/backup/backup_test.go
Normal file
29
internal/backup/backup_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/internal/config"
|
||||||
|
"github.com/photoprism/photoprism/internal/event"
|
||||||
|
"github.com/photoprism/photoprism/internal/get"
|
||||||
|
"github.com/photoprism/photoprism/internal/photoprism"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
log = logrus.StandardLogger()
|
||||||
|
log.SetLevel(logrus.TraceLevel)
|
||||||
|
event.AuditLog = log
|
||||||
|
|
||||||
|
c := config.TestConfig()
|
||||||
|
defer c.CloseDb()
|
||||||
|
|
||||||
|
get.SetConfig(c)
|
||||||
|
photoprism.SetConfig(c)
|
||||||
|
|
||||||
|
code := m.Run()
|
||||||
|
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
3
internal/backup/const.go
Normal file
3
internal/backup/const.go
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
const SqlBackupFileNamePattern = "[2-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9].sql"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package photoprism
|
package backup
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -11,14 +11,149 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize/english"
|
||||||
|
|
||||||
"github.com/photoprism/photoprism/internal/config"
|
"github.com/photoprism/photoprism/internal/config"
|
||||||
"github.com/photoprism/photoprism/internal/entity"
|
"github.com/photoprism/photoprism/internal/entity"
|
||||||
|
"github.com/photoprism/photoprism/internal/get"
|
||||||
"github.com/photoprism/photoprism/pkg/clean"
|
"github.com/photoprism/photoprism/pkg/clean"
|
||||||
"github.com/photoprism/photoprism/pkg/fs"
|
"github.com/photoprism/photoprism/pkg/fs"
|
||||||
)
|
)
|
||||||
|
|
||||||
const SqlBackupFileNamePattern = "[2-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9].sql"
|
// Database creates a database backup dump with the specified file and path name.
|
||||||
|
func Database(backupPath, fileName string, toStdOut, force bool, retain int) (err error) {
|
||||||
|
// Ensure that only one database backup/restore operation is running at a time.
|
||||||
|
backupDatabaseMutex.Lock()
|
||||||
|
defer backupDatabaseMutex.Unlock()
|
||||||
|
|
||||||
|
// Backup action shown in logs.
|
||||||
|
backupAction := "creating"
|
||||||
|
|
||||||
|
// Get configuration.
|
||||||
|
c := get.Config()
|
||||||
|
|
||||||
|
if !toStdOut {
|
||||||
|
if backupPath == "" {
|
||||||
|
backupPath = c.BackupDatabasePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the backup path if it does not already exist.
|
||||||
|
if err = fs.MkdirAll(backupPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the backup path is writable.
|
||||||
|
if !fs.PathWritable(backupPath) {
|
||||||
|
return fmt.Errorf("backup path is not writable")
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileName == "" {
|
||||||
|
backupFile := time.Now().UTC().Format("2006-01-02") + ".sql"
|
||||||
|
fileName = filepath.Join(backupPath, backupFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debugf("backup: database backups will be stored in %s", clean.Log(backupPath))
|
||||||
|
|
||||||
|
if _, err = os.Stat(fileName); err == nil && !force {
|
||||||
|
return fmt.Errorf("%s already exists", clean.Log(filepath.Base(fileName)))
|
||||||
|
} else if err == nil {
|
||||||
|
backupAction = "replacing"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create backup path if not exists.
|
||||||
|
if dir := filepath.Dir(fileName); dir != "." {
|
||||||
|
if err = fs.MkdirAll(dir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
|
||||||
|
switch c.DatabaseDriver() {
|
||||||
|
case config.MySQL, config.MariaDB:
|
||||||
|
cmd = exec.Command(
|
||||||
|
c.MariadbDumpBin(),
|
||||||
|
"--protocol", "tcp",
|
||||||
|
"-h", c.DatabaseHost(),
|
||||||
|
"-P", c.DatabasePortString(),
|
||||||
|
"-u", c.DatabaseUser(),
|
||||||
|
"-p"+c.DatabasePassword(),
|
||||||
|
c.DatabaseName(),
|
||||||
|
)
|
||||||
|
case config.SQLite3:
|
||||||
|
if !fs.FileExistsNotEmpty(c.DatabaseFile()) {
|
||||||
|
return fmt.Errorf("sqlite database file %s not found", clean.LogQuote(c.DatabaseFile()))
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd = exec.Command(
|
||||||
|
c.SqliteBin(),
|
||||||
|
c.DatabaseFile(),
|
||||||
|
".dump",
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported database type: %s", c.DatabaseDriver())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to stdout or file.
|
||||||
|
var f *os.File
|
||||||
|
if toStdOut {
|
||||||
|
log.Infof("backup: sending database backup to stdout")
|
||||||
|
f = os.Stdout
|
||||||
|
} else if f, err = os.OpenFile(fileName, os.O_TRUNC|os.O_RDWR|os.O_CREATE, fs.ModeBackup); err != nil {
|
||||||
|
return fmt.Errorf("failed to create %s (%s)", clean.Log(fileName), err)
|
||||||
|
} else {
|
||||||
|
log.Infof("backup: %s database backup file %s", backupAction, clean.Log(filepath.Base(fileName)))
|
||||||
|
defer f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
cmd.Stdout = f
|
||||||
|
|
||||||
|
// Log exact command for debugging in trace mode.
|
||||||
|
log.Trace(cmd.String())
|
||||||
|
|
||||||
|
// Run backup command.
|
||||||
|
if cmdErr := cmd.Run(); cmdErr != nil {
|
||||||
|
if errStr := strings.TrimSpace(stderr.String()); errStr != "" {
|
||||||
|
return errors.New(errStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmdErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old backups if the number of backup files to keep has been specified.
|
||||||
|
if !toStdOut && backupPath != "" && retain > 0 {
|
||||||
|
files, globErr := filepath.Glob(filepath.Join(regexp.QuoteMeta(backupPath), SqlBackupFileNamePattern))
|
||||||
|
|
||||||
|
if globErr != nil {
|
||||||
|
return globErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
return fmt.Errorf("found no database backup files in %s", backupPath)
|
||||||
|
} else if len(files) <= retain {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(files)
|
||||||
|
|
||||||
|
log.Infof("backup: retaining %s", english.Plural(retain, "database backup", "database backups"))
|
||||||
|
|
||||||
|
for i := 0; i < len(files)-retain; i++ {
|
||||||
|
if err = os.Remove(files[i]); err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
log.Infof("backup: removed database backup file %s", clean.Log(filepath.Base(files[i])))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// RestoreDatabase restores the database from a backup file with the specified path and name.
|
// RestoreDatabase restores the database from a backup file with the specified path and name.
|
||||||
func RestoreDatabase(backupPath, fileName string, fromStdIn, force bool) (err error) {
|
func RestoreDatabase(backupPath, fileName string, fromStdIn, force bool) (err error) {
|
||||||
@@ -26,7 +161,7 @@ func RestoreDatabase(backupPath, fileName string, fromStdIn, force bool) (err er
|
|||||||
backupDatabaseMutex.Lock()
|
backupDatabaseMutex.Lock()
|
||||||
defer backupDatabaseMutex.Unlock()
|
defer backupDatabaseMutex.Unlock()
|
||||||
|
|
||||||
c := Config()
|
c := get.Config()
|
||||||
|
|
||||||
// If empty, use default backup file name.
|
// If empty, use default backup file name.
|
||||||
if !fromStdIn {
|
if !fromStdIn {
|
||||||
12
internal/backup/sync.go
Normal file
12
internal/backup/sync.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
backupDatabaseMutex = sync.Mutex{}
|
||||||
|
backupAlbumsMutex = sync.Mutex{}
|
||||||
|
backupAlbumsTime = time.Time{}
|
||||||
|
)
|
||||||
2
internal/backup/testdata/.gitignore
vendored
Normal file
2
internal/backup/testdata/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"github.com/dustin/go-humanize/english"
|
"github.com/dustin/go-humanize/english"
|
||||||
"github.com/urfave/cli"
|
"github.com/urfave/cli"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/internal/backup"
|
||||||
"github.com/photoprism/photoprism/internal/config"
|
"github.com/photoprism/photoprism/internal/config"
|
||||||
"github.com/photoprism/photoprism/internal/photoprism"
|
|
||||||
"github.com/photoprism/photoprism/pkg/fs"
|
"github.com/photoprism/photoprism/pkg/fs"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ func backupAction(ctx *cli.Context) error {
|
|||||||
fileName = filepath.Join(databasePath, backupFile)
|
fileName = filepath.Join(databasePath, backupFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = photoprism.BackupDatabase(databasePath, fileName, fileName == "-", force, retain); err != nil {
|
if err = backup.Database(databasePath, fileName, fileName == "-", force, retain); err != nil {
|
||||||
return fmt.Errorf("failed to create database backup: %w", err)
|
return fmt.Errorf("failed to create database backup: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ func backupAction(ctx *cli.Context) error {
|
|||||||
albumsPath = conf.BackupAlbumsPath()
|
albumsPath = conf.BackupAlbumsPath()
|
||||||
}
|
}
|
||||||
|
|
||||||
if count, backupErr := photoprism.BackupAlbums(albumsPath, true); backupErr != nil {
|
if count, backupErr := backup.Albums(albumsPath, true); backupErr != nil {
|
||||||
return backupErr
|
return backupErr
|
||||||
} else {
|
} else {
|
||||||
log.Infof("backup: saved %s", english.Plural(count, "album backup", "album backups"))
|
log.Infof("backup: saved %s", english.Plural(count, "album backup", "album backups"))
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"github.com/dustin/go-humanize/english"
|
"github.com/dustin/go-humanize/english"
|
||||||
"github.com/urfave/cli"
|
"github.com/urfave/cli"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/internal/backup"
|
||||||
"github.com/photoprism/photoprism/internal/get"
|
"github.com/photoprism/photoprism/internal/get"
|
||||||
"github.com/photoprism/photoprism/internal/photoprism"
|
|
||||||
"github.com/photoprism/photoprism/pkg/clean"
|
"github.com/photoprism/photoprism/pkg/clean"
|
||||||
"github.com/photoprism/photoprism/pkg/fs"
|
"github.com/photoprism/photoprism/pkg/fs"
|
||||||
)
|
)
|
||||||
@@ -81,7 +81,7 @@ func restoreAction(ctx *cli.Context) error {
|
|||||||
// Restore database from backup dump?
|
// Restore database from backup dump?
|
||||||
if !restoreDatabase {
|
if !restoreDatabase {
|
||||||
// Do nothing.
|
// Do nothing.
|
||||||
} else if err = photoprism.RestoreDatabase(databasePath, databaseFile, databaseFile == "-", force); err != nil {
|
} else if err = backup.RestoreDatabase(databasePath, databaseFile, databaseFile == "-", force); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ func restoreAction(ctx *cli.Context) error {
|
|||||||
} else {
|
} else {
|
||||||
log.Infof("restore: restoring album backups from %s", clean.Log(albumsPath))
|
log.Infof("restore: restoring album backups from %s", clean.Log(albumsPath))
|
||||||
|
|
||||||
if count, restoreErr := photoprism.RestoreAlbums(albumsPath, true); restoreErr != nil {
|
if count, restoreErr := backup.RestoreAlbums(albumsPath, true); restoreErr != nil {
|
||||||
return restoreErr
|
return restoreErr
|
||||||
} else {
|
} else {
|
||||||
log.Infof("restore: restored %s from YAML files", english.Plural(count, "album", "albums"))
|
log.Infof("restore: restored %s from YAML files", english.Plural(count, "album", "albums"))
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import (
|
|||||||
"github.com/urfave/cli"
|
"github.com/urfave/cli"
|
||||||
|
|
||||||
"github.com/photoprism/photoprism/internal/auto"
|
"github.com/photoprism/photoprism/internal/auto"
|
||||||
|
"github.com/photoprism/photoprism/internal/backup"
|
||||||
"github.com/photoprism/photoprism/internal/mutex"
|
"github.com/photoprism/photoprism/internal/mutex"
|
||||||
"github.com/photoprism/photoprism/internal/photoprism"
|
|
||||||
"github.com/photoprism/photoprism/internal/server"
|
"github.com/photoprism/photoprism/internal/server"
|
||||||
"github.com/photoprism/photoprism/internal/session"
|
"github.com/photoprism/photoprism/internal/session"
|
||||||
"github.com/photoprism/photoprism/internal/workers"
|
"github.com/photoprism/photoprism/internal/workers"
|
||||||
@@ -126,7 +126,7 @@ func startAction(ctx *cli.Context) error {
|
|||||||
go server.Start(cctx, conf)
|
go server.Start(cctx, conf)
|
||||||
|
|
||||||
// Restore albums from YAML files.
|
// Restore albums from YAML files.
|
||||||
if count, restoreErr := photoprism.RestoreAlbums(conf.BackupAlbumsPath(), false); restoreErr != nil {
|
if count, restoreErr := backup.RestoreAlbums(conf.BackupAlbumsPath(), false); restoreErr != nil {
|
||||||
log.Errorf("restore: %s (albums)", restoreErr)
|
log.Errorf("restore: %s (albums)", restoreErr)
|
||||||
} else if count > 0 {
|
} else if count > 0 {
|
||||||
log.Infof("restore: %s restored", english.Plural(count, "album backup", "album backups"))
|
log.Infof("restore: %s restored", english.Plural(count, "album backup", "album backups"))
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ import (
|
|||||||
_ "github.com/jinzhu/gorm/dialects/sqlite"
|
_ "github.com/jinzhu/gorm/dialects/sqlite"
|
||||||
"github.com/klauspost/cpuid/v2"
|
"github.com/klauspost/cpuid/v2"
|
||||||
"github.com/pbnjay/memory"
|
"github.com/pbnjay/memory"
|
||||||
"github.com/robfig/cron/v3"
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/urfave/cli"
|
"github.com/urfave/cli"
|
||||||
|
|
||||||
@@ -766,14 +765,7 @@ func (c *Config) IndexWorkers() int {
|
|||||||
|
|
||||||
// IndexSchedule returns the indexing schedule in cron format, e.g. "0 */3 * * *" to start indexing every 3 hours.
|
// IndexSchedule returns the indexing schedule in cron format, e.g. "0 */3 * * *" to start indexing every 3 hours.
|
||||||
func (c *Config) IndexSchedule() string {
|
func (c *Config) IndexSchedule() string {
|
||||||
if c.options.IndexSchedule == "" {
|
return Schedule(c.options.IndexSchedule)
|
||||||
return ""
|
|
||||||
} else if _, err := cron.ParseStandard(c.options.IndexSchedule); err != nil {
|
|
||||||
log.Tracef("config: invalid auto indexing schedule (%s)", err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.options.IndexSchedule
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WakeupInterval returns the duration between background worker runs
|
// WakeupInterval returns the duration between background worker runs
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ package config
|
|||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/robfig/cron/v3"
|
|
||||||
|
|
||||||
"github.com/photoprism/photoprism/pkg/clean"
|
"github.com/photoprism/photoprism/pkg/clean"
|
||||||
"github.com/photoprism/photoprism/pkg/fs"
|
"github.com/photoprism/photoprism/pkg/fs"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultBackupSchedule = "0 12 * * *"
|
DefaultBackupSchedule = "daily"
|
||||||
DefaultBackupRetain = 3
|
DefaultBackupRetain = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,14 +32,7 @@ func (c *Config) BackupBasePath() string {
|
|||||||
|
|
||||||
// BackupSchedule returns the backup schedule in cron format, e.g. "0 12 * * *" for daily at noon.
|
// BackupSchedule returns the backup schedule in cron format, e.g. "0 12 * * *" for daily at noon.
|
||||||
func (c *Config) BackupSchedule() string {
|
func (c *Config) BackupSchedule() string {
|
||||||
if c.options.BackupSchedule == "" {
|
return Schedule(c.options.BackupSchedule)
|
||||||
return ""
|
|
||||||
} else if _, err := cron.ParseStandard(c.options.BackupSchedule); err != nil {
|
|
||||||
log.Tracef("config: invalid backup schedule (%s)", err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.options.BackupSchedule
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackupRetain returns the maximum number of SQL database dumps to keep, or -1 to keep all.
|
// BackupRetain returns the maximum number of SQL database dumps to keep, or -1 to keep all.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func TestConfig_BackupBasePath(t *testing.T) {
|
|||||||
|
|
||||||
func TestConfig_BackupSchedule(t *testing.T) {
|
func TestConfig_BackupSchedule(t *testing.T) {
|
||||||
c := NewConfig(CliTestContext())
|
c := NewConfig(CliTestContext())
|
||||||
assert.Equal(t, DefaultBackupSchedule, c.BackupSchedule())
|
assert.Contains(t, c.BackupSchedule(), " * * *")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfig_BackupRetain(t *testing.T) {
|
func TestConfig_BackupRetain(t *testing.T) {
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ var Flags = CliFlags{
|
|||||||
}}, {
|
}}, {
|
||||||
Flag: cli.StringFlag{
|
Flag: cli.StringFlag{
|
||||||
Name: "backup-schedule",
|
Name: "backup-schedule",
|
||||||
Usage: "backup `SCHEDULE` in cron format, e.g. \"0 12 * * *\" for daily at noon",
|
Usage: "backup `SCHEDULE` in cron format (e.g. \"0 12 * * *\" for daily at noon) or at a random time (daily, weekly)",
|
||||||
Value: DefaultBackupSchedule,
|
Value: DefaultBackupSchedule,
|
||||||
EnvVar: EnvVar("BACKUP_SCHEDULE"),
|
EnvVar: EnvVar("BACKUP_SCHEDULE"),
|
||||||
}}, {
|
}}, {
|
||||||
@@ -221,7 +221,7 @@ var Flags = CliFlags{
|
|||||||
}}, {
|
}}, {
|
||||||
Flag: cli.StringFlag{
|
Flag: cli.StringFlag{
|
||||||
Name: "index-schedule",
|
Name: "index-schedule",
|
||||||
Usage: "indexing `SCHEDULE` in cron format, e.g. \"0 */3 * * *\" for every 3 hours (leave empty to disable)",
|
Usage: "regular indexing `SCHEDULE` in cron format (e.g. \"0 */3 * * *\" or \"@every 3h\" for every 3 hours; leave empty to disable)",
|
||||||
Value: DefaultIndexSchedule,
|
Value: DefaultIndexSchedule,
|
||||||
EnvVar: EnvVar("INDEX_SCHEDULE"),
|
EnvVar: EnvVar("INDEX_SCHEDULE"),
|
||||||
}}, {
|
}}, {
|
||||||
|
|||||||
41
internal/config/schedule.go
Normal file
41
internal/config/schedule.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/pkg/clean"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ScheduleDaily = "daily"
|
||||||
|
ScheduleWeekly = "weekly"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Schedule evaluates a schedule config value and returns it, or an empty string if it is invalid. Cron schedules consist
|
||||||
|
// of 5 space separated values: minute, hour, day of month, month and day of week, e.g. "0 12 * * *" for daily at noon.
|
||||||
|
func Schedule(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
s = strings.TrimSpace(strings.ToLower(s))
|
||||||
|
|
||||||
|
switch s {
|
||||||
|
case ScheduleDaily:
|
||||||
|
return fmt.Sprintf("%d %d * * *", rand.IntN(60), rand.IntN(24))
|
||||||
|
case ScheduleWeekly:
|
||||||
|
return fmt.Sprintf("%d %d * * 0", rand.IntN(60), rand.IntN(24))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example: "0 12 * * *" stands for daily at noon.
|
||||||
|
if _, err := cron.ParseStandard(s); err != nil {
|
||||||
|
log.Warnf("config: invalid schedule %s (%s)", clean.Log(s), err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
23
internal/config/schedule_test.go
Normal file
23
internal/config/schedule_test.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSchedule(t *testing.T) {
|
||||||
|
assert.Equal(t, "", Schedule(""))
|
||||||
|
assert.Equal(t, DefaultIndexSchedule, Schedule(DefaultIndexSchedule))
|
||||||
|
|
||||||
|
// Random default backup schedule.
|
||||||
|
backupSchedule := Schedule(DefaultBackupSchedule)
|
||||||
|
assert.Equal(t, backupSchedule, Schedule(backupSchedule))
|
||||||
|
|
||||||
|
// Regular backups at a random time (daily or weekly).
|
||||||
|
daily := Schedule(ScheduleDaily)
|
||||||
|
weekly := Schedule(ScheduleWeekly)
|
||||||
|
|
||||||
|
assert.Equal(t, daily, Schedule(daily))
|
||||||
|
assert.Equal(t, weekly, Schedule(weekly))
|
||||||
|
}
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package photoprism
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/photoprism/photoprism/internal/query"
|
|
||||||
"github.com/photoprism/photoprism/pkg/clean"
|
|
||||||
"github.com/photoprism/photoprism/pkg/fs"
|
|
||||||
)
|
|
||||||
|
|
||||||
var backupAlbumsTime = time.Time{}
|
|
||||||
var backupAlbumsMutex = sync.Mutex{}
|
|
||||||
|
|
||||||
// BackupAlbums creates a YAML file backup of all albums.
|
|
||||||
func BackupAlbums(backupPath string, force bool) (count int, err error) {
|
|
||||||
// Make sure only one backup/restore operation is running at a time.
|
|
||||||
backupAlbumsMutex.Lock()
|
|
||||||
defer backupAlbumsMutex.Unlock()
|
|
||||||
|
|
||||||
// Get albums from database.
|
|
||||||
albums, queryErr := query.Albums(0, 1000000)
|
|
||||||
|
|
||||||
if queryErr != nil {
|
|
||||||
return count, queryErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if !fs.PathExists(backupPath) {
|
|
||||||
backupPath = Config().BackupAlbumsPath()
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debugf("backup: album backups will be stored in %s", clean.Log(backupPath))
|
|
||||||
log.Infof("backup: saving album metadata in YAML backup files")
|
|
||||||
|
|
||||||
var latest time.Time
|
|
||||||
|
|
||||||
// Ignore the last modification timestamp if the force flag is set.
|
|
||||||
if !force {
|
|
||||||
latest = backupAlbumsTime
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save albums to YAML backup files.
|
|
||||||
for _, a := range albums {
|
|
||||||
// Album modification timestamp.
|
|
||||||
changed := a.UpdatedAt
|
|
||||||
|
|
||||||
// Skip albums that have already been saved to YAML backup files.
|
|
||||||
if !force && !backupAlbumsTime.IsZero() && !changed.IsZero() && !backupAlbumsTime.Before(changed) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remember the lastest modification timestamp.
|
|
||||||
if changed.After(latest) {
|
|
||||||
latest = changed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write album metadata to YAML backup file.
|
|
||||||
if saveErr := a.SaveBackupYaml(backupPath); saveErr != nil {
|
|
||||||
err = saveErr
|
|
||||||
} else {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set backupAlbumsTime to latest modification timestamp,
|
|
||||||
// so that already saved albums can be skipped next time.
|
|
||||||
backupAlbumsTime = latest
|
|
||||||
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
package photoprism
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dustin/go-humanize/english"
|
|
||||||
|
|
||||||
"github.com/photoprism/photoprism/internal/config"
|
|
||||||
"github.com/photoprism/photoprism/pkg/clean"
|
|
||||||
"github.com/photoprism/photoprism/pkg/fs"
|
|
||||||
)
|
|
||||||
|
|
||||||
var backupDatabaseMutex = sync.Mutex{}
|
|
||||||
|
|
||||||
// BackupDatabase creates a database backup dump with the specified file and path name.
|
|
||||||
func BackupDatabase(backupPath, fileName string, toStdOut, force bool, retain int) (err error) {
|
|
||||||
// Ensure that only one database backup/restore operation is running at a time.
|
|
||||||
backupDatabaseMutex.Lock()
|
|
||||||
defer backupDatabaseMutex.Unlock()
|
|
||||||
|
|
||||||
// Backup action shown in logs.
|
|
||||||
backupAction := "creating"
|
|
||||||
|
|
||||||
// Get configuration.
|
|
||||||
c := Config()
|
|
||||||
|
|
||||||
if !toStdOut {
|
|
||||||
if backupPath == "" {
|
|
||||||
backupPath = c.BackupDatabasePath()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the backup path if it does not already exist.
|
|
||||||
if err = fs.MkdirAll(backupPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the backup path is writable.
|
|
||||||
if !fs.PathWritable(backupPath) {
|
|
||||||
return fmt.Errorf("backup path is not writable")
|
|
||||||
}
|
|
||||||
|
|
||||||
if fileName == "" {
|
|
||||||
backupFile := time.Now().UTC().Format("2006-01-02") + ".sql"
|
|
||||||
fileName = filepath.Join(backupPath, backupFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Debugf("backup: database backups will be stored in %s", clean.Log(backupPath))
|
|
||||||
|
|
||||||
if _, err = os.Stat(fileName); err == nil && !force {
|
|
||||||
return fmt.Errorf("%s already exists", clean.Log(filepath.Base(fileName)))
|
|
||||||
} else if err == nil {
|
|
||||||
backupAction = "replacing"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create backup path if not exists.
|
|
||||||
if dir := filepath.Dir(fileName); dir != "." {
|
|
||||||
if err = fs.MkdirAll(dir); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cmd *exec.Cmd
|
|
||||||
|
|
||||||
switch c.DatabaseDriver() {
|
|
||||||
case config.MySQL, config.MariaDB:
|
|
||||||
cmd = exec.Command(
|
|
||||||
c.MariadbDumpBin(),
|
|
||||||
"--protocol", "tcp",
|
|
||||||
"-h", c.DatabaseHost(),
|
|
||||||
"-P", c.DatabasePortString(),
|
|
||||||
"-u", c.DatabaseUser(),
|
|
||||||
"-p"+c.DatabasePassword(),
|
|
||||||
c.DatabaseName(),
|
|
||||||
)
|
|
||||||
case config.SQLite3:
|
|
||||||
if !fs.FileExistsNotEmpty(c.DatabaseFile()) {
|
|
||||||
return fmt.Errorf("sqlite database file %s not found", clean.LogQuote(c.DatabaseFile()))
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd = exec.Command(
|
|
||||||
c.SqliteBin(),
|
|
||||||
c.DatabaseFile(),
|
|
||||||
".dump",
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported database type: %s", c.DatabaseDriver())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write to stdout or file.
|
|
||||||
var f *os.File
|
|
||||||
if toStdOut {
|
|
||||||
log.Infof("backup: sending database backup to stdout")
|
|
||||||
f = os.Stdout
|
|
||||||
} else if f, err = os.OpenFile(fileName, os.O_TRUNC|os.O_RDWR|os.O_CREATE, fs.ModeBackup); err != nil {
|
|
||||||
return fmt.Errorf("failed to create %s (%s)", clean.Log(fileName), err)
|
|
||||||
} else {
|
|
||||||
log.Infof("backup: %s database backup file %s", backupAction, clean.Log(filepath.Base(fileName)))
|
|
||||||
defer f.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
var stderr bytes.Buffer
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
cmd.Stdout = f
|
|
||||||
|
|
||||||
// Log exact command for debugging in trace mode.
|
|
||||||
log.Trace(cmd.String())
|
|
||||||
|
|
||||||
// Run backup command.
|
|
||||||
if cmdErr := cmd.Run(); cmdErr != nil {
|
|
||||||
if errStr := strings.TrimSpace(stderr.String()); errStr != "" {
|
|
||||||
return errors.New(errStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cmdErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete old backups if the number of backup files to keep has been specified.
|
|
||||||
if !toStdOut && backupPath != "" && retain > 0 {
|
|
||||||
files, globErr := filepath.Glob(filepath.Join(regexp.QuoteMeta(backupPath), SqlBackupFileNamePattern))
|
|
||||||
|
|
||||||
if globErr != nil {
|
|
||||||
return globErr
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) == 0 {
|
|
||||||
return fmt.Errorf("found no database backup files in %s", backupPath)
|
|
||||||
} else if len(files) <= retain {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Strings(files)
|
|
||||||
|
|
||||||
log.Infof("backup: retaining %s", english.Plural(retain, "database backup", "database backups"))
|
|
||||||
|
|
||||||
for i := 0; i < len(files)-retain; i++ {
|
|
||||||
if err = os.Remove(files[i]); err != nil {
|
|
||||||
return err
|
|
||||||
} else {
|
|
||||||
log.Infof("backup: removed database backup file %s", clean.Log(filepath.Base(files[i])))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
// Albums returns a slice of albums.
|
// Albums returns a slice of albums.
|
||||||
func Albums(offset, limit int) (results entity.Albums, err error) {
|
func Albums(offset, limit int) (results entity.Albums, err error) {
|
||||||
err = UnscopedDb().Table("albums").Select("*").Offset(offset).Limit(limit).Find(&results).Error
|
err = UnscopedDb().Table("albums").Select("*").Order("album_type, album_uid").Offset(offset).Limit(limit).Find(&results).Error
|
||||||
return results, err
|
return results, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
|
|
||||||
"github.com/dustin/go-humanize/english"
|
"github.com/dustin/go-humanize/english"
|
||||||
|
|
||||||
|
"github.com/photoprism/photoprism/internal/backup"
|
||||||
"github.com/photoprism/photoprism/internal/config"
|
"github.com/photoprism/photoprism/internal/config"
|
||||||
"github.com/photoprism/photoprism/internal/mutex"
|
"github.com/photoprism/photoprism/internal/mutex"
|
||||||
"github.com/photoprism/photoprism/internal/photoprism"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Backup represents a background backup worker.
|
// Backup represents a background backup worker.
|
||||||
@@ -58,7 +58,7 @@ func (w *Backup) Start(database, albums bool, force bool, retain int) (err error
|
|||||||
if database {
|
if database {
|
||||||
databasePath := w.conf.BackupDatabasePath()
|
databasePath := w.conf.BackupDatabasePath()
|
||||||
|
|
||||||
if err = photoprism.BackupDatabase(databasePath, "", false, force, retain); err != nil {
|
if err = backup.Database(databasePath, "", false, force, retain); err != nil {
|
||||||
log.Errorf("backup: %s (database)", err)
|
log.Errorf("backup: %s (database)", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ func (w *Backup) Start(database, albums bool, force bool, retain int) (err error
|
|||||||
if albums {
|
if albums {
|
||||||
albumsPath := w.conf.BackupAlbumsPath()
|
albumsPath := w.conf.BackupAlbumsPath()
|
||||||
|
|
||||||
if count, backupErr := photoprism.BackupAlbums(albumsPath, false); backupErr != nil {
|
if count, backupErr := backup.Albums(albumsPath, false); backupErr != nil {
|
||||||
log.Errorf("backup: %s (albums)", backupErr.Error())
|
log.Errorf("backup: %s (albums)", backupErr.Error())
|
||||||
} else if count > 0 {
|
} else if count > 0 {
|
||||||
log.Infof("backup: saved %s", english.Plural(count, "album backup", "album backups"))
|
log.Infof("backup: saved %s", english.Plural(count, "album backup", "album backups"))
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func TestMain(m *testing.M) {
|
|||||||
|
|
||||||
c := config.TestConfig()
|
c := config.TestConfig()
|
||||||
defer c.CloseDb()
|
defer c.CloseDb()
|
||||||
|
|
||||||
get.SetConfig(c)
|
get.SetConfig(c)
|
||||||
photoprism.SetConfig(c)
|
photoprism.SetConfig(c)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user