server: linux/arm64 through the release pipeline (#431)
* server: linux/arm64 through the release pipeline Self-hosters on ARM (cheap VPSes, Raspberry Pi) had no binary and no auto-updates — the pipeline was amd64-only. Backward-compatible: the flat url/sha256 manifest fields stay amd64 (every fleet updater already reads them); arm64 rides in additive optional fields old binaries ignore (verified against the pre-arm64 release binary). - release.go: releaseEntry arm64_url/arm64_sha256/arm64_size + forArch(); validateManifest applies the same https/hex rules when they're present - updater.go: downloads resolve via forArch(runtime.GOARCH) — an arm64 box facing a pre-arm64 release skips it (never installs the wrong arch) - build-linux.sh: ARCH env (default amd64 unchanged) - release-server.sh: publish builds+uploads both arches and writes both into the edge entry; promote carries or reconstructs the arm64 fields; promoting to STABLE also clobbers latest-stable binaries onto the rolling 'server' release (fixed JSON-free URLs for the upcoming installer), and publish uploads deploy/install.sh to the rolling release when present Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * server: pin updaterArch in the test feed fixture (deterministic on arm64 dev machines) ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+228
−20
deploy/build-linux.sh+7 −5deploy/release-server.sh+39 −3release.go+33 −4release_test.go+43 −0updater.go+16 −5updater_test.go+90 −3
diff --git a/deploy/build-linux.sh b/deploy/build-linux.sh| index 7d0f4ff..7bf40e5 100755 |
| --- a/deploy/build-linux.sh |
| +++ b/deploy/build-linux.sh |
| @@ -1,10 +1,11 @@ |
| #!/usr/bin/env sh |
| -# Build the linux/amd64 lchat binary for the router box, stamped with a build id |
| +# Build the linux lchat binary for the router box, stamped with a build id |
| # (the git short sha, or $1) so `lchat stage`/`deploy --version` can identify and |
| # health-check it. Prints the build id and the output path. |
| # |
| -# ./deploy/build-linux.sh # id = git short sha |
| +# ./deploy/build-linux.sh # amd64, id = git short sha |
| # ./deploy/build-linux.sh test-scroll # id = test-scroll (e.g. a canary label) |
| +# ARCH=arm64 ./deploy/build-linux.sh # linux/arm64 (self-hosters on ARM) |
| # OUT=/tmp/lchat ./deploy/build-linux.sh |
| set -eu |
| @@ -13,9 +14,10 @@ if [ -z "$VER" ]; then |
| echo "build-linux: could not determine a build id (pass one as \$1)" >&2 |
| exit 1 |
| fi |
| -OUT="${OUT:-lchat-linux-amd64}" |
| +ARCH="${ARCH:-amd64}" |
| +OUT="${OUT:-lchat-linux-$ARCH}" |
| -GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \ |
| +GOOS=linux GOARCH="$ARCH" CGO_ENABLED=0 \ |
| go build -ldflags "-X main.buildVersion=${VER}" -o "$OUT" . |
| -echo "built ${VER} -> ${OUT}" |
| +echo "built ${VER} (${ARCH}) -> ${OUT}" |
diff --git a/deploy/release-server.sh b/deploy/release-server.sh| index a5bea62..bcc55d6 100755 |
| --- a/deploy/release-server.sh |
| +++ b/deploy/release-server.sh |
| @@ -18,6 +18,7 @@ set -eu |
| REPO="${LCHAT_RELEASES_REPO:-elevenmessenger/releases}" |
| KEY="${LCHAT_RELEASE_KEY_FILE:-}" |
| BIN=lchat-linux-amd64 |
| +BIN_ARM=lchat-linux-arm64 |
| [ -n "$KEY" ] && [ -f "$KEY" ] || { |
| echo "release-server: set LCHAT_RELEASE_KEY_FILE to the signing key file" >&2; exit 1; } |
| @@ -60,23 +61,35 @@ publish) |
| NOTES="${*:-$(git log -1 --pretty=%s)}" |
| OUT="$TMP/$BIN" ./deploy/build-linux.sh "$VER" |
| + ARCH=arm64 OUT="$TMP/$BIN_ARM" ./deploy/build-linux.sh "$VER" |
| SHA="$(shasum -a 256 "$TMP/$BIN" | cut -d' ' -f1)" |
| SIZE="$(wc -c < "$TMP/$BIN" | tr -d ' ')" |
| + SHA_ARM="$(shasum -a 256 "$TMP/$BIN_ARM" | cut -d' ' -f1)" |
| + SIZE_ARM="$(wc -c < "$TMP/$BIN_ARM" | tr -d ' ')" |
| echo "$SHA $BIN" > "$TMP/$BIN.sha256" |
| + echo "$SHA_ARM $BIN_ARM" > "$TMP/$BIN_ARM.sha256" |
| if gh release view "server-$VER" -R "$REPO" >/dev/null 2>&1; then |
| echo "release-server: server-$VER already exists on $REPO (promote it, or land a new commit)" >&2; exit 1 |
| fi |
| gh release create "server-$VER" -R "$REPO" --title "Server $VER" --notes "$NOTES" \ |
| - "$TMP/$BIN" "$TMP/$BIN.sha256" |
| + "$TMP/$BIN" "$TMP/$BIN.sha256" "$TMP/$BIN_ARM" "$TMP/$BIN_ARM.sha256" |
| fetch_payload | jq \ |
| --arg ver "$VER" --arg sha "$SHA" --argjson size "$SIZE" --arg notes "$NOTES" \ |
| + --arg sha_arm "$SHA_ARM" --argjson size_arm "$SIZE_ARM" \ |
| --arg url "https://github.com/$REPO/releases/download/server-$VER/$BIN" \ |
| + --arg url_arm "https://github.com/$REPO/releases/download/server-$VER/$BIN_ARM" \ |
| --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ |
| - '.channels.edge = {version:$ver, url:$url, sha256:$sha, size:$size, notes:$notes, published_at:$now}' \ |
| + '.channels.edge = {version:$ver, url:$url, sha256:$sha, size:$size, |
| + arm64_url:$url_arm, arm64_sha256:$sha_arm, arm64_size:$size_arm, |
| + notes:$notes, published_at:$now}' \ |
| > "$TMP/payload.json" |
| sign_and_upload "$TMP/payload.json" |
| + # The installer rides the rolling release too, so its URL is always current. |
| + if [ -f deploy/install.sh ]; then |
| + gh release upload server deploy/install.sh -R "$REPO" --clobber |
| + fi |
| echo "published server-$VER and pointed edge at it" |
| ;; |
| @@ -87,7 +100,9 @@ promote) |
| fetch_payload > "$TMP/payload.json" |
| # Prefer the entry already on a channel (usually promoting edge -> beta -> |
| - # stable); otherwise rebuild it from the server-<ver> release's own assets. |
| + # stable); otherwise rebuild it from the server-<ver> release's own assets |
| + # (arm64 fields only when that release has an arm64 build — pre-arm64 |
| + # releases stay amd64-only). |
| ENTRY="$(jq --arg ver "$VER" '[.channels[] | select(.version == $ver)][0] // empty' "$TMP/payload.json")" |
| if [ -z "$ENTRY" ]; then |
| gh release download "server-$VER" -R "$REPO" -p "$BIN.sha256" -O "$TMP/$BIN.sha256" || { |
| @@ -100,10 +115,31 @@ promote) |
| --arg url "https://github.com/$REPO/releases/download/server-$VER/$BIN" \ |
| --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ |
| '{version:$ver, url:$url, sha256:$sha, size:$size, notes:$notes, published_at:$now}')" |
| + if gh release download "server-$VER" -R "$REPO" -p "$BIN_ARM.sha256" -O "$TMP/$BIN_ARM.sha256" 2>/dev/null; then |
| + SHA_ARM="$(cut -d' ' -f1 < "$TMP/$BIN_ARM.sha256")" |
| + SIZE_ARM="$(gh release view "server-$VER" -R "$REPO" --json assets \ |
| + --jq ".assets[] | select(.name == \"$BIN_ARM\") | .size")" |
| + ENTRY="$(printf '%s' "$ENTRY" | jq --arg sha "$SHA_ARM" --argjson size "${SIZE_ARM:-0}" \ |
| + --arg url "https://github.com/$REPO/releases/download/server-$VER/$BIN_ARM" \ |
| + '. + {arm64_url:$url, arm64_sha256:$sha, arm64_size:$size}')" |
| + fi |
| fi |
| jq --arg chan "$CHAN" --argjson entry "$ENTRY" '.channels[$chan] = $entry' \ |
| "$TMP/payload.json" > "$TMP/payload2.json" |
| sign_and_upload "$TMP/payload2.json" |
| + |
| + # Stable also refreshes the rolling release's "latest stable" binaries — the |
| + # fixed, JSON-free URLs deploy/install.sh downloads. |
| + if [ "$CHAN" = stable ]; then |
| + mkdir -p "$TMP/stable" |
| + for A in "$BIN" "$BIN.sha256" "$BIN_ARM" "$BIN_ARM.sha256"; do |
| + if gh release download "server-$VER" -R "$REPO" -p "$A" -O "$TMP/stable/$A" 2>/dev/null; then |
| + gh release upload server "$TMP/stable/$A" -R "$REPO" --clobber |
| + else |
| + echo "note: server-$VER has no $A — rolling copy not refreshed" >&2 |
| + fi |
| + done |
| + fi |
| echo "promoted $VER to $CHAN" |
| ;; |
diff --git a/release.go b/release.go| index 208627d..581be52 100644 |
| --- a/release.go |
| +++ b/release.go |
| @@ -62,14 +62,34 @@ var releaseManifestURL = envOr("LCHAT_UPDATE_MANIFEST_URL", |
| "https://github.com/elevenmessenger/releases/releases/download/server/manifest.json") |
| type releaseEntry struct { |
| - Version string `json:"version"` |
| - URL string `json:"url"` |
| - SHA256 string `json:"sha256"` |
| - Size int64 `json:"size,omitempty"` |
| + Version string `json:"version"` |
| + // The flat fields are the amd64 binary — kept flat (not per-arch keyed) so |
| + // manifests stay readable by every updater already in the fleet. |
| + URL string `json:"url"` |
| + SHA256 string `json:"sha256"` |
| + Size int64 `json:"size,omitempty"` |
| + // Optional arm64 build of the same version; absent on releases published |
| + // before arm64 support (an arm64 box then skips the version, never |
| + // installs the wrong arch). |
| + ARM64URL string `json:"arm64_url,omitempty"` |
| + ARM64SHA256 string `json:"arm64_sha256,omitempty"` |
| + ARM64Size int64 `json:"arm64_size,omitempty"` |
| Notes string `json:"notes,omitempty"` |
| PublishedAt string `json:"published_at,omitempty"` |
| } |
| +// forArch resolves the binary for a GOARCH: amd64 is the flat fields, arm64 |
| +// the optional ones (ok=false when a release has no build for that arch). |
| +func (e releaseEntry) forArch(goarch string) (url, sha string, ok bool) { |
| + switch goarch { |
| + case "amd64": |
| + return e.URL, e.SHA256, true |
| + case "arm64": |
| + return e.ARM64URL, e.ARM64SHA256, e.ARM64URL != "" |
| + } |
| + return "", "", false |
| +} |
| + |
| type releaseManifest struct { |
| Schema int `json:"schema"` |
| GeneratedAt time.Time `json:"generated_at"` |
| @@ -114,6 +134,15 @@ func validateManifest(m *releaseManifest) error { |
| if raw, err := hex.DecodeString(e.SHA256); err != nil || len(raw) != sha256.Size { |
| return fmt.Errorf("channel %s: sha256 must be %d hex chars", ch, sha256.Size*2) |
| } |
| + // The arm64 build is optional, but when present it obeys the same rules. |
| + if e.ARM64URL != "" || e.ARM64SHA256 != "" { |
| + if !strings.HasPrefix(e.ARM64URL, "https://") && !isLoopbackURL(e.ARM64URL) { |
| + return fmt.Errorf("channel %s: arm64 URL must be https (%q)", ch, e.ARM64URL) |
| + } |
| + if raw, err := hex.DecodeString(e.ARM64SHA256); err != nil || len(raw) != sha256.Size { |
| + return fmt.Errorf("channel %s: arm64 sha256 must be %d hex chars", ch, sha256.Size*2) |
| + } |
| + } |
| } |
| return nil |
| } |
diff --git a/release_test.go b/release_test.go| index b7d5e3c..b2e249a 100644 |
| --- a/release_test.go |
| +++ b/release_test.go |
| @@ -110,6 +110,49 @@ func TestValidateManifestRejectsBadEntries(t *testing.T) { |
| } |
| } |
| +func TestReleaseEntryForArch(t *testing.T) { |
| + both := releaseEntry{ |
| + URL: "https://x/amd64", SHA256: strings.Repeat("aa", 32), |
| + ARM64URL: "https://x/arm64", ARM64SHA256: strings.Repeat("bb", 32), |
| + } |
| + if u, s, ok := both.forArch("amd64"); !ok || u != "https://x/amd64" || s != strings.Repeat("aa", 32) { |
| + t.Fatalf("amd64 = %q %q %v", u, s, ok) |
| + } |
| + if u, s, ok := both.forArch("arm64"); !ok || u != "https://x/arm64" || s != strings.Repeat("bb", 32) { |
| + t.Fatalf("arm64 = %q %q %v", u, s, ok) |
| + } |
| + // A pre-arm64 entry: amd64 resolves, arm64 says "no build". |
| + amdOnly := releaseEntry{URL: "https://x/amd64", SHA256: strings.Repeat("aa", 32)} |
| + if _, _, ok := amdOnly.forArch("arm64"); ok { |
| + t.Fatal("arm64 resolved on an amd64-only entry") |
| + } |
| + if _, _, ok := amdOnly.forArch("riscv64"); ok { |
| + t.Fatal("unknown arch resolved") |
| + } |
| +} |
| + |
| +func TestValidateManifestChecksARM64Fields(t *testing.T) { |
| + m := testManifest() |
| + e := m.Channels["edge"] |
| + e.ARM64URL = "http://insecure.example/lchat" |
| + e.ARM64SHA256 = strings.Repeat("cd", 32) |
| + m.Channels["edge"] = e |
| + if err := validateManifest(m); err == nil { |
| + t.Fatal("http arm64 URL accepted") |
| + } |
| + e.ARM64URL = "https://ok.example/lchat" |
| + e.ARM64SHA256 = "short" |
| + m.Channels["edge"] = e |
| + if err := validateManifest(m); err == nil { |
| + t.Fatal("bad arm64 sha accepted") |
| + } |
| + e.ARM64SHA256 = strings.Repeat("cd", 32) |
| + m.Channels["edge"] = e |
| + if err := validateManifest(m); err != nil { |
| + t.Fatalf("good arm64 entry rejected: %v", err) |
| + } |
| +} |
| + |
| func TestReadReleaseKeyRoundTrip(t *testing.T) { |
| pub, priv, _ := ed25519.GenerateKey(rand.Reader) |
| path := filepath.Join(t.TempDir(), "key.b64") |
diff --git a/updater.go b/updater.go| index 8bcc39a..c15766b 100644 |
| --- a/updater.go |
| +++ b/updater.go |
| @@ -11,6 +11,7 @@ import ( |
| "net/http" |
| "os" |
| "path/filepath" |
| + "runtime" |
| "strings" |
| "time" |
| ) |
| @@ -114,10 +115,20 @@ func fetchUpdatePrefs(socket string) (updatePrefs, error) { |
| return p, nil |
| } |
| -// downloadReleaseBinary fetches a release into the incoming dir and refuses |
| -// anything whose sha256 doesn't match the signed manifest entry. Returns the |
| -// temp path; the caller stages it and removes the temp. |
| +// updaterArch is the architecture this box runs — a var so tests can exercise |
| +// the arm64 resolution without faking runtime.GOARCH. |
| +var updaterArch = runtime.GOARCH |
| + |
| +// downloadReleaseBinary fetches this box's arch of a release into the incoming |
| +// dir and refuses anything whose sha256 doesn't match the signed manifest |
| +// entry. Returns the temp path; the caller stages it and removes the temp. |
| func downloadReleaseBinary(e releaseEntry) (string, error) { |
| + url, sha, ok := e.forArch(updaterArch) |
| + if !ok { |
| + // An arm64 box facing a pre-arm64 release: skip, never install the |
| + // wrong arch. The next release publishes both and the box catches up. |
| + return "", fmt.Errorf("release %s has no %s build — skipping", e.Version, updaterArch) |
| + } |
| if err := os.MkdirAll(incomingDir(), 0o755); err != nil { |
| return "", err |
| } |
| @@ -131,7 +142,7 @@ func downloadReleaseBinary(e releaseEntry) (string, error) { |
| return "", err |
| } |
| client := &http.Client{Timeout: 10 * time.Minute} |
| - resp, err := client.Get(e.URL) |
| + resp, err := client.Get(url) |
| if err != nil { |
| return fail(err) |
| } |
| @@ -143,7 +154,7 @@ func downloadReleaseBinary(e releaseEntry) (string, error) { |
| if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil { |
| return fail(err) |
| } |
| - if got := hex.EncodeToString(h.Sum(nil)); got != e.SHA256 { |
| + if got := hex.EncodeToString(h.Sum(nil)); got != sha { |
| return fail(fmt.Errorf("download %s: sha256 mismatch (refusing to stage)", e.Version)) |
| } |
| return f.Name(), nil |
diff --git a/updater_test.go b/updater_test.go| index 14edd4a..aa01bdb 100644 |
| --- a/updater_test.go |
| +++ b/updater_test.go |
| @@ -82,6 +82,12 @@ func startFeed(t *testing.T, binaries map[string]string) (*httptest.Server, func |
| ver := strings.TrimPrefix(r.URL.Path, "/bin/") |
| w.Write([]byte(binaries[ver])) |
| }) |
| + // Each version's arm64 build is distinct bytes, so a test can prove the |
| + // right arch was staged. |
| + mux.HandleFunc("/arm/", func(w http.ResponseWriter, r *http.Request) { |
| + ver := strings.TrimPrefix(r.URL.Path, "/arm/") |
| + w.Write([]byte(binaries[ver] + "@arm64")) |
| + }) |
| ts := httptest.NewServer(mux) |
| t.Cleanup(ts.Close) |
| @@ -90,14 +96,24 @@ func startFeed(t *testing.T, binaries map[string]string) (*httptest.Server, func |
| releaseManifestURL = ts.URL + "/manifest.json" |
| t.Cleanup(func() { releasePubKeyB64, releaseManifestURL = oldKey, oldURL }) |
| + // Pin the box arch so feed tests are deterministic on any dev machine |
| + // (an arm64 Mac would otherwise resolve the arm64 variant everywhere); |
| + // arch-specific tests override AFTER calling startFeed. |
| + prevArch := updaterArch |
| + updaterArch = "amd64" |
| + t.Cleanup(func() { updaterArch = prevArch }) |
| + |
| setChannels := func(channels map[string]string) { |
| m := &releaseManifest{Channels: map[string]releaseEntry{}} |
| for ch, ver := range channels { |
| sum := sha256.Sum256([]byte(binaries[ver])) |
| + armSum := sha256.Sum256([]byte(binaries[ver] + "@arm64")) |
| m.Channels[ch] = releaseEntry{ |
| - Version: ver, |
| - URL: ts.URL + "/bin/" + ver, |
| - SHA256: hex.EncodeToString(sum[:]), |
| + Version: ver, |
| + URL: ts.URL + "/bin/" + ver, |
| + SHA256: hex.EncodeToString(sum[:]), |
| + ARM64URL: ts.URL + "/arm/" + ver, |
| + ARM64SHA256: hex.EncodeToString(armSum[:]), |
| } |
| } |
| w, err := signReleaseManifest(priv, m) |
| @@ -308,6 +324,77 @@ func TestUpdatePassDryRunTouchesNothing(t *testing.T) { |
| } |
| } |
| +func TestUpdatePassStagesTheRightArch(t *testing.T) { |
| + withTempOpt(t) |
| + stubSystemctl(t) |
| + |
| + _, setChannels := startFeed(t, map[string]string{"new1234": "binary-content"}) |
| + setChannels(map[string]string{"stable": "new1234"}) |
| + // After startFeed (which pins amd64): this box is arm64. |
| + updaterArch = "arm64" |
| + |
| + reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "") |
| + seedInstance(t, reg, "h1.example.com", s1, "old1111") |
| + |
| + if err := updatePass(reg, "lchat@", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + staged, err := os.ReadFile(versionBinPath("new1234")) |
| + if err != nil || string(staged) != "binary-content@arm64" { |
| + t.Fatalf("arm64 box staged %q, %v — must be the arm64 build", staged, err) |
| + } |
| + if v, _ := liveVersion("h1.example.com"); v != "new1234" { |
| + t.Fatalf("arm64 apply didn't land: live = %q", v) |
| + } |
| +} |
| + |
| +func TestUpdatePassSkipsArchlessRelease(t *testing.T) { |
| + withTempOpt(t) |
| + calls := stubSystemctl(t) |
| + prevArch := updaterArch |
| + updaterArch = "arm64" |
| + t.Cleanup(func() { updaterArch = prevArch }) |
| + |
| + // A pre-arm64 manifest: flat amd64 fields only. An arm64 box must skip it |
| + // rather than install the wrong arch. |
| + pub, priv, _ := ed25519.GenerateKey(rand.Reader) |
| + feed := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + wrapper, _ := signReleaseManifest(priv, &releaseManifest{Channels: map[string]releaseEntry{ |
| + "stable": {Version: "new1234", URL: "https://example.com/lchat", SHA256: strings.Repeat("ab", 32)}, |
| + }}) |
| + w.Write(wrapper) |
| + })) |
| + defer feed.Close() |
| + oldKey, oldURL := releasePubKeyB64, releaseManifestURL |
| + releasePubKeyB64 = base64.StdEncoding.EncodeToString(pub) |
| + releaseManifestURL = feed.URL |
| + t.Cleanup(func() { releasePubKeyB64, releaseManifestURL = oldKey, oldURL }) |
| + |
| + reg, err := openRegistry(filepath.Join(t.TempDir(), "reg.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s1 := startFakeInstance(t, "h1.example.com", http.StatusNotFound, updatePrefs{}, "") |
| + seedInstance(t, reg, "h1.example.com", s1, "old1111") |
| + |
| + if err := updatePass(reg, "lchat@", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if v, _ := liveVersion("h1.example.com"); v != "old1111" { |
| + t.Fatalf("arm64 box moved to an amd64-only release: %q", v) |
| + } |
| + if _, err := os.Stat(versionBinPath("new1234")); !os.IsNotExist(err) { |
| + t.Fatal("amd64 binary staged on an arm64 box") |
| + } |
| + if len(*calls) != 0 { |
| + t.Fatalf("restarts on a skipped release: %v", *calls) |
| + } |
| +} |
| + |
| func TestUpdatePassSkipsPinnedInstance(t *testing.T) { |
| withTempOpt(t) |
| calls := stubSystemctl(t) |