An updater: what is published on Gitea, and installing it from Settings
A new selfupdate package asks one URL what the newest build is — a Gitea/Forgejo repo API base, or any directory holding VERSION, SHA256SUMS and hamlab-linux-<arch>, which is what make release already writes and what install.sh already downloads. The binary is checked against SHA256SUMS before it goes near the running one, and a token is sent when the source is behind a login, so a private Gitea serves its own fleet without opening the instance to everyone. Version ordering is install.sh's vnum rule, so the daemon and the installer never disagree about which build is newer. Settings gains an Updates card: installed, published, when it last looked, the source, release notes, Check for updates, and Install. The rail badges Settings when a newer build is out. Nothing installs unasked — it restarts the daemon and drops terminals. scripts/gitea-release.sh (make gitea-release) publishes dist/ as a Gitea release. Deliberate, and not part of deploy: deploy pushes to the machines this hub owns, publishing offers the build to every install anywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
98a8de8091
commit
68bf3bb218
@@ -0,0 +1,145 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompareMatchesInstaller(t *testing.T) {
|
||||
older := []([2]string){
|
||||
{"v0.1.0-69-g9c4f9df", "v0.1.0-107-gad88a48"},
|
||||
{"v0.1.0", "v0.1.0-1-gabc1234"},
|
||||
{"v0.1.0-107", "v0.2.0"},
|
||||
{"v0.9.9-999", "v1.0.0"},
|
||||
{"v0.1.9", "v0.1.10"},
|
||||
}
|
||||
for _, p := range older {
|
||||
if got := Compare(p[0], p[1]); got != -1 {
|
||||
t.Errorf("Compare(%q, %q) = %d, want -1", p[0], p[1], got)
|
||||
}
|
||||
if got := Compare(p[1], p[0]); got != 1 {
|
||||
t.Errorf("Compare(%q, %q) = %d, want 1", p[1], p[0], got)
|
||||
}
|
||||
}
|
||||
same := []string{"v0.1.0-107-gad88a48", "v1.2.3", "dev"}
|
||||
for _, v := range same {
|
||||
if got := Compare(v, v); got != 0 {
|
||||
t.Errorf("Compare(%q, %q) = %d, want 0", v, v, got)
|
||||
}
|
||||
}
|
||||
// the hash is ignored: the same build described two ways is the same build
|
||||
if got := Compare("v0.1.0-107-gad88a48", "v0.1.0-107-gffffff1"); got != 0 {
|
||||
t.Errorf("hash should not count: %d", got)
|
||||
}
|
||||
// "dev" is not a version; anything numbered beats it
|
||||
if got := Compare("dev", "v0.1.0"); got != -1 {
|
||||
t.Errorf("Compare(dev, v0.1.0) = %d, want -1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// serve puts a release behind both shapes of source and returns the two base URLs.
|
||||
func serve(t *testing.T, version string, body []byte, token string) (static, api string, srv *httptest.Server) {
|
||||
t.Helper()
|
||||
sum := sha256.Sum256(body)
|
||||
sums := fmt.Sprintf("%s %s\n%s install.sh\n", hex.EncodeToString(sum[:]), AssetName("amd64"), strings.Repeat("0", 64))
|
||||
mux := http.NewServeMux()
|
||||
guard := func(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if token != "" && r.Header.Get("Authorization") != "token "+token {
|
||||
w.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
}
|
||||
}
|
||||
mux.HandleFunc("/dl/VERSION", guard(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, version) }))
|
||||
mux.HandleFunc("/dl/SHA256SUMS", guard(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, sums) }))
|
||||
mux.HandleFunc("/dl/"+AssetName("amd64"), guard(func(w http.ResponseWriter, r *http.Request) { w.Write(body) }))
|
||||
mux.HandleFunc("/api/v1/repos/m/hamlab/releases/latest", guard(func(w http.ResponseWriter, r *http.Request) {
|
||||
base := "http://" + r.Host + "/dl/"
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"tag_name": version, "body": "notes here",
|
||||
"assets": []map[string]string{
|
||||
{"name": AssetName("amd64"), "browser_download_url": base + AssetName("amd64")},
|
||||
{"name": "SHA256SUMS", "browser_download_url": base + "SHA256SUMS"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
srv = httptest.NewServer(mux)
|
||||
return srv.URL + "/dl", srv.URL + "/api/v1/repos/m/hamlab", srv
|
||||
}
|
||||
|
||||
func TestLatestAndFetch(t *testing.T) {
|
||||
body := make([]byte, 2<<20)
|
||||
copy(body, "ELF fake hamlab")
|
||||
static, api, srv := serve(t, "v0.9.0-3-gdeadbee", body, "")
|
||||
defer srv.Close()
|
||||
|
||||
for _, url := range []string{static, api} {
|
||||
s := Source{URL: url}
|
||||
rel, err := s.Latest(context.Background(), "amd64")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", url, err)
|
||||
}
|
||||
if rel.Version != "v0.9.0-3-gdeadbee" {
|
||||
t.Fatalf("%s: version %q", url, rel.Version)
|
||||
}
|
||||
dest := filepath.Join(t.TempDir(), "hamlab.new")
|
||||
if err := s.Fetch(context.Background(), rel, "amd64", dest); err != nil {
|
||||
t.Fatalf("%s: fetch: %v", url, err)
|
||||
}
|
||||
got, _ := os.ReadFile(dest)
|
||||
if len(got) != len(body) {
|
||||
t.Fatalf("%s: got %d bytes, want %d", url, len(got), len(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRefusesBadChecksum(t *testing.T) {
|
||||
body := make([]byte, 2<<20)
|
||||
static, _, srv := serve(t, "v1.0.0", body, "")
|
||||
defer srv.Close()
|
||||
s := Source{URL: static}
|
||||
rel, err := s.Latest(context.Background(), "amd64")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rel.Sums = static + "/VERSION" // sums that name nothing
|
||||
dest := filepath.Join(t.TempDir(), "hamlab.new")
|
||||
if err := s.Fetch(context.Background(), rel, "amd64", dest); err == nil {
|
||||
t.Fatal("a release whose sums do not name the binary must be refused")
|
||||
}
|
||||
if _, err := os.Stat(dest); !os.IsNotExist(err) {
|
||||
t.Fatal("a refused download must not be left on disk")
|
||||
}
|
||||
// and an arch nobody published
|
||||
if _, err := s.Latest(context.Background(), "riscv64"); err == nil {
|
||||
t.Log("static sources cannot know an arch is missing until the fetch; that is fine")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenIsSentAndMissingTokenSaysSo(t *testing.T) {
|
||||
body := make([]byte, 2<<20)
|
||||
_, api, srv := serve(t, "v1.0.0", body, "s3cret")
|
||||
defer srv.Close()
|
||||
if _, err := (Source{URL: api}).Latest(context.Background(), "amd64"); err == nil || !strings.Contains(err.Error(), "update.token") {
|
||||
t.Fatalf("a walled source should say a token is needed, got %v", err)
|
||||
}
|
||||
rel, err := (Source{URL: api, Token: "s3cret"}).Latest(context.Background(), "amd64")
|
||||
if err != nil || rel.Version != "v1.0.0" {
|
||||
t.Fatalf("with the token: %v %+v", err, rel)
|
||||
}
|
||||
dest := filepath.Join(t.TempDir(), "hamlab.new")
|
||||
if err := (Source{URL: api, Token: "s3cret"}).Fetch(context.Background(), rel, "amd64", dest); err != nil {
|
||||
t.Fatalf("fetch with token: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user