From 24ab233376b84719bfb5997c3c466f85ff774e5b Mon Sep 17 00:00:00 2001 From: Gani Georgiev Date: Wed, 17 May 2023 21:14:12 +0300 Subject: [PATCH] added experimental update command --- examples/base/main.go | 4 + plugins/ghupdate/ghupdate.go | 328 +++++++++++++++++++++++++++++++ plugins/ghupdate/release.go | 35 ++++ plugins/ghupdate/release_test.go | 23 +++ 4 files changed, 390 insertions(+) create mode 100644 plugins/ghupdate/ghupdate.go create mode 100644 plugins/ghupdate/release.go create mode 100644 plugins/ghupdate/release_test.go diff --git a/examples/base/main.go b/examples/base/main.go index 04b8f1dc..908d31d0 100644 --- a/examples/base/main.go +++ b/examples/base/main.go @@ -10,6 +10,7 @@ import ( "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/plugins/ghupdate" "github.com/pocketbase/pocketbase/plugins/jsvm" "github.com/pocketbase/pocketbase/plugins/migratecmd" ) @@ -79,6 +80,9 @@ func main() { Dir: migrationsDir, }) + // GitHub selfupdate + ghupdate.MustRegister(app, app.RootCmd, nil) + app.OnAfterBootstrap().Add(func(e *core.BootstrapEvent) error { app.Dao().ModelQueryTimeout = time.Duration(queryTimeout) * time.Second return nil diff --git a/plugins/ghupdate/ghupdate.go b/plugins/ghupdate/ghupdate.go new file mode 100644 index 00000000..9fff819f --- /dev/null +++ b/plugins/ghupdate/ghupdate.go @@ -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 "" +} diff --git a/plugins/ghupdate/release.go b/plugins/ghupdate/release.go new file mode 100644 index 00000000..fd39179f --- /dev/null +++ b/plugins/ghupdate/release.go @@ -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) +} diff --git a/plugins/ghupdate/release_test.go b/plugins/ghupdate/release_test.go new file mode 100644 index 00000000..8fa6fa1f --- /dev/null +++ b/plugins/ghupdate/release_test.go @@ -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) + } +}