Skip to content

Commit

Permalink
(untested!) added temp backup api scaffoldings before introducing aut…
Browse files Browse the repository at this point in the history
…obackups and rotations
  • Loading branch information
ganigeorgiev committed May 8, 2023
1 parent 60eee96 commit d3314e1
Show file tree
Hide file tree
Showing 17 changed files with 913 additions and 39 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@

- Updated the logs "real" user IP to check for `Fly-Client-IP` header and changed the `X-Forward-For` header to use the first non-empty leftmost-ish IP as it the closest to the "real IP".

- Added new `archive.Create()` and `archive.Extract()` helpers (_currently works only with zip_).

- Added new `Filesystem.List(prefix)` helper to retrieve a flat list with all files under the provided prefix.

- Added new `App.NewBackupsFilesystem()` helper to create a dedicated fs abstraction for managing app backups.

- (@todo docs) Added new `App.OnTerminate()` hook.


## v0.15.3

Expand Down
189 changes: 189 additions & 0 deletions apis/backup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package apis

import (
"context"
"log"
"net/http"
"path/filepath"
"time"

"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)

// bindBackupApi registers the file api endpoints and the corresponding handlers.
//
// @todo add hooks once the app hooks api restructuring is finalized
func bindBackupApi(app core.App, rg *echo.Group) {
api := backupApi{app: app}

subGroup := rg.Group("/backups", ActivityLogger(app))
subGroup.GET("", api.list, RequireAdminAuth())
subGroup.POST("", api.create, RequireAdminAuth())
subGroup.GET("/:name", api.download)
subGroup.DELETE("/:name", api.delete, RequireAdminAuth())
subGroup.POST("/:name/restore", api.restore, RequireAdminAuth())
}

type backupApi struct {
app core.App
}

type backupItem struct {
Name string `json:"name"`
Size int64 `json:"size"`
Modified types.DateTime `json:"modified"`
}

func (api *backupApi) list(c echo.Context) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

fsys, err := api.app.NewBackupsFilesystem()
if err != nil {
return NewBadRequestError("Failed to load backups filesystem", err)
}
defer fsys.Close()

fsys.SetContext(ctx)

backups, err := fsys.List("")
if err != nil {
return NewBadRequestError("Failed to retrieve backup items. Raw error: \n"+err.Error(), nil)
}

result := make([]backupItem, len(backups))

for i, obj := range backups {
modified, _ := types.ParseDateTime(obj.ModTime)

result[i] = backupItem{
Name: obj.Key,
Size: obj.Size,
Modified: modified,
}
}

return c.JSON(http.StatusOK, result)
}

func (api *backupApi) create(c echo.Context) error {
if cast.ToString(api.app.Cache().Get(core.CacheActiveBackupsKey)) != "" {
return NewBadRequestError("Try again later - another backup/restore process has already been started", nil)
}

form := forms.NewBackupCreate(api.app)
if err := c.Bind(form); err != nil {
return NewBadRequestError("An error occurred while loading the submitted data.", err)
}

return form.Submit(func(next forms.InterceptorNextFunc[string]) forms.InterceptorNextFunc[string] {
return func(name string) error {
if err := next(name); err != nil {
return NewBadRequestError("Failed to create backup", err)
}

return c.NoContent(http.StatusNoContent)
}
})
}

func (api *backupApi) download(c echo.Context) error {
fileToken := c.QueryParam("token")

_, err := api.app.Dao().FindAdminByToken(
fileToken,
api.app.Settings().AdminFileToken.Secret,
)
if err != nil {
return NewForbiddenError("Insufficient permissions to access the resource.", err)
}

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()

fsys, err := api.app.NewBackupsFilesystem()
if err != nil {
return NewBadRequestError("Failed to load backups filesystem", err)
}
defer fsys.Close()

fsys.SetContext(ctx)

name := c.PathParam("name")

br, err := fsys.GetFile(name)
if err != nil {
return NewBadRequestError("Failed to retrieve backup item. Raw error: \n"+err.Error(), nil)
}
defer br.Close()

return fsys.Serve(
c.Response(),
c.Request(),
name,
filepath.Base(name), // without the path prefix (if any)
)
}

func (api *backupApi) restore(c echo.Context) error {
if cast.ToString(api.app.Cache().Get(core.CacheActiveBackupsKey)) != "" {
return NewBadRequestError("Try again later - another backup/restore process has already been started", nil)
}

name := c.PathParam("name")

existsCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

fsys, err := api.app.NewBackupsFilesystem()
if err != nil {
return NewBadRequestError("Failed to load backups filesystem", err)
}
defer fsys.Close()

fsys.SetContext(existsCtx)

if exists, err := fsys.Exists(name); !exists {
return NewNotFoundError("Missing or invalid backup file", err)
}

go func() {
// wait max 10 minutes
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()

// give some optimistic time to write the response
time.Sleep(1 * time.Second)

if err := api.app.RestoreBackup(ctx, name); err != nil && api.app.IsDebug() {
log.Println(err)
}
}()

return c.NoContent(http.StatusNoContent)
}

func (api *backupApi) delete(c echo.Context) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

fsys, err := api.app.NewBackupsFilesystem()
if err != nil {
return NewBadRequestError("Failed to load backups filesystem", err)
}
defer fsys.Close()

fsys.SetContext(ctx)

name := c.PathParam("name")

if err := fsys.Delete(name); err != nil {
return NewBadRequestError("Invalid or already deleted backup file. Raw error: \n"+err.Error(), nil)
}

return c.NoContent(http.StatusNoContent)
}
3 changes: 2 additions & 1 deletion apis/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ func InitApi(app core.App) (*echo.Echo, error) {
bindFileApi(app, api)
bindRealtimeApi(app, api)
bindLogsApi(app, api)
bindBackupApi(app, api)
bindHealthApi(app, api)

// trigger the custom BeforeServe hook for the created api router
Expand Down Expand Up @@ -191,7 +192,7 @@ func bindStaticAdminUI(app core.App, e *echo.Echo) error {
return nil
}

const totalAdminsCacheKey = "totalAdmins"
const totalAdminsCacheKey = "@totalAdmins"

func updateTotalAdminsCache(app core.App) error {
total, err := app.Dao().TotalAdmins()
Expand Down
11 changes: 10 additions & 1 deletion apis/serve.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package apis

import (
"context"
"crypto/tls"
"log"
"net"
Expand Down Expand Up @@ -85,7 +86,7 @@ func Serve(app core.App, options *ServeOptions) error {
GetCertificate: certManager.GetCertificate,
NextProtos: []string{acme.ALPNProto},
},
ReadTimeout: 5 * time.Minute,
ReadTimeout: 10 * time.Minute,
ReadHeaderTimeout: 30 * time.Second,
// WriteTimeout: 60 * time.Second, // breaks sse!
Handler: router,
Expand Down Expand Up @@ -119,6 +120,14 @@ func Serve(app core.App, options *ServeOptions) error {
regular.Printf(" ➜ Admin UI: %s\n", color.CyanString("%s://%s/_/", schema, serverConfig.Addr))
}

// try to gracefully shutdown the server on app termination
app.OnTerminate().Add(func(e *core.TerminateEvent) error {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
serverConfig.Shutdown(ctx)
return nil
})

// start HTTPS server
if options.HttpsAddr != "" {
// if httpAddr is set, start an HTTP server to redirect the traffic to the HTTPS version
Expand Down
41 changes: 40 additions & 1 deletion core/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
package core

import (
"context"

"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models/settings"
Expand Down Expand Up @@ -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

Expand All @@ -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
// ---------------------------------------------------------------
Expand All @@ -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
// ---------------------------------------------------------------
Expand Down
Loading

0 comments on commit d3314e1

Please sign in to comment.