first commit
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
// This file implements pigo's binary self-replacement for `pigo update` (issue
|
||||
// #466). Given the current build version it discovers the latest release (via
|
||||
// version.go), downloads the matching goreleaser archive for the running
|
||||
// GOOS/GOARCH, verifies its SHA256 against the release's checksums.txt, and
|
||||
// atomically replaces the running executable.
|
||||
//
|
||||
// The archive naming mirrors .goreleaser.yaml and install.sh exactly, so this
|
||||
// stays a single source of truth with the release tooling. Replacement is
|
||||
// atomic: the new binary is written to a temp file in the target's directory
|
||||
// and os.Rename'd over the current executable, so a failure mid-download never
|
||||
// leaves a truncated binary in place.
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// checksumsFile is the goreleaser checksums artifact name (see .goreleaser.yaml).
|
||||
const checksumsFile = "checksums.txt"
|
||||
|
||||
// Updater performs a self-replacement. Its fields are seams for testing;
|
||||
// NewUpdater fills them with production defaults.
|
||||
type Updater struct {
|
||||
HTTPClient *http.Client
|
||||
// Repo is "owner/name"; ReleaseBaseURL overrides the download host in tests.
|
||||
Repo string
|
||||
ReleaseBaseURL string // e.g. https://github.com/smallnest/pigo/releases/download
|
||||
GOOS, GOARCH string
|
||||
// ExecPath is the executable to replace; defaults to os.Executable().
|
||||
ExecPath string
|
||||
}
|
||||
|
||||
// Run performs `pigo update` (self-update pigo). It discovers the latest
|
||||
// release, compares it to current, and replaces the running binary when a
|
||||
// newer release exists. When current is a source build ("dev"), it cannot
|
||||
// compare and proceeds to install the latest. Returns a process exit code.
|
||||
func Run(ctx context.Context, current string, out, errOut io.Writer) int {
|
||||
tag, err := LatestTag(ctx, nil, Repo)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "pigo: failed to check for updates: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if avail, comparable := UpdateAvailable(current, tag); comparable && !avail {
|
||||
fmt.Fprintf(out, "already up to date at %s\n", current)
|
||||
return 0
|
||||
}
|
||||
u, err := NewUpdater()
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "pigo: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
if err := u.Apply(ctx, tag, out); err != nil {
|
||||
fmt.Fprintf(errOut, "pigo: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(out, "updated to %s\n", tag)
|
||||
return 0
|
||||
}
|
||||
|
||||
// NewUpdater returns an Updater configured for the running process.
|
||||
func NewUpdater() (*Updater, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("selfupdate: locate executable: %w", err)
|
||||
}
|
||||
// Resolve symlinks so we replace the real file, not a symlink.
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
return &Updater{
|
||||
HTTPClient: &http.Client{Timeout: 60 * time.Second},
|
||||
Repo: Repo,
|
||||
ReleaseBaseURL: "https://github.com/" + Repo + "/releases/download",
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
ExecPath: exe,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// archiveName builds the goreleaser archive filename for a release version
|
||||
// (without leading "v") on the updater's platform. It mirrors the
|
||||
// name_template and format_overrides in .goreleaser.yaml.
|
||||
func (u *Updater) archiveName(versionNoV string) string {
|
||||
osName := map[string]string{"darwin": "Darwin", "linux": "Linux", "windows": "Windows"}[u.GOOS]
|
||||
if osName == "" {
|
||||
osName = u.GOOS
|
||||
}
|
||||
arch := map[string]string{"amd64": "x86_64", "386": "i386"}[u.GOARCH]
|
||||
if arch == "" {
|
||||
arch = u.GOARCH // arm64 and others pass through
|
||||
}
|
||||
ext := "tar.gz"
|
||||
if u.GOOS == "windows" {
|
||||
ext = "zip"
|
||||
}
|
||||
return fmt.Sprintf("pigo_%s_%s_%s.%s", versionNoV, osName, arch, ext)
|
||||
}
|
||||
|
||||
// binaryName is the executable name inside the archive.
|
||||
func (u *Updater) binaryName() string {
|
||||
if u.GOOS == "windows" {
|
||||
return "pigo.exe"
|
||||
}
|
||||
return "pigo"
|
||||
}
|
||||
|
||||
// Apply downloads the release identified by tag, verifies its checksum, and
|
||||
// atomically replaces the target executable. tag is like "v0.4.0".
|
||||
func (u *Updater) Apply(ctx context.Context, tag string, out io.Writer) error {
|
||||
versionNoV := strings.TrimPrefix(strings.TrimSpace(tag), "v")
|
||||
archive := u.archiveName(versionNoV)
|
||||
base := fmt.Sprintf("%s/%s", strings.TrimRight(u.ReleaseBaseURL, "/"), tag)
|
||||
|
||||
fmt.Fprintf(out, "downloading %s ...\n", archive)
|
||||
archiveBytes, err := u.download(ctx, base+"/"+archive)
|
||||
if err != nil {
|
||||
return fmt.Errorf("selfupdate: download archive: %w", err)
|
||||
}
|
||||
|
||||
sums, err := u.download(ctx, base+"/"+checksumsFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("selfupdate: download checksums: %w", err)
|
||||
}
|
||||
want, err := checksumFor(sums, archive)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
got := sha256.Sum256(archiveBytes)
|
||||
if hex.EncodeToString(got[:]) != want {
|
||||
return fmt.Errorf("selfupdate: checksum mismatch for %s (archive corrupt or tampered)", archive)
|
||||
}
|
||||
|
||||
binary, err := extractBinary(archiveBytes, u.binaryName(), u.GOOS == "windows")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.replace(binary); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// download fetches url and returns the full body. A non-200 status is an error.
|
||||
func (u *Updater) download(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := u.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// replace atomically swaps the target executable with newBin: it writes a temp
|
||||
// file in the target's directory, sets it executable, and renames it over the
|
||||
// target. Writing to the same directory keeps the rename atomic (same
|
||||
// filesystem). A permission error on the directory yields an actionable message.
|
||||
func (u *Updater) replace(newBin []byte) error {
|
||||
dir := filepath.Dir(u.ExecPath)
|
||||
tmp, err := os.CreateTemp(dir, ".pigo-update-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("selfupdate: cannot write to %s: %w (try running with sudo, or install pigo to a writable directory)", dir, err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // no-op after successful rename
|
||||
|
||||
if _, err := tmp.Write(newBin); err != nil {
|
||||
tmp.Close()
|
||||
return fmt.Errorf("selfupdate: write new binary: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("selfupdate: close new binary: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmpName, 0o755); err != nil {
|
||||
return fmt.Errorf("selfupdate: chmod new binary: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, u.ExecPath); err != nil {
|
||||
return fmt.Errorf("selfupdate: replace %s: %w", u.ExecPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checksumFor finds the hex SHA256 for archive in a goreleaser checksums.txt
|
||||
// body (lines of "<hex> <filename>").
|
||||
func checksumFor(sums []byte, archive string) (string, error) {
|
||||
sc := bufio.NewScanner(bytes.NewReader(sums))
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) == 2 && fields[1] == archive {
|
||||
return fields[0], nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("selfupdate: %s not found in checksums.txt", archive)
|
||||
}
|
||||
|
||||
// extractBinary pulls the named binary out of an archive (tar.gz, or zip when
|
||||
// isZip). It returns the binary bytes.
|
||||
func extractBinary(archive []byte, name string, isZip bool) ([]byte, error) {
|
||||
if isZip {
|
||||
return extractFromZip(archive, name)
|
||||
}
|
||||
return extractFromTarGz(archive, name)
|
||||
}
|
||||
|
||||
func extractFromTarGz(archive []byte, name string) ([]byte, error) {
|
||||
gz, err := gzip.NewReader(bytes.NewReader(archive))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("selfupdate: gzip reader: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("selfupdate: read tar: %w", err)
|
||||
}
|
||||
if filepath.Base(hdr.Name) == name && hdr.Typeflag == tar.TypeReg {
|
||||
return io.ReadAll(tr)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("selfupdate: %s not found in archive", name)
|
||||
}
|
||||
|
||||
func extractFromZip(archive []byte, name string) ([]byte, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("selfupdate: zip reader: %w", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if filepath.Base(f.Name) == name {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("selfupdate: open %s in zip: %w", name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("selfupdate: %s not found in archive", name)
|
||||
}
|
||||
Reference in New Issue
Block a user