From 004d18db92e709afcd9f95d32ef9c8d3800a1b1f Mon Sep 17 00:00:00 2001 From: lofyer Date: Mon, 27 Jul 2026 09:16:05 +0800 Subject: [PATCH] feat: workspace tooling for the MesaStack fork Add one-shot scripts driving both forks from the workspace root: - build-image: frontend build + thin-overlay image build + verification - verify-image: static and runtime smoke tests (brand, API key prefix, login, real key creation), always cleans up its container - sync-upstream: bump both forks to a new upstream release, cutting fresh v-lofyer branches and replaying fork-only commits - bootstrap: clone the two forks into place after cloning this repo Fork-only commit detection deliberately goes beyond 'git log --cherry-pick --right-only', which re-replays upstream commits that were rebased or squashed into the new release; commits contained by any upstream ref or tag are dropped instead. The forks stay as separate repositories rather than submodules so each keeps a clean upstream history to rebase onto new releases. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .gitignore | 8 ++ AGENTS.md | 234 ++++++++++++++++++++++++++++++++++++++++++ scripts/bootstrap | 76 ++++++++++++++ scripts/build-image | 102 ++++++++++++++++++ scripts/lib/common.sh | 76 ++++++++++++++ scripts/sync-upstream | 233 +++++++++++++++++++++++++++++++++++++++++ scripts/verify-image | 153 +++++++++++++++++++++++++++ 7 files changed, 882 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100755 scripts/bootstrap create mode 100755 scripts/build-image create mode 100644 scripts/lib/common.sh create mode 100755 scripts/sync-upstream create mode 100755 scripts/verify-image diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e02c5b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# The two forks are separate repositories cloned in by scripts/bootstrap, +# not submodules, so they are never tracked here. +/gpustack-ui-lofyer/ +/gpustack-lofyer/ + +# Local build/verify scratch +*.log +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5941b55 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,234 @@ +# Agent Instructions - MesaStack (GPUStack downstream fork) + +This is the **combined workspace** for our MesaStack product, a downstream fork of +[GPUStack](https://github.com/gpustack). It bundles both the frontend and backend +repos so agents and contributors work from a single root going forward. + +## Layout + +``` +gpustack-all-lofyer/ <- this repo (tooling only) +├── AGENTS.md # workspace-level conventions +├── scripts/ +│ ├── bootstrap # clone the two forks into place +│ ├── build-image # frontend build + image build + verify +│ ├── verify-image # smoke test a built image +│ ├── sync-upstream # bump both forks to a new upstream release +│ └── lib/common.sh # shared helpers +├── gpustack-ui-lofyer/ # frontend fork (upstream: gpustack/gpustack-ui) +└── gpustack-lofyer/ # backend fork (upstream: gpustack/gpustack) +``` + +This repo tracks **only the workspace tooling**. The two forks live in their own +repositories and are *not* submodules, so each keeps a clean upstream history that +can be rebased onto new releases. After cloning this repo, run `scripts/bootstrap` +to lay the forks out at the paths above. + +Each sub-repo is an independent git repository with its own history, remotes, and +`AGENTS.md` / `CLAUDE.md`. Read the sub-repo's own instructions before working in it. + +Remotes (`origin` = private registry, `upstream` = GitHub): + +| Repo | origin | +| ------------------ | --------------------------------------------------- | +| gpustack-all-lofyer| `ssh://git@git.digiman.live:11022/root/gpustack-all-lofyer.git` | +| gpustack-ui-lofyer | `ssh://git@git.digiman.live:11022/root/gpustack-ui.git` | +| gpustack-lofyer | `ssh://git@git.digiman.live:11022/root/gpustack.git` | + +## Workspace scripts + +Run these from the workspace root. Every script refuses to proceed unless both +forks sit on the **same** `v-lofyer` branch, so a build can never mix a +frontend and backend from different upstream releases. + +```bash +scripts/bootstrap [v] # clone both forks, checkout v-lofyer +scripts/build-image # npm build + image build + full verification +scripts/build-image --skip-ui-build # reuse an existing frontend dist/ +scripts/build-image --no-verify # build only +scripts/build-image --push # push the image after a green build +scripts/build-image --dry-run # show the plan +scripts/verify-image # smoke test the current version's image +scripts/verify-image --static-only # skip booting a container +scripts/sync-upstream --list # upstream releases available in BOTH repos +scripts/sync-upstream v2.2.2 # bump both forks to v2.2.2 +scripts/sync-upstream v2.2.2 --dry-run +scripts/sync-upstream --continue # resume after resolving a cherry-pick conflict +``` + +## Versioning + +Both repos track the **same upstream release** and keep customizations on a +`v-lofyer` branch. Frontend and backend are always bumped +**together** to the same upstream tag. Current target: **v2.2.1**. + +| Repo | Branch | Upstream base | Upstream repo | +| ------------------ | --------------- | ------------- | ----------------------- | +| gpustack-ui-lofyer | `v2.2.1-lofyer` | tag `v2.2.1` | `gpustack/gpustack-ui` | +| gpustack-lofyer | `v2.2.1-lofyer` | tag `v2.2.1` | `gpustack/gpustack` | + +## Branch rules + +- **Never commit customizations to `main` or to any upstream branch.** `main` + mirrors upstream and must stay clean so it can be fast-forwarded/mirrored. +- All customization work lives on `v-lofyer` (e.g. + `v2.2.1-lofyer`), one such branch per upstream release we follow. +- One upstream version = one `-lofyer` branch. Do **not** reuse an old branch for a + new upstream version; cut a fresh one from the new tag and re-apply commits. +- Keep the frontend and backend `-lofyer` branch names in lockstep (same version + suffix) so a customer build always pairs matching branches. +- Only fast-forward-safe, curated customization commits belong on `-lofyer`. Prefer + small, cherry-pickable commits so the next version bump replays cleanly. + +## Updating to a new upstream version + +Use `scripts/sync-upstream`, which does the whole bump for both repos: + +```bash +scripts/sync-upstream --list # releases available in BOTH upstreams +scripts/sync-upstream v2.2.2 --dry-run # review the replay plan first +scripts/sync-upstream v2.2.2 # cut branches + replay fork commits +scripts/build-image # verify the result end to end +git -C gpustack-ui-lofyer push -u origin v2.2.2-lofyer +git -C gpustack-lofyer push -u origin v2.2.2-lofyer +# then update the version table + "Current target" above +``` + +What it does per repo: fetch `upstream`, require a clean worktree and that the tag +exists in **both** upstreams, cut `v-lofyer` from the tag, then cherry-pick +our fork-only commits oldest-first. On conflict it stops and tells you exactly what +to resolve; afterwards run `scripts/sync-upstream --continue`, then re-run with the +target version to replay the remainder. Nothing is pushed automatically. + +**Identifying fork-only commits** is the subtle part, and the script handles it: +`git log --cherry-pick --right-only` alone is *not* sufficient, because upstream +commits that were rebased or squashed into the new release get a different patch-id +and would be replayed a second time. The script therefore also drops any commit that +an `upstream/*` branch or any tag contains. If you ever do this by hand, verify with +`git branch -r --contains | grep upstream`. + +Untracked files (e.g. work in progress under `src/pages/`) are treated as yours and +left untouched; only tracked modifications block the bump. + +## Customizations to re-apply each version + +### Frontend (`gpustack-ui-lofyer`) + +Fork-only commits carried on top of upstream. Details in +`gpustack-ui-lofyer/AGENTS.md` + `CLAUDE.md`. Notably: + +- `scripts/sync-github` and `scripts/rebrand` (the tooling itself). +- Rebrand user-facing `GPUStack` -> `MesaStack` (run `scripts/rebrand`). +- Footer/topbar trim, navigation moved to top header. + +### Backend (`gpustack-lofyer`) + +- **API key / token prefix**: `API_KEY_PREFIX` in `gpustack/security.py` is set to + `mesastack` (upstream `gpustack`). This drives API keys, worker registration + tokens, cluster registration tokens (`mesastack_{access}_{secret}`), and the + masked display value. We intentionally **do not** stay backward compatible with + old `gpustack_` keys (fresh customer deployments only). Keep the assertions in + `tests/utils/test_api_keys.py` in sync with the prefix. +- **Image build tooling**: `pack/Dockerfile.lofyer` + `hack/package-lofyer` (thin + overlay build, see "Shipping a customer build" below). These are fork-only files, + so cherry-pick them onto each new `-lofyer` branch. + +Note the backend `origin` points at the private registry and `upstream` at GitHub, +mirroring the frontend convention: + +``` +upstream https://github.com/gpustack/gpustack +origin ssh://git@192.168.0.23:11022/root/gpustack.git +``` + +## Brand rules + +User-facing brand is **MesaStack** (upstream **GPUStack**). Functional identifiers +are **not** rebranded, to preserve compatibility with upstream contracts across the +frontend/backend boundary: + +- lowercase `gpustack` (npm/pip package, URLs, paths, k8s namespace) +- `GPUSTACK_*` env constants +- `X-*` HTTP headers, cookies (`gpustack_session`, `gpustack_oidc_*`) +- JS/Python identifiers (e.g. `getGPUStackPlugin`, `gpustack_worker` proctitles) +- backend-contract strings (see `SKIP_LINE_PATTERNS` in frontend `scripts/rebrand`) + +The API key **prefix** (`mesastack`) is a deliberate exception: it is user-facing +and, by our choice, not upstream-compatible. + +## Shipping a customer build + +A release requires **both** the rebranded frontend and the backend on matching +`v-lofyer` branches. The deliverable is a single control-plane container +image built by `gpustack-lofyer/hack/package-lofyer`. + +### Building the image + +One command from the workspace root: + +```bash +scripts/build-image # frontend build -> image -> full verification +``` + +It resolves the version from the (matching) `v-lofyer` branches, builds the +rebranded frontend, delegates to `gpustack-lofyer/hack/package-lofyer` for the image, +then runs `scripts/verify-image`. Flags: `--skip-ui-build`, `--no-verify`, `--push`, +`--dry-run`. Env: `NAMESPACE`, `REPOSITORY`, `TAG`, `IMAGE`, `BASE_IMAGE`, +`SMOKE_PORT`. + +The backend script can also be used directly if you only want the image step: + +```bash +cd gpustack-lofyer && ./hack/package-lofyer # -> mesastack/gpustack:v +``` + +It derives the upstream tag from the current branch name, so it always overlays a +matching official base image. Knobs: `UI_BUILD=1`, `DRY_RUN=1`, `PUSH=1`, `TAG`, +`IMAGE`, `BASE_IMAGE`, `NAMESPACE`, `REPOSITORY`, `UI_REPO`. + +### Why a thin overlay, not `make package` + +Upstream `pack/Dockerfile` rebuilds the entire control plane (PostgreSQL 17, +Higress gateway stack, Prometheus/Grafana, Skopeo compiled from source, ROCm +`amd_smi`, vLLM extras). Those components are hardcoded and cannot be skipped, so a +full `make package` costs 30-60+ minutes and tens of GB. Our fork only diverges in +two places, so `pack/Dockerfile.lofyer` overlays them onto the official release +image (`gpustack/gpustack:v`) in seconds: + +1. `gpustack/ui/` replaced with our rebranded frontend `dist/` +2. fork-patched Python sources (currently just `gpustack/security.py`) + +The script stages only files that actually differ from the upstream tag +(`git diff ..HEAD -- 'gpustack/**/*.py'`), keeping the layer small and +auditable. The Dockerfile self-checks the result at build time (asserts +`API_KEY_PREFIX == mesastack`, and that the UI carries the MesaStack brand). + +Note this image is the **control plane** (server + worker). Inference runners use +separate `gpustack-runner` images pulled at runtime and are unaffected. + +If you ever need a fully self-built image with no upstream base (clean supply +chain), use `PACKAGE_UI_DOWNLOAD=false make package` after staging our `dist/` +into `gpustack/ui/`, and expect the long build. + +### Verifying a build + +`scripts/build-image` runs this automatically; use it standalone to re-check an +existing image: + +```bash +scripts/verify-image # static + runtime checks +scripts/verify-image --static-only # skip booting a container +IMAGE=mesastack/gpustack:v2.2.1 scripts/verify-image +``` + +Checks performed: + +- **static**: `gpustack version`, `API_KEY_PREFIX == mesastack`, old `gpustack_` + keys rejected, masked value format, UI assets present, UI carries the MesaStack + brand, no leftover `GPUStack` string in `index.html` +- **runtime**: boots with `--disable-worker`, confirms `/` serves + `MesaStack`, logs in via `POST /auth/login` (bootstrap password at + `/var/lib/gpustack/initial_admin_password`) and creates a key via + `POST /v2/api-keys` to prove the `mesastack_` prefix is live end to end + +The test container is always removed, including on failure. diff --git a/scripts/bootstrap b/scripts/bootstrap new file mode 100755 index 0000000..dea237d --- /dev/null +++ b/scripts/bootstrap @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# bootstrap: clone the two forks into this workspace. +# +# This repo intentionally tracks only the workspace-level tooling (AGENTS.md and +# scripts/); the frontend and backend forks stay in their own repositories so we +# keep clean upstream histories and can rebase onto new releases. Run this after +# cloning the workspace to lay out: +# +# gpustack-all-lofyer/ +# ├── gpustack-ui-lofyer/ frontend fork +# └── gpustack-lofyer/ backend fork +# +# Usage: +# scripts/bootstrap # clone both, checkout the default version +# scripts/bootstrap v2.2.1 # checkout a specific v-lofyer +# +# Existing clones are left alone (only their remotes are verified). +# +set -o errexit +set -o nounset +set -o pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh" + +# Keep in sync with the version table in AGENTS.md. +DEFAULT_VERSION="v2.2.1" +VERSION="${1:-${DEFAULT_VERSION}}" +BRANCH="${VERSION}-lofyer" + +require_cmd git + +clone_fork() { + local name="${1}" dir="${2}" origin="${3}" upstream="${4}" + + step "${name}" + if [[ -d "${dir}/.git" ]]; then + log "already present at ${dir}" + else + log "cloning ${origin}" + git clone "${origin}" "${dir}" + fi + + if git -C "${dir}" remote get-url upstream >/dev/null 2>&1; then + git -C "${dir}" remote set-url upstream "${upstream}" + else + git -C "${dir}" remote add upstream "${upstream}" + fi + log "origin : $(git -C "${dir}" remote get-url origin)" + log "upstream: $(git -C "${dir}" remote get-url upstream)" + + local current + current="$(repo_branch "${dir}")" + if [[ "${current}" == "${BRANCH}" ]]; then + log "already on ${BRANCH}" + elif git -C "${dir}" rev-parse -q --verify "refs/heads/${BRANCH}" >/dev/null; then + git -C "${dir}" checkout "${BRANCH}" + elif git -C "${dir}" rev-parse -q --verify "refs/remotes/origin/${BRANCH}" >/dev/null; then + git -C "${dir}" checkout -b "${BRANCH}" "origin/${BRANCH}" + else + warn "branch ${BRANCH} not found (staying on ${current}); fetch or pick another version" + fi +} + +clone_fork "Frontend" "${UI_REPO}" "${UI_ORIGIN_URL}" "${UI_UPSTREAM_URL}" +clone_fork "Backend" "${BACKEND_REPO}" "${BACKEND_ORIGIN_URL}" "${BACKEND_UPSTREAM_URL}" + +step "Done" +log "frontend: $(repo_branch "${UI_REPO}")" +log "backend : $(repo_branch "${BACKEND_REPO}")" +cat <-lofyer branch, so a +# build can never mix a frontend and backend from different upstream releases. +# +# Usage: +# scripts/build-image # build + verify +# scripts/build-image --no-verify # build only +# scripts/build-image --skip-ui-build # reuse existing frontend dist/ +# scripts/build-image --push # push after a successful build +# scripts/build-image --dry-run # show what would happen +# +# Env knobs (also see gpustack-lofyer/hack/package-lofyer): +# NAMESPACE / REPOSITORY / TAG / IMAGE / BASE_IMAGE +# SMOKE_PORT host port used by the smoke test (default 18080) +# +set -o errexit +set -o nounset +set -o pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh" + +UI_BUILD=1 +VERIFY=1 +PUSH=0 +DRY_RUN=0 +SMOKE_PORT="${SMOKE_PORT:-18080}" + +while [[ $# -gt 0 ]]; do + case "${1}" in + --skip-ui-build) UI_BUILD=0 ;; + --no-verify) VERIFY=0 ;; + --verify) VERIFY=1 ;; + --push) PUSH=1 ;; + --dry-run) DRY_RUN=1 ;; + -h|--help) sed -n '2,26p' "${0}"; exit 0 ;; + *) fail "unknown argument: ${1}" ;; + esac + shift +done + +require_cmd docker git +require_repos + +VERSION="$(resolve_workspace_version)" +TAG="${TAG:-${VERSION}}" +IMAGE="${IMAGE:-${NAMESPACE}/${REPOSITORY}:${TAG}}" + +log "workspace version : ${VERSION}" +log "target image : ${IMAGE}" + +# 1. Frontend. +step "Building frontend" +if [[ "${UI_BUILD}" == "1" ]]; then + require_cmd npm + if [[ "${DRY_RUN}" == "1" ]]; then + echo "(dry-run) (cd ${UI_REPO} && npm run build)" + else + (cd "${UI_REPO}" && npm run build) + fi +else + log "skipping frontend build (--skip-ui-build)" + [[ -f "${UI_REPO}/dist/index.html" ]] \ + || fail "no existing frontend dist at ${UI_REPO}/dist (drop --skip-ui-build)" +fi + +# 2. Image. Delegates to the backend packaging script, which stages the dist +# and only the python files that differ from the upstream tag. +step "Building image ${IMAGE}" +PACK_ENV=( + "NAMESPACE=${NAMESPACE}" + "REPOSITORY=${REPOSITORY}" + "TAG=${TAG}" + "IMAGE=${IMAGE}" + "UI_REPO=${UI_REPO}" + "PUSH=${PUSH}" + "DRY_RUN=${DRY_RUN}" +) +[[ -n "${BASE_IMAGE:-}" ]] && PACK_ENV+=("BASE_IMAGE=${BASE_IMAGE}") + +env "${PACK_ENV[@]}" "${BACKEND_REPO}/hack/package-lofyer" + +if [[ "${DRY_RUN}" == "1" ]]; then + log "dry-run complete" + exit 0 +fi + +# 3. Verify. +if [[ "${VERIFY}" == "1" ]]; then + step "Verifying ${IMAGE}" + IMAGE="${IMAGE}" SMOKE_PORT="${SMOKE_PORT}" "$(dirname "${BASH_SOURCE[0]}")/verify-image" +fi + +step "Done" +log "image: ${IMAGE}" +log "run it with:" +echo " docker run -d --name mesastack -p 80:80 -v /var/run/docker.sock:/var/run/docker.sock ${IMAGE}" diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh new file mode 100644 index 0000000..afb8c84 --- /dev/null +++ b/scripts/lib/common.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# +# Shared helpers for the workspace-level scripts. +# +# Layout assumed by every script (see AGENTS.md): +# +# gpustack-all-lofyer/ +# ├── scripts/ +# ├── gpustack-ui-lofyer/ frontend fork +# └── gpustack-lofyer/ backend fork +# + +# Resolve the workspace root from the calling script's location. +WORKSPACE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" + +UI_REPO="${UI_REPO:-${WORKSPACE_DIR}/gpustack-ui-lofyer}" +BACKEND_REPO="${BACKEND_REPO:-${WORKSPACE_DIR}/gpustack-lofyer}" + +# Private registry (origin) and upstream GitHub remotes for both forks. +UI_ORIGIN_URL="${UI_ORIGIN_URL:-ssh://git@git.digiman.live:11022/root/gpustack-ui.git}" +UI_UPSTREAM_URL="${UI_UPSTREAM_URL:-https://github.com/gpustack/gpustack-ui.git}" +BACKEND_ORIGIN_URL="${BACKEND_ORIGIN_URL:-ssh://git@git.digiman.live:11022/root/gpustack.git}" +BACKEND_UPSTREAM_URL="${BACKEND_UPSTREAM_URL:-https://github.com/gpustack/gpustack.git}" + +# Image coordinates. +NAMESPACE="${NAMESPACE:-mesastack}" +REPOSITORY="${REPOSITORY:-gpustack}" + +_name="$(basename "${0}")" + +log() { echo -e "\033[1;34m[${_name}]\033[0m $*"; } +warn() { echo -e "\033[1;33m[${_name}]\033[0m $*" >&2; } +fail() { echo -e "\033[1;31m[${_name}]\033[0m $*" >&2; exit 1; } +step() { echo -e "\n\033[1;36m==> $*\033[0m"; } + +require_cmd() { + for c in "$@"; do + command -v "${c}" >/dev/null 2>&1 || fail "'${c}' is required but not installed" + done +} + +require_repos() { + [[ -d "${UI_REPO}/.git" ]] || fail "frontend repo missing at ${UI_REPO} (run scripts/bootstrap)" + [[ -d "${BACKEND_REPO}/.git" ]] || fail "backend repo missing at ${BACKEND_REPO} (run scripts/bootstrap)" +} + +# Current branch of a repo. +repo_branch() { git -C "${1}" rev-parse --abbrev-ref HEAD 2>/dev/null; } + +# Extract the upstream version from a v-lofyer branch name. +# Echoes the version (e.g. v2.2.1) or fails. +lofyer_branch_version() { + local branch="${1}" + if [[ "${branch}" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+[0-9A-Za-z.]*)-lofyer$ ]]; then + echo "${BASH_REMATCH[1]}" + return 0 + fi + return 1 +} + +# Verify both forks sit on the same v-lofyer branch and echo the version. +resolve_workspace_version() { + local ui_branch backend_branch ui_version backend_version + ui_branch="$(repo_branch "${UI_REPO}")" + backend_branch="$(repo_branch "${BACKEND_REPO}")" + + ui_version="$(lofyer_branch_version "${ui_branch}")" \ + || fail "frontend is on '${ui_branch}', expected a v-lofyer branch" + backend_version="$(lofyer_branch_version "${backend_branch}")" \ + || fail "backend is on '${backend_branch}', expected a v-lofyer branch" + + [[ "${ui_version}" == "${backend_version}" ]] \ + || fail "version mismatch: frontend=${ui_branch} backend=${backend_branch} (must match)" + + echo "${ui_version}" +} diff --git a/scripts/sync-upstream b/scripts/sync-upstream new file mode 100755 index 0000000..0d387f7 --- /dev/null +++ b/scripts/sync-upstream @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +# +# sync-upstream: move both forks onto a new upstream release. +# +# For each repo it fetches upstream, cuts a fresh v-lofyer branch from +# the target tag, and replays our fork-only commits onto it. Conflicts are left +# in the worktree for you to resolve (upstream frequently reflows docs or +# deletes files we keep), then re-run with --continue. +# +# Usage: +# scripts/sync-upstream --list # show available upstream versions +# scripts/sync-upstream v2.2.2 # bump both forks to v2.2.2 +# scripts/sync-upstream v2.2.2 --dry-run # show the plan only +# scripts/sync-upstream --continue # resume after resolving conflicts +# +# Notes: +# - Refuses to run with dirty worktrees (except untracked files, which are +# yours and are left alone). +# - The tag must exist in BOTH upstreams, so frontend and backend never end up +# on different releases. +# - Nothing is pushed. Review, then push each repo yourself. +# +set -o errexit +set -o nounset +set -o pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh" + +TARGET="" +LIST=0 +DRY_RUN=0 +CONTINUE=0 + +while [[ $# -gt 0 ]]; do + case "${1}" in + --list) LIST=1 ;; + --dry-run) DRY_RUN=1 ;; + --continue) CONTINUE=1 ;; + -h|--help) sed -n '2,24p' "${0}"; exit 0 ;; + v*) TARGET="${1}" ;; + *) fail "unknown argument: ${1}" ;; + esac + shift +done + +require_cmd git +require_repos + +# Ensure an 'upstream' remote exists and points at GitHub. +ensure_upstream_remote() { + local repo="${1}" url="${2}" + if git -C "${repo}" remote get-url upstream >/dev/null 2>&1; then + local current + current="$(git -C "${repo}" remote get-url upstream)" + if [[ "${current}" != "${url}" ]]; then + log " updating upstream remote: ${current} -> ${url}" + git -C "${repo}" remote set-url upstream "${url}" + fi + else + log " adding upstream remote -> ${url}" + git -C "${repo}" remote add upstream "${url}" + fi +} + +fetch_upstream() { + local repo="${1}" url="${2}" name="${3}" + log "fetching upstream for ${name}" + ensure_upstream_remote "${repo}" "${url}" + git -C "${repo}" fetch --tags --prune upstream >/dev/null 2>&1 +} + +# Stable release tags only (drop rc/alpha/beta), newest last. +list_versions() { + git -C "${1}" tag --list 'v*' \ + | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + | sort -V +} + +# --- resume path ------------------------------------------------------------- +if [[ "${CONTINUE}" == "1" ]]; then + for spec in "frontend:${UI_REPO}" "backend:${BACKEND_REPO}"; do + name="${spec%%:*}"; repo="${spec#*:}" + if [[ -d "${repo}/.git/sequencer" ]] || [[ -f "${repo}/.git/CHERRY_PICK_HEAD" ]]; then + step "Resuming cherry-pick in ${name}" + if git -C "${repo}" diff --name-only --diff-filter=U | grep -q .; then + git -C "${repo}" diff --name-only --diff-filter=U | sed 's/^/ conflict: /' + fail "${name} still has unresolved conflicts; resolve and 'git add' them first" + fi + GIT_EDITOR=true git -C "${repo}" cherry-pick --continue || true + # The repo's lint hooks can stash/restore and upset the sequencer; if the + # commit landed, clear the leftover state so the next pick can proceed. + if [[ -d "${repo}/.git/sequencer" ]] && ! git -C "${repo}" diff --name-only --diff-filter=U | grep -q .; then + git -C "${repo}" cherry-pick --quit 2>/dev/null || true + fi + log "${name}: resumed; re-run sync-upstream with the target version to replay remaining commits" + fi + done + exit 0 +fi + +# --- list path --------------------------------------------------------------- +fetch_upstream "${UI_REPO}" "${UI_UPSTREAM_URL}" "frontend" +fetch_upstream "${BACKEND_REPO}" "${BACKEND_UPSTREAM_URL}" "backend" + +if [[ "${LIST}" == "1" ]]; then + step "Upstream stable releases present in BOTH repos" + comm -12 <(list_versions "${UI_REPO}") <(list_versions "${BACKEND_REPO}") | tail -15 + step "Current" + log "frontend: $(repo_branch "${UI_REPO}")" + log "backend : $(repo_branch "${BACKEND_REPO}")" + exit 0 +fi + +[[ -n "${TARGET}" ]] || fail "no target version given (try --list)" + +# --- preflight --------------------------------------------------------------- +step "Preflight" +CURRENT_VERSION="$(resolve_workspace_version)" +log "current version: ${CURRENT_VERSION}" +log "target version : ${TARGET}" +[[ "${TARGET}" != "${CURRENT_VERSION}" ]] || fail "already on ${TARGET}" + +NEW_BRANCH="${TARGET}-lofyer" + +for spec in "frontend:${UI_REPO}" "backend:${BACKEND_REPO}"; do + name="${spec%%:*}"; repo="${spec#*:}" + + git -C "${repo}" rev-parse -q --verify "refs/tags/${TARGET}" >/dev/null \ + || fail "${name}: tag ${TARGET} not found upstream" + + # Tracked modifications block a clean branch cut; untracked files are the + # user's own work and are preserved across checkout. + if ! git -C "${repo}" diff-index --quiet HEAD -- 2>/dev/null; then + git -C "${repo}" status --short | grep -v '^??' | sed 's/^/ /' + fail "${name}: worktree has uncommitted changes; commit or stash them first" + fi + + git -C "${repo}" rev-parse -q --verify "refs/heads/${NEW_BRANCH}" >/dev/null \ + && fail "${name}: branch ${NEW_BRANCH} already exists" +done +log "both repos are clean and ${TARGET} exists in both upstreams" + +# Fork-only commits = on our branch but not in the new upstream tag, oldest first. +# Fork-only commits: on our branch, not reachable from the new tag, and not +# present on any upstream ref. +# +# `--cherry-pick --right-only` alone is not enough: upstream commits that were +# rebased or squashed into the new release get a different patch-id and would be +# replayed again. So we additionally drop anything that any upstream branch or +# tag contains, which is what actually distinguishes our work from upstream's. +fork_commits() { + local repo="${1}" old_branch="${2}" c + while read -r c; do + [[ -n "${c}" ]] || continue + # Skip commits reachable from any upstream remote branch or upstream tag. + if git -C "${repo}" branch -r --contains "${c}" 2>/dev/null | grep -q '^\s*upstream/'; then + continue + fi + if git -C "${repo}" tag --contains "${c}" 2>/dev/null | grep -q .; then + continue + fi + echo "${c}" + done < <(git -C "${repo}" log --reverse --no-merges --format='%H' \ + "${TARGET}..${old_branch}" --cherry-pick --right-only) +} + +# --- plan -------------------------------------------------------------------- +step "Plan" +declare -A PICKS +for spec in "frontend:${UI_REPO}" "backend:${BACKEND_REPO}"; do + name="${spec%%:*}"; repo="${spec#*:}" + old_branch="$(repo_branch "${repo}")" + mapfile -t commits < <(fork_commits "${repo}" "${old_branch}") + PICKS["${name}"]="${commits[*]:-}" + log "${name}: ${old_branch} -> ${NEW_BRANCH}, replaying ${#commits[@]} fork commit(s)" + for c in "${commits[@]:-}"; do + [[ -n "${c}" ]] && echo " $(git -C "${repo}" log --format='%h %s' -1 "${c}")" + done +done + +if [[ "${DRY_RUN}" == "1" ]]; then + step "Dry run" + log "no changes made" + exit 0 +fi + +# --- execute ----------------------------------------------------------------- +for spec in "frontend:${UI_REPO}" "backend:${BACKEND_REPO}"; do + name="${spec%%:*}"; repo="${spec#*:}" + step "Updating ${name}" + + git -C "${repo}" checkout -b "${NEW_BRANCH}" "${TARGET}" + log " cut ${NEW_BRANCH} from ${TARGET}" + + read -r -a commits <<< "${PICKS[${name}]}" + for c in "${commits[@]:-}"; do + [[ -n "${c}" ]] || continue + subject="$(git -C "${repo}" log --format='%h %s' -1 "${c}")" + if git -C "${repo}" cherry-pick "${c}" >/dev/null 2>&1; then + log " picked ${subject}" + else + # A lint/pre-commit hook may have committed successfully while still + # returning non-zero; treat "no conflicts left" as success. + if git -C "${repo}" diff --name-only --diff-filter=U | grep -q .; then + warn " CONFLICT while picking ${subject}" + git -C "${repo}" diff --name-only --diff-filter=U | sed 's/^/ /' + cat >&2 < + $(dirname "${BASH_SOURCE[0]}")/sync-upstream --continue + $(dirname "${BASH_SOURCE[0]}")/sync-upstream ${TARGET} # replay the rest +EOF + exit 1 + fi + git -C "${repo}" cherry-pick --quit 2>/dev/null || true + log " picked ${subject} (hook noise ignored)" + fi + done +done + +step "Done" +log "both forks are on ${NEW_BRANCH}" +cat < +# IMAGE=mesastack/gpustack:v2.2.1 scripts/verify-image +# scripts/verify-image --static-only # skip booting a container +# +set -o errexit +set -o nounset +set -o pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh" + +STATIC_ONLY=0 +while [[ $# -gt 0 ]]; do + case "${1}" in + --static-only) STATIC_ONLY=1 ;; + -h|--help) sed -n '2,18p' "${0}"; exit 0 ;; + *) fail "unknown argument: ${1}" ;; + esac + shift +done + +require_cmd docker curl python3 + +if [[ -z "${IMAGE:-}" ]]; then + require_repos + VERSION="$(resolve_workspace_version)" + IMAGE="${NAMESPACE}/${REPOSITORY}:${TAG:-${VERSION}}" +fi + +SMOKE_PORT="${SMOKE_PORT:-18080}" +CONTAINER="${CONTAINER:-mesastack-verify}" +SITE_PACKAGES="/usr/local/lib/python3.11/dist-packages" +BRAND="${BRAND:-MesaStack}" +EXPECTED_PREFIX="${EXPECTED_PREFIX:-mesastack}" + +docker image inspect "${IMAGE}" >/dev/null 2>&1 || fail "image not found locally: ${IMAGE}" +log "verifying ${IMAGE}" + +failed=0 +check() { + local label="${1}"; shift + if "$@" >/dev/null 2>&1; then + echo -e " \033[1;32mPASS\033[0m ${label}" + else + echo -e " \033[1;31mFAIL\033[0m ${label}" + failed=1 + fi +} + +# ---------- 1. Static checks ---------- +step "Static checks" +docker run --rm --entrypoint bash "${IMAGE}" -c " +set -e +echo \"version: \$(gpustack version)\" +echo \"prefix : \$(python3 -c 'from gpustack.security import API_KEY_PREFIX; print(API_KEY_PREFIX)')\" +" || fail "image failed to report version/prefix" + +check "API_KEY_PREFIX == ${EXPECTED_PREFIX}" \ + docker run --rm --entrypoint python3 "${IMAGE}" -c \ + "from gpustack.security import API_KEY_PREFIX as p; assert p == '${EXPECTED_PREFIX}', p" + +check "old gpustack_ keys rejected" \ + docker run --rm --entrypoint python3 "${IMAGE}" -c \ + "from gpustack.security import is_valid_format as v; assert not v('gpustack_aaaa_bbbb')[0]" + +check "masked value uses ${EXPECTED_PREFIX}_" \ + docker run --rm --entrypoint python3 "${IMAGE}" -c \ + "from gpustack.utils.api_keys import get_masked_api_key_value as m; assert m('abcd1234') == '${EXPECTED_PREFIX}_abcd***', m('abcd1234')" + +check "UI index.html present" \ + docker run --rm --entrypoint test "${IMAGE}" -f "${SITE_PACKAGES}/gpustack/ui/index.html" + +check "UI carries ${BRAND} brand" \ + docker run --rm --entrypoint grep "${IMAGE}" -q "${BRAND}" "${SITE_PACKAGES}/gpustack/ui/index.html" + +check "no upstream GPUStack brand in UI index.html" \ + docker run --rm --entrypoint bash "${IMAGE}" -c \ + "! grep -q 'GPUStack' ${SITE_PACKAGES}/gpustack/ui/index.html" + +if [[ "${STATIC_ONLY}" == "1" ]]; then + step "Result" + [[ "${failed}" == "0" ]] && { log "static checks passed"; exit 0; } || fail "static checks failed" +fi + +# ---------- 2. Runtime checks ---------- +step "Runtime checks (booting ${CONTAINER} on :${SMOKE_PORT})" + +cleanup() { + docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true + rm -f "${COOKIES:-/dev/null}" 2>/dev/null || true +} +trap cleanup EXIT + +docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true +docker run -d --name "${CONTAINER}" -p "${SMOKE_PORT}:80" "${IMAGE}" --disable-worker >/dev/null \ + || fail "failed to start container" + +BASE="http://127.0.0.1:${SMOKE_PORT}" +log "waiting for server to become ready" +ready=0 +for _ in $(seq 1 60); do + if curl -sS -m 5 -o /dev/null "${BASE}/" 2>/dev/null; then ready=1; break; fi + if ! docker ps --filter "name=${CONTAINER}" --format '{{.ID}}' | grep -q .; then + docker logs "${CONTAINER}" 2>&1 | tail -20 + fail "container exited during startup" + fi + sleep 5 +done +[[ "${ready}" == "1" ]] || { docker logs "${CONTAINER}" 2>&1 | tail -20; fail "server did not become ready"; } + +check "/ serves ${BRAND} title" \ + bash -c "curl -sS -m 15 '${BASE}/' | grep -q '${BRAND}'" + +COOKIES="$(mktemp)" +PASSWORD="$(docker exec "${CONTAINER}" cat /var/lib/gpustack/initial_admin_password 2>/dev/null || echo "")" +if [[ -z "${PASSWORD}" ]]; then + warn "bootstrap password not found; skipping API key check" +else + login_status="$(curl -sS -c "${COOKIES}" -m 20 -o /dev/null -w '%{http_code}' \ + -X POST "${BASE}/auth/login" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode "username=admin" \ + --data-urlencode "password=${PASSWORD}" || echo "000")" + check "admin login succeeds" test "${login_status}" = "200" + + if [[ "${login_status}" == "200" ]]; then + key_value="$(curl -sS -b "${COOKIES}" -m 20 -X POST "${BASE}/v2/api-keys" \ + -H 'Content-Type: application/json' \ + -d '{"name":"verify-image","description":"prefix check"}' \ + | python3 -c 'import json,sys; print(json.load(sys.stdin).get("value",""))' 2>/dev/null || echo "")" + log "generated key: ${key_value%%_*}_" + check "generated key uses ${EXPECTED_PREFIX}_ prefix" \ + bash -c "[[ '${key_value}' == ${EXPECTED_PREFIX}_* ]]" + fi +fi + +step "Result" +if [[ "${failed}" == "0" ]]; then + log "all checks passed for ${IMAGE}" +else + fail "one or more checks failed for ${IMAGE}" +fi