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<version>-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>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
commit
004d18db92
@@ -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
|
||||
@@ -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<version>-lofyer` branch, so a build can never mix a
|
||||
frontend and backend from different upstream releases.
|
||||
|
||||
```bash
|
||||
scripts/bootstrap [v<version>] # clone both forks, checkout v<version>-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<upstream-version>-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<upstream-version>-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<version>-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 <sha> | 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<version>-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<version>-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<version>
|
||||
```
|
||||
|
||||
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<version>`) 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 <tag>..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
|
||||
`<title>MesaStack</title>`, 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.
|
||||
Executable
+76
@@ -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<version>-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 <<EOF
|
||||
|
||||
Next steps:
|
||||
cd ${UI_REPO} && pnpm install # or npm install, per the frontend repo
|
||||
scripts/build-image # build + verify the container image
|
||||
EOF
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# build-image: one-shot build of the MesaStack control-plane image from the
|
||||
# workspace root. Builds the rebranded frontend, overlays it plus our patched
|
||||
# backend sources onto the matching official release image, then (by default)
|
||||
# smoke tests the result.
|
||||
#
|
||||
# It requires both forks to sit on the *same* v<version>-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}"
|
||||
@@ -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<version>-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<version>-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<version>-lofyer branch"
|
||||
backend_version="$(lofyer_branch_version "${backend_branch}")" \
|
||||
|| fail "backend is on '${backend_branch}', expected a v<version>-lofyer branch"
|
||||
|
||||
[[ "${ui_version}" == "${backend_version}" ]] \
|
||||
|| fail "version mismatch: frontend=${ui_branch} backend=${backend_branch} (must match)"
|
||||
|
||||
echo "${ui_version}"
|
||||
}
|
||||
Executable
+233
@@ -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<version>-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 <<EOF
|
||||
|
||||
Resolve the conflict in ${repo}, then:
|
||||
git -C ${repo} add <files>
|
||||
$(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 <<EOF
|
||||
|
||||
Next steps:
|
||||
1. Verify the build: scripts/build-image
|
||||
2. Review the history: git -C ${UI_REPO} log --oneline ${TARGET}..HEAD
|
||||
git -C ${BACKEND_REPO} log --oneline ${TARGET}..HEAD
|
||||
3. Push both repos: git -C ${UI_REPO} push -u origin ${NEW_BRANCH}
|
||||
git -C ${BACKEND_REPO} push -u origin ${NEW_BRANCH}
|
||||
4. Update the version table in AGENTS.md
|
||||
EOF
|
||||
Executable
+153
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# verify-image: smoke test a built MesaStack image.
|
||||
#
|
||||
# Checks, in order:
|
||||
# 1. static - gpustack version, API_KEY_PREFIX, UI assets carry our brand
|
||||
# and no leftover upstream brand in index.html
|
||||
# 2. runtime - boots the server, confirms / serves the MesaStack title,
|
||||
# logs in and creates an API key to prove the mesastack_ prefix
|
||||
# is live end to end, and that old gpustack_ keys are rejected
|
||||
#
|
||||
# Usage:
|
||||
# scripts/verify-image # verify ${NAMESPACE}/${REPOSITORY}:<workspace version>
|
||||
# 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 '<title>${BRAND}</title>'"
|
||||
|
||||
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%%_*}_<redacted>"
|
||||
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
|
||||
Reference in New Issue
Block a user