diff --git a/beszel/internal/agent/update.go b/beszel/internal/agent/update.go index e470311..0c39926 100644 --- a/beszel/internal/agent/update.go +++ b/beszel/internal/agent/update.go @@ -1,168 +1,147 @@ package agent import ( - "fmt" - "log" - "os" - "os/exec" - "strings" - - "beszel" - - "github.com/blang/semver" - "github.com/rhysd/go-github-selfupdate/selfupdate" + "beszel/internal/ghupdate" + "fmt" + "log" + "os" + "os/exec" + "strings" ) // restarter knows how to restart the beszel-agent service. type restarter interface { - Restart() error + Restart() error } type systemdRestarter struct{ cmd string } func (s *systemdRestarter) Restart() error { - // Only restart if the service is active - if err := exec.Command(s.cmd, "is-active", "beszel-agent.service").Run(); err != nil { - return nil - } - log.Print("Restarting beszel-agent.service via systemd…") - return exec.Command(s.cmd, "restart", "beszel-agent.service").Run() + // Only restart if the service is active + if err := exec.Command(s.cmd, "is-active", "beszel-agent.service").Run(); err != nil { + return nil + } + ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent.service via systemd…") + return exec.Command(s.cmd, "restart", "beszel-agent.service").Run() } type openRCRestarter struct{ cmd string } func (o *openRCRestarter) Restart() error { - if err := exec.Command(o.cmd, "status", "beszel-agent").Run(); err != nil { - return nil - } - log.Print("Restarting beszel-agent via OpenRC…") - return exec.Command(o.cmd, "restart", "beszel-agent").Run() + if err := exec.Command(o.cmd, "status", "beszel-agent").Run(); err != nil { + return nil + } + ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via OpenRC…") + return exec.Command(o.cmd, "restart", "beszel-agent").Run() } type openWRTRestarter struct{ cmd string } func (w *openWRTRestarter) Restart() error { - if err := exec.Command(w.cmd, "running", "beszel-agent").Run(); err != nil { - return nil - } - log.Print("Restarting beszel-agent via procd…") - return exec.Command(w.cmd, "restart", "beszel-agent").Run() + if err := exec.Command(w.cmd, "running", "beszel-agent").Run(); err != nil { + return nil + } + ghupdate.ColorPrint(ghupdate.ColorYellow, "Restarting beszel-agent via procd…") + return exec.Command(w.cmd, "restart", "beszel-agent").Run() } func detectRestarter() restarter { - if path, err := exec.LookPath("systemctl"); err == nil { - return &systemdRestarter{cmd: path} - } - if path, err := exec.LookPath("rc-service"); err == nil { - return &openRCRestarter{cmd: path} - } - if path, err := exec.LookPath("service"); err == nil { - return &openWRTRestarter{cmd: path} - } - return nil + if path, err := exec.LookPath("systemctl"); err == nil { + return &systemdRestarter{cmd: path} + } + if path, err := exec.LookPath("rc-service"); err == nil { + return &openRCRestarter{cmd: path} + } + if path, err := exec.LookPath("service"); err == nil { + return &openWRTRestarter{cmd: path} + } + return nil } // Update checks GitHub for a newer release of beszel-agent, applies it, // fixes SELinux context if needed, and restarts the service. func Update() error { - // 1) Parse current version - 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) + exePath, _ := os.Executable() - // 2) Create updater with our binary name filter - updater, err := selfupdate.NewUpdater(selfupdate.Config{ - Filters: []string{"beszel-agent"}, - }) - if err != nil { - return fmt.Errorf("creating self-update client: %w", err) - } + dataDir, err := getDataDir() + if err != nil { + dataDir = os.TempDir() + } + updated, err := ghupdate.Update(ghupdate.Config{ + ArchiveExecutable: "beszel-agent", + DataDir: dataDir, + }) + if err != nil { + log.Fatal(err) + } + if !updated { + return nil + } - // 3) Detect latest - log.Print("Checking for updates…") - latest, found, err := updater.DetectLatest("henrygd/beszel") - if err != nil { - return fmt.Errorf("failed to detect latest release: %w", err) - } - if !found { - log.Print("No updates available.") - return nil - } - log.Printf("Latest version: %s", latest.Version) + // make sure the file is executable + if err := os.Chmod(exePath, 0755); err != nil { + ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set executable permissions: %v", err) + } + // set ownership to beszel:beszel if possible + if chownPath, err := exec.LookPath("chown"); err == nil { + if err := exec.Command(chownPath, "beszel:beszel", exePath).Run(); err != nil { + ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to set file ownership: %v", err) + } + } - // 4) Compare versions - if !latest.Version.GT(current) { - log.Print("You are already up to date.") - return nil - } + // 6) Fix SELinux context if necessary + if err := handleSELinuxContext(exePath); err != nil { + ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: SELinux context handling: %v", err) + } - // 5) Perform the update - exePath, err := os.Executable() - if err != nil { - return fmt.Errorf("unable to locate executable: %w", err) - } - log.Printf("Updating from %s to %s…", current, latest.Version) - if err := updater.UpdateTo(latest, exePath); err != nil { - return fmt.Errorf("update failed: %w", err) - } - log.Printf("Successfully updated to %s", latest.Version) - log.Print("Release notes:\n", strings.TrimSpace(latest.ReleaseNotes)) + // 7) Restart service if running under a recognised init system + if r := detectRestarter(); r != nil { + if err := r.Restart(); err != nil { + ghupdate.ColorPrintf(ghupdate.ColorYellow, "Warning: failed to restart service: %v", err) + ghupdate.ColorPrint(ghupdate.ColorYellow, "Please restart the service manually.") + } + } else { + ghupdate.ColorPrint(ghupdate.ColorYellow, "No supported init system detected; please restart manually if needed.") + } - // 6) Fix SELinux context if necessary - 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 + return nil } // handleSELinuxContext restores or applies the correct SELinux label to the binary. func handleSELinuxContext(path string) error { - out, err := exec.Command("getenforce").Output() - if err != nil { - // SELinux not enabled or getenforce not available - return nil - } - state := strings.TrimSpace(string(out)) - if state == "Disabled" { - return nil - } + out, err := exec.Command("getenforce").Output() + if err != nil { + // SELinux not enabled or getenforce not available + return nil + } + state := strings.TrimSpace(string(out)) + if state == "Disabled" { + return nil + } - log.Print("SELinux is enabled; applying context…") - var errs []string + ghupdate.ColorPrint(ghupdate.ColorYellow, "SELinux is enabled; applying context…") + var errs []string - // Try persistent context via semanage+restorecon - if semanagePath, err := exec.LookPath("semanage"); err == nil { - if err := exec.Command(semanagePath, "fcontext", "-a", "-t", "bin_t", path).Run(); err != nil { - errs = append(errs, "semanage fcontext failed: "+err.Error()) - } else if restoreconPath, err := exec.LookPath("restorecon"); err == nil { - if err := exec.Command(restoreconPath, "-v", path).Run(); err != nil { - errs = append(errs, "restorecon failed: "+err.Error()) - } - } - } + // Try persistent context via semanage+restorecon + if semanagePath, err := exec.LookPath("semanage"); err == nil { + if err := exec.Command(semanagePath, "fcontext", "-a", "-t", "bin_t", path).Run(); err != nil { + errs = append(errs, "semanage fcontext failed: "+err.Error()) + } else if restoreconPath, err := exec.LookPath("restorecon"); err == nil { + if err := exec.Command(restoreconPath, "-v", path).Run(); err != nil { + errs = append(errs, "restorecon failed: "+err.Error()) + } + } + } - // Fallback to temporary context via chcon - if chconPath, err := exec.LookPath("chcon"); err == nil { - if err := exec.Command(chconPath, "-t", "bin_t", path).Run(); err != nil { - errs = append(errs, "chcon failed: "+err.Error()) - } - } + // Fallback to temporary context via chcon + if chconPath, err := exec.LookPath("chcon"); err == nil { + if err := exec.Command(chconPath, "-t", "bin_t", path).Run(); err != nil { + errs = append(errs, "chcon failed: "+err.Error()) + } + } - if len(errs) > 0 { - return fmt.Errorf("SELinux context errors: %s", strings.Join(errs, "; ")) - } - return nil -} \ No newline at end of file + if len(errs) > 0 { + return fmt.Errorf("SELinux context errors: %s", strings.Join(errs, "; ")) + } + return nil +} diff --git a/beszel/internal/ghupdate/ghupdate.go b/beszel/internal/ghupdate/ghupdate.go new file mode 100644 index 0000000..52c5f22 --- /dev/null +++ b/beszel/internal/ghupdate/ghupdate.go @@ -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 +} diff --git a/beszel/internal/ghupdate/ghupdate_test.go b/beszel/internal/ghupdate/ghupdate_test.go new file mode 100644 index 0000000..8fa6fa1 --- /dev/null +++ b/beszel/internal/ghupdate/ghupdate_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) + } +} diff --git a/beszel/internal/ghupdate/release.go b/beszel/internal/ghupdate/release.go new file mode 100644 index 0000000..2cd84fc --- /dev/null +++ b/beszel/internal/ghupdate/release.go @@ -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) +} diff --git a/beszel/internal/hub/update.go b/beszel/internal/hub/update.go index 0dc1a53..28f6ca9 100644 --- a/beszel/internal/hub/update.go +++ b/beszel/internal/hub/update.go @@ -1,61 +1,35 @@ package hub import ( - "beszel" + "beszel/internal/ghupdate" "fmt" + "log" "os" "os/exec" - "strings" - "github.com/blang/semver" - "github.com/rhysd/go-github-selfupdate/selfupdate" "github.com/spf13/cobra" ) // Update updates beszel to the latest version func Update(_ *cobra.Command, _ []string) { - var latest *selfupdate.Release - var found bool - var err error - currentVersion := semver.MustParse(beszel.Version) - fmt.Println("beszel", currentVersion) - fmt.Println("Checking for updates...") - updater, _ := selfupdate.NewUpdater(selfupdate.Config{ - Filters: []string{"beszel_"}, + dataDir := os.TempDir() + + // set dataDir to ./beszel_data if it exists + if _, err := os.Stat("./beszel_data"); err == nil { + dataDir = "./beszel_data" + } + + updated, err := ghupdate.Update(ghupdate.Config{ + ArchiveExecutable: "beszel", + DataDir: dataDir, }) - latest, found, err = updater.DetectLatest("henrygd/beszel") - if err != nil { - fmt.Println("Error checking for updates:", err) - os.Exit(1) + log.Fatal(err) } - - 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") + if !updated { 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 restartService() }