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

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+231
View File
@@ -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
}