Archived
Add install script, release Makefile and self-update
Releases are built locally (no runner) and uploaded to the Gitea generic package registry. install.sh installs submarine plus a sub symlink; `submarine update` replaces the binary after verifying its checksum. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
// Package update implements self-update from the Gitea generic package
|
||||
// registry. It has no UI imports; the network is only used on explicit calls.
|
||||
package update
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultBaseURL is the registry root; see docs/architecture.md#distribution.
|
||||
const DefaultBaseURL = "https://git.simon-franken.de/api/packages/simonfranken/generic"
|
||||
|
||||
// EnvBaseURL overrides the registry root (tests, manual verification).
|
||||
const EnvBaseURL = "SUBMARINE_UPDATE_URL"
|
||||
|
||||
// maxBinary bounds a download so a bad server cannot fill the disk.
|
||||
const maxBinary = 256 << 20
|
||||
|
||||
// Client talks to the registry.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTP *http.Client
|
||||
GOOS string
|
||||
GOARCH string
|
||||
}
|
||||
|
||||
// New returns a client for the default (or $SUBMARINE_UPDATE_URL) registry.
|
||||
func New() *Client {
|
||||
base := os.Getenv(EnvBaseURL)
|
||||
if base == "" {
|
||||
base = DefaultBaseURL
|
||||
}
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(base, "/"),
|
||||
HTTP: &http.Client{Timeout: 5 * time.Minute},
|
||||
GOOS: runtime.GOOS,
|
||||
GOARCH: runtime.GOARCH,
|
||||
}
|
||||
}
|
||||
|
||||
// Latest returns the newest released version, e.g. "v0.2.0".
|
||||
func (c *Client) Latest(ctx context.Context) (string, error) {
|
||||
b, err := c.get(ctx, c.BaseURL+"/submarine-latest/latest/VERSION", 1<<10)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
v := strings.TrimSpace(string(b))
|
||||
if _, ok := parse(v); !ok {
|
||||
return "", fmt.Errorf("registry returned an invalid version %q", v)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Newer reports whether latest is a higher version than current. Builds that
|
||||
// are not release versions (e.g. "dev") are never considered up to date.
|
||||
func Newer(current, latest string) bool {
|
||||
l, ok := parse(latest)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
c, ok := parse(current)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
for i := range l {
|
||||
if l[i] != c[i] {
|
||||
return l[i] > c[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Release reports whether v is a release version (vX.Y.Z).
|
||||
func Release(v string) bool { _, ok := parse(v); return ok }
|
||||
|
||||
func parse(v string) ([3]int, bool) {
|
||||
var out [3]int
|
||||
parts := strings.Split(strings.TrimPrefix(v, "v"), ".")
|
||||
if !strings.HasPrefix(v, "v") || len(parts) != 3 {
|
||||
return out, false
|
||||
}
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n < 0 || p != strconv.Itoa(n) {
|
||||
return out, false
|
||||
}
|
||||
out[i] = n
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// Install downloads version for this platform, verifies its checksum and
|
||||
// replaces the running executable (symlinks resolved) atomically.
|
||||
func (c *Client) Install(ctx context.Context, version string) (string, error) {
|
||||
if !Release(version) {
|
||||
return "", fmt.Errorf("invalid version %q", version)
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exe, err = filepath.EvalSymlinks(exe); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return exe, c.InstallTo(ctx, version, exe)
|
||||
}
|
||||
|
||||
// InstallTo is Install with an explicit target path.
|
||||
func (c *Client) InstallTo(ctx context.Context, version, target string) error {
|
||||
asset := fmt.Sprintf("submarine_%s_%s", c.GOOS, c.GOARCH)
|
||||
base := c.BaseURL + "/submarine/" + version + "/"
|
||||
|
||||
sums, err := c.get(ctx, base+"checksums.txt", 1<<20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
want, err := checksumFor(sums, asset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Same directory as the target, so the final rename is atomic.
|
||||
tmp, err := os.CreateTemp(filepath.Dir(target), ".submarine-update-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot write next to %s (permission?): %w", target, err)
|
||||
}
|
||||
defer os.Remove(tmp.Name()) // no-op after a successful rename
|
||||
|
||||
h := sha256.New()
|
||||
if err := c.stream(ctx, base+asset, io.MultiWriter(tmp, h)); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if got := hex.EncodeToString(h.Sum(nil)); got != want {
|
||||
return fmt.Errorf("checksum mismatch for %s: got %s, want %s", asset, got, want)
|
||||
}
|
||||
if err := os.Chmod(tmp.Name(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), target)
|
||||
}
|
||||
|
||||
func checksumFor(sums []byte, asset string) (string, error) {
|
||||
sc := bufio.NewScanner(strings.NewReader(string(sums)))
|
||||
for sc.Scan() {
|
||||
f := strings.Fields(sc.Text())
|
||||
if len(f) == 2 && strings.TrimPrefix(f[1], "*") == asset {
|
||||
return strings.ToLower(f[0]), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no checksum for %s in checksums.txt", asset)
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) {
|
||||
var buf strings.Builder
|
||||
if err := c.stream(ctx, url, &limitWriter{&buf, limit}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(buf.String()), nil
|
||||
}
|
||||
|
||||
func (c *Client) stream(ctx context.Context, url string, w io.Writer) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GET %s: %s", url, resp.Status)
|
||||
}
|
||||
n, err := io.Copy(w, io.LimitReader(resp.Body, maxBinary+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > maxBinary {
|
||||
return errors.New("download too large")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type limitWriter struct {
|
||||
w io.Writer
|
||||
n int64
|
||||
}
|
||||
|
||||
func (l *limitWriter) Write(p []byte) (int, error) {
|
||||
l.n -= int64(len(p))
|
||||
if l.n < 0 {
|
||||
return 0, errors.New("response too large")
|
||||
}
|
||||
return l.w.Write(p)
|
||||
}
|
||||
|
||||
// CheckInterval is how often the passive check may hit the network.
|
||||
const CheckInterval = 24 * time.Hour
|
||||
|
||||
// LatestCached is Latest with a file cache: within CheckInterval it returns
|
||||
// the cached answer without any network access. A failed lookup is cached too,
|
||||
// so an unreachable registry is not retried on every start.
|
||||
func (c *Client) LatestCached(ctx context.Context, cacheFile string) (string, error) {
|
||||
if fi, err := os.Stat(cacheFile); err == nil && time.Since(fi.ModTime()) < CheckInterval {
|
||||
b, _ := os.ReadFile(cacheFile)
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
}
|
||||
v, err := c.Latest(ctx)
|
||||
_ = os.MkdirAll(filepath.Dir(cacheFile), 0o755)
|
||||
_ = os.WriteFile(cacheFile, []byte(v), 0o644) // v is "" on failure
|
||||
return v, err
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewer(t *testing.T) {
|
||||
cases := []struct {
|
||||
cur, latest string
|
||||
want bool
|
||||
}{
|
||||
{"v0.1.0", "v0.2.0", true},
|
||||
{"v0.2.0", "v0.2.0", false},
|
||||
{"v0.10.0", "v0.9.0", false},
|
||||
{"v1.0.0", "v0.9.9", false},
|
||||
{"dev", "v0.1.0", true},
|
||||
{"v0.1.0-3-gabc", "v0.1.0", true},
|
||||
{"v0.1.0", "garbage", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Newer(c.cur, c.latest); got != c.want {
|
||||
t.Errorf("Newer(%q, %q) = %v, want %v", c.cur, c.latest, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func registry(t *testing.T, bin, sum string) *Client {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/submarine-latest/latest/VERSION", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Write([]byte("v0.2.0\n"))
|
||||
})
|
||||
mux.HandleFunc("/submarine/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Write([]byte(sum + " submarine_linux_arm64\n"))
|
||||
})
|
||||
mux.HandleFunc("/submarine/v0.2.0/submarine_linux_arm64", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Write([]byte(bin))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return &Client{BaseURL: srv.URL, HTTP: srv.Client(), GOOS: "linux", GOARCH: "arm64"}
|
||||
}
|
||||
|
||||
func sha(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
|
||||
|
||||
func TestLatest(t *testing.T) {
|
||||
c := registry(t, "x", sha("x"))
|
||||
v, err := c.Latest(context.Background())
|
||||
if err != nil || v != "v0.2.0" {
|
||||
t.Fatalf("Latest = %q, %v", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallReplaces(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "submarine")
|
||||
os.WriteFile(target, []byte("old"), 0o755)
|
||||
c := registry(t, "new-binary", sha("new-binary"))
|
||||
if err := c.InstallTo(context.Background(), "v0.2.0", target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := os.ReadFile(target)
|
||||
if string(got) != "new-binary" {
|
||||
t.Fatalf("target = %q", got)
|
||||
}
|
||||
if fi, _ := os.Stat(target); fi.Mode().Perm()&0o111 == 0 {
|
||||
t.Error("target not executable")
|
||||
}
|
||||
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
|
||||
t.Errorf("leftover files: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallChecksumMismatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "submarine")
|
||||
os.WriteFile(target, []byte("old"), 0o755)
|
||||
c := registry(t, "tampered", sha("original"))
|
||||
err := c.InstallTo(context.Background(), "v0.2.0", target)
|
||||
if err == nil || !strings.Contains(err.Error(), "checksum mismatch") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if got, _ := os.ReadFile(target); string(got) != "old" {
|
||||
t.Errorf("target modified: %q", got)
|
||||
}
|
||||
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
|
||||
t.Errorf("temp file left behind: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallMissingAsset(t *testing.T) {
|
||||
c := registry(t, "x", sha("x"))
|
||||
c.GOARCH = "riscv64"
|
||||
err := c.InstallTo(context.Background(), "v0.2.0", filepath.Join(t.TempDir(), "submarine"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user