Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// defaultSyscallExec replaces the current process image; it never
|
||||
// returns on success.
|
||||
func defaultSyscallExec(path string, argv []string) error {
|
||||
return syscall.Exec(path, argv, os.Environ())
|
||||
}
|
||||
|
||||
func goos() string { return runtime.GOOS }
|
||||
func goarch() string { return runtime.GOARCH }
|
||||
@@ -0,0 +1,231 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
// Package updater checks Gitea releases for new versions and replaces
|
||||
// the running binary in place, preserving the process ID so systemd
|
||||
// never notices the restart.
|
||||
package updater
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
json "encoding/json/v2"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// client is shared by the release check and the download; the timeout
|
||||
// bounds a hung server.
|
||||
var client = &http.Client{Timeout: 2 * time.Minute}
|
||||
|
||||
// maxBinarySize caps the downloaded binary.
|
||||
const maxBinarySize = 50 << 20
|
||||
|
||||
// ReleaseBase is the Gitea instance hosting volumen releases.
|
||||
var ReleaseBase = "https://sourcedock.dev"
|
||||
|
||||
// syscallExec and executable are the two process-level operations the
|
||||
// self-update performs; they are variables so a test can observe them
|
||||
// without replacing the running binary.
|
||||
var (
|
||||
syscallExec = defaultSyscallExec
|
||||
executable = os.Executable
|
||||
)
|
||||
|
||||
// RepoPath identifies the volumen repository on the Gitea instance.
|
||||
const RepoPath = "/api/v1/repos/petrbalvin/volumen"
|
||||
|
||||
// RepoSlug is the repository path used by non-API URLs such as release
|
||||
// downloads.
|
||||
const RepoSlug = "/petrbalvin/volumen"
|
||||
|
||||
// CheckLatest returns the newest published release tag (without the
|
||||
// leading "v"), or "" when no release exists.
|
||||
func CheckLatest() (string, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, ReleaseBase+RepoPath+"/releases/latest", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("release check: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("release check: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var payload struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.UnmarshalRead(io.LimitReader(resp.Body, 1<<20), &payload); err != nil {
|
||||
return "", fmt.Errorf("release check: %w", err)
|
||||
}
|
||||
return strings.TrimPrefix(payload.TagName, "v"), nil
|
||||
}
|
||||
|
||||
// UpdateAvailable returns the newer version when one exists, else "".
|
||||
func UpdateAvailable(current string) string {
|
||||
latest, err := CheckLatest()
|
||||
if err != nil || latest == "" || latest == current {
|
||||
return ""
|
||||
}
|
||||
if CompareVersions(latest, current) > 0 {
|
||||
return latest
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fetchChecksum reads checksums.txt from the release and returns the
|
||||
// SHA-256 recorded for name.
|
||||
func fetchChecksum(version, name string) (string, error) {
|
||||
url := fmt.Sprintf("%s%s/releases/download/v%s/checksums.txt", ReleaseBase, RepoSlug, version)
|
||||
body, err := fetchBounded(url, 1<<20)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download checksums: %w", err)
|
||||
}
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(body)))
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == name {
|
||||
return fields[0], nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("checksums.txt has no entry for %s", name)
|
||||
}
|
||||
|
||||
// fetchBounded downloads url, refusing bodies larger than limit. The
|
||||
// URL is built from the release base and the version, so it is never
|
||||
// caller-supplied; redirects are followed by the shared client.
|
||||
func fetchBounded(url string, limit int64) ([]byte, error) {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("fetch %s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(body)) > limit {
|
||||
return nil, fmt.Errorf("body exceeds %d bytes", limit)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// CompareVersions compares two dotted versions numerically per part.
|
||||
func CompareVersions(a, b string) int {
|
||||
as, bs := strings.Split(a, "."), strings.Split(b, ".")
|
||||
for i := 0; i < len(as) || i < len(bs); i++ {
|
||||
av, bv := part(as, i), part(bs, i)
|
||||
if av != bv {
|
||||
if av < bv {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// part returns the i-th numeric component of a version. A component
|
||||
// longer than an int is clamped rather than allowed to overflow, because
|
||||
// an overflowed comparison can invert.
|
||||
func part(parts []string, i int) int {
|
||||
if i >= len(parts) {
|
||||
return 0
|
||||
}
|
||||
value := 0
|
||||
for _, r := range parts[i] {
|
||||
if r < '0' || r > '9' {
|
||||
break
|
||||
}
|
||||
digit := int(r - '0')
|
||||
if value > (math.MaxInt-digit)/10 {
|
||||
return math.MaxInt
|
||||
}
|
||||
value = value*10 + digit
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// SelfUpdate downloads the release binary for this platform, replaces
|
||||
// the running executable atomically, and restarts the process in
|
||||
// place. It returns the target version.
|
||||
func SelfUpdate(current string) (string, error) {
|
||||
latest, err := CheckLatest()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if latest == "" || CompareVersions(latest, current) <= 0 {
|
||||
return latest, fmt.Errorf("already running the latest release")
|
||||
}
|
||||
exe, err := executable()
|
||||
if err != nil {
|
||||
return latest, fmt.Errorf("locate executable: %w", err)
|
||||
}
|
||||
exe, err = filepath.EvalSymlinks(exe)
|
||||
if err != nil {
|
||||
return latest, fmt.Errorf("resolve executable: %w", err)
|
||||
}
|
||||
|
||||
binaryName := fmt.Sprintf("volumen-%s-%s-%s", latest, goos(), goarch())
|
||||
asset := fmt.Sprintf("%s%s/releases/download/v%s/%s", ReleaseBase, RepoSlug, latest, binaryName)
|
||||
|
||||
expected, err := fetchChecksum(latest, binaryName)
|
||||
if err != nil {
|
||||
return latest, err
|
||||
}
|
||||
body, err := fetchBounded(asset, maxBinarySize)
|
||||
if err != nil {
|
||||
return latest, fmt.Errorf("download %s: %w", asset, err)
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
if hex.EncodeToString(sum[:]) != expected {
|
||||
return latest, fmt.Errorf("checksum mismatch for %s", binaryName)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(exe)
|
||||
tmp, err := os.CreateTemp(dir, ".volumen-update-*")
|
||||
if err != nil {
|
||||
return latest, fmt.Errorf("stage download: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
if _, err := tmp.Write(body); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return latest, fmt.Errorf("stage download: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return latest, fmt.Errorf("stage download: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return latest, fmt.Errorf("stage download: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmpPath, 0o755); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return latest, fmt.Errorf("stage download: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, exe); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return latest, fmt.Errorf("replace executable: %w", err)
|
||||
}
|
||||
// Restart in place; the PID stays the same so systemd sees nothing.
|
||||
argv := append([]string{exe}, os.Args[1:]...)
|
||||
if err := syscallExec(exe, argv); err != nil {
|
||||
return latest, fmt.Errorf("restart: %w", err)
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompareVersions(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want int
|
||||
}{
|
||||
{"0.6.0", "0.6.0", 0},
|
||||
{"0.7.0", "0.6.0", 1},
|
||||
{"0.6.0", "0.7.0", -1},
|
||||
{"1.0.0", "0.9.9", 1},
|
||||
{"0.6.1", "0.6", 1},
|
||||
{"0.6", "0.6.0", 0},
|
||||
{"0.10.0", "0.9.0", 1},
|
||||
{"1.2.3-rc1", "1.2.3", 0}, // suffix ignored per part
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := CompareVersions(tc.a, tc.b); got != tc.want {
|
||||
t.Fatalf("CompareVersions(%q, %q) = %d, want %d", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLatestParsesTag(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v1.2.3"})
|
||||
}))
|
||||
defer srv.Close()
|
||||
old := ReleaseBase
|
||||
ReleaseBase = srv.URL
|
||||
defer func() { ReleaseBase = old }()
|
||||
|
||||
latest, err := CheckLatest()
|
||||
if err != nil || latest != "1.2.3" {
|
||||
t.Fatalf("CheckLatest = %q, %v", latest, err)
|
||||
}
|
||||
if got := UpdateAvailable("1.2.2"); got != "1.2.3" {
|
||||
t.Fatalf("UpdateAvailable = %q", got)
|
||||
}
|
||||
if got := UpdateAvailable("1.2.3"); got != "" {
|
||||
t.Fatalf("UpdateAvailable(current) = %q", got)
|
||||
}
|
||||
if got := UpdateAvailable("2.0.0"); got != "" {
|
||||
t.Fatalf("UpdateAvailable(newer local) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLatestErrors(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
old := ReleaseBase
|
||||
ReleaseBase = srv.URL
|
||||
defer func() { ReleaseBase = old }()
|
||||
if _, err := CheckLatest(); err == nil {
|
||||
t.Fatal("want error on HTTP 500")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfUpdateURLsIncludeRepoPath(t *testing.T) {
|
||||
var paths []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.URL.Path)
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/releases/latest"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v9.9.9"})
|
||||
case strings.HasSuffix(r.URL.Path, "checksums.txt"):
|
||||
_, _ = w.Write([]byte("0000000000000000000000000000000000000000000000000000000000000000 volumen-9.9.9-" + goos() + "-" + goarch() + "\n"))
|
||||
default:
|
||||
_, _ = w.Write([]byte("binary"))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
old := ReleaseBase
|
||||
ReleaseBase = srv.URL
|
||||
defer func() { ReleaseBase = old }()
|
||||
|
||||
// SelfUpdate downloads and then fails on the checksum; the point of
|
||||
// this test is the request paths, not the outcome.
|
||||
_, _ = SelfUpdate("0.0.1")
|
||||
|
||||
want := RepoSlug + "/releases/download/v9.9.9/"
|
||||
downloads := 0
|
||||
for _, path := range paths {
|
||||
if !strings.Contains(path, "/releases/download/") {
|
||||
continue // the /api/... release check uses its own path shape
|
||||
}
|
||||
downloads++
|
||||
if !strings.Contains(path, want) {
|
||||
t.Fatalf("download path %q does not contain %q", path, want)
|
||||
}
|
||||
}
|
||||
if downloads < 2 {
|
||||
t.Fatalf("expected checksums and binary downloads, saw %d download requests", downloads)
|
||||
}
|
||||
}
|
||||
|
||||
// The self-update tail replaces the running binary, so it is exercised
|
||||
// through the two seams rather than by actually exec-ing anything.
|
||||
func TestSelfUpdateStagesAndExecs(t *testing.T) {
|
||||
binary := []byte("#!/bin/sh\necho replaced\n")
|
||||
sum := sha256.Sum256(binary)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/releases/latest"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v9.9.9"})
|
||||
case strings.HasSuffix(r.URL.Path, "checksums.txt"):
|
||||
fmt.Fprintf(w, "%s volumen-9.9.9-%s-%s\n", hex.EncodeToString(sum[:]), goos(), goarch())
|
||||
default:
|
||||
_, _ = w.Write(binary)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
oldBase := ReleaseBase
|
||||
ReleaseBase = srv.URL
|
||||
defer func() { ReleaseBase = oldBase }()
|
||||
|
||||
dir := t.TempDir()
|
||||
exe := filepath.Join(dir, "volumen")
|
||||
if err := os.WriteFile(exe, []byte("old binary"), 0o755); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
oldExecutable, oldExec := executable, syscallExec
|
||||
executable = func() (string, error) { return exe, nil }
|
||||
var gotPath string
|
||||
var gotArgv []string
|
||||
syscallExec = func(path string, argv []string) error {
|
||||
gotPath, gotArgv = path, argv
|
||||
return errors.New("stop here")
|
||||
}
|
||||
t.Cleanup(func() { executable, syscallExec = oldExecutable, oldExec })
|
||||
|
||||
_, err := SelfUpdate("0.0.1")
|
||||
if err == nil || !strings.Contains(err.Error(), "stop here") {
|
||||
t.Fatalf("SelfUpdate = %v, want the seam's error", err)
|
||||
}
|
||||
if gotPath != exe {
|
||||
t.Fatalf("exec path = %q, want %q", gotPath, exe)
|
||||
}
|
||||
if len(gotArgv) == 0 || gotArgv[0] != exe {
|
||||
t.Fatalf("argv = %v", gotArgv)
|
||||
}
|
||||
staged, err := os.ReadFile(exe)
|
||||
if err != nil {
|
||||
t.Fatalf("read the replaced binary: %v", err)
|
||||
}
|
||||
if string(staged) != string(binary) {
|
||||
t.Fatalf("the binary was not replaced: %q", staged)
|
||||
}
|
||||
info, err := os.Stat(exe)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o755 {
|
||||
t.Fatalf("mode = %v, want 0755", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelfUpdateRejectsAChecksumMismatch(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/releases/latest"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v9.9.9"})
|
||||
case strings.HasSuffix(r.URL.Path, "checksums.txt"):
|
||||
fmt.Fprintf(w, "%s volumen-9.9.9-%s-%s\n", strings.Repeat("0", 64), goos(), goarch())
|
||||
default:
|
||||
_, _ = w.Write([]byte("binary"))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
old := ReleaseBase
|
||||
ReleaseBase = srv.URL
|
||||
defer func() { ReleaseBase = old }()
|
||||
|
||||
exe := filepath.Join(t.TempDir(), "volumen")
|
||||
if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
oldExecutable := executable
|
||||
executable = func() (string, error) { return exe, nil }
|
||||
t.Cleanup(func() { executable = oldExecutable })
|
||||
|
||||
if _, err := SelfUpdate("0.0.1"); err == nil || !strings.Contains(err.Error(), "checksum") {
|
||||
t.Fatalf("SelfUpdate = %v, want a checksum failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVersionsClampsLongComponents(t *testing.T) {
|
||||
// A component wider than an int must compare, not overflow.
|
||||
huge := strings.Repeat("9", 30)
|
||||
if CompareVersions(huge, "1.0.0") <= 0 {
|
||||
t.Fatal("a very large component did not compare as newer")
|
||||
}
|
||||
if CompareVersions("1.0.0", huge) >= 0 {
|
||||
t.Fatal("a very large component did not compare as newer")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user