#!/usr/bin/env bash # Build the modelrouter CORE distribution packages (deb + rpm + tar.gz). # # Unlike the GUI packages (Electron), the core is a single static-ish binary # that server admins install with dpkg/rpm. The systemd unit bakes in the # memory tuning (GOGC=50, MALLOC_ARENA_MAX=2) measured on the production box. # # Requires: go, nfpm (PATH), dpkg-deb/rpmbuild for verification # Outputs: cmd/build/dist/modelrouter__amd64.deb # cmd/build/dist/modelrouter-.x86_64.rpm # cmd/build/dist/modelrouter--linux-amd64.tar.gz set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" VERSION="${1:-$(git describe --tags --dirty 2>/dev/null | sed 's/^v//' || echo 0.1.0)}" DIST="$ROOT/cmd/build/dist" BUILD_BIN="$ROOT/cmd/build/modelrouter" log() { printf '\033[1;34m[core-dist]\033[0m %s\n' "$*"; } # nfpm is the primary packager, but it is a `go install` artifact and the build # host may be offline (no GOPROXY) — a release must not depend on being able to # fetch it. Fall back to dpkg-deb / rpmbuild, which are present wherever the # packages are actually verified. Both paths produce the same file layout. NFPM_BIN="$(command -v nfpm || true)" [ -z "$NFPM_BIN" ] && [ -n "$(go env GOPATH)" ] && { [ -x "$(go env GOPATH)/bin/nfpm" ] && NFPM_BIN="$(go env GOPATH)/bin/nfpm" } if [ -n "$NFPM_BIN" ]; then log "packager: nfpm ($NFPM_BIN)" else command -v dpkg-deb >/dev/null || { echo "[core-dist] neither nfpm nor dpkg-deb available"; exit 1; } command -v rpmbuild >/dev/null || { echo "[core-dist] neither nfpm nor rpmbuild available"; exit 1; } log "packager: nfpm missing -> dpkg-deb + rpmbuild fallback" fi mkdir -p "$DIST" # ---- fallback packagers ------------------------------------------------- # Same file layout as packaging/nfpm.yaml. Kept in this file (not a separate # script) so the two paths cannot drift silently: both are exercised by the # archive-content gate at the end. package_deb_fallback() { local root="$ROOT/cmd/build/pkgroot-deb" rm -rf "$root" mkdir -p "$root/DEBIAN" "$root/usr/bin" "$root/etc/systemd/system" \ "$root/usr/share/modelrouter/adapters" "$root/usr/share/doc/modelrouter" install -m 0755 "$BUILD_BIN" "$root/usr/bin/modelrouter" install -m 0644 "$ROOT/packaging/modelrouter.service" "$root/etc/systemd/system/modelrouter.service" install -m 0644 "$ROOT/packaging/config.example.yaml" "$root/usr/share/modelrouter/config.example.yaml" install -m 0644 "$ROOT"/internal/lua/adapters/*.lua "$root/usr/share/modelrouter/adapters/" install -m 0755 "$ROOT/packaging/install-browser-stack.sh" "$root/usr/share/modelrouter/install-browser-stack.sh" install -m 0644 "$ROOT/README.md" "$root/usr/share/doc/modelrouter/README.md" install -m 0755 "$ROOT/packaging/postinst.sh" "$root/DEBIAN/postinst" install -m 0755 "$ROOT/packaging/prerm.sh" "$root/DEBIAN/prerm" local inst_size inst_size=$(du -sk "$root" | awk '{print $1}') cat > "$root/DEBIAN/control" < Recommends: ca-certificates Homepage: https://gitcode.com/JianFeeeee/ModelRouter Description: Unified OpenAI-compatible multi-source LLM gateway. Routes requests across multiple upstream LLM providers with Lua adapters, model scheduling, quota windows, an elastic Lua worker pool, and a Web UI. EOF dpkg-deb --root-owner-group -Zgzip --build "$root" \ "$DIST/modelrouter_${VERSION}_amd64.deb" >/dev/null log " built $(basename "$DIST/modelrouter_${VERSION}_amd64.deb")" } package_rpm_fallback() { local top="$ROOT/cmd/build/rpmbuild" rm -rf "$top" mkdir -p "$top"/{BUILD,RPMS,SOURCES,SPECS,BUILDROOT} local spec="$top/SPECS/modelrouter.spec" { cat < "$spec" rpmbuild --define "_topdir $top" --define "_binary_payload w9.gzdio" -bb "$spec" \ >/dev/null 2>&1 || { echo "[core-dist] FATAL: rpmbuild failed; spec kept at $spec"; exit 1 } local built built=$(find "$top/RPMS" -name 'modelrouter-*.rpm' | head -1) [ -n "$built" ] || { echo "[core-dist] FATAL: rpmbuild produced no rpm"; exit 1; } cp "$built" "$DIST/modelrouter-${VERSION}.x86_64.rpm" log " built $(basename "$DIST/modelrouter-${VERSION}.x86_64.rpm")" } # 1) build the core binary (luajit tag, -s -w like deploy.sh) log "building core binary (luajit, stripped)..." CGO_ENABLED=1 go build -tags luajit -trimpath -ldflags="-s -w" -o "$BUILD_BIN" ./cmd/modelrouter # 2) sanity gates: binary must contain the expected symbols and not be degenerate. # NOTE under `set -o pipefail`, `strings | grep -q` is BROKEN: grep -q exits # on first hit and closes the pipe, strings gets SIGPIPE -> pipeline returns # 141 -> the check spuriously fails. Stage strings output in a temp file. SYMS_TMP="$(mktemp)" strings "$BUILD_BIN" > "$SYMS_TMP" for sym in ProbeSlots reqRing shrinkStepLocked mergeUsage; do grep -qF "$sym" "$SYMS_TMP" || { echo "[core-dist] FATAL: binary missing expected symbol '$sym'"; rm -f "$SYMS_TMP"; exit 1 } done rm -f "$SYMS_TMP" [ "$(stat -c%s "$BUILD_BIN")" -gt 5000000 ] || { echo "[core-dist] FATAL: core binary implausibly small"; exit 1 } log " ok: $(stat -c%s "$BUILD_BIN") bytes, symbols present" # 3) deb + rpm via nfpm. nfpm v2 here does not render {{ .Env.VERSION }}, so # stamp the version into a throwaway copy of the config; -t places the # generated package in the dist dir. log "packaging deb + rpm (v$VERSION)..." if [ -n "$NFPM_BIN" ]; then NFPM_CFG="$(mktemp)" && sed "s/__VERSION__/$VERSION/g" "$ROOT/packaging/nfpm.yaml" > "$NFPM_CFG" "$NFPM_BIN" package --config "$NFPM_CFG" --packager deb -t "$DIST" "$NFPM_BIN" package --config "$NFPM_CFG" --packager rpm -t "$DIST" rm -f "$NFPM_CFG" else package_deb_fallback package_rpm_fallback fi # 4) tar.gz log "packaging tar.gz..." TAR_DIR="$DIST/modelrouter-$VERSION-linux-amd64" rm -rf "$TAR_DIR"; mkdir -p "$TAR_DIR" cp "$BUILD_BIN" "$TAR_DIR/modelrouter" # Use the tracked sanitised template, NOT the local config.yaml: that file is # gitignored and carries the operator's real gateway_keys / upstream keys. It # was previously copied verbatim as config.example.yaml, which shipped a real, # working admin key (sk-gw-local-0001) to every install — and postinst.sh copies # the example to /etc when none exists, so it ended up live in production. cp "$ROOT/packaging/config.example.yaml" "$TAR_DIR/config.example.yaml" cp "$ROOT/packaging/modelrouter.service" "$TAR_DIR/modelrouter.service" # 改名迁移脚本随包分发:升级自 llmsproxy 时代的机器需要它(dry-run 默认)。 cp "$ROOT/packaging/migrate-to-modelrouter.sh" "$TAR_DIR/migrate-to-modelrouter.sh" mkdir -p "$TAR_DIR/adapters" cp "$ROOT"/internal/lua/adapters/*.lua "$TAR_DIR/adapters/" # The core distribution contains only the gateway and protocol adapters. # Optional plugins remain in the source repository, but are neither embedded in # the binary nor copied into release archives. Operators install selected Lua # plugins into plugin_dir separately. tar -C "$DIST" -czf "$DIST/modelrouter-$VERSION-linux-amd64.tar.gz" \ "$(basename "$TAR_DIR")" rm -rf "$TAR_DIR" # 5) verify artifacts are healthy (the same gate that would have caught the # 264KB exe fiasco) log "verifying packages..." for f in "$DIST"/modelrouter_*.deb "$DIST"/modelrouter-*.rpm "$DIST"/*.tar.gz; do [ -f "$f" ] || continue sz=$(stat -c%s "$f") [ "$sz" -gt 100000 ] || { echo "[core-dist] FATAL: $f suspiciously small ($sz B)"; exit 1; } log " OK $f ($((sz/1024)) KB)" done # dpkg-deb takes ONE archive; every extra argument is read as an additional # control-component name. With the glob unquoted this used to expand to three # .deb files (1.5.9 / 1.6.0 / the one just built) and dpkg-deb exited 2 — under # `set -e` that aborted the script right here, so the "done" line never printed # and NO content verification below ever ran. The size gate above was the only # check that ever executed. Pick the newest deb explicitly. newest_deb=$(ls -t "$DIST"/modelrouter_*.deb 2>/dev/null | head -1 || true) if [ -n "$newest_deb" ]; then dpkg-deb -I "$newest_deb" 2>/dev/null | grep -E 'Package|Version' | head -2 || true fi # 6) the archive must actually carry what the release notes promise. # # A size floor alone cannot catch this: a tar.gz missing all 70KB of plugins # still clears the 100KB threshold easily, and the seeded-on-first-boot # behaviour hides it at runtime — the operator sees a working billing plugin and # concludes the package is complete. Assert the file is in the archive. log "verifying archive contents..." TARBALL="$DIST/modelrouter-$VERSION-linux-amd64.tar.gz" [ -f "$TARBALL" ] || { echo "[core-dist] FATAL: $TARBALL missing"; exit 1; } listing=$(tar tzf "$TARBALL" 2>/dev/null || true) [ -n "$listing" ] || { echo "[core-dist] FATAL: cannot list $TARBALL"; exit 1; } for want in modelrouter config.example.yaml modelrouter.service migrate-to-modelrouter.sh; do if ! printf '%s\n' "$listing" | grep -q "/$want\$"; then echo "[core-dist] FATAL: archive is missing $want" echo " (the archive must carry the binary, config template, unit and migration script)" exit 1 fi log " OK archive contains $want" done n_plugins=$(printf '%s\n' "$listing" | grep -c "/plugins/.*\.lua\$" || true) if [ "$n_plugins" -ne 0 ]; then echo "[core-dist] FATAL: archive unexpectedly contains $n_plugins plugins" exit 1 fi log " OK archive excludes optional plugins" n_adapters=$(printf '%s\n' "$listing" | grep -c "/adapters/.*\.lua\$" || true) if [ "$n_adapters" -lt 10 ]; then echo "[core-dist] FATAL: archive has only $n_adapters adapters (expected >= 10)" exit 1 fi log " OK archive contains $n_adapters adapters" log "done: $DIST"