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,8 @@
|
||||
# Copy to .env (gitignored); the Makefile loads it automatically.
|
||||
|
||||
# Access token with write:package scope. Required for `make release`.
|
||||
GITEA_TOKEN=
|
||||
|
||||
# Optional overrides (defaults shown).
|
||||
# GITEA_URL=https://git.simon-franken.de
|
||||
# GITEA_OWNER=simonfranken
|
||||
@@ -22,6 +22,8 @@ go run ./cmd/submarine [dir] # run on a repository (default: cwd)
|
||||
go run ./cmd/submarine --mock # run on mock data
|
||||
go run ./cmd/submarine --no-fetch [dir] # without background fetch
|
||||
scripts/make-fixture.sh DIR # build a test superproject in DIR
|
||||
make dist # cross-compile into dist/
|
||||
GITEA_TOKEN=… make release # publish a tagged release (outward-facing)
|
||||
go vet ./...
|
||||
go test ./...
|
||||
gofmt -l . # must print nothing
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Build and publish submarine. Releases are built locally and uploaded to the
|
||||
# Gitea generic package registry (see docs/architecture.md#distribution).
|
||||
#
|
||||
# make dist cross-compile into dist/
|
||||
# GITEA_TOKEN=... make release test, build and publish the tagged HEAD
|
||||
|
||||
# Settings from .env (see .env.template); `make VAR=...` on the command line wins.
|
||||
-include .env
|
||||
export GITEA_TOKEN
|
||||
|
||||
GITEA_URL ?= https://git.simon-franken.de
|
||||
GITEA_OWNER ?= simonfranken
|
||||
REGISTRY := $(GITEA_URL)/api/packages/$(GITEA_OWNER)/generic
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
PLATFORMS := linux_amd64 linux_arm64 darwin_amd64 darwin_arm64
|
||||
BINARIES := $(addprefix dist/submarine_,$(PLATFORMS))
|
||||
|
||||
.PHONY: build test vet dist release clean FORCE
|
||||
|
||||
build:
|
||||
go build -ldflags '$(LDFLAGS)' -o submarine ./cmd/submarine
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
@test -z "$$(gofmt -l .)" || { gofmt -l .; echo "gofmt: files need formatting"; exit 1; }
|
||||
|
||||
dist: $(BINARIES) dist/checksums.txt
|
||||
|
||||
# FORCE: always rebuild, since the version is baked in.
|
||||
dist/submarine_%: FORCE
|
||||
@mkdir -p dist
|
||||
CGO_ENABLED=0 GOOS=$(word 1,$(subst _, ,$*)) GOARCH=$(word 2,$(subst _, ,$*)) \
|
||||
go build -trimpath -ldflags '$(LDFLAGS)' -o $@ ./cmd/submarine
|
||||
|
||||
# Rebuilt every time so it always matches the binaries.
|
||||
dist/checksums.txt: $(BINARIES)
|
||||
cd dist && sha256sum $(notdir $(BINARIES)) > checksums.txt
|
||||
|
||||
release:
|
||||
@test -n "$$GITEA_TOKEN" || { echo "GITEA_TOKEN is not set (needs write:package)"; exit 1; }
|
||||
@echo '$(VERSION)' | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$$' || \
|
||||
{ echo "VERSION '$(VERSION)' is not a clean tag: tag HEAD as vX.Y.Z with a clean tree"; exit 1; }
|
||||
@test -z "$$(git status --porcelain)" || { echo "working tree is not clean"; exit 1; }
|
||||
$(MAKE) clean test vet dist VERSION=$(VERSION)
|
||||
@set -e; \
|
||||
for f in $(BINARIES) dist/checksums.txt; do \
|
||||
echo "upload $$(basename $$f)"; \
|
||||
curl -fsS -X PUT -H "Authorization: token $$GITEA_TOKEN" \
|
||||
--upload-file $$f "$(REGISTRY)/submarine/$(VERSION)/$$(basename $$f)" >/dev/null; \
|
||||
done; \
|
||||
echo "point latest at $(VERSION)"; \
|
||||
printf '%s\n' '$(VERSION)' > dist/VERSION; \
|
||||
curl -sS -X DELETE -H "Authorization: token $$GITEA_TOKEN" \
|
||||
"$(GITEA_URL)/api/v1/packages/$(GITEA_OWNER)/generic/submarine-latest/latest" >/dev/null || true; \
|
||||
curl -fsS -X PUT -H "Authorization: token $$GITEA_TOKEN" \
|
||||
--upload-file dist/VERSION "$(REGISTRY)/submarine-latest/latest/VERSION" >/dev/null; \
|
||||
echo "released $(VERSION)"
|
||||
|
||||
clean:
|
||||
rm -rf dist submarine
|
||||
+83
-1
@@ -6,6 +6,8 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@@ -15,13 +17,27 @@ import (
|
||||
"submarine/internal/submodule/git"
|
||||
"submarine/internal/submodule/mock"
|
||||
"submarine/internal/tui"
|
||||
"submarine/internal/update"
|
||||
)
|
||||
|
||||
// version is set at build time (-ldflags "-X main.version=v1.2.3").
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "update":
|
||||
os.Exit(runUpdate(os.Args[2:]))
|
||||
case "version", "--version", "-version":
|
||||
fmt.Println("submarine", version)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
useMock := flag.Bool("mock", false, "use built-in mock data instead of a real repository")
|
||||
noFetch := flag.Bool("no-fetch", false, "don't fetch remotes in the background on startup")
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [--no-fetch] [dir]")
|
||||
fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [--no-fetch] [dir]\n submarine update [--check] [--force]\n submarine version")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
flag.Parse()
|
||||
@@ -56,9 +72,75 @@ func main() {
|
||||
zone.NewGlobal()
|
||||
defer zone.Close()
|
||||
|
||||
notice := checkForUpdate(ctx, *noFetch || *useMock)
|
||||
|
||||
p := tea.NewProgram(tui.New(ctx, provider, tui.Options{Fetch: !*noFetch}), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
select {
|
||||
case v := <-notice:
|
||||
if v != "" {
|
||||
fmt.Fprintf(os.Stderr, "submarine %s is available (you have %s), run: submarine update\n", v, version)
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// checkForUpdate looks for a newer release in the background (at most once a
|
||||
// day, cached) and delivers its version, or "" when there is none. Disabled
|
||||
// for dev builds, --no-fetch, --mock and SUBMARINE_NO_UPDATE_CHECK.
|
||||
func checkForUpdate(ctx context.Context, off bool) <-chan string {
|
||||
out := make(chan string, 1)
|
||||
cache, err := os.UserCacheDir()
|
||||
if off || err != nil || !update.Release(version) || os.Getenv("SUBMARINE_NO_UPDATE_CHECK") != "" {
|
||||
out <- ""
|
||||
return out
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
v, err := update.New().LatestCached(ctx, filepath.Join(cache, "submarine", "latest-version"))
|
||||
if err != nil || !update.Newer(version, v) {
|
||||
v = ""
|
||||
}
|
||||
out <- v
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
func runUpdate(args []string) int {
|
||||
fs := flag.NewFlagSet("update", flag.ExitOnError)
|
||||
check := fs.Bool("check", false, "only report whether a newer version exists")
|
||||
force := fs.Bool("force", false, "install the latest version even if not newer (also for dev builds)")
|
||||
fs.Parse(args)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
c := update.New()
|
||||
latest, err := c.Latest(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "submarine: update check failed:", err)
|
||||
return 1
|
||||
}
|
||||
if !*force && !update.Newer(version, latest) {
|
||||
fmt.Printf("submarine %s is up to date\n", version)
|
||||
return 0
|
||||
}
|
||||
if *check {
|
||||
fmt.Printf("update available: %s (you have %s)\n", latest, version)
|
||||
return 0
|
||||
}
|
||||
if !update.Release(version) && !*force {
|
||||
fmt.Fprintf(os.Stderr, "submarine: this is a %q build; use --force to replace it with %s\n", version, latest)
|
||||
return 1
|
||||
}
|
||||
exe, err := c.Install(ctx, latest)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "submarine: update failed:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("updated %s: %s -> %s\n", exe, version, latest)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -15,6 +15,16 @@ bare commit hash.
|
||||
| [architecture.md](architecture.md) | Package layout, the provider boundary, async loading, testing |
|
||||
| [decisions.md](decisions.md) | Decision log with rationale (ADR style) |
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
curl -fsSL https://git.simon-franken.de/simonfranken/submarine/raw/branch/main/install.sh | sh
|
||||
```
|
||||
|
||||
Installs `submarine` and the alias `sub` (linux/macOS, amd64/arm64) to
|
||||
`~/.local/bin`. Update with `submarine update`. Maintainers publish with
|
||||
`GITEA_TOKEN=… make release` (see [architecture](architecture.md#distribution)).
|
||||
|
||||
## Status
|
||||
|
||||
**Prototype.** The UI is complete for browsing. It reads real repositories
|
||||
|
||||
@@ -21,6 +21,7 @@ internal/submodule/git/ provider using the git CLI (reads, fetch, watch)
|
||||
watch.go Watch: polls git metadata for changes
|
||||
gitdir.go resolves .git dirs and gitdir: files
|
||||
internal/submodule/mock/ fixture provider with artificial latency
|
||||
internal/update/ self-update from the package registry; NO UI imports
|
||||
internal/tui/ Bubble Tea application
|
||||
app.go root model: layout, message routing, loading
|
||||
keys.go key bindings (also feed the help view)
|
||||
@@ -167,6 +168,32 @@ Fetch (network) ──writes refs──▶ .git/… ◀──writes refs──
|
||||
`ansi.Truncate`, which keeps escape sequences and bubblezone markers intact.
|
||||
- `zone.Scan` runs once, on the root view.
|
||||
|
||||
## Distribution
|
||||
|
||||
No CI runner: releases are built and published from a developer machine.
|
||||
|
||||
- `make dist` cross-compiles `CGO_ENABLED=0` binaries for linux/darwin ×
|
||||
amd64/arm64 into `dist/` plus `checksums.txt`; the version comes from
|
||||
`git describe` and is baked in with `-X main.version`.
|
||||
- `make release` with `GITEA_TOKEN` set, e.g. in `.env` (copy `.env.template`; token needs `write:package`; HEAD must be a
|
||||
clean `vX.Y.Z` tag) runs tests, builds and uploads to the Gitea generic
|
||||
registry `https://git.simon-franken.de/api/packages/simonfranken/generic`:
|
||||
- `submarine/<version>/submarine_<os>_<arch>` and `checksums.txt`
|
||||
- `submarine-latest/latest/VERSION`, replaced **last**, so a failed upload
|
||||
never advertises a broken version.
|
||||
- `install.sh` (`curl -fsSL …/raw/branch/main/install.sh | sh`) detects
|
||||
OS/arch, verifies the sha256, installs `submarine` and a `sub` symlink to
|
||||
`~/.local/bin` (`SUBMARINE_INSTALL_DIR`, `SUBMARINE_VERSION`,
|
||||
`SUBMARINE_BASE_URL` override).
|
||||
- `submarine update [--check] [--force]` (package `internal/update`) reads
|
||||
`VERSION`, downloads the asset for the running platform, verifies the
|
||||
checksum and atomically replaces the real executable (symlinks resolved,
|
||||
so `sub` works too). `SUBMARINE_UPDATE_URL` overrides the registry.
|
||||
- After the TUI exits, a one-line notice is printed if a newer release
|
||||
exists. The check runs in the background at startup, at most once a day
|
||||
(cached in the user cache dir) and is off for dev builds, `--no-fetch`,
|
||||
`--mock` and `SUBMARINE_NO_UPDATE_CHECK=1`.
|
||||
|
||||
## Testing
|
||||
|
||||
```sh
|
||||
|
||||
@@ -183,3 +183,18 @@ once per submodule at a time and coalesce bursts.
|
||||
**Consequences.** One mechanism to test and trust. After our fetch the
|
||||
data updates up to one poll interval later; the header shows "fetched just
|
||||
now" right away.
|
||||
|
||||
## D21 — Distribution: local build, Gitea generic registry, curl | sh
|
||||
|
||||
**Decision.** Releases are built locally with `make` and uploaded with an
|
||||
access token to the Gitea generic package registry. Installation is a
|
||||
POSIX `install.sh`; `sub` is a symlink to `submarine`. Updates are
|
||||
`submarine update`, driven by a `latest/VERSION` pointer file.
|
||||
**Rationale.** The Gitea instance has no working runner. The generic
|
||||
registry needs no extra hosting and is publicly readable. A symlink keeps
|
||||
one binary to update. The pointer is uploaded last and lives in its own
|
||||
package so versions stay clean.
|
||||
**Consequences.** Integrity is a sha256 from the same host: it catches
|
||||
corrupt or partial downloads, not a compromised registry (no signatures).
|
||||
The update check is a deliberate, outside-the-provider network use: only
|
||||
on `update` and a once-a-day background check that can be disabled.
|
||||
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/bin/sh
|
||||
# Install submarine (and the `sub` alias).
|
||||
# curl -fsSL https://git.simon-franken.de/simonfranken/submarine/raw/branch/main/install.sh | sh
|
||||
#
|
||||
# Environment:
|
||||
# SUBMARINE_VERSION install this version (default: latest)
|
||||
# SUBMARINE_INSTALL_DIR target directory (default: ~/.local/bin)
|
||||
# SUBMARINE_BASE_URL registry root (default: the Gitea generic registry)
|
||||
set -eu
|
||||
|
||||
base="${SUBMARINE_BASE_URL:-https://git.simon-franken.de/api/packages/simonfranken/generic}"
|
||||
dir="${SUBMARINE_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
die() { echo "install: $*" >&2; exit 1; }
|
||||
|
||||
case "$(uname -s)" in
|
||||
Linux) os=linux ;;
|
||||
Darwin) os=darwin ;;
|
||||
*) die "unsupported OS: $(uname -s) (linux and macOS only)" ;;
|
||||
esac
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) arch=amd64 ;;
|
||||
arm64|aarch64) arch=arm64 ;;
|
||||
*) die "unsupported architecture: $(uname -m)" ;;
|
||||
esac
|
||||
|
||||
command -v curl >/dev/null 2>&1 || die "curl is required"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha() { sha256sum "$1" | cut -d' ' -f1; }
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
sha() { shasum -a 256 "$1" | cut -d' ' -f1; }
|
||||
else
|
||||
die "sha256sum or shasum is required"
|
||||
fi
|
||||
|
||||
version="${SUBMARINE_VERSION:-}"
|
||||
if [ -z "$version" ]; then
|
||||
version="$(curl -fsSL "$base/submarine-latest/latest/VERSION")" || die "cannot determine the latest version"
|
||||
version="$(printf '%s' "$version" | tr -d '[:space:]')"
|
||||
fi
|
||||
case "$version" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) die "invalid version: $version" ;;
|
||||
esac
|
||||
|
||||
asset="submarine_${os}_${arch}"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||
|
||||
echo "Installing submarine $version ($os/$arch) to $dir"
|
||||
curl -fsSL -o "$tmp/$asset" "$base/submarine/$version/$asset" || die "download failed: $asset"
|
||||
curl -fsSL -o "$tmp/checksums.txt" "$base/submarine/$version/checksums.txt" || die "download failed: checksums.txt"
|
||||
|
||||
want="$(awk -v a="$asset" '$2 == a || $2 == "*" a { print $1 }' "$tmp/checksums.txt")"
|
||||
[ -n "$want" ] || die "no checksum for $asset"
|
||||
[ "$(sha "$tmp/$asset")" = "$want" ] || die "checksum mismatch for $asset"
|
||||
|
||||
mkdir -p "$dir"
|
||||
chmod +x "$tmp/$asset"
|
||||
# Stage next to the target, then rename: atomic, and safe if it is running.
|
||||
cp "$tmp/$asset" "$dir/.submarine.new"
|
||||
mv -f "$dir/.submarine.new" "$dir/submarine"
|
||||
|
||||
if [ -e "$dir/sub" ] && [ "$(readlink "$dir/sub" 2>/dev/null)" != "submarine" ]; then
|
||||
echo "install: $dir/sub exists and is not ours, leaving it alone (use \`submarine\`)" >&2
|
||||
else
|
||||
ln -sf submarine "$dir/sub"
|
||||
fi
|
||||
|
||||
echo "Installed: $dir/submarine (alias: sub)"
|
||||
case ":$PATH:" in
|
||||
*":$dir:"*) ;;
|
||||
*) echo "Note: $dir is not on your PATH. Add it, e.g.: export PATH=\"$dir:\$PATH\"" ;;
|
||||
esac
|
||||
echo "Update later with: submarine update"
|
||||
@@ -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