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 (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/get"
|
||||
"github.com/photoprism/photoprism/internal/query"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"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.
|
||||
func RestoreAlbums(backupPath string, force bool) (count int, result error) {
|
||||
// Make sure only one backup/restore operation is running at a time.
|
||||
backupAlbumsMutex.Lock()
|
||||
defer backupAlbumsMutex.Unlock()
|
||||
|
||||
c := Config()
|
||||
c := get.Config()
|
||||
|
||||
if !c.BackupAlbums() && !force {
|
||||
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 (
|
||||
"bytes"
|
||||
@@ -11,14 +11,149 @@ import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dustin/go-humanize/english"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/get"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"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.
|
||||
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()
|
||||
defer backupDatabaseMutex.Unlock()
|
||||
|
||||
c := Config()
|
||||
c := get.Config()
|
||||
|
||||
// If empty, use default backup file name.
|
||||
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/urfave/cli"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/backup"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/photoprism"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
@@ -101,7 +101,7 @@ func backupAction(ctx *cli.Context) error {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func backupAction(ctx *cli.Context) error {
|
||||
albumsPath = conf.BackupAlbumsPath()
|
||||
}
|
||||
|
||||
if count, backupErr := photoprism.BackupAlbums(albumsPath, true); backupErr != nil {
|
||||
if count, backupErr := backup.Albums(albumsPath, true); backupErr != nil {
|
||||
return backupErr
|
||||
} else {
|
||||
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/urfave/cli"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/backup"
|
||||
"github.com/photoprism/photoprism/internal/get"
|
||||
"github.com/photoprism/photoprism/internal/photoprism"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
@@ -81,7 +81,7 @@ func restoreAction(ctx *cli.Context) error {
|
||||
// Restore database from backup dump?
|
||||
if !restoreDatabase {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ func restoreAction(ctx *cli.Context) error {
|
||||
} else {
|
||||
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
|
||||
} else {
|
||||
log.Infof("restore: restored %s from YAML files", english.Plural(count, "album", "albums"))
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"github.com/urfave/cli"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/auto"
|
||||
"github.com/photoprism/photoprism/internal/backup"
|
||||
"github.com/photoprism/photoprism/internal/mutex"
|
||||
"github.com/photoprism/photoprism/internal/photoprism"
|
||||
"github.com/photoprism/photoprism/internal/server"
|
||||
"github.com/photoprism/photoprism/internal/session"
|
||||
"github.com/photoprism/photoprism/internal/workers"
|
||||
@@ -126,7 +126,7 @@ func startAction(ctx *cli.Context) error {
|
||||
go server.Start(cctx, conf)
|
||||
|
||||
// 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)
|
||||
} else if count > 0 {
|
||||
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/klauspost/cpuid/v2"
|
||||
"github.com/pbnjay/memory"
|
||||
"github.com/robfig/cron/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
"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.
|
||||
func (c *Config) IndexSchedule() string {
|
||||
if 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
|
||||
return Schedule(c.options.IndexSchedule)
|
||||
}
|
||||
|
||||
// WakeupInterval returns the duration between background worker runs
|
||||
|
||||
@@ -3,14 +3,12 @@ package config
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultBackupSchedule = "0 12 * * *"
|
||||
DefaultBackupSchedule = "daily"
|
||||
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.
|
||||
func (c *Config) BackupSchedule() string {
|
||||
if 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
|
||||
return Schedule(c.options.BackupSchedule)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
c := NewConfig(CliTestContext())
|
||||
assert.Equal(t, DefaultBackupSchedule, c.BackupSchedule())
|
||||
assert.Contains(t, c.BackupSchedule(), " * * *")
|
||||
}
|
||||
|
||||
func TestConfig_BackupRetain(t *testing.T) {
|
||||
|
||||
@@ -193,7 +193,7 @@ var Flags = CliFlags{
|
||||
}}, {
|
||||
Flag: cli.StringFlag{
|
||||
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,
|
||||
EnvVar: EnvVar("BACKUP_SCHEDULE"),
|
||||
}}, {
|
||||
@@ -221,7 +221,7 @@ var Flags = CliFlags{
|
||||
}}, {
|
||||
Flag: cli.StringFlag{
|
||||
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,
|
||||
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.
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
|
||||
"github.com/dustin/go-humanize/english"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/backup"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/mutex"
|
||||
"github.com/photoprism/photoprism/internal/photoprism"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func (w *Backup) Start(database, albums bool, force bool, retain int) (err error
|
||||
if albums {
|
||||
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())
|
||||
} else if count > 0 {
|
||||
log.Infof("backup: saved %s", english.Plural(count, "album backup", "album backups"))
|
||||
|
||||
@@ -19,6 +19,7 @@ func TestMain(m *testing.M) {
|
||||
|
||||
c := config.TestConfig()
|
||||
defer c.CloseDb()
|
||||
|
||||
get.SetConfig(c)
|
||||
photoprism.SetConfig(c)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user