mirror of
https://github.com/fankes/beszel.git
synced 2025-10-19 01:39:34 +08:00
update updater (#1009)
This commit is contained in:
@@ -1,168 +1,147 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"beszel/internal/ghupdate"
|
||||||
"log"
|
"fmt"
|
||||||
"os"
|
"log"
|
||||||
"os/exec"
|
"os"
|
||||||
"strings"
|
"os/exec"
|
||||||
|
"strings"
|
||||||
"beszel"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
|
||||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// restarter knows how to restart the beszel-agent service.
|
// restarter knows how to restart the beszel-agent service.
|
||||||
type restarter interface {
|
type restarter interface {
|
||||||
Restart() error
|
Restart() error
|
||||||
}
|
}
|
||||||
|
|
||||||
type systemdRestarter struct{ cmd string }
|
type systemdRestarter struct{ cmd string }
|
||||||
|
|
||||||
func (s *systemdRestarter) Restart() error {
|
func (s *systemdRestarter) Restart() error {
|
||||||
// Only restart if the service is active
|
// Only restart if the service is active
|
||||||
if err := exec.Command(s.cmd, "is-active", "beszel-agent.service").Run(); err != nil {
|
if err := exec.Command(s.cmd, "is-active", "beszel-agent.service").Run(); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
log.Print("Restarting beszel-agent.service via systemd…")
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent.service via systemd…")
|
||||||
return exec.Command(s.cmd, "restart", "beszel-agent.service").Run()
|
return exec.Command(s.cmd, "restart", "beszel-agent.service").Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
type openRCRestarter struct{ cmd string }
|
type openRCRestarter struct{ cmd string }
|
||||||
|
|
||||||
func (o *openRCRestarter) Restart() error {
|
func (o *openRCRestarter) Restart() error {
|
||||||
if err := exec.Command(o.cmd, "status", "beszel-agent").Run(); err != nil {
|
if err := exec.Command(o.cmd, "status", "beszel-agent").Run(); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
log.Print("Restarting beszel-agent via OpenRC…")
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via OpenRC…")
|
||||||
return exec.Command(o.cmd, "restart", "beszel-agent").Run()
|
return exec.Command(o.cmd, "restart", "beszel-agent").Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
type openWRTRestarter struct{ cmd string }
|
type openWRTRestarter struct{ cmd string }
|
||||||
|
|
||||||
func (w *openWRTRestarter) Restart() error {
|
func (w *openWRTRestarter) Restart() error {
|
||||||
if err := exec.Command(w.cmd, "running", "beszel-agent").Run(); err != nil {
|
if err := exec.Command(w.cmd, "running", "beszel-agent").Run(); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
log.Print("Restarting beszel-agent via procd…")
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via procd…")
|
||||||
return exec.Command(w.cmd, "restart", "beszel-agent").Run()
|
return exec.Command(w.cmd, "restart", "beszel-agent").Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectRestarter() restarter {
|
func detectRestarter() restarter {
|
||||||
if path, err := exec.LookPath("systemctl"); err == nil {
|
if path, err := exec.LookPath("systemctl"); err == nil {
|
||||||
return &systemdRestarter{cmd: path}
|
return &systemdRestarter{cmd: path}
|
||||||
}
|
}
|
||||||
if path, err := exec.LookPath("rc-service"); err == nil {
|
if path, err := exec.LookPath("rc-service"); err == nil {
|
||||||
return &openRCRestarter{cmd: path}
|
return &openRCRestarter{cmd: path}
|
||||||
}
|
}
|
||||||
if path, err := exec.LookPath("service"); err == nil {
|
if path, err := exec.LookPath("service"); err == nil {
|
||||||
return &openWRTRestarter{cmd: path}
|
return &openWRTRestarter{cmd: path}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update checks GitHub for a newer release of beszel-agent, applies it,
|
// Update checks GitHub for a newer release of beszel-agent, applies it,
|
||||||
// fixes SELinux context if needed, and restarts the service.
|
// fixes SELinux context if needed, and restarts the service.
|
||||||
func Update() error {
|
func Update() error {
|
||||||
// 1) Parse current version
|
exePath, _ := os.Executable()
|
||||||
current, err := semver.Parse(beszel.Version)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid current version %q: %w", beszel.Version, err)
|
|
||||||
}
|
|
||||||
log.Printf("Current version: %s", current)
|
|
||||||
|
|
||||||
// 2) Create updater with our binary name filter
|
dataDir, err := getDataDir()
|
||||||
updater, err := selfupdate.NewUpdater(selfupdate.Config{
|
if err != nil {
|
||||||
Filters: []string{"beszel-agent"},
|
dataDir = os.TempDir()
|
||||||
})
|
}
|
||||||
if err != nil {
|
updated, err := ghupdate.Update(ghupdate.Config{
|
||||||
return fmt.Errorf("creating self-update client: %w", err)
|
ArchiveExecutable: "beszel-agent",
|
||||||
}
|
DataDir: dataDir,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if !updated {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 3) Detect latest
|
// make sure the file is executable
|
||||||
log.Print("Checking for updates…")
|
if err := os.Chmod(exePath, 0755); err != nil {
|
||||||
latest, found, err := updater.DetectLatest("henrygd/beszel")
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set executable permissions: %v", err)
|
||||||
if err != nil {
|
}
|
||||||
return fmt.Errorf("failed to detect latest release: %w", err)
|
// set ownership to beszel:beszel if possible
|
||||||
}
|
if chownPath, err := exec.LookPath("chown"); err == nil {
|
||||||
if !found {
|
if err := exec.Command(chownPath, "beszel:beszel", exePath).Run(); err != nil {
|
||||||
log.Print("No updates available.")
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set file ownership: %v", err)
|
||||||
return nil
|
}
|
||||||
}
|
}
|
||||||
log.Printf("Latest version: %s", latest.Version)
|
|
||||||
|
|
||||||
// 4) Compare versions
|
// 6) Fix SELinux context if necessary
|
||||||
if !latest.Version.GT(current) {
|
if err := handleSELinuxContext(exePath); err != nil {
|
||||||
log.Print("You are already up to date.")
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: SELinux context handling: %v", err)
|
||||||
return nil
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 5) Perform the update
|
// 7) Restart service if running under a recognised init system
|
||||||
exePath, err := os.Executable()
|
if r := detectRestarter(); r != nil {
|
||||||
if err != nil {
|
if err := r.Restart(); err != nil {
|
||||||
return fmt.Errorf("unable to locate executable: %w", err)
|
ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to restart service: %v", err)
|
||||||
}
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually.")
|
||||||
log.Printf("Updating from %s to %s…", current, latest.Version)
|
}
|
||||||
if err := updater.UpdateTo(latest, exePath); err != nil {
|
} else {
|
||||||
return fmt.Errorf("update failed: %w", err)
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "No supported init system detected; please restart manually if needed.")
|
||||||
}
|
}
|
||||||
log.Printf("Successfully updated to %s", latest.Version)
|
|
||||||
log.Print("Release notes:\n", strings.TrimSpace(latest.ReleaseNotes))
|
|
||||||
|
|
||||||
// 6) Fix SELinux context if necessary
|
return nil
|
||||||
if err := handleSELinuxContext(exePath); err != nil {
|
|
||||||
log.Printf("Warning: SELinux context handling: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7) Restart service if running under a recognised init system
|
|
||||||
if r := detectRestarter(); r != nil {
|
|
||||||
if err := r.Restart(); err != nil {
|
|
||||||
log.Printf("Warning: failed to restart service: %v", err)
|
|
||||||
log.Print("Please restart the service manually.")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log.Print("No supported init system detected; please restart manually if needed.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSELinuxContext restores or applies the correct SELinux label to the binary.
|
// handleSELinuxContext restores or applies the correct SELinux label to the binary.
|
||||||
func handleSELinuxContext(path string) error {
|
func handleSELinuxContext(path string) error {
|
||||||
out, err := exec.Command("getenforce").Output()
|
out, err := exec.Command("getenforce").Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// SELinux not enabled or getenforce not available
|
// SELinux not enabled or getenforce not available
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
state := strings.TrimSpace(string(out))
|
state := strings.TrimSpace(string(out))
|
||||||
if state == "Disabled" {
|
if state == "Disabled" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Print("SELinux is enabled; applying context…")
|
ghupdate.ColorPrint(ghupdate.ColorYellow, "SELinux is enabled; applying context…")
|
||||||
var errs []string
|
var errs []string
|
||||||
|
|
||||||
// Try persistent context via semanage+restorecon
|
// Try persistent context via semanage+restorecon
|
||||||
if semanagePath, err := exec.LookPath("semanage"); err == nil {
|
if semanagePath, err := exec.LookPath("semanage"); err == nil {
|
||||||
if err := exec.Command(semanagePath, "fcontext", "-a", "-t", "bin_t", path).Run(); err != nil {
|
if err := exec.Command(semanagePath, "fcontext", "-a", "-t", "bin_t", path).Run(); err != nil {
|
||||||
errs = append(errs, "semanage fcontext failed: "+err.Error())
|
errs = append(errs, "semanage fcontext failed: "+err.Error())
|
||||||
} else if restoreconPath, err := exec.LookPath("restorecon"); err == nil {
|
} else if restoreconPath, err := exec.LookPath("restorecon"); err == nil {
|
||||||
if err := exec.Command(restoreconPath, "-v", path).Run(); err != nil {
|
if err := exec.Command(restoreconPath, "-v", path).Run(); err != nil {
|
||||||
errs = append(errs, "restorecon failed: "+err.Error())
|
errs = append(errs, "restorecon failed: "+err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to temporary context via chcon
|
// Fallback to temporary context via chcon
|
||||||
if chconPath, err := exec.LookPath("chcon"); err == nil {
|
if chconPath, err := exec.LookPath("chcon"); err == nil {
|
||||||
if err := exec.Command(chconPath, "-t", "bin_t", path).Run(); err != nil {
|
if err := exec.Command(chconPath, "-t", "bin_t", path).Run(); err != nil {
|
||||||
errs = append(errs, "chcon failed: "+err.Error())
|
errs = append(errs, "chcon failed: "+err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
return fmt.Errorf("SELinux context errors: %s", strings.Join(errs, "; "))
|
return fmt.Errorf("SELinux context errors: %s", strings.Join(errs, "; "))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
401
beszel/internal/ghupdate/ghupdate.go
Normal file
401
beszel/internal/ghupdate/ghupdate.go
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
// Package ghupdate implements a new command to self update the current
|
||||||
|
// executable with the latest GitHub release. This is based on PocketBase's
|
||||||
|
// ghupdate package with modifications.
|
||||||
|
package ghupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"beszel"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/blang/semver"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/archive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Minimal color functions using ANSI escape codes
|
||||||
|
const (
|
||||||
|
colorReset = "\033[0m"
|
||||||
|
ColorYellow = "\033[33m"
|
||||||
|
colorGreen = "\033[32m"
|
||||||
|
colorCyan = "\033[36m"
|
||||||
|
colorGray = "\033[90m"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ColorPrint(color, text string) {
|
||||||
|
fmt.Println(color + text + colorReset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ColorPrintf(color, format string, args ...interface{}) {
|
||||||
|
fmt.Printf(color+format+colorReset+"\n", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HttpClient is a base HTTP client interface (usually used for test purposes).
|
||||||
|
type HttpClient interface {
|
||||||
|
Do(req *http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config defines the config options of the ghupdate plugin.
|
||||||
|
//
|
||||||
|
// NB! This plugin is considered experimental and its config options may change in the future.
|
||||||
|
type Config 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"; an additional ".exe" check is also performed as a fallback).
|
||||||
|
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
|
||||||
|
|
||||||
|
// The data directory to use when fetching and downloading the latest release.
|
||||||
|
DataDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Update(config Config) (updated bool, err error) {
|
||||||
|
p := &plugin{
|
||||||
|
currentVersion: beszel.Version,
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
type plugin struct {
|
||||||
|
config Config
|
||||||
|
currentVersion string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *plugin) update() (updated bool, err error) {
|
||||||
|
ColorPrint(ColorYellow, "Fetching release information...")
|
||||||
|
|
||||||
|
if p.config.DataDir == "" {
|
||||||
|
p.config.DataDir = os.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.config.Owner == "" {
|
||||||
|
p.config.Owner = "henrygd"
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.config.Repo == "" {
|
||||||
|
p.config.Repo = "beszel"
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.config.Context == nil {
|
||||||
|
p.config.Context = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.config.HttpClient == nil {
|
||||||
|
p.config.HttpClient = http.DefaultClient
|
||||||
|
}
|
||||||
|
|
||||||
|
var latest *release
|
||||||
|
latest, err = fetchLatestRelease(
|
||||||
|
p.config.Context,
|
||||||
|
p.config.HttpClient,
|
||||||
|
fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", p.config.Owner, p.config.Repo),
|
||||||
|
)
|
||||||
|
// if the first fetch fails, try the beszel.dev API (fallback for China)
|
||||||
|
if err != nil {
|
||||||
|
ColorPrint(ColorYellow, "Failed to fetch release. Trying beszel.dev mirror...")
|
||||||
|
latest, err = fetchLatestRelease(
|
||||||
|
p.config.Context,
|
||||||
|
p.config.HttpClient,
|
||||||
|
fmt.Sprintf("https://gh.beszel.dev/repos/%s/%s/releases/latest?api=true", p.config.Owner, p.config.Repo),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
currentVersion := semver.MustParse(strings.TrimPrefix(p.currentVersion, "v"))
|
||||||
|
newVersion := semver.MustParse(strings.TrimPrefix(latest.Tag, "v"))
|
||||||
|
|
||||||
|
if newVersion.LTE(currentVersion) {
|
||||||
|
ColorPrintf(colorGreen, "You already have the latest version %s.", p.currentVersion)
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
suffix := archiveSuffix(p.config.ArchiveExecutable, runtime.GOOS, runtime.GOARCH)
|
||||||
|
asset, err := latest.findAssetBySuffix(suffix)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseDir := filepath.Join(p.config.DataDir, core.LocalTempDirName)
|
||||||
|
defer os.RemoveAll(releaseDir)
|
||||||
|
|
||||||
|
ColorPrintf(ColorYellow, "Downloading %s...", asset.Name)
|
||||||
|
|
||||||
|
// download the release asset
|
||||||
|
assetPath := filepath.Join(releaseDir, asset.Name)
|
||||||
|
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ColorPrintf(ColorYellow, "Extracting %s...", asset.Name)
|
||||||
|
|
||||||
|
extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name)
|
||||||
|
defer os.RemoveAll(extractDir)
|
||||||
|
|
||||||
|
// Extract based on file extension
|
||||||
|
if strings.HasSuffix(asset.Name, ".tar.gz") {
|
||||||
|
if err := extractTarGz(assetPath, extractDir); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := archive.Extract(assetPath, extractDir); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColorPrint(ColorYellow, "Replacing the executable...")
|
||||||
|
|
||||||
|
oldExec, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
renamedOldExec := oldExec + ".old"
|
||||||
|
defer os.Remove(renamedOldExec)
|
||||||
|
|
||||||
|
newExec := filepath.Join(extractDir, p.config.ArchiveExecutable)
|
||||||
|
if _, err := os.Stat(newExec); err != nil {
|
||||||
|
// try again with an .exe extension
|
||||||
|
newExec = newExec + ".exe"
|
||||||
|
if _, fallbackErr := os.Stat(newExec); fallbackErr != nil {
|
||||||
|
return false, fmt.Errorf("the executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rename the current executable
|
||||||
|
if err := os.Rename(oldExec, renamedOldExec); err != nil {
|
||||||
|
return false, fmt.Errorf("failed to rename the current executable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tryToRevertExecChanges := func() {
|
||||||
|
if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil {
|
||||||
|
slog.Debug(
|
||||||
|
"Failed to revert executable",
|
||||||
|
slog.String("old", renamedOldExec),
|
||||||
|
slog.String("new", oldExec),
|
||||||
|
slog.String("error", revertErr.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace with the extracted binary
|
||||||
|
if err := os.Rename(newExec, oldExec); err != nil {
|
||||||
|
// If rename fails due to cross-device link, try copying instead
|
||||||
|
if isCrossDeviceError(err) {
|
||||||
|
if err := copyFile(newExec, oldExec); err != nil {
|
||||||
|
tryToRevertExecChanges()
|
||||||
|
return false, fmt.Errorf("failed replacing the executable: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
tryToRevertExecChanges()
|
||||||
|
return false, fmt.Errorf("failed replacing the executable: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ColorPrint(colorGray, "---")
|
||||||
|
ColorPrint(colorGreen, "Update completed successfully! You can start the executable as usual.")
|
||||||
|
|
||||||
|
// print the release notes
|
||||||
|
if latest.Body != "" {
|
||||||
|
fmt.Print("\n")
|
||||||
|
ColorPrintf(colorCyan, "Here is a list with some of the %s changes:", latest.Tag)
|
||||||
|
// remove the update command note to avoid "stuttering"
|
||||||
|
// (@todo consider moving to a config option)
|
||||||
|
releaseNotes := strings.TrimSpace(strings.Replace(latest.Body, "> _To update the prebuilt executable you can run `./"+p.config.ArchiveExecutable+" update`._", "", 1))
|
||||||
|
ColorPrint(colorCyan, releaseNotes)
|
||||||
|
fmt.Print("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchLatestRelease(
|
||||||
|
ctx context.Context,
|
||||||
|
client HttpClient,
|
||||||
|
url string,
|
||||||
|
) (*release, error) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCrossDeviceError checks if the error is due to a cross-device link
|
||||||
|
func isCrossDeviceError(err error) bool {
|
||||||
|
return err != nil && (strings.Contains(err.Error(), "cross-device") ||
|
||||||
|
strings.Contains(err.Error(), "EXDEV"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyFile copies a file from src to dst, preserving permissions
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
sourceFile, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer sourceFile.Close()
|
||||||
|
|
||||||
|
destFile, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer destFile.Close()
|
||||||
|
|
||||||
|
// Copy the file contents
|
||||||
|
if _, err := io.Copy(destFile, sourceFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve the original file permissions
|
||||||
|
sourceInfo, err := sourceFile.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return destFile.Chmod(sourceInfo.Mode())
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveSuffix(binaryName, goos, goarch string) string {
|
||||||
|
if goos == "windows" {
|
||||||
|
return fmt.Sprintf("%s_%s_%s.zip", binaryName, goos, goarch)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s_%s_%s.tar.gz", binaryName, goos, goarch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTarGz(srcPath, destDir string) error {
|
||||||
|
src, err := os.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
gz, err := gzip.NewReader(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer gz.Close()
|
||||||
|
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
|
||||||
|
for {
|
||||||
|
header, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if header.Typeflag == tar.TypeDir {
|
||||||
|
if err := os.MkdirAll(filepath.Join(destDir, header.Name), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filepath.Join(destDir, header.Name)), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
outFile, err := os.Create(filepath.Join(destDir, header.Name))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer outFile.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(outFile, tr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
23
beszel/internal/ghupdate/ghupdate_test.go
Normal file
23
beszel/internal/ghupdate/ghupdate_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
36
beszel/internal/ghupdate/release.go
Normal file
36
beszel/internal/ghupdate/release.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package ghupdate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type releaseAsset struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DownloadUrl string `json:"browser_download_url"`
|
||||||
|
Id int `json:"id"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type release struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Tag string `json:"tag_name"`
|
||||||
|
Published string `json:"published_at"`
|
||||||
|
Url string `json:"html_url"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Assets []*releaseAsset `json:"assets"`
|
||||||
|
Id int `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
@@ -1,61 +1,35 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel/internal/ghupdate"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/blang/semver"
|
|
||||||
"github.com/rhysd/go-github-selfupdate/selfupdate"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update updates beszel to the latest version
|
// Update updates beszel to the latest version
|
||||||
func Update(_ *cobra.Command, _ []string) {
|
func Update(_ *cobra.Command, _ []string) {
|
||||||
var latest *selfupdate.Release
|
dataDir := os.TempDir()
|
||||||
var found bool
|
|
||||||
var err error
|
// set dataDir to ./beszel_data if it exists
|
||||||
currentVersion := semver.MustParse(beszel.Version)
|
if _, err := os.Stat("./beszel_data"); err == nil {
|
||||||
fmt.Println("beszel", currentVersion)
|
dataDir = "./beszel_data"
|
||||||
fmt.Println("Checking for updates...")
|
}
|
||||||
updater, _ := selfupdate.NewUpdater(selfupdate.Config{
|
|
||||||
Filters: []string{"beszel_"},
|
updated, err := ghupdate.Update(ghupdate.Config{
|
||||||
|
ArchiveExecutable: "beszel",
|
||||||
|
DataDir: dataDir,
|
||||||
})
|
})
|
||||||
latest, found, err = updater.DetectLatest("henrygd/beszel")
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Error checking for updates:", err)
|
log.Fatal(err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
if !updated {
|
||||||
if !found {
|
|
||||||
fmt.Println("No updates found")
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Latest version:", latest.Version)
|
|
||||||
|
|
||||||
if latest.Version.LTE(currentVersion) {
|
|
||||||
fmt.Println("You are up to date")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var binaryPath string
|
|
||||||
fmt.Printf("Updating from %s to %s...\n", currentVersion, latest.Version)
|
|
||||||
binaryPath, err = os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("Error getting binary path:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
err = selfupdate.UpdateTo(latest.AssetURL, binaryPath)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("Please try rerunning with sudo. Error:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Printf("Successfully updated to %s\n\n%s\n", latest.Version, strings.TrimSpace(latest.ReleaseNotes))
|
|
||||||
|
|
||||||
// Try to restart the service if it's running
|
// Try to restart the service if it's running
|
||||||
restartService()
|
restartService()
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user