Add selfupdate: replace the running program with the newest Gitea release
CI / test (push) Successful in 1m56s
CI / test (push) Successful in 1m56s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b37b18c40d
commit
d1bbd28fb0
4 files changed
+456
No files matched your search
@@ -0,0 +1,299 @@
|
||||
// Package selfupdate lets a program replace itself with the newest release published on a
|
||||
// Gitea server: it checks the latest release, downloads the archive for this platform,
|
||||
// verifies it against the release's SHA256SUMS, checks that the new binary runs, swaps it
|
||||
// in and restarts into it.
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxArchive = 300 << 20
|
||||
|
||||
// Config says what to update and where from.
|
||||
type Config struct {
|
||||
Server string // e.g. https://git.veillax.com
|
||||
Repo string // owner/name, e.g. Veillax/blacktip
|
||||
App string // binary and archive prefix, e.g. blacktip
|
||||
Current string // the running version, e.g. v0.1.2; anything that isn't vX.Y.Z is never updated
|
||||
// Interval between checks (default 6 hours).
|
||||
Interval time.Duration
|
||||
Logf func(format string, args ...any)
|
||||
|
||||
// The fields below exist for tests and unusual setups.
|
||||
HTTP *http.Client
|
||||
GOOS string
|
||||
GOARCH string
|
||||
ExePath string // binary to replace (default: the running one)
|
||||
// Verify runs on the downloaded binary before it replaces the old one. The default
|
||||
// runs it with -version and checks that it reports the new tag.
|
||||
Verify func(path, tag string) error
|
||||
// Restart is called after the swap (default: re-exec the new binary in place, keeping the
|
||||
// process ID and environment).
|
||||
Restart func(exe string) error
|
||||
}
|
||||
|
||||
func (c *Config) defaults() {
|
||||
if c.Interval <= 0 {
|
||||
c.Interval = 6 * time.Hour
|
||||
}
|
||||
if c.Logf == nil {
|
||||
c.Logf = func(string, ...any) {}
|
||||
}
|
||||
if c.HTTP == nil {
|
||||
c.HTTP = &http.Client{Timeout: 5 * time.Minute}
|
||||
}
|
||||
if c.GOOS == "" {
|
||||
c.GOOS = runtime.GOOS
|
||||
}
|
||||
if c.GOARCH == "" {
|
||||
c.GOARCH = runtime.GOARCH
|
||||
}
|
||||
if c.Verify == nil {
|
||||
c.Verify = verifyRuns(c.App)
|
||||
}
|
||||
if c.Restart == nil {
|
||||
c.Restart = restart
|
||||
}
|
||||
c.Server = strings.TrimRight(c.Server, "/")
|
||||
}
|
||||
|
||||
// Run checks shortly after start and then every Interval until ctx ends. When an update
|
||||
// is installed, Restart takes over and Run normally does not return.
|
||||
func Run(ctx context.Context, cfg Config) {
|
||||
cfg.defaults()
|
||||
if _, ok := parseVersion(cfg.Current); !ok {
|
||||
cfg.Logf("auto-update: this is a development build (%q), so it will not update itself", cfg.Current)
|
||||
return
|
||||
}
|
||||
if cfg.GOOS == "windows" {
|
||||
cfg.Logf("auto-update: not supported on Windows, because a running program can't be replaced; update it by hand")
|
||||
return
|
||||
}
|
||||
wait := time.Duration(30+rand.Intn(240)) * time.Second // spread a fleet's first checks out
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(wait):
|
||||
}
|
||||
if _, err := CheckOnce(ctx, cfg); err != nil {
|
||||
cfg.Logf("auto-update: %v", err)
|
||||
}
|
||||
wait = cfg.Interval + time.Duration(rand.Int63n(int64(cfg.Interval/10)+1))
|
||||
}
|
||||
}
|
||||
|
||||
// CheckOnce looks for a newer release and installs it. It returns the new tag when it
|
||||
// installed one (and Restart returned, which only happens in tests or on failure).
|
||||
func CheckOnce(ctx context.Context, cfg Config) (string, error) {
|
||||
cfg.defaults()
|
||||
cur, ok := parseVersion(cfg.Current)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%q is not a release version", cfg.Current)
|
||||
}
|
||||
var rel struct {
|
||||
Tag string `json:"tag_name"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
Draft bool `json:"draft"`
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"browser_download_url"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
body, err := get(ctx, cfg.HTTP, cfg.Server+"/api/v1/repos/"+cfg.Repo+"/releases/latest", 1<<20)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("checking for a new release: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(body, &rel); err != nil {
|
||||
return "", fmt.Errorf("reading the latest release: %w", err)
|
||||
}
|
||||
if rel.Prerelease || rel.Draft {
|
||||
return "", nil
|
||||
}
|
||||
latest, ok := parseVersion(rel.Tag)
|
||||
if !ok || !latest.newerThan(cur) {
|
||||
return "", nil // up to date, or something we don't understand: never downgrade
|
||||
}
|
||||
|
||||
if cfg.GOOS == "windows" {
|
||||
return "", errors.New("auto-update is not supported on Windows")
|
||||
}
|
||||
archive := fmt.Sprintf("%s_%s_%s_%s.tar.gz", cfg.App, rel.Tag, cfg.GOOS, cfg.GOARCH)
|
||||
var archiveURL, sumsURL string
|
||||
for _, a := range rel.Assets {
|
||||
switch a.Name {
|
||||
case archive:
|
||||
archiveURL = a.URL
|
||||
case "SHA256SUMS":
|
||||
sumsURL = a.URL
|
||||
}
|
||||
}
|
||||
if archiveURL == "" || sumsURL == "" {
|
||||
return "", fmt.Errorf("release %s has no %s with SHA256SUMS yet (it may still be building); will retry", rel.Tag, archive)
|
||||
}
|
||||
cfg.Logf("auto-update: %s is available (running %s); downloading %s", rel.Tag, cfg.Current, archive)
|
||||
|
||||
sums, err := get(ctx, cfg.HTTP, sumsURL, 1<<20)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
want := ""
|
||||
for _, line := range strings.Split(string(sums), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) == 2 && strings.TrimPrefix(f[1], "*") == archive {
|
||||
want = strings.ToLower(f[0])
|
||||
}
|
||||
}
|
||||
if want == "" {
|
||||
return "", fmt.Errorf("SHA256SUMS does not list %s", archive)
|
||||
}
|
||||
data, err := get(ctx, cfg.HTTP, archiveURL, maxArchive)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
got := sha256.Sum256(data)
|
||||
if hex.EncodeToString(got[:]) != want {
|
||||
return "", fmt.Errorf("checksum mismatch for %s: not installing", archive)
|
||||
}
|
||||
bin, err := extract(data, cfg.App)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
exe := cfg.ExePath
|
||||
if exe == "" {
|
||||
if exe, err = os.Executable(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exe, err = filepath.EvalSymlinks(exe); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
tmp := filepath.Join(filepath.Dir(exe), "."+filepath.Base(exe)+".new")
|
||||
if err := os.WriteFile(tmp, bin, 0o755); err != nil {
|
||||
return "", fmt.Errorf("can't write next to %s (is its folder writable by this user?): %w", exe, err)
|
||||
}
|
||||
if err := cfg.Verify(tmp, rel.Tag); err != nil {
|
||||
os.Remove(tmp)
|
||||
return "", fmt.Errorf("the new binary failed its check, keeping the old one: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, exe); err != nil { // atomic; the running process keeps its old file
|
||||
os.Remove(tmp)
|
||||
return "", fmt.Errorf("replacing %s: %w", exe, err)
|
||||
}
|
||||
cfg.Logf("auto-update: installed %s, restarting", rel.Tag)
|
||||
if err := cfg.Restart(exe); err != nil {
|
||||
return rel.Tag, fmt.Errorf("installed %s but could not restart into it (it will start with the next restart): %w", rel.Tag, err)
|
||||
}
|
||||
return rel.Tag, nil
|
||||
}
|
||||
|
||||
func get(ctx context.Context, c *http.Client, url string, max int64) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "sharks-selfupdate")
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("%s: %s", url, resp.Status)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, max+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(b)) > max {
|
||||
return nil, fmt.Errorf("%s is larger than %d bytes", url, max)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// extract finds the program inside a release archive (<pkg>/<app>).
|
||||
func extract(data []byte, app string) ([]byte, error) {
|
||||
zr, err := gzip.NewReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tr := tar.NewReader(zr)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return nil, fmt.Errorf("no %s inside the archive", app)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.Typeflag == tar.TypeReg && path.Base(h.Name) == app && strings.Count(h.Name, "/") == 1 {
|
||||
return io.ReadAll(io.LimitReader(tr, maxArchive))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func verifyRuns(app string) func(path, tag string) error {
|
||||
return func(p, tag string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, p, "-version").Output()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if want := app + " " + tag; strings.TrimSpace(string(out)) != want {
|
||||
return fmt.Errorf("it reports %q, expected %q", strings.TrimSpace(string(out)), want)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type version [3]int
|
||||
|
||||
func parseVersion(s string) (version, bool) {
|
||||
var v version
|
||||
if !strings.HasPrefix(s, "v") || strings.ContainsAny(s, "-+") {
|
||||
return v, false // pre-releases and local builds are never part of auto-update
|
||||
}
|
||||
parts := strings.Split(s[1:], ".")
|
||||
if len(parts) != 3 {
|
||||
return v, false
|
||||
}
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n < 0 {
|
||||
return v, false
|
||||
}
|
||||
v[i] = n
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func (a version) newerThan(b version) bool {
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return a[i] > b[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in new issue
Block a user