added experimental update command
This commit is contained in:
parent
a42ab6a205
commit
24ab233376
|
@ -10,6 +10,7 @@ import (
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/plugins/ghupdate"
|
||||||
"github.com/pocketbase/pocketbase/plugins/jsvm"
|
"github.com/pocketbase/pocketbase/plugins/jsvm"
|
||||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||||
)
|
)
|
||||||
|
@ -79,6 +80,9 @@ func main() {
|
||||||
Dir: migrationsDir,
|
Dir: migrationsDir,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// GitHub selfupdate
|
||||||
|
ghupdate.MustRegister(app, app.RootCmd, nil)
|
||||||
|
|
||||||
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
|
app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error {
|
||||||
app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
|
app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -0,0 +1,328 @@
|
||||||
|
// Package ghupdate implements a new command to selfupdate the current
|
||||||
|
// PocketBase executable with the latest GitHub release.
|
||||||
|
package ghupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/archive"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HttpClient is a base HTTP client interface (usually used for test purposes).
|
||||||
|
type HttpClient interface {
|
||||||
|
Do(req *http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options defines optional struct to customize the default plugin behavior.
|
||||||
|
//
|
||||||
|
// NB! This plugin is considered experimental and its options may change in the future.
|
||||||
|
type Options struct {
|
||||||
|
// Owner specifies the account owner of the repository (default to "pocketbase").
|
||||||
|
Owner string
|
||||||
|
|
||||||
|
// Repo specifies the name of the repository (default to "pocketbase").
|
||||||
|
Repo string
|
||||||
|
|
||||||
|
// ArchiveExecutable specifies the name of the executable file in the release archive (default to "pocketbase").
|
||||||
|
ArchiveExecutable string
|
||||||
|
|
||||||
|
// Optional context to use when fetching and downloading the latest release.
|
||||||
|
Context context.Context
|
||||||
|
|
||||||
|
// The HTTP client to use when fetching and downloading the latest release.
|
||||||
|
// Defaults to `http.DefaultClient`.
|
||||||
|
HttpClient HttpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// MustRegister registers the ghupdate plugin to the provided app instance
|
||||||
|
// and panic if it fails.
|
||||||
|
func MustRegister(app core.App, rootCmd *cobra.Command, options *Options) {
|
||||||
|
if err := Register(app, rootCmd, options); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register registers the ghupdate plugin to the provided app instance.
|
||||||
|
func Register(app core.App, rootCmd *cobra.Command, options *Options) error {
|
||||||
|
p := &plugin{
|
||||||
|
app: app,
|
||||||
|
currentVersion: rootCmd.Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
if options != nil {
|
||||||
|
p.options = options
|
||||||
|
} else {
|
||||||
|
p.options = &Options{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.options.Owner == "" {
|
||||||
|
p.options.Owner = "pocketbase"
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.options.Repo == "" {
|
||||||
|
p.options.Repo = "pocketbase"
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.options.ArchiveExecutable == "" {
|
||||||
|
p.options.ArchiveExecutable = "pocketbase"
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.options.HttpClient == nil {
|
||||||
|
p.options.HttpClient = http.DefaultClient
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.options.Context == nil {
|
||||||
|
p.options.Context = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.AddCommand(p.updateCmd())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type plugin struct {
|
||||||
|
app core.App
|
||||||
|
currentVersion string
|
||||||
|
options *Options
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *plugin) updateCmd() *cobra.Command {
|
||||||
|
var withBackup bool
|
||||||
|
|
||||||
|
command := &cobra.Command{
|
||||||
|
Use: "update",
|
||||||
|
Short: "Automatically updates the current PocketBase executable with the latest available version",
|
||||||
|
// @todo remove after logs generalization
|
||||||
|
// prevents printing the error log twice
|
||||||
|
SilenceErrors: true,
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(command *cobra.Command, args []string) error {
|
||||||
|
return p.update(withBackup)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
command.PersistentFlags().BoolVar(
|
||||||
|
&withBackup,
|
||||||
|
"backup",
|
||||||
|
true,
|
||||||
|
"Creates a pb_data backup at the end of the update process",
|
||||||
|
)
|
||||||
|
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *plugin) update(withBackup bool) error {
|
||||||
|
color.Yellow("Fetching release information...")
|
||||||
|
|
||||||
|
latest, err := fetchLatestRelease(
|
||||||
|
p.options.Context,
|
||||||
|
p.options.HttpClient,
|
||||||
|
p.options.Owner,
|
||||||
|
p.options.Repo,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.currentVersion >= latest.Tag {
|
||||||
|
color.Green("You already have the latest PocketBase %s.", p.currentVersion)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH)
|
||||||
|
if suffix == "" {
|
||||||
|
return errors.New("unsupported platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
asset, err := latest.findAssetBySuffix(suffix)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDir := path.Join(p.app.DataDir(), core.LocalTempDirName)
|
||||||
|
defer os.RemoveAll(releaseDir)
|
||||||
|
|
||||||
|
color.Yellow("Downloading %s...", asset.Name)
|
||||||
|
|
||||||
|
// download the release asset
|
||||||
|
assetZip := path.Join(releaseDir, asset.Name)
|
||||||
|
if err := downloadFile(p.options.Context, p.options.HttpClient, asset.DownloadUrl, assetZip); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
color.Yellow("Extracting %s...", asset.Name)
|
||||||
|
|
||||||
|
extractDir := path.Join(releaseDir, "extracted_"+asset.Name)
|
||||||
|
defer os.RemoveAll(extractDir)
|
||||||
|
|
||||||
|
if err := archive.Extract(assetZip, extractDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
color.Yellow("Replacing the executable...")
|
||||||
|
|
||||||
|
oldExec, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
renamedOldExec := oldExec + ".old"
|
||||||
|
defer os.Remove(renamedOldExec)
|
||||||
|
|
||||||
|
newExec := path.Join(extractDir, p.options.ArchiveExecutable)
|
||||||
|
|
||||||
|
// rename the current executable
|
||||||
|
if err := os.Rename(oldExec, renamedOldExec); err != nil {
|
||||||
|
return fmt.Errorf("Failed to rename the current executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tryToRevertExecChanges := func() {
|
||||||
|
if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil && p.app.IsDebug() {
|
||||||
|
log.Println(revertErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace with the extracted binary
|
||||||
|
if err := os.Rename(newExec, oldExec); err != nil {
|
||||||
|
tryToRevertExecChanges()
|
||||||
|
return fmt.Errorf("Failed replacing the executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if withBackup {
|
||||||
|
color.Yellow("Creating pb_data backup...")
|
||||||
|
|
||||||
|
backupName := fmt.Sprintf("@update_%s.zip", latest.Tag)
|
||||||
|
if err := p.app.CreateBackup(p.options.Context, backupName); err != nil {
|
||||||
|
tryToRevertExecChanges()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
color.HiBlack("---")
|
||||||
|
color.Green("Update completed sucessfully! You can start the executable as usual.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchLatestRelease(
|
||||||
|
ctx context.Context,
|
||||||
|
client HttpClient,
|
||||||
|
owner string,
|
||||||
|
repo string,
|
||||||
|
) (*release, error) {
|
||||||
|
url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
rawBody, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// http.Client doesn't treat non 2xx responses as error
|
||||||
|
if res.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"(%d) failed to fetch latest releases:\n%s",
|
||||||
|
res.StatusCode,
|
||||||
|
string(rawBody),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &release{}
|
||||||
|
if err := json.Unmarshal(rawBody, result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFile(
|
||||||
|
ctx context.Context,
|
||||||
|
client HttpClient,
|
||||||
|
url string,
|
||||||
|
destPath string,
|
||||||
|
) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
// http.Client doesn't treat non 2xx responses as error
|
||||||
|
if res.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("(%d) failed to send download file request", res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure that the dest parent dir(s) exist
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dest, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dest.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(dest, res.Body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveSuffix(goos, goarch string) string {
|
||||||
|
switch goos {
|
||||||
|
case "linux":
|
||||||
|
switch goarch {
|
||||||
|
case "amd64":
|
||||||
|
return "_linux_amd64.zip"
|
||||||
|
case "arm64":
|
||||||
|
return "_linux_arm64.zip"
|
||||||
|
case "arm":
|
||||||
|
return "_linux_armv7.zip"
|
||||||
|
}
|
||||||
|
case "darwin":
|
||||||
|
switch goarch {
|
||||||
|
case "amd64":
|
||||||
|
return "_darwin_amd64.zip"
|
||||||
|
case "arm64":
|
||||||
|
return "_darwin_arm64.zip"
|
||||||
|
}
|
||||||
|
case "windows":
|
||||||
|
switch goarch {
|
||||||
|
case "amd64":
|
||||||
|
return "_windows_amd64.zip"
|
||||||
|
case "arm64":
|
||||||
|
return "_windows_arm64.zip"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
package ghupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type releaseAsset struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
DownloadUrl string `json:"browser_download_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type release struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Tag string `json:"tag_name"`
|
||||||
|
Published string `json:"published_at"`
|
||||||
|
Url string `json:"html_url"`
|
||||||
|
Assets []*releaseAsset `json:"assets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAssetBySuffix returns the first available asset containing the specified suffix.
|
||||||
|
func (r *release) findAssetBySuffix(suffix string) (*releaseAsset, error) {
|
||||||
|
if suffix != "" {
|
||||||
|
for _, asset := range r.Assets {
|
||||||
|
if strings.HasSuffix(asset.Name, suffix) {
|
||||||
|
return asset, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("missing asset containing " + suffix)
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
package ghupdate
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestReleaseFindAssetBySuffix(t *testing.T) {
|
||||||
|
r := release{
|
||||||
|
Assets: []*releaseAsset{
|
||||||
|
{Name: "test1.zip", Id: 1},
|
||||||
|
{Name: "test2.zip", Id: 2},
|
||||||
|
{Name: "test22.zip", Id: 22},
|
||||||
|
{Name: "test3.zip", Id: 3},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
asset, err := r.findAssetBySuffix("2.zip")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected nil, got err: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if asset.Id != 2 {
|
||||||
|
t.Fatalf("Expected asset with id %d, got %v", 2, asset)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue