(untested!) added temp backup api scaffoldings before introducing autobackups and rotations

This commit is contained in:
Gani Georgiev
2023-05-08 21:52:40 +03:00
parent 60eee96034
commit d3314e1e23
17 changed files with 914 additions and 40 deletions
+40 -1
View File
@@ -4,6 +4,8 @@
package core
import (
"context"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models/settings"
@@ -69,12 +71,20 @@ type App interface {
// NewMailClient creates and returns a configured app mail client.
NewMailClient() mailer.Mailer
// NewFilesystem creates and returns a configured filesystem.System instance.
// NewFilesystem creates and returns a configured filesystem.System instance
// for managing regular app files (eg. collection uploads).
//
// NB! Make sure to call `Close()` on the returned result
// after you are done working with it.
NewFilesystem() (*filesystem.System, error)
// NewBackupsFilesystem creates and returns a configured filesystem.System instance
// for managing app backups.
//
// NB! Make sure to call `Close()` on the returned result
// after you are done working with it.
NewBackupsFilesystem() (*filesystem.System, error)
// RefreshSettings reinitializes and reloads the stored application settings.
RefreshSettings() error
@@ -92,6 +102,31 @@ type App interface {
// (eg. closing db connections).
ResetBootstrapState() error
// CreateBackup creates a new backup of the current app pb_data directory.
//
// Backups can be stored on S3 if it is configured in app.Settings().Backups.
//
// Please refer to the godoc of the specific core.App implementation
// for details on the backup procedures.
CreateBackup(ctx context.Context, name string) error
// RestoreBackup restores the backup with the specified name and restarts
// the current running application process.
//
// The safely perform the restore it is recommended to have free disk space
// for at least 2x the size of the restored pb_data backup.
//
// Please refer to the godoc of the specific core.App implementation
// for details on the restore procedures.
//
// NB! This feature is experimental and currently is expected to work only on UNIX based systems.
RestoreBackup(ctx context.Context, name string) error
// Restart restarts the current running application process.
//
// Currently it is relying on execve so it is supported only on UNIX based systems.
Restart() error
// ---------------------------------------------------------------
// App event hooks
// ---------------------------------------------------------------
@@ -118,6 +153,10 @@ type App interface {
// It could be used to log the final API error in external services.
OnAfterApiError() *hook.Hook[*ApiErrorEvent]
// OnTerminate hook is triggered when the app is in the process
// of being terminated (eg. on SIGTERM signal).
OnTerminate() *hook.Hook[*TerminateEvent]
// ---------------------------------------------------------------
// Dao event hooks
// ---------------------------------------------------------------
+69 -1
View File
@@ -7,6 +7,8 @@ import (
"log"
"os"
"path/filepath"
"runtime"
"syscall"
"time"
"github.com/fatih/color"
@@ -27,6 +29,10 @@ const (
DefaultDataMaxIdleConns int = 20
DefaultLogsMaxOpenConns int = 10
DefaultLogsMaxIdleConns int = 2
LocalStorageDirName string = "storage"
LocalBackupsDirName string = "backups"
LocalTempDirName string = ".pb_temp_to_delete" // temp pb_data sub directory that will be deleted on each app.Bootstrap()
)
var _ App = (*BaseApp)(nil)
@@ -55,6 +61,7 @@ type BaseApp struct {
onBeforeServe *hook.Hook[*ServeEvent]
onBeforeApiError *hook.Hook[*ApiErrorEvent]
onAfterApiError *hook.Hook[*ApiErrorEvent]
onTerminate *hook.Hook[*TerminateEvent]
// dao event hooks
onModelBeforeCreate *hook.Hook[*ModelEvent]
@@ -192,6 +199,7 @@ func NewBaseApp(config *BaseAppConfig) *BaseApp {
onBeforeServe: &hook.Hook[*ServeEvent]{},
onBeforeApiError: &hook.Hook[*ApiErrorEvent]{},
onAfterApiError: &hook.Hook[*ApiErrorEvent]{},
onTerminate: &hook.Hook[*TerminateEvent]{},
// dao event hooks
onModelBeforeCreate: &hook.Hook[*ModelEvent]{},
@@ -338,6 +346,9 @@ func (app *BaseApp) Bootstrap() error {
// we don't check for an error because the db migrations may have not been executed yet
app.RefreshSettings()
// cleanup the pb_data temp directory (if any)
os.RemoveAll(filepath.Join(app.DataDir(), LocalTempDirName))
if err := app.OnAfterBootstrap().Trigger(event); err != nil && app.IsDebug() {
log.Println(err)
}
@@ -471,6 +482,7 @@ func (app *BaseApp) NewMailClient() mailer.Mailer {
}
// NewFilesystem creates a new local or S3 filesystem instance
// for managing regular app files (eg. collection uploads)
// based on the current app settings.
//
// NB! Make sure to call `Close()` on the returned result
@@ -488,7 +500,54 @@ func (app *BaseApp) NewFilesystem() (*filesystem.System, error) {
}
// fallback to local filesystem
return filesystem.NewLocal(filepath.Join(app.DataDir(), "storage"))
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalStorageDirName))
}
// NewFilesystem creates a new local or S3 filesystem instance
// for managing app backups based on the current app settings.
//
// NB! Make sure to call `Close()` on the returned result
// after you are done working with it.
func (app *BaseApp) NewBackupsFilesystem() (*filesystem.System, error) {
if app.settings != nil && app.settings.Backups.S3.Enabled {
return filesystem.NewS3(
app.settings.Backups.S3.Bucket,
app.settings.Backups.S3.Region,
app.settings.Backups.S3.Endpoint,
app.settings.Backups.S3.AccessKey,
app.settings.Backups.S3.Secret,
app.settings.Backups.S3.ForcePathStyle,
)
}
// fallback to local filesystem
return filesystem.NewLocal(filepath.Join(app.DataDir(), LocalBackupsDirName))
}
// Restart restarts (aka. replaces) the current running application process.
//
// NB! It relies on execve which is supported only on UNIX based systems.
func (app *BaseApp) Restart() error {
if runtime.GOOS == "windows" {
return errors.New("restart is not supported on windows")
}
execPath, err := os.Executable()
if err != nil {
return err
}
// optimistically reset the app bootstrap state
app.ResetBootstrapState()
if err := syscall.Exec(execPath, os.Args, os.Environ()); err != nil {
// restart the app bootstrap state
app.Bootstrap()
return err
}
return nil
}
// RefreshSettings reinitializes and reloads the stored application settings.
@@ -541,6 +600,10 @@ func (app *BaseApp) OnAfterApiError() *hook.Hook[*ApiErrorEvent] {
return app.onAfterApiError
}
func (app *BaseApp) OnTerminate() *hook.Hook[*TerminateEvent] {
return app.onTerminate
}
// -------------------------------------------------------------------
// Dao event hooks
// -------------------------------------------------------------------
@@ -1084,4 +1147,9 @@ func (app *BaseApp) registerDefaultHooks() {
return nil
})
app.OnTerminate().Add(func(e *TerminateEvent) error {
app.ResetBootstrapState()
return nil
})
}
+248
View File
@@ -0,0 +1,248 @@
package core
import (
"context"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"runtime"
"time"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/tools/archive"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
const CacheActiveBackupsKey string = "@activeBackup"
// CreateBackup creates a new backup of the current app pb_data directory.
//
// If name is empty, it will be autogenerated.
// If backup with the same name exists, the new backup file will replace it.
//
// The backup is executed within a transaction, meaning that new writes
// will be temporary "blocked" until the backup file is generated.
//
// By default backups are stored in pb_data/backups
// (the backups directory itself is excluded from the generated backup).
//
// When using S3 storage for the uploaded collection files, you have to
// take care manually to backup those since they are not part of the pb_data.
//
// 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")
}
// auto generate backup name
if name == "" {
name = fmt.Sprintf(
"pb_backup_%s.zip",
time.Now().UTC().Format("20060102150405"),
)
}
app.Cache().Set(CacheActiveBackupsKey, name)
defer app.Cache().Remove(CacheActiveBackupsKey)
// Archive pb_data in a temp directory, exluding the "backups" dir itself (if exist).
//
// Run in transaction to temporary block other writes (transactions uses the NonconcurrentDB connection).
// ---
tempPath := filepath.Join(os.TempDir(), "pb_backup_"+security.PseudorandomString(4))
createErr := app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
if err := archive.Create(app.DataDir(), tempPath, LocalBackupsDirName); err != nil {
return err
}
return nil
})
if createErr != nil {
return createErr
}
defer os.Remove(tempPath)
// Persist the backup in the backups filesystem.
// ---
fsys, err := app.NewBackupsFilesystem()
if err != nil {
return err
}
defer fsys.Close()
fsys.SetContext(ctx)
file, err := filesystem.NewFileFromPath(tempPath)
if err != nil {
return err
}
file.OriginalName = name
file.Name = file.OriginalName
if err := fsys.UploadFile(file, file.Name); err != nil {
return err
}
return nil
}
// RestoreBackup restores the backup with the specified name and restarts
// the current running application process.
//
// NB! This feature is experimental and currently is expected to work only on UNIX based systems.
//
// To safely perform the restore it is recommended to have free disk space
// for at least 2x the size of the restored pb_data backup.
//
// The performed steps are:
//
// 1. Download the backup with the specified name in a temp location
// (this is in case of S3; otherwise it creates a temp copy of the zip)
//
// 2. Extract the backup in a temp directory next to the app "pb_data"
// (eg. "pb_data/../pb_data_to_restore").
//
// 3. Move the current app "pb_data" under a special sub temp dir that
// will be deleted on the next app start up (eg. "pb_data_to_restore/.pb_temp_to_delete/").
// This is because on some operating systems it may not be allowed
// to delete the currently open "pb_data" files.
//
// 4. Rename the extracted dir from step 1 as the new "pb_data".
//
// 5. Move from the old "pb_data" any local backups that may have been
// created previously to the new "pb_data/backups".
//
// 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.
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")
}
app.Cache().Set(CacheActiveBackupsKey, name)
defer app.Cache().Remove(CacheActiveBackupsKey)
fsys, err := app.NewBackupsFilesystem()
if err != nil {
return err
}
defer fsys.Close()
fsys.SetContext(ctx)
// fetch the backup file in a temp location
br, err := fsys.GetFile(name)
if err != nil {
return err
}
defer br.Close()
tempZip, err := os.CreateTemp(os.TempDir(), "pb_restore")
if err != nil {
return err
}
defer os.Remove(tempZip.Name())
if _, err := io.Copy(tempZip, br); err != nil {
return err
}
parentDataDir := filepath.Dir(app.DataDir())
extractedDataDir := filepath.Join(parentDataDir, "pb_restore_"+security.PseudorandomString(4))
defer os.RemoveAll(extractedDataDir)
if err := archive.Extract(tempZip.Name(), extractedDataDir); err != nil {
return err
}
// ensure that a database file exists
extractedDB := filepath.Join(extractedDataDir, "data.db")
if _, err := os.Stat(extractedDB); err != nil {
return fmt.Errorf("data.db file is missing or invalid: %w", err)
}
// remove the extracted zip file since we no longer need it
// (this is in case the app restarts and the defer calls are not called)
if err := os.Remove(tempZip.Name()); err != nil && app.IsDebug() {
log.Println(err)
}
// make sure that a special temp directory exists in the extracted one
if err := os.MkdirAll(filepath.Join(extractedDataDir, LocalTempDirName), os.ModePerm); err != nil {
return fmt.Errorf("failed to create a temp dir: %w", err)
}
// move the current pb_data to a special temp location that will
// hold the old data between dirs replace
// (the temp dir will be automatically removed on the next app start)
oldTempDataDir := filepath.Join(extractedDataDir, LocalTempDirName, "old_pb_data")
if err := os.Rename(app.DataDir(), oldTempDataDir); err != nil {
return fmt.Errorf("failed to move the current pb_data to a temp location: %w", err)
}
// "restore", aka. set the extracted backup as the new pb_data directory
if err := os.Rename(extractedDataDir, app.DataDir()); err != nil {
return fmt.Errorf("failed to set the extracted backup as pb_data dir: %w", err)
}
// update the old temp data dir path after the restore
oldTempDataDir = filepath.Join(app.DataDir(), LocalTempDirName, "old_pb_data")
oldLocalBackupsDir := filepath.Join(oldTempDataDir, LocalBackupsDirName)
newLocalBackupsDir := filepath.Join(app.DataDir(), LocalBackupsDirName)
revertDataDirChanges := func(revertLocalBackupsDir bool) error {
if revertLocalBackupsDir {
if _, err := os.Stat(newLocalBackupsDir); err == nil {
if err := os.Rename(newLocalBackupsDir, oldLocalBackupsDir); err != nil {
return fmt.Errorf("failed to revert the backups dir change: %w", err)
}
}
}
if err := os.Rename(app.DataDir(), extractedDataDir); err != nil {
return fmt.Errorf("failed to revert the extracted dir change: %w", err)
}
if err := os.Rename(oldTempDataDir, app.DataDir()); err != nil {
return fmt.Errorf("failed to revert old pb_data dir change: %w", err)
}
return nil
}
// 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() {
log.Println(err)
}
return fmt.Errorf("failed to move the local pb_data/backups dir: %w", err)
}
}
// restart the app
if err := app.Restart(); err != nil {
if err := revertDataDirChanges(false); err != nil {
panic(err)
}
return fmt.Errorf("failed to restart the app process: %w", err)
}
return nil
}
+30
View File
@@ -236,3 +236,33 @@ func TestBaseAppNewFilesystem(t *testing.T) {
t.Fatalf("Expected nil s3 filesystem, got %v", s3)
}
}
func TestBaseAppNewBackupsFilesystem(t *testing.T) {
const testDataDir = "./pb_base_app_test_data_dir/"
defer os.RemoveAll(testDataDir)
app := NewBaseApp(&BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
IsDebug: false,
})
// local
local, localErr := app.NewBackupsFilesystem()
if localErr != nil {
t.Fatal(localErr)
}
if local == nil {
t.Fatal("Expected local backups filesystem instance, got nil")
}
// misconfigured s3
app.Settings().Backups.S3.Enabled = true
s3, s3Err := app.NewBackupsFilesystem()
if s3Err == nil {
t.Fatal("Expected S3 error, got nil")
}
if s3 != nil {
t.Fatalf("Expected nil s3 backups filesystem, got %v", s3)
}
}
+4
View File
@@ -65,6 +65,10 @@ type BootstrapEvent struct {
App App
}
type TerminateEvent struct {
App App
}
type ServeEvent struct {
App App
Router *echo.Echo