added backup apis and tests
This commit is contained in:
@@ -1152,4 +1152,8 @@ func (app *BaseApp) registerDefaultHooks() {
|
||||
app.ResetBootstrapState()
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := app.initAutobackupHooks(); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
+118
-15
@@ -9,16 +9,18 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/tools/archive"
|
||||
"github.com/pocketbase/pocketbase/tools/cron"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
const CacheActiveBackupsKey string = "@activeBackup"
|
||||
const CacheKeyActiveBackup string = "@activeBackup"
|
||||
|
||||
// CreateBackup creates a new backup of the current app pb_data directory.
|
||||
//
|
||||
@@ -36,9 +38,8 @@ const CacheActiveBackupsKey string = "@activeBackup"
|
||||
//
|
||||
// Backups can be stored on S3 if it is configured in app.Settings().Backups.
|
||||
func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
canBackup := cast.ToString(app.Cache().Get(CacheActiveBackupsKey)) != ""
|
||||
if canBackup {
|
||||
return errors.New("try again later - another backup/restore process has already been started")
|
||||
if app.Cache().Has(CacheKeyActiveBackup) {
|
||||
return errors.New("try again later - another backup/restore operation has already been started")
|
||||
}
|
||||
|
||||
// auto generate backup name
|
||||
@@ -49,8 +50,8 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
)
|
||||
}
|
||||
|
||||
app.Cache().Set(CacheActiveBackupsKey, name)
|
||||
defer app.Cache().Remove(CacheActiveBackupsKey)
|
||||
app.Cache().Set(CacheKeyActiveBackup, name)
|
||||
defer app.Cache().Remove(CacheKeyActiveBackup)
|
||||
|
||||
// Archive pb_data in a temp directory, exluding the "backups" dir itself (if exist).
|
||||
//
|
||||
@@ -121,19 +122,18 @@ func (app *BaseApp) CreateBackup(ctx context.Context, name string) error {
|
||||
// 6. Restart the app (on successfull app bootstap it will also remove the old pb_data).
|
||||
//
|
||||
// If a failure occure during the restore process the dir changes are reverted.
|
||||
// It for whatever reason the revert is not possible, it panics.
|
||||
// If for whatever reason the revert is not possible, it panics.
|
||||
func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return errors.New("restore is not supported on windows")
|
||||
}
|
||||
|
||||
canBackup := cast.ToString(app.Cache().Get(CacheActiveBackupsKey)) != ""
|
||||
if canBackup {
|
||||
return errors.New("try again later - another backup/restore process has already been started")
|
||||
if app.Cache().Has(CacheKeyActiveBackup) {
|
||||
return errors.New("try again later - another backup/restore operation has already been started")
|
||||
}
|
||||
|
||||
app.Cache().Set(CacheActiveBackupsKey, name)
|
||||
defer app.Cache().Remove(CacheActiveBackupsKey)
|
||||
app.Cache().Set(CacheKeyActiveBackup, name)
|
||||
defer app.Cache().Remove(CacheKeyActiveBackup)
|
||||
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil {
|
||||
@@ -227,7 +227,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
// restore the local pb_data/backups dir (if any)
|
||||
if _, err := os.Stat(oldLocalBackupsDir); err == nil {
|
||||
if err := os.Rename(oldLocalBackupsDir, newLocalBackupsDir); err != nil {
|
||||
if err := revertDataDirChanges(true); err != nil && app.IsDebug() {
|
||||
if err := revertDataDirChanges(false); err != nil && app.IsDebug() {
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
|
||||
// restart the app
|
||||
if err := app.Restart(); err != nil {
|
||||
if err := revertDataDirChanges(false); err != nil {
|
||||
if err := revertDataDirChanges(true); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -246,3 +246,106 @@ func (app *BaseApp) RestoreBackup(ctx context.Context, name string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// initAutobackupHooks registers the autobackup app serve hooks.
|
||||
// @todo add tests
|
||||
func (app *BaseApp) initAutobackupHooks() error {
|
||||
c := cron.New()
|
||||
|
||||
loadJob := func() {
|
||||
c.Stop()
|
||||
|
||||
rawSchedule := app.Settings().Backups.Cron
|
||||
if rawSchedule == "" || !app.IsBootstrapped() {
|
||||
return
|
||||
}
|
||||
|
||||
c.Add("@autobackup", rawSchedule, func() {
|
||||
autoPrefix := "@auto_pb_backup_"
|
||||
|
||||
name := fmt.Sprintf(
|
||||
"%s%s.zip",
|
||||
autoPrefix,
|
||||
time.Now().UTC().Format("20060102150405"),
|
||||
)
|
||||
|
||||
if err := app.CreateBackup(context.Background(), name); err != nil && app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
}
|
||||
|
||||
maxKeep := app.Settings().Backups.CronMaxKeep
|
||||
|
||||
if maxKeep == 0 {
|
||||
return // no explicit limit
|
||||
}
|
||||
|
||||
fsys, err := app.NewBackupsFilesystem()
|
||||
if err != nil && app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
files, err := fsys.List(autoPrefix)
|
||||
if err != nil && app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
if maxKeep >= len(files) {
|
||||
return // nothing to remove
|
||||
}
|
||||
|
||||
// sort desc
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].ModTime.After(files[j].ModTime)
|
||||
})
|
||||
|
||||
// keep only the most recent n auto backup files
|
||||
toRemove := files[maxKeep:]
|
||||
|
||||
for _, f := range toRemove {
|
||||
if err := fsys.Delete(f.Key); err != nil && app.IsDebug() {
|
||||
// @todo replace after logs generalization
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// restart the ticker
|
||||
c.Start()
|
||||
}
|
||||
|
||||
// load on app serve
|
||||
app.OnBeforeServe().Add(func(e *ServeEvent) error {
|
||||
loadJob()
|
||||
return nil
|
||||
})
|
||||
|
||||
// stop the ticker on app termination
|
||||
app.OnTerminate().Add(func(e *TerminateEvent) error {
|
||||
c.Stop()
|
||||
return nil
|
||||
})
|
||||
|
||||
// reload on app settings change
|
||||
app.OnModelAfterUpdate((&models.Param{}).TableName()).Add(func(e *ModelEvent) error {
|
||||
if !c.HasStarted() {
|
||||
return nil // no need to reload as it hasn't been started yet
|
||||
}
|
||||
|
||||
p := e.Model.(*models.Param)
|
||||
if p == nil || p.Key != models.ParamAppSettings {
|
||||
return nil
|
||||
}
|
||||
|
||||
loadJob()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package core_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/pocketbase/pocketbase/tools/archive"
|
||||
"github.com/pocketbase/pocketbase/tools/list"
|
||||
)
|
||||
|
||||
func TestCreateBackup(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// test pending error
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "")
|
||||
if err := app.CreateBackup(context.Background(), "test.zip"); err == nil {
|
||||
t.Fatal("Expected pending error, got nil")
|
||||
}
|
||||
app.Cache().Remove(core.CacheKeyActiveBackup)
|
||||
|
||||
// create with auto generated name
|
||||
if err := app.CreateBackup(context.Background(), ""); err != nil {
|
||||
t.Fatal("Failed to create a backup with autogenerated name")
|
||||
}
|
||||
|
||||
// create with custom name
|
||||
if err := app.CreateBackup(context.Background(), "custom"); err != nil {
|
||||
t.Fatal("Failed to create a backup with custom name")
|
||||
}
|
||||
|
||||
// create new with the same name (aka. replace)
|
||||
if err := app.CreateBackup(context.Background(), "custom"); err != nil {
|
||||
t.Fatal("Failed to create and replace a backup with the same name")
|
||||
}
|
||||
|
||||
backupsDir := filepath.Join(app.DataDir(), core.LocalBackupsDirName)
|
||||
|
||||
entries, err := os.ReadDir(backupsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedFiles := []string{
|
||||
`^pb_backup_\w+\.zip$`,
|
||||
`^pb_backup_\w+\.zip.attrs$`,
|
||||
"custom",
|
||||
"custom.attrs",
|
||||
}
|
||||
|
||||
if len(entries) != len(expectedFiles) {
|
||||
names := getEntryNames(entries)
|
||||
t.Fatalf("Expected %d backup files, got %d: \n%v", len(expectedFiles), len(entries), names)
|
||||
}
|
||||
|
||||
for i, entry := range entries {
|
||||
if !list.ExistInSliceWithRegex(entry.Name(), expectedFiles) {
|
||||
t.Fatalf("[%d] Missing backup file %q", i, entry.Name())
|
||||
}
|
||||
|
||||
if strings.HasSuffix(entry.Name(), ".attrs") {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(backupsDir, entry.Name())
|
||||
|
||||
if err := verifyBackupContent(app, path); err != nil {
|
||||
t.Fatalf("[%d] Failed to verify backup content: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreBackup(t *testing.T) {
|
||||
app, _ := tests.NewTestApp()
|
||||
defer app.Cleanup()
|
||||
|
||||
// create test backup
|
||||
if err := app.CreateBackup(context.Background(), "test"); err != nil {
|
||||
t.Fatal("Failed to create test backup")
|
||||
}
|
||||
|
||||
// test pending error
|
||||
app.Cache().Set(core.CacheKeyActiveBackup, "")
|
||||
if err := app.RestoreBackup(context.Background(), "test"); err == nil {
|
||||
t.Fatal("Expected pending error, got nil")
|
||||
}
|
||||
app.Cache().Remove(core.CacheKeyActiveBackup)
|
||||
|
||||
// missing backup
|
||||
if err := app.RestoreBackup(context.Background(), "missing"); err == nil {
|
||||
t.Fatal("Expected missing error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
func verifyBackupContent(app core.App, path string) error {
|
||||
dir, err := os.MkdirTemp("", "backup_test")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
if err := archive.Extract(path, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
expectedRootEntries := []string{
|
||||
"storage",
|
||||
"data.db",
|
||||
"data.db-shm",
|
||||
"data.db-wal",
|
||||
"logs.db",
|
||||
"logs.db-shm",
|
||||
"logs.db-wal",
|
||||
".gitignore",
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(entries) != len(expectedRootEntries) {
|
||||
names := getEntryNames(entries)
|
||||
return fmt.Errorf("Expected %d backup files, got %d: \n%v", len(expectedRootEntries), len(entries), names)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !list.ExistInSliceWithRegex(entry.Name(), expectedRootEntries) {
|
||||
return fmt.Errorf("Didn't expect %q entry", entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getEntryNames(entries []fs.DirEntry) []string {
|
||||
names := make([]string, len(entries))
|
||||
|
||||
for i, entry := range entries {
|
||||
names[i] = entry.Name()
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
Reference in New Issue
Block a user