Compare commits
No files matched your search
@@ -0,0 +1,180 @@
|
||||
# Attaches binaries to a release when a version tag is pushed.
|
||||
#
|
||||
# Without this a release is a git tag, and installing llmgw on a node means
|
||||
# having a Go toolchain and the source. The onboarding script copies a binary
|
||||
# from wherever it is run, which works and quietly makes whoever runs it the
|
||||
# build server.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
# The built-in token every job is given carries whatever the instance's
|
||||
# default mode allows, and under the restricted default that is read-only
|
||||
# on releases — so the build would succeed and the publish would be
|
||||
# refused. `contents` covers code and releases together, which is what
|
||||
# creating a release needs: `releases: write` alone was refused, because
|
||||
# creating one can create its tag.
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Tags are what the version string is built from, and a shallow clone
|
||||
# has none of them.
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME:-$(git describe --tags --always)}"
|
||||
mkdir -p dist
|
||||
|
||||
# CGO_ENABLED=0 is the whole point: the binary has to run on a node
|
||||
# whose libc is older than the builder's, and llmgw has no C
|
||||
# dependency — modernc.org/sqlite is pure Go precisely so this stays
|
||||
# true.
|
||||
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
|
||||
os="${target%/*}"; arch="${target#*/}"
|
||||
name="llmgw-${version}-${os}-${arch}"
|
||||
echo "building $name"
|
||||
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build \
|
||||
-trimpath \
|
||||
-ldflags "-s -w -X gitea.mynodes.xyz/ilker/llmgw/internal/version.Version=${version}" \
|
||||
-o "dist/${name}" ./cmd/llmgw
|
||||
(cd dist && sha256sum "${name}" >> checksums.txt)
|
||||
done
|
||||
|
||||
# An NVML build for nodes with NVLink, which the portable binary
|
||||
# cannot serve.
|
||||
#
|
||||
# NVML is the only source of NVLink topology, its bindings need cgo,
|
||||
# and cgo is exactly what CGO_ENABLED=0 above exists to avoid. So the
|
||||
# portable binary silently falls back to taking the lowest free GPU
|
||||
# indices — right on an SXM box with NVSwitch, and wrong on a PCIe
|
||||
# box where NVLink bridges pair specific cards: a tensor group
|
||||
# straddling two bridges runs several times slower and nothing says
|
||||
# so. Shipping only the portable build meant nobody could have that
|
||||
# topology without building it themselves.
|
||||
#
|
||||
# Dynamically linked against the host's libc by necessity, so it is
|
||||
# published beside the portable one rather than instead of it.
|
||||
if command -v gcc >/dev/null; then
|
||||
nvml_name="llmgw-${version}-linux-amd64-nvml"
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build \
|
||||
-tags nvml -trimpath \
|
||||
-ldflags "-s -w -X gitea.mynodes.xyz/ilker/llmgw/internal/version.Version=${version}" \
|
||||
-o "dist/${nvml_name}" ./cmd/llmgw
|
||||
(cd dist && sha256sum "${nvml_name}" >> checksums.txt)
|
||||
else
|
||||
echo "no C toolchain; skipping the nvml build" >&2
|
||||
fi
|
||||
|
||||
# Proof the artifact runs at all before it is published. It cannot
|
||||
# test the cross-compiled ones, which is why the native build is
|
||||
# checked rather than assumed.
|
||||
./dist/llmgw-${version}-linux-amd64 --version
|
||||
cat dist/checksums.txt
|
||||
|
||||
- name: Attach to the release
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Gitea gives every job a GITEA_TOKEN, so this is not the usual
|
||||
# "you forgot to add a secret" — an empty one means the job was denied
|
||||
# a token, which is an instance setting rather than a repository one.
|
||||
if [ -z "${TOKEN:-}" ]; then
|
||||
echo "The job was given no GITEA_TOKEN, so nothing can be uploaded."
|
||||
echo "Check Settings -> Actions -> General; the binaries are in dist/."
|
||||
exit 1
|
||||
fi
|
||||
# workflow_dispatch can be fired from a branch, where GITHUB_REF_NAME
|
||||
# is the branch name. Uploading then would create a release called
|
||||
# "main". The binaries are still built and kept as a check that the
|
||||
# release build works; only the publishing is a tag's business.
|
||||
if [ "${GITHUB_REF_TYPE:-}" != "tag" ]; then
|
||||
echo "not a tag (ref=${GITHUB_REF_NAME}); built, not published"
|
||||
exit 0
|
||||
fi
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||
|
||||
# Gitea materialises a release row for every pushed tag, so this
|
||||
# nearly always finds one and the create below is for a tag pushed
|
||||
# some other way.
|
||||
#
|
||||
# The id is read with grep rather than sed because sed's .* is greedy:
|
||||
# "id" appears again inside the nested author object, so the old
|
||||
# pattern took the author's id when it matched at all — and it did not
|
||||
# match "id": 13 with a space, which is what actually happened. The
|
||||
# first "id" in a release object is the release's own.
|
||||
release_id() {
|
||||
curl -sS -H "Authorization: token $TOKEN" "$api/releases/tags/$tag" \
|
||||
| grep -o '"id"[[:space:]]*:[[:space:]]*[0-9]*' \
|
||||
| head -1 | grep -o '[0-9]*$'
|
||||
}
|
||||
|
||||
id=$(release_id || true)
|
||||
if [ -z "$id" ]; then
|
||||
echo "no release for $tag yet; creating one"
|
||||
out=$(curl -sS -w '\n%{http_code}' -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \
|
||||
"$api/releases")
|
||||
echo "create returned $(printf '%s' "$out" | tail -1)"
|
||||
# Re-read rather than parsing the create response: a 409 means it
|
||||
# was there all along, which is a success for our purposes.
|
||||
id=$(release_id || true)
|
||||
[ -n "$id" ] || printf '%s\n' "$out" | head -5 >&2
|
||||
fi
|
||||
[ -n "$id" ] || { echo "could not find or create the release" >&2; exit 1; }
|
||||
echo "release id $id"
|
||||
|
||||
for f in dist/*; do
|
||||
echo "uploading $(basename "$f")"
|
||||
curl -sS -f -X POST -H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$f" \
|
||||
"$api/releases/$id/assets?name=$(basename "$f")" >/dev/null \
|
||||
|| { echo "upload of $(basename "$f") was refused" >&2; exit 1; }
|
||||
done
|
||||
echo "attached $(ls dist | wc -l) files to $tag"
|
||||
|
||||
# The release page says what changed, copied from CHANGELOG.md. Gitea makes
|
||||
# an empty release for every tag and the job above only attaches files, so
|
||||
# every release until this existed was a list of binaries and nothing else.
|
||||
#
|
||||
# After the build, because that is what guarantees the release exists. Run by
|
||||
# hand from a branch it syncs every version in the changelog, which is how
|
||||
# the releases made before this got their notes.
|
||||
notes:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Copy the changelog onto the release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_INSTANCE: ${{ github.server_url }}
|
||||
GITEA_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||
python3 scripts/gitea/release_notes.py --tag "${GITHUB_REF_NAME}"
|
||||
else
|
||||
python3 scripts/gitea/release_notes.py
|
||||
fi
|
||||
+509
-6
@@ -8,14 +8,517 @@ is only released once its **exit criterion** is met, not when its code lands.
|
||||
| v0.1.0 | 0 | Skeleton and sim-engine | released |
|
||||
| v0.2.0 | 1 | Static router: streaming proxy, filter/score/pick | released |
|
||||
| v0.3.0 | 1.5 | Admission control: priority bands, saturation detector | released |
|
||||
| v0.4.0 | 2 | Agent: GPU/IB discovery, single-node engines | in progress |
|
||||
| v0.5.0 | 3 | Control plane: catalog, placement, reconciler | planned |
|
||||
| v0.6.0 | 4 | Multi-node replicas and fabric awareness | planned |
|
||||
| v0.4.0 | 2 | Agent: GPU/IB discovery, single-node engines | released as part of v0.5.0 |
|
||||
| v0.5.0 | 3 | Control plane: catalog, placement, reconciler | released |
|
||||
| v0.6.0 | 4 | Multi-node replicas and fabric awareness | released |
|
||||
| v0.7.0 | — | Conversations, agent memory, disk management | released |
|
||||
| v0.8.0 | — | PostgreSQL, leader election, agent failover | released |
|
||||
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
|
||||
|
||||
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
|
||||
APIs change between minors without a deprecation period.
|
||||
|
||||
## v0.8.5 — 2026-09-28
|
||||
|
||||
### A control plane in one command
|
||||
|
||||
```
|
||||
sudo llmgw server init --admin you@example.com
|
||||
```
|
||||
|
||||
Creates the llmgw user and its directories, the certificate authority, the
|
||||
control plane's certificate for the machine's hostname and every address it
|
||||
has, `server.yaml`, the default project, and an administrator whose password is
|
||||
generated and printed once — then installs the systemd unit, starts it and
|
||||
waits until it answers. What used to be two steps of install.md is now this.
|
||||
|
||||
Each step is skipped when its result exists, so running it again finishes an
|
||||
install that stopped and changes nothing on one that did not. `--host` adds an
|
||||
address the machine cannot see of itself (a DNS alias, a NAT or floating
|
||||
address) and reissues the certificate keeping the old ones. It never overwrites
|
||||
`server.yaml` or an authority, and certificates issued by an authority kept on
|
||||
another machine are left as they are.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **The configuration install.md showed could not start.** An empty catalog
|
||||
without `control.enabled: true` is refused as having nothing to serve. The
|
||||
documentation and the file `init` writes both set it.
|
||||
|
||||
## v0.8.4 — 2026-09-28
|
||||
|
||||
### Node logs on the admin page
|
||||
|
||||
Each node on the cluster page links to its logs — the agent's own, and the
|
||||
output of every engine running there — with an option to follow them. `llmgw
|
||||
ctl logs NODE` reads the same. They come over the agent's existing stream, so
|
||||
no ssh, jump host or root is needed to see what a node did. The agent keeps its
|
||||
last 2000 lines in memory; each engine its last few hundred.
|
||||
|
||||
## v0.8.3 — 2026-09-28
|
||||
|
||||
Found by building a node from nothing with v0.8.2.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **A new node's first engine install failed on apt's lock.** cloud-init and
|
||||
unattended-upgrades run apt in a machine's first minutes, and
|
||||
`DPkg::Lock::Timeout` does not cover the lock `apt-get update` takes. A
|
||||
package command held up by a lock now waits for up to ten minutes, and says
|
||||
so; a failed install is retried after a minute rather than ten.
|
||||
- **A new node reported no disk** until its first download created the weights
|
||||
directory. It is created when the agent starts.
|
||||
- **Onboarding took one control plane address.** It takes several, comma
|
||||
separated, so a new node fails over like the rest.
|
||||
|
||||
## v0.8.2 — 2026-09-28
|
||||
|
||||
A node no longer runs whatever engine somebody once put on it. It installs its
|
||||
own.
|
||||
|
||||
### Engines are installed by the node
|
||||
|
||||
`engines:` in server.yaml names the engines every node has and their versions,
|
||||
defaulting to the llama.cpp build this release was checked against (b10991).
|
||||
Each agent converges on it: it picks the build for its hardware — CUDA with its
|
||||
runtime when an NVIDIA driver is loaded, CPU otherwise — installs the OS
|
||||
packages that build needs with the machine's own package manager, downloads the
|
||||
archive and checks it against the sha256 GitHub publishes, unpacks it refusing
|
||||
anything that would land outside its directory, and runs it. `ldd` names a
|
||||
missing library or a system too old for the build ("libstdc++.so.6 is too old
|
||||
(needs GLIBCXX_3.4.30)"); `--version` proves the rest. Installs are checked
|
||||
again every ten minutes.
|
||||
|
||||
An engine is reported only once installed and seen to run, or when the agent's
|
||||
configuration names it on purpose. A node that has installed nothing yet is not
|
||||
a candidate for any model, where before it was a candidate for all of them.
|
||||
`ctl nodes`, the cluster page and placement's refusals say where each
|
||||
installation stands, and warn about a CPU without AVX2.
|
||||
|
||||
Not every system is refused for its name: the build runs on Ubuntu 22.04+,
|
||||
Debian 12+ and RHEL-family 10, and `ldd` decides. vLLM and SGLang are not yet
|
||||
installed this way and must still be named in the agent's configuration.
|
||||
|
||||
### Onboarding
|
||||
|
||||
Onboarding installs llmgw from the CI release for the node's architecture,
|
||||
checked against the release's checksums, and writes an agent configuration of
|
||||
only the control plane's addresses and the TLS files.
|
||||
|
||||
### Fixes
|
||||
|
||||
- A race on the weights fingerprint, which two heartbeat loops touched during a
|
||||
failover.
|
||||
|
||||
## v0.8.1 — 2026-09-28
|
||||
|
||||
Nothing here changes a configuration format: every file that worked with
|
||||
v0.8.0 still works. What changes is how much of it needs writing, and how much
|
||||
of running the cluster can be done from a page rather than a shell.
|
||||
|
||||
### A node describes itself
|
||||
|
||||
An agent file used to name its node, its address, the path to llama-server and
|
||||
its weight directories — different on every node and kept true by hand. The
|
||||
agent now finds these out: its id is the hostname, its address is the one on
|
||||
the route to the control plane, llama-server is looked for on PATH and in
|
||||
`/opt/llama.cpp`, a node without nvidia-smi simply has no NVIDIA cards, and
|
||||
fetched weights land in `/var/lib/llmgw-weights`, which is always scanned.
|
||||
Memory is re-read on every heartbeat and installed engines once a minute, so a
|
||||
VM given more RAM or a node given an engine becomes a candidate without an
|
||||
agent restart; the control plane logs both. The agent file shrinks to the
|
||||
control plane's addresses and the TLS files, the same on every node.
|
||||
|
||||
### Models from the mirror, without writing YAML
|
||||
|
||||
Search the registry from `/admin/mirror`, fetch a sharded model in one action,
|
||||
and serve a mirrored version from its row — name, source, engine and an
|
||||
estimated memory requirement filled in. A model can say what it is for, and
|
||||
`/v1/models` shows it. Placement no longer hands the same memory to two models
|
||||
or puts an engine on a node that lacks it, and says when a replica runs an
|
||||
edited profile.
|
||||
|
||||
### Tenancy, quotas and usage on pages
|
||||
|
||||
The tenancy tree, quotas and budgets are managed from the admin pages; an
|
||||
organization's budget bounds its teams, a group's quota counts against the
|
||||
group, and a budget that cannot be spent against says so. `/admin/usage`
|
||||
charts requests, tokens and spend over time by organization, team, project or
|
||||
person, and a request is priced by the model that served it.
|
||||
|
||||
### Watching the cluster
|
||||
|
||||
The cluster page has its own charts, backed by a year of Prometheus history,
|
||||
with alerts that were made to fire before being trusted — including for the
|
||||
disk the metrics share with the models.
|
||||
|
||||
### Chat
|
||||
|
||||
The picker offers each model once rather than once per alias, never asks for
|
||||
more tokens than the model allows, and offers a model that loads on demand —
|
||||
sending it a message is what loads it.
|
||||
|
||||
## v0.8.0 — 2026-09-27
|
||||
|
||||
The store stops being a file on one machine's disk. PostgreSQL, a way to move
|
||||
an existing installation onto it without reissuing every key, and the two
|
||||
things a second control plane needs so that running one is safe rather than
|
||||
clever.
|
||||
|
||||
### A second control plane, and a way to fail over to it
|
||||
|
||||
Leadership is a lease in the store, so two control planes against one database
|
||||
cannot both place work — which they otherwise would, both seeing a model
|
||||
wanting replicas and none running, leaving twice the replicas each holding GPUs
|
||||
the other believes are free.
|
||||
|
||||
A standby also refuses agent streams. Commands travel down the stream the agent
|
||||
opened, so a standby holding one is a node the leader cannot command, and the
|
||||
leader's placements would fail with "no agent connected" for a node that is
|
||||
plainly connected. Agents therefore take a list of addresses rather than one,
|
||||
trying each in turn: a standby refusing, a stopped control plane refusing the
|
||||
connection and a crashed one never answering are the same thing from a node.
|
||||
|
||||
Measured on the cluster. Leadership moved three seconds after the leader was
|
||||
stopped — it released the lease on the way out rather than letting it lapse —
|
||||
and all five agents had registered with the standby six seconds later. The
|
||||
standby adopted every replica rather than restarting any: six replicas, all
|
||||
ready, engines that never stopped serving.
|
||||
|
||||
Everything is unset-means-leader, so a single control plane and every
|
||||
deployment that existed before this behave exactly as they did.
|
||||
|
||||
What this does not do is move client traffic. The standby served inference
|
||||
correctly on its own port while the public address returned 502, because
|
||||
nothing in front of them moved — that needs a load balancer or a shared
|
||||
address, and it is the remaining piece.
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
The store runs on PostgreSQL, chosen by what the address looks like: a path is
|
||||
still SQLite, a `postgres://` URL is not, and `@` followed by a path reads the
|
||||
address out of a file so a DSN carrying a password stays out of a configuration
|
||||
that gets copied and pasted into issues. Every existing installation keeps
|
||||
working untouched.
|
||||
|
||||
One set of queries rather than one per database, because the SQL here is almost
|
||||
entirely portable and two copies would drift one query at a time. The rewrite
|
||||
of placeholders is a wrapper around the handle rather than a change at sixty
|
||||
call sites — which found six places reaching past it, including all three
|
||||
manual transactions and two prepared statements, every one of which worked on
|
||||
SQLite and would have failed on PostgreSQL.
|
||||
|
||||
Exactly one query is not the same sentence in both: the note search, MATCH
|
||||
against `@@`. SQLite gets full-text search from an FTS5 table kept in step by
|
||||
three triggers; PostgreSQL derives a tsvector from the row, so there is nothing
|
||||
to keep in step and no trigger to forget. It uses the 'simple' configuration
|
||||
rather than English — these are an agent's own notes, in whatever language it
|
||||
chose, and stemming English rules over Turkish would quietly stop finding
|
||||
things.
|
||||
|
||||
`llmgw keys move-store` copies an existing store rather than starting a fresh
|
||||
one, because keys are stored hashed: a migration that drops them cannot
|
||||
recreate them, and every person and every script would need a new one.
|
||||
|
||||
Testing it against a real database found three things nothing else would have.
|
||||
`schema_migrations` is created in Go rather than in a migration file, so it
|
||||
escaped the port and still said INTEGER — 64 bits in SQLite, 32 in PostgreSQL,
|
||||
and a millisecond timestamp does not fit. The copy order followed the order the
|
||||
migrations create tables in, and `users` is created after `api_keys` even
|
||||
though `api_keys` references it. And `key_claims.sealed` is emptied the moment
|
||||
a claim is used, so a claimed row holds an empty blob in a NOT NULL column — an
|
||||
empty blob scans as a typed nil slice and a real NULL as an untyped one, a
|
||||
difference pgx cannot see, so it arrived as NULL.
|
||||
|
||||
The driver is pgx, which is pure Go: the whole binary is built with
|
||||
CGO_ENABLED=0 so that it runs on a node whose libc is older than the builder's,
|
||||
and a cgo driver would take that away from every deployment, including the ones
|
||||
staying on SQLite.
|
||||
|
||||
## v0.7.0 — 2026-09-25
|
||||
|
||||
What a cluster needs to be lived with rather than demonstrated: somewhere to
|
||||
keep a conversation, somewhere to keep a note, and an honest answer about the
|
||||
disk underneath both.
|
||||
|
||||
### Memory notes for agents, and threads without API keys
|
||||
|
||||
Notes an agent writes for itself and reads back in later conversations, offered
|
||||
as REST (`/v1/memory`) and as MCP tools at `/mcp` (`remember`, `recall`,
|
||||
`forget`). A note is a key, text and tags, searched by words or tag, private to
|
||||
a project and to the agent id in `x-llmgw-agent`. llmgw does not inject the
|
||||
tools into chat requests: an agent that is not given the server never sees
|
||||
memory.
|
||||
|
||||
Off for every project until someone runs `llmgw keys memory --retain-hours N`,
|
||||
and the retention is required, as it is for threads.
|
||||
|
||||
On a gateway with no authentication, a `threads:` block (`store`,
|
||||
`retain_hours`) now keeps conversations and notes in one shared pool. Before,
|
||||
`x-llmgw-thread` was silently ignored there; now a gateway configured to keep
|
||||
nothing refuses it with an explanation instead. The shared pool is for one
|
||||
trusted machine: anyone who can reach the port and knows an id can read it.
|
||||
|
||||
### Conversations that can move between models
|
||||
|
||||
A thread a client names with `x-llmgw-thread`, carrying only its new turn.
|
||||
llmgw replays the stored history to whichever model is serving, which is what
|
||||
lets a conversation begun on a large model continue on a small one — including
|
||||
when a spent budget moved it there and the caller never chose.
|
||||
|
||||
Off for every project until someone runs `llmgw keys threads`. Until then llmgw
|
||||
stores no message content anywhere, as it never has, and that default is worth
|
||||
more than the feature. The retention is required rather than optional, because
|
||||
a conversation store with no expiry is a transcript of everything a team ever
|
||||
typed kept forever by omission, and it is measured from the last turn so a
|
||||
conversation in progress does not expire underneath it.
|
||||
|
||||
History that does not fit the serving model is trimmed oldest-first and the
|
||||
reply says how many turns went, in `x-llmgw-thread-trimmed`. System messages
|
||||
are never dropped — losing one silently changes what the model is for while it
|
||||
carries on answering — and neither is the turn the caller just sent.
|
||||
|
||||
Threads are scoped to a project, and reading, writing or deleting one that
|
||||
belongs to another answers exactly as one that never existed: two answers would
|
||||
let a caller map which ids are real in projects they cannot read.
|
||||
|
||||
Capturing the reply is opt-in and bounded, so a request without a thread
|
||||
accumulates nothing on the hottest path in the system, and one with a thread
|
||||
stops at a cap rather than growing to it.
|
||||
|
||||
`DELETE /v1/threads/{id}` forgets a conversation, as a client rather than as an
|
||||
operator: the person who said the thing should not have to ask someone to read
|
||||
it before it can go.
|
||||
|
||||
### Disk, and taking back only what llmgw put there
|
||||
|
||||
Disk was the one resource nothing reported. `statfs` was already being called
|
||||
on every model directory, but only to ask whether the filesystem was a network
|
||||
one — the free space in the same struct was discarded, so the only way to find
|
||||
out why a node was filling up was to ssh to it.
|
||||
|
||||
Nodes now report it, per filesystem rather than per configured directory: free
|
||||
space belongs to the disk, and two model directories on one disk are one pool
|
||||
of bytes. Shared filesystems are kept out of a node's own figure, because space
|
||||
on a filer belongs to everyone mounting it and twenty nodes would otherwise
|
||||
each report the same free terabyte. It shows in `ctl nodes`, the dashboard and
|
||||
Prometheus, and a node that reported nothing reads as unknown everywhere — an
|
||||
older agent must not look like a full disk.
|
||||
|
||||
A profile may declare `min_disk_gb`, and a node without the room is no longer a
|
||||
candidate, with a reason `explain-placement` prints. Skipped on a node that
|
||||
already holds the weights: it needs no room for them, and without that the node
|
||||
best placed to run a model is the one most likely to be rejected.
|
||||
|
||||
`ctl weights` lists model files llmgw downloaded for models no longer in the
|
||||
catalog, and `--collect` deletes them. The design is mostly about what it will
|
||||
not do. Only files llmgw fetched are candidates, and that is a calculation
|
||||
rather than a heuristic — the cache name is a hash of the source, so the wanted
|
||||
set is computable exactly. Anything an operator put on a node is left alone
|
||||
however unreferenced it looks. Each node checks every path again before
|
||||
unlinking it, refusing anything outside its cache directory or without the
|
||||
cache name, so a control plane that is wrong cannot talk a node into losing a
|
||||
model that took a week to copy.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **A capability was lost at an indirection.** The controller is handed a
|
||||
forwarding dispatcher, and reaches past its interface by type assertion to
|
||||
find what lies beyond scheduling. Deleting weights was implemented on the
|
||||
agent service and not on the forwarder, so it was simply absent at runtime —
|
||||
with the whole suite green, because the dispatchers the tests use do not
|
||||
implement it either and its absence there is the expected case.
|
||||
- **`ctl tool-check` blamed the engine for what the model did.** It had one
|
||||
verdict for a failed attempt and told operators not to try another model.
|
||||
Measured: the engine renders the tool prompt correctly, the coder models do
|
||||
not use the `<tool_call>` wrapper it asks for, and `qwen2.5-3b-instruct` on
|
||||
the same engine returns a populated `tool_calls` array. A failed attempt is
|
||||
now two verdicts, told apart by whether the reply used the wrapper.
|
||||
|
||||
## v0.6.0 — 2026-09-18
|
||||
|
||||
Phase 4: a replica can be bigger than one machine, and the fabric it spans
|
||||
decides where it is allowed to go. Around that, everything a cluster needs
|
||||
before anyone other than its operator is let near it — keys, budgets, a way in
|
||||
through a browser, and enough visibility to answer "why is nothing scheduling".
|
||||
|
||||
### Multi-node replicas
|
||||
|
||||
A replica is now a set of shards with ranks, a leader and a bootstrap address,
|
||||
rather than one process. Two invariants are enforced rather than documented: a
|
||||
replica never spans two fabrics, and a tensor-parallel group never mixes GPU
|
||||
classes. Both are the kind of mistake that does not fail — it hangs, or it runs
|
||||
several times slower, and nothing says which.
|
||||
|
||||
A replica missing a shard is not ready. It was previously derived from the
|
||||
shards that had reported, so a two-node replica with one node up looked
|
||||
servable and took traffic into a model that could not answer.
|
||||
|
||||
Adoption after a restart happens one agent at a time, because that is how
|
||||
agents reconnect. Merging them as they arrive is what keeps a multi-node
|
||||
replica whole instead of being torn into fragments that are then killed as
|
||||
strays — and an adopted engine is health-checked before it is called ready,
|
||||
rather than being assumed alive because its process exists.
|
||||
|
||||
### Fabrics are distribution domains too
|
||||
|
||||
An isolated fabric is not only a compute boundary: weights present in one are
|
||||
not present in the other. Node weight inventories are reported and summarised
|
||||
per fabric, so "the model is on the cluster" is never mistaken for "the model
|
||||
is reachable from the nodes that would run it". A fabric name that matches
|
||||
nothing a node actually has is refused at registration rather than becoming an
|
||||
unschedulable replica hours later.
|
||||
|
||||
### Who may use it
|
||||
|
||||
API keys, tenants and per-model allowlists, on SQLite (`modernc.org/sqlite`, so
|
||||
still no cgo). Rate limits and budgets are enforced on the request path; usage
|
||||
is written off it, in batches, so accounting never adds latency to inference.
|
||||
Budgets survive a restart — counters are rebuilt from the store, because a
|
||||
limit anyone can clear by bouncing the process is not a limit.
|
||||
|
||||
A spent budget is now served a cheaper model where the catalog offers one,
|
||||
rather than refused. For a team trying models out, being dropped to something
|
||||
smaller is a far better answer than a wall, and the response says which model
|
||||
answered.
|
||||
|
||||
People can sign up, be approved, and issue and revoke their own keys. A key is
|
||||
shown once; only its hash is kept.
|
||||
|
||||
### The browser surface
|
||||
|
||||
A landing page that says how to connect, a read-only cluster dashboard, and an
|
||||
in-browser chat that never puts an API key in the browser. The dashboard shows
|
||||
who is using the cluster and what they are running — per person and per model,
|
||||
with requests, tokens, errors, spend and latency.
|
||||
|
||||
All of it is served from the client-facing port behind a session, deliberately
|
||||
not from the admin port, which also serves pprof — and pprof can dump the heap
|
||||
that holds every API key in plaintext.
|
||||
|
||||
### Models without a restart
|
||||
|
||||
The catalog is live. Models can be added and removed from the command line or
|
||||
the API, take effect immediately, and outlive a restart; configuration still
|
||||
wins over the store, and the whole catalog is validated before anything is
|
||||
written.
|
||||
|
||||
A model is only listed once it can actually serve. It was listed as soon as it
|
||||
was known, so the first thing a new user did was ask for a model that returned
|
||||
503.
|
||||
|
||||
### Loading on demand, and letting go
|
||||
|
||||
A model whose scaling allows it is started when someone asks for it, and
|
||||
stopped when nobody has for a while. An idle replica is evicted to make room
|
||||
for one that was asked for, with guards that exist because each is a way to
|
||||
lose work quietly: only on-demand models, only single-node replicas, never one
|
||||
that has never been used, and never one younger than its minimum lifetime.
|
||||
|
||||
Unknown is not idle. A replica with no recorded use is left alone rather than
|
||||
treated as idle since the epoch.
|
||||
|
||||
### Knowing whether it will run before it runs
|
||||
|
||||
- `ctl plan` works out whether a model fits the cluster and how it would be
|
||||
split, in the precision the chosen engine can actually serve — recommending
|
||||
a quantisation the engine cannot load would cause the failure the command
|
||||
exists to prevent.
|
||||
- `ctl tool-check` verifies llmgw's engine flags against the engines actually
|
||||
installed. Flags drift between releases, and the alternative is finding out
|
||||
during a model load.
|
||||
- Placement now knows a model's context length and memory footprint, and node
|
||||
RAM, so a CPU profile is not placed on a machine that cannot hold it.
|
||||
- Placement waits for the fleet to settle after a restart rather than deciding
|
||||
against a half-reported cluster.
|
||||
|
||||
### Weights
|
||||
|
||||
Nodes report the model files they can see, without llmgw owning the storage:
|
||||
a shared filer, an image exported over the fabric, rsync or a copy made by hand
|
||||
all look the same to placement, which only cares whether the files are there.
|
||||
Reporting nothing means "not asked to look", not "has nothing".
|
||||
|
||||
Where the simple arrangement is not enough, a node can fetch a model by URL —
|
||||
single-flight, resumable, written to a temporary name and renamed atomically,
|
||||
so a killed download never leaves something that looks like weights.
|
||||
|
||||
### Seeing the fleet
|
||||
|
||||
Prometheus metrics for nodes, GPUs and replicas, collected from live state at
|
||||
scrape time rather than from a background goroutine, and served on their own
|
||||
endpoint rather than through the one that also exposes pprof. The same view is
|
||||
in the dashboard, so nothing else needs installing to see it.
|
||||
|
||||
### Onboarding a node
|
||||
|
||||
Adding a node is one command. There are systemd units, a private CA and a
|
||||
from-nothing install guide that starts with a machine that has no CUDA and no
|
||||
binary.
|
||||
|
||||
### llama.cpp, and CPU profiles
|
||||
|
||||
A third engine adapter, and the reason for it: llama.cpp serves the same OpenAI
|
||||
API without a GPU, so placement, supervision, scraping, scheduling and admission
|
||||
can all be exercised on ordinary machines.
|
||||
|
||||
A profile may now omit `gpu_class`, which is how it says it runs on the CPU. Only
|
||||
engines that can actually serve without a card may omit it — for vLLM an omitted
|
||||
class stays an error, because otherwise a typo becomes a replica placed on a
|
||||
machine with no hardware and diagnosed minutes later during model loading. CPU
|
||||
replicas are spread across nodes rather than bin packed: unlike GPUs there is no
|
||||
allocation to stop two of them sharing a machine's cores.
|
||||
|
||||
### Metric mappings are per engine
|
||||
|
||||
`Running`, `Waiting` and `KVCacheUsage` now have a vocabulary per engine rather
|
||||
than one for the cluster. A mixed fleet has two names for the same three numbers,
|
||||
and a single global mapping left half of it unmeasurable.
|
||||
|
||||
A mapping that leaves a metric unnamed now means the engine does not publish it,
|
||||
and the field stays zero. Naming one the engine does not serve is still a contract
|
||||
violation. `Running` and `Waiting` can never be omitted.
|
||||
|
||||
### Mutual TLS on the agent stream
|
||||
|
||||
A private CA (`llmgw ctl cert init-ca | issue-server | issue-client | inspect`),
|
||||
certificates on both ends, and the control plane refusing any agent whose claimed
|
||||
node id does not match its certificate's common name. Public authorities cannot
|
||||
supply these: they stopped issuing the `clientAuth` usage.
|
||||
|
||||
Plaintext HTTP/2 remains the default. `agent_tls:` on the server and `tls:` on the
|
||||
agent turn it on.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **An agent told to stop did not stop.** Cancelling the context is not enough —
|
||||
HTTP/2 watches it while the request body is being written, and a bidirectional
|
||||
stream spends its life blocked on the read side. Measured on a deployed node:
|
||||
`systemctl stop` took 90 seconds and ended in SIGKILL. Now 0.01 seconds.
|
||||
- **An agent could not explain why it was refused.** The session cancelled its own
|
||||
context and then asked that context whether it was shutting down, so every
|
||||
stream error read as a clean close: no message, and the backoff reset.
|
||||
- **A broken metric contract was invisible.** It was logged per scrape at debug
|
||||
level, so a cluster could look healthy while every routing decision was made
|
||||
blind. It is now a warning, once, with a matching line when it clears.
|
||||
- **Token estimates came from the routing prefix, not the prompt.** Every prompt
|
||||
over about 2 KB was undercounted, so tokens-per-minute limits let through far
|
||||
more than they were set to.
|
||||
- **Model-fitting double-counted overhead.** Whole-model overhead was charged per
|
||||
rank and a 10% reserve taken twice, so `plan` reported that a 70B model in
|
||||
bfloat16 does not fit two 80 GB cards. It does.
|
||||
- **`x-llmgw-served-model` was never sent.** It was set after the proxy had
|
||||
already written the response header, which is the one point at which headers
|
||||
stop being changeable. A client had no way to tell a fallback from the model
|
||||
it asked for.
|
||||
- **Fleet metrics published nothing.** They were registered after the registry
|
||||
had been built, so the collector existed and was never scraped.
|
||||
- **GPU discovery broke on older drivers.** A field added for utilisation was
|
||||
required rather than optional, so a driver that does not report it produced no
|
||||
inventory at all instead of an inventory without that number.
|
||||
- **Replica ids and ports were reused** while the processes holding them were
|
||||
still alive.
|
||||
|
||||
## v0.5.0 — 2026-09-16
|
||||
|
||||
Phases 2 and 3. The cluster runs itself: agents report their hardware, the
|
||||
@@ -50,10 +553,10 @@ not have. The failure path already ships an engine log tail with every status
|
||||
report, so the case that matters most is covered; reading a healthy shard's
|
||||
output on demand is not.
|
||||
|
||||
## Unreleased — phase 4 in progress
|
||||
### Phases 2 and 3, in detail
|
||||
|
||||
Agent groundwork. Everything here works without GPUs and is covered by tests;
|
||||
what is missing is the control plane connection, so there is no release yet.
|
||||
Written as the work landed, which is why it reads as a log rather than as
|
||||
notes. Everything here shipped in v0.5.0.
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -6,6 +6,18 @@ heterogeneous GPUs.
|
||||
It serves LLMs running on your own GPU servers (vLLM / SGLang) behind one
|
||||
OpenAI-compatible endpoint. Design document: [llmgw-plan.md](llmgw-plan.md).
|
||||
|
||||
## Getting started
|
||||
|
||||
Starting from nothing — no packages installed, no drivers, no nodes — follow
|
||||
[docs/install.md](docs/install.md): it builds a control plane, adds a node and
|
||||
serves a model, and says plainly which parts llmgw does not install for you.
|
||||
|
||||
| Guide | For |
|
||||
|---|---|
|
||||
| [docs/install.md](docs/install.md) | Standing up a cluster from scratch |
|
||||
| [docs/node-onboarding.md](docs/node-onboarding.md) | Adding nodes to one that exists |
|
||||
| [docs/onboarding.md](docs/onboarding.md) | Giving people accounts and keys |
|
||||
|
||||
## Status
|
||||
|
||||
Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
|
||||
@@ -17,13 +29,47 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
|
||||
| v0.3.0 | 1.5 | Admission control (priority bands) | released |
|
||||
| v0.4.0 | 2 | Agent: GPU/IB discovery, engine supervision | released |
|
||||
| v0.5.0 | 3 | Control plane: catalog, placement, reconciler, ctl | released |
|
||||
| v0.6.0 | 4 | Multi-node placement and fabric awareness | planned |
|
||||
| v0.6.0 | 4 | Multi-node placement and fabric awareness | released |
|
||||
| v0.7.0 | — | Conversations, agent memory, disk management | released |
|
||||
| v0.8.0 | — | PostgreSQL, leader election, agent failover | released |
|
||||
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
|
||||
|
||||
As of v0.3.0 the router serves traffic and protects it: a batch workload no
|
||||
longer starves interactive requests. Still missing before v1.0.0: authentication,
|
||||
rate limits and budgets, and the control plane that replaces the static endpoint
|
||||
list.
|
||||
As of v0.6.0 a cluster runs unattended: agents report their hardware, the
|
||||
control plane places replicas — including ones larger than a single machine —
|
||||
models load when someone asks for them and stop when nobody does, and keys,
|
||||
budgets and rate limits decide who may use any of it.
|
||||
|
||||
Before v1.0.0: tracing, reference documentation, and filtering on `ctl nodes`
|
||||
and `ctl replicas` for fleets too large to read a whole table of. Two items
|
||||
that were on this list are not gaps: a model falls through to its fallbacks
|
||||
when there is no capacity as well as when a budget is spent, and routing
|
||||
profiles are chosen by the key rather than by a request header on purpose —
|
||||
a header would let any client promote itself into the top priority band.
|
||||
|
||||
One thing is worth saying plainly:
|
||||
multi-node replicas are proven on a simulated cluster and on real hardware
|
||||
without a fabric. The InfiniBand, NVML and NCCL paths are written against the
|
||||
formats and failure modes those tools actually have, but they have not yet run
|
||||
on a machine that has them.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [docs/install.md](docs/install.md) — installing from nothing: control plane,
|
||||
nodes, engines, weights, first request
|
||||
- [docs/quickstart.md](docs/quickstart.md) — the short path
|
||||
- [docs/choosing-models.md](docs/choosing-models.md) — which models to run,
|
||||
how many of each, and where the weights should live
|
||||
- [docs/data.md](docs/data.md) — what llmgw stores, what it does not, and
|
||||
what leaves the machine; written for a security review
|
||||
- [docs/reference.md](docs/reference.md) — every configuration key, generated
|
||||
from the structs and checked by a test, so it cannot describe a setting that
|
||||
does not exist or miss one that does
|
||||
- [deploy/mirror/](deploy/mirror/) — a model mirror, which is the cheapest
|
||||
and most useful thing to build around llmgw
|
||||
- [deploy/ha/](deploy/ha/) — running two control planes, and the one health
|
||||
check that has to be right
|
||||
- [docs/node-onboarding.md](docs/node-onboarding.md),
|
||||
[docs/onboarding.md](docs/onboarding.md) — adding machines, and adding people
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
+1156
-98
File diff suppressed because it is too large.
Load diff
@@ -23,6 +23,7 @@ message AgentMessage {
|
||||
Register register = 1;
|
||||
Heartbeat heartbeat = 2;
|
||||
ShardStatus shard = 3;
|
||||
LogsReply logs = 4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +34,9 @@ message ControlMessage {
|
||||
StopShard stop = 2;
|
||||
DrainShard drain = 3;
|
||||
Ack ack = 4;
|
||||
RemoveWeights remove_weights = 5;
|
||||
Provision provision = 6;
|
||||
LogsRequest logs = 7;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +71,84 @@ message Register {
|
||||
// — so the new engines fail to allocate, with an error naming the wrong
|
||||
// process (plan §6.5).
|
||||
repeated RunningShard running_shards = 9;
|
||||
|
||||
// memory_total_mb is the machine's RAM, and 0 means the agent did not say —
|
||||
// which is what every agent older than this field reports, and is treated as
|
||||
// no constraint rather than as a node with no memory.
|
||||
//
|
||||
// Placement needs it because a CPU profile has no card to be free, so every
|
||||
// ready node looks like a candidate: a 14B model whose working set is 24 GB
|
||||
// would be placed just as happily on a node with 8 GB.
|
||||
int64 memory_total_mb = 10;
|
||||
|
||||
// weights are the model files this node can see, from the directories its
|
||||
// configuration names.
|
||||
//
|
||||
// How they got there is the operator's business and deliberately outside
|
||||
// llmgw's: a shared filesystem, an image exported over the fabric, rsync, or
|
||||
// a copy made by hand all look identical here. What matters to placement is
|
||||
// only whether they are present, because a node without them either cannot
|
||||
// start the replica at all or must first move tens of gigabytes.
|
||||
//
|
||||
// Empty means the node was not asked to look, which is not the same as having
|
||||
// nothing — an agent with no model directories configured behaves exactly as
|
||||
// every agent did before this existed, and constrains nothing.
|
||||
repeated LocalWeights weights = 11;
|
||||
|
||||
// storage is the disk behind those weights, one entry per filesystem.
|
||||
//
|
||||
// Reported so an operator can see a node filling up before a fetch dies at
|
||||
// ninety percent, and so placement can refuse a node that cannot hold a
|
||||
// model rather than discovering it during the download.
|
||||
//
|
||||
// Empty means the node was not asked to look, exactly as for weights, and
|
||||
// constrains nothing.
|
||||
repeated Storage storage = 12;
|
||||
|
||||
// host is what the machine is, so the control plane can have the mirror
|
||||
// fetch software built for it rather than assuming every node matches the
|
||||
// first.
|
||||
Host host = 13;
|
||||
|
||||
// engines_probed says engine_versions is an answer. Without it an empty map
|
||||
// meant "an agent too old to say" and constrained nothing — so a freshly
|
||||
// built node with no engine installed yet was a candidate for every model.
|
||||
bool engines_probed = 14;
|
||||
}
|
||||
|
||||
// Host describes the operating system and processor of a node.
|
||||
message Host {
|
||||
// From /etc/os-release: "ubuntu", "24.04", "noble".
|
||||
string os_id = 1;
|
||||
string os_version = 2;
|
||||
string os_codename = 3;
|
||||
// Debian's spelling, which is what package indexes are keyed by: "amd64",
|
||||
// "arm64".
|
||||
string arch = 4;
|
||||
// The vector extensions inference cares about, of those the CPU has. A VM
|
||||
// given QEMU's default CPU model has none of them, and runs, and is five
|
||||
// times slower, and nothing says why.
|
||||
repeated string cpu_flags = 5;
|
||||
// nvidia_driver is the driver version, empty when there is none. A card
|
||||
// without a driver is not a GPU anything can use.
|
||||
string nvidia_driver = 6;
|
||||
}
|
||||
|
||||
// Storage is one filesystem a node keeps weights on.
|
||||
//
|
||||
// Per filesystem rather than per configured directory: free space belongs to
|
||||
// the disk, and two model directories on one disk are one pool of bytes.
|
||||
message Storage {
|
||||
// path is one directory on this filesystem, so the operator recognises it.
|
||||
string path = 1;
|
||||
// free_bytes is what a non-root process can write — not the root reserve,
|
||||
// which is space the agent cannot have.
|
||||
int64 free_bytes = 2;
|
||||
int64 total_bytes = 3;
|
||||
// shared marks a filesystem the node did not fill. Free space on one is
|
||||
// shared with every other node using it, so two nodes each reporting 500 GB
|
||||
// free on the same filer have 500 GB between them and not a terabyte.
|
||||
bool shared = 4;
|
||||
}
|
||||
|
||||
// RunningShard describes an engine already running on a node.
|
||||
@@ -94,6 +176,13 @@ message RunningShard {
|
||||
|
||||
string leader_addr = 13;
|
||||
int32 leader_port = 14;
|
||||
|
||||
// profile_fp is the fingerprint of the profile this shard was started from,
|
||||
// echoed back from StartShard. The control plane keeps no record of a running
|
||||
// replica across its own restart, so without this a shard adopted after a
|
||||
// restart could not be compared with the catalog — which is exactly when the
|
||||
// catalog is most likely to have been edited.
|
||||
string profile_fp = 15;
|
||||
}
|
||||
|
||||
message GPU {
|
||||
@@ -114,6 +203,10 @@ message GPU {
|
||||
repeated string problems = 12;
|
||||
int32 temperature_c = 13;
|
||||
int32 ecc_errors = 14;
|
||||
// utilization_percent is how busy the card was over the last sample period.
|
||||
// Zero when the driver is too old to report it, which is not the same as
|
||||
// idle: the metric is simply absent rather than wrong.
|
||||
int32 utilization_percent = 15;
|
||||
}
|
||||
|
||||
message FabricAttachment {
|
||||
@@ -126,12 +219,89 @@ message FabricAttachment {
|
||||
}
|
||||
|
||||
// Heartbeat says the node is alive and reports anything that changed cheaply.
|
||||
// LocalWeights is one model file or directory a node can see.
|
||||
message LocalWeights {
|
||||
// path is what an engine would be given, so it can be compared against a
|
||||
// model's source without interpretation.
|
||||
string path = 1;
|
||||
int64 size_bytes = 2;
|
||||
// shared marks weights on a filesystem the node did not have to fill —
|
||||
// reported so a fabric with a shared filer is not mistaken for one where
|
||||
// every node happens to have fetched its own copy.
|
||||
bool shared = 3;
|
||||
}
|
||||
|
||||
message Heartbeat {
|
||||
string node_id = 1;
|
||||
// gpu_memory_used_mb, indexed by GPU, catches a foreign process appearing on
|
||||
// a card after registration.
|
||||
repeated int32 gpu_memory_used_mb = 2;
|
||||
repeated string new_problems = 3;
|
||||
|
||||
// storage for the same reason the GPU memory is here: it changes while the
|
||||
// agent runs, and a figure sampled once at registration is a figure that is
|
||||
// wrong on exactly the node worth watching — the one busy filling up.
|
||||
//
|
||||
// Always sent, because it is a few numbers per filesystem.
|
||||
repeated Storage storage = 4;
|
||||
|
||||
// weights only when they changed, because the list is long enough that
|
||||
// sending it every few seconds would be paying for nothing most of the time.
|
||||
//
|
||||
// weights_changed is what says the list is meant: without it an empty list
|
||||
// would be ambiguous between "this node now holds nothing" and "nothing to
|
||||
// report", and the first would quietly empty a node's inventory.
|
||||
bool weights_changed = 5;
|
||||
repeated LocalWeights weights = 6;
|
||||
|
||||
// memory_total_mb for the same reason again: RAM is added to a virtual
|
||||
// machine while it runs, or across a reboot the agent does not notice, and a
|
||||
// figure read once at registration kept a node that had grown to 32 GB
|
||||
// looking like the 7 GB machine it used to be. 0 means not read.
|
||||
int64 memory_total_mb = 7;
|
||||
|
||||
// engine_versions only when they changed, like weights. An engine installed
|
||||
// after the agent started is otherwise invisible to placement until
|
||||
// somebody restarts the agent — which is a step nobody knows to take.
|
||||
bool engines_changed = 8;
|
||||
map<string, string> engine_versions = 9;
|
||||
|
||||
// provision is where installing the node's software stands, sent when it
|
||||
// changed. The flag makes an empty list mean "nothing to install" rather
|
||||
// than "nothing to say", as for weights.
|
||||
bool provision_changed = 10;
|
||||
repeated ProvisionStatus provision = 11;
|
||||
}
|
||||
|
||||
// Provision is the software a node should have: which engines, at which
|
||||
// versions. Nothing more.
|
||||
//
|
||||
// Everything else is the node's to work out, because only the node knows it:
|
||||
// which build fits its hardware, where that build is published, what its
|
||||
// operating system is missing, and whether the result actually runs. The agent
|
||||
// converges on this — installs what is absent, checks what is there — and
|
||||
// reports each engine through ProvisionStatus. Nothing is taken on trust from
|
||||
// what happens to be on the machine: an engine is offered to placement only
|
||||
// once the agent has installed it and seen it run.
|
||||
message Provision {
|
||||
repeated EngineWant engines = 1;
|
||||
}
|
||||
|
||||
// EngineWant names one engine at one version.
|
||||
message EngineWant {
|
||||
string engine = 1; // "llamacpp"
|
||||
string version = 2; // "b10991"
|
||||
}
|
||||
|
||||
// ProvisionStatus is where one engine's installation stands on a node.
|
||||
message ProvisionStatus {
|
||||
string engine = 1;
|
||||
string version = 2;
|
||||
string variant = 3;
|
||||
// "installing", "ready", "failed", or "unsupported" when no build fits.
|
||||
string state = 4;
|
||||
// detail says what is happening or what went wrong, in words for a person.
|
||||
string detail = 5;
|
||||
}
|
||||
|
||||
// ShardStatus reports a shard's lifecycle transition.
|
||||
@@ -146,6 +316,15 @@ message ShardStatus {
|
||||
// fails to load this is the only diagnosis available.
|
||||
repeated string last_output = 7;
|
||||
|
||||
// fetch is how far a download has got, when the shard is waiting on one.
|
||||
//
|
||||
// The agent logs this already, on the node, where nobody is looking. From
|
||||
// the control plane a replica pulling a hundred gigabytes and one that is
|
||||
// wedged are the same thing — a shard that has been starting for ten
|
||||
// minutes — and telling them apart is the difference between waiting and
|
||||
// going to look.
|
||||
FetchProgress fetch = 8;
|
||||
|
||||
enum State {
|
||||
STATE_UNSPECIFIED = 0;
|
||||
STATE_PENDING = 1;
|
||||
@@ -158,6 +337,16 @@ message ShardStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// FetchProgress is a download in flight.
|
||||
message FetchProgress {
|
||||
string source = 1;
|
||||
int64 bytes_done = 2;
|
||||
// bytes_total is 0 when the server did not say how large the file is, which
|
||||
// is a real answer: a percentage would then be invented, and an invented
|
||||
// percentage that stops moving is worse than a byte count that keeps rising.
|
||||
int64 bytes_total = 3;
|
||||
}
|
||||
|
||||
// StartShard launches one shard. It is idempotent by shard_id: the control
|
||||
// plane retries, and a repeat must not produce a second process.
|
||||
message StartShard {
|
||||
@@ -184,6 +373,10 @@ message StartShard {
|
||||
// are sent so the agent can echo them back in RunningShard after a restart.
|
||||
string pool = 13;
|
||||
string fabric = 14;
|
||||
|
||||
// profile_fp identifies the version of the profile, so the agent can say
|
||||
// which one a running shard was started from. The agent never interprets it.
|
||||
string profile_fp = 15;
|
||||
}
|
||||
|
||||
message EngineProfile {
|
||||
@@ -200,6 +393,17 @@ message EngineProfile {
|
||||
string served_model_name = 11;
|
||||
}
|
||||
|
||||
// RemoveWeights deletes cached model files a node no longer needs.
|
||||
//
|
||||
// The paths are computed by the control plane from the catalog, but the agent
|
||||
// checks them again before unlinking anything: it deletes only files inside its
|
||||
// own cache directory whose names this system generated. A control plane that
|
||||
// is wrong, compromised or simply newer than the agent must not be able to
|
||||
// talk it into deleting a model an operator spent a week copying there.
|
||||
message RemoveWeights {
|
||||
repeated string paths = 1;
|
||||
}
|
||||
|
||||
// StopShard terminates a shard and frees its GPUs.
|
||||
message StopShard {
|
||||
string shard_id = 1;
|
||||
@@ -216,3 +420,29 @@ message Ack {
|
||||
bool ok = 2;
|
||||
string error = 3;
|
||||
}
|
||||
|
||||
// LogsRequest asks a node for the recent lines of one of its logs.
|
||||
//
|
||||
// Over the stream the agent already holds, because that is the one way into a
|
||||
// node every deployment has: the admin page otherwise means ssh through a jump
|
||||
// host to a machine whose address nobody remembers, to read a journal only
|
||||
// root may open.
|
||||
message LogsRequest {
|
||||
// request_id pairs the reply with its request; several may be in flight.
|
||||
string request_id = 1;
|
||||
// source is "agent" for the agent's own log, or a shard id for that
|
||||
// engine's output.
|
||||
string source = 2;
|
||||
int32 lines = 3;
|
||||
}
|
||||
|
||||
// LogsReply answers a LogsRequest.
|
||||
message LogsReply {
|
||||
string request_id = 1;
|
||||
repeated string lines = 2;
|
||||
// sources are every log this node can show, so a page can offer them
|
||||
// without knowing what runs there.
|
||||
repeated string sources = 3;
|
||||
// error says why the lines are absent: an unknown source, say.
|
||||
string error = 4;
|
||||
}
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/bin/sh
|
||||
# add-node.sh — build a node and put it in the fleet, in one command.
|
||||
#
|
||||
# provision-node.sh and onboard-node.sh stay separate because the two halves
|
||||
# need different access: one the hypervisor, one the certificate authority.
|
||||
# That is a real boundary and this script does not erase it — it drives both
|
||||
# where one person holds both, which is the common case and was the case here.
|
||||
#
|
||||
# The reason to have it is not keystrokes. The VM name, the hostname, the node
|
||||
# id and the certificate's common name must all be the same string, and the
|
||||
# address appears in three places; split across two invocations run minutes
|
||||
# apart, that agreement is maintained by whoever is typing. Here it is stated
|
||||
# once.
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'USAGE'
|
||||
usage: add-node.sh --name NODE --ip ADDR/CIDR --server URL [options]
|
||||
|
||||
--name NODE node id, hostname and VM name; also the certificate's CN
|
||||
--ip ADDR/CIDR e.g. 10.0.0.145/24
|
||||
--server URL control plane, e.g. https://10.0.0.140:7443
|
||||
|
||||
--vmid N Proxmox VMID (default: the last octet of --ip)
|
||||
--cores N (default 4)
|
||||
--cpu TYPE QEMU CPU model (default host; see provision-node.sh)
|
||||
--memory MB (default 8192)
|
||||
--disk GB (default 20)
|
||||
--fabric NAME InfiniBand fabric; repeatable as NAME:hca1,hca2.
|
||||
Omit for a node with no fabric.
|
||||
--llamacpp-bin P path to llama-server on the node
|
||||
--vllm-bin P path to vllm
|
||||
--sglang-python P path to the python that has sglang
|
||||
--binary PATH llmgw binary to install (default: the one on PATH)
|
||||
--pki DIR certificate authority (default /etc/llmgw/pki)
|
||||
--ssh-user U (default ilker)
|
||||
--provision-only build the VM and stop
|
||||
--onboard-only skip provisioning; the machine already exists
|
||||
USAGE
|
||||
exit 2
|
||||
}
|
||||
|
||||
NAME= IP= SERVER= VMID= CORES=4 MEMORY=8192 DISK=20 CPU=
|
||||
BINARY= PKI=/etc/llmgw/pki SSH_USER=ilker
|
||||
PROVISION=1 ONBOARD=1
|
||||
set -- "$@"
|
||||
PASS_THROUGH=
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--name) NAME="$2"; shift 2;;
|
||||
--ip) IP="$2"; shift 2;;
|
||||
--server) SERVER="$2"; shift 2;;
|
||||
--vmid) VMID="$2"; shift 2;;
|
||||
--cores) CORES="$2"; shift 2;;
|
||||
--cpu) CPU="$2"; shift 2;;
|
||||
--memory) MEMORY="$2"; shift 2;;
|
||||
--disk) DISK="$2"; shift 2;;
|
||||
--binary) BINARY="$2"; shift 2;;
|
||||
--pki) PKI="$2"; shift 2;;
|
||||
--ssh-user) SSH_USER="$2"; shift 2;;
|
||||
--provision-only) ONBOARD=0; shift;;
|
||||
--onboard-only) PROVISION=0; shift;;
|
||||
# Everything the onboarding step understands and this one does not need to.
|
||||
--fabric|--llamacpp-bin|--vllm-bin|--sglang-python|--ib-root|--nvidia-smi|--mgmt-addr|--mgmt-if)
|
||||
PASS_THROUGH="$PASS_THROUGH $1 $2"; shift 2;;
|
||||
-h|--help) usage;;
|
||||
*) echo "unknown option: $1" >&2; usage;;
|
||||
esac
|
||||
done
|
||||
[ -n "$NAME" ] && [ -n "$IP" ] && [ -n "$SERVER" ] || usage
|
||||
|
||||
# The address without its prefix, which is what onboarding and ssh want.
|
||||
ADDR="${IP%%/*}"
|
||||
# The last octet as the VMID by default: it keeps `qm list` and `ctl nodes` in
|
||||
# the same order, and one fewer number to choose is one fewer to get wrong.
|
||||
[ -n "$VMID" ] || VMID="${ADDR##*.}"
|
||||
|
||||
here() { dirname "$0"; }
|
||||
PROVISION_SH="$(here)/provision-node.sh"
|
||||
ONBOARD_SH="$(here)/onboard-node.sh"
|
||||
[ -x "$PROVISION_SH" ] || PROVISION_SH="$(command -v llmgw-provision-node || true)"
|
||||
[ -x "$ONBOARD_SH" ] || ONBOARD_SH="$(command -v llmgw-onboard-node || true)"
|
||||
|
||||
if [ "$PROVISION" = 1 ]; then
|
||||
[ -x "$PROVISION_SH" ] || { echo "cannot find provision-node.sh" >&2; exit 1; }
|
||||
echo "== 1/2 building $NAME as VM $VMID"
|
||||
# shellcheck disable=SC2086
|
||||
"$PROVISION_SH" --vmid "$VMID" --name "$NAME" --ip "$IP" \
|
||||
--cores "$CORES" --memory "$MEMORY" --disk "$DISK" ${CPU:+--cpu "$CPU"}
|
||||
fi
|
||||
|
||||
if [ "$ONBOARD" = 0 ]; then
|
||||
echo "$NAME built. Onboard it from the CA host when you are ready."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[ -x "$ONBOARD_SH" ] || { echo "cannot find onboard-node.sh" >&2; exit 1; }
|
||||
echo
|
||||
echo "== 2/2 onboarding $NAME"
|
||||
# Onboarding waits for ssh itself, which is what makes running the two halves
|
||||
# back to back safe: cloud-init is still regenerating host keys and restarting
|
||||
# sshd for the first minute of a new machine's life.
|
||||
# shellcheck disable=SC2086
|
||||
"$ONBOARD_SH" --node "$NAME" --addr "$ADDR" --server "$SERVER" \
|
||||
--pki "$PKI" --ssh-user "$SSH_USER" \
|
||||
${BINARY:+--binary "$BINARY"} $PASS_THROUGH
|
||||
|
||||
echo
|
||||
echo "Check it with: llmgw ctl nodes"
|
||||
echo
|
||||
echo "The agent installs its engines itself; the node takes no model until they"
|
||||
echo "are installed and seen to run. 'llmgw ctl nodes' shows where that stands."
|
||||
Executable
+289
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Give a machine an identity in an llmgw cluster.
|
||||
#
|
||||
# Run on the host that holds the certificate authority, because that is the one
|
||||
# thing that cannot be delegated: this script issues the credential by which the
|
||||
# control plane will believe the node is who it says it is.
|
||||
#
|
||||
# What it does, in the order it matters:
|
||||
# 1. issue a client certificate whose common name is the node id
|
||||
# 2. copy the CA root, the certificate and its key to the machine
|
||||
# 3. trust the root system-wide, so other tooling there stops complaining
|
||||
# 4. install the binary, the configuration and the unit
|
||||
# 5. start the agent
|
||||
#
|
||||
# The node id, the VM name, the hostname and the certificate common name are all
|
||||
# the same string. They do not have to be, and every time they are not, someone
|
||||
# spends an afternoon working out which of the four a message is talking about.
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat >&2 <<USAGE
|
||||
usage: $0 --node ID --addr HOST --server URL [options]
|
||||
|
||||
--node ID node id, hostname and certificate common name
|
||||
--addr HOST how to reach the machine over ssh
|
||||
--server URL the control plane's agent endpoint, e.g. https://10.0.0.140:7443;
|
||||
several, comma separated, for a node that fails over
|
||||
--fabric NAME InfiniBand fabric this node is attached to; repeatable as
|
||||
--fabric NAME:hca1,hca2 . Omit for a node with no fabric,
|
||||
which can host single-node replicas and will never be given
|
||||
a multi-node one.
|
||||
--mgmt-addr ADDR address engines are reachable on (default: the node's
|
||||
address on the route to --server, found by the agent)
|
||||
--mgmt-if NAME interface NCCL bootstraps over (default: the one holding
|
||||
that address)
|
||||
--llamacpp-bin P use this llama-server instead of the one the agent installs
|
||||
--vllm-bin P path to the vllm entry point (not yet installed by the agent)
|
||||
--sglang-python P python interpreter of the SGLang virtualenv
|
||||
--nvidia-smi PATH where nvidia-smi lives, when it is not on PATH. Containerized
|
||||
driver installs routinely put it somewhere else.
|
||||
--ib-root DIR the sysfs InfiniBand directory (default /sys/class/infiniband)
|
||||
--pki DIR where the authority lives (default /etc/llmgw/pki)
|
||||
--binary PATH llmgw binary to install (default: the one running this).
|
||||
Releases carry static linux/amd64 and linux/arm64 builds;
|
||||
downloading one is better than copying whatever happens to
|
||||
be on the machine you are running this from.
|
||||
--releases URL where release binaries are downloaded from (default the
|
||||
project's Gitea releases)
|
||||
--ssh-user NAME (default ilker)
|
||||
USAGE
|
||||
exit 2
|
||||
}
|
||||
|
||||
NODE= ADDR= SERVER= MGMT_OVERRIDE= MGMT_IF_OVERRIDE=
|
||||
NVIDIA_SMI= IB_ROOT=/sys/class/infiniband
|
||||
LLAMACPP_BIN= VLLM_BIN= SGLANG_PYTHON=
|
||||
SSH_WAIT=180
|
||||
PKI=/etc/llmgw/pki BINARY= SSH_USER=ilker
|
||||
RELEASES=https://gitea.mynodes.xyz/ilker/llmgw/releases/download
|
||||
FABRIC_NAME= FABRIC_HCAS="mlx5_0,mlx5_1"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--node) NODE="$2"; shift 2;;
|
||||
--addr) ADDR="$2"; shift 2;;
|
||||
--server) SERVER="$2"; shift 2;;
|
||||
--fabric) FABRIC_NAME="${2%%:*}"; [ "${2#*:}" != "$2" ] && FABRIC_HCAS="${2#*:}"; shift 2;;
|
||||
--mgmt-addr) MGMT_OVERRIDE="$2"; shift 2;;
|
||||
--mgmt-if) MGMT_IF_OVERRIDE="$2"; shift 2;;
|
||||
--llamacpp-bin) LLAMACPP_BIN="$2"; shift 2;;
|
||||
--vllm-bin) VLLM_BIN="$2"; shift 2;;
|
||||
--sglang-python) SGLANG_PYTHON="$2"; shift 2;;
|
||||
--nvidia-smi) NVIDIA_SMI="$2"; shift 2;;
|
||||
--ib-root) IB_ROOT="$2"; shift 2;;
|
||||
--pki) PKI="$2"; shift 2;;
|
||||
--binary) BINARY="$2"; shift 2;;
|
||||
--releases) RELEASES="$2"; shift 2;;
|
||||
--ssh-user) SSH_USER="$2"; shift 2;;
|
||||
*) usage;;
|
||||
esac
|
||||
done
|
||||
[ -n "$NODE" ] && [ -n "$ADDR" ] && [ -n "$SERVER" ] || usage
|
||||
BINARY="${BINARY:-$(command -v llmgw)}"
|
||||
[ -x "$BINARY" ] || { echo "no llmgw binary; pass --binary" >&2; exit 1; }
|
||||
[ -f "$PKI/ca-key.pem" ] || { echo "no authority in $PKI; run 'llmgw ctl cert init-ca' first" >&2; exit 1; }
|
||||
|
||||
# accept-new, not "no": a machine provisioned a minute ago has a host key
|
||||
# nobody has seen, and refusing it stopped onboarding dead with "Host key
|
||||
# verification failed" — an error about ssh, in the middle of a script whose
|
||||
# whole job is to talk to a new machine. accept-new learns an unknown key and
|
||||
# still refuses a changed one, which is the distinction that matters: the risk
|
||||
# being guarded against is a substituted machine, not a new one.
|
||||
SSHOPTS="-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
|
||||
SSH="ssh $SSHOPTS $SSH_USER@$ADDR"
|
||||
say() { printf ' %s\n' "$*"; }
|
||||
|
||||
echo "onboarding $NODE at $ADDR"
|
||||
|
||||
# Waiting rather than failing: cloud-init restarts sshd after it regenerates
|
||||
# the host keys, so a machine that answered a moment ago refuses connections
|
||||
# for a few seconds. Observed while onboarding node05 — the agent was already
|
||||
# registered and healthy while ssh was refusing.
|
||||
wait_for_ssh() {
|
||||
deadline=$(( $(date +%s) + SSH_WAIT ))
|
||||
until $SSH true 2>/dev/null; do
|
||||
[ "$(date +%s)" -lt "$deadline" ] || {
|
||||
echo "cannot reach $SSH_USER@$ADDR over ssh after ${SSH_WAIT}s" >&2
|
||||
exit 1
|
||||
}
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
wait_for_ssh
|
||||
|
||||
# 1. The certificate. Issued fresh every time: re-onboarding a machine should
|
||||
# not depend on finding an old key, and the old one keeps working until it
|
||||
# is revoked, which is a separate decision.
|
||||
say "issuing a certificate"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
"$BINARY" ctl cert issue-client "$NODE" --dir "$PKI" --out "$TMP" >/dev/null
|
||||
|
||||
# 2 and 3. Identity, and trusting the root so future tooling on that machine
|
||||
# does not treat our certificates as strangers.
|
||||
say "installing the certificate and trusting the root"
|
||||
scp -q $SSHOPTS "$PKI/ca.pem" "$TMP/$NODE.pem" "$TMP/$NODE-key.pem" "$SSH_USER@$ADDR:/tmp/"
|
||||
$SSH "set -e
|
||||
sudo install -d -m 0755 /etc/llmgw
|
||||
sudo install -m 0644 /tmp/ca.pem /etc/llmgw/ca.pem
|
||||
sudo install -m 0644 /tmp/$NODE.pem /etc/llmgw/agent.pem
|
||||
sudo install -m 0600 /tmp/$NODE-key.pem /etc/llmgw/agent-key.pem
|
||||
sudo chown root:root /etc/llmgw/*
|
||||
sudo install -m 0644 /tmp/ca.pem /usr/local/share/ca-certificates/llmgw-ca.crt
|
||||
sudo update-ca-certificates >/dev/null 2>&1 || true
|
||||
rm -f /tmp/ca.pem /tmp/$NODE.pem /tmp/$NODE-key.pem"
|
||||
|
||||
# 4. The binary, the configuration and the unit.
|
||||
# The node fetches the release CI built for this version, for its own
|
||||
# architecture, and checks it against the release's checksums — rather than
|
||||
# being handed whatever binary is on the machine running this script. A build
|
||||
# that is not a release (a developer's, with commits past the tag) has no such
|
||||
# file, and is copied as before.
|
||||
TAG="$("$BINARY" --version 2>/dev/null | awk '{print $NF}')"
|
||||
if printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
say "installing llmgw $TAG from the release"
|
||||
$SSH "set -e
|
||||
arch=\$(dpkg --print-architecture)
|
||||
name=llmgw-$TAG-linux-\$arch
|
||||
cd /tmp && rm -f \$name checksums.txt
|
||||
curl -fsSL -o \$name '$RELEASES/$TAG/'\$name
|
||||
curl -fsSL -o checksums.txt '$RELEASES/$TAG/checksums.txt'
|
||||
grep \" \$name\\\$\" checksums.txt | sha256sum -c - >/dev/null
|
||||
sudo install -m 0755 \$name /usr/local/bin/llmgw
|
||||
rm -f \$name checksums.txt"
|
||||
else
|
||||
say "installing the binary (${TAG:-unversioned}, not a release)"
|
||||
scp -q $SSHOPTS "$BINARY" "$SSH_USER@$ADDR:/tmp/llmgw"
|
||||
$SSH 'sudo install -m 0755 /tmp/llmgw /usr/local/bin/llmgw && rm -f /tmp/llmgw'
|
||||
fi
|
||||
|
||||
say "writing the configuration"
|
||||
NODE_IS_HOSTNAME=no
|
||||
[ "$($SSH hostname 2>/dev/null)" = "$NODE" ] && NODE_IS_HOSTNAME=yes
|
||||
# Only what the node cannot find out for itself. Its name is its hostname, its
|
||||
# address is the one on the route to the control plane, llama-server is looked
|
||||
# for on PATH and where release archives are unpacked, GPUs are there when
|
||||
# nvidia-smi is, and memory, disk and engines are re-read while it runs. Every
|
||||
# setting written here is one that has to be kept true by hand — so an option
|
||||
# below is written only when it was given, as an override.
|
||||
{
|
||||
echo "# $NODE, written by onboard-node.sh."
|
||||
# Several addresses, comma separated, are every control plane the node may
|
||||
# fail over to; a standby refuses it and it moves on to the next.
|
||||
if [ "${SERVER#*,}" != "$SERVER" ]; then
|
||||
echo "servers:"
|
||||
printf '%s\n' "$SERVER" | tr ',' '\n' | sed 's/^ *//; s/^/ - "/; s/$/"/'
|
||||
else
|
||||
echo "server: \"$SERVER\""
|
||||
fi
|
||||
echo
|
||||
echo "tls:"
|
||||
echo " ca: /etc/llmgw/ca.pem"
|
||||
echo " cert: /etc/llmgw/agent.pem"
|
||||
echo " key: /etc/llmgw/agent-key.pem"
|
||||
# The certificate's common name is --node, and the control plane refuses a
|
||||
# stream whose node id differs from it. Said explicitly only when the
|
||||
# hostname would say something else.
|
||||
[ "$NODE_IS_HOSTNAME" = yes ] || echo "node_id: $NODE"
|
||||
[ -n "$MGMT_OVERRIDE" ] && echo "mgmt_addr: \"$MGMT_OVERRIDE\""
|
||||
[ -n "$MGMT_IF_OVERRIDE" ] && echo "mgmt_interface: $MGMT_IF_OVERRIDE"
|
||||
if [ -n "$FABRIC_NAME" ]; then
|
||||
# Fabric membership is the one thing that cannot be found: OpenSM's default
|
||||
# subnet prefix is the same on every fabric, so a matching prefix proves
|
||||
# nothing and a wrong guess hangs the first all-reduce.
|
||||
echo "fabrics:"
|
||||
echo " - name: $FABRIC_NAME"
|
||||
echo " hcas: [$(echo "$FABRIC_HCAS" | sed 's/,/, /g')]"
|
||||
fi
|
||||
if [ -n "$NVIDIA_SMI" ] || [ "$IB_ROOT" != /sys/class/infiniband ]; then
|
||||
echo "discovery:"
|
||||
[ -n "$NVIDIA_SMI" ] && echo " nvidia_smi: \"$NVIDIA_SMI\""
|
||||
[ "$IB_ROOT" != /sys/class/infiniband ] && echo " ib_root: $IB_ROOT"
|
||||
fi
|
||||
if [ -n "$LLAMACPP_BIN$VLLM_BIN$SGLANG_PYTHON" ]; then
|
||||
echo "engines:"
|
||||
[ -n "$LLAMACPP_BIN" ] && printf ' llamacpp:\n bin: "%s"\n' "$LLAMACPP_BIN"
|
||||
[ -n "$VLLM_BIN" ] && printf ' vllm:\n bin: "%s"\n' "$VLLM_BIN"
|
||||
[ -n "$SGLANG_PYTHON" ] && printf ' sglang:\n python: "%s"\n' "$SGLANG_PYTHON"
|
||||
fi
|
||||
true
|
||||
} > "$TMP/agent.yaml"
|
||||
scp -q "$TMP/agent.yaml" "$SSH_USER@$ADDR:/tmp/agent.yaml"
|
||||
|
||||
cat > "$TMP/llmgw-agent.service" <<'UNIT'
|
||||
[Unit]
|
||||
Description=llmgw node agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/llmgw agent --config /etc/llmgw/agent.yaml
|
||||
# Root, because the agent launches engines and needs device access. Engines
|
||||
# outlive a restart of this unit and are adopted again on the next start, so
|
||||
# KillMode must not take the whole process group with it.
|
||||
KillMode=process
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
StateDirectory=llmgw-agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
scp -q "$TMP/llmgw-agent.service" "$SSH_USER@$ADDR:/tmp/"
|
||||
|
||||
# An engine path that is wrong, or right but unrunnable, is the failure this
|
||||
# node type is most likely to have, and it is invisible: the agent registers
|
||||
# healthy and every shard placed on it dies with "executable file not found"
|
||||
# or a missing shared library. Checked here because it is cheap here and
|
||||
# expensive later — llama-server on node05 was present, executable, and could
|
||||
# not start for want of libgomp1, and the node reported ready throughout.
|
||||
check_engine() {
|
||||
[ -n "$2" ] || return 0
|
||||
if $SSH "test -x '$2'" 2>/dev/null; then
|
||||
if $SSH "'$2' --version >/dev/null 2>&1 || '$2' --help >/dev/null 2>&1"; then
|
||||
say "$1: $2 runs"
|
||||
return 0
|
||||
fi
|
||||
ENGINE_PROBLEMS="$ENGINE_PROBLEMS
|
||||
$1 at $2 exists but will not run. Usually a missing shared library:
|
||||
ssh $SSH_USER@$ADDR ldd $2 | grep 'not found'"
|
||||
return 0
|
||||
fi
|
||||
ENGINE_PROBLEMS="$ENGINE_PROBLEMS
|
||||
$1 is configured at $2, which is not an executable on $NODE."
|
||||
}
|
||||
ENGINE_PROBLEMS=
|
||||
if [ -n "$LLAMACPP_BIN$VLLM_BIN$SGLANG_PYTHON" ]; then
|
||||
say "checking the engines"
|
||||
check_engine llamacpp "$LLAMACPP_BIN"
|
||||
check_engine vllm "$VLLM_BIN"
|
||||
check_engine sglang "$SGLANG_PYTHON"
|
||||
fi
|
||||
|
||||
say "starting the agent"
|
||||
$SSH 'set -e
|
||||
sudo install -m 0644 -o root -g root /tmp/agent.yaml /etc/llmgw/agent.yaml
|
||||
sudo install -m 0644 /tmp/llmgw-agent.service /etc/systemd/system/llmgw-agent.service
|
||||
rm -f /tmp/agent.yaml /tmp/llmgw-agent.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now llmgw-agent >/dev/null
|
||||
sudo systemctl restart llmgw-agent'
|
||||
|
||||
echo
|
||||
if [ -z "$LLAMACPP_BIN$VLLM_BIN$SGLANG_PYTHON" ]; then
|
||||
say "the agent now installs the engines the control plane asks for: the"
|
||||
say "build for this machine, checked against its publisher's checksum and run"
|
||||
say "before the node is offered any model. 'llmgw ctl nodes' shows progress."
|
||||
fi
|
||||
if [ -n "$ENGINE_PROBLEMS" ]; then
|
||||
echo "$NODE is onboarded but an engine will not run:$ENGINE_PROBLEMS"
|
||||
echo
|
||||
echo "The node will register as ready regardless. Fix this before a replica"
|
||||
echo "is placed on it, or the shard fails and the reason is three logs away."
|
||||
echo
|
||||
fi
|
||||
echo "$NODE onboarded. It should appear in 'llmgw ctl nodes' within a few seconds."
|
||||
echo "If it does not, the control plane's log says why — a certificate whose"
|
||||
echo "common name does not match the claimed node id is refused by design."
|
||||
Executable
+96
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Create a VM on Proxmox for a new llmgw node.
|
||||
#
|
||||
# Run on the Proxmox host. It does the half of onboarding that is about
|
||||
# hardware; onboard-node.sh does the half that is about identity, and the two
|
||||
# are separate because they are done by different people with different access.
|
||||
#
|
||||
# Idempotent only in the sense that it refuses to touch an existing VMID. A node
|
||||
# that already exists is not something a provisioning script should have an
|
||||
# opinion about.
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat >&2 <<USAGE
|
||||
usage: $0 --vmid N --name NAME --ip ADDR [options]
|
||||
|
||||
--vmid N Proxmox VM id; must be free
|
||||
--name NAME VM name; use the node id, so the machine, the hostname and
|
||||
the certificate common name are the same string
|
||||
--ip ADDR address with prefix, e.g. 10.0.0.144/24
|
||||
--gw ADDR gateway (default 10.0.0.1)
|
||||
--cores N (default 4)
|
||||
--cpu TYPE QEMU CPU model (default host)
|
||||
--memory MB (default 8192)
|
||||
--disk GB (default 20)
|
||||
--bridge NAME (default vmbr0)
|
||||
--storage NAME (default local-lvm)
|
||||
--image PATH cloud image (default Ubuntu 24.04 in the ISO store)
|
||||
--user NAME cloud-init user (default ilker)
|
||||
--sshkeys PATH authorized_keys to install (default /root/.ssh/authorized_keys)
|
||||
USAGE
|
||||
exit 2
|
||||
}
|
||||
|
||||
VMID= NAME= IP= GW=10.0.0.1 CORES=4 MEMORY=8192 DISK=20
|
||||
# host, not Proxmox's kvm64 default. kvm64 hides AVX, AVX2, FMA and even
|
||||
# SSE4.2 from the guest, and an inference node without vector instructions is
|
||||
# not slightly slower, it is unusable: llama.cpp falls back to its scalar
|
||||
# backend and a 1.5B model dropped from 39 tokens a second to 5.8, with prompt
|
||||
# processing 19x worse. It cost an afternoon to find because nothing is broken
|
||||
# — every number is simply small.
|
||||
#
|
||||
# The trade is live migration: a VM pinned to the host CPU cannot move to a
|
||||
# machine with a different one. For a node whose job is arithmetic, that is
|
||||
# the right way round.
|
||||
CPU=host
|
||||
BRIDGE=vmbr0 STORAGE=local-lvm USER=ilker
|
||||
IMAGE=/var/lib/vz/template/iso/noble-server-cloudimg-amd64.img
|
||||
SSHKEYS=/root/.ssh/authorized_keys
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--vmid) VMID="$2"; shift 2;;
|
||||
--name) NAME="$2"; shift 2;;
|
||||
--ip) IP="$2"; shift 2;;
|
||||
--gw) GW="$2"; shift 2;;
|
||||
--cores) CORES="$2"; shift 2;;
|
||||
--cpu) CPU="$2"; shift 2;;
|
||||
--memory) MEMORY="$2"; shift 2;;
|
||||
--disk) DISK="$2"; shift 2;;
|
||||
--bridge) BRIDGE="$2"; shift 2;;
|
||||
--storage) STORAGE="$2"; shift 2;;
|
||||
--image) IMAGE="$2"; shift 2;;
|
||||
--user) USER="$2"; shift 2;;
|
||||
--sshkeys) SSHKEYS="$2"; shift 2;;
|
||||
*) usage;;
|
||||
esac
|
||||
done
|
||||
[ -n "$VMID" ] && [ -n "$NAME" ] && [ -n "$IP" ] || usage
|
||||
|
||||
if qm status "$VMID" >/dev/null 2>&1; then
|
||||
echo "VM $VMID already exists. Pick a free id; this script will not adopt one." >&2
|
||||
exit 1
|
||||
fi
|
||||
[ -f "$IMAGE" ] || { echo "no cloud image at $IMAGE" >&2; exit 1; }
|
||||
|
||||
echo "creating $NAME as VM $VMID at $IP"
|
||||
qm create "$VMID" \
|
||||
--name "$NAME" --cores "$CORES" --memory "$MEMORY" --cpu "$CPU" \
|
||||
--net0 "virtio,bridge=$BRIDGE" --ostype l26 --agent enabled=1
|
||||
|
||||
# Imported rather than installed: a cloud image plus cloud-init is the whole
|
||||
# reason a node takes a minute to build instead of an afternoon.
|
||||
qm importdisk "$VMID" "$IMAGE" "$STORAGE" >/dev/null
|
||||
qm set "$VMID" --scsihw virtio-scsi-pci --scsi0 "$STORAGE:vm-$VMID-disk-0,discard=on,ssd=1" >/dev/null
|
||||
qm disk resize "$VMID" scsi0 "${DISK}G" >/dev/null
|
||||
qm set "$VMID" --ide2 "$STORAGE:cloudinit" --boot order=scsi0 >/dev/null
|
||||
qm set "$VMID" \
|
||||
--ipconfig0 "ip=$IP,gw=$GW" \
|
||||
--nameserver "$GW 8.8.8.8" \
|
||||
--ciuser "$USER" \
|
||||
--sshkeys "$SSHKEYS" >/dev/null
|
||||
|
||||
qm start "$VMID"
|
||||
echo "started. cloud-init takes a minute; then run onboard-node.sh from the CA host."
|
||||
@@ -4,16 +4,44 @@
|
||||
# keep serving if the agent restarts: the router talks to them directly, and
|
||||
# orphan adoption picks them up again on the next start.
|
||||
|
||||
node_id: gpu-a-01 # defaults to the hostname
|
||||
# Everything a node can find out about itself, it does: its id is its hostname,
|
||||
# its address is the one on the route to the control plane, llama-server is
|
||||
# looked for on PATH and in /opt/llama.cpp, GPUs are there when nvidia-smi is,
|
||||
# weights are fetched into /var/lib/llmgw-weights and looked for there, and
|
||||
# memory, disk and installed engines are re-read while the agent runs. So a
|
||||
# plain CPU node needs only the two blocks below, and the same file on every
|
||||
# node. What follows them are overrides, for when the defaults are wrong.
|
||||
|
||||
server: "http://llmgw-server.internal:7443"
|
||||
|
||||
# The agent's half of mutual TLS. The certificate's common name must be this
|
||||
# node's id — its hostname, unless node_id says otherwise — because the control
|
||||
# plane compares the two and refuses the stream when they disagree:
|
||||
#
|
||||
# llmgw ctl cert issue-client gpu-a-01 --dir /etc/llmgw/pki
|
||||
#
|
||||
# With this set, `server` above must be an https:// address — an agent holding
|
||||
# certificates and dialling http:// is refused at startup rather than connecting
|
||||
# in the clear without noticing.
|
||||
#
|
||||
# tls:
|
||||
# ca: /etc/llmgw/ca.pem
|
||||
# cert: /etc/llmgw/agent.pem
|
||||
# key: /etc/llmgw/agent-key.pem
|
||||
|
||||
# ---- overrides ---------------------------------------------------------------
|
||||
|
||||
# node_id: gpu-a-01 # when it is not the hostname
|
||||
|
||||
# The address engines are reachable on, and the address NCCL bootstraps over.
|
||||
# Never a fabric address: NCCL rendezvouses over TCP before moving to RDMA.
|
||||
mgmt_addr: "10.0.0.11"
|
||||
mgmt_interface: eth0
|
||||
# mgmt_addr: "10.0.0.11"
|
||||
# mgmt_interface: eth0
|
||||
|
||||
labels:
|
||||
rack: r12
|
||||
# Free-form, for a profile's node_selector. Nothing needs them; do not restate
|
||||
# what is measured (memory, GPUs, engines), because a label goes stale.
|
||||
# labels:
|
||||
# rack: r12
|
||||
|
||||
heartbeat_interval: 5s
|
||||
reconnect_base: 1s
|
||||
@@ -40,7 +68,30 @@ discovery:
|
||||
# tensor-parallel group off the PCIe path inside a node. It needs a binary
|
||||
# built with `-tags nvml`; otherwise this silently falls back to nvidia-smi.
|
||||
prefer_nvml: true
|
||||
skip_gpus: false
|
||||
|
||||
# Where this node keeps model weights besides /var/lib/llmgw-weights, which is
|
||||
# where fetched weights land and is always looked in. Storage differs at
|
||||
# every installation and llmgw deliberately does not own it: a Lustre mount, an
|
||||
# image exported over the fabric with SRP or NVMe-oF, local NVMe, or a copy
|
||||
# made by hand all look identical from here.
|
||||
#
|
||||
# What it buys is placement preferring a node that already holds the model over
|
||||
# one that must first move tens of gigabytes. That matters most where fabrics
|
||||
# are isolated from one another, because weights in one fabric are not weights
|
||||
# in another and the only path between them is the management network.
|
||||
#
|
||||
# model_dirs:
|
||||
# - /var/lib/llmgw-models
|
||||
# - /mnt/lustre/models
|
||||
|
||||
# The agent installs the engines the control plane names (see `engines:` in
|
||||
# server.yaml) under /opt/llmgw/engines: the build for this machine — CUDA when
|
||||
# an NVIDIA driver is loaded — checked against its publisher's checksum, with
|
||||
# the OS packages it needs, and run before the node takes any model. llama.cpp
|
||||
# is installed this way today. Naming a binary below opts that engine out and
|
||||
# uses yours instead; vLLM and SGLang still have to be named until the agent
|
||||
# installs them too.
|
||||
# engines_dir: /opt/llmgw/engines
|
||||
|
||||
engines:
|
||||
vllm:
|
||||
|
||||
+162
-6
@@ -7,12 +7,92 @@
|
||||
# __ for nesting: LLMGW_ROUTER__RETRIES=3
|
||||
|
||||
listen: ":8080" # client-facing OpenAI API
|
||||
admin_listen: ":9090" # metrics, health, pprof — do not expose this publicly
|
||||
admin_listen: "127.0.0.1:9090" # dashboard, management API, pprof — keep it local
|
||||
# Metrics only, on their own port, so Prometheus can reach them without pprof
|
||||
# being reachable too. Empty means off; metrics stay on admin_listen either way.
|
||||
# metrics_listen: "0.0.0.0:9091"
|
||||
# Where node agents connect. Separate from the client port so the fleet-facing
|
||||
# surface can be firewalled independently. Agents speak plaintext HTTP/2 today,
|
||||
# so this must stay on a trusted network until mTLS lands (plan §5.6).
|
||||
# surface can be firewalled independently.
|
||||
agent_listen: ":7443"
|
||||
|
||||
# Mutual TLS on the agent port (plan §5.6). With it, a node's identity is taken
|
||||
# from its certificate's common name and an agent claiming a different node id
|
||||
# is refused. Without it the port is plaintext HTTP/2 and anyone who can reach
|
||||
# it can claim to be any node, so it must stay on a trusted network.
|
||||
#
|
||||
# These certificates come from llmgw's own authority, not a public one: public
|
||||
# authorities stopped issuing the clientAuth usage, so no Let's Encrypt
|
||||
# certificate can authenticate an agent.
|
||||
#
|
||||
# llmgw ctl cert init-ca --dir /etc/llmgw/pki
|
||||
# llmgw ctl cert issue-server llmgw-ctl --host 10.0.0.140 --dir /etc/llmgw/pki
|
||||
#
|
||||
# Every address an agent might dial has to appear in --host, bare IP addresses
|
||||
# included: hostname verification reads subject alternative names and ignores
|
||||
# the common name entirely.
|
||||
#
|
||||
# This covers the fleet-facing port only. The client-facing API above is public
|
||||
# and belongs behind a publicly trusted certificate.
|
||||
#
|
||||
# agent_tls:
|
||||
# ca: /etc/llmgw/ca.pem
|
||||
# cert: /etc/llmgw/server.pem
|
||||
# key: /etc/llmgw/server-key.pem
|
||||
|
||||
# The public page at "/". Someone who reaches this address without a key needs a
|
||||
# route to getting one, and the gateway cannot guess who to send them to.
|
||||
#
|
||||
# landing:
|
||||
# contact: "platform@example.com" # or an https:// URL, or "#platform on Slack"
|
||||
# notice: "Internal use only."
|
||||
|
||||
# API keys on the client-facing port (plan §7.9). Off by default, because a
|
||||
# gateway on a trusted network with no keys issued still has to serve — but
|
||||
# nothing else protects that port, so turning it on is what makes it safe to
|
||||
# expose.
|
||||
#
|
||||
# llmgw keys init --store /var/lib/llmgw/llmgw.db
|
||||
# llmgw keys create --store /var/lib/llmgw/llmgw.db --project default "laptop"
|
||||
#
|
||||
# The key is printed once. Only its hash is stored, so there is nothing to
|
||||
# recover it from.
|
||||
#
|
||||
# auth:
|
||||
# enabled: true
|
||||
# store: /var/lib/llmgw/llmgw.db
|
||||
# # How long a resolved key is trusted before being read again. This is the
|
||||
# # price of keeping the database off the request path: it bounds how long a
|
||||
# # revoked key keeps working.
|
||||
# cache_ttl: 30s
|
||||
# # How long a rejected key is remembered as rejected, so a client looping
|
||||
# # with a bad key cannot become a database read per request.
|
||||
# negative_ttl: 5s
|
||||
#
|
||||
# # Serve the signup form. What it creates is a pending account that can do
|
||||
# # nothing until `llmgw users approve` places it in a project, so this is not
|
||||
# # a way in — it only moves key creation from you to the person who needs one.
|
||||
# # Off, there is no form and you issue keys with `llmgw keys create --claim`.
|
||||
# signup_open: true
|
||||
#
|
||||
# # What each model costs, in micro-units per thousand tokens. A unit is
|
||||
# # whatever you decide: self-hosted models have no per-token price, so it can
|
||||
# # be GPU-seconds, a notional rate, or 1000 everywhere — which turns budgets
|
||||
# # into plain token quotas.
|
||||
# #
|
||||
# # A model priced at zero is exempt from budget checks, which is what lets a
|
||||
# # key with nothing left still be served a cheap fallback.
|
||||
# default_price: { prompt_per_1k: 0, completion_per_1k: 0 }
|
||||
# pricing:
|
||||
# llama-3.3-70b: { prompt_per_1k: 1000, completion_per_1k: 3000 }
|
||||
# qwen2.5-32b: { prompt_per_1k: 400, completion_per_1k: 1200 }
|
||||
#
|
||||
# # Usage rows are written in batches off the request path. Beyond buffer_size
|
||||
# # they are dropped rather than blocking a request: losing some accounting is
|
||||
# # bad, turning a stalled database into an outage is worse.
|
||||
# flush_interval: 200ms
|
||||
# batch_size: 500
|
||||
# buffer_size: 8192
|
||||
|
||||
router:
|
||||
scrape_interval: 2s
|
||||
scrape_timeout: 1s
|
||||
@@ -33,6 +113,39 @@ router:
|
||||
failure_threshold: 3
|
||||
|
||||
default_profile: chat # chat | batch | long-context | debug
|
||||
|
||||
# Approximate prefix routing (plan §7.5 tier 1): send a request to the replica
|
||||
# that has probably already prefilled its prefix.
|
||||
#
|
||||
# It earns its keep when the working set does not fit in one replica's cache.
|
||||
# With a handful of prompts and a large cache, scattering is free — every
|
||||
# replica ends up holding everything — and this only adds constraint. Measured
|
||||
# with sim-engines whose caches were too small for the working set: cache hits
|
||||
# went from 22.5% to 81.7%.
|
||||
#
|
||||
# Watch llmgw_prefix_decisions_total to tune it. A high
|
||||
# load_balance_override count means the guard is doing its job and affinity is
|
||||
# not getting a chance; a high cache_miss_min_load means match_ratio or
|
||||
# min_match is too strict for your traffic.
|
||||
prefix:
|
||||
enabled: false
|
||||
# The shortest match worth acting on, in bytes. Below this the overlap is
|
||||
# coincidence — two English prompts share "The".
|
||||
min_match: 64
|
||||
# The share of the prompt that must match. A short shared header on a very
|
||||
# long prompt saves almost no prefill.
|
||||
match_ratio: 0.3
|
||||
# The imbalance guard, and both must trip before affinity is abandoned.
|
||||
# Absolute alone fires on an idle cluster where one replica has two requests
|
||||
# and another none; relative alone fires on a busy one where 200 against 180
|
||||
# is a rounding error. Without the guard the cache hit rate rises while p99
|
||||
# collapses.
|
||||
balance_abs: 8
|
||||
balance_rel: 1.5
|
||||
# An engine evicts on its own schedule, so an old record may describe a
|
||||
# cache that is gone.
|
||||
ttl: 10m
|
||||
max_nodes: 100000
|
||||
session_header: x-session-id # affinity key; falls back to the `user` field
|
||||
# Ask engines for usage even when the client did not, so token accounting
|
||||
# works regardless of client behaviour. The extra chunk is dropped on the way
|
||||
@@ -52,9 +165,9 @@ router:
|
||||
# traffic, because an engine queue is FIFO and knows nothing about tenants.
|
||||
admission:
|
||||
enabled: true
|
||||
# Until authentication lands, the band comes from a header and is trusted.
|
||||
# Do not expose this port to callers who should not be able to promote
|
||||
# themselves.
|
||||
# With auth on, the key decides the band and this header is ignored. With
|
||||
# auth off the header is trusted, so any client can promote itself into the
|
||||
# top band — do not expose the port in that configuration.
|
||||
band_header: x-llmgw-band
|
||||
default_band: gold
|
||||
# A band waits once pool saturation passes its ceiling, so the cluster
|
||||
@@ -84,6 +197,13 @@ router:
|
||||
session_load_factor: 1.25
|
||||
|
||||
# How aggressively the control plane reconciles.
|
||||
# The engines every node installs, and at which version. Unset keeps the
|
||||
# versions this release was checked against (llama.cpp b10991). Each agent
|
||||
# picks the build for its own hardware and installs it itself; changing a
|
||||
# version here reaches every node on its next connect.
|
||||
# engines:
|
||||
# llamacpp: b10991
|
||||
|
||||
control:
|
||||
interval: 5s
|
||||
base_backoff: 10s
|
||||
@@ -96,15 +216,51 @@ control:
|
||||
port_range: [20000, 20999]
|
||||
mgmt_interface: eth0
|
||||
|
||||
# Each model needs a source — a weights path or a HuggingFace id — and at least
|
||||
# one profile if the control plane is to place it. Without profiles the model is
|
||||
# catalogued but never started, which is the router-only mode below.
|
||||
models:
|
||||
- name: llama-3.3-70b
|
||||
source: meta-llama/Llama-3.3-70B-Instruct
|
||||
aliases: [llama]
|
||||
fallbacks: [qwen2.5-32b]
|
||||
routing_profile: chat
|
||||
scaling: { min: 1, max: 4 }
|
||||
profiles:
|
||||
# Two nodes of four H100s each. A multi-node profile requires a fabric,
|
||||
# and a replica never spans two of them.
|
||||
- name: tp8
|
||||
engine: vllm
|
||||
gpu_class: h100-80g
|
||||
tp: 8
|
||||
nodes: 2
|
||||
gpus_per_node: 4
|
||||
priority: 100
|
||||
weight: 8
|
||||
startup_timeout: 30m
|
||||
# Where a whole node is free, one node is cheaper to schedule and faster
|
||||
# to start than two.
|
||||
- name: tp4-a100
|
||||
engine: vllm
|
||||
gpu_class: a100-40g
|
||||
tp: 4
|
||||
priority: 50
|
||||
weight: 3
|
||||
startup_timeout: 20m
|
||||
|
||||
- name: qwen2.5-32b
|
||||
source: Qwen/Qwen2.5-32B-Instruct
|
||||
aliases: [qwen]
|
||||
routing_profile: batch
|
||||
scaling: { min: 1, max: 2 }
|
||||
profiles:
|
||||
- name: tp2
|
||||
engine: sglang
|
||||
gpu_class: h100-80g
|
||||
tp: 2
|
||||
priority: 100
|
||||
weight: 2
|
||||
startup_timeout: 15m
|
||||
|
||||
# A static replica list, used only when no model declares a profile. That is the
|
||||
# router-only mode for testing against sim-engines; with the control plane
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Two control planes
|
||||
|
||||
llmgw handles the hard half of this itself: only one control plane places work,
|
||||
and agents find whichever one is leading. What it does not do is move client
|
||||
traffic, because that happens in front of both and llmgw is not in front of
|
||||
anything.
|
||||
|
||||
This directory is the missing half. It is a pattern rather than a product:
|
||||
substitute your own load balancer if you have one.
|
||||
|
||||
## What llmgw does on its own
|
||||
|
||||
**One leader.** Leadership is a lease in the shared store. A second control
|
||||
plane starts, sees the lease, and stands by — it places nothing, evicts
|
||||
nothing and drains nothing. Start one by accident and your cluster is
|
||||
unharmed.
|
||||
|
||||
**Agents follow.** A standby refuses agent streams, so agents move to the
|
||||
leader by themselves. Give each agent both addresses:
|
||||
|
||||
```yaml
|
||||
servers:
|
||||
- "https://ctl-1.internal:7443"
|
||||
- "https://ctl-2.internal:7443"
|
||||
```
|
||||
|
||||
Measured on a cluster: leadership moved three seconds after the leader was
|
||||
stopped, every agent had arrived six seconds later, and every replica was
|
||||
adopted rather than restarted — the engines never stopped serving.
|
||||
|
||||
## What it does not do
|
||||
|
||||
Client traffic. During that same failover the standby served inference
|
||||
correctly on its own port while the public address returned 502, because
|
||||
nothing in front of the two had moved.
|
||||
|
||||
So something has to. The options, in the order most people should consider
|
||||
them:
|
||||
|
||||
**A load balancer you already run.** Health-check `/ready` on each control
|
||||
plane and send traffic to whichever answers. This is the right answer if you
|
||||
have one, and it needs nothing else from this directory.
|
||||
|
||||
**A floating address.** `keepalived.conf.example` here moves one IP between
|
||||
the two machines. Fewer moving parts than a load balancer and no extra hop,
|
||||
at the cost of one machine carrying all the traffic.
|
||||
|
||||
**DNS.** Works, eventually. A record with a short TTL is a failover measured
|
||||
in whatever your resolvers actually honour rather than what you set, which on
|
||||
a bad day is minutes. Fine for a cluster people use interactively, wrong for
|
||||
one an agent is driving.
|
||||
|
||||
## Check `/ready`, not `/health`
|
||||
|
||||
This is the one thing to get right, and the obvious choice is the wrong one.
|
||||
|
||||
`/health` says the process is up. It says nothing about whether it can serve,
|
||||
and a standby is exactly that case: alive, answering, and holding no endpoints
|
||||
at all — its reconciler placed nothing and it has no agent streams to adopt
|
||||
from. A load balancer checking `/health` would send it half the traffic, and
|
||||
half the traffic would be a 503.
|
||||
|
||||
`/ready` says this process has somewhere to send a request. It is 503 on a
|
||||
standby, 503 on a leader whose replicas have all failed, and 200 when there is
|
||||
something to serve — which is the question a load balancer is actually asking.
|
||||
Both are open, because a load balancer has no key.
|
||||
|
||||
During a failover neither passes for about the length of the lease handover:
|
||||
three seconds when the leader is stopped cleanly, up to the lease TTL when it
|
||||
is not. That gap is real and there is no way to have it both ways — traffic
|
||||
cannot go to a control plane that has nothing to route to.
|
||||
|
||||
Not the admin port, for a different reason: it serves pprof, and pprof can
|
||||
dump the process heap, where API keys sit in plaintext.
|
||||
|
||||
## Both control planes need the same store
|
||||
|
||||
Leadership lives in it, and so do keys, budgets and conversations. Two control
|
||||
planes on two SQLite files are two clusters that happen to share a name.
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
store: "@/etc/llmgw/secrets/store-dsn"
|
||||
```
|
||||
|
||||
See [docs/install.md](../../docs/install.md) for moving an existing
|
||||
installation onto PostgreSQL without reissuing every key.
|
||||
@@ -0,0 +1,65 @@
|
||||
# A floating address for two control planes.
|
||||
#
|
||||
# One IP that lives on whichever machine is up. Clients and the reverse proxy
|
||||
# in front of llmgw point at the address, not at either host, so a failover is
|
||||
# invisible to them.
|
||||
#
|
||||
# This is not a load balancer: all traffic goes to the holder. That is usually
|
||||
# the right trade for a control plane, which serves far less than the nodes
|
||||
# it schedules.
|
||||
#
|
||||
# Copy to /etc/keepalived/keepalived.conf on both machines, changing state,
|
||||
# priority and interface.
|
||||
|
||||
global_defs {
|
||||
enable_script_security
|
||||
script_user nobody
|
||||
}
|
||||
|
||||
# The address moves only while llmgw can actually serve, not merely while the
|
||||
# machine is powered on.
|
||||
#
|
||||
# /ready, not /health. /health is 200 on a standby, which is up, answering and
|
||||
# holding no endpoints at all — so the address would sit on a host that refuses
|
||||
# every request. /ready is 200 only when there is somewhere to send one.
|
||||
#
|
||||
# Not the admin port either: it serves pprof, which can dump the heap that
|
||||
# holds every API key.
|
||||
vrrp_script check_llmgw {
|
||||
script "/usr/bin/curl -fsS --max-time 2 -o /dev/null http://127.0.0.1:8080/ready"
|
||||
interval 2
|
||||
timeout 3
|
||||
# Two failures before giving up the address, so one slow moment does not
|
||||
# move it. Two successes before taking it back, so a flapping gateway does
|
||||
# not drag the address with it.
|
||||
fall 2
|
||||
rise 2
|
||||
weight -40
|
||||
}
|
||||
|
||||
vrrp_instance llmgw {
|
||||
# MASTER on the machine you would rather serve; BACKUP on the other.
|
||||
state MASTER
|
||||
interface eth0
|
||||
# Any number both machines agree on and nothing else on the segment uses.
|
||||
virtual_router_id 51
|
||||
# The higher one wins while both are healthy. The weight above is enough
|
||||
# to invert this when the preferred machine's gateway is not answering.
|
||||
priority 100
|
||||
advert_int 1
|
||||
|
||||
authentication {
|
||||
auth_type PASS
|
||||
# Change this. It stops another host on the segment claiming the
|
||||
# address, which is otherwise a single unicast packet away.
|
||||
auth_pass CHANGEME
|
||||
}
|
||||
|
||||
virtual_ipaddress {
|
||||
10.0.0.150/24
|
||||
}
|
||||
|
||||
track_script {
|
||||
check_llmgw
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
# A model mirror
|
||||
|
||||
One machine that holds the model files, so nodes fetch over your own network
|
||||
instead of over the internet.
|
||||
|
||||
This is the highest-value piece of infrastructure around llmgw and the cheapest.
|
||||
Eight nodes each pulling forty gigabytes from Hugging Face is a day's work, and
|
||||
on plenty of corporate networks it is not possible at all. From a mirror on the
|
||||
same switch it is minutes.
|
||||
|
||||
llmgw needs nothing special for this: a model's `source` is a URL, and the URL
|
||||
can be yours.
|
||||
|
||||
## What it is
|
||||
|
||||
An HTTP server serving a directory. That is the whole design.
|
||||
|
||||
```
|
||||
/srv/models/
|
||||
qwen2.5-14b-instruct/
|
||||
2026-03/
|
||||
q4_k_m.gguf
|
||||
bge-m3/
|
||||
2026-01/
|
||||
model.safetensors
|
||||
```
|
||||
|
||||
The date in the path is the point of the layout, not decoration. A model
|
||||
repository that changes upstream changes what you are running, and the first
|
||||
sign is a prompt that worked last week answering differently this week with
|
||||
nobody able to say why. Pin it here and that cannot happen.
|
||||
|
||||
## Running it
|
||||
|
||||
`llmgw` is the mirror. Same binary as the control plane and the agents, one
|
||||
subcommand:
|
||||
|
||||
```
|
||||
sudo mkdir -p /srv/models && sudo chown llmgw:llmgw /srv/models
|
||||
llmgw mirror serve --dir /srv/models --base http://models.internal:8081
|
||||
```
|
||||
|
||||
There is a unit in `deploy/systemd/llmgw-mirror.service`. Set `--base` to the
|
||||
address nodes reach this machine on — not `localhost`, and not a name only this
|
||||
host knows itself by. It is the string that ends up in catalog entries, and a
|
||||
wrong one makes `mirror unused` report files as unreferenced when they are in
|
||||
use. The next step after that list is deletion.
|
||||
|
||||
### Two listeners
|
||||
|
||||
`--listen :8081` serves the weights. Read-only, unauthenticated, Range requests
|
||||
supported so an interrupted fetch on a node resumes rather than restarts. This
|
||||
is the path eight nodes pull forty gigabytes from.
|
||||
|
||||
`--api-listen :8082` is the management API. Separate on purpose: putting a
|
||||
client certificate in front of the byte path would mean re-certifying every
|
||||
agent's fetcher in order to change how models are administered.
|
||||
|
||||
The API is unauthenticated unless you give it the fleet's CA, and it says so at
|
||||
startup rather than leaving you to find out. Anyone who can reach that port can
|
||||
start downloads and delete models, so either keep it on a trusted network or
|
||||
hand it certificates:
|
||||
|
||||
```
|
||||
llmgw mirror serve --dir /srv/models --base http://models.internal:8081 \
|
||||
--tls-ca /etc/llmgw/ca.pem \
|
||||
--tls-cert /etc/llmgw/mirror.pem \
|
||||
--tls-key /etc/llmgw/mirror-key.pem
|
||||
```
|
||||
|
||||
That is the same CA the agents already use, so the certificates come from the
|
||||
authority you have:
|
||||
|
||||
```
|
||||
llmgw ctl cert issue-server mirror --host models.internal --host 10.0.0.146
|
||||
llmgw ctl cert issue-client control-plane
|
||||
```
|
||||
|
||||
`--host` has to cover every address the API is called on, because that is what
|
||||
a client verifies against. Both sides are checked — the mirror will not accept a
|
||||
caller without a certificate from this CA, the same rule the agent stream
|
||||
follows.
|
||||
|
||||
### nginx instead
|
||||
|
||||
If you already run nginx, it serves a directory perfectly well and you do not
|
||||
need `mirror serve` for the bytes:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name models.internal;
|
||||
|
||||
root /srv/models;
|
||||
|
||||
# Read-only. This is deliberate: a mirror that can be written to over HTTP
|
||||
# is a way to replace a model with another one, and the nodes would fetch
|
||||
# it without complaint.
|
||||
location / {
|
||||
autoindex on;
|
||||
# Large files, and the client is a machine with a resumable fetcher.
|
||||
# Sendfile and no buffering keep memory flat regardless of size.
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`autoindex` is there so a person can browse what is available; llmgw does not
|
||||
need it.
|
||||
|
||||
## Filling it
|
||||
|
||||
From the mirror itself:
|
||||
|
||||
```
|
||||
llmgw mirror add \
|
||||
--model qwen2.5-14b-instruct --version 2026-03 \
|
||||
"https://huggingface.co/<repo>/resolve/<revision>/q4_k_m.gguf"
|
||||
```
|
||||
|
||||
Use a revision rather than `main` in the source URL. `main` is what moves.
|
||||
|
||||
It downloads to a temporary name and renames when complete, so an interrupted
|
||||
download never leaves something a catalog could be pointed at, and it prints the
|
||||
sha256 when it lands. Compare that against what upstream publishes — Hugging
|
||||
Face gives it as `lfs.oid` from
|
||||
`https://huggingface.co/api/models/<repo>/tree/<revision>`. A mirror is the one
|
||||
place in this system where a corrupt file is copied to every node instead of
|
||||
being caught once.
|
||||
|
||||
It refuses to overwrite a version that is already there. Add a new one beside
|
||||
it: that refusal is what makes the old weights still available when the new ones
|
||||
turn out to be worse.
|
||||
|
||||
## Driving it from elsewhere
|
||||
|
||||
The same fetch over the API, so the control plane — or anything else — can ask
|
||||
for a model without anyone logging in:
|
||||
|
||||
```
|
||||
curl -X POST http://models.internal:8082/v1/models -d '{
|
||||
"source": "https://huggingface.co/<repo>/resolve/<revision>/q4_k_m.gguf",
|
||||
"model": "qwen2.5-14b-instruct",
|
||||
"version": "2026-03"
|
||||
}'
|
||||
```
|
||||
|
||||
The mirror does the downloading. A control plane that pulled the bytes itself
|
||||
and pushed them here would carry every model twice over the network, through the
|
||||
one machine least able to spare the bandwidth.
|
||||
|
||||
It answers `202` with the URL a catalog entry should use, and the bytes are not
|
||||
here yet. Poll for them:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET /v1/models` | everything held or arriving, and the base to build URLs from |
|
||||
| `POST /v1/models` | fetch this source into model/version |
|
||||
| `GET /v1/models/{model}/{version}` | `fetching` with a byte count, `present`, or `failed` with the reason |
|
||||
| `DELETE /v1/models/{model}/{version}` | remove a version; refuses while it is downloading |
|
||||
| `GET /v1/health` | up *and* able to read its own directory |
|
||||
|
||||
A failed fetch stays `failed` with what went wrong, rather than disappearing:
|
||||
otherwise the thing waiting on that model waits forever with nothing to say why.
|
||||
Asking twice for the same download joins the one already running instead of
|
||||
starting a rival writer on the same file.
|
||||
|
||||
`DELETE` takes the whole version, never one file of it. A model split across
|
||||
several files is useless with one missing, and half a version on disk looks
|
||||
present and will not load.
|
||||
|
||||
## Retiring a model
|
||||
|
||||
```
|
||||
llmgw mirror unused # what no catalog entry refers to
|
||||
llmgw mirror remove --model <m> --version <v> --file <f>
|
||||
```
|
||||
|
||||
`unused` compares against every `source` in the running catalog, so the answer
|
||||
is what can go rather than what looks old. It refuses to answer when it cannot
|
||||
reach the control plane: an empty catalog and an unreachable one produce the
|
||||
same list — everything — and the obvious next step after that list is deletion.
|
||||
|
||||
Retire the catalog entry first, let the fleet settle, then remove. A node that
|
||||
has to refetch a file you deleted cannot, and the failure arrives later, on the
|
||||
node, looking like something else.
|
||||
|
||||
## From the control plane's admin page
|
||||
|
||||
Configure the mirror on the control plane and the same operations appear at
|
||||
`/admin/mirror`, behind the admin session the other admin pages use:
|
||||
|
||||
```yaml
|
||||
mirror:
|
||||
api: https://models.internal:8082
|
||||
tls:
|
||||
ca: /etc/llmgw/ca.crt
|
||||
cert: /etc/llmgw/control-plane.crt
|
||||
key: /etc/llmgw/control-plane.key
|
||||
```
|
||||
|
||||
Leave the block out and the page is not mounted at all, which is the right page
|
||||
for a deployment with no mirror — not an empty one pointing at nothing.
|
||||
|
||||
The page lists what is held, what is arriving with its byte count, and what
|
||||
failed with the reason; it starts a fetch, retries a failed one, and removes a
|
||||
version. It reloads itself only while something is downloading, because a reload
|
||||
at any other moment takes with it whatever is half-typed in the form.
|
||||
|
||||
Two things it deliberately will not do. It cannot point the control plane at a
|
||||
different mirror — that is configuration, and changing it needs access to the
|
||||
host rather than a session. And it does not write the catalog for you: it shows
|
||||
the URL an entry should use, and putting it there is still a deliberate act,
|
||||
because that is the moment a model starts being served.
|
||||
|
||||
The address is a mistake worth naming: `api` is the **management** port, not the
|
||||
one nodes fetch bytes from. They are two listeners so this one can be locked
|
||||
down without re-certifying every agent's fetcher.
|
||||
|
||||
## Pointing llmgw at it
|
||||
|
||||
```yaml
|
||||
- name: qwen2.5-14b-instruct
|
||||
source: http://models.internal/qwen2.5-14b-instruct/2026-03/q4_k_m.gguf
|
||||
scaling: { min: 2, max: 2 }
|
||||
profiles:
|
||||
- name: gpu
|
||||
engine: vllm
|
||||
gpu_class: h100-80g
|
||||
tp: 1
|
||||
```
|
||||
|
||||
Each node fetches once, in the background, before its engine starts — and
|
||||
`llmgw ctl replicas` shows how far along it is while it does. A killed download
|
||||
resumes rather than restarting, and a partial file is never visible under the
|
||||
model's name, so an interrupted fetch cannot be handed to an engine.
|
||||
|
||||
## What this is not
|
||||
|
||||
It is not a cache. Nothing populates it on its own: a model is there because
|
||||
somebody asked for it, by hand or over the API, and one nobody asked for is not
|
||||
there. That is on purpose. A mirror that fetched on demand would reach the
|
||||
internet at the least convenient moment, and in a network where that is blocked
|
||||
it would fail in a way that looks like llmgw being broken.
|
||||
|
||||
It is also not a replacement for a shared filesystem. If you already run one
|
||||
that every node mounts, put the models on it and use a path as the `source` —
|
||||
nothing is fetched at all, and llmgw notices the filesystem is shared and will
|
||||
not count the same free space once per node.
|
||||
|
||||
## When this stops being enough
|
||||
|
||||
Somewhere above ten or twenty nodes, one mirror serving a forty gigabyte file
|
||||
to all of them at once becomes the bottleneck instead of the fix. At that point
|
||||
the options are a filer, a second mirror per rack, or distribution between the
|
||||
nodes themselves.
|
||||
|
||||
llmgw knows which nodes already hold which weights — it is in `ctl status` as
|
||||
"N models on X/Y nodes" — so distributing between them is a feature it is
|
||||
positioned to add. It has not been built, because a mirror is enough for
|
||||
everyone who has needed it so far.
|
||||
@@ -0,0 +1,170 @@
|
||||
# Watching a cluster
|
||||
|
||||
Prometheus, Alertmanager and Grafana, configured for what llmgw actually
|
||||
publishes. Every alert here is written against a series this system emits;
|
||||
nothing is aimed at a metric that might exist.
|
||||
|
||||
## What it is
|
||||
|
||||
One scrape target for the whole fleet.
|
||||
|
||||
Agents publish no metrics of their own. The control plane gathers what they
|
||||
report — node state, memory, disk, engine queue depth, KV occupancy — and
|
||||
republishes all of it, so a single endpoint describes every machine. That is
|
||||
worth knowing before going looking for an exporter on each node.
|
||||
|
||||
## The port it scrapes
|
||||
|
||||
`metrics_listen`, not `admin_listen`.
|
||||
|
||||
The admin listener is bound to loopback for one reason: it also serves pprof,
|
||||
and pprof can dump the process heap, where API keys sit in plaintext. Metrics
|
||||
are the one thing on that listener something else genuinely needs to reach, so
|
||||
they get a port of their own instead of becoming an argument for opening the
|
||||
other one.
|
||||
|
||||
```yaml
|
||||
# /etc/llmgw/server.yaml
|
||||
metrics_listen: ":9091"
|
||||
```
|
||||
|
||||
Confirm it carries nothing else:
|
||||
|
||||
```
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://<control-plane>:9091/debug/pprof/ # 404
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://<control-plane>:9091/api/v1/nodes # 404
|
||||
```
|
||||
|
||||
## Installing
|
||||
|
||||
On Ubuntu 24.04 the first two come from the archive; Grafana needs its own
|
||||
repository.
|
||||
|
||||
```
|
||||
apt-get install prometheus prometheus-alertmanager
|
||||
|
||||
install -m 0644 prometheus.yml /etc/prometheus/prometheus.yml
|
||||
install -m 0644 llmgw-rules.yml /etc/prometheus/rules/llmgw.yml
|
||||
install -m 0644 alertmanager.yml /etc/prometheus/alertmanager.yml
|
||||
promtool check config /etc/prometheus/prometheus.yml
|
||||
systemctl restart prometheus prometheus-alertmanager
|
||||
|
||||
install -m 0644 grafana/datasource.yaml /etc/grafana/provisioning/datasources/llmgw.yaml
|
||||
install -m 0644 grafana/dashboards.yaml /etc/grafana/provisioning/dashboards/llmgw.yaml
|
||||
install -m 0644 -o grafana -g grafana grafana/llmgw-overview.json \
|
||||
/var/lib/grafana/dashboards/llmgw.json
|
||||
systemctl restart grafana-server
|
||||
```
|
||||
|
||||
The dashboard is provisioned from a file rather than clicked together, so it
|
||||
survives a reinstall and can be reviewed in a diff.
|
||||
|
||||
## Where to run it
|
||||
|
||||
Not on the control plane. Monitoring that dies with the thing it monitors tells
|
||||
you nothing at the moment you need it, and the control plane is also the busiest
|
||||
machine in a small cluster.
|
||||
|
||||
Anywhere else on the management network will do. The mirror is a reasonable
|
||||
host: it has the disk, and serving weights is bursty rather than constant.
|
||||
|
||||
## Nothing is sent anywhere
|
||||
|
||||
Alertmanager groups and holds alerts and has no receiver.
|
||||
|
||||
Where an alert should go — an address, a Slack workspace, a webhook — is a
|
||||
decision with credentials attached, and a monitoring system that quietly starts
|
||||
mailing people is worse than one that is visibly not wired up. Alerts are
|
||||
visible at `:9093` and in Prometheus; add a receiver to `alertmanager.yml` to
|
||||
make them travel. `critical` already routes separately so it can be pointed
|
||||
somewhere else without touching the rest.
|
||||
|
||||
## Check that it fires
|
||||
|
||||
An alerting system nobody has seen fire is an assumption.
|
||||
|
||||
```
|
||||
llmgw ctl cordon <node> # work already there keeps running
|
||||
# ~2 minutes later
|
||||
curl -s http://<host>:9093/api/v2/alerts | jq '.[].labels'
|
||||
llmgw ctl uncordon <node> # the alert resolves on its own
|
||||
```
|
||||
|
||||
Cordoning is the cheapest real test: it changes a metric the rules read, stops
|
||||
nothing, and reverses in one command.
|
||||
|
||||
## What the alerts assume
|
||||
|
||||
**A stopped model publishes no series.** `llmgw_model_replicas_ready` exists
|
||||
only for models with a replica placed, so `== 0` cannot fire for one that is
|
||||
deliberately off. That is what makes an on-demand catalogue quiet here.
|
||||
|
||||
**Saturation of 1 is the healthy limit, not failure.** It is the worse of queue
|
||||
depth and KV occupancy, measured by `ctl bench` rather than guessed. The alert
|
||||
allows five minutes above it before saying anything.
|
||||
|
||||
**Latencies are in seconds.** These thresholds are written for CPU inference. A
|
||||
p99 first token of thirty seconds is the alerting point here and would be
|
||||
absurd on a GPU; change it when the hardware changes.
|
||||
|
||||
**Disk is only reported by nodes that hold weights.** A node with no model
|
||||
directory reports nothing rather than zero, and the rule divides free by total
|
||||
so a node that reports neither never fires.
|
||||
|
||||
## Where the metrics live
|
||||
|
||||
On the machine that scrapes them, in Prometheus's own store, and that is the
|
||||
right answer at this size. Measured on this cluster: 2,359 series at 41 samples
|
||||
a second, which compresses to roughly 7 MB a day — about 3 GB for a year.
|
||||
|
||||
```
|
||||
# /etc/default/prometheus
|
||||
ARGS="--storage.tsdb.retention.time=365d --storage.tsdb.retention.size=20GB"
|
||||
```
|
||||
|
||||
A year because the questions worth asking of this data are seasonal: what did
|
||||
capacity look like last quarter, was it always this slow, how much did the
|
||||
fleet grow. Fifteen days answers none of them.
|
||||
|
||||
Both limits, not one. The time limit is the intent; the size cap is what stops
|
||||
Prometheus becoming the thing that fills the disk if the series count grows
|
||||
tenfold after somebody adds a fleet.
|
||||
|
||||
No remote write, no object store, no second tier. At two thousand series that
|
||||
is infrastructure for a problem this cluster does not have, and every one of
|
||||
those has its own failure modes to learn.
|
||||
|
||||
### The part that is not about capacity
|
||||
|
||||
The disk is not the constraint — 900 GB free against 3 GB a year. What matters
|
||||
is what shares it.
|
||||
|
||||
On this cluster the model mirror and the metrics database are on the same
|
||||
filesystem. A mirror that fills it stops Prometheus writing, so monitoring goes
|
||||
quiet at exactly the moment the disk is the problem, and the alert that would
|
||||
have said so is the one that died.
|
||||
|
||||
Two ways out, in order of how much they cost:
|
||||
|
||||
- Watch it, which is now done. `prometheus-node-exporter` arrives with the
|
||||
Prometheus package and was running, unscraped, on this machine — so the disk
|
||||
holding both was watched by nothing. It is a scrape target now, and
|
||||
`MonitoringDiskFilling` fires at fifteen percent rather than five, because
|
||||
the thing being protected is the ability to be told.
|
||||
- Separate them: a volume of its own for `/var/lib/prometheus`, or monitoring
|
||||
on its own machine. Worth doing before the mirror holds a few hundred
|
||||
gigabytes; not worth doing today at seven percent used.
|
||||
|
||||
## What is not here
|
||||
|
||||
No exporter on the llmgw nodes, so no host CPU, load average or network for
|
||||
them. The control plane publishes memory, disk and liveness for every node,
|
||||
which is what placement decides on; the rest is a `node_exporter` away if
|
||||
somebody wants it. The monitoring host itself is scraped, for the reason above.
|
||||
|
||||
No probe of the mirror. Its `/v1/health` is JSON rather than Prometheus text,
|
||||
and checking it properly means `blackbox_exporter`.
|
||||
|
||||
No recording rules. At this size every query in the dashboard runs fast enough
|
||||
raw, and a recording rule is a second definition of a number to keep in step
|
||||
with the first.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Alertmanager for the llmgw cluster.
|
||||
#
|
||||
# Nothing is sent anywhere yet, on purpose. Where an alert should go — an
|
||||
# address, a Slack workspace, a webhook — is a decision with credentials
|
||||
# attached, and a monitoring system that quietly starts mailing people is worse
|
||||
# than one that is visibly not wired up. Alerts group and fire here and are
|
||||
# visible at :9093 and in Prometheus; add a receiver below to make them travel.
|
||||
global:
|
||||
resolve_timeout: 5m
|
||||
|
||||
route:
|
||||
# By alert and by what it is about, so five nodes going quiet in the same
|
||||
# network blip arrive as one notification rather than five.
|
||||
group_by: ['alertname', 'node', 'model']
|
||||
group_wait: 30s
|
||||
group_interval: 5m
|
||||
# Long, because a repeat is a reminder and not news. A cluster with one thing
|
||||
# wrong should not produce a message an hour.
|
||||
repeat_interval: 12h
|
||||
receiver: none
|
||||
|
||||
routes:
|
||||
# Critical gets its own branch so it can be pointed somewhere else later
|
||||
# without touching the rest: the difference between "a node is draining"
|
||||
# and "nothing is serving" is who should be woken.
|
||||
- matchers: [severity="critical"]
|
||||
receiver: none
|
||||
group_wait: 10s
|
||||
repeat_interval: 4h
|
||||
|
||||
receivers:
|
||||
- name: none
|
||||
|
||||
inhibit_rules:
|
||||
# A node that is down explains every replica on it. Reporting both says the
|
||||
# same thing twice and buries whichever is the actual cause.
|
||||
- source_matchers: [alertname="NodeNotReady"]
|
||||
target_matchers: [alertname="EndpointMetricsStale"]
|
||||
equal: ['node']
|
||||
- source_matchers: [alertname="ControlPlaneUnreachable"]
|
||||
target_matchers: [severity="warning"]
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: 1
|
||||
providers:
|
||||
- name: llmgw
|
||||
orgId: 1
|
||||
folder: llmgw
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 30
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
@@ -0,0 +1,11 @@
|
||||
# Provisioned rather than clicked in, so the dashboard below can reference it by
|
||||
# a fixed uid and the whole setup survives a reinstall of the machine.
|
||||
apiVersion: 1
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
uid: llmgw-prom
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://localhost:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
@@ -0,0 +1,492 @@
|
||||
{
|
||||
"uid": "llmgw-overview",
|
||||
"title": "llmgw \u2014 cluster",
|
||||
"tags": [
|
||||
"llmgw"
|
||||
],
|
||||
"timezone": "browser",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"refresh": "30s",
|
||||
"time": {
|
||||
"from": "now-6h",
|
||||
"to": "now"
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Nodes ready",
|
||||
"type": "stat",
|
||||
"description": "Nodes taking work. A node that is known but draining or cordoned is not counted.",
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "count(llmgw_node_up == 1)",
|
||||
"legendFormat": "ready",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
},
|
||||
"textMode": "auto",
|
||||
"colorMode": "value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Replicas serving",
|
||||
"type": "stat",
|
||||
"description": "Across every model. A model deliberately stopped contributes nothing and is not a fault.",
|
||||
"gridPos": {
|
||||
"x": 4,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum(llmgw_model_replicas_ready)",
|
||||
"legendFormat": "ready",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
},
|
||||
"textMode": "auto",
|
||||
"colorMode": "value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Models with something serving",
|
||||
"type": "stat",
|
||||
"description": "",
|
||||
"gridPos": {
|
||||
"x": 8,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 4
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "count(llmgw_model_replicas_ready > 0)",
|
||||
"legendFormat": "models",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
]
|
||||
},
|
||||
"textMode": "auto",
|
||||
"colorMode": "value"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Requests per second",
|
||||
"type": "timeseries",
|
||||
"description": "Failing is counted separately rather than as a percentage, so a quiet cluster reads as quiet instead of as 0% of nothing.",
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 0,
|
||||
"w": 12,
|
||||
"h": 4
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum(rate(llmgw_requests_total[5m]))",
|
||||
"legendFormat": "all",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"expr": "sum(rate(llmgw_requests_total{status=~\"5..\"}[5m]))",
|
||||
"legendFormat": "failing",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "Replicas ready, by model",
|
||||
"type": "timeseries",
|
||||
"description": "Only models with a replica placed appear. One that is stopped on purpose publishes no series.",
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 4,
|
||||
"w": 12,
|
||||
"h": 7
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "llmgw_model_replicas_ready",
|
||||
"legendFormat": "{{model}}",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"title": "Pool saturation",
|
||||
"type": "timeseries",
|
||||
"description": "The worse of queue depth and KV occupancy. 1 is the limit measured as healthy, not the point of failure.",
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 4,
|
||||
"w": 12,
|
||||
"h": 7
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "llmgw_pool_saturation",
|
||||
"legendFormat": "{{model}}",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"max": 2,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "Time to first token",
|
||||
"type": "timeseries",
|
||||
"description": "As the router sees it. On CPU these are seconds, not milliseconds; compare against what ctl bench measured.",
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 11,
|
||||
"w": 12,
|
||||
"h": 7
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "histogram_quantile(0.5, sum by (model, le) (rate(llmgw_ttft_seconds_bucket[5m])))",
|
||||
"legendFormat": "{{model}} p50",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"expr": "histogram_quantile(0.99, sum by (model, le) (rate(llmgw_ttft_seconds_bucket[5m])))",
|
||||
"legendFormat": "{{model}} p99",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"title": "Tokens per second",
|
||||
"type": "timeseries",
|
||||
"description": "",
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 11,
|
||||
"w": 12,
|
||||
"h": 7
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum by (direction) (rate(llmgw_tokens_total[5m]))",
|
||||
"legendFormat": "{{direction}}",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"title": "Engine queue depth",
|
||||
"type": "timeseries",
|
||||
"description": "Requests the engine has accepted and not started. Rising here is the first sign of saturation.",
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 18,
|
||||
"w": 8,
|
||||
"h": 6
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "llmgw_endpoint_waiting",
|
||||
"legendFormat": "{{replica}}",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"title": "KV cache occupancy",
|
||||
"type": "timeseries",
|
||||
"description": "",
|
||||
"gridPos": {
|
||||
"x": 8,
|
||||
"y": 18,
|
||||
"w": 8,
|
||||
"h": 6
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "llmgw_endpoint_kv_cache_usage",
|
||||
"legendFormat": "{{replica}}",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "percentunit"
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"title": "Model disk free",
|
||||
"type": "timeseries",
|
||||
"description": "The filesystems holding weights. A node that cannot hold the next model is skipped by placement rather than failing.",
|
||||
"gridPos": {
|
||||
"x": 16,
|
||||
"y": 18,
|
||||
"w": 8,
|
||||
"h": 6
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "llmgw_node_disk_free_bytes",
|
||||
"legendFormat": "{{node}}",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "bytes"
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"title": "Seconds since each node last reported",
|
||||
"type": "timeseries",
|
||||
"description": "Heartbeats are every 5s. A line that climbs is an agent going quiet; the control plane stops placing there at 15s.",
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 24,
|
||||
"w": 12,
|
||||
"h": 6
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "llmgw_node_last_seen_seconds",
|
||||
"legendFormat": "{{node}}",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"title": "Replicas by state",
|
||||
"type": "timeseries",
|
||||
"description": "",
|
||||
"gridPos": {
|
||||
"x": 12,
|
||||
"y": 24,
|
||||
"w": 12,
|
||||
"h": 6
|
||||
},
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"expr": "sum by (state) (llmgw_replicas)",
|
||||
"legendFormat": "{{state}}",
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "llmgw-prom"
|
||||
}
|
||||
}
|
||||
],
|
||||
"fieldConfig": {
|
||||
"defaults": {},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
# Alerts for llmgw. Every expression here is against a series this cluster
|
||||
# actually publishes; nothing is written against a metric that might exist.
|
||||
groups:
|
||||
- name: llmgw-fleet
|
||||
rules:
|
||||
- alert: ControlPlaneUnreachable
|
||||
expr: up{job="llmgw"} == 0
|
||||
for: 1m
|
||||
labels: { severity: critical }
|
||||
annotations:
|
||||
summary: "The control plane is not answering"
|
||||
description: >-
|
||||
Nothing has scraped 10.0.0.140:9091 for a minute. Engines already
|
||||
running keep serving — the router is in the same process, so if
|
||||
this is down, so is the gateway.
|
||||
|
||||
- alert: NodeNotReady
|
||||
expr: llmgw_node_up == 0
|
||||
for: 2m
|
||||
labels: { severity: warning }
|
||||
annotations:
|
||||
summary: "{{ $labels.node }} is {{ $labels.state }}"
|
||||
description: >-
|
||||
The node is known but not taking work. Two minutes, because a node
|
||||
restarting its agent is briefly not ready and is not a problem.
|
||||
|
||||
- alert: AgentGoneQuiet
|
||||
expr: llmgw_node_last_seen_seconds > 60
|
||||
for: 1m
|
||||
labels: { severity: warning }
|
||||
annotations:
|
||||
summary: "{{ $labels.node }} has not reported for {{ $value | humanizeDuration }}"
|
||||
description: >-
|
||||
Heartbeats are every 5s. A minute of silence is the network or the
|
||||
agent, and the control plane stages expiry at 15s unreachable and
|
||||
60s lost — so this fires around the point it stops placing there.
|
||||
|
||||
- alert: ModelHasNothingServing
|
||||
expr: llmgw_model_replicas_ready == 0
|
||||
for: 10m
|
||||
labels: { severity: critical }
|
||||
annotations:
|
||||
summary: "{{ $labels.model }} has no replica ready"
|
||||
description: >-
|
||||
The model is placed and nothing is serving it. Ten minutes, because
|
||||
a large model on CPU legitimately takes minutes to load. A model
|
||||
that is deliberately stopped publishes no series at all, so this
|
||||
cannot fire for one nobody asked for.
|
||||
|
||||
- alert: PoolSaturated
|
||||
expr: llmgw_pool_saturation > 1
|
||||
for: 5m
|
||||
labels: { severity: warning }
|
||||
annotations:
|
||||
summary: "{{ $labels.model }} is over its healthy limit"
|
||||
description: >-
|
||||
Saturation is the worse of queue depth and KV occupancy; 1 is the
|
||||
limit measured as healthy, not the point of failure. Sustained above
|
||||
it means requests are queueing — add a replica or lower the load.
|
||||
|
||||
- alert: EndpointBreakerOpen
|
||||
expr: llmgw_endpoint_breaker_open == 1
|
||||
for: 2m
|
||||
labels: { severity: warning }
|
||||
annotations:
|
||||
summary: "{{ $labels.replica }} has been taken out of rotation"
|
||||
description: >-
|
||||
The router stopped sending to this replica after repeated failures.
|
||||
Traffic is going elsewhere; if it is the only replica, the model is
|
||||
down.
|
||||
|
||||
- alert: EndpointMetricsStale
|
||||
expr: llmgw_endpoint_metrics_stale == 1
|
||||
for: 5m
|
||||
labels: { severity: warning }
|
||||
annotations:
|
||||
summary: "{{ $labels.replica }} is being routed to blind"
|
||||
description: >-
|
||||
Its queue depth and KV usage are old, so the scheduler is choosing
|
||||
with stale numbers. The replica may be serving perfectly well; the
|
||||
decisions about it are the problem.
|
||||
|
||||
- alert: NodeDiskFilling
|
||||
expr: llmgw_node_disk_free_bytes / llmgw_node_disk_total_bytes < 0.10
|
||||
for: 15m
|
||||
labels: { severity: warning }
|
||||
annotations:
|
||||
summary: "{{ $labels.node }} has {{ $value | humanizePercentage }} of its model disk free"
|
||||
description: >-
|
||||
Weights are tens of gigabytes. A node that cannot hold the next one
|
||||
is skipped by placement rather than failing, so this is quiet until
|
||||
somebody wonders why a model will not land there.
|
||||
|
||||
- name: monitoring-host
|
||||
rules:
|
||||
- alert: MonitoringDiskFilling
|
||||
expr: >-
|
||||
node_filesystem_avail_bytes{job="monitoring-host",fstype!~"tmpfs|overlay|squashfs"}
|
||||
/ node_filesystem_size_bytes{job="monitoring-host",fstype!~"tmpfs|overlay|squashfs"} < 0.15
|
||||
for: 10m
|
||||
labels: { severity: warning }
|
||||
annotations:
|
||||
summary: "{{ $labels.mountpoint }} on the monitoring host is {{ $value | humanizePercentage }} free"
|
||||
description: >-
|
||||
This filesystem holds the model mirror and the metrics database at
|
||||
once. Filling it stops new weights arriving and stops Prometheus
|
||||
writing — so monitoring goes quiet at the moment it is most needed,
|
||||
which is why this fires at fifteen percent rather than five.
|
||||
|
||||
- alert: MonitoringDiskAlmostFull
|
||||
expr: >-
|
||||
node_filesystem_avail_bytes{job="monitoring-host",fstype!~"tmpfs|overlay|squashfs"}
|
||||
/ node_filesystem_size_bytes{job="monitoring-host",fstype!~"tmpfs|overlay|squashfs"} < 0.05
|
||||
for: 5m
|
||||
labels: { severity: critical }
|
||||
annotations:
|
||||
summary: "{{ $labels.mountpoint }} on the monitoring host has {{ $value | humanizePercentage }} left"
|
||||
|
||||
- name: llmgw-traffic
|
||||
rules:
|
||||
# These need traffic through the gateway to have any series at all. A
|
||||
# counter with no observations publishes nothing, so a quiet cluster
|
||||
# simply has no data here rather than a rule that is always false.
|
||||
- alert: HighErrorRate
|
||||
expr: >-
|
||||
sum by (model) (rate(llmgw_requests_total{status=~"5.."}[5m]))
|
||||
/ sum by (model) (rate(llmgw_requests_total[5m])) > 0.05
|
||||
for: 5m
|
||||
labels: { severity: critical }
|
||||
annotations:
|
||||
summary: "{{ $labels.model }} is failing {{ $value | humanizePercentage }} of requests"
|
||||
|
||||
- alert: SlowFirstToken
|
||||
expr: >-
|
||||
histogram_quantile(0.99,
|
||||
sum by (model, le) (rate(llmgw_ttft_seconds_bucket[10m]))) > 30
|
||||
for: 10m
|
||||
labels: { severity: warning }
|
||||
annotations:
|
||||
summary: "{{ $labels.model }} takes {{ $value | humanizeDuration }} to first token at p99"
|
||||
description: >-
|
||||
On CPU this is expected under load and is why the threshold is
|
||||
thirty seconds rather than one. Compare with what ctl bench measured
|
||||
before treating it as a fault.
|
||||
|
||||
- alert: RequestsBeingRejected
|
||||
expr: sum by (band) (rate(llmgw_admission_rejected_total[5m])) > 0
|
||||
for: 5m
|
||||
labels: { severity: warning }
|
||||
annotations:
|
||||
summary: "Admission is turning away {{ $labels.band }} traffic"
|
||||
description: >-
|
||||
Working as designed when the cluster is full: the lowest band is
|
||||
held first. Worth knowing about because the callers being refused
|
||||
do not know why.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Prometheus for the llmgw cluster.
|
||||
#
|
||||
# One scrape target for the whole fleet. Agents publish nothing of their own —
|
||||
# the control plane gathers what they report and republishes it, so node health,
|
||||
# disk and endpoint state all arrive here from 10.0.0.140.
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
external_labels:
|
||||
cluster: mynodes
|
||||
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets: ['localhost:9093']
|
||||
|
||||
rule_files:
|
||||
- /etc/prometheus/rules/llmgw.yml
|
||||
|
||||
scrape_configs:
|
||||
# The control plane's dedicated metrics port. Not its admin listener: that one
|
||||
# is loopback because it also serves pprof, and pprof can dump the heap where
|
||||
# API keys sit in plaintext.
|
||||
- job_name: llmgw
|
||||
static_configs:
|
||||
- targets: ['10.0.0.140:9091']
|
||||
labels:
|
||||
instance: control-plane
|
||||
|
||||
- job_name: prometheus
|
||||
static_configs:
|
||||
- targets: ['localhost:9090']
|
||||
|
||||
# The machine this runs on. It came with the Prometheus package and was not
|
||||
# being scraped, which left the disk holding both the model mirror and the
|
||||
# metrics themselves watched by nothing — so a mirror that filled it would
|
||||
# take monitoring down and the alert that would have warned did not exist.
|
||||
- job_name: monitoring-host
|
||||
static_configs:
|
||||
- targets: ['localhost:9100']
|
||||
labels:
|
||||
instance: monitoring
|
||||
@@ -0,0 +1,34 @@
|
||||
# systemd units
|
||||
|
||||
Two roles, two units. Copy the one you need to `/etc/systemd/system/`, put the
|
||||
matching configuration in `/etc/llmgw/`, and `systemctl enable --now` it.
|
||||
|
||||
## The control plane — `llmgw-server.service`
|
||||
|
||||
Runs unprivileged. It needs to read its own TLS key, so that file must belong to
|
||||
the `llmgw` user and nothing else:
|
||||
|
||||
```
|
||||
useradd --system --home-dir /var/lib/llmgw --shell /usr/sbin/nologin llmgw
|
||||
chown llmgw:llmgw /etc/llmgw/server-key.pem
|
||||
chmod 0400 /etc/llmgw/server-key.pem
|
||||
```
|
||||
|
||||
`StateDirectory=llmgw` creates `/var/lib/llmgw` with the right owner, which is
|
||||
where the store belongs. `ProtectSystem=strict` makes the rest of the filesystem
|
||||
read-only, so anything else it writes to has to be named explicitly.
|
||||
|
||||
## The agent — `llmgw-agent.service`
|
||||
|
||||
Runs as root, because it launches engines and needs device access.
|
||||
|
||||
`KillMode=process` is the line that matters and the reason this file exists
|
||||
rather than being generated. Without it, systemd kills the whole control group
|
||||
on stop — including the engines, which hold a loaded model and are serving
|
||||
traffic. Engines are meant to outlive an agent restart: the router talks to them
|
||||
directly, and the agent adopts them again when it comes back. Taking them down
|
||||
with the agent turns a thirty-second upgrade into a full model reload on every
|
||||
node.
|
||||
|
||||
systemd will log `Found left-over process ... in control group while starting
|
||||
unit. Ignoring.` on the next start. That is the mechanism working, not a fault.
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=llmgw node agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/llmgw agent --config /etc/llmgw/agent.yaml
|
||||
# Root, because the agent launches engines and needs device access. Engines
|
||||
# outlive a restart of this unit and are adopted again on the next start, so
|
||||
# KillMode must not take the whole process group with it.
|
||||
KillMode=process
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
StateDirectory=llmgw-agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,29 @@
|
||||
[Unit]
|
||||
Description=llmgw model mirror
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
# --base is what a catalog entry will say, so it has to be the address nodes
|
||||
# reach this machine on — not localhost, and not the name only this host knows
|
||||
# itself by. Getting it wrong makes `mirror unused` report files as unreferenced
|
||||
# when they are in use, and the next step after that list is deletion.
|
||||
ExecStart=/usr/local/bin/llmgw mirror serve \
|
||||
--dir /srv/models \
|
||||
--listen :8081 \
|
||||
--api-listen :8082 \
|
||||
--base http://models.internal:8081
|
||||
User=llmgw
|
||||
Group=llmgw
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
# The one writable path. The mirror serves reads to the whole fleet and writes
|
||||
# only what its own API was asked to fetch, so nothing else needs to be.
|
||||
ReadWritePaths=/srv/models
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=llmgw control plane
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/llmgw server --config /etc/llmgw/server.yaml
|
||||
User=llmgw
|
||||
Group=llmgw
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
StateDirectory=llmgw
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,149 @@
|
||||
# Choosing what to run
|
||||
|
||||
llmgw serves whatever you give it and has no opinion about which models those
|
||||
are. This page has an opinion, because "we have GPUs, now what" is the question
|
||||
that stalls most of these projects — and the usual first answer, one large
|
||||
model, is the wrong shape.
|
||||
|
||||
Model releases move faster than documentation. Treat the families named here as
|
||||
a starting point and check what is current before committing; the reasoning is
|
||||
what should outlive the names.
|
||||
|
||||
## Three jobs, not one model
|
||||
|
||||
The requests a company actually makes fall into three groups with different
|
||||
needs, and no single model serves all three well.
|
||||
|
||||
**Interactive** — chat, autocomplete, short questions. Most of your traffic.
|
||||
Here latency beats quality: a person waits two seconds and does not wait
|
||||
twenty. A 7–14B class model, always resident.
|
||||
|
||||
**Heavy** — long document analysis, code generation, anything that needs
|
||||
reasoning. Quality beats latency, and a queue is acceptable. A 30–70B class
|
||||
model, loaded on demand:
|
||||
|
||||
```yaml
|
||||
scaling: { min: 0, max: 1, idle_timeout: 30m }
|
||||
```
|
||||
|
||||
llmgw starts it when somebody asks and stops it when nobody has, so it costs
|
||||
nothing while it is not in use. The first request waits for the load, and the
|
||||
client is told that rather than refused.
|
||||
|
||||
**Embeddings** — and this is the one that gets forgotten. Most "let us chat
|
||||
with our documents" projects are retrieval projects, and retrieval runs on an
|
||||
embedding model. It is small, it is cheap, and it carries the highest request
|
||||
volume of the three: every document chunk once, every query forever. llmgw
|
||||
routes `/v1/embeddings` like any other endpoint.
|
||||
|
||||
A deployment with the first two and not the third can answer questions from
|
||||
what the model already knew. It cannot answer questions about your company.
|
||||
|
||||
## Swedish and English
|
||||
|
||||
The first thing to know is that this combination is harder than either alone.
|
||||
|
||||
Open models are strongest in English by a wide margin. Swedish is served —
|
||||
better than most languages its size, because the Nordic languages appear in
|
||||
multilingual training sets — but the gap is real, and it widens as models get
|
||||
smaller. **A 7B model that feels fine in English can feel noticeably worse in
|
||||
Swedish**, which means the interactive tier may need to be larger than an
|
||||
English-only deployment would choose. Test before deciding; do not assume the
|
||||
English benchmark transfers.
|
||||
|
||||
Two things matter more than the model's headline multilingual score:
|
||||
|
||||
**Cross-lingual retrieval.** A Swedish company writes internal documents in
|
||||
Swedish and reads technical documentation in English. Somebody will ask a
|
||||
question in Swedish whose answer is in an English document. An embedding model
|
||||
that handles both languages separately may still fail to connect them, and it
|
||||
fails silently — the model gives a confident answer from the wrong source, and
|
||||
nobody notices because the answer reads well. Test this explicitly: ask in one
|
||||
language, check that documents in the other are retrieved.
|
||||
|
||||
**Swedish compounds.** *Kundtjänstmedarbetare* is one token-hostile word where
|
||||
English would use three. This hurts keyword search more than dense retrieval,
|
||||
so if you run hybrid search, expect the lexical half to underperform on Swedish
|
||||
and weight accordingly.
|
||||
|
||||
## How to actually decide
|
||||
|
||||
Benchmarks will not settle this. Take twenty real documents and twenty real
|
||||
questions from the company — in both languages, including at least a few where
|
||||
the question and the answer are in different languages — and compare candidates
|
||||
on those. An afternoon of this is worth more than a week of reading leaderboards,
|
||||
and it is the only way to find the failure modes that matter to you.
|
||||
|
||||
Do the same for the embedding model, separately. A good generation model on top
|
||||
of bad retrieval produces fluent, confident, wrong answers, which is the worst
|
||||
failure this stack has: it does not look like a failure.
|
||||
|
||||
## How many replicas
|
||||
|
||||
Not "how many employees". **Concurrency**, which is far lower than headcount
|
||||
because people spend most of their time reading rather than waiting on a model.
|
||||
Two hundred people generate perhaps five to fifteen concurrent requests at
|
||||
peak, for interactive use.
|
||||
|
||||
vLLM batches continuously, so one replica on a capable card serves many
|
||||
concurrent requests. Which means the replica count is usually decided by
|
||||
**redundancy rather than throughput**:
|
||||
|
||||
| Tier | Replicas | Why |
|
||||
|---|---|---|
|
||||
| Embeddings | 2 | Not for capacity. If retrieval stops, every document feature stops. |
|
||||
| Interactive | 2 | So a node can be rebooted during working hours. |
|
||||
| Heavy | 0–1, on demand | Loaded when asked for, stopped when idle. |
|
||||
|
||||
Then measure rather than guess:
|
||||
|
||||
```
|
||||
llmgw ctl bench <model>
|
||||
```
|
||||
|
||||
It climbs concurrency until the SLO breaks and reports the last level that met
|
||||
it. Peak throughput arrives long after latency has become unusable, so
|
||||
throughput alone overstates what the hardware can serve.
|
||||
|
||||
## Where the weights live
|
||||
|
||||
The single highest-value piece of infrastructure here is **a local mirror**: one
|
||||
internal HTTP server holding the model files. [deploy/mirror/](../deploy/mirror/)
|
||||
has the nginx config and the directory layout. Hugging Face is slow, rate
|
||||
limited, and blocked outright on plenty of corporate networks. Eight nodes each
|
||||
pulling forty gigabytes from the internet is a day; from a mirror on the same
|
||||
switch it is minutes. Point a model's `source` at it and llmgw fetches from
|
||||
there.
|
||||
|
||||
**Pin the version.** Put it in the path — `/models/qwen2.5-14b/2026-03/q4_k_m.gguf`
|
||||
— so a repository that changes upstream cannot change what you are running.
|
||||
Without this, a prompt that worked last week gives a different answer this week
|
||||
and nobody can explain why.
|
||||
|
||||
**Quantisation.** For llama.cpp, `q4_k_m` is the practical default: the quality
|
||||
difference from `q8_0` is smaller than most people expect and the memory
|
||||
difference is a factor of two. For vLLM on modern cards, FP8 or AWQ. Run
|
||||
`llmgw ctl plan <model>` to see whether a given precision fits before
|
||||
downloading anything.
|
||||
|
||||
**Disk.** Budget three to five times what you first estimate. Several
|
||||
quantisations, several versions, and partial downloads all accumulate.
|
||||
`llmgw ctl nodes` shows free space per node once `model_dirs` is configured,
|
||||
and `llmgw ctl weights` reclaims files llmgw downloaded and no model needs —
|
||||
never files you placed yourself.
|
||||
|
||||
Under ten nodes, let each keep its own copy; it is simpler and the disk is
|
||||
cheap. Above that, consider a shared filesystem — llmgw notices one and will
|
||||
not count the same free terabyte once per node.
|
||||
|
||||
## Before anyone starts using it
|
||||
|
||||
Set budgets and limits on day one, not later:
|
||||
|
||||
```
|
||||
llmgw keys limits --scope project --id <project> --rpm 60 --budget 50000000
|
||||
```
|
||||
|
||||
The reason is not technical. A resource that appears free and unlimited becomes,
|
||||
three months later, a cost nobody can account for — and introducing limits at
|
||||
that point is a much harder conversation than having had them from the start.
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# What llmgw stores, and what leaves the machine
|
||||
|
||||
Written for the review that happens before a gateway is allowed near anyone's
|
||||
documents. It describes what the software does. Mapping that onto an
|
||||
obligation — GDPR, a sector rule, an internal policy — is work for whoever
|
||||
holds the obligation; this page exists so that work starts from facts rather
|
||||
than from a vendor's summary.
|
||||
|
||||
Everything below is checkable in the source, and the places to look are named.
|
||||
|
||||
## The short version
|
||||
|
||||
By default llmgw stores **no prompt or completion text anywhere**. It stores
|
||||
who made a request, when, how many tokens, and what it cost.
|
||||
|
||||
Two features change that, and both are off until somebody turns them on for a
|
||||
named project, and neither can be turned on without also saying for how long.
|
||||
|
||||
Nothing on the serving path leaves the cluster.
|
||||
|
||||
## What is stored, exactly
|
||||
|
||||
**Usage records**, one per request: the key, the project, the model asked for,
|
||||
the model that answered, prompt and completion token counts, cost, HTTP status
|
||||
and latency. No message content. The row is in `internal/store/store.go` as
|
||||
`Usage` and there is no field for text in it.
|
||||
|
||||
**API keys**, as SHA-256 hashes. The key itself is shown once, at creation, and
|
||||
never stored — so a leaked database hands over no working credential, and
|
||||
neither llmgw nor an administrator can recover a key somebody lost. A prefix is
|
||||
kept so a key can be identified in a listing without revealing it.
|
||||
|
||||
**People and tenancy**: accounts, the organisation/team/project tree, and the
|
||||
limits attached to it. Passwords are hashed.
|
||||
|
||||
**Sessions** for the browser pages, as tokens.
|
||||
|
||||
**Conversations and agent notes** — only for a project that asked. See below.
|
||||
|
||||
## What is not stored
|
||||
|
||||
Prompts. Completions. Anything the model was shown or produced, unless the
|
||||
project turned on conversations or notes.
|
||||
|
||||
The chat page in llmgw's own web interface keeps the conversation in a
|
||||
JavaScript variable in the browser. Refresh it and the conversation is gone,
|
||||
because it was never anywhere else.
|
||||
|
||||
## Conversations and notes, when they are turned on
|
||||
|
||||
Two features keep text, and both work the same way:
|
||||
|
||||
- **Conversations** (`x-llmgw-thread`) let a client send only its new turn while
|
||||
llmgw replays the history — including to a different model than the one the
|
||||
conversation started on.
|
||||
- **Agent notes** (`/v1/memory`, and MCP tools) let an agent keep something for
|
||||
itself across conversations.
|
||||
|
||||
Both are **off for every project** until an operator runs `llmgw keys threads`
|
||||
or `llmgw keys memory` for that project by name. A request carrying a thread id
|
||||
against a project that has not is refused with an explanation, not quietly
|
||||
served without its history.
|
||||
|
||||
Both **require a retention window**. There is no way to turn either on without
|
||||
saying how many hours, because a store with no expiry is a transcript of
|
||||
everything a team ever typed, kept forever because nobody chose a number. The
|
||||
window is measured from the last turn, so an active conversation does not
|
||||
expire underneath it, and a background sweep removes what has passed it.
|
||||
|
||||
Both are **scoped to a project**, and the scope is part of every lookup rather
|
||||
than a check afterwards. Reading a conversation that belongs to another project
|
||||
returns the same answer as reading one that does not exist, so a leaked id
|
||||
cannot be used to discover what exists elsewhere.
|
||||
|
||||
`DELETE /v1/threads/{id}` removes a conversation and its messages immediately.
|
||||
It is a client endpoint, not an administrative one: the person who said the
|
||||
thing can unsay it without asking somebody else to read it first.
|
||||
|
||||
Turning retention off stops new storage, and existing conversations go at the
|
||||
next hourly sweep.
|
||||
|
||||
## What leaves the machine
|
||||
|
||||
**Nothing on the serving path.** A request arrives, is routed to an engine on
|
||||
your own hardware, and the answer comes back. The router talks to engines, the
|
||||
engines talk to nothing, the agents talk to the control plane. All of it is
|
||||
inside the cluster.
|
||||
|
||||
Two things can reach outside, both started by an operator rather than by a
|
||||
request:
|
||||
|
||||
**Fetching weights.** When a model's `source` is a URL, the agent downloads it.
|
||||
Point that at an internal mirror and nothing leaves at all — which is also the
|
||||
faster arrangement, because eight nodes pulling forty gigabytes from the
|
||||
internet is a day's work and from a local mirror it is minutes. A `source` that
|
||||
is a path on disk is never fetched.
|
||||
|
||||
**`llmgw ctl plan`.** Reads a model's architecture from huggingface.co to work
|
||||
out whether it fits your hardware. An operator command run at planning time,
|
||||
not part of serving, and not run by anything automatically.
|
||||
|
||||
There is no telemetry, no licence check, and no call home. The binary is
|
||||
static, built with `CGO_ENABLED=0`, and runs in a network with no route to the
|
||||
internet as long as the weights are already on disk.
|
||||
|
||||
## What the software cannot do for you
|
||||
|
||||
**The admin port serves pprof**, and pprof can dump the process heap — where
|
||||
API keys sit in plaintext while they are being checked. Bind it to loopback and
|
||||
reach it over SSH. The default does this; a deployment that changes it should
|
||||
know why.
|
||||
|
||||
**The store holds everything.** With conversations turned on it holds text as
|
||||
well as accounting, so where it lives, who can read it and how it is backed up
|
||||
are decisions with the same weight as the ones above. The PostgreSQL DSN
|
||||
belongs in a file readable only by the user llmgw runs as, which is why the
|
||||
configuration takes `@/path/to/file` rather than the address itself.
|
||||
|
||||
**Model weights are yours.** llmgw reclaims only files it downloaded, and it
|
||||
identifies those by a name it generated. Anything you put on a node it will not
|
||||
touch, whatever the catalog says.
|
||||
|
||||
## Where to look
|
||||
|
||||
| Claim | Where |
|
||||
|---|---|
|
||||
| Usage rows carry no text | `internal/store/store.go`, type `Usage` |
|
||||
| Keys are stored hashed | `internal/store/keys.go` |
|
||||
| Conversations are off by default | `internal/store/threads.go`, `ThreadRetention` |
|
||||
| Retention is required | `llmgw keys threads --help` |
|
||||
| Scope is part of the lookup | `internal/store/threads.go`, `Thread` |
|
||||
| Deletion is a client endpoint | `internal/router/threads.go`, `handleDeleteThread` |
|
||||
| Weight fetching is the only serving-path egress | `internal/agent/weights/fetch.go` |
|
||||
+1045
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,230 @@
|
||||
# Adding a node
|
||||
|
||||
Written for whoever runs the cluster.
|
||||
|
||||
## The short version
|
||||
|
||||
Where one person holds both the hypervisor and the certificate authority —
|
||||
which is the common case — one command does the whole thing in about
|
||||
45 seconds:
|
||||
|
||||
```
|
||||
llmgw-add-node \
|
||||
--name llmgw-node05 --ip 10.0.0.145/24 \
|
||||
--server https://10.0.0.140:7443 \
|
||||
--cores 8 --memory 32768 --disk 60 \
|
||||
--llamacpp-bin /opt/llama.cpp/llama-server
|
||||
```
|
||||
|
||||
The VMID defaults to the last octet of the address, so `qm list` and
|
||||
`llmgw ctl nodes` stay in the same order. `--provision-only` and
|
||||
`--onboard-only` split it back into the two halves below when the two kinds
|
||||
of access belong to different people.
|
||||
|
||||
The rest of this page is what that command does, and what it still does not.
|
||||
|
||||
## The two halves
|
||||
|
||||
Two scripts, because the two halves of onboarding are done by different people
|
||||
with different access: one needs the hypervisor, the other needs the
|
||||
certificate authority.
|
||||
|
||||
## 1. The machine
|
||||
|
||||
On the Proxmox host:
|
||||
|
||||
```
|
||||
llmgw-provision-node --vmid 144 --name llmgw-node04 --ip 10.0.0.144/24
|
||||
```
|
||||
|
||||
Name it after the node id. **The VM name, the hostname, the node id and the
|
||||
certificate's common name are all the same string.** They do not have to be, and
|
||||
every time they are not, somebody spends an afternoon working out which of the
|
||||
four a message is talking about.
|
||||
|
||||
It imports a cloud image and lets cloud-init do the rest, which is why a node
|
||||
takes a minute rather than an afternoon. It refuses to touch an existing VMID:
|
||||
adopting a machine is not a decision a provisioning script should make.
|
||||
|
||||
Releases carry static `linux/amd64` and `linux/arm64` binaries. Download one and
|
||||
pass it with `--binary` rather than copying whatever happens to be on the
|
||||
machine you are running onboarding from — otherwise whoever runs the script
|
||||
quietly becomes the build server, and the fleet ends up running whatever was in
|
||||
their working tree.
|
||||
|
||||
## 2. The identity
|
||||
|
||||
On the host that holds the certificate authority — this is the part that cannot
|
||||
be delegated, because it issues the credential by which the control plane
|
||||
believes the node is who it says it is:
|
||||
|
||||
```
|
||||
llmgw-onboard-node \
|
||||
--node llmgw-node04 \
|
||||
--addr 10.0.0.144 \
|
||||
--server https://10.0.0.140:7443 \
|
||||
--fabric ib-b
|
||||
```
|
||||
|
||||
It issues a client certificate whose common name is the node id, installs it
|
||||
along with the CA root, trusts that root system-wide so other tooling on the
|
||||
machine stops treating llmgw's certificates as strangers, installs the binary
|
||||
and the unit, and starts the agent. The node appears in `llmgw ctl nodes` within
|
||||
a few seconds.
|
||||
|
||||
If it does not, the control plane's log says why. A certificate whose common
|
||||
name does not match the claimed node id is refused by design — that check is
|
||||
what makes a node's identity something it proves rather than something it picks.
|
||||
|
||||
**It needs ssh from the CA host to the node.** Provisioning copies that host's
|
||||
`authorized_keys` into the new machine, so a node built by the script above
|
||||
already satisfies it; an older machine may need the key added.
|
||||
|
||||
It waits for ssh rather than failing on it, and learns the host key of a machine
|
||||
it has not seen (`accept-new`, which still refuses a *changed* key — the risk
|
||||
being guarded against is a substituted machine, not a new one). Both are needed
|
||||
to run the halves back to back: for the first minute of its life a new machine
|
||||
is still regenerating host keys and restarting sshd, so it refuses connections
|
||||
it accepted a moment earlier.
|
||||
|
||||
It also checks that a configured engine actually runs, which is cheap here and
|
||||
expensive later — see below.
|
||||
|
||||
## The CPU model matters, and its default is wrong
|
||||
|
||||
Nodes are provisioned with `--cpu host`. Proxmox's default, `kvm64`, hides
|
||||
AVX, AVX2, FMA and even SSE4.2 from the guest, and llama.cpp then loads its
|
||||
scalar backend instead of the one built for the host's instruction set.
|
||||
Measured on node05, an 8-core VM on a Xeon E5-2680 v4, same weights, same
|
||||
threads, the only change being the CPU model:
|
||||
|
||||
| Qwen2.5-Coder | kvm64 tok/s | host tok/s |
|
||||
| -------------- | ----------: | ---------: |
|
||||
| 1.5B q4_K_M | 5.80 | 39.09 |
|
||||
| 3B q4_K_M | 2.90 | 20.51 |
|
||||
| 7B q4_K_M | 1.33 | 9.88 |
|
||||
|
||||
Prompt processing is worse still: 7.6 tokens a second against 143.8, a factor
|
||||
of nineteen.
|
||||
|
||||
It is worth calling out because nothing looks broken. The node registers, the
|
||||
engine starts, replies are correct — every number is simply small, and the
|
||||
obvious conclusion is that CPU inference is hopeless rather than that the VM
|
||||
cannot multiply efficiently.
|
||||
|
||||
The trade is live migration: a VM pinned to the host CPU cannot move to a
|
||||
machine with a different one. For a node whose job is arithmetic that is the
|
||||
right way round, and `--cpu` overrides it where it is not.
|
||||
|
||||
## What onboarding does not do
|
||||
|
||||
It gives a machine an identity and an agent. It does **not** install inference
|
||||
engines or model weights, and a node missing either looks healthy in
|
||||
`llmgw ctl nodes` right up until a replica is placed on it and the shard dies
|
||||
with "executable file not found".
|
||||
|
||||
Onboarding does check that an engine you point it at can be executed, and says
|
||||
so loudly when it cannot. That check exists because the failure is invisible
|
||||
otherwise: `llama-server` on node05 was present, executable, and could not start
|
||||
for want of `libgomp1`, and the node reported `ready` in `llmgw ctl nodes` the
|
||||
whole time. Being told at onboarding beats finding out when a replica is placed
|
||||
and the reason is three logs away.
|
||||
|
||||
So the node is not finished until:
|
||||
|
||||
- the engines its catalog profiles name are installed (`vllm`, `sglang`,
|
||||
`llama-server`), at the paths the agent configuration gives
|
||||
- the weights are on disk, at the `source` each model declares
|
||||
|
||||
Both are deliberately outside this script. Engines are a platform decision with
|
||||
versions and drivers attached, and distributing weights is its own problem —
|
||||
hundreds of gigabytes that want a shared filesystem or a cache, not an scp from
|
||||
a laptop. llmgw will fetch weights for a model whose `source` is a URL, one
|
||||
copy per node, which covers the simple case; anything larger stays the
|
||||
operator's arrangement, and llmgw is content to find the files already there.
|
||||
|
||||
The `sim` engine needs neither, which is what makes it useful for exercising
|
||||
everything else.
|
||||
|
||||
## Checking an engine after an upgrade
|
||||
|
||||
Engines rename and remove flags between versions, and when that happens the
|
||||
failure arrives minutes into a model load, in the engine's own error message, on
|
||||
a node nobody was watching.
|
||||
|
||||
```
|
||||
llmgw ctl engine-check vllm
|
||||
llmgw ctl engine-check llamacpp --help-output "$(llama-server --help 2>&1)"
|
||||
```
|
||||
|
||||
It builds the widest command llmgw would ever produce — a multi-node one, since
|
||||
a flag that only appears there is exactly the one nobody notices has gone — and
|
||||
checks every flag against the engine's own `--help`. No GPU required: a CLI
|
||||
prints its help on any machine.
|
||||
|
||||
Some programs do not give up their whole help for `--help`. vLLM prints a
|
||||
summary of section names and expects `--help=all`; checking against the summary
|
||||
reports every flag as missing, confidently and wrongly. `engine-check` knows
|
||||
that one. If you add an engine, check that what you get back is the full list.
|
||||
|
||||
vLLM also refuses to do anything at all on a machine with no accelerator,
|
||||
including print its help, unless `VLLM_TARGET_DEVICE` says what to assume.
|
||||
`engine-check` sets it.
|
||||
|
||||
Give it the **whole** help. Truncating llama.cpp's 725 lines to 400 reports five
|
||||
flags as missing that the running cluster uses; the command warns when the text
|
||||
it was given does not document `--help` itself, which is the cheapest sign it
|
||||
was cut short.
|
||||
|
||||
## Fabrics
|
||||
|
||||
`--fabric` is how a node says which InfiniBand fabric it is attached to, and
|
||||
there is no way around stating it. OpenSM's default subnet prefix is `fe80::` on
|
||||
every fabric, so a matching prefix proves nothing and a wrong guess produces a
|
||||
replica that starts, passes health checks, and then hangs on its first
|
||||
all-reduce.
|
||||
|
||||
Omit it for a node with no fabric. Such a node is its own island: it can host
|
||||
single-node replicas and will never be given a multi-node one.
|
||||
|
||||
Two adapters per fabric is the default; `--fabric ib-b:mlx5_2,mlx5_3` names them
|
||||
explicitly. An adapter must never appear under two fabrics — the agent refuses
|
||||
that configuration, because the consequence is the hang above.
|
||||
|
||||
## Where the driver is
|
||||
|
||||
```
|
||||
--nvidia-smi /opt/nvidia/bin/nvidia-smi
|
||||
--ib-root /sys/class/infiniband
|
||||
```
|
||||
|
||||
Both default to the usual places. Containerized driver installs routinely put
|
||||
`nvidia-smi` somewhere else, and an agent that cannot find it registers with no
|
||||
GPUs — which looks exactly like a node that has none.
|
||||
|
||||
## Removing a node
|
||||
|
||||
```
|
||||
llmgw ctl drain llmgw-node04 # move its replicas off, one at a time
|
||||
llmgw ctl nodes # wait until it holds none
|
||||
```
|
||||
|
||||
Then stop the agent. Draining brings each replacement up before stopping the
|
||||
original, which is the difference between a rolling move and an outage.
|
||||
|
||||
The node's certificate stays valid until it expires. There is no revocation list
|
||||
— if a machine is decommissioned rather than retired, reissue the authority or
|
||||
accept that a certificate naming a node that no longer exists can still connect
|
||||
and claim to be it.
|
||||
|
||||
## Testing without hardware
|
||||
|
||||
Every part of this works on machines with no GPUs and no InfiniBand. Point
|
||||
`--nvidia-smi` at a script that prints the CSV discovery asks for, point
|
||||
`--ib-root` at a sysfs-shaped directory, and use `engine: sim` in the catalog.
|
||||
|
||||
The simulated engine rendezvouses for real: rank 0 binds the dist-init port and
|
||||
does not serve until every worker has joined, and workers serve nothing. That
|
||||
exercises the multi-node launch sequence, the fabric constraint, and failure and
|
||||
rebuild — which is most of what goes wrong — without exercising NCCL, which is
|
||||
the rest.
|
||||
@@ -0,0 +1,319 @@
|
||||
# Onboarding a user
|
||||
|
||||
Written for whoever operates an llmgw deployment: the person with a shell on the
|
||||
control plane machine.
|
||||
|
||||
There are two ways a person gets a key. Pick one and put it in the config.
|
||||
|
||||
| | Self-service | Operator-issued |
|
||||
|---|---|---|
|
||||
| Config | `auth.signup_open: true` | `auth.signup_open: false` |
|
||||
| They do | sign up, wait, then make their own keys | wait for a link |
|
||||
| You do | approve once | issue every key |
|
||||
| Good for | more than a handful of people | a few, or machines |
|
||||
|
||||
Self-service does not mean open. A signup produces an account that can do
|
||||
nothing at all; **approval is what makes it work**, and approval needs a shell
|
||||
here rather than a session cookie, because it is the step that decides who gets
|
||||
to spend the cluster.
|
||||
|
||||
Everything below runs as the user the server runs as, so the files it creates
|
||||
stay readable by it:
|
||||
|
||||
```
|
||||
sudo -u llmgw llmgw <command> --store /var/lib/llmgw/llmgw.db
|
||||
```
|
||||
|
||||
## Self-service
|
||||
|
||||
Turn on the form:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
enabled: true
|
||||
store: /var/lib/llmgw/llmgw.db
|
||||
signup_open: true
|
||||
```
|
||||
|
||||
The landing page then links to it, and the rest is:
|
||||
|
||||
```
|
||||
llmgw users list --pending --store …
|
||||
ID EMAIL NAME STATE PROJECT SIGNED UP
|
||||
usr-k4mq… ayse@example.com Ayşe pending - 4m12s ago
|
||||
|
||||
llmgw users approve usr-k4mq… --project default --by ilker --store …
|
||||
```
|
||||
|
||||
That is the whole operator loop. From then on they sign in at `/login`, and
|
||||
`/profile` is where they create keys, see when each was last used, and revoke the
|
||||
ones they are done with. A key is shown once on the page that creates it and
|
||||
never again.
|
||||
|
||||
**This gateway sends no email.** There is no "your account was approved" message
|
||||
and no password reset, so tell people what to expect: they sign up, they tell
|
||||
you, you approve, they sign in. If that loop is too manual for your size, it is
|
||||
the thing to automate — not the approval step.
|
||||
|
||||
To close an account:
|
||||
|
||||
```
|
||||
llmgw users suspend usr-k4mq… --store …
|
||||
```
|
||||
|
||||
which closes their sessions immediately. Their **API keys keep working** — those
|
||||
are separate credentials, and revoking them is a separate step:
|
||||
|
||||
```
|
||||
llmgw keys list --project default --store …
|
||||
llmgw keys revoke key-… --store …
|
||||
```
|
||||
|
||||
That separation is deliberate: a suspended person's running jobs should not all
|
||||
fail at once without someone deciding they should.
|
||||
|
||||
## Operator-issued
|
||||
|
||||
With `signup_open: false` there is no form, and you issue keys yourself. This is
|
||||
also the path for things that are not people — CI, a notebook server, a script.
|
||||
|
||||
### Decide where the key belongs
|
||||
|
||||
Keys hang off a project, projects off a team, teams off an organization. Limits
|
||||
can be set at any of those levels and compose downward by taking the **most
|
||||
restrictive** value — a key cannot raise what its team set, and a team cannot
|
||||
raise what its organization set. That is the only rule under which a budget
|
||||
means anything.
|
||||
|
||||
If you have no structure yet, one of each is fine:
|
||||
|
||||
```
|
||||
llmgw keys init --store … --org mynodes --team default --project default
|
||||
```
|
||||
|
||||
It is idempotent: running it again renames rather than duplicating, so it can
|
||||
live in whatever provisions the machine.
|
||||
|
||||
Split it when you want separate limits or separate reporting, not before.
|
||||
|
||||
### Issue the key as a one-time link
|
||||
|
||||
```
|
||||
llmgw keys create --store … \
|
||||
--project default \
|
||||
--band gold \
|
||||
--base-url https://llmgw.example \
|
||||
--claim \
|
||||
"ayse-laptop"
|
||||
|
||||
https://llmgw.example/claim/3mKq…
|
||||
```
|
||||
|
||||
Send the link, not the key.
|
||||
|
||||
A key pasted into a chat window stays in that chat window forever, searchable by
|
||||
everyone who joins the channel later. A link works once and then expires, so the
|
||||
worst case is someone opening it before the intended person — which you find out
|
||||
about, because they will tell you the link is dead.
|
||||
|
||||
The name is for you, not the user: it appears in `keys list` and is how you will
|
||||
know which key to revoke.
|
||||
|
||||
**Why the link is safe to store.** The key is sealed with the link's own token,
|
||||
and the token is never written down — only its hash is. A database leak yields
|
||||
two useless hashes and a blob nothing in the database can open.
|
||||
|
||||
Without `--claim` the key is printed to stdout instead, for when you are
|
||||
provisioning something that is not a person:
|
||||
|
||||
```
|
||||
llmgw keys create --store … --project ci "github-runner" > /run/secrets/llmgw-key
|
||||
```
|
||||
|
||||
## Limits
|
||||
|
||||
Limits are optional. A key with none is unlimited, which is the right default on
|
||||
a private cluster and the wrong one the moment the gateway faces anything else.
|
||||
|
||||
```
|
||||
# Everyone in this project
|
||||
llmgw keys limits --store … --scope project --id default \
|
||||
--rpm 60 --tpm 200000
|
||||
|
||||
# One key, tighter
|
||||
llmgw keys limits --store … --scope key --id key-mpmip77twpt7qaxc \
|
||||
--rpm 10
|
||||
|
||||
# Restrict which models
|
||||
llmgw keys limits --store … --scope project --id default \
|
||||
--models qwen-0.5b,llama-3.3-70b
|
||||
```
|
||||
|
||||
An **absent** model list permits everything. An **empty** one permits nothing —
|
||||
`--models ""` is a way to suspend a project without revoking its keys.
|
||||
|
||||
Allowlists compose by intersection: a key cannot add a model its team withheld.
|
||||
A model's canonical name and any of its aliases name the same thing, so either
|
||||
form works.
|
||||
|
||||
Budgets are counted in micro-units, and what a unit means is yours to decide.
|
||||
Self-hosted models have no per-token price, so it can be GPU-seconds, a notional
|
||||
rate, or 1 000 000 everywhere — which turns a budget into a plain token quota.
|
||||
Models are priced in the server's `auth.pricing` section; a model priced at zero
|
||||
is exempt from budget checks, which is what lets a key with nothing left still be
|
||||
served a cheap fallback.
|
||||
|
||||
```
|
||||
llmgw keys limits --store … --scope project --id default \
|
||||
--budget 50000000 --period month # 50 units a month
|
||||
```
|
||||
|
||||
## The landing page
|
||||
|
||||
Someone who reaches the gateway without a key sees the landing page. Tell it who
|
||||
to send them to, or it can only shrug:
|
||||
|
||||
```yaml
|
||||
landing:
|
||||
contact: "platform@example.com" # or an https:// URL, or "#platform on Slack"
|
||||
notice: "Internal use only."
|
||||
```
|
||||
|
||||
An address becomes a mailto link, an `http(s)` URL stays a link, anything else is
|
||||
shown as text. Other schemes are never linked — configuration is not always
|
||||
written by whoever runs the gateway, and a `javascript:` href on a page served to
|
||||
every visitor is a short path to a stored XSS.
|
||||
|
||||
## What the user sees
|
||||
|
||||
They open the link, press the button, and get the key once. The page shows them
|
||||
the base URL and a working `curl`, and points at two endpoints:
|
||||
|
||||
- `GET /v1/models` — what this key may use
|
||||
- `GET /me` — its limits and what it has spent
|
||||
|
||||
`/me` is there so nobody has to ask you "why was I rate limited" or "how much
|
||||
have I used". It is outside `/v1` because it is not part of the OpenAI API.
|
||||
|
||||
## Rotation and revocation
|
||||
|
||||
```
|
||||
llmgw keys list --store … --project default
|
||||
```
|
||||
|
||||
`LAST USED` is written in batches rather than per request, so it lags by up to a
|
||||
minute. It is the column that tells you whether a key is still in use, which is
|
||||
what makes rotation safe:
|
||||
|
||||
**To rotate**, issue the new key, let both work while the user switches, watch
|
||||
`LAST USED` on the old one stop moving, then revoke it.
|
||||
|
||||
```
|
||||
llmgw keys revoke --store … key-mpmip77twpt7qaxc
|
||||
```
|
||||
|
||||
Revocation is a soft delete: the key stops working, the row stays, and its usage
|
||||
history keeps its subject. A running server keeps accepting a revoked key until
|
||||
its cache expires — `auth.cache_ttl`, 30 seconds by default. That is the price of
|
||||
keeping the database off the request path, and it is the number to lower if you
|
||||
need revocation to bite faster.
|
||||
|
||||
## Passwords
|
||||
|
||||
People change their own from `/profile`. It asks for the current one — a session
|
||||
is what someone gets from a borrowed laptop, and the password is what they do not
|
||||
have — and it signs every other browser out, which is the point if they are
|
||||
changing it because something went wrong.
|
||||
|
||||
There is no self-service reset, because this gateway sends no email and a reset
|
||||
link with nowhere to send it is not a reset. When someone loses theirs:
|
||||
|
||||
```
|
||||
llmgw users reset-password usr-k4mq… --password "a temporary passphrase" --store …
|
||||
```
|
||||
|
||||
Tell them out of band and have them change it from their profile. Their sessions
|
||||
are closed — if they lost the password, the reason might be that someone else has
|
||||
it. Their API keys keep working; those are separate credentials.
|
||||
|
||||
## Trying a model in the browser
|
||||
|
||||
`/chat` is a playground for anyone signed in. It exists so somebody can see
|
||||
whether a model works without setting up a client, and — more often — so they
|
||||
can tell whether a problem is the gateway or their code.
|
||||
|
||||
**The browser never holds a key.** The page carries a session; the server turns
|
||||
that into the same identity an API key would produce, resolved from the person's
|
||||
project, and the request goes down the ordinary path: admission, model
|
||||
allowlist, rate limits, budget, accounting. Anything else would make the page a
|
||||
hole in every limit the rest of the gateway enforces.
|
||||
|
||||
Usage from the page is attributed to `session:<user-id>` rather than a key, so
|
||||
it is billed to the right project and visibly did not come from a credential
|
||||
somebody holds. One turn is capped at 512 tokens: a playground should not be the
|
||||
thing that ties up a replica for ten minutes, and anyone who wants a longer
|
||||
answer has an API key for that.
|
||||
|
||||
Nothing is stored. The conversation lives in the page and is gone when it is
|
||||
closed.
|
||||
|
||||
## Seeing the cluster
|
||||
|
||||
The dashboard — nodes, replicas, models, why something did not get placed, usage
|
||||
per key — is served twice, and the difference matters.
|
||||
|
||||
On the **admin listener** (`127.0.0.1:9090` by default) it needs no
|
||||
authentication: having a shell on the machine is the credential.
|
||||
|
||||
```
|
||||
ssh -L 9090:127.0.0.1:9090 <control-plane> # then http://127.0.0.1:9090/
|
||||
```
|
||||
|
||||
That listener also serves pprof, which can dump the process heap — where API
|
||||
keys sit in plaintext. **Never expose that port.** It is bound to loopback for
|
||||
that reason.
|
||||
|
||||
On the **client-facing port** the same page is at `/admin`, behind a session and
|
||||
an admin flag, so it can be reached from a browser without the port carrying
|
||||
pprof along with it:
|
||||
|
||||
```
|
||||
llmgw users promote usr-k4mq… --store …
|
||||
```
|
||||
|
||||
Someone without the flag gets a 404 rather than a 403, because 403 confirms the
|
||||
page exists.
|
||||
|
||||
## Managing accounts from the browser
|
||||
|
||||
`/admin/users` lists who is waiting and who is active. Approving (into a project)
|
||||
and suspending are there, because that is the work an operator does constantly
|
||||
and it should not need a terminal.
|
||||
|
||||
Three things are deliberately not there:
|
||||
|
||||
| | Why |
|
||||
|---|---|
|
||||
| `llmgw users promote` | An admin who can make admins turns a stolen session into permanent access |
|
||||
| `llmgw users reset-password` | Setting someone's password is taking over their account |
|
||||
| `llmgw keys create` | Issuing a credential on another person's behalf |
|
||||
|
||||
Each of those turns a stolen admin cookie into more than it was, and each is
|
||||
rare enough that a shell is no hardship. Cordon, drain and everything else that
|
||||
touches the cluster are also CLI-only for the same reason: the dashboard shows
|
||||
the cluster, it does not steer it.
|
||||
|
||||
Every administrative action is logged with who did it, including the ones that
|
||||
were refused.
|
||||
|
||||
## What this does not do
|
||||
|
||||
- **No email.** No approval notice, no self-service password reset, no address
|
||||
verification. The loop is manual on purpose at this size; if it stops being
|
||||
the right size, that is the thing to automate.
|
||||
- **No SSO.** Accounts are local, with local passwords.
|
||||
- **No roles.** There is one flag, `is_admin`, and it grants looking at the
|
||||
dashboard. Everything else an administrator does is the CLI, and having a
|
||||
shell here is what makes someone one.
|
||||
- **Rate limits are per gateway instance.** With two instances each counts its own,
|
||||
so the effective limit doubles. v1 is single-instance by design.
|
||||
@@ -0,0 +1,375 @@
|
||||
# Quickstart: one machine, one model, ten minutes
|
||||
|
||||
Written for someone who wants to see llmgw answer a request on a single
|
||||
workstation before reading anything else. No systemd, no `sudo`. For a real
|
||||
cluster, see [install.md](install.md).
|
||||
|
||||
What you end up with: an OpenAI-compatible endpoint on `localhost:8080` in front
|
||||
of a real model served by llama.cpp.
|
||||
|
||||
## Two ways to run
|
||||
|
||||
Steps 1 to 4 use the simple one, **router-only**: you start the engines yourself
|
||||
and tell the router where they are with `--endpoint`. No certificates, no node
|
||||
agent.
|
||||
|
||||
Step 5 uses the **control plane and node agent**, the same architecture as a real
|
||||
cluster on one machine: the agent starts and supervises the engine, and the
|
||||
control plane places the model.
|
||||
|
||||
## 1. Build
|
||||
|
||||
```
|
||||
git clone https://gitea.mynodes.xyz/ilker/llmgw && cd llmgw
|
||||
make build
|
||||
make test
|
||||
./bin/llmgw --version
|
||||
```
|
||||
|
||||
`make build` writes `./bin/llmgw`. Go 1.26 or later is the only requirement.
|
||||
|
||||
## 2. Check the path with a fake engine
|
||||
|
||||
Before involving a GPU, prove the router works. `sim-engine` is an
|
||||
OpenAI-compatible fake that streams placeholder words.
|
||||
|
||||
```
|
||||
./bin/llmgw sim-engine --listen :8001 --model llama &
|
||||
./bin/llmgw server --endpoint e1=http://127.0.0.1:8001=llama &
|
||||
|
||||
curl -N localhost:8080/v1/chat/completions \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'x-session-id: demo' \
|
||||
-d '{"model":"llama","messages":[{"role":"user","content":"hello"}],"stream":true}'
|
||||
```
|
||||
|
||||
Chunks of `alpha beta gamma ...` ending in `data: [DONE]` mean the router is
|
||||
fine. Stop both before continuing (see [Stopping](#stopping)).
|
||||
|
||||
## 3. Serve a real model
|
||||
|
||||
Two processes, two ports. Clients only ever talk to the router; the router
|
||||
knows where the engine is because you tell it with `--endpoint`:
|
||||
|
||||
```
|
||||
curl :8080 -> llmgw router :8080 -> llama serve :8082 -> model
|
||||
(client) (the gateway) (the engine)
|
||||
```
|
||||
|
||||
Requests go to 8080. Port 8082 is the engine's own, and you only touch it to
|
||||
check that the engine is up.
|
||||
|
||||
llmgw does not install engines or weights. You need:
|
||||
|
||||
- a `llama-server` build from llama.cpp (CUDA build if you want the GPU), and
|
||||
- a `.gguf` file.
|
||||
|
||||
Check the card first with `nvidia-smi`. An 8 GB card holds a 1B model at bf16
|
||||
(2.4 GB) or a 9B model at `q4_k_m` (5.4 GB).
|
||||
|
||||
Start the engine on its own port. The model name given to `--alias` is the name
|
||||
clients will use:
|
||||
|
||||
```
|
||||
llama serve \
|
||||
-m /path/to/Llama-3.2-1B-Instruct-BF16.gguf \
|
||||
--host 0.0.0.0 --port 8082 -ngl 99 --alias llama
|
||||
```
|
||||
|
||||
`-ngl 99` offloads every layer to the GPU. Confirm it is up and knows the model:
|
||||
|
||||
```
|
||||
curl -s localhost:8082/v1/models
|
||||
```
|
||||
|
||||
Then point the router at it. The format is `id=URL=model`:
|
||||
|
||||
```
|
||||
./bin/llmgw server --endpoint e1=http://127.0.0.1:8082=llama
|
||||
```
|
||||
|
||||
## 4. Ask it something
|
||||
|
||||
Streaming:
|
||||
|
||||
```
|
||||
curl -N localhost:8080/v1/chat/completions \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'x-session-id: demo' \
|
||||
-d '{"model":"llama","messages":[{"role":"user","content":"hello"}],"stream":true}'
|
||||
```
|
||||
|
||||
One reply at once:
|
||||
|
||||
```
|
||||
curl -s localhost:8080/v1/chat/completions \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"model":"llama","messages":[{"role":"user","content":"hello"}],"max_tokens":100}'
|
||||
```
|
||||
|
||||
Also useful: `curl -s localhost:8080/v1/models` and
|
||||
`curl -s localhost:8080/health`. `llmgw ctl` does not work in this mode: it
|
||||
talks to the control plane's admin API, which router-only mode does not have,
|
||||
so `ctl status` answers `404`.
|
||||
|
||||
On the machine this was written on, a 1B bf16 model on an RTX 4070 laptop GPU
|
||||
answered at roughly 85 tokens per second.
|
||||
|
||||
## Several replicas of one model
|
||||
|
||||
Give the router more than one `--endpoint` with the same model name and it
|
||||
spreads requests across them. `x-session-id` keeps a conversation on the same
|
||||
replica so its cache is reused:
|
||||
|
||||
```
|
||||
./bin/llmgw server \
|
||||
--endpoint e1=http://127.0.0.1:8082=llama \
|
||||
--endpoint e2=http://127.0.0.1:8083=llama
|
||||
```
|
||||
|
||||
## Stopping
|
||||
|
||||
```
|
||||
pkill -f "llmgw server"
|
||||
pkill -f "llmgw sim-engine"
|
||||
```
|
||||
|
||||
Stop the engine the way you started it (Ctrl-C in its terminal).
|
||||
|
||||
## What goes wrong
|
||||
|
||||
**`listening on :8080: bind: address already in use`.** An earlier router is
|
||||
still running. `ss -ltnp | grep 8080` names the process; stop it first. This is
|
||||
the usual result of moving from step 2 to step 3 without stopping the fake
|
||||
engine's router.
|
||||
|
||||
**`/v1/models` on the engine returns an empty list.** The engine was started
|
||||
without `-m`, so it is a router with nothing to load. Restart it with a model.
|
||||
|
||||
**`error: nothing to serve`.** `llmgw server` with no `--endpoint` and no model
|
||||
carrying `profiles` has nothing to do. In router-only mode, always pass at least
|
||||
one `--endpoint`.
|
||||
|
||||
**Requests fail with a model-not-found error.** The name in the request must
|
||||
match the name after the last `=` in `--endpoint`, and it must be a name the
|
||||
engine actually serves (its `--alias`).
|
||||
|
||||
**Answers are poor.** A 1B model is a plumbing test, not a chat assistant. Swap
|
||||
in a larger `.gguf`; nothing else changes.
|
||||
|
||||
## 5. Let llmgw run the engine (control plane and node agent)
|
||||
|
||||
Steps 2 and 3 start the engine by hand. Here a **node agent** starts and
|
||||
supervises llama.cpp for you, and a control plane decides where the model runs.
|
||||
Still one machine, still no `sudo`. Stop everything from steps 2 and 3 first:
|
||||
the GPU has to be free and port 8080 unused.
|
||||
|
||||
```
|
||||
curl :8080 -> llmgw server (control plane + router) <- llmgw agent (this node)
|
||||
|
|
||||
starts llama-server :20000
|
||||
```
|
||||
|
||||
The agent dials the control plane over mutual TLS, so this needs a small private
|
||||
certificate authority. Everything goes in a scratch directory, `devcluster/`,
|
||||
which is in `.gitignore`.
|
||||
|
||||
### Certificates
|
||||
|
||||
```
|
||||
mkdir -p devcluster/pki
|
||||
./bin/llmgw ctl cert init-ca --dir devcluster/pki
|
||||
./bin/llmgw ctl cert issue-server llmgw-ctl --host 127.0.0.1 --host localhost \
|
||||
--dir devcluster/pki --out devcluster
|
||||
./bin/llmgw ctl cert issue-client llmgw-node01 --dir devcluster/pki --out devcluster
|
||||
```
|
||||
|
||||
The name given to `issue-client` becomes the certificate's common name and must
|
||||
equal `node_id` in the agent config. The control plane refuses an agent whose
|
||||
claimed id differs from its certificate.
|
||||
|
||||
### The engine wrapper
|
||||
|
||||
The agent starts the engine as `<bin> --model ... --port ...`. A `llama` binary
|
||||
that wants `llama serve ...` refuses every flag, so put a wrapper in front of it:
|
||||
|
||||
```
|
||||
cat > devcluster/llama-server <<'EOF'
|
||||
#!/bin/sh
|
||||
exec /path/to/llama serve "$@"
|
||||
EOF
|
||||
chmod +x devcluster/llama-server
|
||||
|
||||
./bin/llmgw ctl engine-check llamacpp --bin devcluster/llama-server
|
||||
```
|
||||
|
||||
`engine-check` should end with `every flag llmgw builds is accepted`. If your
|
||||
build already ships a `llama-server` binary, point `bin` at that and skip the
|
||||
wrapper.
|
||||
|
||||
### Control plane config: `devcluster/server.yaml`
|
||||
|
||||
```yaml
|
||||
listen: "127.0.0.1:8080"
|
||||
admin_listen: "127.0.0.1:9090"
|
||||
agent_listen: "127.0.0.1:7443"
|
||||
|
||||
agent_tls:
|
||||
ca: devcluster/pki/ca.pem
|
||||
cert: devcluster/server.pem
|
||||
key: devcluster/server-key.pem
|
||||
|
||||
models:
|
||||
- name: llama
|
||||
source: /path/to/Llama-3.2-1B-Instruct-BF16.gguf
|
||||
routing_profile: chat
|
||||
context_length: 8192
|
||||
scaling: { min: 1, max: 1 }
|
||||
profiles:
|
||||
- name: gpu
|
||||
engine: llamacpp
|
||||
gpu_class: 4070-8g
|
||||
weight: 1
|
||||
priority: 100
|
||||
startup_timeout: 5m
|
||||
```
|
||||
|
||||
Three things in there are easy to get wrong:
|
||||
|
||||
- **The model must have at least one profile.** That is what switches the control
|
||||
plane on. With `models: []` the server starts as a plain router and exits with
|
||||
`nothing to serve`.
|
||||
- **`gpu_class` decides CPU or GPU.** Without it the replica is placed in the CPU
|
||||
pool and runs on the CPU. Get the class name from `./bin/llmgw agent inventory`
|
||||
(the `CLASS` column).
|
||||
- **There is no `auth:` block.** Leave it out for a local trial; with auth on,
|
||||
requests need an API key.
|
||||
|
||||
### Agent config: `devcluster/agent.yaml`
|
||||
|
||||
```yaml
|
||||
node_id: llmgw-node01
|
||||
server: "https://127.0.0.1:7443"
|
||||
mgmt_addr: "127.0.0.1"
|
||||
|
||||
tls:
|
||||
ca: devcluster/pki/ca.pem
|
||||
cert: devcluster/llmgw-node01.pem
|
||||
key: devcluster/llmgw-node01-key.pem
|
||||
|
||||
engines:
|
||||
llamacpp:
|
||||
bin: /absolute/path/to/devcluster/llama-server
|
||||
|
||||
supervisor:
|
||||
work_dir: devcluster/agent-work
|
||||
```
|
||||
|
||||
### Run it
|
||||
|
||||
Two terminals:
|
||||
|
||||
```
|
||||
./bin/llmgw server --config devcluster/server.yaml
|
||||
./bin/llmgw agent --config devcluster/agent.yaml
|
||||
```
|
||||
|
||||
The model is not placed at once. The control plane waits about 35 seconds for the
|
||||
fleet to settle after it starts, then places the replica, and the agent then
|
||||
loads the weights. Watch it with:
|
||||
|
||||
```
|
||||
./bin/llmgw ctl nodes
|
||||
./bin/llmgw ctl models
|
||||
./bin/llmgw ctl replicas
|
||||
./bin/llmgw ctl explain-placement llama
|
||||
```
|
||||
|
||||
`STATUS ready` in `ctl models` means it is serving. `nvidia-smi` should now show
|
||||
the model in GPU memory. The same curl from step 4 works against port 8080.
|
||||
|
||||
`ctl` works here, unlike in router-only mode, because there is a control plane
|
||||
for it to talk to.
|
||||
|
||||
Stop it with Ctrl-C in each terminal. The agent leaves the engine running when
|
||||
the agent itself stops, so if you restart the agent and the GPU stays full,
|
||||
`pgrep -af "llama serve"` will show the engine still up.
|
||||
|
||||
## 6. Memory: conversations and notes
|
||||
|
||||
Off unless you ask. llmgw otherwise stores nothing anyone typed. On a single
|
||||
machine with no API keys, one block in the server config turns on two things:
|
||||
|
||||
```yaml
|
||||
threads:
|
||||
store: devcluster/data/threads.db
|
||||
retain_hours: 24
|
||||
```
|
||||
|
||||
Restart the server after adding it. `retain_hours` is required: everything kept
|
||||
expires that long after it was last written.
|
||||
|
||||
Everything kept this way sits in one shared pool with no owner, and anyone who
|
||||
can reach the port and knows an id can read it. That is fine on one machine and
|
||||
wrong on a shared network. With auth on, threads and notes are per project
|
||||
instead and the block is ignored; see [install.md](install.md).
|
||||
|
||||
### Conversations (threads)
|
||||
|
||||
Send the same `x-llmgw-thread` id and only the new turn. llmgw puts the stored
|
||||
history in front of it:
|
||||
|
||||
```
|
||||
curl -s localhost:8080/v1/chat/completions \
|
||||
-H 'content-type: application/json' -H 'x-llmgw-thread: demo' \
|
||||
-d '{"model":"llama","messages":[{"role":"user","content":"My name is Deniz."}]}'
|
||||
|
||||
curl -s localhost:8080/v1/chat/completions \
|
||||
-H 'content-type: application/json' -H 'x-llmgw-thread: demo' \
|
||||
-d '{"model":"llama","messages":[{"role":"user","content":"What is my name?"}]}'
|
||||
|
||||
curl -X DELETE localhost:8080/v1/threads/demo # forget it
|
||||
```
|
||||
|
||||
Without the block, a request carrying `x-llmgw-thread` is refused with
|
||||
`this gateway keeps no conversations` rather than answered without its history.
|
||||
|
||||
### Notes an agent keeps for itself
|
||||
|
||||
Notes outlive any one conversation. Each is a key, some text and optional tags,
|
||||
kept per agent; name the agent with `x-llmgw-agent` (default `default`), so two
|
||||
agents using the same key do not read each other's notes.
|
||||
|
||||
```
|
||||
curl -X PUT localhost:8080/v1/memory/favorite-color \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"text":"The user likes dark green.","tags":["preference"]}'
|
||||
|
||||
curl 'localhost:8080/v1/memory?q=green' # search by words
|
||||
curl 'localhost:8080/v1/memory?tag=preference' # or by tag
|
||||
curl localhost:8080/v1/memory/favorite-color
|
||||
curl -X DELETE localhost:8080/v1/memory/favorite-color
|
||||
```
|
||||
|
||||
The same notes are offered to an agent as tools over MCP at
|
||||
`http://localhost:8080/mcp`: `remember`, `recall` and `forget`. Add that URL to
|
||||
your agent's MCP server list (HTTP transport; how you set request headers such
|
||||
as `x-llmgw-agent` depends on the client). llmgw does not put these tools into
|
||||
chat requests on its own, so an agent that is not given the server never sees
|
||||
memory.
|
||||
|
||||
Limits: a key is up to 200 bytes, a note's text up to 16 KiB, at most 16 tags,
|
||||
1000 notes per agent. Tags are lowercase letters, digits, `-` and `_`.
|
||||
|
||||
## Where this stops
|
||||
|
||||
Router-only mode (steps 2 to 4) has no authentication, no placement and no model
|
||||
management: you start and restart engines yourself. Step 5 adds placement, model
|
||||
management and a node agent, and can add authentication as `install.md`
|
||||
describes. For a real cluster, with several nodes, systemd and a persistent
|
||||
certificate authority, follow [install.md](install.md).
|
||||
|
||||
One caveat found while writing this page: `install.md` recommends starting with
|
||||
an empty `models: []` and adding models later with `ctl model add`. The control
|
||||
plane only turns on when the config declares a model with `profiles`, so that
|
||||
first start does not produce a control plane.
|
||||
+1323
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,7 @@ require (
|
||||
connectrpc.com/connect v1.21.0
|
||||
github.com/NVIDIA/go-nvml v0.13.4-0
|
||||
github.com/cespare/xxhash/v2 v2.3.0
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.1
|
||||
github.com/knadh/koanf/providers/env/v2 v2.0.1
|
||||
github.com/knadh/koanf/providers/file v1.2.1
|
||||
@@ -14,23 +15,36 @@ require (
|
||||
github.com/prometheus/client_model v0.6.3
|
||||
github.com/prometheus/common v0.71.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/net v0.59.0
|
||||
golang.org/x/sync v0.23.0
|
||||
google.golang.org/protobuf v1.36.12
|
||||
modernc.org/sqlite v1.59.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/knadh/koanf/maps v0.1.2 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/net v0.59.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
modernc.org/libc v1.75.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
@@ -7,14 +7,31 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||
@@ -29,12 +46,17 @@ github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
|
||||
github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
|
||||
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
|
||||
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
|
||||
@@ -43,11 +65,16 @@ github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3
|
||||
github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -57,6 +84,10 @@ go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
@@ -65,6 +96,37 @@ golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
|
||||
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
|
||||
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
|
||||
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,396 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func newStore(t *testing.T) *store.DB {
|
||||
t.Helper()
|
||||
s, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "a.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
for _, err := range []error{
|
||||
s.PutOrg(ctx, store.Org{ID: "org", Name: "Org"}),
|
||||
s.PutTeam(ctx, store.Team{ID: "team", OrgID: "org", Name: "Team"}),
|
||||
s.PutProject(ctx, store.Project{ID: "proj", TeamID: "team", Name: "Proj"}),
|
||||
} {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func newService(t *testing.T) (*Service, *store.DB) {
|
||||
t.Helper()
|
||||
s := newStore(t)
|
||||
return New(s, DefaultConfig(), slog.New(slog.DiscardHandler)), s
|
||||
}
|
||||
|
||||
const goodPassword = "correct horse battery staple"
|
||||
|
||||
func TestSignupLeavesAnAccountWaiting(t *testing.T) {
|
||||
// A public signup form that produced a working account would be a way in.
|
||||
svc, st := newService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := svc.SignUp(ctx, "Ayse@Example.COM", "Ayşe", goodPassword); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, err := st.UserByEmail(ctx, "ayse@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.State != store.UserPending {
|
||||
t.Errorf("a new account is %q", u.State)
|
||||
}
|
||||
if u.Email != "ayse@example.com" {
|
||||
t.Errorf("the address was not normalized: %q", u.Email)
|
||||
}
|
||||
|
||||
if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); !errors.Is(err, ErrNotApproved) {
|
||||
t.Errorf("a pending account signed in: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignupDoesNotRevealExistingAccounts(t *testing.T) {
|
||||
// A form that says "this address already has an account" is an account
|
||||
// enumerator anyone can run, and the person signing up learns nothing they
|
||||
// need: either way they wait for approval.
|
||||
svc, _ := newService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := svc.SignUp(ctx, "ayse@example.com", "A", goodPassword); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.SignUp(ctx, "ayse@example.com", "Someone Else", goodPassword); err != nil {
|
||||
t.Errorf("the second signup reported %v, which confirms the address is taken", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalIsWhatMakesAnAccountWork(t *testing.T) {
|
||||
svc, st := newService(t)
|
||||
ctx := context.Background()
|
||||
must(t, svc.SignUp(ctx, "ayse@example.com", "A", goodPassword))
|
||||
|
||||
u, _ := st.UserByEmail(ctx, "ayse@example.com")
|
||||
must(t, st.ApproveUser(ctx, u.ID, "proj", "ilker", time.Now()))
|
||||
|
||||
token, csrf, err := svc.SignIn(ctx, "ayse@example.com", goodPassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if token == "" || csrf == "" {
|
||||
t.Fatal("sign-in produced no session")
|
||||
}
|
||||
_, got, err := svc.Resolve(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ProjectID != "proj" {
|
||||
t.Errorf("the approved account landed in project %q", got.ProjectID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalNeedsAProject(t *testing.T) {
|
||||
// An approved account with nowhere to put its keys passes every check and
|
||||
// then fails at the first key it tries to make.
|
||||
_, st := newStoreAndUser(t)
|
||||
if err := st.ApproveUser(context.Background(), "usr-1", "", "ilker", time.Now()); err == nil {
|
||||
t.Error("an account was approved into no project")
|
||||
}
|
||||
}
|
||||
|
||||
func newStoreAndUser(t *testing.T) (*Service, *store.DB) {
|
||||
t.Helper()
|
||||
svc, st := newService(t)
|
||||
hash, _ := HashPassword(goodPassword)
|
||||
must(t, st.CreateUser(context.Background(), store.User{
|
||||
ID: "usr-1", Email: "ayse@example.com", Name: "A",
|
||||
PasswordHash: hash, State: store.UserPending, CreatedAt: time.Now(),
|
||||
}))
|
||||
return svc, st
|
||||
}
|
||||
|
||||
func TestSuspensionTakesEffectOnOpenSessions(t *testing.T) {
|
||||
// Leaving existing sessions open would make suspension advisory.
|
||||
svc, st := newStoreAndUser(t)
|
||||
ctx := context.Background()
|
||||
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
|
||||
|
||||
token, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.Resolve(ctx, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
must(t, st.SetUserState(ctx, "usr-1", store.UserSuspended))
|
||||
if _, _, err := svc.Resolve(ctx, token); !errors.Is(err, store.ErrNoSession) {
|
||||
t.Errorf("a suspended account kept its session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignOutEndsTheSession(t *testing.T) {
|
||||
svc, st := newStoreAndUser(t)
|
||||
ctx := context.Background()
|
||||
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
|
||||
|
||||
token, _, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword)
|
||||
must(t, svc.SignOut(ctx, token))
|
||||
if _, _, err := svc.Resolve(ctx, token); !errors.Is(err, store.ErrNoSession) {
|
||||
t.Errorf("the session survived sign-out: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWrongPasswordAndAMissingAccountAreIndistinguishable(t *testing.T) {
|
||||
svc, st := newStoreAndUser(t)
|
||||
ctx := context.Background()
|
||||
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
|
||||
|
||||
_, _, wrong := svc.SignIn(ctx, "ayse@example.com", "wrong password here")
|
||||
_, _, missing := svc.SignIn(ctx, "nobody@example.com", "wrong password here")
|
||||
if !errors.Is(wrong, ErrBadCredentials) || !errors.Is(missing, ErrBadCredentials) {
|
||||
t.Fatalf("wrong=%v missing=%v", wrong, missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuessingIsBounded(t *testing.T) {
|
||||
// Without this a public login form is an open brute-force target.
|
||||
svc, st := newStoreAndUser(t)
|
||||
ctx := context.Background()
|
||||
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.MaxAttempts = 3
|
||||
svc = New(st, cfg, slog.New(slog.DiscardHandler))
|
||||
|
||||
for i := range 3 {
|
||||
if _, _, err := svc.SignIn(ctx, "ayse@example.com", "nope"); !errors.Is(err, ErrBadCredentials) {
|
||||
t.Fatalf("attempt %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, _, err := svc.SignIn(ctx, "ayse@example.com", "nope"); !errors.Is(err, ErrTooManyTries) {
|
||||
t.Errorf("guessing was not bounded: %v", err)
|
||||
}
|
||||
// And a correct password is refused too while the window is open, or the
|
||||
// limit would be trivially bypassed by interleaving.
|
||||
if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); !errors.Is(err, ErrTooManyTries) {
|
||||
t.Errorf("the window did not apply to a correct password: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASuccessfulSignInClearsTheWindow(t *testing.T) {
|
||||
// Otherwise a person who mistypes their password a few times is locked out
|
||||
// for the rest of the window even after getting it right.
|
||||
st := newStore(t)
|
||||
hash, _ := HashPassword(goodPassword)
|
||||
ctx := context.Background()
|
||||
must(t, st.CreateUser(ctx, store.User{
|
||||
ID: "usr-1", Email: "a@example.com", Name: "A",
|
||||
PasswordHash: hash, State: store.UserPending, CreatedAt: time.Now(),
|
||||
}))
|
||||
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.MaxAttempts = 3
|
||||
svc := New(st, cfg, slog.New(slog.DiscardHandler))
|
||||
|
||||
for range 2 {
|
||||
_, _, _ = svc.SignIn(ctx, "a@example.com", "nope")
|
||||
}
|
||||
if _, _, err := svc.SignIn(ctx, "a@example.com", goodPassword); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 3 {
|
||||
if _, _, err := svc.SignIn(ctx, "a@example.com", "nope"); errors.Is(err, ErrTooManyTries) {
|
||||
t.Fatal("the window was not cleared by a successful sign-in")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFTokensAreCheckedAgainstTheSession(t *testing.T) {
|
||||
svc, st := newStoreAndUser(t)
|
||||
ctx := context.Background()
|
||||
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
|
||||
|
||||
token, csrf, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword)
|
||||
sess, _, _ := svc.Resolve(ctx, token)
|
||||
|
||||
if !svc.CheckCSRF(sess, csrf) {
|
||||
t.Error("the session's own token was rejected")
|
||||
}
|
||||
for _, bad := range []string{"", "wrong", csrf + "x", csrf[:len(csrf)-1]} {
|
||||
if svc.CheckCSRF(sess, bad) {
|
||||
t.Errorf("%q was accepted as a csrf token", bad)
|
||||
}
|
||||
}
|
||||
// A token from another session must not work in this one.
|
||||
other, otherCSRF, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword)
|
||||
_ = other
|
||||
if svc.CheckCSRF(sess, otherCSRF) {
|
||||
t.Error("another session's token was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordsAreHashedNotStored(t *testing.T) {
|
||||
svc, st := newService(t)
|
||||
ctx := context.Background()
|
||||
must(t, svc.SignUp(ctx, "ayse@example.com", "A", goodPassword))
|
||||
|
||||
u, _ := st.UserByEmail(ctx, "ayse@example.com")
|
||||
if strings.Contains(u.PasswordHash, goodPassword) {
|
||||
t.Fatal("the password is in the database")
|
||||
}
|
||||
if !strings.HasPrefix(u.PasswordHash, "$argon2id$") {
|
||||
t.Errorf("hash format: %q", u.PasswordHash)
|
||||
}
|
||||
ok, err := VerifyPassword(goodPassword, u.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
t.Errorf("the hash does not verify: %v %v", ok, err)
|
||||
}
|
||||
if ok, _ := VerifyPassword("something else entirely", u.PasswordHash); ok {
|
||||
t.Error("a wrong password verified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoHashesOfOnePasswordDiffer(t *testing.T) {
|
||||
// A salt that did not vary would make the table a rainbow-table lookup.
|
||||
a, _ := HashPassword(goodPassword)
|
||||
b, _ := HashPassword(goodPassword)
|
||||
if a == b {
|
||||
t.Fatal("hashing is not salted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortPasswordsAreRefused(t *testing.T) {
|
||||
if _, err := HashPassword("short"); !errors.Is(err, ErrWeakPassword) {
|
||||
t.Errorf("a five-character password was accepted: %v", err)
|
||||
}
|
||||
svc, _ := newService(t)
|
||||
if err := svc.SignUp(context.Background(), "a@example.com", "A", "short"); !errors.Is(err, ErrWeakPassword) {
|
||||
t.Errorf("signup accepted it: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedHashesAreRejectedRatherThanMatched(t *testing.T) {
|
||||
for _, bad := range []string{"", "plaintext", "$argon2id$", "$bcrypt$v=19$m=1,t=1,p=1$aa$bb"} {
|
||||
if ok, err := VerifyPassword(goodPassword, bad); ok || err == nil {
|
||||
t.Errorf("VerifyPassword(%q) = %v, %v", bad, ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestObviousNonAddressesAreRefused(t *testing.T) {
|
||||
svc, _ := newService(t)
|
||||
ctx := context.Background()
|
||||
for _, bad := range []string{"", "ayse", "ayse@", "@example.com", "a b@example.com", "a@b", "a@@b.com"} {
|
||||
if err := svc.SignUp(ctx, bad, "A", goodPassword); !errors.Is(err, ErrInvalidEmail) {
|
||||
t.Errorf("SignUp(%q) = %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangingAPasswordNeedsTheOldOne(t *testing.T) {
|
||||
// A session is what an attacker gets from a borrowed laptop or a stolen
|
||||
// cookie. The password is what they do not have, and without this check the
|
||||
// first thing they would do is take the account.
|
||||
svc, st := newStoreAndUser(t)
|
||||
ctx := context.Background()
|
||||
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
|
||||
u, _ := st.UserByID(ctx, "usr-1")
|
||||
|
||||
if err := svc.ChangePassword(ctx, st, u, "not the password", "a brand new passphrase"); !errors.Is(err, ErrBadCredentials) {
|
||||
t.Errorf("a wrong current password was accepted: %v", err)
|
||||
}
|
||||
// And the old one still works.
|
||||
if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); err != nil {
|
||||
t.Errorf("the password changed anyway: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangingAPasswordSignsOutEverywhere(t *testing.T) {
|
||||
// Leaving other sessions open would mean a password change does nothing
|
||||
// about the reason someone is changing it.
|
||||
svc, st := newStoreAndUser(t)
|
||||
ctx := context.Background()
|
||||
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
|
||||
|
||||
laptop, _, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword)
|
||||
phone, _, _ := svc.SignIn(ctx, "ayse@example.com", goodPassword)
|
||||
|
||||
u, _ := st.UserByID(ctx, "usr-1")
|
||||
must(t, svc.ChangePassword(ctx, st, u, goodPassword, "a brand new passphrase"))
|
||||
|
||||
for name, token := range map[string]string{"laptop": laptop, "phone": phone} {
|
||||
if _, _, err := svc.Resolve(ctx, token); !errors.Is(err, store.ErrNoSession) {
|
||||
t.Errorf("the %s session survived the change: %v", name, err)
|
||||
}
|
||||
}
|
||||
// The new password works and the old one does not.
|
||||
if _, _, err := svc.SignIn(ctx, "ayse@example.com", "a brand new passphrase"); err != nil {
|
||||
t.Errorf("the new password does not work: %v", err)
|
||||
}
|
||||
if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); !errors.Is(err, ErrBadCredentials) {
|
||||
t.Errorf("the old password still works: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewPasswordMustActuallyBeNew(t *testing.T) {
|
||||
svc, st := newStoreAndUser(t)
|
||||
ctx := context.Background()
|
||||
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
|
||||
u, _ := st.UserByID(ctx, "usr-1")
|
||||
|
||||
if err := svc.ChangePassword(ctx, st, u, goodPassword, goodPassword); !errors.Is(err, ErrSamePassword) {
|
||||
t.Errorf("changing a password to itself: %v", err)
|
||||
}
|
||||
if err := svc.ChangePassword(ctx, st, u, goodPassword, "short"); !errors.Is(err, ErrWeakPassword) {
|
||||
t.Errorf("a short new password was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuessingTheCurrentPasswordIsBounded(t *testing.T) {
|
||||
// Someone with a stolen session should not get unlimited attempts at the
|
||||
// password that would let them keep it.
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
hash, _ := HashPassword(goodPassword)
|
||||
must(t, st.CreateUser(ctx, store.User{
|
||||
ID: "usr-1", Email: "a@example.com", Name: "A",
|
||||
PasswordHash: hash, State: store.UserPending, CreatedAt: time.Now(),
|
||||
}))
|
||||
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.MaxAttempts = 3
|
||||
svc := New(st, cfg, slog.New(slog.DiscardHandler))
|
||||
u, _ := st.UserByID(ctx, "usr-1")
|
||||
|
||||
for range 3 {
|
||||
_ = svc.ChangePassword(ctx, st, u, "wrong", "a brand new passphrase")
|
||||
}
|
||||
if err := svc.ChangePassword(ctx, st, u, "wrong", "a brand new passphrase"); !errors.Is(err, ErrTooManyTries) {
|
||||
t.Errorf("guessing was unbounded: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Package account is the human half of authentication: people sign up, an
|
||||
// operator approves them, and they issue their own API keys from a profile page
|
||||
// (plan §7.9).
|
||||
//
|
||||
// This is deliberately a different mechanism from the one in internal/auth.
|
||||
// That one authenticates machines holding 256-bit random keys; this one
|
||||
// authenticates people holding passwords they chose. The two have opposite
|
||||
// requirements — a fast hash is correct there and wrong here — and keeping them
|
||||
// apart is what stops one set of assumptions leaking into the other.
|
||||
package account
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Argon2 parameters. OWASP's current floor for argon2id, which costs roughly
|
||||
// 50ms per attempt on ordinary hardware — slow enough to make an offline
|
||||
// dictionary attack expensive, fast enough that a login does not feel broken.
|
||||
//
|
||||
// They are stored with each hash, so raising them later leaves existing
|
||||
// passwords working until their owners next sign in.
|
||||
const (
|
||||
argonTime = 3
|
||||
argonMemory = 64 * 1024 // KiB
|
||||
argonThreads = 4
|
||||
argonKeyLen = 32
|
||||
saltLen = 16
|
||||
)
|
||||
|
||||
// MinPasswordLen is the only password rule.
|
||||
//
|
||||
// Length is the one requirement that reliably buys entropy. Composition rules —
|
||||
// a digit, a symbol, a capital — push people towards Password1! and produce
|
||||
// less entropy than they cost, so there are none.
|
||||
const MinPasswordLen = 12
|
||||
|
||||
// ErrWeakPassword says a password is too short to accept.
|
||||
var ErrWeakPassword = fmt.Errorf("a password must be at least %d characters", MinPasswordLen)
|
||||
|
||||
// ErrBadHash says a stored hash could not be parsed.
|
||||
var ErrBadHash = errors.New("unreadable password hash")
|
||||
|
||||
// HashPassword derives a storable hash.
|
||||
func HashPassword(password string) (string, error) {
|
||||
if len([]rune(password)) < MinPasswordLen {
|
||||
return "", ErrWeakPassword
|
||||
}
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
|
||||
b64 := base64.RawStdEncoding.EncodeToString
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argonMemory, argonTime, argonThreads, b64(salt), b64(sum)), nil
|
||||
}
|
||||
|
||||
// VerifyPassword reports whether a password matches a stored hash.
|
||||
//
|
||||
// The comparison is constant time. The parameters come from the hash rather
|
||||
// than from the constants above, so a hash written under older settings still
|
||||
// verifies.
|
||||
func VerifyPassword(password, encoded string) (bool, error) {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, ErrBadHash
|
||||
}
|
||||
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version {
|
||||
return false, ErrBadHash
|
||||
}
|
||||
var memory, time uint32
|
||||
var threads uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
|
||||
return false, ErrBadHash
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, ErrBadHash
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, ErrBadHash
|
||||
}
|
||||
|
||||
got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
||||
}
|
||||
|
||||
// dummyHash is verified against when no account matches, so a login attempt for
|
||||
// an address that does not exist costs the same time as one that does.
|
||||
//
|
||||
// Without it, a stopwatch enumerates every account on the system.
|
||||
var dummyHash string
|
||||
|
||||
func init() {
|
||||
h, err := HashPassword(strings.Repeat("x", MinPasswordLen))
|
||||
if err != nil {
|
||||
panic("account: cannot build the dummy hash: " + err.Error())
|
||||
}
|
||||
dummyHash = h
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// What a sign-in or sign-up can fail with.
|
||||
var (
|
||||
ErrBadCredentials = errors.New("that email and password do not match an account")
|
||||
ErrNotApproved = errors.New("this account is waiting for approval")
|
||||
ErrSuspended = errors.New("this account has been suspended")
|
||||
ErrTooManyTries = errors.New("too many attempts; try again shortly")
|
||||
ErrInvalidEmail = errors.New("that does not look like an email address")
|
||||
)
|
||||
|
||||
// Store is what the service needs from persistence.
|
||||
type Store interface {
|
||||
CreateUser(ctx context.Context, u store.User) error
|
||||
UserByEmail(ctx context.Context, email string) (store.User, error)
|
||||
UserByID(ctx context.Context, id string) (store.User, error)
|
||||
TouchLogin(ctx context.Context, id string, at time.Time) error
|
||||
|
||||
CreateSession(ctx context.Context, userID, token, csrf string, ttl time.Duration) error
|
||||
SessionByToken(ctx context.Context, token string) (store.Session, store.User, error)
|
||||
DeleteSession(ctx context.Context, token string) error
|
||||
TouchSession(ctx context.Context, token string, at time.Time) error
|
||||
}
|
||||
|
||||
// Config tunes the service.
|
||||
type Config struct {
|
||||
// SignupOpen decides whether anyone may create an account. Off, the form is
|
||||
// not served at all; accounts are made with `llmgw users create`.
|
||||
SignupOpen bool
|
||||
// SessionTTL is how long a sign-in lasts.
|
||||
SessionTTL time.Duration
|
||||
// MaxAttempts and AttemptWindow bound password guessing per address.
|
||||
MaxAttempts int
|
||||
AttemptWindow time.Duration
|
||||
}
|
||||
|
||||
// DefaultConfig returns sensible values.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
SignupOpen: true,
|
||||
SessionTTL: store.SessionTTL,
|
||||
MaxAttempts: 10,
|
||||
AttemptWindow: 15 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) normalize() {
|
||||
d := DefaultConfig()
|
||||
if c.SessionTTL <= 0 {
|
||||
c.SessionTTL = d.SessionTTL
|
||||
}
|
||||
if c.MaxAttempts <= 0 {
|
||||
c.MaxAttempts = d.MaxAttempts
|
||||
}
|
||||
if c.AttemptWindow <= 0 {
|
||||
c.AttemptWindow = d.AttemptWindow
|
||||
}
|
||||
}
|
||||
|
||||
// Service is signup, sign-in and sessions.
|
||||
type Service struct {
|
||||
store Store
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
attempts map[string]*attemptWindow
|
||||
}
|
||||
|
||||
type attemptWindow struct {
|
||||
count int
|
||||
since time.Time
|
||||
}
|
||||
|
||||
// New builds the service.
|
||||
func New(s Store, cfg Config, log *slog.Logger) *Service {
|
||||
cfg.normalize()
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Service{store: s, cfg: cfg, log: log, now: time.Now, attempts: map[string]*attemptWindow{}}
|
||||
}
|
||||
|
||||
// SignupOpen reports whether the form should be served.
|
||||
func (s *Service) SignupOpen() bool { return s.cfg.SignupOpen }
|
||||
|
||||
// SignUp creates a pending account.
|
||||
//
|
||||
// It returns nil whether or not the address was already taken. A signup form
|
||||
// that says "this address already has an account" is an account enumerator that
|
||||
// anyone can run, and the person signing up learns nothing they need: either
|
||||
// way, what happens next is that they wait for approval.
|
||||
func (s *Service) SignUp(ctx context.Context, email, name, password string) error {
|
||||
email = store.NormalizeEmail(email)
|
||||
if !validEmail(email) {
|
||||
return ErrInvalidEmail
|
||||
}
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if name = strings.TrimSpace(name); name == "" {
|
||||
name = email
|
||||
}
|
||||
|
||||
err = s.store.CreateUser(ctx, store.User{
|
||||
ID: randomID("usr"), Email: email, Name: name,
|
||||
PasswordHash: hash, State: store.UserPending, CreatedAt: s.now(),
|
||||
})
|
||||
if errors.Is(err, store.ErrEmailTaken) {
|
||||
// Logged so an operator can see it; not told to the caller.
|
||||
s.log.Info("signup for an address that already has an account")
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.log.Info("a new account is waiting for approval", "email", email)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SignIn checks a password and opens a session, returning the session token and
|
||||
// the CSRF token that must accompany the forms in it.
|
||||
func (s *Service) SignIn(ctx context.Context, email, password string) (token, csrf string, err error) {
|
||||
email = store.NormalizeEmail(email)
|
||||
if !s.allowAttempt(email) {
|
||||
return "", "", ErrTooManyTries
|
||||
}
|
||||
|
||||
u, err := s.store.UserByEmail(ctx, email)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
// Verify anyway, against a hash that cannot match. Returning early here
|
||||
// would let a stopwatch enumerate every account on the system.
|
||||
_, _ = VerifyPassword(password, dummyHash)
|
||||
return "", "", ErrBadCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
ok, err := VerifyPassword(password, u.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
return "", "", ErrBadCredentials
|
||||
}
|
||||
// The password was right, so this was not an attack.
|
||||
s.clearAttempts(email)
|
||||
|
||||
// State is checked after the password, on purpose. Checking first would
|
||||
// tell anyone who guesses an address whether it has a pending account.
|
||||
switch u.State {
|
||||
case store.UserPending:
|
||||
return "", "", ErrNotApproved
|
||||
case store.UserSuspended:
|
||||
return "", "", ErrSuspended
|
||||
}
|
||||
|
||||
if token, err = store.NewSessionToken(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if csrf, err = store.NewSessionToken(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := s.store.CreateSession(ctx, u.ID, token, csrf, s.cfg.SessionTTL); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := s.store.TouchLogin(ctx, u.ID, s.now()); err != nil {
|
||||
s.log.Warn("could not record a sign-in", "err", err)
|
||||
}
|
||||
return token, csrf, nil
|
||||
}
|
||||
|
||||
// Resolve turns a session cookie into the person holding it.
|
||||
func (s *Service) Resolve(ctx context.Context, token string) (store.Session, store.User, error) {
|
||||
if token == "" {
|
||||
return store.Session{}, store.User{}, store.ErrNoSession
|
||||
}
|
||||
sess, u, err := s.store.SessionByToken(ctx, token)
|
||||
if err != nil {
|
||||
return store.Session{}, store.User{}, err
|
||||
}
|
||||
if err := s.store.TouchSession(ctx, token, s.now()); err != nil {
|
||||
s.log.Debug("could not touch a session", "err", err)
|
||||
}
|
||||
return sess, u, nil
|
||||
}
|
||||
|
||||
// SignOut closes one session.
|
||||
func (s *Service) SignOut(ctx context.Context, token string) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
return s.store.DeleteSession(ctx, token)
|
||||
}
|
||||
|
||||
// CheckCSRF compares a submitted token against the session's.
|
||||
//
|
||||
// A cookie authenticates the browser, not the page that asked. Without this, any
|
||||
// site the person visits can post a form to ours and have the browser attach the
|
||||
// session automatically.
|
||||
func (s *Service) CheckCSRF(sess store.Session, submitted string) bool {
|
||||
if submitted == "" || len(sess.CSRFHash) == 0 {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare(store.HashKey(submitted), sess.CSRFHash) == 1
|
||||
}
|
||||
|
||||
// allowAttempt bounds password guessing per address.
|
||||
//
|
||||
// Per address rather than per IP: an attacker has many addresses to come from
|
||||
// and only one to guess against, and limiting by IP would lock out everyone
|
||||
// behind one office NAT the moment somebody fat-fingers their password.
|
||||
func (s *Service) allowAttempt(email string) bool {
|
||||
now := s.now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
w, ok := s.attempts[email]
|
||||
if !ok || now.Sub(w.since) > s.cfg.AttemptWindow {
|
||||
s.attempts[email] = &attemptWindow{count: 1, since: now}
|
||||
return true
|
||||
}
|
||||
w.count++
|
||||
return w.count <= s.cfg.MaxAttempts
|
||||
}
|
||||
|
||||
func (s *Service) clearAttempts(email string) {
|
||||
s.mu.Lock()
|
||||
delete(s.attempts, email)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// validEmail is deliberately crude. A real check is delivery, which this does
|
||||
// not do; this only rejects what is obviously not an address so the database
|
||||
// does not fill with them.
|
||||
func validEmail(s string) bool {
|
||||
at := strings.IndexByte(s, '@')
|
||||
if at <= 0 || at != strings.LastIndexByte(s, '@') || at == len(s)-1 {
|
||||
return false
|
||||
}
|
||||
if strings.ContainsAny(s, " \t\r\n<>\"") {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(s[at+1:], ".")
|
||||
}
|
||||
|
||||
func randomID(prefix string) string {
|
||||
var buf [10]byte
|
||||
_, _ = rand.Read(buf[:])
|
||||
return prefix + "-" + strings.ToLower(
|
||||
base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf[:]))
|
||||
}
|
||||
|
||||
// ErrSamePassword says the new password is the old one.
|
||||
var ErrSamePassword = errors.New("that is the password you already have")
|
||||
|
||||
// PasswordChanger is the extra storage a password change needs.
|
||||
type PasswordChanger interface {
|
||||
SetPassword(ctx context.Context, userID, hash string) error
|
||||
DeleteUserSessions(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
// ChangePassword replaces a person's password and signs them out everywhere.
|
||||
//
|
||||
// The current password is required even though the caller already holds a
|
||||
// session. A session is what an attacker gets from a borrowed laptop or a stolen
|
||||
// cookie; the password is what they do not have, and without this check the
|
||||
// first thing they would do is take the account.
|
||||
//
|
||||
// Every session is closed, this browser's included. Leaving the others open
|
||||
// would mean a password change does nothing about the reason someone is
|
||||
// changing it. The caller re-issues a session for the browser in front of them.
|
||||
func (s *Service) ChangePassword(ctx context.Context, pw PasswordChanger,
|
||||
u store.User, current, next string,
|
||||
) error {
|
||||
if !s.allowAttempt("pw:" + u.ID) {
|
||||
return ErrTooManyTries
|
||||
}
|
||||
ok, err := VerifyPassword(current, u.PasswordHash)
|
||||
if err != nil || !ok {
|
||||
return ErrBadCredentials
|
||||
}
|
||||
s.clearAttempts("pw:" + u.ID)
|
||||
|
||||
if same, _ := VerifyPassword(next, u.PasswordHash); same {
|
||||
return ErrSamePassword
|
||||
}
|
||||
hash, err := HashPassword(next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pw.SetPassword(ctx, u.ID, hash); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pw.DeleteUserSessions(ctx, u.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.log.Info("a password was changed", "user", u.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenSession starts a session for a person who has just proved who they are by
|
||||
// some other means — immediately after changing their password, which closed
|
||||
// the one they were using.
|
||||
func (s *Service) OpenSession(ctx context.Context, userID string) (token, csrf string, err error) {
|
||||
if token, err = store.NewSessionToken(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if csrf, err = store.NewSessionToken(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := s.store.CreateSession(ctx, userID, token, csrf, s.cfg.SessionTTL); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return token, csrf, nil
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package accounting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// Refusals a key can hit. They are distinguished so the client is told which
|
||||
// limit it hit, and so retrying is only suggested where retrying helps.
|
||||
var (
|
||||
ErrRateLimited = errors.New("request rate limit exceeded")
|
||||
ErrTokenLimited = errors.New("token rate limit exceeded")
|
||||
ErrBudgetSpent = errors.New("budget exhausted")
|
||||
)
|
||||
|
||||
// LimitError carries which limit was hit and, where it makes sense, when to
|
||||
// come back.
|
||||
type LimitError struct {
|
||||
Err error
|
||||
// RetryAfter is zero when waiting does not help, which is the case for a
|
||||
// spent budget: it will not refill until the period rolls over, and telling
|
||||
// a client to retry in a second would be a lie.
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (e *LimitError) Error() string { return e.Err.Error() }
|
||||
func (e *LimitError) Unwrap() error { return e.Err }
|
||||
|
||||
// Accountant enforces limits and records what was used.
|
||||
//
|
||||
// One object because the three halves are not independent: a budget check needs
|
||||
// the price, the price needs the model, and the record closes the loop by
|
||||
// feeding the counters that the next check reads.
|
||||
type Accountant struct {
|
||||
pricing *Pricing
|
||||
budgets *Budgets
|
||||
requests *Limiter
|
||||
tokens *Limiter
|
||||
recorder *Recorder
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New builds an accountant.
|
||||
func New(pricing *Pricing, recorder *Recorder, log *slog.Logger) *Accountant {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Accountant{
|
||||
pricing: pricing,
|
||||
budgets: NewBudgets(),
|
||||
requests: NewLimiter(),
|
||||
tokens: NewLimiter(),
|
||||
recorder: recorder,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// RestoreBudgets seeds the in-memory counters from the store.
|
||||
//
|
||||
// Called at startup. A restart must not reset a budget, which it would if the
|
||||
// counters only ever lived in memory.
|
||||
func (a *Accountant) RestoreBudgets(ctx context.Context, s store.Store, period store.Period) error {
|
||||
start := PeriodStart(period, time.Now())
|
||||
if start.IsZero() {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.SpendSinceByScope(ctx, start)
|
||||
if err != nil {
|
||||
return fmt.Errorf("restoring budgets: %w", err)
|
||||
}
|
||||
// Every scope a key sits under, not just the key. A budget owned by a team
|
||||
// is one counter fed by every key beneath it, and restoring only per-key
|
||||
// totals would bring the control plane back up believing the team had spent
|
||||
// nothing — which is a budget that resets on every restart, for exactly the
|
||||
// scopes where it matters most.
|
||||
spend := make(map[string]int64, len(rows)*4)
|
||||
for _, r := range rows {
|
||||
for _, o := range []store.LimitOwner{
|
||||
{Scope: store.ScopeKey, ID: r.KeyID},
|
||||
{Scope: store.ScopeProject, ID: r.ProjectID},
|
||||
{Scope: store.ScopeTeam, ID: r.TeamID},
|
||||
{Scope: store.ScopeOrg, ID: r.OrgID},
|
||||
} {
|
||||
if k := o.CounterKey(); k != "" {
|
||||
spend[k] += r.Micros
|
||||
}
|
||||
}
|
||||
}
|
||||
a.budgets.Restore(period, spend)
|
||||
a.log.Info("restored budgets from the store",
|
||||
"keys", len(rows), "counters", len(spend), "since", start)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reservation is what Check returns: the estimate it charged, so Record can
|
||||
// settle the difference against the real figure.
|
||||
// pool is one level's counter and the period its budget runs over.
|
||||
type pool struct {
|
||||
Counter string
|
||||
Period store.Period
|
||||
}
|
||||
|
||||
type Reservation struct {
|
||||
KeyID string
|
||||
// Budgets and Tokens are every counter the check went through, carried so
|
||||
// the settlement lands on the same ones. Every, not the tightest: an
|
||||
// organization's pool only fills up if each team's spend is added to it
|
||||
// as well as to the team's own.
|
||||
//
|
||||
// Derived again at settlement would mean re-resolving the limits, and a
|
||||
// budget checked against the team and settled against the key is a budget
|
||||
// that never fills up.
|
||||
Budgets []pool
|
||||
Tokens []string
|
||||
EstimatedTokens int64
|
||||
Price Price
|
||||
// Period is the key's budget period, carried from the check so the record
|
||||
// counts against the same window the check read. Deriving it later would
|
||||
// mean guessing, and a monthly budget credited to a daily counter is a
|
||||
// budget that never binds.
|
||||
Period store.Period
|
||||
}
|
||||
|
||||
// Check decides whether a request may proceed.
|
||||
//
|
||||
// It runs before scheduling and touches no database: every counter it reads is
|
||||
// in memory. The token limit is checked against an estimate, because the real
|
||||
// figure only exists once the request has run; Record settles the difference.
|
||||
func (a *Accountant) Check(p auth.Principal, model string, estimatedTokens int64) (Reservation, error) {
|
||||
price := a.pricing.For(model)
|
||||
lim := p.Limits
|
||||
// Every level that set a limit, each against its own pool. A quota written
|
||||
// on a team is one pool the team shares, and the organization above it is a
|
||||
// second pool that the team's spending also fills — both have to hold, or
|
||||
// an organization given 1000 with four teams given 500 spends 2000.
|
||||
chain := levels(lim, p.KeyID)
|
||||
|
||||
res := Reservation{
|
||||
KeyID: p.KeyID, EstimatedTokens: estimatedTokens,
|
||||
Price: price, Period: lim.BudgetPeriod,
|
||||
}
|
||||
for _, l := range chain {
|
||||
if l.Budget != nil {
|
||||
res.Budgets = append(res.Budgets, pool{l.Counter, l.Period})
|
||||
}
|
||||
if l.TPM != nil {
|
||||
res.Tokens = append(res.Tokens, l.Counter)
|
||||
}
|
||||
}
|
||||
if p.KeyID == "" {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Budget first. It is the limit whose refusal cannot be retried away, so
|
||||
// telling the client about it first is more useful than reporting a rate
|
||||
// limit it would hit again in a second.
|
||||
//
|
||||
// A model that costs nothing is exempt, which is what lets a key with no
|
||||
// budget left still be served a cheap fallback (plan §7.7).
|
||||
// Budgets first, and all of them. Exhausted only reads, so the order costs
|
||||
// nothing and a refusal here charges no counter.
|
||||
if !price.Zero() {
|
||||
for _, l := range chain {
|
||||
if l.Budget != nil && a.budgets.Exhausted(l.Counter, l.Period, *l.Budget) {
|
||||
return res, &LimitError{Err: ErrBudgetSpent}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limits charge as they check — deliberately, so two concurrent
|
||||
// requests cannot both see room. With several levels that means a refusal
|
||||
// at one has already taken from the levels before it, so those are handed
|
||||
// back. Without the refund a team at its ceiling would quietly eat its
|
||||
// organization's rate limit with requests that were never served.
|
||||
if err := a.allowAll(chain, func(l level) (string, int, int64, bool) {
|
||||
return l.Counter, valInt(l.RPM), 1, l.RPM != nil
|
||||
}, a.requests); err != nil {
|
||||
return res, &LimitError{Err: ErrRateLimited, RetryAfter: time.Second}
|
||||
}
|
||||
if err := a.allowAll(chain, func(l level) (string, int, int64, bool) {
|
||||
return l.Counter, valInt(l.TPM), estimatedTokens, l.TPM != nil
|
||||
}, a.tokens); err != nil {
|
||||
// The request was not charged against the request-rate limit in vain:
|
||||
// it was, and that is correct — it did arrive.
|
||||
return res, &LimitError{Err: ErrTokenLimited, RetryAfter: time.Second}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// level is one scope's limits with the counter they are enforced against.
|
||||
type level struct {
|
||||
Counter string
|
||||
Period store.Period
|
||||
RPM *int
|
||||
TPM *int
|
||||
Budget *int64
|
||||
}
|
||||
|
||||
// levels turns a resolved chain into the pools a request must pass.
|
||||
//
|
||||
// Falls back to the collapsed values when the chain is empty, which is what a
|
||||
// principal assembled by hand looks like — in tests, and anywhere that builds
|
||||
// limits without walking the tree.
|
||||
func levels(e store.Effective, keyID string) []level {
|
||||
if len(e.Chain) == 0 {
|
||||
var out []level
|
||||
if e.RPM != nil || e.TPM != nil || e.Budget != nil {
|
||||
out = append(out, level{
|
||||
Counter: counterFor(e.BudgetOwner, keyID),
|
||||
Period: e.BudgetPeriod, Budget: e.Budget,
|
||||
})
|
||||
out[0].RPM, out[0].TPM = e.RPM, e.TPM
|
||||
// One entry, so a hand-built principal with different owners per
|
||||
// limit still counts each where it said to.
|
||||
if r, b := counterFor(e.RPMOwner, keyID), out[0].Counter; r != b && e.RPM != nil {
|
||||
out[0].RPM = nil
|
||||
out = append(out, level{Counter: r, RPM: e.RPM})
|
||||
}
|
||||
if tp, b := counterFor(e.TPMOwner, keyID), out[0].Counter; tp != b && e.TPM != nil {
|
||||
out[0].TPM = nil
|
||||
out = append(out, level{Counter: tp, TPM: e.TPM})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
out := make([]level, 0, len(e.Chain))
|
||||
for _, l := range e.Chain {
|
||||
out = append(out, level{
|
||||
Counter: store.LimitOwner{Scope: l.Scope, ID: l.ScopeID}.CounterKey(),
|
||||
Period: l.BudgetPeriod, RPM: l.RPM, TPM: l.TPM, Budget: l.Budget,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// allowAll charges every level that has this limit, refunding on a refusal.
|
||||
func (a *Accountant) allowAll(chain []level,
|
||||
pick func(level) (key string, limit int, n int64, has bool), lim *Limiter,
|
||||
) error {
|
||||
var charged []struct {
|
||||
key string
|
||||
n int64
|
||||
}
|
||||
for _, l := range chain {
|
||||
key, limit, n, has := pick(l)
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
if !lim.Allow(key, limit, n) {
|
||||
for _, c := range charged {
|
||||
lim.Charge(c.key, -c.n)
|
||||
}
|
||||
return errRefused
|
||||
}
|
||||
charged = append(charged, struct {
|
||||
key string
|
||||
n int64
|
||||
}{key, n})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var errRefused = errors.New("refused")
|
||||
|
||||
func valInt(v *int) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// Record settles a finished request: it reconciles the token estimate, adds to
|
||||
// the budget, and queues the usage row.
|
||||
func (a *Accountant) Record(res Reservation, u store.Usage) {
|
||||
if res.KeyID == "" {
|
||||
return
|
||||
}
|
||||
actual := u.PromptTokens + u.CompletionTokens
|
||||
|
||||
// The estimate was charged up front; charge the difference now. A caller
|
||||
// whose requests are consistently larger than estimated is still bounded,
|
||||
// one window later than with a perfect estimate.
|
||||
// Every level the check went through, so an organization's pool fills up
|
||||
// from its teams' spending rather than only from requests that named it.
|
||||
for _, c := range res.Tokens {
|
||||
a.tokens.Charge(c, actual-res.EstimatedTokens)
|
||||
}
|
||||
|
||||
u.CostMicros = res.Price.Cost(u.PromptTokens, u.CompletionTokens)
|
||||
for _, b := range res.Budgets {
|
||||
a.budgets.Add(b.Counter, b.Period, u.CostMicros)
|
||||
}
|
||||
|
||||
if a.recorder != nil {
|
||||
a.recorder.Record(u)
|
||||
}
|
||||
}
|
||||
|
||||
// Status returns what a key has used, for headers and `ctl`.
|
||||
func (a *Accountant) Status(p auth.Principal) (spent int64, rpm, tpm float64) {
|
||||
if p.KeyID == "" {
|
||||
return 0, 0, 0
|
||||
}
|
||||
// The pool that will refuse them, which is not always the one with the
|
||||
// smallest number on it. A team given 2000 inside an organization given the
|
||||
// same is bounded by whichever is nearer its ceiling, and that is the
|
||||
// organization as soon as a second team spends anything. A header that
|
||||
// reported the team's would disagree with the refusal that follows it.
|
||||
for _, l := range levels(p.Limits, p.KeyID) {
|
||||
if l.Budget != nil {
|
||||
if v := a.budgets.Spent(l.Counter, l.Period); v > spent {
|
||||
spent = v
|
||||
}
|
||||
}
|
||||
if l.RPM != nil {
|
||||
if v := a.requests.Rate(l.Counter); v > rpm {
|
||||
rpm = v
|
||||
}
|
||||
}
|
||||
if l.TPM != nil {
|
||||
if v := a.tokens.Rate(l.Counter); v > tpm {
|
||||
tpm = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return spent, rpm, tpm
|
||||
}
|
||||
|
||||
// counterFor is the counter a limit owned by this scope should use.
|
||||
//
|
||||
// Falls back to the key's own counter when no scope owns the limit, which
|
||||
// happens when nothing in the chain set one.
|
||||
//
|
||||
// Namespaced even then. A bare key id here and a "key:"-prefixed one in the
|
||||
// restore path would be two different counters for the same key, so a budget
|
||||
// would check against an empty one and survive every restart — which is how
|
||||
// this was wrong when it was first written.
|
||||
func counterFor(o store.LimitOwner, keyID string) string {
|
||||
if k := o.CounterKey(); k != "" {
|
||||
return k
|
||||
}
|
||||
return keyCounter(keyID)
|
||||
}
|
||||
|
||||
// keyCounter is a key's own counter, in the same namespace as every other.
|
||||
func or(v, fallback string) string {
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func keyCounter(keyID string) string {
|
||||
if keyID == "" {
|
||||
return ""
|
||||
}
|
||||
return store.LimitOwner{Scope: store.ScopeKey, ID: keyID}.CounterKey()
|
||||
}
|
||||
|
||||
// AnythingPriced reports whether a budget can ever be spent against.
|
||||
//
|
||||
// On the accountant rather than making callers reach for the price table: what
|
||||
// they are asking is whether the budgets they are about to display mean
|
||||
// anything, and the accountant is what would enforce them.
|
||||
func (a *Accountant) AnythingPriced() bool { return a.pricing.AnythingPriced() }
|
||||
@@ -0,0 +1,632 @@
|
||||
package accounting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func intp(v int) *int { return &v }
|
||||
func i64p(v int64) *int64 { return &v }
|
||||
|
||||
func principal(id string, tune func(*store.Effective)) auth.Principal {
|
||||
e := store.Effective{KeyID: id, ProjectID: "proj"}
|
||||
if tune != nil {
|
||||
tune(&e)
|
||||
}
|
||||
return auth.Principal{KeyID: id, ProjectID: "proj", Limits: e}
|
||||
}
|
||||
|
||||
func pricing(t *testing.T) *Pricing {
|
||||
t.Helper()
|
||||
return NewPricing(map[string]Price{
|
||||
"paid": {PromptPer1k: 1000, CompletionPer1k: 3000},
|
||||
"free": {},
|
||||
}, Price{})
|
||||
}
|
||||
|
||||
func TestCostRoundsUpSoSmallRequestsAreNotFree(t *testing.T) {
|
||||
// Rounding down would make a stream of tiny requests cost nothing, which is
|
||||
// exactly the traffic shape a quota exists to bound.
|
||||
p := Price{PromptPer1k: 1000, CompletionPer1k: 3000}
|
||||
if got := p.Cost(1, 0); got != 1 {
|
||||
t.Errorf("one prompt token cost %d", got)
|
||||
}
|
||||
if got := p.Cost(1000, 1000); got != 4000 {
|
||||
t.Errorf("a thousand of each cost %d, want 4000", got)
|
||||
}
|
||||
if got := p.Cost(0, 0); got != 0 {
|
||||
t.Errorf("an empty request cost %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroPricedModelsStayAvailableWhenTheBudgetIsSpent(t *testing.T) {
|
||||
// This is what makes budget fallback work: a key with nothing left can
|
||||
// still be served something that costs nothing.
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
p := principal("k1", func(e *store.Effective) {
|
||||
e.Budget = i64p(100)
|
||||
e.BudgetPeriod = store.PeriodMonth
|
||||
})
|
||||
|
||||
res, err := a.Check(p, "paid", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 1000, CompletionTokens: 0})
|
||||
|
||||
if _, err := a.Check(p, "paid", 10); !errors.Is(err, ErrBudgetSpent) {
|
||||
t.Errorf("a spent budget still served a paid model: %v", err)
|
||||
}
|
||||
if _, err := a.Check(p, "free", 10); err != nil {
|
||||
t.Errorf("a spent budget refused a free model: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBudgetRefusalDoesNotSuggestRetrying(t *testing.T) {
|
||||
// A budget does not refill until the period rolls over, so a Retry-After of
|
||||
// a second would be a lie that turns one client into a retry loop.
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
p := principal("k1", func(e *store.Effective) {
|
||||
e.Budget = i64p(1)
|
||||
e.BudgetPeriod = store.PeriodDay
|
||||
})
|
||||
res, _ := a.Check(p, "paid", 10)
|
||||
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 5000})
|
||||
|
||||
_, err := a.Check(p, "paid", 10)
|
||||
var le *LimitError
|
||||
if !errors.As(err, &le) {
|
||||
t.Fatalf("expected a limit error, got %v", err)
|
||||
}
|
||||
if le.RetryAfter != 0 {
|
||||
t.Errorf("a spent budget suggested retrying in %v", le.RetryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitsBind(t *testing.T) {
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
p := principal("k1", func(e *store.Effective) { e.RPM = intp(3) })
|
||||
|
||||
for i := range 3 {
|
||||
if _, err := a.Check(p, "free", 1); err != nil {
|
||||
t.Fatalf("request %d was refused: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := a.Check(p, "free", 1); !errors.Is(err, ErrRateLimited) {
|
||||
t.Errorf("the fourth request was allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenLimitsUseTheEstimateAndThenSettle(t *testing.T) {
|
||||
// The real token count only exists once the request has run, so the check
|
||||
// has to work on an estimate. A caller that consistently underestimates
|
||||
// must still end up bounded.
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
p := principal("k1", func(e *store.Effective) { e.TPM = intp(1000) })
|
||||
|
||||
// Claims 10 tokens, actually uses 900.
|
||||
res, err := a.Check(p, "free", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 400, CompletionTokens: 500})
|
||||
|
||||
// The settled figure is what the next check sees, so a second request of
|
||||
// the same shape cannot fit.
|
||||
if _, err := a.Check(p, "free", 200); !errors.Is(err, ErrTokenLimited) {
|
||||
t.Errorf("the underestimate was never settled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoLimitIsNotALimitOfZero(t *testing.T) {
|
||||
// The distinction the store keeps with a nil pointer. Getting it wrong here
|
||||
// would silently forbid every request for every key with no limits set.
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
p := principal("k1", nil)
|
||||
|
||||
for range 50 {
|
||||
if _, err := a.Check(p, "paid", 10_000); err != nil {
|
||||
t.Fatalf("a key with no limits was refused: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnauthenticatedRequestIsNotAccounted(t *testing.T) {
|
||||
// With auth off there is no key to charge, and Check must not refuse.
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
res, err := a.Check(auth.Principal{}, "paid", 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Record(res, store.Usage{PromptTokens: 1000})
|
||||
if spent, _, _ := a.Status(auth.Principal{}); spent != 0 {
|
||||
t.Errorf("an anonymous request was charged %d", spent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBudgetSurvivesARestart(t *testing.T) {
|
||||
// The counters live in memory; the store is what makes them survive.
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "acct.db")
|
||||
s, err := store.OpenSQLite(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
must(t, s.PutOrg(ctx, store.Org{ID: "o", Name: "o"}))
|
||||
must(t, s.PutTeam(ctx, store.Team{ID: "t", OrgID: "o", Name: "t"}))
|
||||
must(t, s.PutProject(ctx, store.Project{ID: "proj", TeamID: "t", Name: "p"}))
|
||||
_, k, _ := store.NewKey("k1", "n", "proj")
|
||||
k.CreatedAt = time.Now()
|
||||
must(t, s.CreateKey(ctx, k))
|
||||
|
||||
must(t, s.WriteUsage(ctx, []store.Usage{
|
||||
{At: time.Now(), KeyID: "k1", ProjectID: "proj", Model: "paid",
|
||||
ServedModel: "paid", CostMicros: 5000, Status: 200},
|
||||
}))
|
||||
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
must(t, a.RestoreBudgets(ctx, s, store.PeriodMonth))
|
||||
|
||||
p := principal("k1", func(e *store.Effective) {
|
||||
e.Budget = i64p(5000)
|
||||
e.BudgetPeriod = store.PeriodMonth
|
||||
})
|
||||
if _, err := a.Check(p, "paid", 10); !errors.Is(err, ErrBudgetSpent) {
|
||||
t.Errorf("the budget was reset by the restart: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBudgetsResetAtThePeriodBoundary(t *testing.T) {
|
||||
b := NewBudgets()
|
||||
now := time.Date(2026, 1, 31, 23, 59, 0, 0, time.UTC)
|
||||
b.now = func() time.Time { return now }
|
||||
|
||||
b.Add("k1", store.PeriodMonth, 900)
|
||||
if got := b.Spent("k1", store.PeriodMonth); got != 900 {
|
||||
t.Fatalf("spent %d", got)
|
||||
}
|
||||
|
||||
now = now.Add(2 * time.Minute) // into February
|
||||
if got := b.Spent("k1", store.PeriodMonth); got != 0 {
|
||||
t.Errorf("the counter carried %d across the month boundary", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyAndMonthlyBoundariesDiffer(t *testing.T) {
|
||||
b := NewBudgets()
|
||||
now := time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC)
|
||||
b.now = func() time.Time { return now }
|
||||
|
||||
b.Add("daily", store.PeriodDay, 100)
|
||||
b.Add("monthly", store.PeriodMonth, 100)
|
||||
|
||||
now = now.Add(24 * time.Hour)
|
||||
if got := b.Spent("daily", store.PeriodDay); got != 0 {
|
||||
t.Errorf("the daily counter carried %d across a day", got)
|
||||
}
|
||||
if got := b.Spent("monthly", store.PeriodMonth); got != 100 {
|
||||
t.Errorf("the monthly counter lost %d within a month", 100-got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheWindowDoesNotAllowTwiceTheLimit(t *testing.T) {
|
||||
// The reason for a sliding window. With a fixed one, a caller sends its
|
||||
// whole allowance at the end of a window and again at the start of the
|
||||
// next, so the observed rate is double the limit exactly when the system is
|
||||
// least able to take it.
|
||||
l := NewLimiter()
|
||||
base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
now := base.Add(59 * time.Second)
|
||||
l.now = func() time.Time { return now }
|
||||
|
||||
const limit = 60
|
||||
sent := 0
|
||||
for range limit {
|
||||
if l.Allow("k", limit, 1) {
|
||||
sent++
|
||||
}
|
||||
}
|
||||
if sent != limit {
|
||||
t.Fatalf("the first burst only managed %d", sent)
|
||||
}
|
||||
|
||||
// One second later, in the next fixed window.
|
||||
now = base.Add(61 * time.Second)
|
||||
second := 0
|
||||
for range limit {
|
||||
if l.Allow("k", limit, 1) {
|
||||
second++
|
||||
}
|
||||
}
|
||||
// Some allowance returns as the old window slides out of view, but nowhere
|
||||
// near the whole of it.
|
||||
if second > limit/2 {
|
||||
t.Errorf("a second burst of %d passed immediately after %d; the window is not sliding",
|
||||
second, sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterIsSafeUnderConcurrency(t *testing.T) {
|
||||
// Checking and charging have to be one operation, or two concurrent
|
||||
// requests both see room and both take it.
|
||||
l := NewLimiter()
|
||||
const limit = 100
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
allowed := 0
|
||||
for range 500 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if l.Allow("k", limit, 1) {
|
||||
mu.Lock()
|
||||
allowed++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if allowed != limit {
|
||||
t.Errorf("%d requests passed a limit of %d", allowed, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// teamMember is a key whose budget and rate limit were set on its team, which
|
||||
// is the arrangement an enterprise actually asks for: a group gets a quota and
|
||||
// the people in it share it.
|
||||
func teamMember(keyID, teamID string, tune func(*store.Effective)) auth.Principal {
|
||||
e := store.Effective{KeyID: keyID, ProjectID: "proj", TeamID: teamID}
|
||||
owner := store.LimitOwner{Scope: store.ScopeTeam, ID: teamID}
|
||||
e.BudgetOwner, e.RPMOwner, e.TPMOwner = owner, owner, owner
|
||||
if tune != nil {
|
||||
tune(&e)
|
||||
}
|
||||
return auth.Principal{KeyID: keyID, ProjectID: "proj", Limits: e}
|
||||
}
|
||||
|
||||
func TestATeamBudgetIsOnePoolRatherThanOnePerMember(t *testing.T) {
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
budget := func(e *store.Effective) {
|
||||
e.Budget = i64p(3000)
|
||||
e.BudgetPeriod = store.PeriodMonth
|
||||
}
|
||||
first := teamMember("k1", "t1", budget)
|
||||
second := teamMember("k2", "t1", budget)
|
||||
|
||||
// One member spends the lot.
|
||||
res, err := a.Check(first, "paid", 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 1000, CompletionTokens: 1000})
|
||||
|
||||
// The second must now be refused. Counted per key it would have its own
|
||||
// untouched 3000 — a team budget of 3000 with ten keys would permit thirty
|
||||
// thousand, which is the bug this change exists for.
|
||||
if _, err := a.Check(second, "paid", 10); !errors.Is(err, ErrBudgetSpent) {
|
||||
t.Errorf("the team's budget should be shared; the second member got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATeamRateLimitIsSharedToo(t *testing.T) {
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
rpm := func(e *store.Effective) { e.RPM = intp(1) }
|
||||
|
||||
if _, err := a.Check(teamMember("k1", "t1", rpm), "free", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.Check(teamMember("k2", "t1", rpm), "free", 1); !errors.Is(err, ErrRateLimited) {
|
||||
t.Error("a rate limit set on the team should be shared by its keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoTeamsDoNotShareACounter(t *testing.T) {
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
rpm := func(e *store.Effective) { e.RPM = intp(1) }
|
||||
|
||||
if _, err := a.Check(teamMember("k1", "t1", rpm), "free", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Pooling is the point; pooling the wrong things is the danger. One team
|
||||
// exhausting another's quota would be worse than the bug being fixed.
|
||||
if _, err := a.Check(teamMember("k2", "t2", rpm), "free", 1); err != nil {
|
||||
t.Errorf("a second team should have its own counter, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKeyLimitStaysPerKey(t *testing.T) {
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
perKey := func(id string) auth.Principal {
|
||||
return principal(id, func(e *store.Effective) {
|
||||
e.RPM = intp(1)
|
||||
e.RPMOwner = store.LimitOwner{Scope: store.ScopeKey, ID: id}
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := a.Check(perKey("k1"), "free", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The tree still has to be able to say "each of you may do this much".
|
||||
// Sharing a group pool must not become the only thing expressible.
|
||||
if _, err := a.Check(perKey("k2"), "free", 1); err != nil {
|
||||
t.Errorf("a limit owned by the key should not be shared, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDifferentLimitsCanBeOwnedByDifferentScopes(t *testing.T) {
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
// A team budget with a per-key rate limit is the ordinary arrangement: the
|
||||
// group's money, the individual's throughput.
|
||||
mk := func(id string) auth.Principal {
|
||||
e := store.Effective{KeyID: id, TeamID: "t1"}
|
||||
e.Budget, e.BudgetPeriod = i64p(3000), store.PeriodMonth
|
||||
e.BudgetOwner = store.LimitOwner{Scope: store.ScopeTeam, ID: "t1"}
|
||||
e.RPM = intp(1)
|
||||
e.RPMOwner = store.LimitOwner{Scope: store.ScopeKey, ID: id}
|
||||
return auth.Principal{KeyID: id, Limits: e}
|
||||
}
|
||||
|
||||
res, err := a.Check(mk("k1"), "paid", 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 1000, CompletionTokens: 1000})
|
||||
|
||||
// k2's rate limit is its own and untouched; the budget is the team's and
|
||||
// gone. The budget is checked first, so that is what comes back.
|
||||
if _, err := a.Check(mk("k2"), "paid", 10); !errors.Is(err, ErrBudgetSpent) {
|
||||
t.Errorf("expected the shared budget to bite, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusReportsTheCounterThatWillRefuseYou(t *testing.T) {
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
budget := func(e *store.Effective) {
|
||||
e.Budget = i64p(9000)
|
||||
e.BudgetPeriod = store.PeriodMonth
|
||||
}
|
||||
res, err := a.Check(teamMember("k1", "t1", budget), "paid", 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 1000, CompletionTokens: 1000})
|
||||
|
||||
// A member who has spent nothing themselves still sees the team's spend,
|
||||
// because that is the number that will stop them. A header disagreeing with
|
||||
// the refusal it precedes is worse than no header.
|
||||
spent, _, _ := a.Status(teamMember("k2", "t1", budget))
|
||||
if spent == 0 {
|
||||
t.Error("a member should see the pool's spend, not their own")
|
||||
}
|
||||
}
|
||||
|
||||
func TestATeamBudgetSurvivesARestart(t *testing.T) {
|
||||
// The per-key restore could not do this: it rebuilt a counter named after
|
||||
// the key, while the check reads one named after the team. The control
|
||||
// plane came back believing the team had spent nothing — a budget that
|
||||
// resets on every restart, for exactly the scope where it matters most.
|
||||
ctx := context.Background()
|
||||
s, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "team.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
must(t, s.PutOrg(ctx, store.Org{ID: "o", Name: "o"}))
|
||||
must(t, s.PutTeam(ctx, store.Team{ID: "t1", OrgID: "o", Name: "t"}))
|
||||
must(t, s.PutProject(ctx, store.Project{ID: "proj", TeamID: "t1", Name: "p"}))
|
||||
for _, id := range []string{"k1", "k2"} {
|
||||
_, k, _ := store.NewKey(id, "n", "proj")
|
||||
k.CreatedAt = time.Now()
|
||||
must(t, s.CreateKey(ctx, k))
|
||||
}
|
||||
|
||||
// Two members of one team, 2500 each: neither alone exceeds a 4000 budget,
|
||||
// together they do.
|
||||
must(t, s.WriteUsage(ctx, []store.Usage{
|
||||
{At: time.Now(), KeyID: "k1", ProjectID: "proj", Model: "paid",
|
||||
ServedModel: "paid", CostMicros: 2500, Status: 200},
|
||||
{At: time.Now(), KeyID: "k2", ProjectID: "proj", Model: "paid",
|
||||
ServedModel: "paid", CostMicros: 2500, Status: 200},
|
||||
}))
|
||||
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
must(t, a.RestoreBudgets(ctx, s, store.PeriodMonth))
|
||||
|
||||
p := teamMember("k1", "t1", func(e *store.Effective) {
|
||||
e.Budget = i64p(4000)
|
||||
e.BudgetPeriod = store.PeriodMonth
|
||||
})
|
||||
if _, err := a.Check(p, "paid", 10); !errors.Is(err, ErrBudgetSpent) {
|
||||
t.Errorf("the team's spend should have been restored and pooled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// inTree is a key under org/team/project, with whatever each level sets.
|
||||
func inTree(keyID string, chain ...store.Limits) auth.Principal {
|
||||
e := store.Effective{KeyID: keyID, Chain: chain}
|
||||
// The collapsed values still exist for headers and the profile page; they
|
||||
// are not what enforcement reads any more.
|
||||
for _, l := range chain {
|
||||
if l.Budget != nil && (e.Budget == nil || *l.Budget < *e.Budget) {
|
||||
e.Budget, e.BudgetPeriod = l.Budget, l.BudgetPeriod
|
||||
e.BudgetOwner = store.LimitOwner{Scope: l.Scope, ID: l.ScopeID}
|
||||
}
|
||||
}
|
||||
return auth.Principal{KeyID: keyID, Limits: e}
|
||||
}
|
||||
|
||||
func orgBudget(id string, v int64) store.Limits {
|
||||
return store.Limits{Scope: store.ScopeOrg, ScopeID: id,
|
||||
Budget: i64p(v), BudgetPeriod: store.PeriodMonth}
|
||||
}
|
||||
|
||||
func teamBudget(id string, v int64) store.Limits {
|
||||
return store.Limits{Scope: store.ScopeTeam, ScopeID: id,
|
||||
Budget: i64p(v), BudgetPeriod: store.PeriodMonth}
|
||||
}
|
||||
|
||||
func TestTheSumOfTeamsCannotExceedTheOrganization(t *testing.T) {
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
|
||||
// Each request costs 800. The organization allows 2000 and each of its four
|
||||
// teams allows 2000, so no team comes close to its own ceiling — but the
|
||||
// organization's fills after three. Taking only the most restrictive value,
|
||||
// the organization's number was never looked at and all four went through.
|
||||
//
|
||||
// A budget stops spending once it is reached rather than refusing the
|
||||
// request that would cross it, so the third is served and the fourth is not.
|
||||
spend := func(team string) error {
|
||||
p := inTree(team+"-k", orgBudget("o", 2000), teamBudget(team, 2000))
|
||||
res, err := a.Check(p, "paid", 1000)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.Record(res, store.Usage{KeyID: team + "-k", PromptTokens: 200, CompletionTokens: 200})
|
||||
return nil
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, team := range []string{"t1", "t2", "t3", "t4"} {
|
||||
if err := spend(team); err == nil {
|
||||
total++
|
||||
}
|
||||
}
|
||||
if total != 3 {
|
||||
t.Errorf("expected the organization to stop the fourth team, %d got through", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATeamStillStopsBeforeItsOrganizationDoes(t *testing.T) {
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
p := func() auth.Principal {
|
||||
return inTree("k1", orgBudget("o", 100_000), teamBudget("t1", 800))
|
||||
}
|
||||
|
||||
res, err := a.Check(p(), "paid", 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 200, CompletionTokens: 200})
|
||||
|
||||
// The narrower pool binds first, which is what it is for. Enforcing every
|
||||
// level must not stop the tighter one working.
|
||||
if _, err := a.Check(p(), "paid", 10); !errors.Is(err, ErrBudgetSpent) {
|
||||
t.Errorf("the team's own budget should still bite, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpendingFillsEveryPoolAboveIt(t *testing.T) {
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
p := inTree("k1", orgBudget("o", 5000), teamBudget("t1", 5000))
|
||||
|
||||
res, err := a.Check(p, "paid", 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 200, CompletionTokens: 200})
|
||||
|
||||
// Settled onto both, or the organization's pool would only ever fill from
|
||||
// requests that named it directly — which is none of them.
|
||||
if got := a.budgets.Spent("org:o", store.PeriodMonth); got == 0 {
|
||||
t.Error("the organization's pool did not fill")
|
||||
}
|
||||
if got := a.budgets.Spent("team:t1", store.PeriodMonth); got == 0 {
|
||||
t.Error("the team's pool did not fill")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefusalHandsBackWhatTheNarrowerLevelsTook(t *testing.T) {
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
// The team allows plenty; the organization allows one request a minute.
|
||||
chain := []store.Limits{
|
||||
{Scope: store.ScopeOrg, ScopeID: "o", RPM: intp(1)},
|
||||
{Scope: store.ScopeTeam, ScopeID: "t1", RPM: intp(100)},
|
||||
}
|
||||
if _, err := a.Check(inTree("k1", chain...), "free", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := a.requests.Rate("team:t1")
|
||||
|
||||
if _, err := a.Check(inTree("k2", chain...), "free", 1); !errors.Is(err, ErrRateLimited) {
|
||||
t.Fatalf("the organization should have refused the second, got %v", err)
|
||||
}
|
||||
|
||||
// A rate limiter charges as it checks, so a refusal further along has
|
||||
// already taken from the levels before it. Left there, a team at its
|
||||
// organization's ceiling would quietly eat its own rate limit with requests
|
||||
// that were never served.
|
||||
if after := a.requests.Rate("team:t1"); after != before {
|
||||
t.Errorf("the refused request stayed charged to the team: %v -> %v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAChainWithNoLimitsAnywhereAllowsEverything(t *testing.T) {
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := a.Check(inTree("k1"), "paid", 1000); err != nil {
|
||||
t.Fatalf("nothing is limited, but request %d was refused: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusReportsTheFullestPoolNotTheSmallestNumber(t *testing.T) {
|
||||
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
|
||||
chain := func(team string) auth.Principal {
|
||||
return inTree(team+"-k", orgBudget("o", 9000), teamBudget(team, 9000))
|
||||
}
|
||||
|
||||
// Another team spends; this one has spent nothing of its own.
|
||||
res, err := a.Check(chain("t1"), "paid", 1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Record(res, store.Usage{KeyID: "t1-k", PromptTokens: 200, CompletionTokens: 200})
|
||||
|
||||
spent, _, _ := a.Status(chain("t2"))
|
||||
// The organization's pool is the one nearer its ceiling, so it is the one
|
||||
// that will refuse them. Reporting the team's nil would disagree with the
|
||||
// refusal that eventually follows it.
|
||||
if spent == 0 {
|
||||
t.Error("a member should see the fullest pool that bounds them")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyPriceTableMeansNoBudgetCanEverBind(t *testing.T) {
|
||||
// The ordinary case for self-hosted models, and the reason a budget on a
|
||||
// team can read as a limit while binding on nothing.
|
||||
if NewPricing(nil, Price{}).AnythingPriced() {
|
||||
t.Error("nothing priced should say so")
|
||||
}
|
||||
if NewPricing(map[string]Price{"m": {}}, Price{}).AnythingPriced() {
|
||||
t.Error("a model priced at zero is not priced")
|
||||
}
|
||||
if !NewPricing(map[string]Price{"m": {PromptPer1k: 1}}, Price{}).AnythingPriced() {
|
||||
t.Error("one priced model is enough for a budget to move")
|
||||
}
|
||||
if !NewPricing(nil, Price{CompletionPer1k: 1}).AnythingPriced() {
|
||||
t.Error("a fallback price prices everything")
|
||||
}
|
||||
var nilPricing *Pricing
|
||||
if nilPricing.AnythingPriced() {
|
||||
t.Error("no price table at all is not priced")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package accounting
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// PeriodStart is when the current budget period began.
|
||||
//
|
||||
// Periods are anchored to UTC rather than to a per-key start date, so every
|
||||
// key's day and month boundary is the same instant. A key whose period started
|
||||
// when it was created would make "spend this month" mean something different
|
||||
// for every key, and make the restore query unanswerable in one pass.
|
||||
func PeriodStart(p store.Period, now time.Time) time.Time {
|
||||
u := now.UTC()
|
||||
switch p {
|
||||
case store.PeriodDay:
|
||||
return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC)
|
||||
case store.PeriodMonth:
|
||||
return time.Date(u.Year(), u.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
default:
|
||||
return time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
// Budgets tracks spend per key in memory, so a budget check costs no query.
|
||||
//
|
||||
// The database catches up through the batch writer. That means the in-memory
|
||||
// figure leads the stored one, which is the right direction: a budget that only
|
||||
// counted what had already been written would let a burst overspend by however
|
||||
// much the writer was behind.
|
||||
type Budgets struct {
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
spend map[string]counter
|
||||
}
|
||||
|
||||
type counter struct {
|
||||
micros int64
|
||||
since time.Time
|
||||
}
|
||||
|
||||
// NewBudgets builds an empty tracker.
|
||||
func NewBudgets() *Budgets {
|
||||
return &Budgets{now: time.Now, spend: map[string]counter{}}
|
||||
}
|
||||
|
||||
// Restore seeds the counters from what the store already recorded.
|
||||
//
|
||||
// A restart must not reset a budget, which it would if the counter lived only
|
||||
// in memory. The caller passes the spend since each period's start.
|
||||
func (b *Budgets) Restore(period store.Period, spend map[string]int64) {
|
||||
start := PeriodStart(period, b.now())
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for key, micros := range spend {
|
||||
b.spend[key] = counter{micros: micros, since: start}
|
||||
}
|
||||
}
|
||||
|
||||
// Spent returns a key's spend in the current period, resetting it if the period
|
||||
// has rolled over.
|
||||
func (b *Budgets) Spent(keyID string, period store.Period) int64 {
|
||||
start := PeriodStart(period, b.now())
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.currentLocked(keyID, start).micros
|
||||
}
|
||||
|
||||
// Add records spend.
|
||||
func (b *Budgets) Add(keyID string, period store.Period, micros int64) {
|
||||
if micros == 0 {
|
||||
return
|
||||
}
|
||||
start := PeriodStart(period, b.now())
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
c := b.currentLocked(keyID, start)
|
||||
c.micros += micros
|
||||
b.spend[keyID] = c
|
||||
}
|
||||
|
||||
// Exhausted reports whether a key has spent its budget.
|
||||
func (b *Budgets) Exhausted(keyID string, period store.Period, budget int64) bool {
|
||||
return b.Spent(keyID, period) >= budget
|
||||
}
|
||||
|
||||
// currentLocked returns the counter, rolled over if the period changed.
|
||||
func (b *Budgets) currentLocked(keyID string, start time.Time) counter {
|
||||
c, ok := b.spend[keyID]
|
||||
if !ok || c.since.Before(start) {
|
||||
// Either never seen, or the period rolled over while the process was
|
||||
// running. Both start from zero.
|
||||
c = counter{since: start}
|
||||
b.spend[keyID] = c
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Package accounting turns finished requests into spend, enforces what a key
|
||||
// is allowed, and gets both into the store without touching the request path
|
||||
// (plan §7.9.3, §7.9.4).
|
||||
package accounting
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Price is what one model costs, in micro-units per thousand tokens.
|
||||
//
|
||||
// Micro-units rather than a float because spend is accumulated by repeated
|
||||
// addition, and repeated addition of floats drifts. What a unit means is the
|
||||
// operator's: self-hosted models have no per-token price, so it may be
|
||||
// GPU-seconds, a notional rate, or 1000 everywhere — which turns a budget into
|
||||
// a token quota.
|
||||
type Price struct {
|
||||
PromptPer1k int64 `koanf:"prompt_per_1k"`
|
||||
CompletionPer1k int64 `koanf:"completion_per_1k"`
|
||||
}
|
||||
|
||||
// Zero reports whether this model spends nothing.
|
||||
//
|
||||
// It matters beyond arithmetic: a zero-cost model stays available to a key
|
||||
// whose budget is spent, which is what makes the plan's budget fallback work
|
||||
// (§7.7).
|
||||
func (p Price) Zero() bool { return p.PromptPer1k == 0 && p.CompletionPer1k == 0 }
|
||||
|
||||
// Cost computes what a request spent, rounding up.
|
||||
//
|
||||
// Up rather than to nearest: rounding down would make a stream of tiny requests
|
||||
// free, which is exactly the shape of traffic a quota exists to bound.
|
||||
func (p Price) Cost(promptTokens, completionTokens int64) int64 {
|
||||
return divUp(promptTokens*p.PromptPer1k, 1000) + divUp(completionTokens*p.CompletionPer1k, 1000)
|
||||
}
|
||||
|
||||
func divUp(n, d int64) int64 {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (n + d - 1) / d
|
||||
}
|
||||
|
||||
// Pricing resolves a model name to its price.
|
||||
type Pricing struct {
|
||||
mu sync.RWMutex
|
||||
byModel map[string]Price
|
||||
fallback Price
|
||||
}
|
||||
|
||||
// NewPricing builds a price table. Names are matched case-insensitively,
|
||||
// because a model name reaches us from whatever the client typed.
|
||||
func NewPricing(byModel map[string]Price, fallback Price) *Pricing {
|
||||
table := make(map[string]Price, len(byModel))
|
||||
for name, p := range byModel {
|
||||
table[strings.ToLower(name)] = p
|
||||
}
|
||||
return &Pricing{byModel: table, fallback: fallback}
|
||||
}
|
||||
|
||||
// For returns a model's price, or the fallback.
|
||||
func (p *Pricing) For(model string) Price {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if v, ok := p.byModel[strings.ToLower(model)]; ok {
|
||||
return v
|
||||
}
|
||||
return p.fallback
|
||||
}
|
||||
|
||||
// AnythingPriced reports whether any model costs anything.
|
||||
//
|
||||
// A budget is spend per period, and spend is computed from a price table. With
|
||||
// no prices every request costs nothing, so a budget written on a team is a
|
||||
// number that can never move — displayed as a limit, reported as zero percent
|
||||
// used, and binding on nothing.
|
||||
//
|
||||
// Self-hosted models have no per-token price, so an empty table is the ordinary
|
||||
// case rather than a misconfiguration. What is not ordinary is showing somebody
|
||||
// a budget without saying so.
|
||||
func (p *Pricing) AnythingPriced() bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if !p.fallback.Zero() {
|
||||
return true
|
||||
}
|
||||
for _, price := range p.byModel {
|
||||
if !price.Zero() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package accounting
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Limiter enforces per-key rate limits.
|
||||
//
|
||||
// A sliding window rather than a fixed one. A fixed window lets a caller send
|
||||
// its whole minute's allowance in the last second of one window and again in
|
||||
// the first second of the next, so the observed rate is twice the configured
|
||||
// limit exactly when the system is least able to take it.
|
||||
//
|
||||
// The window is approximated from two buckets, which is the usual compromise:
|
||||
// exact enough that the doubling cannot happen, and bounded memory per key
|
||||
// rather than a timestamp per request.
|
||||
type Limiter struct {
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
keys map[string]*window
|
||||
}
|
||||
|
||||
type window struct {
|
||||
// current and previous are counts in this minute and the one before.
|
||||
current, previous int64
|
||||
start time.Time
|
||||
}
|
||||
|
||||
// NewLimiter builds an empty limiter.
|
||||
func NewLimiter() *Limiter {
|
||||
return &Limiter{now: time.Now, keys: map[string]*window{}}
|
||||
}
|
||||
|
||||
const windowLen = time.Minute
|
||||
|
||||
// Allow charges n against a key's limit and reports whether it fits.
|
||||
//
|
||||
// Charging and checking are one operation on purpose: doing them separately
|
||||
// lets two concurrent requests both see room and both take it.
|
||||
func (l *Limiter) Allow(key string, limit int, n int64) bool {
|
||||
if limit <= 0 {
|
||||
// No limit configured. Zero would be a limit of zero, which is why
|
||||
// callers pass a nil pointer rather than 0 for "unset".
|
||||
return true
|
||||
}
|
||||
now := l.now()
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
w := l.windowLocked(key, now)
|
||||
if w.rate(now)+float64(n) > float64(limit) {
|
||||
return false
|
||||
}
|
||||
w.current += n
|
||||
return true
|
||||
}
|
||||
|
||||
// Charge records usage that was already allowed, for reconciliation.
|
||||
//
|
||||
// Token limits are checked against an estimate before the request runs, because
|
||||
// the real figure only exists once it has. The difference is charged here, so a
|
||||
// caller whose requests are consistently larger than estimated is still bounded
|
||||
// — one window later than it would be with a perfect estimate.
|
||||
func (l *Limiter) Charge(key string, n int64) {
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
now := l.now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
w := l.windowLocked(key, now)
|
||||
w.current += n
|
||||
if w.current < 0 {
|
||||
w.current = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Rate returns a key's current observed rate.
|
||||
func (l *Limiter) Rate(key string) float64 {
|
||||
now := l.now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.windowLocked(key, now).rate(now)
|
||||
}
|
||||
|
||||
func (l *Limiter) windowLocked(key string, now time.Time) *window {
|
||||
w, ok := l.keys[key]
|
||||
if !ok {
|
||||
w = &window{start: now.Truncate(windowLen)}
|
||||
l.keys[key] = w
|
||||
}
|
||||
w.roll(now)
|
||||
return w
|
||||
}
|
||||
|
||||
// roll advances the window to now.
|
||||
func (w *window) roll(now time.Time) {
|
||||
start := now.Truncate(windowLen)
|
||||
switch {
|
||||
case start.Equal(w.start):
|
||||
return
|
||||
case start.Sub(w.start) == windowLen:
|
||||
w.previous, w.current = w.current, 0
|
||||
default:
|
||||
// More than one window has passed with no traffic.
|
||||
w.previous, w.current = 0, 0
|
||||
}
|
||||
w.start = start
|
||||
}
|
||||
|
||||
// rate weights the previous window by how much of it is still in view.
|
||||
func (w *window) rate(now time.Time) float64 {
|
||||
elapsed := now.Sub(w.start)
|
||||
if elapsed >= windowLen {
|
||||
return float64(w.current)
|
||||
}
|
||||
remaining := 1 - float64(elapsed)/float64(windowLen)
|
||||
return float64(w.previous)*remaining + float64(w.current)
|
||||
}
|
||||
|
||||
// Forget drops a key's window. Used when a key is revoked; keeping it would
|
||||
// leak memory for every key that ever called.
|
||||
func (l *Limiter) Forget(key string) {
|
||||
l.mu.Lock()
|
||||
delete(l.keys, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package accounting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// RecorderConfig tunes the batch writer.
|
||||
type RecorderConfig struct {
|
||||
// FlushInterval and BatchSize decide when a batch is written. Whichever
|
||||
// comes first.
|
||||
FlushInterval time.Duration
|
||||
BatchSize int
|
||||
// Capacity bounds the buffer. Beyond it, records are dropped rather than
|
||||
// blocking a request — see Record.
|
||||
Capacity int
|
||||
}
|
||||
|
||||
// DefaultRecorderConfig returns sensible values.
|
||||
func DefaultRecorderConfig() RecorderConfig {
|
||||
return RecorderConfig{FlushInterval: 200 * time.Millisecond, BatchSize: 500, Capacity: 8192}
|
||||
}
|
||||
|
||||
func (c *RecorderConfig) normalize() {
|
||||
d := DefaultRecorderConfig()
|
||||
if c.FlushInterval <= 0 {
|
||||
c.FlushInterval = d.FlushInterval
|
||||
}
|
||||
if c.BatchSize <= 0 {
|
||||
c.BatchSize = d.BatchSize
|
||||
}
|
||||
if c.Capacity <= 0 {
|
||||
c.Capacity = d.Capacity
|
||||
}
|
||||
}
|
||||
|
||||
// Toucher is the part of the authenticator the recorder flushes alongside
|
||||
// usage, so last-used timestamps ride the same transaction schedule.
|
||||
type Toucher interface {
|
||||
FlushTouches(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Recorder batches usage records into the store.
|
||||
//
|
||||
// The buffered channel is the whole design. A request must never wait on a
|
||||
// database write, so Record is a non-blocking send and the writer drains it on
|
||||
// its own schedule.
|
||||
type Recorder struct {
|
||||
cfg RecorderConfig
|
||||
store store.Store
|
||||
touch Toucher
|
||||
log *slog.Logger
|
||||
|
||||
ch chan store.Usage
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
mu sync.Mutex
|
||||
dropped int64
|
||||
written int64
|
||||
}
|
||||
|
||||
// NewRecorder builds a recorder. Start begins writing.
|
||||
func NewRecorder(s store.Store, touch Toucher, cfg RecorderConfig, log *slog.Logger) *Recorder {
|
||||
cfg.normalize()
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Recorder{
|
||||
cfg: cfg, store: s, touch: touch, log: log,
|
||||
ch: make(chan store.Usage, cfg.Capacity),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Record queues one request's accounting.
|
||||
//
|
||||
// Never blocks. If the buffer is full — a database stalled long enough to fill
|
||||
// eight thousand records — the record is dropped and counted. Losing some
|
||||
// accounting is bad; making every request wait on a stalled database is worse,
|
||||
// because it converts a reporting problem into an outage.
|
||||
func (r *Recorder) Record(u store.Usage) {
|
||||
select {
|
||||
case r.ch <- u:
|
||||
default:
|
||||
r.mu.Lock()
|
||||
r.dropped++
|
||||
n := r.dropped
|
||||
r.mu.Unlock()
|
||||
if n == 1 || n%1000 == 0 {
|
||||
r.log.Warn("dropping usage records: the store is not keeping up",
|
||||
"dropped_total", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start runs the writer until ctx ends.
|
||||
func (r *Recorder) Start(ctx context.Context) {
|
||||
r.wg.Add(1)
|
||||
go func() {
|
||||
defer r.wg.Done()
|
||||
defer close(r.done)
|
||||
r.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop waits for the writer to drain and finish.
|
||||
func (r *Recorder) Stop() { r.wg.Wait() }
|
||||
|
||||
func (r *Recorder) run(ctx context.Context) {
|
||||
t := time.NewTicker(r.cfg.FlushInterval)
|
||||
defer t.Stop()
|
||||
|
||||
batch := make([]store.Usage, 0, r.cfg.BatchSize)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Drain what is already queued before leaving. These are completed
|
||||
// requests that have already been served; throwing them away would
|
||||
// mean a clean shutdown silently loses the last moments of billing.
|
||||
r.drain(&batch)
|
||||
r.flush(context.WithoutCancel(ctx), batch)
|
||||
return
|
||||
|
||||
case u := <-r.ch:
|
||||
batch = append(batch, u)
|
||||
if len(batch) >= r.cfg.BatchSize {
|
||||
r.flush(ctx, batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
|
||||
case <-t.C:
|
||||
r.drain(&batch)
|
||||
if len(batch) > 0 {
|
||||
r.flush(ctx, batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
if err := r.touchFlush(ctx); err != nil {
|
||||
r.log.Warn("failed to write last-used timestamps", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drain moves everything queued into the batch without blocking.
|
||||
func (r *Recorder) drain(batch *[]store.Usage) {
|
||||
for len(*batch) < r.cfg.BatchSize {
|
||||
select {
|
||||
case u := <-r.ch:
|
||||
*batch = append(*batch, u)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Recorder) flush(ctx context.Context, batch []store.Usage) {
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
if err := r.store.WriteUsage(ctx, batch); err != nil {
|
||||
// The records are gone either way; what matters is that this is loud.
|
||||
// Silently losing billing data is the kind of thing discovered at the
|
||||
// end of a month.
|
||||
r.log.Error("failed to write usage records", "count", len(batch), "err", err)
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.written += int64(len(batch))
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *Recorder) touchFlush(ctx context.Context) error {
|
||||
if r.touch == nil {
|
||||
return nil
|
||||
}
|
||||
return r.touch.FlushTouches(ctx)
|
||||
}
|
||||
|
||||
// Stats reports what the recorder has done, for metrics and tests.
|
||||
func (r *Recorder) Stats() (written, dropped int64) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.written, r.dropped
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package accounting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// fakeStore records what was written and can be made slow or broken.
|
||||
type fakeStore struct {
|
||||
store.Store
|
||||
|
||||
mu sync.Mutex
|
||||
batches [][]store.Usage
|
||||
|
||||
block chan struct{}
|
||||
fail atomic.Bool
|
||||
touched atomic.Int64
|
||||
}
|
||||
|
||||
func (f *fakeStore) WriteUsage(_ context.Context, rows []store.Usage) error {
|
||||
if f.block != nil {
|
||||
<-f.block
|
||||
}
|
||||
if f.fail.Load() {
|
||||
return errors.New("store is down")
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.batches = append(f.batches, append([]store.Usage(nil), rows...))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) rows() []store.Usage {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []store.Usage
|
||||
for _, b := range f.batches {
|
||||
out = append(out, b...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fakeStore) batchCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.batches)
|
||||
}
|
||||
|
||||
type fakeToucher struct{ n *atomic.Int64 }
|
||||
|
||||
func (f fakeToucher) FlushTouches(context.Context) error {
|
||||
f.n.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRecordsAreBatchedNotWrittenOneByOne(t *testing.T) {
|
||||
// The whole point of the buffer: a hundred requests must not be a hundred
|
||||
// transactions.
|
||||
f := &fakeStore{}
|
||||
r := NewRecorder(f, nil, RecorderConfig{FlushInterval: 20 * time.Millisecond, BatchSize: 500},
|
||||
slog.New(slog.DiscardHandler))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.Start(ctx)
|
||||
|
||||
for i := range 100 {
|
||||
r.Record(store.Usage{KeyID: "k", PromptTokens: int64(i)})
|
||||
}
|
||||
waitFor(t, time.Second, func() bool { return len(f.rows()) == 100 })
|
||||
|
||||
if n := f.batchCount(); n > 5 {
|
||||
t.Errorf("100 records took %d transactions", n)
|
||||
}
|
||||
cancel()
|
||||
r.Stop()
|
||||
}
|
||||
|
||||
func TestRecordNeverBlocksARequest(t *testing.T) {
|
||||
// A stalled database must not become an outage. Losing accounting is bad;
|
||||
// making every request wait on the store is worse.
|
||||
f := &fakeStore{block: make(chan struct{})}
|
||||
r := NewRecorder(f, nil, RecorderConfig{
|
||||
FlushInterval: time.Millisecond, BatchSize: 1, Capacity: 8,
|
||||
}, slog.New(slog.DiscardHandler))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.Start(ctx)
|
||||
defer func() { close(f.block); cancel(); r.Stop() }()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for range 1000 {
|
||||
r.Record(store.Usage{KeyID: "k"})
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Record blocked while the store was stalled")
|
||||
}
|
||||
|
||||
_, dropped := r.Stats()
|
||||
if dropped == 0 {
|
||||
t.Error("the overflow was not counted, so nobody would ever know")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAShutdownDoesNotLoseWhatIsAlreadyQueued(t *testing.T) {
|
||||
// Those are completed requests that were already served. Throwing them away
|
||||
// means a clean shutdown silently loses the last moments of billing.
|
||||
f := &fakeStore{}
|
||||
r := NewRecorder(f, nil, RecorderConfig{FlushInterval: time.Hour, BatchSize: 10_000},
|
||||
slog.New(slog.DiscardHandler))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.Start(ctx)
|
||||
|
||||
for range 50 {
|
||||
r.Record(store.Usage{KeyID: "k"})
|
||||
}
|
||||
cancel()
|
||||
r.Stop()
|
||||
|
||||
if got := len(f.rows()); got != 50 {
|
||||
t.Errorf("a clean shutdown wrote %d of 50 queued records", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedWriteIsLoudRatherThanSilent(t *testing.T) {
|
||||
// Silently losing billing data is the kind of thing discovered at the end
|
||||
// of a month.
|
||||
f := &fakeStore{}
|
||||
f.fail.Store(true)
|
||||
logged := &countingHandler{}
|
||||
r := NewRecorder(f, nil, RecorderConfig{FlushInterval: 10 * time.Millisecond, BatchSize: 1},
|
||||
slog.New(logged))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.Start(ctx)
|
||||
r.Record(store.Usage{KeyID: "k"})
|
||||
|
||||
waitFor(t, time.Second, func() bool { return logged.count(slog.LevelError) > 0 })
|
||||
cancel()
|
||||
r.Stop()
|
||||
|
||||
if written, _ := r.Stats(); written != 0 {
|
||||
t.Errorf("a failed write was counted as %d written", written)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastUsedRidesTheSameSchedule(t *testing.T) {
|
||||
var n atomic.Int64
|
||||
f := &fakeStore{}
|
||||
r := NewRecorder(f, fakeToucher{n: &n}, RecorderConfig{FlushInterval: 10 * time.Millisecond},
|
||||
slog.New(slog.DiscardHandler))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.Start(ctx)
|
||||
waitFor(t, time.Second, func() bool { return n.Load() > 0 })
|
||||
cancel()
|
||||
r.Stop()
|
||||
}
|
||||
|
||||
type countingHandler struct {
|
||||
mu sync.Mutex
|
||||
levels []slog.Level
|
||||
}
|
||||
|
||||
func (c *countingHandler) Enabled(context.Context, slog.Level) bool { return true }
|
||||
func (c *countingHandler) WithAttrs([]slog.Attr) slog.Handler { return c }
|
||||
func (c *countingHandler) WithGroup(string) slog.Handler { return c }
|
||||
func (c *countingHandler) Handle(_ context.Context, r slog.Record) error {
|
||||
c.mu.Lock()
|
||||
c.levels = append(c.levels, r.Level)
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *countingHandler) count(l slog.Level) int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
n := 0
|
||||
for _, got := range c.levels {
|
||||
if got == l {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, limit time.Duration, pred func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(limit)
|
||||
for time.Now().Before(deadline) {
|
||||
if pred() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition never became true")
|
||||
}
|
||||
+924
-41
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
@@ -34,8 +35,10 @@ type statusRecorder struct {
|
||||
ch chan status
|
||||
|
||||
mu sync.Mutex
|
||||
calls []adoptCall
|
||||
adopted []reconciler.AdoptedShard
|
||||
unclaim []string // shard ids to report back as unclaimed
|
||||
fetches []fetchReport
|
||||
}
|
||||
|
||||
type status struct {
|
||||
@@ -53,9 +56,29 @@ func (r *statusRecorder) OnShardStatus(replicaID, shardID string, st supervisor.
|
||||
}
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Adopt(shards []reconciler.AdoptedShard) []reconciler.AdoptedShard {
|
||||
// fetches records download progress, so a test can assert that a fetch is
|
||||
// visible from the control plane rather than only in the node's log.
|
||||
func (r *statusRecorder) OnFetchProgress(replicaID, shardID, source string, done, total int64) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.fetches = append(r.fetches, fetchReport{replicaID, shardID, source, done, total})
|
||||
}
|
||||
|
||||
func (r *statusRecorder) fetchReports() []fetchReport {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]fetchReport(nil), r.fetches...)
|
||||
}
|
||||
|
||||
type fetchReport struct {
|
||||
replicaID, shardID, source string
|
||||
done, total int64
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Adopt(nodeID string, shards []reconciler.AdoptedShard) []reconciler.AdoptedShard {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls = append(r.calls, adoptCall{nodeID: nodeID, shards: shards})
|
||||
r.adopted = append(r.adopted, shards...)
|
||||
|
||||
var unclaimed []reconciler.AdoptedShard
|
||||
@@ -67,6 +90,19 @@ func (r *statusRecorder) Adopt(shards []reconciler.AdoptedShard) []reconciler.Ad
|
||||
return unclaimed
|
||||
}
|
||||
|
||||
// adoptCall records that the service reached the reconciler at all, which an
|
||||
// empty shard list used to prevent.
|
||||
type adoptCall struct {
|
||||
nodeID string
|
||||
shards []reconciler.AdoptedShard
|
||||
}
|
||||
|
||||
func (r *statusRecorder) adoptCalls() []adoptCall {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return slices.Clone(r.calls)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) adoptedShards() []reconciler.AdoptedShard {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -208,7 +244,7 @@ func startWith(t *testing.T, log *slog.Logger, cfgFns ...func(*Config)) *harness
|
||||
|
||||
inv := inventory.NewStore()
|
||||
rec := newRecorder()
|
||||
svc := agentsvc.New(inv, rec, log)
|
||||
svc := agentsvc.New(inv, rec, false, log)
|
||||
|
||||
path, handler := svc.Handler()
|
||||
mux := http.NewServeMux()
|
||||
@@ -285,8 +321,17 @@ func (h *harness) waitRegistered(t *testing.T) {
|
||||
}
|
||||
|
||||
// shardCommand builds a single-node vLLM shard on a free port.
|
||||
//
|
||||
// The model path is a real file. It used to be "/models/x", which stopped
|
||||
// working once the agent began checking that weights exist before launching an
|
||||
// engine — the check doing exactly what it is for, on a test that was relying
|
||||
// on nobody looking.
|
||||
func shardCommand(t *testing.T, shardID string) reconciler.ShardCommand {
|
||||
t.Helper()
|
||||
modelPath := filepath.Join(t.TempDir(), "model.gguf")
|
||||
if err := os.WriteFile(modelPath, []byte("weights"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reconciler.ShardCommand{
|
||||
ReplicaID: "rep-1", ShardID: shardID, NodeID: "n1",
|
||||
Spec: engines.ShardSpec{
|
||||
@@ -294,7 +339,7 @@ func shardCommand(t *testing.T, shardID string) reconciler.ShardCommand {
|
||||
GPUIndices: []int{0}, HTTPPort: freePort(t),
|
||||
Profile: engines.Profile{
|
||||
Name: "p", Engine: "vllm", TP: 1,
|
||||
ModelPath: "/models/x", ServedModelName: "test-model",
|
||||
ModelPath: modelPath, ServedModelName: "test-model",
|
||||
},
|
||||
},
|
||||
StartupTimeout: 15 * time.Second,
|
||||
@@ -312,9 +357,15 @@ func freePort(t *testing.T) int {
|
||||
}
|
||||
|
||||
func TestRegistrationCarriesInventory(t *testing.T) {
|
||||
// The node must actually have the adapters it declares. It used to declare
|
||||
// mlx5_0 and mlx5_1 on a node whose sysfs held neither, which passed
|
||||
// because nothing compared the two — and that is the gap a real cluster
|
||||
// would have found as a replica hanging on its first all-reduce.
|
||||
ibRoot := ibTreeWithActivePorts(t, "mlx5_0", "mlx5_1")
|
||||
h := start(t, func(c *Config) {
|
||||
c.Hostname = "gpu-a-01"
|
||||
c.Labels = map[string]string{"rack": "r12"}
|
||||
c.Discovery.IBRoot = ibRoot
|
||||
c.Fabrics = []inventory.FabricAttachment{
|
||||
{Fabric: "ib-a", HCAs: []string{"mlx5_0", "mlx5_1"}, RateGbps: 200},
|
||||
}
|
||||
@@ -598,3 +649,110 @@ func TestUnclaimedShardIsStopped(t *testing.T) {
|
||||
func processAliveForTest(pid int) bool {
|
||||
return syscall.Kill(pid, 0) == nil
|
||||
}
|
||||
|
||||
func TestAShardWithMissingWeightsFailsBeforeLaunching(t *testing.T) {
|
||||
// The commonest setup mistake on a fresh node. Left to the engine it
|
||||
// surfaces minutes into a load, in the engine's own words, on a machine
|
||||
// nobody is watching — so it is caught here, where the message can say
|
||||
// what is actually wrong.
|
||||
a := &Agent{cfg: Config{WeightsCache: t.TempDir()}, log: slog.New(slog.DiscardHandler)}
|
||||
_, err := a.weights().Ensure(t.Context(), filepath.Join(t.TempDir(), "not-there.gguf"))
|
||||
if err == nil {
|
||||
t.Fatal("a missing weights file was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not-there.gguf") {
|
||||
t.Errorf("the error does not name the file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCacheDefaultsToTheFirstModelDirectory(t *testing.T) {
|
||||
// A node that has said where its models live has already answered this;
|
||||
// asking twice is a second thing to get out of step.
|
||||
dir := t.TempDir()
|
||||
a := &Agent{cfg: Config{ModelDirs: []string{dir, "/elsewhere"}}, log: slog.New(slog.DiscardHandler)}
|
||||
if got := a.weights().CacheDir; got != dir {
|
||||
t.Errorf("cache = %q, want the first model directory %q", got, dir)
|
||||
}
|
||||
}
|
||||
|
||||
// ibTreeWithActivePorts builds a sysfs InfiniBand tree with the named adapters,
|
||||
// each with one active port, shaped like the real thing.
|
||||
func ibTreeWithActivePorts(t *testing.T, names ...string) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, name := range names {
|
||||
dir := filepath.Join(root, name, "ports", "1")
|
||||
if err := os.MkdirAll(filepath.Join(dir, "gids"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for file, content := range map[string]string{
|
||||
"state": "4: ACTIVE",
|
||||
"phys_state": "5: LinkUp",
|
||||
"rate": "200 Gb/sec (4X HDR)",
|
||||
"link_layer": "InfiniBand",
|
||||
"lid": "0x1a",
|
||||
"gids/0": "fec0:0000:0000:000a:0002:c903:00a0:1234",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(dir, file), []byte(content+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestAFabricWhoseAdaptersAreAbsentIsNotAdvertised(t *testing.T) {
|
||||
// The failure this prevents: a typo, or a card that enumerated differently
|
||||
// after a reboot, produced NCCL_IB_HCA naming an adapter that is not there.
|
||||
// The replica starts, passes its health checks, and hangs on its first
|
||||
// all-reduce — the exact case the fabric invariant exists to prevent,
|
||||
// arriving through a door nobody was watching.
|
||||
h := start(t, func(c *Config) {
|
||||
c.Discovery.IBRoot = ibTreeWithActivePorts(t, "mlx5_0")
|
||||
c.Fabrics = []inventory.FabricAttachment{
|
||||
{Fabric: "ib-typo", HCAs: []string{"mlx5_9"}, RateGbps: 200},
|
||||
}
|
||||
})
|
||||
|
||||
n, ok := h.inv.Get("n1")
|
||||
if !ok {
|
||||
t.Fatal("node missing from inventory")
|
||||
}
|
||||
if n.HasFabric("ib-typo") {
|
||||
t.Errorf("a fabric with no usable adapter was advertised: %+v", n.Fabrics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFabricRateIsTheSlowestActiveLink(t *testing.T) {
|
||||
// A collective runs at the speed of its slowest link, so reporting the
|
||||
// fastest would have the scheduler believe a fabric is quicker than it is.
|
||||
root := t.TempDir()
|
||||
for name, rate := range map[string]string{"mlx5_0": "200", "mlx5_1": "100"} {
|
||||
dir := filepath.Join(root, name, "ports", "1")
|
||||
if err := os.MkdirAll(filepath.Join(dir, "gids"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for file, content := range map[string]string{
|
||||
"state": "4: ACTIVE", "phys_state": "5: LinkUp",
|
||||
"rate": rate + " Gb/sec (4X HDR)", "link_layer": "InfiniBand",
|
||||
"lid": "0x1a", "gids/0": "fec0::1",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(dir, file), []byte(content+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
h := start(t, func(c *Config) {
|
||||
c.Discovery.IBRoot = root
|
||||
c.Fabrics = []inventory.FabricAttachment{
|
||||
{Fabric: "ib-a", HCAs: []string{"mlx5_0", "mlx5_1"}},
|
||||
}
|
||||
})
|
||||
|
||||
n, _ := h.inv.Get("n1")
|
||||
for _, f := range n.Fabrics {
|
||||
if f.Fabric == "ib-a" && f.RateGbps != 100 {
|
||||
t.Errorf("fabric rate = %d, want the slower link's 100", f.RateGbps)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,11 @@ type Options struct {
|
||||
// PreferNVML uses the NVML backend when this binary has one. It is the only
|
||||
// source of NVLink topology.
|
||||
PreferNVML bool `koanf:"prefer_nvml"`
|
||||
// SkipGPUs omits GPU discovery, for nodes that have none.
|
||||
// SkipGPUs omits GPU discovery. A node without nvidia-smi is already
|
||||
// found to have none, so this is for one whose driver is broken.
|
||||
SkipGPUs bool `koanf:"skip_gpus"`
|
||||
// ModelDirs are scanned for weights. Empty means do not look.
|
||||
ModelDirs []string `koanf:"-"`
|
||||
}
|
||||
|
||||
// Discover builds the node's inventory.
|
||||
@@ -39,6 +42,19 @@ func Discover(ctx context.Context, opts Options, log *slog.Logger) (Inventory, e
|
||||
log = slog.Default()
|
||||
}
|
||||
inv := Inventory{Source: "none"}
|
||||
// First, because it has nothing to do with GPUs and every path below can
|
||||
// return early. Read after them, a node with no nvidia-smi reported no
|
||||
// memory at all: GPU discovery fails, Discover returns, and the fact that
|
||||
// has nothing to do with cards goes with it. That is exactly the node whose
|
||||
// memory matters most, since a CPU profile is what it will be asked to run.
|
||||
inv.MemoryTotalMB = MemoryTotalMB()
|
||||
// Also before the GPU paths, and for the same reason: which weights a node
|
||||
// holds has nothing to do with its cards, and a node with none is exactly
|
||||
// where a CPU profile will look for them.
|
||||
inv.Weights = ScanWeights(opts.ModelDirs)
|
||||
// And the disk they sit on. A node can hold a model today and be unable to
|
||||
// take the next one, which the file list alone never shows.
|
||||
inv.Storage = ScanStorage(opts.ModelDirs)
|
||||
|
||||
if !opts.SkipGPUs {
|
||||
switch {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -99,3 +100,20 @@ func TestNVMLSupportedMatchesBuild(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryIsReportedEvenWhenGPUDiscoveryFails(t *testing.T) {
|
||||
// A node with no nvidia-smi is exactly the node whose memory matters, since
|
||||
// a CPU profile is what it will be asked to run. Reading memory after GPU
|
||||
// discovery meant that node reported none: the GPU error returns early and
|
||||
// takes a fact that has nothing to do with cards with it. Seen on node05,
|
||||
// which has 32 GB and was reporting "-".
|
||||
inv, err := Discover(t.Context(), Options{NvidiaSMI: "/nonexistent/nvidia-smi"}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("GPU discovery was expected to fail with no nvidia-smi")
|
||||
}
|
||||
// On Linux this is a real number; elsewhere there is no /proc/meminfo and 0
|
||||
// is the honest answer. Either way it must not be lost to the GPU error.
|
||||
if runtime.GOOS == "linux" && inv.MemoryTotalMB <= 0 {
|
||||
t.Errorf("memory was not reported alongside a GPU failure: %d", inv.MemoryTotalMB)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FabricCheck is one declared fabric measured against what the node actually
|
||||
// has.
|
||||
type FabricCheck struct {
|
||||
Fabric string
|
||||
// Usable are the declared adapters that exist and have an active port.
|
||||
Usable []string
|
||||
// RateGbps is the slowest active port among them, because a collective
|
||||
// runs at the speed of its slowest link.
|
||||
RateGbps int
|
||||
// Problems are the declared adapters that cannot carry traffic, and why.
|
||||
Problems []string
|
||||
}
|
||||
|
||||
// OK reports whether this fabric can carry a collective at all.
|
||||
func (c FabricCheck) OK() bool { return len(c.Usable) > 0 }
|
||||
|
||||
// CheckFabrics measures declared fabric membership against discovered adapters.
|
||||
//
|
||||
// Fabric membership has to be configuration — OpenSM's default subnet prefix is
|
||||
// the same on every fabric, so a matching prefix proves nothing — but taking it
|
||||
// entirely on trust was a mistake of a different kind. A typo, or a card that
|
||||
// enumerated differently after a reboot, produced NCCL_IB_HCA naming an adapter
|
||||
// that does not exist; and an adapter whose cable is out, or whose subnet
|
||||
// manager is down, is present in sysfs and carries nothing.
|
||||
//
|
||||
// Either way the replica starts, passes its health checks, and hangs on its
|
||||
// first all-reduce — which is the failure the whole fabric invariant exists to
|
||||
// prevent, arriving through a door nobody was watching. Declared and real are
|
||||
// now compared where both are visible, which is only here.
|
||||
func CheckFabrics(declared map[string][]string, discovered []HCA) []FabricCheck {
|
||||
byName := make(map[string]HCA, len(discovered))
|
||||
for _, h := range discovered {
|
||||
byName[h.Name] = h
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(declared))
|
||||
for f := range declared {
|
||||
names = append(names, f)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
out := make([]FabricCheck, 0, len(names))
|
||||
for _, fabric := range names {
|
||||
c := FabricCheck{Fabric: fabric}
|
||||
for _, want := range declared[fabric] {
|
||||
hca, found := byName[want]
|
||||
if !found {
|
||||
c.Problems = append(c.Problems, fmt.Sprintf(
|
||||
"%s is not on this node (found: %s)", want, listNames(discovered)))
|
||||
continue
|
||||
}
|
||||
rate, active := activeRate(hca)
|
||||
if !active {
|
||||
c.Problems = append(c.Problems, fmt.Sprintf(
|
||||
"%s has no active port (%s)", want, portStates(hca)))
|
||||
continue
|
||||
}
|
||||
c.Usable = append(c.Usable, want)
|
||||
if c.RateGbps == 0 || rate < c.RateGbps {
|
||||
c.RateGbps = rate
|
||||
}
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// activeRate returns the fastest active port's rate on an adapter.
|
||||
//
|
||||
// Only ACTIVE ports carry traffic. A port that is physically linked but has no
|
||||
// subnet manager sits in INIT, looks healthy to anything counting adapters, and
|
||||
// passes no data — which is precisely the case worth catching, because it
|
||||
// survives a reboot and looks like a software problem.
|
||||
func activeRate(h HCA) (int, bool) {
|
||||
best, ok := 0, false
|
||||
for _, p := range h.Ports {
|
||||
if !strings.EqualFold(p.State, "ACTIVE") {
|
||||
continue
|
||||
}
|
||||
ok = true
|
||||
if p.RateGbps > best {
|
||||
best = p.RateGbps
|
||||
}
|
||||
}
|
||||
return best, ok
|
||||
}
|
||||
|
||||
func portStates(h HCA) string {
|
||||
if len(h.Ports) == 0 {
|
||||
return "no ports"
|
||||
}
|
||||
var parts []string
|
||||
for _, p := range h.Ports {
|
||||
parts = append(parts, fmt.Sprintf("port %d %s/%s", p.Num, p.State, p.PhysState))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func listNames(hcas []HCA) string {
|
||||
if len(hcas) == 0 {
|
||||
return "none"
|
||||
}
|
||||
var names []string
|
||||
for _, h := range hcas {
|
||||
names = append(names, h.Name)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func hca(name string, ports ...Port) HCA { return HCA{Name: name, Ports: ports} }
|
||||
|
||||
func active(rate int) Port {
|
||||
return Port{Num: 1, State: "ACTIVE", PhysState: "LinkUp", RateGbps: rate, LinkLayer: LinkInfiniBand}
|
||||
}
|
||||
|
||||
func TestADeclaredAdapterThatIsNotThereIsReported(t *testing.T) {
|
||||
// A typo, or a card that enumerated differently after a reboot. Taken on
|
||||
// trust it becomes NCCL_IB_HCA naming an adapter that does not exist, and
|
||||
// the replica hangs on its first all-reduce.
|
||||
got := CheckFabrics(
|
||||
map[string][]string{"ib-a": {"mlx5_0", "mlx5_9"}},
|
||||
[]HCA{hca("mlx5_0", active(200))},
|
||||
)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d checks", len(got))
|
||||
}
|
||||
c := got[0]
|
||||
if len(c.Usable) != 1 || c.Usable[0] != "mlx5_0" {
|
||||
t.Errorf("usable = %v, want only the adapter that exists", c.Usable)
|
||||
}
|
||||
if len(c.Problems) != 1 || !strings.Contains(c.Problems[0], "mlx5_9") {
|
||||
t.Errorf("problems = %v", c.Problems)
|
||||
}
|
||||
// The message names what is actually present, because the operator is
|
||||
// looking at a typo and cannot see the node.
|
||||
if !strings.Contains(c.Problems[0], "mlx5_0") {
|
||||
t.Errorf("the problem does not say what was found: %q", c.Problems[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAdapterWithNoActivePortCannotCarryTraffic(t *testing.T) {
|
||||
// A port that is physically linked but has no subnet manager sits in INIT.
|
||||
// It looks healthy to anything counting adapters and passes no data, which
|
||||
// is the case worth catching: it survives a reboot and looks like software.
|
||||
got := CheckFabrics(
|
||||
map[string][]string{"ib-a": {"mlx5_0"}},
|
||||
[]HCA{hca("mlx5_0", Port{Num: 1, State: "INIT", PhysState: "LinkUp", RateGbps: 200})},
|
||||
)
|
||||
c := got[0]
|
||||
if c.OK() {
|
||||
t.Error("a fabric with no active port was reported usable")
|
||||
}
|
||||
if len(c.Problems) != 1 || !strings.Contains(c.Problems[0], "INIT") {
|
||||
t.Errorf("the problem does not say what state the port is in: %v", c.Problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRateIsTheSlowestActiveLink(t *testing.T) {
|
||||
// A collective runs at the speed of its slowest link, so reporting the
|
||||
// fastest would have the scheduler believe a fabric is quicker than it is.
|
||||
got := CheckFabrics(
|
||||
map[string][]string{"ib-a": {"fast", "slow"}},
|
||||
[]HCA{hca("fast", active(400)), hca("slow", active(100))},
|
||||
)
|
||||
if got[0].RateGbps != 100 {
|
||||
t.Errorf("rate = %d, want 100", got[0].RateGbps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAdapterWithSeveralPortsUsesItsBestActiveOne(t *testing.T) {
|
||||
// A dual-port card with one cable in is a perfectly good adapter, and
|
||||
// judging it by the dead port would take a working fabric out of service.
|
||||
got := CheckFabrics(
|
||||
map[string][]string{"ib-a": {"mlx5_0"}},
|
||||
[]HCA{hca("mlx5_0",
|
||||
Port{Num: 1, State: "DOWN", PhysState: "Disabled"},
|
||||
active(200),
|
||||
)},
|
||||
)
|
||||
if !got[0].OK() {
|
||||
t.Fatalf("a card with one live port was rejected: %+v", got[0])
|
||||
}
|
||||
if got[0].RateGbps != 200 {
|
||||
t.Errorf("rate = %d, want the active port's 200", got[0].RateGbps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFabricWithNothingUsableIsNotOK(t *testing.T) {
|
||||
got := CheckFabrics(
|
||||
map[string][]string{"ib-dead": {"mlx5_0"}},
|
||||
[]HCA{hca("mlx5_0", Port{Num: 1, State: "DOWN", PhysState: "Polling"})},
|
||||
)
|
||||
if got[0].OK() {
|
||||
t.Error("a fabric whose only adapter is down was reported usable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeWithNoFabricsDeclaredProducesNoChecks(t *testing.T) {
|
||||
// Every node without InfiniBand, which must stay entirely unaffected.
|
||||
if got := CheckFabrics(nil, []HCA{hca("mlx5_0", active(200))}); len(got) != 0 {
|
||||
t.Errorf("got %d checks for a node that declared none", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChecksComeBackInAStableOrder(t *testing.T) {
|
||||
// Map iteration would otherwise reorder log lines between restarts, which
|
||||
// makes a diff of two registrations unreadable.
|
||||
declared := map[string][]string{"ib-c": {"m"}, "ib-a": {"m"}, "ib-b": {"m"}}
|
||||
got := CheckFabrics(declared, nil)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d", len(got))
|
||||
}
|
||||
if got[0].Fabric != "ib-a" || got[1].Fabric != "ib-b" || got[2].Fabric != "ib-c" {
|
||||
t.Errorf("order = %s, %s, %s", got[0].Fabric, got[1].Fabric, got[2].Fabric)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,14 @@ type GPU struct {
|
||||
// MemoryUsedMB is what is already allocated. Non-zero on an idle node means
|
||||
// something other than our engines is using the card (plan §5.1).
|
||||
MemoryUsedMB int `json:"memory_used_mb"`
|
||||
// UtilizationPercent is how busy the card was over the last sample period.
|
||||
//
|
||||
// The number people actually watch, and the one nothing collected: memory
|
||||
// used says a model is loaded, and this says whether it is doing anything.
|
||||
// A fleet of cards at ninety percent occupancy and two percent utilisation
|
||||
// is a fleet holding models nobody is asking for, which is invisible
|
||||
// without it.
|
||||
UtilizationPercent int `json:"utilization_percent,omitempty"`
|
||||
// ComputeCapability gates quantization choices: an FP8 profile must not
|
||||
// land on a card below 8.9 (plan §6.2).
|
||||
ComputeCapability string `json:"compute_capability"`
|
||||
@@ -100,6 +108,15 @@ type Inventory struct {
|
||||
// Source names the backend that produced the GPU list, so a consumer can
|
||||
// tell "no GPUs" from "discovery did not work".
|
||||
Source string `json:"source"`
|
||||
// MemoryTotalMB is the machine's RAM. Zero means unknown, which is what an
|
||||
// agent older than this field reports, and is treated as no constraint.
|
||||
MemoryTotalMB int `json:"memory_total_mb,omitempty"`
|
||||
// Weights are the model files this node can see. Empty means it was not
|
||||
// asked to look.
|
||||
Weights []LocalWeights `json:"weights,omitempty"`
|
||||
// Storage is the disk behind those weights, one entry per filesystem.
|
||||
// Empty means the same thing it means for Weights: not asked.
|
||||
Storage []Storage `json:"storage,omitempty"`
|
||||
}
|
||||
|
||||
// Classes returns the distinct GPU classes present, sorted.
|
||||
|
||||
@@ -21,8 +21,21 @@ var smiQueryFields = []string{
|
||||
"driver_version",
|
||||
"temperature.gpu",
|
||||
"ecc.errors.uncorrected.volatile.total",
|
||||
// Last, and optional. An nvidia-smi too old to know this field returns a
|
||||
// shorter row, and every column before it still lines up — which is why it
|
||||
// goes on the end rather than anywhere else, and why the parser requires
|
||||
// only the mandatory prefix.
|
||||
"utilization.gpu",
|
||||
}
|
||||
|
||||
// mandatorySMIFields is how many columns a row must have to be usable.
|
||||
//
|
||||
// Everything before utilization.gpu. A driver that does not know the last field
|
||||
// gives a short row, and rejecting those would make an llmgw upgrade silently
|
||||
// stop discovering GPUs on an older node — a far worse outcome than not knowing
|
||||
// how busy a card is.
|
||||
const mandatorySMIFields = 10
|
||||
|
||||
// DiscoverGPUsSMI enumerates GPUs by shelling out to nvidia-smi.
|
||||
//
|
||||
// This is the cgo-free path (plan §3.2): the NVML bindings need cgo, which
|
||||
@@ -31,6 +44,13 @@ var smiQueryFields = []string{
|
||||
// NVML build adds.
|
||||
func DiscoverGPUsSMI(ctx context.Context, binary string) ([]GPU, error) {
|
||||
if binary == "" {
|
||||
// Not configured and not installed is a node without an NVIDIA card,
|
||||
// not a failed discovery. Reported as an error it also cut discovery
|
||||
// short before InfiniBand was looked at, on every CPU node that had
|
||||
// not been told by hand to skip GPUs.
|
||||
if _, err := exec.LookPath("nvidia-smi"); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
binary = "nvidia-smi"
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, binary,
|
||||
@@ -55,7 +75,7 @@ func parseSMI(out string) ([]GPU, error) {
|
||||
|
||||
gpus := make([]GPU, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if len(row) < len(smiQueryFields) {
|
||||
if len(row) < mandatorySMIFields {
|
||||
continue
|
||||
}
|
||||
idx, err := strconv.Atoi(strings.TrimSpace(row[0]))
|
||||
@@ -76,6 +96,9 @@ func parseSMI(out string) ([]GPU, error) {
|
||||
g.MemoryUsedMB, _ = parseCSVInt(row[4])
|
||||
g.Health.TemperatureC, _ = parseCSVInt(row[8])
|
||||
g.Health.ECCErrors, _ = parseCSVInt(row[9])
|
||||
if len(row) > 10 {
|
||||
g.UtilizationPercent, _ = parseCSVInt(row[10])
|
||||
}
|
||||
|
||||
assessHealth(&g)
|
||||
gpus = append(gpus, g)
|
||||
|
||||
@@ -142,3 +142,38 @@ func TestDiscoverGPUsSMIMissingBinary(t *testing.T) {
|
||||
t.Error("expected an error when nvidia-smi is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOlderDriverWithoutUtilisationStillDiscovers(t *testing.T) {
|
||||
// An nvidia-smi too old for utilization.gpu returns a shorter row. Rejecting
|
||||
// those would make an llmgw upgrade silently stop discovering GPUs on an
|
||||
// older node, which is far worse than not knowing how busy a card is — and
|
||||
// is what the first version of this change did.
|
||||
old := "0, GPU-aaa, NVIDIA H100 80GB HBM3, 81559, 1024, 9.0, 0000:01:00.0, 550.54, 41, 0"
|
||||
gpus, err := parseSMI(old)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(gpus) != 1 {
|
||||
t.Fatalf("a row without utilisation produced %d GPUs", len(gpus))
|
||||
}
|
||||
if gpus[0].MemoryTotalMB != 81559 || gpus[0].Health.TemperatureC != 41 {
|
||||
t.Errorf("columns shifted: %+v", gpus[0])
|
||||
}
|
||||
if gpus[0].UtilizationPercent != 0 {
|
||||
t.Errorf("utilisation = %d, want 0 when unreported", gpus[0].UtilizationPercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUtilisationIsReadWhenPresent(t *testing.T) {
|
||||
row := "0, GPU-aaa, NVIDIA H100 80GB HBM3, 81559, 1024, 9.0, 0000:01:00.0, 550.54, 41, 0, 87"
|
||||
gpus, err := parseSMI(row)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(gpus) != 1 {
|
||||
t.Fatalf("got %d GPUs", len(gpus))
|
||||
}
|
||||
if gpus[0].UtilizationPercent != 87 {
|
||||
t.Errorf("utilisation = %d, want 87", gpus[0].UtilizationPercent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Host is what a node is, as far as installing software on it is concerned.
|
||||
type Host struct {
|
||||
// OSID, OSVersion and OSCodename come from /etc/os-release: "ubuntu",
|
||||
// "24.04", "noble".
|
||||
OSID string `json:"os_id,omitempty"`
|
||||
OSVersion string `json:"os_version,omitempty"`
|
||||
OSCodename string `json:"os_codename,omitempty"`
|
||||
// OSLike is ID_LIKE: the families this system says it follows, "debian"
|
||||
// for Ubuntu, "rhel centos fedora" for Rocky. It is what decides the
|
||||
// package manager, which the name alone does not for every derivative.
|
||||
OSLike []string `json:"os_like,omitempty"`
|
||||
// Arch is Debian's spelling, "amd64" or "arm64", because that is what
|
||||
// package names and release archives are keyed by.
|
||||
Arch string `json:"arch,omitempty"`
|
||||
// CPUFlags are the vector extensions inference cares about, of those this
|
||||
// processor has.
|
||||
CPUFlags []string `json:"cpu_flags,omitempty"`
|
||||
// NVIDIADriver is the loaded driver's version, empty when there is none.
|
||||
NVIDIADriver string `json:"nvidia_driver,omitempty"`
|
||||
}
|
||||
|
||||
// Family is the packaging family: "deb", "rpm", or "" when neither is known.
|
||||
func (h Host) Family() string {
|
||||
for _, id := range append([]string{h.OSID}, h.OSLike...) {
|
||||
switch id {
|
||||
case "debian", "ubuntu":
|
||||
return "deb"
|
||||
case "rhel", "fedora", "centos", "rocky", "almalinux", "suse", "opensuse":
|
||||
return "rpm"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HasCPUFlag reports whether the processor has an extension.
|
||||
func (h Host) HasCPUFlag(f string) bool {
|
||||
for _, have := range h.CPUFlags {
|
||||
if have == f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// interestingFlags are the extensions that decide which build of an engine
|
||||
// runs well. A VM on QEMU's default CPU model has none of them — and still
|
||||
// runs, five times slower, with nothing saying why.
|
||||
var interestingFlags = []string{"avx", "avx2", "fma", "f16c", "avx512f", "avx512_bf16", "amx_tile", "asimd", "sve"}
|
||||
|
||||
// DiscoverHost reads what the machine is.
|
||||
func DiscoverHost() Host {
|
||||
h := hostFromOSRelease("/etc/os-release")
|
||||
h.Arch = debianArch(runtime.GOARCH)
|
||||
h.CPUFlags = cpuFlagsFrom("/proc/cpuinfo")
|
||||
h.NVIDIADriver = nvidiaDriverFrom("/proc/driver/nvidia/version")
|
||||
return h
|
||||
}
|
||||
|
||||
func hostFromOSRelease(path string) Host {
|
||||
var h Host
|
||||
f, err := os.Open(path) //nolint:gosec // a fixed system path
|
||||
if err != nil {
|
||||
return h
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
k, v, ok := strings.Cut(sc.Text(), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v = strings.Trim(v, `"'`)
|
||||
switch k {
|
||||
case "ID":
|
||||
h.OSID = v
|
||||
case "VERSION_ID":
|
||||
h.OSVersion = v
|
||||
case "VERSION_CODENAME":
|
||||
h.OSCodename = v
|
||||
case "ID_LIKE":
|
||||
h.OSLike = strings.Fields(v)
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func debianArch(goarch string) string {
|
||||
switch goarch {
|
||||
case "amd64", "arm64":
|
||||
return goarch
|
||||
case "386":
|
||||
return "i386"
|
||||
case "arm":
|
||||
return "armhf"
|
||||
}
|
||||
return goarch
|
||||
}
|
||||
|
||||
func cpuFlagsFrom(path string) []string {
|
||||
b, err := os.ReadFile(path) //nolint:gosec // a fixed system path
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
k, v, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// "flags" on x86, "Features" on ARM. The first processor is enough:
|
||||
// a machine does not mix instruction sets across its cores.
|
||||
if k = strings.TrimSpace(k); k != "flags" && k != "Features" {
|
||||
continue
|
||||
}
|
||||
for _, f := range strings.Fields(v) {
|
||||
have[f] = true
|
||||
}
|
||||
break
|
||||
}
|
||||
var out []string
|
||||
for _, f := range interestingFlags {
|
||||
if have[f] {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nvidiaDriverFrom reads the loaded kernel module's version, which exists
|
||||
// exactly when a driver is loaded — unlike nvidia-smi, which can be installed
|
||||
// on a machine whose module failed to load.
|
||||
func nvidiaDriverFrom(path string) string {
|
||||
b, err := os.ReadFile(path) //nolint:gosec // a fixed system path
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// "NVRM version: NVIDIA UNIX x86_64 Kernel Module 570.86.15 Thu ..."
|
||||
fields := strings.Fields(string(b))
|
||||
for i, f := range fields {
|
||||
if f == "Module" && i+1 < len(fields) {
|
||||
return fields[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MemoryTotalMB reports the machine's total RAM.
|
||||
//
|
||||
// It exists because placement had no idea how much a node could hold. A CPU
|
||||
// profile has no card to be free, so every ready node looked like a candidate:
|
||||
// a 14B model whose working set is 24 GB would have been placed just as
|
||||
// happily on a node with 8 GB, which reports itself perfectly healthy right up
|
||||
// until the OOM killer arrives. Labels were the stopgap; this is the fact they
|
||||
// were standing in for.
|
||||
//
|
||||
// Read from /proc/meminfo, which is Linux-only — every node is. On anything
|
||||
// else it returns 0, which callers treat as "unknown" and therefore no
|
||||
// constraint, exactly as an older agent that does not report it at all.
|
||||
func MemoryTotalMB() int { return memTotalMBFrom("/proc/meminfo") }
|
||||
|
||||
func memTotalMBFrom(path string) int {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
rest, ok := strings.CutPrefix(sc.Text(), "MemTotal:")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(rest)
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
kb, err := strconv.Atoi(fields[0])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
// MemTotal is what the kernel can hand out, already short of the
|
||||
// installed total by the firmware's reservations. That is the right
|
||||
// number for deciding what fits.
|
||||
return kb / 1024
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMemTotalIsReadFromMeminfo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "meminfo")
|
||||
// The real shape, including the fields before and after, so the parser is
|
||||
// not tested against something tidier than it will see.
|
||||
if err := os.WriteFile(p, []byte(
|
||||
"MemTotal: 32876244 kB\nMemFree: 1234 kB\nMemAvailable: 5678 kB\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := memTotalMBFrom(p), 32876244/1024; got != want {
|
||||
t.Errorf("memTotalMBFrom = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreadableMeminfoReportsUnknownRatherThanZeroMemory(t *testing.T) {
|
||||
// Zero means "did not say" everywhere downstream, and placement treats it
|
||||
// as no constraint. A node with genuinely no memory is not a case.
|
||||
if got := memTotalMBFrom(filepath.Join(t.TempDir(), "absent")); got != 0 {
|
||||
t.Errorf("got %d for a missing file", got)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "meminfo")
|
||||
for _, junk := range []string{"", "MemTotal:\n", "MemTotal: not-a-number kB\n", "Other: 1 kB\n"} {
|
||||
if err := os.WriteFile(p, []byte(junk), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := memTotalMBFrom(p); got != 0 {
|
||||
t.Errorf("got %d for %q", got, junk)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Storage is one filesystem a node keeps weights on.
|
||||
//
|
||||
// One entry per filesystem rather than per configured directory, because free
|
||||
// space belongs to the disk and not to the path. Two model directories on the
|
||||
// same disk are one pool of bytes, and reporting them separately would let the
|
||||
// control plane add 24 GB to 24 GB and place a 40 GB model on a node with 24.
|
||||
type Storage struct {
|
||||
// Path is one directory on this filesystem — the first one configured that
|
||||
// landed here, so the operator recognises it.
|
||||
Path string `json:"path"`
|
||||
// FreeBytes is what a non-root process can still write. Deliberately not
|
||||
// the root reserve: the agent does not run as root, so the reserve is space
|
||||
// it cannot have.
|
||||
FreeBytes int64 `json:"free_bytes"`
|
||||
// TotalBytes is the filesystem's size, so a reading of "8 GB free" can be
|
||||
// told apart on a 10 TB filer and on a 10 GB root partition.
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
// Shared marks a filesystem the node did not have to fill. Free space on
|
||||
// one is shared with every other node using it, so two nodes reporting
|
||||
// 500 GB free on the same filer have 500 GB between them, not a terabyte.
|
||||
Shared bool `json:"shared,omitempty"`
|
||||
}
|
||||
|
||||
// ScanStorage reports the filesystems behind the given model directories.
|
||||
//
|
||||
// Empty in, empty out, and a directory that cannot be read is skipped rather
|
||||
// than reported as full: a mount that has not come back yet looks exactly like
|
||||
// a missing directory, and claiming zero free bytes would take the node out of
|
||||
// placement for a reason that is not true. Absent is not zero here, as
|
||||
// everywhere else in this system.
|
||||
func ScanStorage(dirs []string) []Storage {
|
||||
var out []Storage
|
||||
seen := map[uint64]int{} // filesystem id -> index in out
|
||||
|
||||
for _, dir := range dirs {
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
dev, ok := filesystemID(dir)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if i, dup := seen[dev]; dup {
|
||||
// Same disk, already counted. Keep the first path: it is the one
|
||||
// the operator wrote first and so the one they will recognise.
|
||||
_ = i
|
||||
continue
|
||||
}
|
||||
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(dir, &st); err != nil {
|
||||
continue
|
||||
}
|
||||
// Bsize is int32 on some platforms and int64 on others, which is why
|
||||
// every field here is widened explicitly rather than assumed.
|
||||
block := int64(st.Bsize)
|
||||
seen[dev] = len(out)
|
||||
out = append(out, Storage{
|
||||
Path: dir,
|
||||
FreeBytes: int64(st.Bavail) * block, //nolint:gosec,unconvert // widened on purpose
|
||||
TotalBytes: int64(st.Blocks) * block, //nolint:gosec,unconvert // widened on purpose
|
||||
Shared: isNetworkFS(dir),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// filesystemID identifies the disk a path is on.
|
||||
//
|
||||
// By device number rather than by the statfs filesystem id, because the field
|
||||
// holding that id is spelled differently on Linux and on macOS and this has to
|
||||
// compile on both — the agent runs on one and is developed on the other.
|
||||
func filesystemID(path string) (uint64, bool) {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
st, ok := fi.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return uint64(st.Dev), true //nolint:gosec,unconvert // int32 on macOS, uint64 on Linux
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTwoDirectoriesOnOneDiskAreOnePoolOfBytes(t *testing.T) {
|
||||
// The reason this matters: free space belongs to the disk, not to the path.
|
||||
// Counting two directories separately lets the control plane add 24 GB to
|
||||
// 24 GB and place a 40 GB model on a node that has 24.
|
||||
base := t.TempDir()
|
||||
a := filepath.Join(base, "models-a")
|
||||
b := filepath.Join(base, "models-b")
|
||||
for _, d := range []string{a, b} {
|
||||
if err := os.Mkdir(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got := ScanStorage([]string{a, b})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("two directories on one filesystem reported as %d pools: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Path != a {
|
||||
t.Errorf("kept %q rather than the first directory configured", got[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageReportsSomethingPlausible(t *testing.T) {
|
||||
got := ScanStorage([]string{t.TempDir()})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("a real directory reported %d filesystems", len(got))
|
||||
}
|
||||
s := got[0]
|
||||
if s.TotalBytes <= 0 {
|
||||
t.Error("a filesystem with no size at all")
|
||||
}
|
||||
if s.FreeBytes < 0 || s.FreeBytes > s.TotalBytes {
|
||||
t.Errorf("free %d of total %d", s.FreeBytes, s.TotalBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMissingDirectoryIsNotAFullDisk(t *testing.T) {
|
||||
// A mount that has not come back yet looks exactly like a missing
|
||||
// directory. Reporting it as zero free bytes would take the node out of
|
||||
// placement for a reason that is not true; reporting nothing says only
|
||||
// that nothing is known, which is the honest answer.
|
||||
got := ScanStorage([]string{filepath.Join(t.TempDir(), "never-mounted")})
|
||||
if len(got) != 0 {
|
||||
t.Errorf("a missing directory was reported as a filesystem: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoDirectoriesConstrainNothing(t *testing.T) {
|
||||
if got := ScanStorage(nil); len(got) != 0 {
|
||||
t.Errorf("scanning nothing found %+v", got)
|
||||
}
|
||||
if got := ScanStorage([]string{""}); len(got) != 0 {
|
||||
t.Errorf("an empty path found %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// LocalWeights is one model file or directory a node can see.
|
||||
type LocalWeights struct {
|
||||
Path string `json:"path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
// Shared marks weights on a filesystem this node did not have to fill.
|
||||
//
|
||||
// Worth distinguishing: twenty nodes each holding their own copy and twenty
|
||||
// nodes seeing one filer are the same list of paths and completely
|
||||
// different situations — one has twenty copies of a 141 GB model and the
|
||||
// other has one.
|
||||
Shared bool `json:"shared,omitempty"`
|
||||
}
|
||||
|
||||
// ScanWeights lists the model files under the given directories.
|
||||
//
|
||||
// Storage is the part of this system that will differ at every installation —
|
||||
// a Lustre mount, an image exported over the fabric, local NVMe, an rsync from
|
||||
// a laptop — so llmgw does not try to own it. It observes the result instead:
|
||||
// whatever put the file there, the question placement needs answered is only
|
||||
// whether it is present.
|
||||
//
|
||||
// Scanning nothing is the default and constrains nothing. A node with no
|
||||
// directories configured behaves exactly as every node did before this existed,
|
||||
// which is what makes the whole feature optional.
|
||||
func ScanWeights(dirs []string) []LocalWeights {
|
||||
var out []LocalWeights
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
for _, dir := range dirs {
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
shared := isNetworkFS(dir)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
// A directory that is not there is not an error to report upward:
|
||||
// a mount that has not come back yet looks exactly like this, and
|
||||
// the node is simply one without those weights until it does.
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
path := filepath.Join(dir, e.Name())
|
||||
if _, dup := seen[path]; dup {
|
||||
continue
|
||||
}
|
||||
size, ok := weightSize(path, e)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
out = append(out, LocalWeights{Path: path, SizeBytes: size, Shared: shared})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out
|
||||
}
|
||||
|
||||
// weightSize measures a candidate and says whether it is one at all.
|
||||
//
|
||||
// A model is either a single file — a .gguf, usually — or a directory of
|
||||
// shards and a config, which is what every HuggingFace checkout looks like.
|
||||
// Anything else in the directory is somebody's notes and is not reported.
|
||||
func weightSize(path string, e fs.DirEntry) (int64, bool) {
|
||||
if e.IsDir() {
|
||||
// A directory counts only if it actually holds weights. An empty one
|
||||
// is a half-finished download, and reporting it as present would have
|
||||
// placement choose a node whose engine then fails to load.
|
||||
var total int64
|
||||
var found bool
|
||||
_ = filepath.WalkDir(path, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil //nolint:nilerr // an unreadable subtree is not fatal
|
||||
}
|
||||
if isWeightFile(p) {
|
||||
found = true
|
||||
}
|
||||
if info, err := d.Info(); err == nil {
|
||||
total += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return total, found
|
||||
}
|
||||
if !isWeightFile(path) {
|
||||
return 0, false
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return info.Size(), true
|
||||
}
|
||||
|
||||
func isWeightFile(path string) bool {
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".gguf", ".safetensors", ".bin", ".pt", ".pth":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isNetworkFS reports whether a path is on a filesystem the node did not fill.
|
||||
//
|
||||
// By magic number rather than by parsing mounts, because the mount table says
|
||||
// what was asked for and this says what the kernel actually has. The list is
|
||||
// the network filesystems that turn up in front of GPUs: NFS, Lustre, GPFS,
|
||||
// CephFS, GlusterFS and SMB. Anything unrecognised is treated as local, which
|
||||
// is the conservative direction — a shared filer mistaken for local storage
|
||||
// costs an unnecessary copy, while local storage mistaken for shared would have
|
||||
// the cluster believe a model is everywhere when it is on one node.
|
||||
func isNetworkFS(path string) bool {
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &st); err != nil {
|
||||
return false
|
||||
}
|
||||
switch uint32(st.Type) { //nolint:unconvert // Type is int64 on some platforms
|
||||
case 0x6969, // NFS
|
||||
0x0BD00BD0, // Lustre
|
||||
0x47504653, // GPFS
|
||||
0x00C36400, // CephFS
|
||||
0x65735546, // FUSE, which is how GlusterFS and many object mounts appear
|
||||
0xFF534D42: // SMB/CIFS
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func write(t *testing.T, path string, size int) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, make([]byte, size), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanFindsFilesAndDirectoriesOfWeights(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, filepath.Join(dir, "qwen-3b-q4.gguf"), 1024)
|
||||
// A HuggingFace checkout: a directory of shards rather than one file.
|
||||
write(t, filepath.Join(dir, "Llama-3.3-70B", "model-00001.safetensors"), 2048)
|
||||
write(t, filepath.Join(dir, "Llama-3.3-70B", "config.json"), 64)
|
||||
|
||||
got := ScanWeights([]string{dir})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("found %d entries: %+v", len(got), got)
|
||||
}
|
||||
byName := map[string]int64{}
|
||||
for _, w := range got {
|
||||
byName[filepath.Base(w.Path)] = w.SizeBytes
|
||||
}
|
||||
if byName["qwen-3b-q4.gguf"] != 1024 {
|
||||
t.Errorf("gguf size = %d", byName["qwen-3b-q4.gguf"])
|
||||
}
|
||||
// The directory's size is everything in it, config included: what matters
|
||||
// is how much would have to be moved to put it somewhere else.
|
||||
if byName["Llama-3.3-70B"] != 2048+64 {
|
||||
t.Errorf("checkout size = %d, want %d", byName["Llama-3.3-70B"], 2048+64)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyDirectoryIsNotWeights(t *testing.T) {
|
||||
// A half-finished download looks exactly like this, and reporting it as
|
||||
// present would have placement choose a node whose engine then fails to
|
||||
// load — the failure this whole feature exists to avoid.
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "downloading"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(t, filepath.Join(dir, "downloading", "README.md"), 10)
|
||||
|
||||
if got := ScanWeights([]string{dir}); len(got) != 0 {
|
||||
t.Errorf("a directory with no weight files was reported: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotesAndScriptsAreNotReportedAsModels(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, filepath.Join(dir, "notes.txt"), 10)
|
||||
write(t, filepath.Join(dir, "fetch.sh"), 10)
|
||||
write(t, filepath.Join(dir, "real.gguf"), 10)
|
||||
|
||||
got := ScanWeights([]string{dir})
|
||||
if len(got) != 1 || filepath.Base(got[0].Path) != "real.gguf" {
|
||||
t.Errorf("scan returned %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMissingDirectoryIsNotAnError(t *testing.T) {
|
||||
// A mount that has not come back yet looks exactly like a directory that is
|
||||
// not there, and the node is simply one without those weights until it is.
|
||||
got := ScanWeights([]string{filepath.Join(t.TempDir(), "never-existed")})
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanningNothingIsTheDefaultAndConstrainsNothing(t *testing.T) {
|
||||
// What makes the feature optional: an agent with no directories configured
|
||||
// behaves exactly as every agent did before this existed.
|
||||
if got := ScanWeights(nil); len(got) != 0 {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
if got := ScanWeights([]string{""}); len(got) != 0 {
|
||||
t.Errorf("an empty directory name produced %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSamePathIsReportedOnce(t *testing.T) {
|
||||
// Two configured directories can overlap, or be the same mount reached two
|
||||
// ways. Counting a model twice would overstate what a fabric holds.
|
||||
dir := t.TempDir()
|
||||
write(t, filepath.Join(dir, "m.gguf"), 10)
|
||||
if got := ScanWeights([]string{dir, dir}); len(got) != 1 {
|
||||
t.Errorf("the same directory twice produced %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorageIsNeverMistakenForShared(t *testing.T) {
|
||||
// The conservative direction. A shared filer read as local costs an
|
||||
// unnecessary copy; local read as shared would have the cluster believe a
|
||||
// model is everywhere when it is on one node.
|
||||
dir := t.TempDir()
|
||||
write(t, filepath.Join(dir, "m.gguf"), 10)
|
||||
got := ScanWeights([]string{dir})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
if got[0].Shared {
|
||||
t.Error("a temporary directory on local disk was reported as shared")
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,11 @@ type ShardSpec struct {
|
||||
// not used when building a command.
|
||||
Pool string
|
||||
Fabric string
|
||||
// ProfileFP identifies the version of the profile this shard was started
|
||||
// from. Carried for the same reason as Pool and Fabric, and never read: it
|
||||
// lets the control plane tell, after its own restart, whether a shard that
|
||||
// is still running matches the profile the catalog holds now.
|
||||
ProfileFP string
|
||||
}
|
||||
|
||||
// Leader reports whether this shard serves HTTP for the replica.
|
||||
@@ -141,9 +146,21 @@ func NewRegistry(cfg Config) *Registry {
|
||||
r := &Registry{engines: map[string]Engine{}}
|
||||
r.Add(&VLLM{cfg: cfg.VLLM})
|
||||
r.Add(&SGLang{cfg: cfg.SGLang})
|
||||
r.Add(&LlamaCpp{cfg: cfg.LlamaCpp})
|
||||
r.Add(&Sim{cfg: cfg.Sim})
|
||||
return r
|
||||
}
|
||||
|
||||
// UseInstalled points an engine at a build the agent installed, and reports
|
||||
// whether the engine takes one.
|
||||
func (r *Registry) UseInstalled(name, path string) bool {
|
||||
u, ok := r.engines[name].(interface{ UseInstalled(string) })
|
||||
if ok {
|
||||
u.UseInstalled(path)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// Add registers an engine, replacing any engine of the same name.
|
||||
func (r *Registry) Add(e Engine) { r.engines[e.Name()] = e }
|
||||
|
||||
@@ -168,8 +185,10 @@ func (r *Registry) Names() []string {
|
||||
|
||||
// Config configures the engine adapters.
|
||||
type Config struct {
|
||||
VLLM VLLMConfig `koanf:"vllm"`
|
||||
SGLang SGLangConfig `koanf:"sglang"`
|
||||
VLLM VLLMConfig `koanf:"vllm"`
|
||||
SGLang SGLangConfig `koanf:"sglang"`
|
||||
LlamaCpp LlamaCppConfig `koanf:"llamacpp"`
|
||||
Sim SimConfig `koanf:"sim"`
|
||||
}
|
||||
|
||||
// --- shared helpers ---
|
||||
|
||||
@@ -397,14 +397,14 @@ func TestValidateRequiresModelPath(t *testing.T) {
|
||||
|
||||
func TestRegistry(t *testing.T) {
|
||||
r := NewRegistry(Config{})
|
||||
if got := r.Names(); len(got) != 2 || got[0] != "sglang" || got[1] != "vllm" {
|
||||
if got := r.Names(); !slices.Equal(got, []string{"llamacpp", "sglang", "sim", "vllm"}) {
|
||||
t.Errorf("registry contents: %v", got)
|
||||
}
|
||||
if _, err := r.Get("vllm"); err != nil {
|
||||
t.Errorf("vllm missing: %v", err)
|
||||
}
|
||||
err := func() error { _, err := r.Get("nope"); return err }()
|
||||
if err == nil || !strings.Contains(err.Error(), "sglang, vllm") {
|
||||
if err == nil || !strings.Contains(err.Error(), "llamacpp, sglang, sim, vllm") {
|
||||
t.Errorf("an unknown engine should list what is available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// LlamaCppConfig locates the llama.cpp installation.
|
||||
type LlamaCppConfig struct {
|
||||
// Bin is the llama-server binary. It is the server, not the CLI: the
|
||||
// gateway talks OpenAI over HTTP and needs the one that serves it.
|
||||
// Empty means the agent installs llama.cpp itself, at the version the
|
||||
// control plane asks for. Setting it opts out of that.
|
||||
Bin string `koanf:"bin"`
|
||||
// Parallel is how many requests a server handles at once, when a profile
|
||||
// does not say. llama.cpp defaults to one, which would make every second
|
||||
// request wait behind the first no matter what the router decided.
|
||||
Parallel int `koanf:"parallel"`
|
||||
}
|
||||
|
||||
// LlamaCpp builds llama.cpp launch commands.
|
||||
//
|
||||
// It is the engine that makes a cluster without GPUs useful: llama.cpp runs a
|
||||
// small model on CPU at a speed that is fine for testing the whole path, and it
|
||||
// serves the same OpenAI API as the others, so nothing downstream can tell the
|
||||
// difference.
|
||||
type LlamaCpp struct {
|
||||
cfg LlamaCppConfig
|
||||
installed atomic.Pointer[string]
|
||||
}
|
||||
|
||||
// Name identifies the engine in the catalog.
|
||||
func (*LlamaCpp) Name() string { return "llamacpp" }
|
||||
|
||||
// HealthPath is llama.cpp's readiness endpoint. It returns 503 while the model
|
||||
// is still loading, which is exactly what the supervisor needs.
|
||||
func (*LlamaCpp) HealthPath() string { return "/health" }
|
||||
|
||||
// MetricsPath is llama.cpp's Prometheus endpoint, which only exists when the
|
||||
// server was started with --metrics. Commands always passes it.
|
||||
func (*LlamaCpp) MetricsPath() string { return "/metrics" }
|
||||
|
||||
// ServesHTTP reports whether this rank answers the API. Every llama.cpp shard
|
||||
// does, because a llama.cpp replica is always exactly one shard.
|
||||
func (*LlamaCpp) ServesHTTP(ShardSpec) bool { return true }
|
||||
|
||||
// Validate checks the profile against the GPUs it would run on.
|
||||
//
|
||||
// The two refusals here are about not pretending: llama.cpp has no equivalent
|
||||
// of a tensor-parallel group spanning machines, and its multi-GPU support is a
|
||||
// different mechanism (--split-mode, --tensor-split) that this adapter does not
|
||||
// drive. Accepting either would produce a replica that starts and then serves
|
||||
// from one machine while the catalog believes it has several.
|
||||
func (*LlamaCpp) Validate(p Profile, gpus []GPUInfo) error {
|
||||
if err := validateCommon(p, gpus); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Nodes > 1 {
|
||||
return fmt.Errorf("profile %q: llamacpp does not span nodes; use vllm or sglang for a %d-node replica",
|
||||
p.Name, p.Nodes)
|
||||
}
|
||||
if p.TP > 1 {
|
||||
return fmt.Errorf("profile %q: llamacpp does not do tensor parallelism; "+
|
||||
"it splits a model across local GPUs with --tensor-split, which this adapter does not drive",
|
||||
p.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Commands returns the processes for one shard, which is always one process.
|
||||
func (l *LlamaCpp) Commands(s ShardSpec) ([]Command, error) {
|
||||
bin := l.bin()
|
||||
p := s.Profile
|
||||
|
||||
model, err := ggufIn(p.ModelPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args := []string{
|
||||
"--model", model,
|
||||
"--host", "0.0.0.0",
|
||||
"--port", strconv.Itoa(s.HTTPPort),
|
||||
// Without this there is no /metrics, and an endpoint the router cannot
|
||||
// measure is one it has to route to blind.
|
||||
"--metrics",
|
||||
}
|
||||
// The model's own chat template rather than a generic one, which is what
|
||||
// renders tool definitions in the form the model was trained to read.
|
||||
//
|
||||
// Pinned rather than relied on: build 10991 has it on by default, older
|
||||
// builds do not, and an engine upgrade should not be able to change how
|
||||
// prompts are built underneath a running fleet. Passing it explicitly was
|
||||
// a no-op on the build here — it was added while chasing tool calls that
|
||||
// came back as text, and did not fix that, which turned out to be the
|
||||
// model rather than the template.
|
||||
if !hasFlag(p.Args, "--jinja", "--no-jinja") {
|
||||
args = append(args, "--jinja")
|
||||
}
|
||||
if p.ServedModelName != "" {
|
||||
args = append(args, "--alias", p.ServedModelName)
|
||||
}
|
||||
if !hasFlag(p.Args, "--parallel", "-np") {
|
||||
n := l.cfg.Parallel
|
||||
if n <= 0 {
|
||||
n = 4
|
||||
}
|
||||
args = append(args, "--parallel", strconv.Itoa(n))
|
||||
}
|
||||
// With GPUs assigned, offload everything; llama.cpp keeps the model on the
|
||||
// CPU otherwise, which is not what a GPU placement asked for. Without them
|
||||
// the flag is omitted entirely rather than set to zero, so a build with no
|
||||
// GPU support does not see an argument it rejects.
|
||||
if len(s.GPUIndices) > 0 && !hasFlag(p.Args, "--n-gpu-layers", "-ngl") {
|
||||
args = append(args, "--n-gpu-layers", "999")
|
||||
}
|
||||
args = append(args, p.Args...)
|
||||
|
||||
return []Command{{
|
||||
Name: "llama-server", Path: bin, Args: args, Env: materializeEnv(baseEnv(s)),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// hasFlag reports whether the profile already sets one of these flags, in
|
||||
// either "--flag value" or "--flag=value" form.
|
||||
func hasFlag(args []string, names ...string) bool {
|
||||
for _, a := range args {
|
||||
for _, n := range names {
|
||||
if a == n || strings.HasPrefix(a, n+"=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ggufIn resolves a model path that may be a directory.
|
||||
//
|
||||
// A multi-file version arrives as the directory it was downloaded into, because
|
||||
// that is the only shape a set can take. llama.cpp wants a file: given the
|
||||
// first part of a split GGUF it finds the rest beside it, so the first part is
|
||||
// what it gets.
|
||||
//
|
||||
// A path that is already a file is returned untouched, which is every
|
||||
// single-file model and every hand-staged one.
|
||||
func ggufIn(path string) (string, error) {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil || !st.IsDir() {
|
||||
// Not a directory, or not there at all — the second is the engine's to
|
||||
// report, with the message it would have given anyway.
|
||||
return path, nil //nolint:nilerr // a missing path is the engine's error to give
|
||||
}
|
||||
entries, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "", fmt.Errorf("%s holds no .gguf file", path)
|
||||
}
|
||||
// Sorted, so a split model's first part comes first: the parts are named
|
||||
// "-00001-of-00004" and lexical order is shard order.
|
||||
sort.Strings(names)
|
||||
return filepath.Join(path, names[0]), nil
|
||||
}
|
||||
|
||||
// UseInstalled points the engine at a build the agent installed and checked.
|
||||
// It takes precedence over everything else, because it is the only one that
|
||||
// was seen to run.
|
||||
func (l *LlamaCpp) UseInstalled(path string) { l.installed.Store(&path) }
|
||||
|
||||
// Configured reports whether the agent's configuration names a binary. A
|
||||
// named binary is the operator's deliberate choice; one merely found on PATH
|
||||
// is not, and is not reported as installed.
|
||||
func (l *LlamaCpp) Configured() bool { return l.cfg.Bin != "" }
|
||||
|
||||
// bin is the llama-server to run: the installed build, else the configured
|
||||
// one, else whatever PATH has — which is only ever reached by a node not
|
||||
// provisioned by the agent and not told where its engine is.
|
||||
func (l *LlamaCpp) bin() string {
|
||||
if p := l.installed.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
if l.cfg.Bin != "" {
|
||||
return l.cfg.Bin
|
||||
}
|
||||
return "llama-server"
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func llamaSpec(gpus []int) ShardSpec {
|
||||
return ShardSpec{
|
||||
ReplicaID: "rep-1", ShardID: "sh-1", Rank: 0, NNodes: 1,
|
||||
GPUIndices: gpus, HTTPPort: 8000, MgmtInterface: "eth0",
|
||||
Profile: Profile{
|
||||
Name: "cpu", Engine: "llamacpp", TP: 1,
|
||||
ModelPath: "/models/qwen.gguf", ServedModelName: "qwen-0.5b",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestLlamaCppCommand(t *testing.T) {
|
||||
cmds, err := (&LlamaCpp{}).Commands(llamaSpec(nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cmds) != 1 {
|
||||
t.Fatalf("a llama.cpp shard is one process, got %d", len(cmds))
|
||||
}
|
||||
c := cmds[0]
|
||||
if c.Path != "llama-server" {
|
||||
t.Errorf("path %q", c.Path)
|
||||
}
|
||||
|
||||
// --metrics is not optional: llama-server serves no /metrics without it, and
|
||||
// an endpoint the router cannot measure is one it routes to blind.
|
||||
if !slices.Contains(c.Args, "--metrics") {
|
||||
t.Error("the server was started without --metrics")
|
||||
}
|
||||
if v, _ := flagValue(c.Args, "--model"); v != "/models/qwen.gguf" {
|
||||
t.Errorf("--model %q", v)
|
||||
}
|
||||
if v, _ := flagValue(c.Args, "--port"); v != "8000" {
|
||||
t.Errorf("--port %q", v)
|
||||
}
|
||||
if v, _ := flagValue(c.Args, "--alias"); v != "qwen-0.5b" {
|
||||
t.Errorf("--alias %q", v)
|
||||
}
|
||||
// llama.cpp serves one request at a time unless told otherwise, which would
|
||||
// make every second request wait no matter what the router decided.
|
||||
if v, ok := flagValue(c.Args, "--parallel"); !ok || v == "1" {
|
||||
t.Errorf("--parallel %q", v)
|
||||
}
|
||||
if slices.Contains(c.Args, "--n-gpu-layers") {
|
||||
t.Error("a CPU placement was given GPU layers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLlamaCppOffloadsWhenGivenGPUs(t *testing.T) {
|
||||
cmds, _ := (&LlamaCpp{}).Commands(llamaSpec([]int{2}))
|
||||
if v, ok := flagValue(cmds[0].Args, "--n-gpu-layers"); !ok || v != "999" {
|
||||
t.Errorf("a GPU placement should offload the model, got %q", v)
|
||||
}
|
||||
// The shard must still be pinned to its own card.
|
||||
if !slices.Contains(cmds[0].Env, "CUDA_VISIBLE_DEVICES=2") {
|
||||
t.Error("the shard was not pinned to its GPU")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileArgumentsWinOverDefaults(t *testing.T) {
|
||||
// The escape hatch has to actually work, or an operator tuning slot count
|
||||
// ends up with two --parallel flags and whichever llama.cpp happens to
|
||||
// prefer.
|
||||
s := llamaSpec(nil)
|
||||
s.Profile.Args = []string{"--parallel", "16", "--ctx-size", "4096"}
|
||||
|
||||
cmds, _ := (&LlamaCpp{}).Commands(s)
|
||||
n := 0
|
||||
for _, a := range cmds[0].Args {
|
||||
if a == "--parallel" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("--parallel appears %d times", n)
|
||||
}
|
||||
if v, _ := flagValue(cmds[0].Args, "--parallel"); v != "16" {
|
||||
t.Errorf("the profile's value lost: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortFormArgumentsAreRecognized(t *testing.T) {
|
||||
s := llamaSpec([]int{0})
|
||||
s.Profile.Args = []string{"-np", "8", "-ngl", "20"}
|
||||
|
||||
cmds, _ := (&LlamaCpp{}).Commands(s)
|
||||
for _, flag := range []string{"--parallel", "--n-gpu-layers"} {
|
||||
if slices.Contains(cmds[0].Args, flag) {
|
||||
t.Errorf("%s was added although the profile set its short form", flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLlamaCppRefusesWhatItCannotDo(t *testing.T) {
|
||||
// Accepting either of these would produce a replica that starts and then
|
||||
// serves from one machine while the catalog believes it has several.
|
||||
multi := Profile{Name: "multi", Engine: "llamacpp", TP: 1, Nodes: 2, GPUsPerNode: 1, ModelPath: "/m"}
|
||||
if err := (&LlamaCpp{}).Validate(multi, nil); err == nil {
|
||||
t.Error("a multi-node llama.cpp profile was accepted")
|
||||
}
|
||||
|
||||
tp := Profile{Name: "tp", Engine: "llamacpp", TP: 2, GPUsPerNode: 2, ModelPath: "/m"}
|
||||
if err := (&LlamaCpp{}).Validate(tp, h100(2)); err == nil {
|
||||
t.Error("a tensor-parallel llama.cpp profile was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUProfileValidates(t *testing.T) {
|
||||
p := Profile{Name: "cpu", Engine: "llamacpp", TP: 1, ModelPath: "/models/qwen.gguf"}
|
||||
if err := (&LlamaCpp{}).Validate(p, nil); err != nil {
|
||||
t.Errorf("a CPU profile on a node with no GPUs was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLlamaCppAlwaysServesHTTP(t *testing.T) {
|
||||
// Unlike the others there is no worker rank to get this wrong for.
|
||||
if !(&LlamaCpp{}).ServesHTTP(llamaSpec(nil)) {
|
||||
t.Error("a llama.cpp shard must answer the API")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBinaryIsConfigurable(t *testing.T) {
|
||||
l := &LlamaCpp{cfg: LlamaCppConfig{Bin: "/opt/llama.cpp/bin/llama-server", Parallel: 2}}
|
||||
cmds, _ := l.Commands(llamaSpec(nil))
|
||||
if cmds[0].Path != "/opt/llama.cpp/bin/llama-server" {
|
||||
t.Errorf("path %q", cmds[0].Path)
|
||||
}
|
||||
if v, _ := flagValue(cmds[0].Args, "--parallel"); v != "2" {
|
||||
t.Errorf("--parallel %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownEngineNamesLlamaCpp(t *testing.T) {
|
||||
_, err := NewRegistry(Config{}).Get("llama.cpp")
|
||||
if err == nil || !strings.Contains(err.Error(), "llamacpp") {
|
||||
t.Errorf("the suggestion should name the real engine: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// flagValue reads a "--flag value" pair out of a command line.
|
||||
func flagValue(args []string, flag string) (string, bool) {
|
||||
for i, a := range args {
|
||||
if a == flag && i+1 < len(args) {
|
||||
return args[i+1], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestLlamaCppUsesTheModelsChatTemplate(t *testing.T) {
|
||||
// Pinned so an engine upgrade cannot change how prompts are built under a
|
||||
// running fleet: build 10991 defaults to jinja, older builds do not.
|
||||
//
|
||||
// Not a fix for tool calling, though it was added while chasing that. On
|
||||
// this build the flag is a no-op and the behaviour is identical with and
|
||||
// without it.
|
||||
l := &LlamaCpp{}
|
||||
cmds, err := l.Commands(ShardSpec{HTTPPort: 8080, Profile: Profile{ModelPath: "/m.gguf"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(cmds[0].Args, "--jinja") {
|
||||
t.Errorf("--jinja is missing; tool calls will come back as text: %v", cmds[0].Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLlamaCppLetsAProfileTurnTheTemplateOff(t *testing.T) {
|
||||
// A model whose bundled template is broken has to be serviceable without
|
||||
// editing the adapter.
|
||||
l := &LlamaCpp{}
|
||||
cmds, err := l.Commands(ShardSpec{
|
||||
HTTPPort: 8080,
|
||||
Profile: Profile{ModelPath: "/m.gguf", Args: []string{"--no-jinja"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slices.Contains(cmds[0].Args, "--jinja") {
|
||||
t.Errorf("the profile said --no-jinja and got both: %v", cmds[0].Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASetDirectoryResolvesToItsFirstPart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Deliberately out of order on disk, because readdir order is not shard
|
||||
// order and the first part is the one llama.cpp must be given.
|
||||
for _, n := range []string{
|
||||
"qwen-q4_k_m-00003-of-00003.gguf",
|
||||
"qwen-q4_k_m-00001-of-00003.gguf",
|
||||
"qwen-q4_k_m-00002-of-00003.gguf",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(dir, n), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := ggufIn(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Given the first part llama.cpp finds the rest beside it; given the third
|
||||
// it loads a third of a model.
|
||||
if filepath.Base(got) != "qwen-q4_k_m-00001-of-00003.gguf" {
|
||||
t.Errorf("expected the first shard, got %s", filepath.Base(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPlainFilePathIsUntouched(t *testing.T) {
|
||||
// Every single-file model and every hand-staged one takes this path.
|
||||
p := filepath.Join(t.TempDir(), "model.gguf")
|
||||
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := ggufIn(p); got != p {
|
||||
t.Errorf("expected the path unchanged, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADirectoryWithNoWeightsSaysSo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ggufIn(dir); err == nil {
|
||||
t.Error("a directory with no .gguf in it is not a model llama.cpp can load")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMissingPathIsLeftToTheEngine(t *testing.T) {
|
||||
// The engine's own "no such file" names the path and the flag it came
|
||||
// from, which is more use than anything this could say.
|
||||
p := "/nonexistent/model.gguf"
|
||||
got, err := ggufIn(p)
|
||||
if err != nil || got != p {
|
||||
t.Errorf("expected it passed through, got %q %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Unknown is the version reported for an engine that is installed and would
|
||||
// not say which version it is.
|
||||
//
|
||||
// A value, not an absent key, because the two mean different things and
|
||||
// placement reads the keys: absent is "this node cannot run it", Unknown is
|
||||
// "it is here and the probe could not get a number out of it". Collapsing them
|
||||
// would make a slow engine look like a missing one and take a node out of
|
||||
// scheduling for being slow to answer --version.
|
||||
const Unknown = "unknown"
|
||||
|
||||
// Prober is an engine that can say whether it is installed on this machine.
|
||||
//
|
||||
// Optional, and asserted for rather than part of Engine: an adapter that has no
|
||||
// way to check should report nothing rather than be forced to invent an answer.
|
||||
type Prober interface {
|
||||
// Probe returns the engine's version, or an error when it is not installed.
|
||||
Probe(ctx context.Context) (string, error)
|
||||
}
|
||||
|
||||
// Probe asks every registered engine whether it is here.
|
||||
//
|
||||
// The result is what the node reports at registration, and its key set is what
|
||||
// placement filters on. Until this existed nobody knew: the registry registers
|
||||
// every adapter whether or not its binary exists, so a node with no vLLM
|
||||
// accepted a vLLM shard, registered healthy, and failed the shard minutes later
|
||||
// with "executable file not found" — in the engine's own words, on a machine
|
||||
// nobody was watching.
|
||||
//
|
||||
// Bounded, because it runs on the path to registering and an agent that cannot
|
||||
// reach the control plane is worse than one that reports an incomplete list.
|
||||
func (r *Registry) Probe(ctx context.Context) map[string]string {
|
||||
out := map[string]string{}
|
||||
for name, e := range r.engines {
|
||||
p, ok := e.(Prober)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
v, err := p.Probe(ctx)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if v == "" {
|
||||
v = Unknown
|
||||
}
|
||||
out[name] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Names of the engines in a probe result, sorted.
|
||||
func ProbedNames(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for n := range m {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// probeTimeout bounds one engine's version check.
|
||||
//
|
||||
// Generous, because vLLM's --version imports torch and that is seconds on a
|
||||
// cold page cache, and mean, because this is startup: an engine that has not
|
||||
// answered in this long is reported as present-with-unknown-version rather
|
||||
// than waited for.
|
||||
const probeTimeout = 8 * time.Second
|
||||
|
||||
// run executes a version command, saying whether the program was even there.
|
||||
//
|
||||
// The two failures are different answers and the caller has to tell them
|
||||
// apart. A binary that is absent is an engine this node cannot run. A binary
|
||||
// that is present and exits non-zero is an engine it probably can — unless the
|
||||
// command was a test of whether a module imports, where a non-zero exit is the
|
||||
// whole answer.
|
||||
func run(ctx context.Context, bin string, args ...string) (out string, found bool, ok bool) {
|
||||
path, err := exec.LookPath(bin)
|
||||
if err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Combined, not just stdout: engines print their version to either, and one
|
||||
// that chose stderr would otherwise read as having no version at all.
|
||||
b, err := exec.CommandContext(ctx, path, args...).CombinedOutput()
|
||||
return string(b), true, err == nil
|
||||
}
|
||||
|
||||
// installedBinary reports an engine that is its own executable.
|
||||
//
|
||||
// It resolves the path and runs nothing. The first version of this ran the
|
||||
// engine with --version, which is fine for an engine and not fine for the
|
||||
// wrapper scripts people put in front of one: a script that shifts past flags
|
||||
// it does not recognise and then execs the server will start an inference
|
||||
// server, at agent startup, on every node. The agent's own test suite installs
|
||||
// exactly such a script, and caught it.
|
||||
//
|
||||
// So the version is not read here. Presence is what placement needs, and what
|
||||
// the version is for — telling whether an engine still accepts the flags llmgw
|
||||
// builds — is `llmgw ctl engine-check`, which is run deliberately by somebody
|
||||
// who meant to run the engine.
|
||||
func installedBinary(bin string) (string, error) {
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
return "", errNotInstalled
|
||||
}
|
||||
return Unknown, nil
|
||||
}
|
||||
|
||||
// versionOfModule probes an engine that is a module inside an interpreter.
|
||||
//
|
||||
// This one does execute, and safely: the command is llmgw's own one-line
|
||||
// import, not the engine's launcher, so there is no wrapper to shift past
|
||||
// flags and start a server. There is also no alternative — a module has no
|
||||
// path to look up.
|
||||
//
|
||||
// A non-zero exit is the answer: python3 exists on almost every machine, so
|
||||
// presence of the interpreter says nothing. Treating a failed import as
|
||||
// "installed" reported SGLang on every node in a fleet that had none, which
|
||||
// would take nodes into scheduling for an engine they cannot run — the exact
|
||||
// failure this whole change exists to stop.
|
||||
func versionOfModule(ctx context.Context, python string, args ...string) (string, error) {
|
||||
out, found, ok := run(ctx, python, args...)
|
||||
if !found || !ok {
|
||||
return "", errNotInstalled
|
||||
}
|
||||
return firstVersion(out), nil
|
||||
}
|
||||
|
||||
var errNotInstalled = errors.New("not installed on this node")
|
||||
|
||||
var versionPattern = regexp.MustCompile(`\b\d+\.\d+(\.\d+)?\S*`)
|
||||
|
||||
// firstVersion is the first thing in the output that looks like a version.
|
||||
//
|
||||
// Pattern rather than parsing each engine's format: they print "vllm 0.6.3",
|
||||
// "version: b4123 (…)", and a paragraph with the number in the middle, and all
|
||||
// of those change between releases. What this is for is telling one build from
|
||||
// another, which the first number does.
|
||||
func firstVersion(s string) string {
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if m := versionPattern.FindString(line); m != "" {
|
||||
return strings.TrimRight(m, ",;)")
|
||||
}
|
||||
}
|
||||
return Unknown
|
||||
}
|
||||
|
||||
// Configured is an engine whose location the agent's configuration names.
|
||||
type Configured interface {
|
||||
Configured() bool
|
||||
}
|
||||
|
||||
// ProbeConfigured probes only the engines the configuration names.
|
||||
//
|
||||
// Probe looks everywhere — PATH, the default interpreter — and reports what
|
||||
// it finds, which is exactly the trust in whatever happens to be on a machine
|
||||
// that installing engines replaces. A node the agent provisions reports what
|
||||
// it installed, plus what the operator named on purpose; nothing else.
|
||||
func (r *Registry) ProbeConfigured(ctx context.Context) map[string]string {
|
||||
out := map[string]string{}
|
||||
for name, e := range r.engines {
|
||||
c, ok := e.(Configured)
|
||||
if !ok || !c.Configured() {
|
||||
continue
|
||||
}
|
||||
p, ok := e.(Prober)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if v, err := p.Probe(ctx); err == nil {
|
||||
if v == "" {
|
||||
v = Unknown
|
||||
}
|
||||
out[name] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Probe reports whether the llama.cpp server is installed.
|
||||
func (l *LlamaCpp) Probe(context.Context) (string, error) {
|
||||
return installedBinary(l.bin())
|
||||
}
|
||||
|
||||
// Configured reports whether the configuration names vLLM.
|
||||
func (v *VLLM) Configured() bool { return v.cfg.Bin != "" }
|
||||
|
||||
// Configured reports whether the configuration names SGLang's interpreter.
|
||||
func (g *SGLang) Configured() bool { return g.cfg.Python != "" }
|
||||
|
||||
// Probe reports whether vLLM is installed.
|
||||
func (v *VLLM) Probe(context.Context) (string, error) {
|
||||
bin := v.cfg.Bin
|
||||
if bin == "" {
|
||||
bin = "vllm"
|
||||
}
|
||||
return installedBinary(bin)
|
||||
}
|
||||
|
||||
// Probe reports the SGLang version.
|
||||
//
|
||||
// Through the interpreter, because SGLang is a module rather than a binary:
|
||||
// asking the shell for "sglang" finds nothing on a machine that can run it
|
||||
// perfectly well.
|
||||
func (g *SGLang) Probe(ctx context.Context) (string, error) {
|
||||
python := g.cfg.Python
|
||||
if python == "" {
|
||||
python = "python3"
|
||||
}
|
||||
return versionOfModule(ctx, python, "-c",
|
||||
"import sglang, sys; sys.stdout.write(getattr(sglang, '__version__', ''))")
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// script writes an executable that stands in for an engine.
|
||||
func script(t *testing.T, name, body string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestAnEngineThatIsNotInstalledIsAbsentFromTheReport(t *testing.T) {
|
||||
r := NewRegistry(Config{})
|
||||
r.engines = map[string]Engine{
|
||||
"vllm": &VLLM{cfg: VLLMConfig{Bin: "/nonexistent/vllm"}},
|
||||
}
|
||||
// Absent, not present-with-unknown-version. Placement reads the keys, and
|
||||
// a node listed for an engine it cannot run is the failure being fixed.
|
||||
if got := r.Probe(t.Context()); len(got) != 0 {
|
||||
t.Errorf("expected nothing, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbingNeverRunsTheEngine(t *testing.T) {
|
||||
// A wrapper that shifts past flags it does not recognise and then starts
|
||||
// the server. People put these in front of engines, and the agent's own
|
||||
// tests install one. Probing with --version would launch an inference
|
||||
// server at agent startup, on every node.
|
||||
marker := filepath.Join(t.TempDir(), "ran")
|
||||
bin := script(t, "fake-vllm", "touch "+marker+"; sleep 60")
|
||||
|
||||
r := NewRegistry(Config{})
|
||||
r.engines = map[string]Engine{"vllm": &VLLM{cfg: VLLMConfig{Bin: bin}}}
|
||||
|
||||
got := r.Probe(t.Context())
|
||||
if _, ok := got["vllm"]; !ok {
|
||||
t.Error("a binary that is there should be reported present")
|
||||
}
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
t.Fatal("the engine was executed by a probe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleThatWillNotImportIsNotInstalled(t *testing.T) {
|
||||
// python3 is on almost every machine, so the interpreter being there says
|
||||
// nothing about SGLang. Reporting it present put every node in a fleet
|
||||
// forward for an engine none of them had.
|
||||
bin := script(t, "python3", "exit 1")
|
||||
r := NewRegistry(Config{})
|
||||
r.engines = map[string]Engine{"sglang": &SGLang{cfg: SGLangConfig{Python: bin}}}
|
||||
|
||||
if got := r.Probe(t.Context()); len(got) != 0 {
|
||||
t.Errorf("a failed import means not installed, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleThatImportsIsReportedWithItsVersion(t *testing.T) {
|
||||
bin := script(t, "python3", `echo "0.4.1"`)
|
||||
r := NewRegistry(Config{})
|
||||
r.engines = map[string]Engine{"sglang": &SGLang{cfg: SGLangConfig{Python: bin}}}
|
||||
|
||||
got := r.Probe(t.Context())
|
||||
if got["sglang"] != "0.4.1" {
|
||||
t.Errorf("expected the version, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAVersionIsPickedOutOfWhateverIsPrinted(t *testing.T) {
|
||||
// Only the module probe reaches this now, and a Python package version is
|
||||
// dotted. A bare build number is deliberately not matched: it would turn a
|
||||
// stray number in a line of prose into a version.
|
||||
for in, want := range map[string]string{
|
||||
"0.4.1": "0.4.1",
|
||||
"sglang 0.4.post1": "0.4.post1",
|
||||
"warning: noise\n0.4.1": "0.4.1",
|
||||
"": Unknown,
|
||||
"no numbers here": Unknown,
|
||||
"just 4123": Unknown,
|
||||
} {
|
||||
if got := firstVersion(in); got != want {
|
||||
t.Errorf("firstVersion(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbingIsBoundedByTheContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
bin := script(t, "python3", "sleep 60")
|
||||
r := NewRegistry(Config{})
|
||||
r.engines = map[string]Engine{"sglang": &SGLang{cfg: SGLangConfig{Python: bin}}}
|
||||
|
||||
// Registration waits on this. An agent that cannot reach the control plane
|
||||
// is worse than one reporting an incomplete list.
|
||||
if got := r.Probe(ctx); len(got) != 0 {
|
||||
t.Errorf("a cancelled probe should report nothing, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// SimConfig locates the simulated engine.
|
||||
type SimConfig struct {
|
||||
// Bin is the llmgw binary itself; the engine is one of its subcommands.
|
||||
// Empty means whatever started this agent, which is right almost always.
|
||||
Bin string `koanf:"bin"`
|
||||
}
|
||||
|
||||
// Sim launches llmgw's own simulated engine.
|
||||
//
|
||||
// It is in the production registry on purpose. A cluster can be brought up and
|
||||
// exercised end to end — placement, fabrics, multi-node sequencing, failure and
|
||||
// rebuild — on machines that have no GPUs, which is the only way most of that
|
||||
// path gets tested at all. The multi-node launch sequence went four phases
|
||||
// without ever running, because the only things exercising it were stub
|
||||
// processes that exercised the command line and nothing else.
|
||||
//
|
||||
// Nothing about it pretends to be real: the model name it serves is whatever it
|
||||
// is told, the tokens are invented, and it says so in its own logs.
|
||||
type Sim struct{ cfg SimConfig }
|
||||
|
||||
// Name identifies the engine in the catalog.
|
||||
func (*Sim) Name() string { return "sim" }
|
||||
|
||||
// HealthPath is the simulated engine's readiness endpoint.
|
||||
func (*Sim) HealthPath() string { return "/health" }
|
||||
|
||||
// MetricsPath is its Prometheus endpoint, in vLLM's vocabulary.
|
||||
func (*Sim) MetricsPath() string { return "/metrics" }
|
||||
|
||||
// ServesHTTP reports whether this rank answers the API.
|
||||
//
|
||||
// Only the leader, exactly as vLLM and SGLang behave — which is the behaviour
|
||||
// this engine exists to let us test.
|
||||
func (*Sim) ServesHTTP(s ShardSpec) bool { return s.Leader() }
|
||||
|
||||
// Validate accepts anything the arithmetic allows.
|
||||
//
|
||||
// No compute-capability checks and no quantization rules: there is no hardware
|
||||
// to be wrong about, and inventing refusals would make this a worse stand-in
|
||||
// for the engines it stands in for.
|
||||
func (*Sim) Validate(p Profile, gpus []GPUInfo) error {
|
||||
return validateCommon(p, gpus)
|
||||
}
|
||||
|
||||
// Commands returns the single process that makes up one shard.
|
||||
func (s *Sim) Commands(spec ShardSpec) ([]Command, error) {
|
||||
bin := s.cfg.Bin
|
||||
if bin == "" {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("locating the llmgw binary for the sim engine: %w", err)
|
||||
}
|
||||
bin = exe
|
||||
}
|
||||
p := spec.Profile
|
||||
|
||||
args := []string{"sim-engine",
|
||||
"--listen", "0.0.0.0:" + strconv.Itoa(spec.HTTPPort),
|
||||
"--model", p.ServedModelName,
|
||||
}
|
||||
if spec.NNodes > 1 {
|
||||
args = append(args,
|
||||
"--nnodes", strconv.Itoa(spec.NNodes),
|
||||
"--node-rank", strconv.Itoa(spec.Rank),
|
||||
"--dist-init-addr", spec.LeaderAddr+":"+strconv.Itoa(spec.LeaderPort),
|
||||
)
|
||||
}
|
||||
args = append(args, p.Args...)
|
||||
|
||||
return []Command{{
|
||||
Name: "sim-engine", Path: bin, Args: args,
|
||||
Env: materializeEnv(baseEnv(spec)),
|
||||
// Workers dial the leader, so the leader has to be listening first.
|
||||
// Both ranks express the same wait because both know the same address.
|
||||
WaitForLeaderPort: spec.NNodes > 1 && !spec.Leader(),
|
||||
}}, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func simSpec(rank, nnodes int) ShardSpec {
|
||||
return ShardSpec{
|
||||
ReplicaID: "rep-1", ShardID: "sh-1", Rank: rank, NNodes: nnodes,
|
||||
LeaderAddr: "10.0.0.5", LeaderPort: 20001,
|
||||
GPUIndices: []int{0, 1}, HTTPPort: 20000, MgmtInterface: "eth0",
|
||||
HCAs: []string{"mlx5_0", "mlx5_1"},
|
||||
Profile: Profile{
|
||||
Name: "tp4", Engine: "sim", TP: 4, GPUsPerNode: 2, Nodes: nnodes,
|
||||
ModelPath: "/models/x", ServedModelName: "big-model",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyTheLeaderServes(t *testing.T) {
|
||||
// The behaviour that made replicas fail while they were coming up perfectly
|
||||
// well: health checking a worker's port, which never opens.
|
||||
if !(&Sim{}).ServesHTTP(simSpec(0, 2)) {
|
||||
t.Error("the leader does not serve")
|
||||
}
|
||||
if (&Sim{}).ServesHTTP(simSpec(1, 2)) {
|
||||
t.Error("a worker claims to serve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMultiNodeShardCarriesItsRendezvous(t *testing.T) {
|
||||
cmds, err := (&Sim{cfg: SimConfig{Bin: "/usr/local/bin/llmgw"}}).Commands(simSpec(1, 2))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cmds) != 1 {
|
||||
t.Fatalf("%d commands", len(cmds))
|
||||
}
|
||||
c := cmds[0]
|
||||
|
||||
if c.Args[0] != "sim-engine" {
|
||||
t.Errorf("the engine is a subcommand of llmgw: %v", c.Args)
|
||||
}
|
||||
for flag, want := range map[string]string{
|
||||
"--nnodes": "2", "--node-rank": "1", "--dist-init-addr": "10.0.0.5:20001",
|
||||
} {
|
||||
if got, _ := flagValue(c.Args, flag); got != want {
|
||||
t.Errorf("%s = %q, want %q", flag, got, want)
|
||||
}
|
||||
}
|
||||
// A worker must wait for the leader's port, or the launch is a race.
|
||||
if !c.WaitForLeaderPort {
|
||||
t.Error("a worker does not wait for the leader")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheLeaderDoesNotWaitForItself(t *testing.T) {
|
||||
cmds, _ := (&Sim{cfg: SimConfig{Bin: "x"}}).Commands(simSpec(0, 2))
|
||||
if cmds[0].WaitForLeaderPort {
|
||||
t.Error("the leader waits for a port it is about to open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestASingleNodeShardCarriesNoRendezvous(t *testing.T) {
|
||||
cmds, _ := (&Sim{cfg: SimConfig{Bin: "x"}}).Commands(simSpec(0, 1))
|
||||
for _, flag := range []string{"--nnodes", "--node-rank", "--dist-init-addr"} {
|
||||
if slices.Contains(cmds[0].Args, flag) {
|
||||
t.Errorf("a single-node shard was given %s", flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheShardIsStillPinnedToItsFabric(t *testing.T) {
|
||||
// The simulated engine does not use NCCL, but the environment is generated
|
||||
// by the same code that generates it for the ones that do — so a cluster
|
||||
// exercised with this engine is exercising that too.
|
||||
cmds, _ := (&Sim{cfg: SimConfig{Bin: "x"}}).Commands(simSpec(1, 2))
|
||||
env := cmds[0].Env
|
||||
for _, want := range []string{
|
||||
"NCCL_IB_HCA=mlx5_0,mlx5_1", "NCCL_SOCKET_IFNAME=eth0",
|
||||
"CUDA_VISIBLE_DEVICES=0,1", "NCCL_IB_DISABLE=0",
|
||||
} {
|
||||
if !slices.Contains(env, want) {
|
||||
t.Errorf("the environment is missing %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
|
||||
)
|
||||
|
||||
type standby bool
|
||||
|
||||
func (s standby) IsLeader() bool { return !bool(s) }
|
||||
|
||||
// startPlain runs an agent service over h2c, which is enough to test who
|
||||
// accepts a connection and who refuses it.
|
||||
//
|
||||
// A nil leader means none was configured, which is every deployment that
|
||||
// existed before leadership did and has to keep accepting agents.
|
||||
func startPlain(t *testing.T, leader agentsvc.Leader) (*inventory.Store, string) {
|
||||
t.Helper()
|
||||
inv := inventory.NewStore()
|
||||
svc := agentsvc.New(inv, newRecorder(), false, slog.New(slog.DiscardHandler))
|
||||
if leader != nil {
|
||||
svc.SetLeadership(leader)
|
||||
}
|
||||
|
||||
path, handler := svc.Handler()
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(path, handler)
|
||||
// h2c, because the agent speaks HTTP/2 with prior knowledge and a plain
|
||||
// server negotiates HTTP/1.1 — which turns a clear refusal into a frame
|
||||
// parsing error that says nothing about why.
|
||||
srv := httptest.NewServer(h2c.NewHandler(mux, &http2.Server{}))
|
||||
t.Cleanup(srv.Close)
|
||||
return inv, srv.URL
|
||||
}
|
||||
|
||||
func TestAStandbyRefusesAnAgent(t *testing.T) {
|
||||
// Commands travel down the stream the agent opened, so whichever control
|
||||
// plane holds it is the only one that can start anything on that node. A
|
||||
// standby holding it means the leader's placements fail with "no agent
|
||||
// connected" for a node that is plainly connected.
|
||||
_, addr := startPlain(t, standby(true))
|
||||
|
||||
ag := runAgent(t, Config{NodeID: "n1", Server: addr, MgmtAddr: "127.0.0.1"})
|
||||
err := ag.session(context.Background(), ag.cfg.Server)
|
||||
if err == nil {
|
||||
t.Fatal("a standby accepted an agent")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "standby") {
|
||||
t.Errorf("the refusal does not say why: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAgentReachesTheLeaderPastAStandby(t *testing.T) {
|
||||
// The point of the list. The standby is first, so the agent only registers
|
||||
// if it moved on rather than sitting on the refusal.
|
||||
_, standbyAddr := startPlain(t, standby(true))
|
||||
leaderInv, leaderAddr := startPlain(t, standby(false))
|
||||
|
||||
ag := runAgent(t, Config{
|
||||
NodeID: "n1", MgmtAddr: "127.0.0.1",
|
||||
Servers: []string{standbyAddr, leaderAddr},
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
go ag.Run(ctx)
|
||||
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, ok := leaderInv.Get("n1"); ok {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("the agent never reached the leader past the standby")
|
||||
}
|
||||
|
||||
func TestALeaderStillAcceptsWithNoLeadershipConfigured(t *testing.T) {
|
||||
// Every deployment before this had no leadership at all, and must keep
|
||||
// accepting agents with nothing configured.
|
||||
inv, addr := startPlain(t, nil)
|
||||
|
||||
ag := runAgent(t, Config{NodeID: "n1", Server: addr, MgmtAddr: "127.0.0.1"})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
go ag.Run(ctx)
|
||||
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, ok := inv.Get("n1"); ok {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("a service with no leadership configured refused an agent")
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// agentLogLines is how much of its own log an agent keeps for the admin page.
|
||||
// Enough to cover an engine install or a failed start; the journal keeps the
|
||||
// rest for anyone who can reach it.
|
||||
const agentLogLines = 2000
|
||||
|
||||
// logRing keeps the most recent lines written to it.
|
||||
type logRing struct {
|
||||
mu sync.Mutex
|
||||
lines []string
|
||||
next int
|
||||
full bool
|
||||
}
|
||||
|
||||
func newLogRing(n int) *logRing { return &logRing{lines: make([]string, n)} }
|
||||
|
||||
// Write takes whole lines, as a text handler writes them.
|
||||
func (r *logRing) Write(p []byte) (int, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
|
||||
r.lines[r.next] = line
|
||||
r.next = (r.next + 1) % len(r.lines)
|
||||
if r.next == 0 {
|
||||
r.full = true
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// tail returns up to n of the most recent lines, oldest first.
|
||||
func (r *logRing) tail(n int) []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
size := r.next
|
||||
if r.full {
|
||||
size = len(r.lines)
|
||||
}
|
||||
n = min(n, size)
|
||||
out := make([]string, 0, n)
|
||||
for i := size - n; i < size; i++ {
|
||||
idx := i
|
||||
if r.full {
|
||||
idx = (r.next + i) % len(r.lines)
|
||||
}
|
||||
out = append(out, r.lines[idx])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// teeHandler sends every record to two handlers: wherever the agent already
|
||||
// logs, and the ring the admin page reads.
|
||||
type teeHandler struct{ a, b slog.Handler }
|
||||
|
||||
func (t teeHandler) Enabled(ctx context.Context, l slog.Level) bool {
|
||||
return t.a.Enabled(ctx, l) || t.b.Enabled(ctx, l)
|
||||
}
|
||||
|
||||
func (t teeHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
var err error
|
||||
if t.a.Enabled(ctx, r.Level) {
|
||||
err = t.a.Handle(ctx, r.Clone())
|
||||
}
|
||||
if t.b.Enabled(ctx, r.Level) {
|
||||
_ = t.b.Handle(ctx, r.Clone())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (t teeHandler) WithAttrs(as []slog.Attr) slog.Handler {
|
||||
return teeHandler{t.a.WithAttrs(as), t.b.WithAttrs(as)}
|
||||
}
|
||||
|
||||
func (t teeHandler) WithGroup(name string) slog.Handler {
|
||||
return teeHandler{t.a.WithGroup(name), t.b.WithGroup(name)}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
)
|
||||
|
||||
func TestTheLogRingKeepsTheNewestLinesInOrder(t *testing.T) {
|
||||
r := newLogRing(3)
|
||||
if got := r.tail(10); len(got) != 0 {
|
||||
t.Fatalf("empty ring gave %v", got)
|
||||
}
|
||||
for i := 1; i <= 5; i++ {
|
||||
fmt.Fprintf(r, "line %d\n", i)
|
||||
}
|
||||
if got := r.tail(10); !slices.Equal(got, []string{"line 3", "line 4", "line 5"}) {
|
||||
t.Errorf("after wrapping: %v", got)
|
||||
}
|
||||
if got := r.tail(2); !slices.Equal(got, []string{"line 4", "line 5"}) {
|
||||
t.Errorf("tail(2): %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeAnswersForItsOwnLog(t *testing.T) {
|
||||
cfg := Config{Server: "127.0.0.1:7443", NodeID: "n1", WeightsCache: t.TempDir(), EnginesDir: t.TempDir()}
|
||||
cfg.Superv.WorkDir = t.TempDir()
|
||||
a, err := New(cfg, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.log.Info("engine installation", "state", "installing")
|
||||
|
||||
r := a.logsReply(&llmgwv1.LogsRequest{RequestId: "x", Source: "agent", Lines: 50})
|
||||
if r.GetRequestId() != "x" || !strings.Contains(strings.Join(r.GetLines(), "\n"), "engine installation") {
|
||||
t.Errorf("the agent's own line is not in its log: %v", r.GetLines())
|
||||
}
|
||||
if !slices.Contains(r.GetSources(), "agent") {
|
||||
t.Errorf("sources %v", r.GetSources())
|
||||
}
|
||||
|
||||
r = a.logsReply(&llmgwv1.LogsRequest{RequestId: "y", Source: "no-such-shard"})
|
||||
if r.GetError() == "" {
|
||||
t.Error("an unknown engine was answered as if it existed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
|
||||
)
|
||||
|
||||
// pki issues a cluster's certificates into a temporary directory and returns
|
||||
// the configurations the control plane and one agent would load.
|
||||
func pki(t *testing.T, nodeID string) (server, client tlsconf.Config) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
ca, err := tlsconf.NewCA("test CA")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ca.Write(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv, err := ca.IssueServer("control-plane", []string{"127.0.0.1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := srv.Write(dir, "server"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cli, err := ca.IssueClient(nodeID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cli.Write(dir, "agent"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
caPath := filepath.Join(dir, "ca.pem")
|
||||
return tlsconf.Config{CA: caPath, Cert: filepath.Join(dir, "server.pem"), Key: filepath.Join(dir, "server-key.pem")},
|
||||
tlsconf.Config{CA: caPath, Cert: filepath.Join(dir, "agent.pem"), Key: filepath.Join(dir, "agent-key.pem")}
|
||||
}
|
||||
|
||||
// startTLS runs a real service behind a real TLS listener, with identity
|
||||
// required — the deployed configuration rather than an approximation of it.
|
||||
func startTLS(t *testing.T, serverTLS tlsconf.Config) (*agentsvc.Service, *inventory.Store, string) {
|
||||
t.Helper()
|
||||
|
||||
inv := inventory.NewStore()
|
||||
svc := agentsvc.New(inv, newRecorder(), true, slog.New(slog.DiscardHandler))
|
||||
path, handler := svc.Handler()
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(path, handler)
|
||||
|
||||
tc, err := serverTLS.ServerConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewUnstartedServer(mux)
|
||||
srv.Listener = raw
|
||||
srv.TLS = tc
|
||||
srv.StartTLS()
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
return svc, inv, "https://" + raw.Addr().String()
|
||||
}
|
||||
|
||||
func runAgent(t *testing.T, cfg Config) *Agent {
|
||||
t.Helper()
|
||||
|
||||
cfg.MgmtAddr = "127.0.0.1"
|
||||
cfg.HeartbeatInterval = 50 * time.Millisecond
|
||||
cfg.ReconnectBase = 20 * time.Millisecond
|
||||
cfg.ReconnectMax = 100 * time.Millisecond
|
||||
cfg.Discovery.SkipGPUs = true
|
||||
cfg.Discovery.IBRoot = filepath.Join(t.TempDir(), "absent")
|
||||
cfg.Superv = supervisor.Config{
|
||||
WorkDir: t.TempDir(), LogLines: 64,
|
||||
StopTimeout: 2 * time.Second, HealthInterval: 20 * time.Millisecond,
|
||||
}
|
||||
|
||||
client, err := Client(cfg.TLS, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ag, err := New(cfg, client, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() { _ = ag.Run(ctx) }()
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
ag.Supervisor().StopAll(context.Background())
|
||||
})
|
||||
return ag
|
||||
}
|
||||
|
||||
func registered(svc *agentsvc.Service, inv *inventory.Store, nodeID string) func() bool {
|
||||
return func() bool {
|
||||
_, ok := inv.Get(nodeID)
|
||||
return ok && len(svc.ConnectedNodes()) == 1
|
||||
}
|
||||
}
|
||||
|
||||
func eventually(t *testing.T, limit time.Duration, pred func() bool) bool {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(limit)
|
||||
for time.Now().Before(deadline) {
|
||||
if pred() {
|
||||
return true
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAgentRegistersOverMutualTLS(t *testing.T) {
|
||||
serverTLS, clientTLS := pki(t, "llmgw-node01")
|
||||
svc, inv, url := startTLS(t, serverTLS)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.NodeID = "llmgw-node01"
|
||||
cfg.Server = url
|
||||
cfg.TLS = clientTLS
|
||||
runAgent(t, cfg)
|
||||
|
||||
if !eventually(t, 10*time.Second, registered(svc, inv, "llmgw-node01")) {
|
||||
t.Fatal("the agent never registered over mutual TLS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeCannotClaimAnotherNodesIdentity(t *testing.T) {
|
||||
// The point of the whole exercise. node01 holds a valid certificate — it is
|
||||
// a legitimate member of the cluster — and still cannot register as node02,
|
||||
// because the id it claims is checked against the one it can prove.
|
||||
serverTLS, clientTLS := pki(t, "llmgw-node01")
|
||||
svc, inv, url := startTLS(t, serverTLS)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.NodeID = "llmgw-node02"
|
||||
cfg.Server = url
|
||||
cfg.TLS = clientTLS
|
||||
runAgent(t, cfg)
|
||||
|
||||
// The agent reconnects on failure, so this has to stay false throughout
|
||||
// rather than merely start false.
|
||||
if eventually(t, 2*time.Second, func() bool {
|
||||
_, ok := inv.Get("llmgw-node02")
|
||||
return ok || len(svc.ConnectedNodes()) > 0
|
||||
}) {
|
||||
t.Fatal("an agent registered under a node id its certificate does not authorize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthenticatedAgentIsRefused(t *testing.T) {
|
||||
// The defensive half of the check: identity is required, but the connection
|
||||
// carried none. In the shipped wiring TLS and this flag are tied together,
|
||||
// so reaching here means someone untied them — and the answer must still be
|
||||
// refusal rather than a silently trusted node id.
|
||||
inv := inventory.NewStore()
|
||||
svc := agentsvc.New(inv, newRecorder(), true, slog.New(slog.DiscardHandler))
|
||||
path, handler := svc.Handler()
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(path, handler)
|
||||
|
||||
srv := httptest.NewServer(h2c.NewHandler(mux, &http2.Server{}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.NodeID = "llmgw-node01"
|
||||
cfg.Server = srv.URL
|
||||
runAgent(t, cfg)
|
||||
|
||||
if eventually(t, 2*time.Second, registered(svc, inv, "llmgw-node01")) {
|
||||
t.Fatal("an agent with no proven identity reached the control plane")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaintextAgentIsRejectedWhenTLSIsConfigured(t *testing.T) {
|
||||
// A misconfiguration worth catching at startup rather than at runtime: an
|
||||
// agent holding certificates but dialling http:// would connect in the
|
||||
// clear and never learn that it had.
|
||||
_, clientTLS := pki(t, "llmgw-node01")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.NodeID = "llmgw-node01"
|
||||
cfg.Server = "http://127.0.0.1:9443"
|
||||
cfg.TLS = clientTLS
|
||||
|
||||
if _, err := New(cfg, nil, slog.New(slog.DiscardHandler)); err == nil {
|
||||
t.Fatal("an agent with certificates was allowed to dial a plaintext address")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBareAddressGainsTheRightScheme(t *testing.T) {
|
||||
_, clientTLS := pki(t, "llmgw-node01")
|
||||
|
||||
secure := Config{NodeID: "n", Server: "10.0.0.140:9443", TLS: clientTLS}
|
||||
if err := secure.normalize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secure.Server != "https://10.0.0.140:9443" {
|
||||
t.Errorf("a configured agent dials %q", secure.Server)
|
||||
}
|
||||
|
||||
plain := Config{NodeID: "n", Server: "10.0.0.140:9443"}
|
||||
if err := plain.normalize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plain.Server != "http://10.0.0.140:9443" {
|
||||
t.Errorf("an unconfigured agent dials %q", plain.Server)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusedAgentLearnsWhy(t *testing.T) {
|
||||
// An agent that cannot register must be able to say why. Reporting a
|
||||
// refusal as a clean disconnect leaves an operator with a silent reconnect
|
||||
// loop and nothing to act on — and resets the backoff, so the loop is a
|
||||
// fast one.
|
||||
serverTLS, clientTLS := pki(t, "llmgw-node01")
|
||||
_, _, url := startTLS(t, serverTLS)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.NodeID = "llmgw-node02"
|
||||
cfg.Server = url
|
||||
cfg.TLS = clientTLS
|
||||
cfg.Discovery.SkipGPUs = true
|
||||
cfg.Discovery.IBRoot = filepath.Join(t.TempDir(), "absent")
|
||||
cfg.Superv = supervisor.Config{WorkDir: t.TempDir(), LogLines: 8}
|
||||
|
||||
client, err := Client(cfg.TLS, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ag, err := New(cfg, client, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = ag.session(context.Background(), ag.cfg.Server)
|
||||
if err == nil {
|
||||
t.Fatal("a refused session reported success")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "llmgw-node01") || !strings.Contains(err.Error(), "llmgw-node02") {
|
||||
t.Errorf("the error names neither certificate nor claim: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnAgentWithNothingRunningStillTellsTheControlPlane covers the gap that
|
||||
// made a reconciler fix dead code in production.
|
||||
//
|
||||
// "I am running nothing" is information: it is how the control plane learns
|
||||
// that an engine died while its agent survived. The service used to return
|
||||
// early on an empty list, so that message never reached the reconciler — while
|
||||
// the reconciler's own tests, which call it directly, kept passing.
|
||||
//
|
||||
// This test goes through the real stream for that reason.
|
||||
func TestAnAgentWithNothingRunningStillTellsTheControlPlane(t *testing.T) {
|
||||
serverTLS, clientTLS := pki(t, "llmgw-node01")
|
||||
|
||||
inv := inventory.NewStore()
|
||||
rec := newRecorder()
|
||||
svc := agentsvc.New(inv, rec, true, slog.New(slog.DiscardHandler))
|
||||
path, handler := svc.Handler()
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(path, handler)
|
||||
|
||||
tc, err := serverTLS.ServerConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewUnstartedServer(mux)
|
||||
srv.Listener = raw
|
||||
srv.TLS = tc
|
||||
srv.StartTLS()
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.NodeID = "llmgw-node01"
|
||||
cfg.Server = "https://" + raw.Addr().String()
|
||||
cfg.TLS = clientTLS
|
||||
runAgent(t, cfg)
|
||||
|
||||
// The agent has no shards at all, which is the whole point.
|
||||
if !eventually(t, 10*time.Second, func() bool {
|
||||
return len(rec.adoptCalls()) > 0
|
||||
}) {
|
||||
t.Fatal("an agent running nothing never reached the reconciler")
|
||||
}
|
||||
for _, call := range rec.adoptCalls() {
|
||||
if call.nodeID != "llmgw-node01" {
|
||||
t.Errorf("adopt was told node %q", call.nodeID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
)
|
||||
|
||||
// Want is one engine at one version.
|
||||
type Want struct {
|
||||
Engine string
|
||||
Version string
|
||||
}
|
||||
|
||||
// The states an engine's installation can be in.
|
||||
const (
|
||||
Installing = "installing"
|
||||
Ready = "ready"
|
||||
Failed = "failed"
|
||||
Unsupported = "unsupported"
|
||||
)
|
||||
|
||||
// Status is where one engine's installation stands.
|
||||
type Status struct {
|
||||
Engine string
|
||||
Version string
|
||||
Variant string
|
||||
State string
|
||||
// Detail is for a person: what is happening, or what went wrong.
|
||||
Detail string
|
||||
// Binary is the executable, once ready.
|
||||
Binary string
|
||||
}
|
||||
|
||||
// Installer installs engines under one directory.
|
||||
type Installer struct {
|
||||
// Root is where engines are installed, one directory per build:
|
||||
// <root>/<engine>/<version>-<variant>.
|
||||
Root string
|
||||
Host discovery.Host
|
||||
Log *slog.Logger
|
||||
HTTP *http.Client
|
||||
// OnStatus hears every change, so a download that takes minutes is seen
|
||||
// to be under way rather than silent.
|
||||
OnStatus func(Status)
|
||||
|
||||
// Run executes a command; a seam for tests. Nil runs it for real.
|
||||
Run func(ctx context.Context, env []string, name string, args ...string) ([]byte, error)
|
||||
// APIBase and DownloadBase default to GitHub; tests point them at a fake.
|
||||
APIBase string
|
||||
DownloadBase string
|
||||
}
|
||||
|
||||
// DefaultRoot is where engines are installed when nothing says otherwise.
|
||||
const DefaultRoot = "/opt/llmgw/engines"
|
||||
|
||||
// marker is written into a build's directory once it has been installed and
|
||||
// seen to run. Its absence means the directory is not to be trusted.
|
||||
const marker = ".llmgw-installed"
|
||||
|
||||
type markerFile struct {
|
||||
Engine string `json:"engine"`
|
||||
Version string `json:"version"`
|
||||
Variant string `json:"variant"`
|
||||
Binary string `json:"binary"`
|
||||
SHA256 map[string]string `json:"sha256"`
|
||||
Installed time.Time `json:"installed"`
|
||||
}
|
||||
|
||||
// Converge brings every wanted engine to ready, or says why it cannot.
|
||||
//
|
||||
// An engine already installed is checked again rather than believed: its
|
||||
// directory can be damaged, or a library it needs removed by somebody's
|
||||
// upgrade, and that is found here rather than by the next replica to fail.
|
||||
func (in *Installer) Converge(ctx context.Context, wants []Want) []Status {
|
||||
out := make([]Status, 0, len(wants))
|
||||
for _, w := range wants {
|
||||
st := in.one(ctx, w)
|
||||
in.report(st)
|
||||
out = append(out, st)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (in *Installer) one(ctx context.Context, w Want) Status {
|
||||
st := Status{Engine: w.Engine, Version: w.Version}
|
||||
recipe, ok := Recipes[w.Engine]
|
||||
if !ok {
|
||||
st.State = Unsupported
|
||||
st.Detail = "this agent does not know how to install " + w.Engine +
|
||||
"; install it by hand and name it in the agent's configuration"
|
||||
return st
|
||||
}
|
||||
build, err := recipe.Choose(w.Version, in.Host)
|
||||
if err != nil {
|
||||
st.State, st.Detail = Unsupported, err.Error()
|
||||
return st
|
||||
}
|
||||
st.Variant = build.Variant
|
||||
dir := filepath.Join(in.root(), w.Engine, w.Version+"-"+build.Variant)
|
||||
|
||||
// Installed before: check it still runs, and trust nothing else about it.
|
||||
if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
|
||||
// The packages are checked too. Removing one is exactly the kind of
|
||||
// change an unrelated upgrade makes, and ldd below would catch it only
|
||||
// as a missing library with no hint of which package held it.
|
||||
if err := in.packages(ctx, build.Packages, &st); err != nil {
|
||||
st.State, st.Detail = Failed, err.Error()
|
||||
return st
|
||||
}
|
||||
bin, version, err := in.verify(ctx, dir, build)
|
||||
if err == nil {
|
||||
return in.ready(st, build, bin, version)
|
||||
}
|
||||
in.log().Warn("an installed engine no longer runs; installing it again",
|
||||
"engine", w.Engine, "version", w.Version, "err", err)
|
||||
}
|
||||
|
||||
if err := in.install(ctx, recipe, w, build, dir, &st); err != nil {
|
||||
st.State, st.Detail, st.Binary = Failed, err.Error(), ""
|
||||
return st
|
||||
}
|
||||
bin, version, err := in.verify(ctx, dir, build)
|
||||
if err != nil {
|
||||
st.State, st.Detail = Failed, err.Error()
|
||||
return st
|
||||
}
|
||||
return in.ready(st, build, bin, version)
|
||||
}
|
||||
|
||||
func (in *Installer) ready(st Status, b Build, bin, version string) Status {
|
||||
st.State, st.Binary = Ready, bin
|
||||
st.Detail = strings.TrimSpace(filepath.Base(bin) + " " + version)
|
||||
var missing []string
|
||||
for _, f := range b.CPUFlags {
|
||||
if !in.Host.HasCPUFlag(f) {
|
||||
missing = append(missing, f)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
// Said on every report, not once in a log: it is the difference between
|
||||
// five tokens a second and forty, and a VM given QEMU's default CPU
|
||||
// model looks entirely healthy otherwise.
|
||||
st.Detail += "; this CPU lacks " + strings.Join(missing, ", ") +
|
||||
", so it will run several times slower (a VM needs CPU type \"host\")"
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// install fetches, checks and unpacks a build into dir.
|
||||
//
|
||||
// Into a scratch directory renamed into place at the end, so a failure halfway
|
||||
// leaves nothing that looks installed.
|
||||
func (in *Installer) install(ctx context.Context, r Recipe, w Want, b Build, dir string, st *Status) error {
|
||||
if err := in.packages(ctx, b.Packages, st); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
in.progress(st, "asking GitHub for the checksums of %s %s", w.Engine, w.Version)
|
||||
sums, err := in.checksums(ctx, r.Repo, w.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmp := dir + ".partial"
|
||||
_ = os.RemoveAll(tmp)
|
||||
if err := os.MkdirAll(tmp, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(tmp) //nolint:errcheck // gone either way once renamed
|
||||
|
||||
got := map[string]string{}
|
||||
for _, asset := range b.Assets {
|
||||
want, ok := sums[asset]
|
||||
if !ok {
|
||||
// Refused rather than installed unchecked: this is a binary that
|
||||
// will run as root on every node.
|
||||
return fmt.Errorf("%s %s publishes no checksum for %s, so it cannot be verified",
|
||||
w.Engine, w.Version, asset)
|
||||
}
|
||||
archive := filepath.Join(tmp, asset+".download")
|
||||
if err := in.download(ctx, r.Repo, w.Version, asset, archive, want, st); err != nil {
|
||||
return err
|
||||
}
|
||||
in.progress(st, "unpacking %s", asset)
|
||||
if err := untarGz(archive, tmp); err != nil {
|
||||
return fmt.Errorf("unpacking %s: %w", asset, err)
|
||||
}
|
||||
_ = os.Remove(archive)
|
||||
got[asset] = want
|
||||
}
|
||||
|
||||
in.progress(st, "checking that %s runs", b.Binary)
|
||||
bin, _, err := in.verify(ctx, tmp, b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(tmp, bin)
|
||||
m, _ := json.MarshalIndent(markerFile{
|
||||
Engine: w.Engine, Version: w.Version, Variant: b.Variant,
|
||||
Binary: rel, SHA256: got, Installed: time.Now().UTC(),
|
||||
}, "", " ")
|
||||
if err := os.WriteFile(filepath.Join(tmp, marker), m, 0o644); err != nil { //nolint:gosec // not secret
|
||||
return err
|
||||
}
|
||||
_ = os.RemoveAll(dir)
|
||||
if err := os.Rename(tmp, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
in.log().Info("installed an engine", "engine", w.Engine, "version", w.Version,
|
||||
"variant", b.Variant, "dir", dir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// verify finds the binary and runs it.
|
||||
//
|
||||
// Two checks, because they fail differently. ldd names a missing library
|
||||
// outright, where running the binary would only say it could not start. And
|
||||
// running it is the only proof that the rest — the right instruction set, the
|
||||
// right driver — is in place.
|
||||
func (in *Installer) verify(ctx context.Context, dir string, b Build) (bin, version string, err error) {
|
||||
bin, err = findFile(dir, b.Binary)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if out, err := in.run(ctx, nil, "ldd", bin); err == nil {
|
||||
if missing := lddMissing(string(out)); len(missing) > 0 {
|
||||
return "", "", fmt.Errorf("%s cannot run on this system: %s", b.Binary, strings.Join(missing, "; "))
|
||||
}
|
||||
}
|
||||
tctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
out, err := in.run(tctx, nil, bin, "--version")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("%s --version failed: %v: %s", b.Binary, err, lastLine(out))
|
||||
}
|
||||
return bin, firstVersionLine(out), nil
|
||||
}
|
||||
|
||||
// packages installs the operating system packages a build needs.
|
||||
//
|
||||
// Only those missing, and from the node's own package sources: a package the
|
||||
// system already has is not touched, so an engine never upgrades something
|
||||
// the rest of the machine depends on.
|
||||
func (in *Installer) packages(ctx context.Context, byFamily map[string][]string, st *Status) error {
|
||||
family := in.Host.Family()
|
||||
pkgs := byFamily[family]
|
||||
if len(pkgs) == 0 {
|
||||
// A system of no family this knows is not refused: its packages may
|
||||
// well be there, and if they are not, ldd names the missing library.
|
||||
return nil
|
||||
}
|
||||
pm := packageManagers[family]
|
||||
var missing []string
|
||||
for _, p := range pkgs {
|
||||
if _, err := in.run(ctx, nil, pm.query[0], append(pm.query[1:], p)...); err != nil {
|
||||
missing = append(missing, p)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
tool, cmds := pm.install(in, missing)
|
||||
if tool == "" {
|
||||
return fmt.Errorf("%s is missing and no package manager was found to install it",
|
||||
strings.Join(missing, ", "))
|
||||
}
|
||||
in.progress(st, "installing %s with %s", strings.Join(missing, ", "), tool)
|
||||
for _, c := range cmds {
|
||||
if err := in.runWaitingForLock(ctx, pm.env, c, st); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// lockWait bounds how long a package command waits for another to finish.
|
||||
const lockWait = 10 * time.Minute
|
||||
|
||||
// lockRetry is how often a locked package manager is tried again.
|
||||
var lockRetry = 10 * time.Second
|
||||
|
||||
// runWaitingForLock runs a package command, waiting while another holds the
|
||||
// package manager's lock.
|
||||
//
|
||||
// A machine a minute old is running cloud-init's and unattended-upgrades' own
|
||||
// apt. apt's DPkg::Lock::Timeout covers dpkg's lock and not the one `apt-get
|
||||
// update` takes on its lists, so the first install on a new node failed with
|
||||
// "Unable to lock directory /var/lib/apt/lists/" — found by building one.
|
||||
func (in *Installer) runWaitingForLock(ctx context.Context, env, c []string, st *Status) error {
|
||||
deadline := time.Now().Add(lockWait)
|
||||
for {
|
||||
out, err := in.run(ctx, env, c[0], c[1:]...)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !lockHeld(string(out)) || time.Now().After(deadline) {
|
||||
return fmt.Errorf("%s: %v: %s", strings.Join(c, " "), err, lastLine(out))
|
||||
}
|
||||
in.progress(st, "waiting for another %s to finish (a new machine runs its own updates first)", c[0])
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(lockRetry):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lockHeld reports whether a package command failed only because another
|
||||
// held the lock.
|
||||
func lockHeld(out string) bool {
|
||||
for _, s := range []string{"Unable to lock", "Could not get lock", "is locked by another process",
|
||||
"waiting for process with pid", "System management is locked"} {
|
||||
if strings.Contains(out, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// packageManager is how one packaging family asks and installs.
|
||||
type packageManager struct {
|
||||
// query exits zero when the package named after it is installed.
|
||||
query []string
|
||||
env []string
|
||||
// install picks the tool present on this machine and the commands to run.
|
||||
install func(in *Installer, pkgs []string) (tool string, cmds [][]string)
|
||||
}
|
||||
|
||||
var packageManagers = map[string]packageManager{
|
||||
"deb": {
|
||||
// dpkg -s exits zero for a package merely known about, so the status
|
||||
// is asked for and checked by the command itself: ${db:Status-Abbrev}
|
||||
// is "ii " only when installed.
|
||||
query: []string{"sh", "-c", `test "$(dpkg-query -W -f='${db:Status-Abbrev}' "$0" 2>/dev/null)" = "ii "`},
|
||||
env: []string{"DEBIAN_FRONTEND=noninteractive"},
|
||||
install: func(_ *Installer, pkgs []string) (string, [][]string) {
|
||||
// A lock timeout rather than failing on the lock: a machine a
|
||||
// minute old is running cloud-init's and unattended-upgrades' own
|
||||
// apt, and the first attempt on every new node would fail.
|
||||
lock := "-o=DPkg::Lock::Timeout=600"
|
||||
return "apt-get", [][]string{
|
||||
{"apt-get", lock, "update"},
|
||||
append([]string{"apt-get", lock, "install", "-y", "--no-install-recommends"}, pkgs...),
|
||||
}
|
||||
},
|
||||
},
|
||||
"rpm": {
|
||||
query: []string{"rpm", "-q"},
|
||||
install: func(in *Installer, pkgs []string) (string, [][]string) {
|
||||
for _, tool := range []string{"dnf", "yum"} {
|
||||
if in.has(tool) {
|
||||
return tool, [][]string{append([]string{tool, "install", "-y"}, pkgs...)}
|
||||
}
|
||||
}
|
||||
if in.has("zypper") {
|
||||
return "zypper", [][]string{append([]string{"zypper", "--non-interactive", "install"}, pkgs...)}
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// has reports whether a command is on PATH. Asked through the same seam as
|
||||
// every other command, so a test can say which tools a machine has.
|
||||
func (in *Installer) has(tool string) bool {
|
||||
_, err := in.run(context.Background(), nil, "sh", "-c", `command -v "$0"`, tool)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// checksums reads the sha256 GitHub publishes for each asset of a release.
|
||||
func (in *Installer) checksums(ctx context.Context, repo, tag string) (map[string]string, error) {
|
||||
url := strings.TrimSuffix(in.apiBase(), "/") + "/repos/" + repo + "/releases/tags/" + tag
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
resp, err := in.client().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reaching GitHub for %s %s: %w", repo, tag, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return nil, fmt.Errorf("%s has no release %s", repo, tag)
|
||||
case resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests:
|
||||
// Unauthenticated callers get sixty requests an hour per address, and
|
||||
// a whole fleet behind one NAT shares them.
|
||||
return nil, fmt.Errorf("GitHub is rate-limiting this address (%s); it retries later", resp.Status)
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
return nil, fmt.Errorf("GitHub answered %s for %s %s", resp.Status, repo, tag)
|
||||
}
|
||||
var rel struct {
|
||||
Assets []struct {
|
||||
Name string `json:"name"`
|
||||
Digest string `json:"digest"`
|
||||
} `json:"assets"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return nil, fmt.Errorf("reading %s %s from GitHub: %w", repo, tag, err)
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, a := range rel.Assets {
|
||||
if sum, ok := strings.CutPrefix(a.Digest, "sha256:"); ok && len(sum) == 64 {
|
||||
out[a.Name] = sum
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (in *Installer) download(ctx context.Context, repo, tag, asset, dst, want string, st *Status) error {
|
||||
url := strings.TrimSuffix(in.downloadBase(), "/") + "/" + repo + "/releases/download/" + tag + "/" + asset
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := in.client().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("downloading %s: %w", asset, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("downloading %s: %s", asset, resp.Status)
|
||||
}
|
||||
if resp.ContentLength > 0 {
|
||||
in.progress(st, "downloading %s (%d MB)", asset, resp.ContentLength>>20)
|
||||
} else {
|
||||
in.progress(st, "downloading %s", asset)
|
||||
}
|
||||
f, err := os.Create(dst) //nolint:gosec // inside the install root
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h := sha256.New()
|
||||
_, err = io.Copy(io.MultiWriter(f, h), resp.Body)
|
||||
if cerr := f.Close(); err == nil {
|
||||
err = cerr
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("downloading %s: %w", asset, err)
|
||||
}
|
||||
if got := hex.EncodeToString(h.Sum(nil)); got != want {
|
||||
return fmt.Errorf("%s does not match the checksum its publisher gave (got %s, expected %s)",
|
||||
asset, got[:12], want[:12])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// untarGz unpacks an archive under dir, refusing anything that would land
|
||||
// outside it. A release archive is somebody else's file, and a path of
|
||||
// "../../etc/cron.d/x" in it would otherwise be written as root.
|
||||
func untarGz(archive, dir string) error {
|
||||
f, err := os.Open(archive) //nolint:gosec // inside the install root
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := within(dir, h.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch h.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fs.FileMode(h.Mode)&0o755) //nolint:gosec // checked above
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, tr); err != nil { //nolint:gosec // a release archive, size bounded by its checksum
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeSymlink:
|
||||
// Libraries are versioned through symlinks, and those are
|
||||
// relative. One pointing anywhere else is refused.
|
||||
if filepath.IsAbs(h.Linkname) {
|
||||
return fmt.Errorf("%s links outside the archive", h.Name)
|
||||
}
|
||||
if _, err := within(dir, filepath.Join(filepath.Dir(h.Name), h.Linkname)); err != nil {
|
||||
return fmt.Errorf("%s links outside the archive", h.Name)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(target)
|
||||
if err := os.Symlink(h.Linkname, target); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// Devices, FIFOs and hard links have no business in a release.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func within(dir, name string) (string, error) {
|
||||
target := filepath.Join(dir, name) //nolint:gosec // checked on the next line
|
||||
rel, err := filepath.Rel(dir, target)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("%s would unpack outside the install directory", name)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// lddMissing reads what ldd could not satisfy, in two shapes:
|
||||
//
|
||||
// libgomp.so.1 => not found
|
||||
// /lib64/libstdc++.so.6: version `GLIBCXX_3.4.30' not found (required by ...)
|
||||
//
|
||||
// The second is a system too old for the build — RHEL 9 against a build that
|
||||
// wants GCC 12's libstdc++ — and is said as such, rather than as a library
|
||||
// that is plainly there being "not found".
|
||||
func lddMissing(out string) []string {
|
||||
seen := map[string]bool{}
|
||||
var missing []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.Contains(line, "not found") {
|
||||
continue
|
||||
}
|
||||
var what string
|
||||
if lib, rest, ok := strings.Cut(line, ": version `"); ok {
|
||||
sym, _, _ := strings.Cut(rest, "'")
|
||||
what = filepath.Base(lib) + " is too old (needs " + sym + ")"
|
||||
} else {
|
||||
what = strings.TrimSpace(strings.SplitN(line, "=>", 2)[0]) + " is not installed"
|
||||
}
|
||||
if !seen[what] {
|
||||
seen[what] = true
|
||||
missing = append(missing, what)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func findFile(dir, name string) (string, error) {
|
||||
var found string
|
||||
_ = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil || found != "" {
|
||||
return nil //nolint:nilerr // an unreadable subtree is not the binary
|
||||
}
|
||||
if !d.IsDir() && d.Name() == name {
|
||||
found = p
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if found == "" {
|
||||
return "", fmt.Errorf("the build has no %s in it", name)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
func (in *Installer) progress(st *Status, format string, args ...any) {
|
||||
st.State, st.Detail = Installing, fmt.Sprintf(format, args...)
|
||||
in.report(*st)
|
||||
}
|
||||
|
||||
func (in *Installer) report(st Status) {
|
||||
if in.OnStatus != nil {
|
||||
in.OnStatus(st)
|
||||
}
|
||||
}
|
||||
|
||||
func (in *Installer) run(ctx context.Context, env []string, name string, args ...string) ([]byte, error) {
|
||||
if in.Run != nil {
|
||||
return in.Run(ctx, env, name, args...)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // fixed commands and paths we installed
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
return cmd.CombinedOutput()
|
||||
}
|
||||
|
||||
func (in *Installer) root() string {
|
||||
if in.Root != "" {
|
||||
return in.Root
|
||||
}
|
||||
return DefaultRoot
|
||||
}
|
||||
|
||||
func (in *Installer) log() *slog.Logger {
|
||||
if in.Log != nil {
|
||||
return in.Log
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
func (in *Installer) client() *http.Client {
|
||||
if in.HTTP != nil {
|
||||
return in.HTTP
|
||||
}
|
||||
return &http.Client{Timeout: 30 * time.Minute}
|
||||
}
|
||||
|
||||
func (in *Installer) apiBase() string {
|
||||
if in.APIBase != "" {
|
||||
return in.APIBase
|
||||
}
|
||||
return "https://api.github.com"
|
||||
}
|
||||
|
||||
func (in *Installer) downloadBase() string {
|
||||
if in.DownloadBase != "" {
|
||||
return in.DownloadBase
|
||||
}
|
||||
return "https://github.com"
|
||||
}
|
||||
|
||||
func lastLine(b []byte) string {
|
||||
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
|
||||
return strings.TrimSpace(lines[len(lines)-1])
|
||||
}
|
||||
|
||||
func firstVersionLine(b []byte) string {
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
if strings.Contains(strings.ToLower(l), "version") {
|
||||
return strings.TrimSpace(l)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
)
|
||||
|
||||
var ubuntu = discovery.Host{OSID: "ubuntu", OSVersion: "24.04", Arch: "amd64", CPUFlags: []string{"avx2"}}
|
||||
|
||||
// release is a fake GitHub: one release, its assets and their digests.
|
||||
type release struct {
|
||||
assets map[string][]byte
|
||||
digests map[string]string
|
||||
gets int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func tarGz(t *testing.T, files map[string]string, links map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
for name, body := range files {
|
||||
_ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg})
|
||||
_, _ = tw.Write([]byte(body))
|
||||
}
|
||||
for name, target := range links {
|
||||
_ = tw.WriteHeader(&tar.Header{Name: name, Linkname: target, Typeflag: tar.TypeSymlink})
|
||||
}
|
||||
_ = tw.Close()
|
||||
_ = gz.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func (r *release) serve(t *testing.T) *httptest.Server {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
r.mu.Lock()
|
||||
r.gets++
|
||||
r.mu.Unlock()
|
||||
if strings.HasPrefix(req.URL.Path, "/repos/") {
|
||||
var out struct {
|
||||
Assets []map[string]string `json:"assets"`
|
||||
}
|
||||
for name := range r.assets {
|
||||
out.Assets = append(out.Assets, map[string]string{"name": name, "digest": r.digests[name]})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
return
|
||||
}
|
||||
body, ok := r.assets[filepath.Base(req.URL.Path)]
|
||||
if !ok {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func newRelease(assets map[string][]byte) *release {
|
||||
r := &release{assets: assets, digests: map[string]string{}}
|
||||
for name, b := range assets {
|
||||
sum := sha256.Sum256(b)
|
||||
r.digests[name] = "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// fakeSystem answers the package tools, ldd and the engine itself.
|
||||
type fakeSystem struct {
|
||||
mu sync.Mutex
|
||||
installed map[string]bool
|
||||
tools map[string]bool
|
||||
lddOut string
|
||||
calls []string
|
||||
// locked is how many more package commands find the lock held.
|
||||
locked int
|
||||
}
|
||||
|
||||
var errExit = errors.New("exit 1")
|
||||
|
||||
func (f *fakeSystem) run(_ context.Context, _ []string, name string, args ...string) ([]byte, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls = append(f.calls, name+" "+strings.Join(args, " "))
|
||||
switch {
|
||||
case name == "sh" && strings.Contains(args[1], "dpkg-query"),
|
||||
name == "rpm" && args[0] == "-q":
|
||||
if f.installed[args[len(args)-1]] {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errExit
|
||||
case name == "sh" && strings.Contains(args[1], "command -v"):
|
||||
if f.tools[args[2]] {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errExit
|
||||
case name == "apt-get" || name == "dnf":
|
||||
if f.locked > 0 {
|
||||
f.locked--
|
||||
return []byte("E: Unable to lock directory /var/lib/apt/lists/\n"), errExit
|
||||
}
|
||||
for i, a := range args {
|
||||
if a == "install" {
|
||||
for _, p := range args[i+1:] {
|
||||
if !strings.HasPrefix(p, "-") {
|
||||
f.installed[p] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
case name == "ldd":
|
||||
return []byte(f.lddOut), nil
|
||||
case strings.HasSuffix(name, "llama-server"):
|
||||
return []byte("version: 10991 (abc)\n"), nil
|
||||
}
|
||||
return nil, errors.New("unexpected command " + name)
|
||||
}
|
||||
|
||||
func installer(t *testing.T, srv *httptest.Server, sys *fakeSystem, host discovery.Host) (*Installer, *[]Status) {
|
||||
var seen []Status
|
||||
var mu sync.Mutex
|
||||
return &Installer{
|
||||
Root: t.TempDir(), Host: host, Run: sys.run,
|
||||
APIBase: srv.URL, DownloadBase: srv.URL, HTTP: srv.Client(),
|
||||
OnStatus: func(s Status) { mu.Lock(); seen = append(seen, s); mu.Unlock() },
|
||||
}, &seen
|
||||
}
|
||||
|
||||
func llamaRelease(t *testing.T) *release {
|
||||
return newRelease(map[string][]byte{
|
||||
"llama-b1-bin-ubuntu-x64.tar.gz": tarGz(t,
|
||||
map[string]string{"llama-b1/llama-server": "#!/bin/sh\n", "llama-b1/libllama.so.0": "x"},
|
||||
map[string]string{"llama-b1/libllama.so": "libllama.so.0"}),
|
||||
})
|
||||
}
|
||||
|
||||
func TestAnEngineIsInstalledFromNothing(t *testing.T) {
|
||||
// A node rebuilt from a bare image: no engine, no OpenMP runtime. What it
|
||||
// ends with has to have come from the publisher, checked, and been run.
|
||||
rel := llamaRelease(t)
|
||||
sys := &fakeSystem{installed: map[string]bool{}}
|
||||
in, seen := installer(t, rel.serve(t), sys, ubuntu)
|
||||
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "llamacpp", Version: "b1"}})
|
||||
if len(got) != 1 || got[0].State != Ready {
|
||||
t.Fatalf("not ready: %+v", got)
|
||||
}
|
||||
if !sys.installed["libgomp1"] {
|
||||
t.Error("the OpenMP runtime the build links against was not installed")
|
||||
}
|
||||
if !strings.HasPrefix(got[0].Binary, in.Root) {
|
||||
t.Errorf("binary %s is not the one installed under %s", got[0].Binary, in.Root)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(filepath.Dir(got[0].Binary), "libllama.so")); err != nil {
|
||||
t.Errorf("the library symlink was not unpacked: %v", err)
|
||||
}
|
||||
var states []string
|
||||
for _, s := range *seen {
|
||||
states = append(states, s.State+": "+s.Detail)
|
||||
}
|
||||
if !strings.Contains(strings.Join(states, "\n"), "downloading") {
|
||||
t.Errorf("a download was not reported while it ran:\n%s", strings.Join(states, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInstalledEngineIsCheckedNotFetchedAgain(t *testing.T) {
|
||||
rel := llamaRelease(t)
|
||||
sys := &fakeSystem{installed: map[string]bool{}}
|
||||
in, _ := installer(t, rel.serve(t), sys, ubuntu)
|
||||
w := []Want{{Engine: "llamacpp", Version: "b1"}}
|
||||
in.Converge(t.Context(), w)
|
||||
before := rel.gets
|
||||
|
||||
got := in.Converge(t.Context(), w)
|
||||
if got[0].State != Ready {
|
||||
t.Fatalf("second pass: %+v", got[0])
|
||||
}
|
||||
if rel.gets != before {
|
||||
t.Errorf("a ready engine was downloaded again (%d requests)", rel.gets-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEngineThatCannotLoadALibraryIsNotReady(t *testing.T) {
|
||||
// Found on a node before: llama-server present, executable, and unable to
|
||||
// start for want of a library — while the node reported ready.
|
||||
rel := llamaRelease(t)
|
||||
sys := &fakeSystem{installed: map[string]bool{"libgomp1": true},
|
||||
lddOut: "\tlibcuda.so.1 => not found\n"}
|
||||
in, _ := installer(t, rel.serve(t), sys, ubuntu)
|
||||
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "llamacpp", Version: "b1"}})
|
||||
if got[0].State != Failed || !strings.Contains(got[0].Detail, "libcuda.so.1") {
|
||||
t.Fatalf("expected a failure naming the library: %+v", got[0])
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(in.Root, "llamacpp", "b1-cpu", marker)); err == nil {
|
||||
t.Error("a build that does not run was marked installed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestADownloadThatDoesNotMatchItsChecksumIsRefused(t *testing.T) {
|
||||
rel := llamaRelease(t)
|
||||
for name := range rel.digests {
|
||||
rel.digests[name] = "sha256:" + strings.Repeat("0", 64)
|
||||
}
|
||||
sys := &fakeSystem{installed: map[string]bool{"libgomp1": true}}
|
||||
in, _ := installer(t, rel.serve(t), sys, ubuntu)
|
||||
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "llamacpp", Version: "b1"}})
|
||||
if got[0].State != Failed || !strings.Contains(got[0].Detail, "checksum") {
|
||||
t.Fatalf("a tampered download was not refused: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnArchiveCannotWriteOutsideItsDirectory(t *testing.T) {
|
||||
rel := newRelease(map[string][]byte{
|
||||
"llama-b1-bin-ubuntu-x64.tar.gz": tarGz(t,
|
||||
map[string]string{"../../escaped": "x", "llama-server": ""}, nil),
|
||||
})
|
||||
sys := &fakeSystem{installed: map[string]bool{"libgomp1": true}}
|
||||
in, _ := installer(t, rel.serve(t), sys, ubuntu)
|
||||
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "llamacpp", Version: "b1"}})
|
||||
if got[0].State != Failed {
|
||||
t.Fatalf("an archive escaping its directory was installed: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBuildFollowsTheHardware(t *testing.T) {
|
||||
gpu := ubuntu
|
||||
gpu.NVIDIADriver = "570.86.15"
|
||||
b, err := chooseLlamaCpp("b1", gpu)
|
||||
if err != nil || !strings.HasPrefix(b.Variant, "cuda") || len(b.Assets) != 2 {
|
||||
t.Errorf("an NVIDIA node got %+v, %v; expected the CUDA build and its runtime", b, err)
|
||||
}
|
||||
if _, err := chooseLlamaCpp("b1", discovery.Host{OSID: "ubuntu", Arch: "riscv64"}); err == nil {
|
||||
t.Error("a build was chosen for an architecture llama.cpp does not publish for")
|
||||
}
|
||||
|
||||
slow := ubuntu
|
||||
slow.CPUFlags = nil
|
||||
st := (&Installer{Host: slow}).ready(Status{}, Build{CPUFlags: []string{"avx2"}}, "/x/llama-server", "")
|
||||
if !strings.Contains(st.Detail, "avx2") {
|
||||
t.Errorf("a CPU without AVX2 was not called out: %q", st.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEngineTheAgentCannotInstallSaysSo(t *testing.T) {
|
||||
in := &Installer{Root: t.TempDir(), Host: ubuntu}
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "vllm", Version: "0.29.0"}})
|
||||
if got[0].State != Unsupported {
|
||||
t.Errorf("got %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestARedHatFamilyNodeInstallsWithItsOwnPackageManager(t *testing.T) {
|
||||
// The build is not Ubuntu's in any way that matters; the package that
|
||||
// carries OpenMP's runtime is named differently, and installed by dnf.
|
||||
rocky := discovery.Host{OSID: "rocky", OSVersion: "10.0", OSLike: []string{"rhel", "centos", "fedora"},
|
||||
Arch: "amd64", CPUFlags: []string{"avx2"}}
|
||||
rel := llamaRelease(t)
|
||||
sys := &fakeSystem{installed: map[string]bool{}, tools: map[string]bool{"dnf": true}}
|
||||
in, _ := installer(t, rel.serve(t), sys, rocky)
|
||||
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "llamacpp", Version: "b1"}})
|
||||
if got[0].State != Ready {
|
||||
t.Fatalf("not ready: %+v", got[0])
|
||||
}
|
||||
if !sys.installed["libgomp"] || sys.installed["libgomp1"] {
|
||||
t.Errorf("installed %v; expected the rpm name, libgomp", sys.installed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASystemTooOldForTheBuildSaysWhatItLacks(t *testing.T) {
|
||||
// RHEL 9: glibc is new enough, libstdc++ is one GCC release short. ldd
|
||||
// finds the library and reports the symbol, and "libstdc++ not found"
|
||||
// would send somebody looking for a package that is installed.
|
||||
rel := llamaRelease(t)
|
||||
sys := &fakeSystem{installed: map[string]bool{"libgomp1": true},
|
||||
lddOut: "\t/lib64/libstdc++.so.6: version `GLIBCXX_3.4.30' not found (required by /x/libllama.so)\n" +
|
||||
"\t/lib64/libstdc++.so.6: version `GLIBCXX_3.4.30' not found (required by /x/llama-server)\n"}
|
||||
in, _ := installer(t, rel.serve(t), sys, ubuntu)
|
||||
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "llamacpp", Version: "b1"}})
|
||||
if got[0].State != Failed || !strings.Contains(got[0].Detail, "libstdc++.so.6 is too old (needs GLIBCXX_3.4.30)") {
|
||||
t.Fatalf("detail %q", got[0].Detail)
|
||||
}
|
||||
if strings.Count(got[0].Detail, "GLIBCXX") != 1 {
|
||||
t.Errorf("the same shortfall was listed more than once: %q", got[0].Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPackageManagerBusyWithItsOwnUpdatesIsWaitedFor(t *testing.T) {
|
||||
// A new node's first minutes: cloud-init and unattended-upgrades hold
|
||||
// apt's lists lock, which DPkg::Lock::Timeout does not cover.
|
||||
lockRetry = time.Millisecond
|
||||
t.Cleanup(func() { lockRetry = 10 * time.Second })
|
||||
|
||||
rel := llamaRelease(t)
|
||||
sys := &fakeSystem{installed: map[string]bool{}, locked: 3}
|
||||
in, seen := installer(t, rel.serve(t), sys, ubuntu)
|
||||
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "llamacpp", Version: "b1"}})
|
||||
if got[0].State != Ready {
|
||||
t.Fatalf("gave up on a lock another apt held: %+v", got[0])
|
||||
}
|
||||
waited := false
|
||||
for _, s := range *seen {
|
||||
waited = waited || strings.Contains(s.Detail, "waiting for another apt-get")
|
||||
}
|
||||
if !waited {
|
||||
t.Error("the wait was not reported")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Package provision installs the engines a node is asked to have.
|
||||
//
|
||||
// The control plane says which engines at which versions; everything else is
|
||||
// worked out here, on the node, because only the node knows it: which build
|
||||
// fits its processor and driver, what its operating system is missing, and
|
||||
// whether the result actually runs. Nothing already on the machine is taken
|
||||
// on trust. An engine counts as installed when this package installed it,
|
||||
// checked every file against the checksum its publisher gave, and watched it
|
||||
// start.
|
||||
package provision
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
)
|
||||
|
||||
// Build is one published build of an engine, chosen for one machine.
|
||||
type Build struct {
|
||||
// Variant names the build: "cpu", "cuda-12.8".
|
||||
Variant string
|
||||
// Assets are the release files to fetch, unpacked in order into one
|
||||
// directory.
|
||||
Assets []string
|
||||
// Packages are operating system packages the build links against and a
|
||||
// minimal install may not carry, by packaging family ("deb", "rpm"): the
|
||||
// same library is libgomp1 on one and libgomp on the other.
|
||||
Packages map[string][]string
|
||||
// Binary is the executable's file name, found anywhere in the unpacked
|
||||
// tree.
|
||||
Binary string
|
||||
// CPUFlags are extensions the build is slow without. Missing ones are
|
||||
// reported, not refused: slow is still running.
|
||||
CPUFlags []string
|
||||
}
|
||||
|
||||
// Recipe knows how to install one engine.
|
||||
type Recipe struct {
|
||||
// Repo is the GitHub repository whose releases carry the builds.
|
||||
Repo string
|
||||
// Choose picks the build for a machine, or says why none fits.
|
||||
Choose func(version string, h discovery.Host) (Build, error)
|
||||
}
|
||||
|
||||
// Recipes are the engines this agent can install.
|
||||
//
|
||||
// vLLM and SGLang are not here yet: they are Python environments of several
|
||||
// gigabytes rather than an archive, and are installed by hand and named in the
|
||||
// agent's configuration until they are.
|
||||
var Recipes = map[string]Recipe{
|
||||
"llamacpp": {Repo: "ggml-org/llama.cpp", Choose: chooseLlamaCpp},
|
||||
}
|
||||
|
||||
// cudaForLlamaCpp is the CUDA build taken on an NVIDIA node. The release also
|
||||
// carries a matching cudart archive, so the node needs a driver and nothing
|
||||
// else — no toolkit.
|
||||
const cudaForLlamaCpp = "12.8"
|
||||
|
||||
func chooseLlamaCpp(version string, h discovery.Host) (Build, error) {
|
||||
// The published Linux builds are built on Ubuntu, but nothing about them
|
||||
// is Ubuntu's: they need glibc 2.34 and the libstdc++ of GCC 12, which
|
||||
// Debian 12 and RHEL 10 have and RHEL 9 does not. So the distribution's
|
||||
// name decides nothing here. The build is tried everywhere, and ldd says
|
||||
// whether this system can load it — in the words of the missing symbol.
|
||||
var arch string
|
||||
switch h.Arch {
|
||||
case "amd64":
|
||||
arch = "x64"
|
||||
case "arm64":
|
||||
arch = "arm64"
|
||||
default:
|
||||
return Build{}, fmt.Errorf("llama.cpp publishes no Linux build for %s", orUnknown(h.Arch))
|
||||
}
|
||||
|
||||
b := Build{
|
||||
Variant: "cpu",
|
||||
Assets: []string{fmt.Sprintf("llama-%s-bin-ubuntu-%s.tar.gz", version, arch)},
|
||||
// Everything else the build links is in the archive or in any current
|
||||
// glibc system; OpenMP's runtime is the one a cloud image lacks, and
|
||||
// the server does not start without it.
|
||||
Packages: map[string][]string{"deb": {"libgomp1"}, "rpm": {"libgomp"}},
|
||||
Binary: "llama-server",
|
||||
}
|
||||
if arch == "x64" {
|
||||
// The archive carries a backend per CPU generation and loads the best
|
||||
// one, down to SSE4.2. Below AVX2 it runs, several times slower.
|
||||
b.CPUFlags = []string{"avx2"}
|
||||
}
|
||||
// The CUDA build is published for x64 at this version; on arm64 it is a
|
||||
// newer CUDA that the driver may not support, so an arm64 GPU node runs
|
||||
// the CPU build until someone decides otherwise.
|
||||
if h.NVIDIADriver != "" && arch == "x64" {
|
||||
name := fmt.Sprintf("bin-ubuntu-cuda-%s-x64.tar.gz", cudaForLlamaCpp)
|
||||
b.Variant = "cuda-" + cudaForLlamaCpp
|
||||
b.Assets = []string{
|
||||
fmt.Sprintf("llama-%s-%s", version, name),
|
||||
fmt.Sprintf("cudart-llama-%s-%s", version, name),
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func orUnknown(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "an unrecognised system"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/weights"
|
||||
)
|
||||
|
||||
// agentWithCache is an agent that only knows where its weights live, which is
|
||||
// all removeWeights needs.
|
||||
func agentWithCache(t *testing.T, dir string) *Agent {
|
||||
t.Helper()
|
||||
return &Agent{
|
||||
cfg: Config{ModelDirs: []string{dir}},
|
||||
log: slog.New(slog.DiscardHandler),
|
||||
}
|
||||
}
|
||||
|
||||
func write(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte("weights"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestTheAgentRefusesToDeleteWhatItDidNotFetch(t *testing.T) {
|
||||
// The control plane computes these paths, but the node holds the files. A
|
||||
// control plane that is wrong, newer than this agent, or tampered with must
|
||||
// not be able to talk it into deleting a model an operator spent a week
|
||||
// copying here. So the agent checks again, on its own.
|
||||
dir := t.TempDir()
|
||||
a := agentWithCache(t, dir)
|
||||
|
||||
operators := write(t, filepath.Join(dir, "qwen2.5-coder-14b-instruct-q8_0.gguf"))
|
||||
fetched := write(t, weights.CachePathFor(dir, "https://models.internal/gone.gguf"))
|
||||
|
||||
a.removeWeights([]string{operators, fetched})
|
||||
|
||||
if _, err := os.Stat(operators); err != nil {
|
||||
t.Errorf("a file llmgw never fetched was deleted: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(fetched); !os.IsNotExist(err) {
|
||||
t.Errorf("the fetched file was not removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAgentRefusesPathsOutsideItsCache(t *testing.T) {
|
||||
// Even with a name that looks like one of ours. The cache directory is the
|
||||
// boundary, and traversal out of it is the attack this closes.
|
||||
dir := t.TempDir()
|
||||
elsewhere := t.TempDir()
|
||||
a := agentWithCache(t, dir)
|
||||
|
||||
// A correctly named cache file, in the wrong directory.
|
||||
outside := write(t, weights.CachePathFor(elsewhere, "https://models.internal/x.gguf"))
|
||||
// And the same thing reached by traversal from inside the cache.
|
||||
traversal := filepath.Join(dir, "..", filepath.Base(elsewhere),
|
||||
filepath.Base(outside))
|
||||
|
||||
a.removeWeights([]string{outside, traversal})
|
||||
|
||||
if _, err := os.Stat(outside); err != nil {
|
||||
t.Errorf("a file outside the cache was deleted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovingAFileThatIsAlreadyGoneIsNotAFailure(t *testing.T) {
|
||||
// The expected outcome of a retry. It must not stop the rest of the batch.
|
||||
dir := t.TempDir()
|
||||
a := agentWithCache(t, dir)
|
||||
|
||||
missing := weights.CachePathFor(dir, "https://models.internal/never.gguf")
|
||||
present := write(t, weights.CachePathFor(dir, "https://models.internal/here.gguf"))
|
||||
|
||||
a.removeWeights([]string{missing, present})
|
||||
|
||||
if _, err := os.Stat(present); !os.IsNotExist(err) {
|
||||
t.Errorf("a missing file stopped the batch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithNoCacheConfiguredNothingIsDeleted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := &Agent{cfg: Config{}, log: slog.New(slog.DiscardHandler)}
|
||||
kept := write(t, weights.CachePathFor(dir, "https://models.internal/x.gguf"))
|
||||
|
||||
a.removeWeights([]string{kept})
|
||||
|
||||
if _, err := os.Stat(kept); err != nil {
|
||||
t.Errorf("an agent with no cache directory deleted something: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||
)
|
||||
|
||||
func TestAnAgentConfigNeedsOnlyWhereTheControlPlaneIs(t *testing.T) {
|
||||
// Every node carried its own name, its own address and the same two
|
||||
// directories written out by hand, so no two agent files were alike and a
|
||||
// copy taken from one node registered the next as the first.
|
||||
cfg := Config{Server: "127.0.0.1:7443"}
|
||||
if err := cfg.normalize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host, _ := os.Hostname()
|
||||
if cfg.NodeID != host || cfg.Hostname != host {
|
||||
t.Errorf("node_id %q, hostname %q; expected the machine's name %q",
|
||||
cfg.NodeID, cfg.Hostname, host)
|
||||
}
|
||||
if cfg.MgmtAddr != "127.0.0.1" {
|
||||
t.Errorf("mgmt_addr %q, expected the address on the route to the control plane",
|
||||
cfg.MgmtAddr)
|
||||
}
|
||||
if cfg.WeightsCache != DefaultWeightsCache {
|
||||
t.Errorf("weights land in %q", cfg.WeightsCache)
|
||||
}
|
||||
if !slices.Contains(cfg.ModelDirs, cfg.WeightsCache) {
|
||||
t.Errorf("the download directory %q is not looked in: %v",
|
||||
cfg.WeightsCache, cfg.ModelDirs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatIsWrittenStillWins(t *testing.T) {
|
||||
cfg := Config{
|
||||
Server: "127.0.0.1:7443", NodeID: "gpu-a-01", MgmtAddr: "10.9.9.9",
|
||||
ModelDirs: []string{"/lustre/models"}, WeightsCache: "/scratch/w",
|
||||
}
|
||||
if err := cfg.normalize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.NodeID != "gpu-a-01" || cfg.MgmtAddr != "10.9.9.9" {
|
||||
t.Errorf("configured identity overridden: %q %q", cfg.NodeID, cfg.MgmtAddr)
|
||||
}
|
||||
if !slices.Equal(cfg.ModelDirs, []string{"/lustre/models", "/scratch/w"}) {
|
||||
t.Errorf("model dirs %v", cfg.ModelDirs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHeartbeatSaysWhenTheEnginesChanged(t *testing.T) {
|
||||
cfg := Config{Server: "127.0.0.1:7443", NodeID: "n1", WeightsCache: t.TempDir()}
|
||||
cfg.Superv.WorkDir = t.TempDir()
|
||||
a, err := New(cfg, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engines := func(m *llmgwv1.AgentMessage) *llmgwv1.Heartbeat { return m.GetHeartbeat() }
|
||||
|
||||
a.mu.Lock()
|
||||
a.configuredVers = map[string]string{"llamacpp": "unknown"}
|
||||
a.enginesSent = enginesFingerprint(a.configuredVers)
|
||||
a.mu.Unlock()
|
||||
if hb := engines(a.heartbeat()); hb.GetEnginesChanged() {
|
||||
t.Errorf("unchanged engines were resent")
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.configuredVers = map[string]string{"llamacpp": "unknown", "vllm": "unknown"}
|
||||
a.mu.Unlock()
|
||||
hb := engines(a.heartbeat())
|
||||
if !hb.GetEnginesChanged() || len(hb.GetEngineVersions()) != 2 {
|
||||
t.Errorf("an installed engine was not reported: %v", hb.GetEngineVersions())
|
||||
}
|
||||
if engines(a.heartbeat()).GetEnginesChanged() {
|
||||
t.Errorf("reported again on the next heartbeat")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatTheControlPlaneAsksToInstallIsReportedBack(t *testing.T) {
|
||||
cfg := Config{Server: "127.0.0.1:7443", NodeID: "n1", WeightsCache: t.TempDir(),
|
||||
EnginesDir: t.TempDir()}
|
||||
cfg.Superv.WorkDir = t.TempDir()
|
||||
a, err := New(cfg, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go a.provisionLoop(ctx)
|
||||
|
||||
// An engine this agent cannot install: no network is needed to learn so,
|
||||
// and the answer has to reach the control plane rather than a log.
|
||||
a.setProvision(&llmgwv1.Provision{Engines: []*llmgwv1.EngineWant{{Engine: "vllm", Version: "0.29.0"}}})
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
hb := a.heartbeat().GetHeartbeat()
|
||||
if hb.GetProvisionChanged() {
|
||||
p := hb.GetProvision()
|
||||
if len(p) != 1 || p[0].GetState() != "unsupported" {
|
||||
t.Fatalf("reported %v", p)
|
||||
}
|
||||
a.mu.Lock()
|
||||
_, reported := a.engineReportLocked()["vllm"]
|
||||
a.mu.Unlock()
|
||||
if reported {
|
||||
t.Error("an engine that was not installed is reported as present")
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("the installation's outcome never reached a heartbeat")
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
|
||||
)
|
||||
|
||||
func normalized(t *testing.T, c Config) Config {
|
||||
t.Helper()
|
||||
if err := c.normalizeServerURL(); err != nil {
|
||||
t.Fatalf("normalising %+v: %v", c, err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestOneServerStillWorksExactlyAsBefore(t *testing.T) {
|
||||
// Every agent configuration written before there could be a second control
|
||||
// plane names one address, and none of them should have to change.
|
||||
c := normalized(t, Config{Server: "10.0.0.140:7443"})
|
||||
if !slices.Equal(c.Servers, []string{"http://10.0.0.140:7443"}) {
|
||||
t.Errorf("servers = %v", c.Servers)
|
||||
}
|
||||
if c.Server != "http://10.0.0.140:7443" {
|
||||
t.Errorf("server = %q", c.Server)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAddressAlreadyThereStaysPreferred(t *testing.T) {
|
||||
// An operator adding a standby to an existing file expects the control
|
||||
// plane that was already working to keep being tried first.
|
||||
c := normalized(t, Config{
|
||||
Server: "first:7443",
|
||||
Servers: []string{"second:7443", "third:7443"},
|
||||
})
|
||||
want := []string{"http://first:7443", "http://second:7443", "http://third:7443"}
|
||||
if !slices.Equal(c.Servers, want) {
|
||||
t.Errorf("servers = %v, want %v", c.Servers, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADuplicateAddressIsNotTriedTwiceInARow(t *testing.T) {
|
||||
// Which would look exactly like the rotation not working.
|
||||
c := normalized(t, Config{
|
||||
Server: "a:7443",
|
||||
Servers: []string{"http://a:7443", "b:7443"},
|
||||
})
|
||||
want := []string{"http://a:7443", "http://b:7443"}
|
||||
if !slices.Equal(c.Servers, want) {
|
||||
t.Errorf("servers = %v, want %v", c.Servers, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryAddressIsCheckedAgainstTheTLSSetting(t *testing.T) {
|
||||
// The check existed for one address and has to hold for all of them: a
|
||||
// standby reached over plaintext while the leader is authenticated would
|
||||
// be a way around mutual TLS, not a fallback.
|
||||
c := Config{
|
||||
Servers: []string{"https://a:7443", "http://b:7443"},
|
||||
TLS: tlsconf.Config{CA: "ca.pem", Cert: "c.pem", Key: "k.pem"},
|
||||
}
|
||||
err := c.normalizeServerURL()
|
||||
if err == nil {
|
||||
t.Fatal("a plaintext standby was accepted alongside TLS")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "b:7443") {
|
||||
t.Errorf("the error does not say which address: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoServerAtAllIsStillAnError(t *testing.T) {
|
||||
c := Config{}
|
||||
if err := c.normalizeServerURL(); err == nil {
|
||||
t.Error("an agent with no control plane configured was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemeIsAddedPerAddress(t *testing.T) {
|
||||
c := normalized(t, Config{
|
||||
Servers: []string{"a:7443", "b:7443"},
|
||||
TLS: tlsconf.Config{CA: "ca.pem", Cert: "c.pem", Key: "k.pem"},
|
||||
})
|
||||
for _, s := range c.Servers {
|
||||
if !strings.HasPrefix(s, "https://") {
|
||||
t.Errorf("%q did not get the secure scheme", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
|
||||
)
|
||||
|
||||
// TestAgentStopsWhenContextEnds pins down something cancellation does not do by
|
||||
// itself. A bidirectional stream spends its life blocked reading, and HTTP/2
|
||||
// stops watching the context once the request headers are away, so an agent
|
||||
// that only cancels keeps reading until something kills it. Under systemd that
|
||||
// is the full stop timeout on every restart, followed by SIGKILL.
|
||||
func TestAgentStopsWhenContextEnds(t *testing.T) {
|
||||
serverTLS, clientTLS := pki(t, "llmgw-node01")
|
||||
svc, inv, url := startTLS(t, serverTLS)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.NodeID = "llmgw-node01"
|
||||
cfg.Server = url
|
||||
cfg.TLS = clientTLS
|
||||
cfg.HeartbeatInterval = 50 * time.Millisecond
|
||||
cfg.Discovery.SkipGPUs = true
|
||||
cfg.Discovery.IBRoot = filepath.Join(t.TempDir(), "absent")
|
||||
cfg.Superv = supervisor.Config{
|
||||
WorkDir: t.TempDir(), LogLines: 8,
|
||||
StopTimeout: time.Second, HealthInterval: 20 * time.Millisecond,
|
||||
}
|
||||
|
||||
client, err := Client(cfg.TLS, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ag, err := New(cfg, client, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { _ = ag.Run(ctx); close(done) }()
|
||||
|
||||
// Cancelling before the stream is established would prove nothing: the
|
||||
// blocked read is the case that matters.
|
||||
if !eventually(t, 10*time.Second, registered(svc, inv, "llmgw-node01")) {
|
||||
t.Fatal("the agent never registered")
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the agent did not stop within 5s of being told to")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
@@ -22,13 +24,42 @@ func killGroup(pid int, sig syscall.Signal) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// processAlive reports whether a pid exists.
|
||||
// processAlive reports whether a pid belongs to a process that is still doing
|
||||
// something.
|
||||
//
|
||||
// A zombie is not. It has exited and is only waiting to be reaped, but
|
||||
// kill(pid, 0) still succeeds on it — so a naive check reports a dead engine as
|
||||
// running forever. That happens for real whenever an engine's parent dies
|
||||
// before it does and nothing reaps the orphan: in a container, PID 1 is usually
|
||||
// the workload rather than an init, and orphans simply accumulate.
|
||||
func processAlive(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
// Signal 0 performs the permission and existence checks without delivering
|
||||
// anything.
|
||||
err := syscall.Kill(pid, 0)
|
||||
return err == nil || err == syscall.EPERM
|
||||
if err := syscall.Kill(pid, 0); err != nil {
|
||||
// EPERM means it exists but belongs to someone else, which for our
|
||||
// purposes is still "there".
|
||||
return err == syscall.EPERM
|
||||
}
|
||||
return !isZombie(pid)
|
||||
}
|
||||
|
||||
// isZombie reports whether a pid has exited but not been reaped.
|
||||
//
|
||||
// Only Linux exposes this; elsewhere there is no procfs and the answer is
|
||||
// false, which degrades to the old behaviour rather than to something worse.
|
||||
func isZombie(pid int) bool {
|
||||
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// The comm field is parenthesised and may itself contain spaces or
|
||||
// parentheses, so the state character is found from the last ')'.
|
||||
i := bytes.LastIndexByte(b, ')')
|
||||
if i < 0 || i+2 >= len(b) {
|
||||
return false
|
||||
}
|
||||
return b[i+2] == 'Z'
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
@@ -536,9 +537,9 @@ func TestCorruptStateFileIsCleanedUp(t *testing.T) {
|
||||
|
||||
// --- multi-process shards (plan §11, phase 4) ---
|
||||
|
||||
func TestSidecarRunsBeforeEngine(t *testing.T) {
|
||||
// The Ray shape: a supporting process starts first and keeps running, then
|
||||
// the engine starts against it.
|
||||
func TestASidecarAndAnEngineBothRunUnderOneShard(t *testing.T) {
|
||||
// The Ray shape: a supporting process runs alongside the engine for the
|
||||
// shard's lifetime, but the engine is the one whose exit ends the shard.
|
||||
s, _ := newSupervisor(t)
|
||||
dir := t.TempDir()
|
||||
order := filepath.Join(dir, "order")
|
||||
@@ -557,15 +558,34 @@ func TestSidecarRunsBeforeEngine(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if b, err := os.ReadFile(order); err == nil && strings.Count(string(b), "\n") == 2 {
|
||||
if got := strings.Fields(string(b)); got[0] != "sidecar" || got[1] != "engine" {
|
||||
t.Fatalf("start order was %v", got)
|
||||
}
|
||||
break
|
||||
// Generous, because this waits on two processes starting under the race
|
||||
// detector while every other package is competing for the same cores. Three
|
||||
// seconds was enough alone and not in a full run.
|
||||
var started []string
|
||||
deadline := time.Now().Add(20 * time.Second)
|
||||
for time.Now().Before(deadline) && len(started) < 2 {
|
||||
if b, err := os.ReadFile(order); err == nil {
|
||||
started = strings.Fields(string(b))
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if len(started) < 2 {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
// Asserted rather than broken out of: the original fell through on timeout,
|
||||
// so a slow machine turned this into a no-op and the failure surfaced later
|
||||
// as something else.
|
||||
if len(started) != 2 {
|
||||
t.Fatalf("only %d of 2 processes started: %v", len(started), started)
|
||||
}
|
||||
// Which of the two appended first is deliberately not asserted. The
|
||||
// supervisor spawns the sidecar first, but spawning is not sequencing:
|
||||
// nothing here makes the engine wait, so two shells racing to append give
|
||||
// either order and a test that demanded one would fail for the scheduler's
|
||||
// reasons rather than the supervisor's. The wait that is a real guarantee
|
||||
// is WaitForLeaderPort, and TestWaitForLeaderPortBlocksUntilListening is
|
||||
// where it is checked.
|
||||
if !slices.Contains(started, "sidecar") || !slices.Contains(started, "engine") {
|
||||
t.Fatalf("expected both processes to have run, got %v", started)
|
||||
}
|
||||
|
||||
// The shard's pid is the engine's, not the sidecar's: the engine's exit is
|
||||
@@ -574,7 +594,7 @@ func TestSidecarRunsBeforeEngine(t *testing.T) {
|
||||
t.Fatal("no engine pid recorded")
|
||||
}
|
||||
// Both processes appear in one log tail, distinguishable by prefix.
|
||||
deadline = time.Now().Add(3 * time.Second)
|
||||
deadline = time.Now().Add(20 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
logs := strings.Join(sh.Logs(20), "\n")
|
||||
if strings.Contains(logs, "[sidecar] hello-from-sidecar") &&
|
||||
@@ -727,3 +747,50 @@ func TestWaitAliveFailsOnEarlyExit(t *testing.T) {
|
||||
t.Errorf("waited %v for a process that had already exited", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAdoptedShardCanBecomeReady(t *testing.T) {
|
||||
// Adoption stopped at "the process is alive", leaving the shard in Starting
|
||||
// with nothing that would ever move it. The control plane then waited out
|
||||
// the startup timeout and killed a perfectly healthy engine to start an
|
||||
// identical one — so an agent restart, which exists precisely so engines
|
||||
// are not interrupted, cost every model on the node a full reload.
|
||||
//
|
||||
// Seen on node05: the engine answered /health throughout and the replica
|
||||
// sat in Starting for three minutes until the timeout reaped it.
|
||||
s, _ := newSupervisor(t)
|
||||
spec := engines.ShardSpec{ReplicaID: "rep-1", ShardID: "a", HTTPPort: 20201}
|
||||
if _, err := s.Start(t.Context(), "a", spec, shellCommand("sleep 30")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
health := &scriptedHealth{}
|
||||
s2, err := New(Config{
|
||||
WorkDir: s.cfg.WorkDir, StopTimeout: 2 * time.Second,
|
||||
HealthInterval: 10 * time.Millisecond,
|
||||
}, health, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
orphans, err := s2.FindOrphans()
|
||||
if err != nil || len(orphans) != 1 {
|
||||
t.Fatalf("orphan not found: %+v (%v)", orphans, err)
|
||||
}
|
||||
adopted, err := s2.Adopt(orphans[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := adopted.Status().State; got != Starting {
|
||||
t.Fatalf("a freshly adopted shard is %s, expected Starting", got)
|
||||
}
|
||||
|
||||
// The engine answers, as node05's did. The shard must be able to reach
|
||||
// Ready from here; before this it had no path to it at all.
|
||||
health.ready.Store(true)
|
||||
if err := s2.WaitReady(t.Context(), adopted, "http://127.0.0.1:20201", "m", 2*time.Second); err != nil {
|
||||
t.Fatalf("an adopted, healthy engine never became ready: %v", err)
|
||||
}
|
||||
if got := adopted.Status().State; got != Ready {
|
||||
t.Errorf("state %s after a passing health check", got)
|
||||
}
|
||||
_ = s2.Stop(t.Context(), "a")
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// Package weights makes a model's files present on a node.
|
||||
//
|
||||
// The simple arrangement, and the default: every node has its own copy. A
|
||||
// source that is already a local path is used as it stands; one that names a
|
||||
// remote file is fetched into a cache directory once and reused. Nothing here
|
||||
// manages storage, decides a retention policy, or knows what a filesystem is —
|
||||
// it answers one question, which is where the engine should be pointed.
|
||||
//
|
||||
// The exotic arrangements are all handled by not acting. Weights on a shared
|
||||
// filer, an image exported over the fabric, or anything else an operator has
|
||||
// mounted are simply present, and present means there is nothing to do. That is
|
||||
// why the interesting case needs no code: the escape hatch from a distribution
|
||||
// mechanism is a distribution mechanism that checks first.
|
||||
package weights
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Progress reports how a fetch is going, for anything that wants to say so.
|
||||
type Progress struct {
|
||||
Source string
|
||||
BytesDone int64
|
||||
BytesTotal int64
|
||||
}
|
||||
|
||||
// Percent is how far along, or -1 when the total is unknown.
|
||||
func (p Progress) Percent() int {
|
||||
if p.BytesTotal <= 0 {
|
||||
return -1
|
||||
}
|
||||
return int(p.BytesDone * 100 / p.BytesTotal)
|
||||
}
|
||||
|
||||
// Fetcher makes sources local.
|
||||
type Fetcher struct {
|
||||
CacheDir string
|
||||
Client *http.Client
|
||||
Log *slog.Logger
|
||||
// OnProgress is called occasionally during a fetch. A missing model is the
|
||||
// difference between a replica that is slow to start and one that is stuck,
|
||||
// and without this the two are indistinguishable from outside.
|
||||
OnProgress func(Progress)
|
||||
|
||||
// inflight makes concurrent requests for one source share a single fetch.
|
||||
//
|
||||
// Eight shards of a tensor-parallel replica start at the same instant and
|
||||
// want the same file. Without this they would each open a connection and
|
||||
// each write the same bytes, turning one download into eight and, on a
|
||||
// shared origin, into a self-inflicted denial of service.
|
||||
mu sync.Mutex
|
||||
inflight map[string]*fetchCall
|
||||
}
|
||||
|
||||
type fetchCall struct {
|
||||
done chan struct{}
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
// ErrNotLocal means a source names a file that is not here and cannot be
|
||||
// fetched — a bare path with nothing to fetch it from.
|
||||
var ErrNotLocal = errors.New("the weights are not on this node and the source says nothing about where to get them")
|
||||
|
||||
// Ensure returns a local path for a source, fetching it if it is not here.
|
||||
//
|
||||
// A plain path is never fetched, only checked. That is deliberate: a path is
|
||||
// what an operator writes when the file is already there, whether it arrived on
|
||||
// local disk, a Lustre mount or an image exported over the fabric, and a
|
||||
// downloader that second-guessed that would copy a hundred gigabytes that were
|
||||
// already present.
|
||||
func (f *Fetcher) Ensure(ctx context.Context, source string) (string, error) {
|
||||
if source == "" {
|
||||
return "", fmt.Errorf("no source given")
|
||||
}
|
||||
// A version rather than a file. Everything below assumes one URL is one
|
||||
// file, and a set has to be handled before any of it.
|
||||
if IsSet(source) {
|
||||
return f.ensureSet(ctx, source)
|
||||
}
|
||||
if !isRemote(source) {
|
||||
if !isPath(source) {
|
||||
// A bare model id — "Qwen/Qwen2.5-7B-Instruct" — is not a file and
|
||||
// was never meant to be one. vLLM and SGLang resolve those
|
||||
// themselves, and doing it for them would be both a duplicate
|
||||
// downloader and a change in documented behaviour.
|
||||
return source, nil
|
||||
}
|
||||
if _, err := os.Stat(source); err == nil {
|
||||
return source, nil
|
||||
}
|
||||
return "", fmt.Errorf("%w: %s", ErrNotLocal, source)
|
||||
}
|
||||
|
||||
target, err := f.cachePath(source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return target, nil
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
if f.inflight == nil {
|
||||
f.inflight = map[string]*fetchCall{}
|
||||
}
|
||||
if call, running := f.inflight[source]; running {
|
||||
f.mu.Unlock()
|
||||
select {
|
||||
case <-call.done:
|
||||
return call.path, call.err
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
call := &fetchCall{done: make(chan struct{})}
|
||||
f.inflight[source] = call
|
||||
f.mu.Unlock()
|
||||
|
||||
call.path, call.err = f.fetch(ctx, source, target)
|
||||
close(call.done)
|
||||
|
||||
f.mu.Lock()
|
||||
delete(f.inflight, source)
|
||||
f.mu.Unlock()
|
||||
return call.path, call.err
|
||||
}
|
||||
|
||||
// fetch downloads a source to a target, resuming and verifying.
|
||||
func (f *Fetcher) fetch(ctx context.Context, source, target string) (string, error) {
|
||||
return f.fetchAs(ctx, source, source, target)
|
||||
}
|
||||
|
||||
// fetchAs downloads one URL but reports progress under another name.
|
||||
//
|
||||
// The two differ for a set: the shards waiting on it asked for the version, not
|
||||
// for the fourth file in it, and progress reported under the file's own URL
|
||||
// reaches nobody. What they see is one number climbing across the whole set.
|
||||
func (f *Fetcher) fetchAs(ctx context.Context, source, reportAs, target string) (string, error) {
|
||||
url, err := resolveURL(source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Written beside the target and renamed at the end. A partial file under
|
||||
// the real name would be found by the next scan, reported as present, and
|
||||
// handed to an engine that fails on it minutes later — the exact failure
|
||||
// this whole area exists to prevent.
|
||||
part := target + ".part"
|
||||
existing := int64(0)
|
||||
if st, err := os.Stat(part); err == nil {
|
||||
existing = st.Size()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if existing > 0 {
|
||||
// Resume. A forty gigabyte fetch that fails at ninety percent must not
|
||||
// start again from nothing.
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", existing))
|
||||
}
|
||||
|
||||
client := f.Client
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetching %s: %w", source, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
existing = 0 // the server ignored the range; start over
|
||||
case http.StatusPartialContent:
|
||||
default:
|
||||
return "", fmt.Errorf("fetching %s: %s returned %s", source, url, resp.Status)
|
||||
}
|
||||
|
||||
flags := os.O_CREATE | os.O_WRONLY
|
||||
if existing > 0 {
|
||||
flags |= os.O_APPEND
|
||||
} else {
|
||||
flags |= os.O_TRUNC
|
||||
}
|
||||
out, err := os.OpenFile(part, flags, 0o644)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
total := existing + resp.ContentLength
|
||||
f.report(Progress{Source: reportAs, BytesDone: existing, BytesTotal: total})
|
||||
written, err := f.copyWithProgress(ctx, out, resp.Body, reportAs, existing, total)
|
||||
closeErr := out.Close()
|
||||
if err != nil {
|
||||
// The part file is kept on purpose, so the next attempt resumes.
|
||||
return "", fmt.Errorf("fetching %s after %d bytes: %w", source, written, err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return "", closeErr
|
||||
}
|
||||
|
||||
if err := os.Rename(part, target); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if f.Log != nil {
|
||||
f.Log.Info("weights fetched", "source", source, "path", target, "bytes", written)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (f *Fetcher) copyWithProgress(ctx context.Context, dst io.Writer, src io.Reader,
|
||||
source string, done, total int64,
|
||||
) (int64, error) {
|
||||
buf := make([]byte, 4<<20)
|
||||
last := time.Now()
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return done, err
|
||||
}
|
||||
n, err := src.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := dst.Write(buf[:n]); werr != nil {
|
||||
return done, werr
|
||||
}
|
||||
done += int64(n)
|
||||
// Occasionally, not per read: a four-megabyte buffer over a
|
||||
// hundred gigabytes is twenty-five thousand callbacks, and nobody
|
||||
// needs to be told that often.
|
||||
if time.Since(last) > 2*time.Second {
|
||||
f.report(Progress{Source: source, BytesDone: done, BytesTotal: total})
|
||||
last = time.Now()
|
||||
}
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
f.report(Progress{Source: source, BytesDone: done, BytesTotal: total})
|
||||
return done, nil
|
||||
}
|
||||
if err != nil {
|
||||
return done, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Fetcher) report(p Progress) {
|
||||
if f.OnProgress != nil {
|
||||
f.OnProgress(p)
|
||||
}
|
||||
}
|
||||
|
||||
// cachePath is where a remote source lands.
|
||||
//
|
||||
// Named by a hash of the source with the original filename kept on the end, so
|
||||
// two models with the same filename from different places cannot collide while
|
||||
// a human looking at the directory can still tell what is in it.
|
||||
func (f *Fetcher) cachePath(source string) (string, error) {
|
||||
if f.CacheDir == "" {
|
||||
return "", fmt.Errorf("no cache directory configured, so %s cannot be fetched", source)
|
||||
}
|
||||
return CachePathFor(f.CacheDir, source), nil
|
||||
}
|
||||
|
||||
// isPath reports whether a source names a file on this machine.
|
||||
//
|
||||
// By its leading character, because that is the only honest signal available:
|
||||
// "Qwen/Qwen2.5-7B-Instruct" and "models/qwen.gguf" are indistinguishable as
|
||||
// strings, and an operator writing a local file writes an absolute path. The
|
||||
// cost of being wrong in this direction is that a relative path is passed to
|
||||
// the engine untouched, which is what happened before this code existed.
|
||||
func isPath(source string) bool {
|
||||
return strings.HasPrefix(source, "/") ||
|
||||
strings.HasPrefix(source, "./") ||
|
||||
strings.HasPrefix(source, "../")
|
||||
}
|
||||
|
||||
// isRemote reports whether a source has to be fetched at all.
|
||||
func isRemote(source string) bool {
|
||||
return strings.HasPrefix(source, "hf:") ||
|
||||
strings.HasPrefix(source, "http://") ||
|
||||
strings.HasPrefix(source, "https://")
|
||||
}
|
||||
|
||||
// resolveURL turns a source into something to GET.
|
||||
//
|
||||
// hf: names one file in a repository, which is what a GGUF is. A whole
|
||||
// repository of shards is deliberately not handled here: vLLM and SGLang
|
||||
// download those themselves and do it well, and reimplementing a snapshot
|
||||
// downloader to compete with them would be work spent to arrive back where we
|
||||
// started.
|
||||
func resolveURL(source string) (string, error) {
|
||||
if rest, ok := strings.CutPrefix(source, "hf:"); ok {
|
||||
parts := strings.Split(rest, "/")
|
||||
if len(parts) < 3 {
|
||||
return "", fmt.Errorf(
|
||||
"%s names a repository, not a file; give the file as hf:org/repo/model.gguf, "+
|
||||
"or use the repository id directly and let the engine download it", source)
|
||||
}
|
||||
repo := strings.Join(parts[:2], "/")
|
||||
file := strings.Join(parts[2:], "/")
|
||||
return "https://huggingface.co/" + repo + "/resolve/main/" + file, nil
|
||||
}
|
||||
if _, err := url.Parse(source); err != nil {
|
||||
return "", fmt.Errorf("%s is not a usable source: %w", source, err)
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package weights
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func serveBytes(t *testing.T, body []byte, hits *atomic.Int64) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if hits != nil {
|
||||
hits.Add(1)
|
||||
}
|
||||
http.ServeContent(w, r, "model.gguf", time.Time{}, strings.NewReader(string(body)))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestALocalPathIsUsedNeverCopied(t *testing.T) {
|
||||
// A path is what an operator writes when the file is already there —
|
||||
// local disk, a Lustre mount, an image exported over the fabric. A
|
||||
// downloader that second-guessed that would copy a hundred gigabytes that
|
||||
// were already present.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "model.gguf")
|
||||
if err := os.WriteFile(path, []byte("weights"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
|
||||
got, err := f.Ensure(t.Context(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != path {
|
||||
t.Errorf("returned %q, want the original path", got)
|
||||
}
|
||||
if entries, _ := os.ReadDir(f.CacheDir); len(entries) != 0 {
|
||||
t.Errorf("the cache was written to for a local file: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMissingLocalPathSaysWhatIsWrong(t *testing.T) {
|
||||
// The commonest setup mistake, and "no such file" from an engine twenty
|
||||
// minutes into a load is a poor way to learn it.
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
_, err := f.Ensure(t.Context(), filepath.Join(t.TempDir(), "absent.gguf"))
|
||||
if !errors.Is(err, ErrNotLocal) {
|
||||
t.Fatalf("err = %v, want ErrNotLocal", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "absent.gguf") {
|
||||
t.Errorf("the error does not name the file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARemoteFileIsFetchedOnceAndReused(t *testing.T) {
|
||||
var hits atomic.Int64
|
||||
srv := serveBytes(t, []byte("the weights"), &hits)
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
|
||||
first, err := f.Ensure(t.Context(), srv.URL+"/model.gguf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile(first)
|
||||
if err != nil || string(body) != "the weights" {
|
||||
t.Fatalf("fetched content = %q, err %v", body, err)
|
||||
}
|
||||
|
||||
second, err := f.Ensure(t.Context(), srv.URL+"/model.gguf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second != first {
|
||||
t.Errorf("the second call returned a different path: %q vs %q", second, first)
|
||||
}
|
||||
if hits.Load() != 1 {
|
||||
t.Errorf("the file was fetched %d times; the cache did not hold", hits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentRequestsProduceOneDownload(t *testing.T) {
|
||||
// Eight shards of a tensor-parallel replica start at the same instant and
|
||||
// want the same file. Eight downloads is a self-inflicted denial of service
|
||||
// on whatever is serving them.
|
||||
var hits atomic.Int64
|
||||
srv := serveBytes(t, []byte("shared weights"), &hits)
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
paths := make([]string, 8)
|
||||
errs := make([]error, 8)
|
||||
for i := range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
paths[i], errs[i] = f.Ensure(t.Context(), srv.URL+"/model.gguf")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("caller %d failed: %v", i, err)
|
||||
}
|
||||
if paths[i] != paths[0] {
|
||||
t.Errorf("caller %d got %q, caller 0 got %q", i, paths[i], paths[0])
|
||||
}
|
||||
}
|
||||
if n := hits.Load(); n != 1 {
|
||||
t.Errorf("%d downloads for eight concurrent callers", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPartialDownloadIsNeverVisibleAsTheModel(t *testing.T) {
|
||||
// A partial file under the real name would be found by the next scan,
|
||||
// reported as present, and handed to an engine that fails on it minutes
|
||||
// later — the exact failure all of this exists to prevent.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", "1000")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("only the first bytes"))
|
||||
// and then the connection dies without the rest
|
||||
if fl, ok := w.(http.Flusher); ok {
|
||||
fl.Flush()
|
||||
}
|
||||
panic(http.ErrAbortHandler)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cache := t.TempDir()
|
||||
f := &Fetcher{CacheDir: cache}
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/model.gguf"); err == nil {
|
||||
t.Fatal("a truncated download was reported as success")
|
||||
}
|
||||
entries, _ := os.ReadDir(cache)
|
||||
for _, e := range entries {
|
||||
if !strings.HasSuffix(e.Name(), ".part") {
|
||||
t.Errorf("a non-part file was left behind: %q", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInterruptedDownloadResumes(t *testing.T) {
|
||||
// A forty gigabyte fetch that fails at ninety percent must not start again
|
||||
// from nothing.
|
||||
full := []byte(strings.Repeat("x", 4096))
|
||||
var ranges []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ranges = append(ranges, r.Header.Get("Range"))
|
||||
http.ServeContent(w, r, "model.gguf", time.Time{}, strings.NewReader(string(full)))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cache := t.TempDir()
|
||||
f := &Fetcher{CacheDir: cache}
|
||||
target, _ := f.cachePath(srv.URL + "/model.gguf")
|
||||
// Half a file already on disk, as an interrupted attempt would leave.
|
||||
if err := os.WriteFile(target+".part", full[:2048], 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := f.Ensure(t.Context(), srv.URL+"/model.gguf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := os.ReadFile(got)
|
||||
if len(body) != len(full) {
|
||||
t.Errorf("resumed file is %d bytes, want %d", len(body), len(full))
|
||||
}
|
||||
if len(ranges) == 0 || !strings.Contains(ranges[0], "2048") {
|
||||
t.Errorf("no range request was made: %v", ranges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnHFSourceNamingARepositoryExplainsItself(t *testing.T) {
|
||||
// A whole repository of shards is deliberately not handled here: vLLM and
|
||||
// SGLang download those themselves and do it well. Saying so beats a
|
||||
// confusing 404.
|
||||
_, err := resolveURL("hf:Qwen/Qwen2.5-Coder-32B-Instruct")
|
||||
if err == nil {
|
||||
t.Fatal("a bare repository was accepted as a file")
|
||||
}
|
||||
for _, want := range []string{"model.gguf", "let the engine"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the error does not suggest what to do (%q): %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnHFFileResolvesToItsDownloadURL(t *testing.T) {
|
||||
got, err := resolveURL("hf:Qwen/Qwen2.5-Coder-3B-Instruct-GGUF/qwen2.5-coder-3b-instruct-q4_k_m.gguf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "https://huggingface.co/Qwen/Qwen2.5-Coder-3B-Instruct-GGUF/resolve/main/qwen2.5-coder-3b-instruct-q4_k_m.gguf"
|
||||
if got != want {
|
||||
t.Errorf("resolved to\n %s\nwant\n %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoSourcesWithTheSameFilenameDoNotCollide(t *testing.T) {
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
a, _ := f.cachePath("https://one.example/model.gguf")
|
||||
b, _ := f.cachePath("https://two.example/model.gguf")
|
||||
if a == b {
|
||||
t.Fatalf("both sources cache to %q", a)
|
||||
}
|
||||
// And a person looking at the directory can still tell what is in it.
|
||||
for _, p := range []string{a, b} {
|
||||
if !strings.HasSuffix(p, "model.gguf") {
|
||||
t.Errorf("cache path %q does not keep the filename", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressIsReportedWithAPercentage(t *testing.T) {
|
||||
// A missing model is the difference between a replica that is slow to start
|
||||
// and one that is stuck, and without this they look identical.
|
||||
srv := serveBytes(t, []byte(strings.Repeat("y", 8192)), nil)
|
||||
var seen []Progress
|
||||
f := &Fetcher{
|
||||
CacheDir: t.TempDir(),
|
||||
OnProgress: func(p Progress) { seen = append(seen, p) },
|
||||
}
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/model.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
t.Fatal("no progress was reported")
|
||||
}
|
||||
last := seen[len(seen)-1]
|
||||
if last.Percent() != 100 {
|
||||
t.Errorf("final progress is %d%%, want 100 (%+v)", last.Percent(), last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPercentIsUnknownRatherThanZeroWithoutATotal(t *testing.T) {
|
||||
// A server that sends no Content-Length is common, and reporting 0% for
|
||||
// a download that is nearly finished is worse than admitting ignorance.
|
||||
if got := (Progress{BytesDone: 500}).Percent(); got != -1 {
|
||||
t.Errorf("Percent with no total = %d, want -1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoCacheDirectoryIsAClearRefusal(t *testing.T) {
|
||||
f := &Fetcher{}
|
||||
_, err := f.Ensure(t.Context(), "https://example.invalid/model.gguf")
|
||||
if err == nil || !strings.Contains(err.Error(), "cache directory") {
|
||||
t.Errorf("err = %v, want a complaint about the cache directory", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABareModelIdIsLeftToTheEngine(t *testing.T) {
|
||||
// "Qwen/Qwen2.5-7B-Instruct" is a documented source that vLLM and SGLang
|
||||
// resolve themselves. Treating it as a missing file broke every existing
|
||||
// vLLM profile, which is how this was found.
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
for _, id := range []string{
|
||||
"Qwen/Qwen2.5-7B-Instruct",
|
||||
"meta-llama/Llama-3.3-70B-Instruct",
|
||||
} {
|
||||
got, err := f.Ensure(t.Context(), id)
|
||||
if err != nil {
|
||||
t.Errorf("%s was refused: %v", id, err)
|
||||
}
|
||||
if got != id {
|
||||
t.Errorf("%s was rewritten to %q", id, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAbsolutePathThatIsMissingIsStillAnError(t *testing.T) {
|
||||
// The other half: an operator writing a path means a file, and a missing
|
||||
// one should be said plainly rather than handed to an engine.
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
if _, err := f.Ensure(t.Context(), "/models/definitely-not-here.gguf"); !errors.Is(err, ErrNotLocal) {
|
||||
t.Errorf("err = %v, want ErrNotLocal", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package weights
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cacheNamePattern matches a filename this package created.
|
||||
//
|
||||
// The eight-byte hash of the source, hex encoded, then a dash, then the
|
||||
// original filename — exactly what cachePath writes. It is the only mark of
|
||||
// ownership there is, and it has to be exact: everything else in a model
|
||||
// directory was put there by the operator, and deleting one of those files
|
||||
// could cost a week of someone's bandwidth.
|
||||
var cacheNamePattern = regexp.MustCompile(`^[0-9a-f]{16}-`)
|
||||
|
||||
// CachePathFor is where a source would be cached in the given directory.
|
||||
//
|
||||
// Exported so the control plane can work out which files in a node's reported
|
||||
// list are ones llmgw fetched for a model it still wants, without asking the
|
||||
// node anything. The agent and the control plane must agree on this name
|
||||
// exactly, which is why there is one function and not two.
|
||||
func CachePathFor(dir, source string) string {
|
||||
sum := sha256.Sum256([]byte(source))
|
||||
name := filepath.Base(source)
|
||||
if name == "" || name == "." || name == "/" {
|
||||
name = "weights"
|
||||
}
|
||||
return filepath.Join(dir, hex.EncodeToString(sum[:8])+"-"+name)
|
||||
}
|
||||
|
||||
// Owned reports whether a path is one this package created.
|
||||
//
|
||||
// The dividing line of the whole feature. A file without the cache name was
|
||||
// put there by the operator — by scp, by a shared mount, by hand — and llmgw
|
||||
// has no business deleting it however unreferenced it looks. A file with the
|
||||
// cache name is one llmgw downloaded, and llmgw can take it back.
|
||||
func Owned(path string) bool {
|
||||
return cacheNamePattern.MatchString(filepath.Base(path))
|
||||
}
|
||||
|
||||
// Orphans lists cached files that no wanted source accounts for.
|
||||
//
|
||||
// `have` is what a node reports on disk and `wanted` is every source in the
|
||||
// catalog; both are passed in so this is a calculation rather than a scan, and
|
||||
// so it can be run on the control plane against a node's reported inventory.
|
||||
//
|
||||
// Only files this package created are ever returned. A source that is not
|
||||
// fetched — a plain path, or a model id an engine resolves itself — produces
|
||||
// no cache name and so protects nothing and deletes nothing, which is correct:
|
||||
// those files are not in the cache to begin with.
|
||||
func Orphans(have []string, wanted []string) []string {
|
||||
keep := map[string]struct{}{}
|
||||
for _, src := range wanted {
|
||||
if !isRemote(src) {
|
||||
// Nothing was fetched for it, so nothing in the cache belongs to
|
||||
// it. Computing a name here would reserve a file that does not
|
||||
// exist and could never be an orphan anyway.
|
||||
continue
|
||||
}
|
||||
// By base name rather than full path: the control plane does not know
|
||||
// each node's cache directory, and the name is what identifies the
|
||||
// file wherever the node keeps it.
|
||||
keep[filepath.Base(CachePathFor("", src))] = struct{}{}
|
||||
}
|
||||
|
||||
var out []string
|
||||
seen := map[string]struct{}{}
|
||||
for _, path := range have {
|
||||
base := filepath.Base(path)
|
||||
if !Owned(base) {
|
||||
continue
|
||||
}
|
||||
if _, wanted := keep[base]; wanted {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[path]; dup {
|
||||
continue
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
out = append(out, path)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// PartialName reports whether a path is the temporary file a fetch writes to.
|
||||
//
|
||||
// Those are not orphans in the same sense — a fetch may be running right now —
|
||||
// so they are named separately rather than swept up with the rest. A `.part`
|
||||
// left behind by a killed agent is real garbage, but only its own node can
|
||||
// tell whether it is stale, and nothing here can.
|
||||
func PartialName(path string) bool {
|
||||
return strings.HasSuffix(path, ".part")
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package weights
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOnlyFilesLLMGWFetchedAreEverOrphans(t *testing.T) {
|
||||
// The invariant the whole feature rests on. Everything without the cache
|
||||
// name was put there by the operator — scp, a shared mount, a copy by hand
|
||||
// — and deleting one could cost a week of someone's bandwidth. It does not
|
||||
// matter how unreferenced it looks.
|
||||
const dir = "/var/lib/llmgw-models"
|
||||
have := []string{
|
||||
filepath.Join(dir, "qwen2.5-coder-14b-instruct-q8_0-00001-of-00002.gguf"),
|
||||
filepath.Join(dir, "some-model.safetensors"),
|
||||
filepath.Join(dir, "notes.txt"),
|
||||
CachePathFor(dir, "https://models.internal/gone.gguf"),
|
||||
}
|
||||
|
||||
got := Orphans(have, nil)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected only the fetched file, got %v", got)
|
||||
}
|
||||
if !Owned(got[0]) {
|
||||
t.Errorf("returned a file llmgw did not fetch: %s", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFetchedFileStillInTheCatalogIsKept(t *testing.T) {
|
||||
const dir = "/var/lib/llmgw-models"
|
||||
const wanted = "hf:Qwen/Qwen2.5-Coder-3B-Instruct-GGUF/q4.gguf"
|
||||
const gone = "https://models.internal/old.gguf"
|
||||
|
||||
have := []string{CachePathFor(dir, wanted), CachePathFor(dir, gone)}
|
||||
got := Orphans(have, []string{wanted})
|
||||
|
||||
if slices.Contains(got, CachePathFor(dir, wanted)) {
|
||||
t.Error("a file the catalog still points at was called an orphan")
|
||||
}
|
||||
if !slices.Contains(got, CachePathFor(dir, gone)) {
|
||||
t.Errorf("a file nothing points at was not found: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCacheNameMatchesWhatAFetchActuallyWrites(t *testing.T) {
|
||||
// The control plane proposes deletions by recomputing this name from the
|
||||
// catalog. If it ever drifted from what a fetch writes, the proposal would
|
||||
// name a file that is in use — so the two must be one function, and this
|
||||
// is what says so.
|
||||
f := &Fetcher{CacheDir: "/cache"}
|
||||
const source = "https://models.internal/x.gguf"
|
||||
|
||||
got, err := f.cachePath(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := CachePathFor("/cache", source); got != want {
|
||||
t.Errorf("a fetch writes %q but the catalog would look for %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOfTwoLookalikeFilesIsNotMistakenForTheOther(t *testing.T) {
|
||||
// That the two cache to different names is covered where the naming lives.
|
||||
// What matters here is the consequence: deleting the unreferenced one must
|
||||
// not take the referenced one with it, and both end in "model.gguf".
|
||||
a := CachePathFor("/cache", "https://one.example/model.gguf")
|
||||
b := CachePathFor("/cache", "https://two.example/model.gguf")
|
||||
|
||||
got := Orphans([]string{a, b}, []string{"https://one.example/model.gguf"})
|
||||
if !slices.Equal(got, []string{b}) {
|
||||
t.Errorf("orphans = %v, want only %s", got, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASourceThatIsNeverFetchedProtectsNothingAndDeletesNothing(t *testing.T) {
|
||||
// A plain path, or a model id an engine resolves itself, puts nothing in
|
||||
// the cache. Computing a cache name for it would reserve a file that does
|
||||
// not exist, and it must not stop a real orphan being found either.
|
||||
const dir = "/models"
|
||||
orphan := CachePathFor(dir, "https://models.internal/gone.gguf")
|
||||
|
||||
got := Orphans([]string{orphan, filepath.Join(dir, "local.gguf")},
|
||||
[]string{"/models/local.gguf", "Qwen/Qwen2.5-7B-Instruct"})
|
||||
|
||||
if !slices.Equal(got, []string{orphan}) {
|
||||
t.Errorf("orphans = %v, want only %s", got, orphan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyCatalogDoesNotMakeOperatorFilesDisposable(t *testing.T) {
|
||||
// The dangerous case: every model removed from the catalog at once. Files
|
||||
// llmgw fetched become orphans, which is right. Files it did not must stay
|
||||
// untouched, which is the whole point.
|
||||
const dir = "/models"
|
||||
have := []string{
|
||||
filepath.Join(dir, "hand-placed.gguf"),
|
||||
CachePathFor(dir, "https://models.internal/a.gguf"),
|
||||
}
|
||||
got := Orphans(have, nil)
|
||||
if len(got) != 1 || got[0] != CachePathFor(dir, "https://models.internal/a.gguf") {
|
||||
t.Errorf("orphans = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialsAreNotSweptUpWithTheRest(t *testing.T) {
|
||||
// A fetch may be running right now, and only the node itself can tell a
|
||||
// live download from one a killed agent left behind.
|
||||
if !PartialName("/models/abc-model.gguf.part") {
|
||||
t.Error("a .part file was not recognised")
|
||||
}
|
||||
if PartialName("/models/abc-model.gguf") {
|
||||
t.Error("a finished file was called partial")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package weights
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IsSet reports whether a source names a set of files rather than one.
|
||||
//
|
||||
// By the trailing slash, which is the whole signal: "…/v1/model.gguf" is a file
|
||||
// and "…/v1/" is the version it belongs to. A flag beside the source would be a
|
||||
// second place for the two to disagree, and the URL already distinguishes a
|
||||
// directory from a file everywhere else on the web.
|
||||
func IsSet(source string) bool {
|
||||
return isRemote(source) && strings.HasSuffix(source, "/")
|
||||
}
|
||||
|
||||
// manifest is what the mirror answers for a version.
|
||||
type manifest struct {
|
||||
Model string `json:"model"`
|
||||
Version string `json:"version"`
|
||||
Files []struct {
|
||||
Name string `json:"name"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
} `json:"files"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
// ensureSet downloads every file of a version into one directory.
|
||||
//
|
||||
// A directory, because that is what a multi-file model is: vLLM is handed the
|
||||
// folder and reads the shards and the config out of it, and llama.cpp is handed
|
||||
// the first part and finds the rest beside it. Downloading them into one place
|
||||
// is what makes either possible, and a catalog entry naming one file could
|
||||
// express neither.
|
||||
//
|
||||
// The whole set or nothing. A directory with three of four shards in it loads
|
||||
// as a broken model rather than failing to load, so a partial set is left
|
||||
// unfinished under its temporary name and the next attempt starts again.
|
||||
func (f *Fetcher) ensureSet(ctx context.Context, source string) (string, error) {
|
||||
dir, err := f.cachePath(strings.TrimSuffix(source, "/"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// A complete set is marked by a file written last. Checking for the
|
||||
// directory alone would treat an interrupted download as finished, which is
|
||||
// exactly the failure the marker exists to prevent.
|
||||
done := filepath.Join(dir, ".complete")
|
||||
if _, err := os.Stat(done); err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
m, err := f.manifestFor(ctx, source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(m.Files) == 0 {
|
||||
return "", fmt.Errorf("%s holds no files", source)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, file := range m.Files {
|
||||
// Rejected rather than joined: a name with a separator in it would
|
||||
// write outside the directory the mirror said it was in, and the
|
||||
// mirror is a place an operator copies files into by hand.
|
||||
if strings.ContainsAny(file.Name, `/\`) || file.Name == ".." {
|
||||
return "", fmt.Errorf("%s: the mirror offered a file named %q", source, file.Name)
|
||||
}
|
||||
target := filepath.Join(dir, file.Name)
|
||||
if st, err := os.Stat(target); err == nil && st.Size() == file.Bytes {
|
||||
continue
|
||||
}
|
||||
// Reported under the set's own address, because that is what the
|
||||
// shards are waiting on. Reusing fetch rather than writing a second
|
||||
// downloader keeps resume, the .part rename and the progress callback
|
||||
// in one place.
|
||||
if _, err := f.fetchAs(ctx, source+file.Name, source, target); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(done, []byte(source+"\n"), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// manifestFor asks the mirror what a version consists of.
|
||||
func (f *Fetcher) manifestFor(ctx context.Context, source string) (manifest, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
|
||||
if err != nil {
|
||||
return manifest{}, err
|
||||
}
|
||||
client := f.Client
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return manifest{}, fmt.Errorf("asking %s what it holds: %w", source, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return manifest{}, fmt.Errorf("%s answered %d", source, resp.StatusCode)
|
||||
}
|
||||
|
||||
var m manifest
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return manifest{}, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
// The likely cause is a plain file server with a directory index, which
|
||||
// answers HTML. Said plainly, because "invalid character '<'" sends
|
||||
// somebody looking at the wrong thing.
|
||||
return manifest{}, fmt.Errorf(
|
||||
"%s did not answer with a manifest; a source ending in / has to be a "+
|
||||
"mirror version, not a directory listing", source)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package weights
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mirrorOf serves a manifest at "/m/v/" and the files beneath it.
|
||||
func mirrorOf(t *testing.T, files map[string]string) (*Fetcher, *httptest.Server, *int) {
|
||||
t.Helper()
|
||||
hits := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/") {
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"model":"m","version":"v","files":[`)
|
||||
first := true
|
||||
total := 0
|
||||
for name, body := range files {
|
||||
if !first {
|
||||
b.WriteString(",")
|
||||
}
|
||||
first = false
|
||||
fmt.Fprintf(&b, `{"name":%q,"bytes":%d}`, name, len(body))
|
||||
total += len(body)
|
||||
}
|
||||
fmt.Fprintf(&b, `],"bytes":%d}`, total)
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
return
|
||||
}
|
||||
body, ok := files[strings.TrimPrefix(r.URL.Path, "/m/v/")]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
hits++
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return &Fetcher{CacheDir: t.TempDir()}, srv, &hits
|
||||
}
|
||||
|
||||
func TestASetIsFetchedIntoOneDirectory(t *testing.T) {
|
||||
f, srv, _ := mirrorOf(t, map[string]string{
|
||||
"model-00001-of-00002.safetensors": "aaaa",
|
||||
"model-00002-of-00002.safetensors": "bb",
|
||||
"config.json": "{}",
|
||||
})
|
||||
|
||||
got, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := os.Stat(got)
|
||||
if err != nil || !st.IsDir() {
|
||||
t.Fatalf("expected a directory, got %q: %v", got, err)
|
||||
}
|
||||
// A directory, because that is what a multi-file model is: vLLM reads the
|
||||
// shards and the config out of the folder, and a catalog entry naming one
|
||||
// file could express neither.
|
||||
for _, name := range []string{"model-00001-of-00002.safetensors", "config.json"} {
|
||||
if _, err := os.Stat(filepath.Join(got, name)); err != nil {
|
||||
t.Errorf("%s did not arrive: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFinishedSetIsNotFetchedAgain(t *testing.T) {
|
||||
f, srv, hits := mirrorOf(t, map[string]string{"a.gguf": "aaaa", "b.gguf": "bb"})
|
||||
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := *hits
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *hits != after {
|
||||
t.Errorf("the second call downloaded again: %d then %d", after, *hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHalfFetchedSetIsNotTreatedAsFinished(t *testing.T) {
|
||||
f, srv, _ := mirrorOf(t, map[string]string{"a.gguf": "aaaa", "b.gguf": "bb"})
|
||||
dir, err := f.cachePath(srv.URL + "/m/v")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// One file present, no completion marker — an interrupted download.
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "a.gguf"), []byte("aaaa"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A directory with three of four shards loads as a broken model rather
|
||||
// than failing to load, so the marker is what says the set is whole.
|
||||
if _, err := os.Stat(filepath.Join(got, "b.gguf")); err != nil {
|
||||
t.Errorf("the missing file was not fetched: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFileAlreadyThereAtTheRightSizeIsNotRefetched(t *testing.T) {
|
||||
f, srv, hits := mirrorOf(t, map[string]string{"a.gguf": "aaaa", "b.gguf": "bb"})
|
||||
dir, _ := f.cachePath(srv.URL + "/m/v")
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(dir, "a.gguf"), []byte("aaaa"), 0o644)
|
||||
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Resuming a set means not paying for the shards that already landed.
|
||||
if *hits != 1 {
|
||||
t.Errorf("expected only the missing file to be fetched, got %d downloads", *hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFilenameThatEscapesTheDirectoryIsRefused(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"files":[{"name":"../../etc/passwd","bytes":1}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
|
||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
||||
// The mirror is a place an operator copies files into by hand, so what it
|
||||
// reports is not automatically safe to join onto a path.
|
||||
if err == nil || !strings.Contains(err.Error(), "offered a file named") {
|
||||
t.Errorf("expected the name to be refused, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADirectoryListingIsNotAManifest(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("<pre><a href=\"a.gguf\">a.gguf</a></pre>"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
|
||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
||||
// "invalid character '<'" sends somebody looking at the wrong thing.
|
||||
if err == nil || !strings.Contains(err.Error(), "has to be a mirror version") {
|
||||
t.Errorf("the reason should name the cause, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyATrailingSlashMeansASet(t *testing.T) {
|
||||
for src, want := range map[string]bool{
|
||||
"http://mirror/m/v/": true,
|
||||
"http://mirror/m/v/model.gguf": false,
|
||||
"/var/lib/models/": false, // a local directory is not fetched
|
||||
"Qwen/Qwen2.5-7B-Instruct": false, // a repo id, resolved by the engine
|
||||
} {
|
||||
if got := IsSet(src); got != want {
|
||||
t.Errorf("IsSet(%q) = %v, want %v", src, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// Package auth authenticates client requests against the key store (plan §7.9).
|
||||
//
|
||||
// The rule that shapes everything here: authentication runs on every request,
|
||||
// and the request path does not touch the database. A key is looked up once,
|
||||
// resolved against its tenancy chain once, and then answered from memory until
|
||||
// the entry expires or is invalidated.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// Principal is who a request is, once authenticated.
|
||||
type Principal struct {
|
||||
KeyID string
|
||||
KeyPrefix string
|
||||
ProjectID string
|
||||
TeamID string
|
||||
OrgID string
|
||||
|
||||
// Band and RoutingProfile come from the key, and are authoritative: with
|
||||
// authentication on, a client can no longer promote itself with a header.
|
||||
Band string
|
||||
RoutingProfile string
|
||||
|
||||
Limits store.Effective
|
||||
}
|
||||
|
||||
// Allows reports whether this principal may use a model.
|
||||
func (p Principal) Allows(model string) bool { return p.Limits.Allows(model) }
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
// WithPrincipal attaches an authenticated identity to a context.
|
||||
func WithPrincipal(ctx context.Context, p Principal) context.Context {
|
||||
return context.WithValue(ctx, contextKey{}, p)
|
||||
}
|
||||
|
||||
// FromContext returns the authenticated identity, if there is one.
|
||||
//
|
||||
// Absent means authentication is off, which is a deployment choice rather than
|
||||
// a failure: a gateway on a trusted network with no keys configured still
|
||||
// serves. Callers must therefore treat the missing case as "unrestricted", and
|
||||
// the middleware is what guarantees it cannot mean "unchecked".
|
||||
func FromContext(ctx context.Context) (Principal, bool) {
|
||||
p, ok := ctx.Value(contextKey{}).(Principal)
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// Config tunes the authenticator.
|
||||
type Config struct {
|
||||
// TTL is how long a resolved key is trusted before being read again. It
|
||||
// bounds how stale a revocation can be, so it trades a database read per
|
||||
// key per TTL against how long a revoked key keeps working.
|
||||
TTL time.Duration
|
||||
// NegativeTTL is how long a rejected key is remembered as rejected, so a
|
||||
// client looping with a bad key cannot turn into a database read per
|
||||
// request.
|
||||
NegativeTTL time.Duration
|
||||
// TouchInterval is how often a key's last-used time is written, at most.
|
||||
TouchInterval time.Duration
|
||||
}
|
||||
|
||||
// DefaultConfig returns sensible values.
|
||||
func DefaultConfig() Config {
|
||||
return Config{TTL: 30 * time.Second, NegativeTTL: 5 * time.Second, TouchInterval: time.Minute}
|
||||
}
|
||||
|
||||
func (c *Config) normalize() {
|
||||
d := DefaultConfig()
|
||||
if c.TTL <= 0 {
|
||||
c.TTL = d.TTL
|
||||
}
|
||||
if c.NegativeTTL <= 0 {
|
||||
c.NegativeTTL = d.NegativeTTL
|
||||
}
|
||||
if c.TouchInterval <= 0 {
|
||||
c.TouchInterval = d.TouchInterval
|
||||
}
|
||||
}
|
||||
|
||||
// Errors an authenticator returns. They are distinguished because the status
|
||||
// codes differ: a missing credential is 401, a forbidden model is 403.
|
||||
var (
|
||||
ErrMissing = errors.New("no api key")
|
||||
ErrInvalid = errors.New("invalid api key")
|
||||
ErrExpired = errors.New("api key expired")
|
||||
ErrRevoked = errors.New("api key revoked")
|
||||
)
|
||||
|
||||
// Authenticator resolves keys and caches the result.
|
||||
type Authenticator struct {
|
||||
store store.Store
|
||||
cfg Config
|
||||
now func() time.Time
|
||||
|
||||
mu sync.RWMutex
|
||||
cache map[string]entry
|
||||
|
||||
// touch batches last-used timestamps; nothing reads the field in real time
|
||||
// and writing it per request would put a database write back on the path.
|
||||
touchMu sync.Mutex
|
||||
touch map[string]time.Time
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
principal Principal
|
||||
err error
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// New builds an authenticator over a store.
|
||||
func New(s store.Store, cfg Config) *Authenticator {
|
||||
cfg.normalize()
|
||||
return &Authenticator{
|
||||
store: s, cfg: cfg, now: time.Now,
|
||||
cache: map[string]entry{},
|
||||
touch: map[string]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate resolves a presented credential.
|
||||
func (a *Authenticator) Authenticate(ctx context.Context, presented string) (Principal, error) {
|
||||
if presented == "" {
|
||||
return Principal{}, ErrMissing
|
||||
}
|
||||
// Shape first: a bearer token from another system is rejected without a
|
||||
// lookup, and never reaches the store.
|
||||
if err := store.ParseKey(presented); err != nil {
|
||||
return Principal{}, ErrInvalid
|
||||
}
|
||||
|
||||
hash := store.HashKey(presented)
|
||||
id := string(hash)
|
||||
|
||||
if e, ok := a.lookup(id); ok {
|
||||
if e.err == nil {
|
||||
a.markUsed(e.principal.KeyID)
|
||||
}
|
||||
return e.principal, e.err
|
||||
}
|
||||
|
||||
p, err := a.resolve(ctx, hash)
|
||||
if err != nil && !isRejection(err) {
|
||||
// A database problem is not an authentication answer, so it must not be
|
||||
// cached as one. Failing the request is right — the alternative is
|
||||
// serving traffic whose authorization is unknown.
|
||||
return Principal{}, err
|
||||
}
|
||||
|
||||
ttl := a.cfg.TTL
|
||||
if err != nil {
|
||||
ttl = a.cfg.NegativeTTL
|
||||
}
|
||||
a.store_(id, entry{principal: p, err: err, expires: a.now().Add(ttl)})
|
||||
if err == nil {
|
||||
a.markUsed(p.KeyID)
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (a *Authenticator) resolve(ctx context.Context, hash []byte) (Principal, error) {
|
||||
k, err := a.store.KeyByHash(ctx, hash)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return Principal{}, ErrInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return Principal{}, err
|
||||
}
|
||||
switch {
|
||||
case !k.RevokedAt.IsZero():
|
||||
return Principal{}, ErrRevoked
|
||||
case !k.ExpiresAt.IsZero() && !a.now().Before(k.ExpiresAt):
|
||||
return Principal{}, ErrExpired
|
||||
}
|
||||
|
||||
eff, err := a.store.Resolve(ctx, k.ID)
|
||||
if err != nil {
|
||||
return Principal{}, err
|
||||
}
|
||||
return Principal{
|
||||
KeyID: k.ID, KeyPrefix: k.Prefix, ProjectID: eff.ProjectID,
|
||||
TeamID: eff.TeamID, OrgID: eff.OrgID,
|
||||
Band: eff.Band, RoutingProfile: eff.RoutingProfile,
|
||||
Limits: eff,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) lookup(id string) (entry, bool) {
|
||||
a.mu.RLock()
|
||||
e, ok := a.cache[id]
|
||||
a.mu.RUnlock()
|
||||
if !ok || a.now().After(e.expires) {
|
||||
return entry{}, false
|
||||
}
|
||||
return e, true
|
||||
}
|
||||
|
||||
func (a *Authenticator) store_(id string, e entry) {
|
||||
a.mu.Lock()
|
||||
a.cache[id] = e
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
// Invalidate drops every cached decision.
|
||||
//
|
||||
// Coarse on purpose: a limit change anywhere in the tree can change any key
|
||||
// below it, and working out which keys those are costs more than re-resolving
|
||||
// the few that are actually in use.
|
||||
func (a *Authenticator) Invalidate() {
|
||||
a.mu.Lock()
|
||||
a.cache = map[string]entry{}
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *Authenticator) markUsed(keyID string) {
|
||||
if keyID == "" {
|
||||
return
|
||||
}
|
||||
now := a.now()
|
||||
a.touchMu.Lock()
|
||||
if last, ok := a.touch[keyID]; !ok || now.Sub(last) >= a.cfg.TouchInterval {
|
||||
a.touch[keyID] = now
|
||||
}
|
||||
a.touchMu.Unlock()
|
||||
}
|
||||
|
||||
// FlushTouches writes the pending last-used timestamps. The batch writer calls
|
||||
// it on its own schedule.
|
||||
func (a *Authenticator) FlushTouches(ctx context.Context) error {
|
||||
a.touchMu.Lock()
|
||||
pending := a.touch
|
||||
a.touch = map[string]time.Time{}
|
||||
a.touchMu.Unlock()
|
||||
|
||||
if len(pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
return a.store.TouchKeys(ctx, pending)
|
||||
}
|
||||
|
||||
func isRejection(err error) bool {
|
||||
return errors.Is(err, ErrInvalid) || errors.Is(err, ErrExpired) ||
|
||||
errors.Is(err, ErrRevoked) || errors.Is(err, ErrMissing)
|
||||
}
|
||||
|
||||
// BearerToken extracts the credential from a request.
|
||||
//
|
||||
// Both spellings are accepted because both are in the wild: the OpenAI SDKs
|
||||
// send Authorization, and some tools only manage to set an api-key header.
|
||||
func BearerToken(r *http.Request) string {
|
||||
if h := r.Header.Get("Authorization"); h != "" {
|
||||
if token, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||
return strings.TrimSpace(token)
|
||||
}
|
||||
// A bare value is common enough in hand-written clients to accept.
|
||||
return strings.TrimSpace(h)
|
||||
}
|
||||
return strings.TrimSpace(r.Header.Get("api-key"))
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// fixture builds a store with one org/team/project and one key.
|
||||
func fixture(t *testing.T) (*store.DB, string, string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
s, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "t.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
must(t, s.PutOrg(ctx, store.Org{ID: "org1", Name: "Acme"}))
|
||||
must(t, s.PutTeam(ctx, store.Team{ID: "team1", OrgID: "org1", Name: "R"}))
|
||||
must(t, s.PutProject(ctx, store.Project{ID: "proj1", TeamID: "team1", Name: "P"}))
|
||||
|
||||
secret, k, err := store.NewKey("key1", "laptop", "proj1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k.CreatedAt = time.Now()
|
||||
k.Band = "gold"
|
||||
must(t, s.CreateKey(ctx, k))
|
||||
return s, secret, k.ID
|
||||
}
|
||||
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAValidKeyResolvesItsWholeChain(t *testing.T) {
|
||||
s, secret, keyID := fixture(t)
|
||||
a := New(s, DefaultConfig())
|
||||
|
||||
p, err := a.Authenticate(context.Background(), secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.KeyID != keyID || p.ProjectID != "proj1" || p.TeamID != "team1" || p.OrgID != "org1" {
|
||||
t.Errorf("principal %+v", p)
|
||||
}
|
||||
if p.Band != "gold" {
|
||||
t.Errorf("band %q", p.Band)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejections(t *testing.T) {
|
||||
s, secret, keyID := fixture(t)
|
||||
ctx := context.Background()
|
||||
a := New(s, DefaultConfig())
|
||||
|
||||
if _, err := a.Authenticate(ctx, ""); !errors.Is(err, ErrMissing) {
|
||||
t.Errorf("no credential: %v", err)
|
||||
}
|
||||
// A bearer token from some other system must be rejected on shape alone.
|
||||
if _, err := a.Authenticate(ctx, "ghp_0123456789abcdefghij"); !errors.Is(err, ErrInvalid) {
|
||||
t.Errorf("foreign token: %v", err)
|
||||
}
|
||||
if _, err := a.Authenticate(ctx, alter(secret)); !errors.Is(err, ErrInvalid) {
|
||||
t.Errorf("wrong key: %v", err)
|
||||
}
|
||||
|
||||
must(t, s.RevokeKey(ctx, keyID, time.Now()))
|
||||
a.Invalidate()
|
||||
if _, err := a.Authenticate(ctx, secret); !errors.Is(err, ErrRevoked) {
|
||||
t.Errorf("revoked key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// countingStore counts how often the database is actually read.
|
||||
type countingStore struct {
|
||||
store.Store
|
||||
lookups atomic.Int64
|
||||
fail atomic.Bool
|
||||
}
|
||||
|
||||
func (c *countingStore) KeyByHash(ctx context.Context, hash []byte) (store.Key, error) {
|
||||
c.lookups.Add(1)
|
||||
if c.fail.Load() {
|
||||
return store.Key{}, errors.New("database is down")
|
||||
}
|
||||
return c.Store.KeyByHash(ctx, hash)
|
||||
}
|
||||
|
||||
// alter changes a key by one character, guaranteeing the result differs.
|
||||
//
|
||||
// Replacing the last character with a fixed one does not: keys are base62, so
|
||||
// one in sixty-two already ends in that character and the "wrong" key is the
|
||||
// right one. That test passed locally for a day and failed in CI, which is
|
||||
// exactly how long a one-in-sixty-two flake takes to look like a real bug.
|
||||
func alter(secret string) string {
|
||||
last := secret[len(secret)-1]
|
||||
replacement := byte('a')
|
||||
if last == replacement {
|
||||
replacement = 'b'
|
||||
}
|
||||
return secret[:len(secret)-1] + string(replacement)
|
||||
}
|
||||
|
||||
func TestTheRequestPathDoesNotReadTheDatabase(t *testing.T) {
|
||||
// The rule the whole package is shaped around. Without the cache this is a
|
||||
// database read per request, on every request.
|
||||
s, secret, _ := fixture(t)
|
||||
c := &countingStore{Store: s}
|
||||
a := New(c, DefaultConfig())
|
||||
|
||||
for range 100 {
|
||||
if _, err := a.Authenticate(context.Background(), secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if n := c.lookups.Load(); n != 1 {
|
||||
t.Errorf("100 requests caused %d database reads", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARejectedKeyIsNotADatabaseReadPerRequest(t *testing.T) {
|
||||
// A client looping with a bad key would otherwise be a free way to load the
|
||||
// database from outside.
|
||||
s, _, _ := fixture(t)
|
||||
c := &countingStore{Store: s}
|
||||
a := New(c, DefaultConfig())
|
||||
|
||||
bad := store.KeyPrefix + "0000000000000000000000000000000000000000000"
|
||||
for range 50 {
|
||||
if _, err := a.Authenticate(context.Background(), bad); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if n := c.lookups.Load(); n != 1 {
|
||||
t.Errorf("50 bad requests caused %d database reads", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCachedDecisionsExpire(t *testing.T) {
|
||||
// The cache bounds how long a revocation can take to take effect, so it has
|
||||
// to actually expire.
|
||||
s, secret, keyID := fixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
now := time.Now()
|
||||
a := New(s, Config{TTL: time.Second, NegativeTTL: time.Second})
|
||||
a.now = func() time.Time { return now }
|
||||
|
||||
if _, err := a.Authenticate(ctx, secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
must(t, s.RevokeKey(ctx, keyID, now))
|
||||
|
||||
// Still inside the TTL: the stale answer is the point of the cache.
|
||||
if _, err := a.Authenticate(ctx, secret); err != nil {
|
||||
t.Errorf("the cache expired early: %v", err)
|
||||
}
|
||||
|
||||
now = now.Add(2 * time.Second)
|
||||
if _, err := a.Authenticate(ctx, secret); !errors.Is(err, ErrRevoked) {
|
||||
t.Errorf("a revoked key still works after the TTL: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStoreFailureIsNotAnAuthenticationAnswer(t *testing.T) {
|
||||
// Two things must not happen: the failure must not be cached as a
|
||||
// rejection, and it must not read to the caller as a bad key.
|
||||
s, secret, _ := fixture(t)
|
||||
c := &countingStore{Store: s}
|
||||
a := New(c, DefaultConfig())
|
||||
|
||||
c.fail.Store(true)
|
||||
_, err := a.Authenticate(context.Background(), secret)
|
||||
if err == nil {
|
||||
t.Fatal("a broken database authenticated a request")
|
||||
}
|
||||
if isRejection(err) {
|
||||
t.Errorf("a database failure was reported as a credential problem: %v", err)
|
||||
}
|
||||
|
||||
c.fail.Store(false)
|
||||
if _, err := a.Authenticate(context.Background(), secret); err != nil {
|
||||
t.Errorf("the failure was cached: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiryIsNotCached(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, _ := fixture(t)
|
||||
|
||||
secret, k, _ := store.NewKey("key2", "temp", "proj1")
|
||||
k.CreatedAt = time.Now()
|
||||
k.ExpiresAt = time.Now().Add(time.Hour)
|
||||
must(t, s.CreateKey(ctx, k))
|
||||
|
||||
now := time.Now()
|
||||
a := New(s, Config{TTL: time.Minute})
|
||||
a.now = func() time.Time { return now }
|
||||
|
||||
if _, err := a.Authenticate(ctx, secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Past the key's expiry but inside the cache TTL. The cached decision is
|
||||
// allowed to be stale by at most the TTL, which is the documented trade.
|
||||
now = now.Add(2 * time.Hour)
|
||||
if _, err := a.Authenticate(ctx, secret); !errors.Is(err, ErrExpired) {
|
||||
t.Errorf("an expired key authenticated: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAuthentication(t *testing.T) {
|
||||
s, secret, _ := fixture(t)
|
||||
a := New(s, DefaultConfig())
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 64)
|
||||
for range 64 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := a.Authenticate(context.Background(), secret); err != nil {
|
||||
errs <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareGuardsEveryRouteButTheExemptOnes(t *testing.T) {
|
||||
s, secret, _ := fixture(t)
|
||||
a := New(s, DefaultConfig())
|
||||
|
||||
var reached atomic.Int64
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached.Add(1)
|
||||
if _, ok := FromContext(r.Context()); !ok && r.URL.Path != "/health" {
|
||||
t.Error("an authenticated request carried no principal")
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
srv := httptest.NewServer(Middleware(a, nil, "/health")(inner))
|
||||
defer srv.Close()
|
||||
|
||||
get := func(path, token string) int {
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+path, nil) //nolint:noctx // test
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
if code := get("/health", ""); code != http.StatusOK {
|
||||
t.Errorf("health needs no key, got %d", code)
|
||||
}
|
||||
if code := get("/v1/models", ""); code != http.StatusUnauthorized {
|
||||
t.Errorf("an unauthenticated request got %d", code)
|
||||
}
|
||||
if code := get("/v1/models", "sk-llmgw-bogus"); code != http.StatusUnauthorized {
|
||||
t.Errorf("a bad key got %d", code)
|
||||
}
|
||||
if code := get("/v1/models", secret); code != http.StatusOK {
|
||||
t.Errorf("a good key got %d", code)
|
||||
}
|
||||
if n := reached.Load(); n != 2 {
|
||||
t.Errorf("the handler was reached %d times; only health and the valid key should pass", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARevokedKeyLooksExactlyLikeAnUnknownOne(t *testing.T) {
|
||||
// Telling a caller that a key exists but was revoked confirms the key was
|
||||
// real, which is information worth withholding.
|
||||
s, secret, keyID := fixture(t)
|
||||
must(t, s.RevokeKey(context.Background(), keyID, time.Now()))
|
||||
|
||||
a := New(s, DefaultConfig())
|
||||
srv := httptest.NewServer(Middleware(a, nil)(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })))
|
||||
defer srv.Close()
|
||||
|
||||
body := func(token string) string {
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) //nolint:noctx // test
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var e struct {
|
||||
Error struct{ Message string } `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||||
return e.Error.Message
|
||||
}
|
||||
|
||||
unknown := store.KeyPrefix + "0000000000000000000000000000000000000000000"
|
||||
if body(secret) != body(unknown) {
|
||||
t.Errorf("revoked says %q, unknown says %q", body(secret), body(unknown))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStoreFailureDoesNotReadAsABadKey(t *testing.T) {
|
||||
s, secret, _ := fixture(t)
|
||||
c := &countingStore{Store: s}
|
||||
c.fail.Store(true)
|
||||
a := New(c, DefaultConfig())
|
||||
|
||||
srv := httptest.NewServer(Middleware(a, nil)(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })))
|
||||
defer srv.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) //nolint:noctx // test
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// 503 rather than 401: sending someone hunting for a key problem that does
|
||||
// not exist wastes the one thing an incident is short of.
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Errorf("status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenAcceptsWhatClientsActuallySend(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Bearer sk-llmgw-abc": "sk-llmgw-abc",
|
||||
"sk-llmgw-abc": "sk-llmgw-abc",
|
||||
"Bearer sk-llmgw-abc": "sk-llmgw-abc",
|
||||
"": "",
|
||||
}
|
||||
for header, want := range cases {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if header != "" {
|
||||
r.Header.Set("Authorization", header)
|
||||
}
|
||||
if got := BearerToken(r); got != want {
|
||||
t.Errorf("Authorization %q -> %q, want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Some tools only manage to set this one.
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set("api-key", "sk-llmgw-xyz")
|
||||
if got := BearerToken(r); got != "sk-llmgw-xyz" {
|
||||
t.Errorf("api-key header -> %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastUsedIsBatchedNotWrittenPerRequest(t *testing.T) {
|
||||
s, secret, keyID := fixture(t)
|
||||
ctx := context.Background()
|
||||
a := New(s, Config{TTL: time.Minute, TouchInterval: time.Hour})
|
||||
|
||||
for range 20 {
|
||||
if _, err := a.Authenticate(ctx, secret); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Nothing written yet: the field is not read in real time.
|
||||
k, _ := s.KeyByHash(ctx, store.HashKey(secret))
|
||||
if !k.LastUsedAt.IsZero() {
|
||||
t.Error("last-used was written on the request path")
|
||||
}
|
||||
|
||||
must(t, a.FlushTouches(ctx))
|
||||
k, _ = s.KeyByHash(ctx, store.HashKey(secret))
|
||||
if k.LastUsedAt.IsZero() {
|
||||
t.Error("last-used was never written")
|
||||
}
|
||||
if k.ID != keyID {
|
||||
t.Errorf("wrong key touched: %s", k.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExemptingTheLandingPageDoesNotExemptEverything(t *testing.T) {
|
||||
// A near-miss worth a test. "/" ends in a slash, so a prefix rule would
|
||||
// match every path there is: authentication still installed, still logging,
|
||||
// and letting the entire API through.
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"/", true},
|
||||
{"/_static/base.css", true},
|
||||
{"/health", true},
|
||||
{"/v1/models", false},
|
||||
{"/v1/chat/completions", false},
|
||||
{"/healthz", false}, // not a prefix rule
|
||||
{"/_staticx", false}, // prefix rules end at the slash
|
||||
}
|
||||
exempt := []string{"/health", "/", "/_static/"}
|
||||
for _, c := range cases {
|
||||
if got := isExempt(c.path, exempt); got != c.want {
|
||||
t.Errorf("isExempt(%q) = %v, want %v", c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlterAlwaysChangesTheKey(t *testing.T) {
|
||||
// The helper the test above depends on. If it can return its input, the
|
||||
// test it serves silently stops testing anything.
|
||||
for range 500 {
|
||||
secret, _, err := store.NewKey("id", "n", "p")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if alter(secret) == secret {
|
||||
t.Fatalf("alter returned its input for %q", secret)
|
||||
}
|
||||
}
|
||||
// Including the case that made the original flaky: a key ending in the
|
||||
// replacement character.
|
||||
if got := alter("sk-llmgw-aaaa"); got == "sk-llmgw-aaaa" {
|
||||
t.Error("alter is a no-op when the key already ends in the replacement")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Middleware authenticates every request before it reaches the router.
|
||||
//
|
||||
// It is a wrapper rather than a check inside the handler so there is no path
|
||||
// into the API that forgets to call it. Adding a route cannot accidentally add
|
||||
// an unauthenticated one.
|
||||
func Middleware(a *Authenticator, log *slog.Logger, exempt ...string) func(http.Handler) http.Handler {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isExempt(r.URL.Path, exempt) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
p, err := a.Authenticate(r.Context(), BearerToken(r))
|
||||
if err != nil {
|
||||
writeAuthError(w, err)
|
||||
// The key prefix, never the key: a rejected credential in a log
|
||||
// is still a credential.
|
||||
log.Debug("rejected a request", "path", r.URL.Path,
|
||||
"reason", err, "remote", r.RemoteAddr)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), p)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// isExempt reports whether a path is served without a key.
|
||||
//
|
||||
// An entry ending in "/" is a prefix rule, with one exception that matters
|
||||
// enormously: "/" itself is exact. Treating it as a prefix would match every
|
||||
// path there is, so exempting the landing page would silently exempt the whole
|
||||
// API — authentication still installed, still logging, and letting everything
|
||||
// through.
|
||||
//
|
||||
// Metrics and pprof are not in this list because they live on the admin
|
||||
// listener, a separate port precisely so it can be firewalled rather than
|
||||
// authenticated.
|
||||
func isExempt(path string, exempt []string) bool {
|
||||
for _, e := range exempt {
|
||||
if path == e {
|
||||
return true
|
||||
}
|
||||
if e != "/" && strings.HasSuffix(e, "/") && strings.HasPrefix(path, e) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeAuthError(w http.ResponseWriter, err error) {
|
||||
status, kind, msg := http.StatusUnauthorized, "invalid_request_error", "invalid api key"
|
||||
switch {
|
||||
case errors.Is(err, ErrMissing):
|
||||
msg = "no api key was provided"
|
||||
case errors.Is(err, ErrExpired):
|
||||
msg = "this api key has expired"
|
||||
case errors.Is(err, ErrRevoked):
|
||||
// Deliberately the same message as an unknown key. Telling a caller
|
||||
// that a key exists but was revoked confirms the key was real.
|
||||
msg = "invalid api key"
|
||||
case errors.Is(err, ErrInvalid):
|
||||
default:
|
||||
// A store failure is ours, not the caller's, and must not read as a
|
||||
// rejected credential — that would send someone hunting for a key
|
||||
// problem that does not exist.
|
||||
status, kind, msg = http.StatusServiceUnavailable, "server_error",
|
||||
"authentication is temporarily unavailable"
|
||||
}
|
||||
|
||||
if status == http.StatusUnauthorized {
|
||||
// What the OpenAI SDKs look for to stop retrying.
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="llmgw"`)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
|
||||
"message": msg, "type": kind, "code": nil,
|
||||
}})
|
||||
}
|
||||
|
||||
// WriteForbiddenModel reports a model this key may not use.
|
||||
//
|
||||
// Separate from authentication because it happens later: the model is only
|
||||
// known once the body has been parsed.
|
||||
func WriteForbiddenModel(w http.ResponseWriter, model string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
|
||||
"message": "this api key is not permitted to use model " + model,
|
||||
"type": "invalid_request_error", "code": nil,
|
||||
}})
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
// Package bench measures what a replica can actually do, so the numbers the
|
||||
// router depends on stop being guesses (plan §6.2.1).
|
||||
//
|
||||
// Three of them matter: the weight a replica gets when the scheduler compares
|
||||
// it to others, the queue depth admission treats as healthy, and the KV usage
|
||||
// it treats as full. All three are currently hand-written in configuration, and
|
||||
// a hand-written capacity number is a hand-written opinion about hardware
|
||||
// nobody measured.
|
||||
//
|
||||
// The measurement is a load ramp. Concurrency climbs until the SLO breaks, and
|
||||
// the last level that still met it is the capacity — not peak throughput, which
|
||||
// a GPU will happily deliver long after latency has become unusable.
|
||||
package bench
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SLO is the latency target capacity is measured against.
|
||||
type SLO struct {
|
||||
TTFTp99 time.Duration
|
||||
TPOTp99 time.Duration
|
||||
}
|
||||
|
||||
// Met reports whether a step's latencies satisfy the target.
|
||||
func (s SLO) Met(ttft, tpot time.Duration) bool {
|
||||
if s.TTFTp99 > 0 && ttft > s.TTFTp99 {
|
||||
return false
|
||||
}
|
||||
if s.TPOTp99 > 0 && tpot > s.TPOTp99 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Config describes a run.
|
||||
type Config struct {
|
||||
// Target is the base URL: the gateway, or one replica directly. Pointing at
|
||||
// a replica measures that replica; pointing at the gateway measures the
|
||||
// whole path including scheduling, which is a different question.
|
||||
Target string
|
||||
// MetricsURL is an engine's /metrics, sampled during the run so queue depth
|
||||
// and KV usage can be reported alongside latency. Only meaningful when
|
||||
// Target is a single replica.
|
||||
MetricsURL string
|
||||
// Engine names which metric vocabulary MetricsURL speaks. Getting it wrong
|
||||
// is silent: nothing matches, every sample reads zero, and the suggested
|
||||
// thresholds are built on nothing.
|
||||
Engine string
|
||||
Model string
|
||||
APIKey string
|
||||
|
||||
// Concurrency is the ramp. Each level runs for Duration.
|
||||
Concurrency []int
|
||||
Duration time.Duration
|
||||
|
||||
PromptTokens int
|
||||
MaxTokens int
|
||||
|
||||
// SharedPrefix sends every request the same prompt.
|
||||
//
|
||||
// Off by default, and that default is the whole point: with a shared prefix
|
||||
// the engine's cache answers most of the prefill and the measurement
|
||||
// describes the cache rather than the hardware. Turning it on measures the
|
||||
// cached case deliberately, which is a fair question to ask separately.
|
||||
SharedPrefix bool
|
||||
|
||||
SLO SLO
|
||||
}
|
||||
|
||||
// DefaultConfig returns a ramp that covers most single-replica hardware.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Concurrency: []int{1, 2, 4, 8, 16, 32, 64, 128},
|
||||
Duration: 20 * time.Second,
|
||||
PromptTokens: 512,
|
||||
MaxTokens: 128,
|
||||
SLO: SLO{TTFTp99: 2 * time.Second, TPOTp99: 50 * time.Millisecond},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) normalize() error {
|
||||
d := DefaultConfig()
|
||||
if c.Target == "" {
|
||||
return errors.New("bench: a target is required")
|
||||
}
|
||||
if c.Model == "" {
|
||||
return errors.New("bench: a model is required")
|
||||
}
|
||||
c.Target = strings.TrimRight(c.Target, "/")
|
||||
if len(c.Concurrency) == 0 {
|
||||
c.Concurrency = d.Concurrency
|
||||
}
|
||||
if c.Duration <= 0 {
|
||||
c.Duration = d.Duration
|
||||
}
|
||||
if c.PromptTokens <= 0 {
|
||||
c.PromptTokens = d.PromptTokens
|
||||
}
|
||||
if c.MaxTokens <= 0 {
|
||||
c.MaxTokens = d.MaxTokens
|
||||
}
|
||||
sort.Ints(c.Concurrency)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Percentiles summarizes a latency sample.
|
||||
type Percentiles struct {
|
||||
P50, P90, P99, Max time.Duration
|
||||
}
|
||||
|
||||
// Step is one concurrency level's result.
|
||||
type Step struct {
|
||||
Concurrency int
|
||||
Completed int
|
||||
Failed int
|
||||
// Requests and output tokens per second, over the step's wall clock.
|
||||
RequestsPerSec float64
|
||||
TokensPerSec float64
|
||||
TTFT Percentiles
|
||||
// TPOT is time per output token after the first, which is what a reader
|
||||
// experiences as the speed of the stream.
|
||||
TPOT Percentiles
|
||||
Waiting float64
|
||||
Running float64
|
||||
KVUsage float64
|
||||
MeetsSLO bool
|
||||
// FirstError is kept so a step that failed says why rather than just
|
||||
// reporting zeroes.
|
||||
FirstError string
|
||||
}
|
||||
|
||||
// Result is the whole ramp plus what it concluded.
|
||||
type Result struct {
|
||||
Model string
|
||||
Target string
|
||||
Steps []Step
|
||||
SLO SLO
|
||||
RanAt time.Time
|
||||
Elapsed time.Duration
|
||||
|
||||
// MaxConcurrencyAtSLO is the capacity: the highest level that still met the
|
||||
// target. Zero means even one request did not.
|
||||
MaxConcurrencyAtSLO int
|
||||
// ThroughputTokPerSec and the latencies are taken at that level.
|
||||
ThroughputTokPerSec float64
|
||||
TTFTp99, TPOTp99 time.Duration
|
||||
// SuggestedTargetWaiting and SuggestedKVCeiling are what admission should
|
||||
// treat as healthy, observed rather than guessed.
|
||||
SuggestedTargetWaiting int
|
||||
SuggestedKVCeiling float64
|
||||
}
|
||||
|
||||
// Run executes the ramp.
|
||||
func Run(ctx context.Context, cfg Config, progress func(Step)) (Result, error) {
|
||||
if err := cfg.normalize(); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
res := Result{Model: cfg.Model, Target: cfg.Target, SLO: cfg.SLO, RanAt: time.Now()}
|
||||
start := time.Now()
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
for _, c := range cfg.Concurrency {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
step := runStep(ctx, client, cfg, c)
|
||||
res.Steps = append(res.Steps, step)
|
||||
if progress != nil {
|
||||
progress(step)
|
||||
}
|
||||
// Stop climbing once the target is missed. Everything above is slower
|
||||
// by construction, and the cost of finding out is real GPU time.
|
||||
if !step.MeetsSLO {
|
||||
break
|
||||
}
|
||||
}
|
||||
res.Elapsed = time.Since(start)
|
||||
res.conclude()
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (r *Result) conclude() {
|
||||
for _, s := range r.Steps {
|
||||
if !s.MeetsSLO || s.Completed == 0 {
|
||||
continue
|
||||
}
|
||||
if s.Concurrency <= r.MaxConcurrencyAtSLO {
|
||||
continue
|
||||
}
|
||||
r.MaxConcurrencyAtSLO = s.Concurrency
|
||||
r.ThroughputTokPerSec = s.TokensPerSec
|
||||
r.TTFTp99, r.TPOTp99 = s.TTFT.P99, s.TPOT.P99
|
||||
// The queue depth and cache pressure seen while still meeting the SLO
|
||||
// are exactly what admission should treat as healthy.
|
||||
r.SuggestedTargetWaiting = int(s.Waiting + 0.5)
|
||||
r.SuggestedKVCeiling = roundTo(s.KVUsage, 0.05)
|
||||
}
|
||||
}
|
||||
|
||||
func runStep(ctx context.Context, client *http.Client, cfg Config, concurrency int) Step {
|
||||
step := Step{Concurrency: concurrency}
|
||||
ctx, cancel := context.WithTimeout(ctx, cfg.Duration)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
ttfts []time.Duration
|
||||
tpots []time.Duration
|
||||
tokens int64
|
||||
failed int
|
||||
firstErr string
|
||||
)
|
||||
|
||||
sampler := newSampler(cfg.MetricsURL, cfg.Engine)
|
||||
sampler.start(ctx)
|
||||
|
||||
start := time.Now()
|
||||
var wg sync.WaitGroup
|
||||
for range concurrency {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for ctx.Err() == nil {
|
||||
r, err := one(ctx, client, cfg)
|
||||
mu.Lock()
|
||||
switch {
|
||||
case err != nil:
|
||||
// A cancelled context is the step ending, not a failure.
|
||||
if ctx.Err() == nil {
|
||||
failed++
|
||||
if firstErr == "" {
|
||||
firstErr = err.Error()
|
||||
}
|
||||
}
|
||||
default:
|
||||
ttfts = append(ttfts, r.ttft)
|
||||
if r.tpot > 0 {
|
||||
tpots = append(tpots, r.tpot)
|
||||
}
|
||||
tokens += r.tokens
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
elapsed := time.Since(start)
|
||||
sampler.stop()
|
||||
|
||||
step.Completed = len(ttfts)
|
||||
step.Failed = failed
|
||||
step.FirstError = firstErr
|
||||
step.TTFT = percentiles(ttfts)
|
||||
step.TPOT = percentiles(tpots)
|
||||
if elapsed > 0 {
|
||||
step.RequestsPerSec = float64(step.Completed) / elapsed.Seconds()
|
||||
step.TokensPerSec = float64(tokens) / elapsed.Seconds()
|
||||
}
|
||||
step.Waiting, step.Running, step.KVUsage = sampler.averages()
|
||||
|
||||
// A step that could not complete anything has not met anything.
|
||||
step.MeetsSLO = step.Completed > 0 && step.Failed == 0 &&
|
||||
cfg.SLO.Met(step.TTFT.P99, step.TPOT.P99)
|
||||
return step
|
||||
}
|
||||
|
||||
type outcome struct {
|
||||
ttft time.Duration
|
||||
tpot time.Duration
|
||||
tokens int64
|
||||
}
|
||||
|
||||
// one sends a single streaming completion and times it.
|
||||
func one(ctx context.Context, client *http.Client, cfg Config) (outcome, error) {
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"model": cfg.Model,
|
||||
"stream": true,
|
||||
"max_tokens": cfg.MaxTokens,
|
||||
"messages": []map[string]string{
|
||||
{"role": "user", "content": prompt(cfg)},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return outcome{}, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
cfg.Target+"/v1/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return outcome{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if cfg.APIKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return outcome{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return outcome{}, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
var out outcome
|
||||
var firstAt, lastAt time.Time
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if !strings.HasPrefix(line, "data: ") || strings.HasSuffix(line, "[DONE]") {
|
||||
continue
|
||||
}
|
||||
// Only chunks that carry content count: a role-only first chunk is not
|
||||
// a token, and counting it would flatter the time to first token.
|
||||
if !hasContent(line[6:]) {
|
||||
continue
|
||||
}
|
||||
now := time.Now()
|
||||
if out.tokens == 0 {
|
||||
firstAt = now
|
||||
out.ttft = now.Sub(start)
|
||||
}
|
||||
lastAt = now
|
||||
out.tokens++
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return outcome{}, err
|
||||
}
|
||||
if out.tokens == 0 {
|
||||
return outcome{}, errors.New("the response carried no content")
|
||||
}
|
||||
if out.tokens > 1 {
|
||||
out.tpot = lastAt.Sub(firstAt) / time.Duration(out.tokens-1)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func hasContent(payload string) bool {
|
||||
var chunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, c := range chunk.Choices {
|
||||
if c.Delta.Content != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// prompt builds a request body of roughly the configured size.
|
||||
//
|
||||
// Unique per request unless a shared prefix was asked for: repeating one prompt
|
||||
// lets the engine's cache answer most of the prefill, and the result then
|
||||
// describes the cache rather than the hardware.
|
||||
//
|
||||
// "Shared" has to mean byte-identical. Dropping only the random header while
|
||||
// still picking words at random would leave every prompt different after the
|
||||
// first few tokens, so the cache would miss and the flag would quietly measure
|
||||
// the thing it exists to avoid measuring.
|
||||
func prompt(cfg Config) string {
|
||||
var b strings.Builder
|
||||
if cfg.SharedPrefix {
|
||||
for i := 0; b.Len() < cfg.PromptTokens*4; i++ {
|
||||
b.WriteString(words[i%len(words)])
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%016x %016x ", rand.Uint64(), rand.Uint64()) //nolint:gosec // not security
|
||||
// Roughly four characters to a token, the same crude ratio the router
|
||||
// uses; exactness is not what this number is for.
|
||||
for b.Len() < cfg.PromptTokens*4 {
|
||||
b.WriteString(words[rand.IntN(len(words))]) //nolint:gosec // not security
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
b.WriteString("\n\nSummarize the text above.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var words = strings.Fields(`the quick brown fox jumps over lazy dog while distant
|
||||
thunder rolls across an empty valley and somewhere a door closes softly in the
|
||||
dark house where nobody has lived for years although the garden still produces
|
||||
apples every autumn which fall and rot beneath the trees unnoticed by anyone
|
||||
except the birds that come each morning to pick at them before the frost`)
|
||||
|
||||
func percentiles(v []time.Duration) Percentiles {
|
||||
if len(v) == 0 {
|
||||
return Percentiles{}
|
||||
}
|
||||
s := make([]time.Duration, len(v))
|
||||
copy(s, v)
|
||||
sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
|
||||
at := func(p float64) time.Duration {
|
||||
i := int(float64(len(s)-1) * p)
|
||||
return s[i]
|
||||
}
|
||||
return Percentiles{P50: at(0.50), P90: at(0.90), P99: at(0.99), Max: s[len(s)-1]}
|
||||
}
|
||||
|
||||
// roundTo rounds to a multiple of step, through integers.
|
||||
//
|
||||
// The obvious float arithmetic returns 0.7000000000000001 for what should be
|
||||
// 0.70, which is harmless on a terminal and ugly in the JSON someone pastes
|
||||
// into a configuration file.
|
||||
func roundTo(v, step float64) float64 {
|
||||
if step <= 0 {
|
||||
return v
|
||||
}
|
||||
const scale = 10000
|
||||
units := int64(v/step + 0.5)
|
||||
return float64(int64(float64(units)*step*scale+0.5)) / scale
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package bench
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCapacityIsTheLastLevelThatMetTheSLONotThePeak(t *testing.T) {
|
||||
// A GPU keeps delivering more tokens per second long after latency has
|
||||
// become unusable. Taking peak throughput as capacity would tell the router
|
||||
// a replica can serve a load at which nobody would want the answers.
|
||||
r := Result{SLO: SLO{TTFTp99: time.Second}}
|
||||
r.Steps = []Step{
|
||||
{Concurrency: 1, Completed: 10, TokensPerSec: 100, MeetsSLO: true,
|
||||
TTFT: Percentiles{P99: 100 * time.Millisecond}},
|
||||
{Concurrency: 8, Completed: 80, TokensPerSec: 700, MeetsSLO: true,
|
||||
TTFT: Percentiles{P99: 900 * time.Millisecond}, Waiting: 3.4, KVUsage: 0.71},
|
||||
// Faster in tokens, far too slow in latency.
|
||||
{Concurrency: 32, Completed: 200, TokensPerSec: 1500, MeetsSLO: false,
|
||||
TTFT: Percentiles{P99: 9 * time.Second}},
|
||||
}
|
||||
r.conclude()
|
||||
|
||||
if r.MaxConcurrencyAtSLO != 8 {
|
||||
t.Errorf("capacity = %d, want 8", r.MaxConcurrencyAtSLO)
|
||||
}
|
||||
if r.ThroughputTokPerSec != 700 {
|
||||
t.Errorf("throughput = %v; it must come from the level that met the SLO", r.ThroughputTokPerSec)
|
||||
}
|
||||
if r.SuggestedTargetWaiting != 3 {
|
||||
t.Errorf("target_waiting = %d, want 3", r.SuggestedTargetWaiting)
|
||||
}
|
||||
if r.SuggestedKVCeiling != 0.70 {
|
||||
t.Errorf("kv_ceiling = %v, want 0.70", r.SuggestedKVCeiling)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStepWithFailuresHasNotMetAnything(t *testing.T) {
|
||||
// Latency percentiles over the requests that survived say nothing useful
|
||||
// when the rest were refused.
|
||||
r := Result{SLO: SLO{TTFTp99: time.Second}}
|
||||
r.Steps = []Step{
|
||||
{Concurrency: 4, Completed: 10, Failed: 90, TokensPerSec: 50, MeetsSLO: false,
|
||||
TTFT: Percentiles{P99: 50 * time.Millisecond}},
|
||||
}
|
||||
r.conclude()
|
||||
if r.MaxConcurrencyAtSLO != 0 {
|
||||
t.Errorf("a step that failed 90 of 100 requests was counted as capacity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSLOIsMissedWhenEitherHalfIs(t *testing.T) {
|
||||
s := SLO{TTFTp99: time.Second, TPOTp99: 50 * time.Millisecond}
|
||||
cases := []struct {
|
||||
ttft, tpot time.Duration
|
||||
want bool
|
||||
}{
|
||||
{500 * time.Millisecond, 20 * time.Millisecond, true},
|
||||
{2 * time.Second, 20 * time.Millisecond, false},
|
||||
{500 * time.Millisecond, 80 * time.Millisecond, false},
|
||||
{2 * time.Second, 80 * time.Millisecond, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := s.Met(c.ttft, c.tpot); got != c.want {
|
||||
t.Errorf("Met(%s, %s) = %v", c.ttft, c.tpot, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A target of zero is no target, not a target of zero.
|
||||
none := SLO{}
|
||||
if !none.Met(time.Hour, time.Hour) {
|
||||
t.Error("an unset SLO refused everything")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPercentiles(t *testing.T) {
|
||||
var v []time.Duration
|
||||
for i := 1; i <= 100; i++ {
|
||||
v = append(v, time.Duration(i)*time.Millisecond)
|
||||
}
|
||||
p := percentiles(v)
|
||||
if p.P50 != 50*time.Millisecond || p.P90 != 90*time.Millisecond {
|
||||
t.Errorf("p50=%s p90=%s", p.P50, p.P90)
|
||||
}
|
||||
if p.Max != 100*time.Millisecond {
|
||||
t.Errorf("max=%s", p.Max)
|
||||
}
|
||||
if got := percentiles(nil); got != (Percentiles{}) {
|
||||
t.Errorf("empty sample gave %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptsAreUniqueUnlessAskedNotToBe(t *testing.T) {
|
||||
// The default matters more than it looks. With one repeated prompt the
|
||||
// engine's cache answers most of the prefill, and the measurement describes
|
||||
// the cache rather than the hardware.
|
||||
cfg := Config{PromptTokens: 64}
|
||||
seen := map[string]bool{}
|
||||
for range 100 {
|
||||
seen[prompt(cfg)] = true
|
||||
}
|
||||
if len(seen) < 100 {
|
||||
t.Errorf("%d distinct prompts out of 100; the cache would answer the rest", len(seen))
|
||||
}
|
||||
|
||||
cfg.SharedPrefix = true
|
||||
first := prompt(cfg)
|
||||
for range 20 {
|
||||
if got := prompt(cfg); !strings.HasPrefix(got, first[:200]) {
|
||||
t.Error("--shared-prefix did not produce a shared prefix")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptSizeFollowsTheRequest(t *testing.T) {
|
||||
small := prompt(Config{PromptTokens: 32})
|
||||
large := prompt(Config{PromptTokens: 512})
|
||||
if len(large) < len(small)*4 {
|
||||
t.Errorf("a 512-token prompt is %d chars against %d for 32", len(large), len(small))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigIsValidatedBeforeAnyLoadIsGenerated(t *testing.T) {
|
||||
// The failure has to come before the traffic, not halfway through a ramp.
|
||||
if _, err := Run(context.Background(), Config{Model: "m"}, nil); err == nil {
|
||||
t.Error("a run with no target started")
|
||||
}
|
||||
if _, err := Run(context.Background(), Config{Target: "http://x"}, nil); err == nil {
|
||||
t.Error("a run with no model started")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRampStopsOnceTheSLOBreaks(t *testing.T) {
|
||||
// Everything above a missed level is slower by construction, and finding
|
||||
// that out costs real GPU time.
|
||||
r := Result{}
|
||||
r.Steps = []Step{
|
||||
{Concurrency: 1, Completed: 1, MeetsSLO: true},
|
||||
{Concurrency: 2, Completed: 1, MeetsSLO: false},
|
||||
}
|
||||
r.conclude()
|
||||
if r.MaxConcurrencyAtSLO != 1 {
|
||||
t.Errorf("capacity = %d", r.MaxConcurrencyAtSLO)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTo(t *testing.T) {
|
||||
cases := map[float64]float64{0.71: 0.70, 0.73: 0.75, 0.88: 0.90, 0.0: 0.0}
|
||||
for in, want := range cases {
|
||||
if got := roundTo(in, 0.05); got != want {
|
||||
t.Errorf("roundTo(%v) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package bench
|
||||
|
||||
import "time"
|
||||
|
||||
// Capacity is what one replica was measured to serve, kept so the measurement
|
||||
// outlives the terminal it was printed in.
|
||||
//
|
||||
// The conclusion of a run, not the run. The ramp itself — every concurrency
|
||||
// level, its latencies, its errors — is worth reading once, while deciding
|
||||
// whether the run was sound; it is not worth carrying around afterwards, and
|
||||
// storing it would grow without bound as a model is re-benchmarked. What a
|
||||
// later reader needs is the number and enough provenance to judge whether it is
|
||||
// still true.
|
||||
type Capacity struct {
|
||||
Model string `json:"model"`
|
||||
Profile string `json:"profile"`
|
||||
|
||||
// MaxConcurrencyAtSLO is the capacity: the highest concurrency that still
|
||||
// met the target. Zero means even one request did not, which is a real
|
||||
// result and not a missing one — it says this hardware cannot serve this
|
||||
// model at this SLO at all.
|
||||
MaxConcurrencyAtSLO int `json:"max_concurrency_at_slo"`
|
||||
// ThroughputTokPerSec and the latencies are taken at that level, not at the
|
||||
// peak. Peak throughput arrives long after latency has become unusable, so
|
||||
// reporting it would overstate what the hardware can serve.
|
||||
ThroughputTokPerSec float64 `json:"throughput_tok_per_sec"`
|
||||
TTFTp99 time.Duration `json:"ttft_p99"`
|
||||
TPOTp99 time.Duration `json:"tpot_p99"`
|
||||
|
||||
// SuggestedTargetWaiting and SuggestedKVCeiling are the queue depth and
|
||||
// cache pressure seen while the SLO was still being met, which is what
|
||||
// admission should treat as healthy for this model. Admission currently
|
||||
// applies one global default to every model; these are the per-model
|
||||
// numbers it should be using instead.
|
||||
SuggestedTargetWaiting int `json:"suggested_target_waiting"`
|
||||
SuggestedKVCeiling float64 `json:"suggested_kv_ceiling"`
|
||||
|
||||
// SLO is recorded because capacity is meaningless without it. The same
|
||||
// replica has a different capacity against a 200ms first-token target than
|
||||
// against a 2s one, and a number stored without the target it was measured
|
||||
// against cannot be compared with anything.
|
||||
SLO SLO `json:"slo"`
|
||||
|
||||
// Target is the endpoint the load actually went to, kept so a profile
|
||||
// measured against the gateway is never mistaken for one measured against
|
||||
// a replica. Only the second says anything about the hardware.
|
||||
Target string `json:"target"`
|
||||
MeasuredAt time.Time `json:"measured_at"`
|
||||
// Stale is not a field. Whether a measurement still holds depends on
|
||||
// whether the hardware and the engine build have changed since, which this
|
||||
// package cannot know; MeasuredAt is given so the reader can decide.
|
||||
}
|
||||
|
||||
// Capacity summarizes a completed run for storage.
|
||||
func (r Result) Capacity(profile string) Capacity {
|
||||
return Capacity{
|
||||
Model: r.Model,
|
||||
Profile: profile,
|
||||
MaxConcurrencyAtSLO: r.MaxConcurrencyAtSLO,
|
||||
ThroughputTokPerSec: r.ThroughputTokPerSec,
|
||||
TTFTp99: r.TTFTp99,
|
||||
TPOTp99: r.TPOTp99,
|
||||
SuggestedTargetWaiting: r.SuggestedTargetWaiting,
|
||||
SuggestedKVCeiling: r.SuggestedKVCeiling,
|
||||
SLO: r.SLO,
|
||||
Target: r.Target,
|
||||
MeasuredAt: r.RanAt,
|
||||
}
|
||||
}
|
||||
|
||||
// Usable reports whether this measurement says anything.
|
||||
//
|
||||
// A run that never met the SLO, or one stopped before it finished a level,
|
||||
// concludes at zero. That is worth recording — it is the answer to "can this
|
||||
// run here at all" — but a caller sizing a model against it would compute a
|
||||
// replica count of infinity, so it has to be asked about rather than used.
|
||||
func (c Capacity) Usable() bool { return c.MaxConcurrencyAtSLO > 0 }
|
||||
@@ -0,0 +1,108 @@
|
||||
package bench
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/router/scrape"
|
||||
)
|
||||
|
||||
// sampler reads an engine's metrics during a step.
|
||||
//
|
||||
// It is what turns a latency measurement into something admission can use: the
|
||||
// queue depth and cache pressure observed while the SLO was still being met are
|
||||
// the values target_waiting and kv_ceiling should have, rather than the round
|
||||
// numbers somebody typed.
|
||||
//
|
||||
// Optional, because a run pointed at the gateway has no single engine to read.
|
||||
type sampler struct {
|
||||
url string
|
||||
engine string
|
||||
client *http.Client
|
||||
cancel context.CancelFunc
|
||||
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
waiting float64
|
||||
running float64
|
||||
kv float64
|
||||
n int
|
||||
}
|
||||
|
||||
func newSampler(metricsURL, engine string) *sampler {
|
||||
return &sampler{
|
||||
url: metricsURL,
|
||||
engine: engine,
|
||||
client: &http.Client{Timeout: 2 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sampler) start(ctx context.Context) {
|
||||
if s.url == "" {
|
||||
return
|
||||
}
|
||||
ctx, s.cancel = context.WithCancel(ctx)
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.sample(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *sampler) sample(ctx context.Context) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return
|
||||
}
|
||||
|
||||
// The same mapping the router reads with, so a bench result cannot disagree
|
||||
// with what the scheduler sees. The engine has to be named: reading a
|
||||
// llama.cpp endpoint with vLLM's names finds nothing, and the run would
|
||||
// report a queue depth of zero and suggest thresholds built on it.
|
||||
stats, err := scrape.Parse(resp.Body, scrape.MappingFor(s.engine))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.waiting += float64(stats.Waiting)
|
||||
s.running += float64(stats.Running)
|
||||
s.kv += stats.KVCacheUsage
|
||||
s.n++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *sampler) stop() {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *sampler) averages() (waiting, running, kv float64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.n == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
n := float64(s.n)
|
||||
return s.waiting / n, s.running / n, s.kv / n
|
||||
}
|
||||
@@ -64,7 +64,12 @@ func newAgentCmd() *cobra.Command {
|
||||
}
|
||||
cfg.Version = version.Version
|
||||
|
||||
a, err := agent.New(cfg, agent.HTTP2Client(30*time.Second), log)
|
||||
httpClient, err := agent.Client(cfg.TLS, 30*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a, err := agent.New(cfg, httpClient, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/bench"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
)
|
||||
|
||||
func newBenchCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
cfg := bench.DefaultConfig()
|
||||
var (
|
||||
steps []int
|
||||
target string
|
||||
metrics string
|
||||
engine string
|
||||
key string
|
||||
save bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "bench <model>",
|
||||
Short: "Measure what a replica can do, and what the router should be told",
|
||||
Long: "Drives real traffic. Point it at a replica to measure that hardware, or at\n" +
|
||||
"the gateway to measure the whole path — they answer different questions,\n" +
|
||||
"and only the first produces a capacity profile.\n\n" +
|
||||
"Concurrency climbs until the SLO breaks. The last level that still met it\n" +
|
||||
"is the capacity: peak throughput arrives long after latency has become\n" +
|
||||
"unusable, so throughput alone would overstate what the hardware can serve.\n\n" +
|
||||
"This costs real GPU time and competes with real traffic. Run it on a\n" +
|
||||
"replica you have cordoned, or during a window where that is acceptable.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg.Model = args[0]
|
||||
cfg.Target = target
|
||||
cfg.MetricsURL = metrics
|
||||
cfg.Engine = engine
|
||||
cfg.APIKey = key
|
||||
if len(steps) > 0 {
|
||||
cfg.Concurrency = steps
|
||||
}
|
||||
|
||||
if metrics == "" {
|
||||
cmd.PrintErrln("No --metrics endpoint: queue depth and KV usage will not be " +
|
||||
"measured, so target_waiting and kv_ceiling cannot be suggested.")
|
||||
}
|
||||
cmd.PrintErrf("benchmarking %s against %s\n\n", cfg.Model, cfg.Target)
|
||||
|
||||
// Fixed columns rather than a tabwriter: rows are printed as each
|
||||
// level finishes, and a tabwriter cannot align a column it has not
|
||||
// seen the rest of yet — it lays out whatever has been flushed, so
|
||||
// every row after the first came out ragged.
|
||||
const row = "%-5s %-7s %-7s %-9s %-9s %-9s %-6s %-5s %s\n"
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), row,
|
||||
"CONC", "REQ/S", "TOK/S", "TTFT p50", "TTFT p99", "TPOT p99", "WAIT", "KV", "SLO")
|
||||
|
||||
progress := func(s bench.Step) {
|
||||
verdict := "ok"
|
||||
if !s.MeetsSLO {
|
||||
verdict = "MISSED"
|
||||
}
|
||||
if s.Failed > 0 {
|
||||
verdict = fmt.Sprintf("%d failed", s.Failed)
|
||||
}
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), row,
|
||||
fmt.Sprint(s.Concurrency),
|
||||
fmt.Sprintf("%.1f", s.RequestsPerSec),
|
||||
fmt.Sprintf("%.0f", s.TokensPerSec),
|
||||
ms(s.TTFT.P50), ms(s.TTFT.P99), ms(s.TPOT.P99),
|
||||
fmt.Sprintf("%.1f", s.Waiting),
|
||||
fmt.Sprintf("%.0f%%", s.KVUsage*100),
|
||||
verdict)
|
||||
}
|
||||
|
||||
res, err := bench.Run(cmd.Context(), cfg, progress)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if save {
|
||||
if err := saveCapacity(cmd, client, res, target); err != nil {
|
||||
// Reported, not fatal. The run has already cost the GPU
|
||||
// time; losing its conclusion to a failed write would mean
|
||||
// running it again, and the numbers are on screen either
|
||||
// way.
|
||||
cmd.PrintErrf("the measurement was not saved: %v\n", err)
|
||||
}
|
||||
}
|
||||
if *asJSON {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(res)
|
||||
}
|
||||
printConclusion(cmd, res, cfg)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&target, "target", "http://127.0.0.1:8080", "base URL: a replica, or the gateway")
|
||||
f.StringVar(&metrics, "metrics", "", "the replica's /metrics, to observe queue depth and KV usage")
|
||||
f.StringVar(&engine, "engine", "", "which metric names --metrics uses: vllm | sglang | llamacpp")
|
||||
f.StringVar(&key, "key", "", "API key, when the gateway requires one")
|
||||
f.IntSliceVar(&steps, "concurrency", nil, "levels to try (default 1,2,4,…,128)")
|
||||
f.DurationVar(&cfg.Duration, "duration", cfg.Duration, "how long to hold each level")
|
||||
f.IntVar(&cfg.PromptTokens, "prompt-tokens", cfg.PromptTokens, "approximate prompt size")
|
||||
f.IntVar(&cfg.MaxTokens, "max-tokens", cfg.MaxTokens, "tokens to generate per request")
|
||||
f.BoolVar(&cfg.SharedPrefix, "shared-prefix", false,
|
||||
"send every request the same prompt, measuring the cached case instead of the hardware")
|
||||
f.DurationVar(&cfg.SLO.TTFTp99, "ttft-p99", cfg.SLO.TTFTp99, "time-to-first-token target")
|
||||
f.DurationVar(&cfg.SLO.TPOTp99, "tpot-p99", cfg.SLO.TPOTp99, "per-output-token target")
|
||||
f.BoolVar(&save, "save", false,
|
||||
"record the capacity against the model and profile, so the number outlives this terminal")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// saveCapacity records a run's conclusion against the profile it was measured
|
||||
// on.
|
||||
//
|
||||
// The profile is looked up from the replica rather than asked for. Capacity
|
||||
// belongs to the hardware and the engine arguments as much as to the weights,
|
||||
// so a measurement filed under the wrong profile is worse than none — and the
|
||||
// control plane already knows which profile is answering on which endpoint, so
|
||||
// nobody needs to be trusted to type it.
|
||||
func saveCapacity(cmd *cobra.Command, client func() *ctlClient, res bench.Result, target string) error {
|
||||
var reps []adminapi.ReplicaView
|
||||
if err := client().get(cmd.Context(), "/api/v1/replicas", &reps); err != nil {
|
||||
return err
|
||||
}
|
||||
profile := ""
|
||||
for _, rep := range reps {
|
||||
if rep.Model == res.Model && sameEndpoint(rep.Endpoint, target) {
|
||||
profile = rep.Profile
|
||||
break
|
||||
}
|
||||
}
|
||||
if profile == "" {
|
||||
// Almost always because --target was the gateway. Benchmarking the
|
||||
// whole path is a useful thing to do and says nothing about what one
|
||||
// replica can serve, so there is nothing here worth filing as capacity.
|
||||
return fmt.Errorf("%s is not a replica of %s, so this run measures the path rather than the hardware; "+
|
||||
"point --target at a replica endpoint from `llmgw ctl replicas`", target, res.Model)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(res.Capacity(profile))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var saved bench.Capacity
|
||||
if err := client().put(cmd.Context(),
|
||||
"/api/v1/models/"+res.Model+"/capacity", body, &saved); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.PrintErrf("saved: %s on profile %s, %d concurrent at the SLO\n",
|
||||
saved.Model, saved.Profile, saved.MaxConcurrencyAtSLO)
|
||||
return nil
|
||||
}
|
||||
|
||||
// sameEndpoint compares two base URLs, ignoring a trailing slash.
|
||||
//
|
||||
// Written out because the two come from different places: one is what the
|
||||
// control plane recorded, the other is what somebody typed or pasted, and a
|
||||
// trailing slash is the difference that would silently make every run look like
|
||||
// it was aimed at the gateway.
|
||||
func sameEndpoint(a, b string) bool {
|
||||
return strings.TrimSuffix(a, "/") == strings.TrimSuffix(b, "/")
|
||||
}
|
||||
|
||||
func printConclusion(cmd *cobra.Command, res bench.Result, cfg bench.Config) {
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintln(out)
|
||||
|
||||
if res.MaxConcurrencyAtSLO == 0 {
|
||||
fmt.Fprintf(out, "No level met the SLO (TTFT p99 ≤ %s, TPOT p99 ≤ %s).\n",
|
||||
cfg.SLO.TTFTp99, cfg.SLO.TPOTp99)
|
||||
if len(res.Steps) > 0 && res.Steps[0].FirstError != "" {
|
||||
fmt.Fprintf(out, "First error: %s\n", res.Steps[0].FirstError)
|
||||
}
|
||||
fmt.Fprintln(out, "Either the hardware cannot serve this model at this SLO, or the")
|
||||
fmt.Fprintln(out, "target is stricter than the model can meet at any concurrency.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "Capacity: %d concurrent requests at %.0f tokens/s\n",
|
||||
res.MaxConcurrencyAtSLO, res.ThroughputTokPerSec)
|
||||
fmt.Fprintf(out, " TTFT p99 %s · TPOT p99 %s · measured over %s\n\n",
|
||||
ms(res.TTFTp99), ms(res.TPOTp99), res.Elapsed.Round(time.Second))
|
||||
|
||||
fmt.Fprintln(out, "Put this in the model's profile:")
|
||||
fmt.Fprintf(out, " weight: %d\n", res.MaxConcurrencyAtSLO)
|
||||
if res.SuggestedTargetWaiting > 0 || res.SuggestedKVCeiling > 0 {
|
||||
fmt.Fprintln(out, "\nAnd in router.scoring, from what the engine reported while still")
|
||||
fmt.Fprintln(out, "meeting the target:")
|
||||
if res.SuggestedTargetWaiting > 0 {
|
||||
fmt.Fprintf(out, " target_waiting: %d\n", res.SuggestedTargetWaiting)
|
||||
}
|
||||
if res.SuggestedKVCeiling > 0 {
|
||||
fmt.Fprintf(out, " kv_ceiling: %.2f\n", res.SuggestedKVCeiling)
|
||||
}
|
||||
}
|
||||
|
||||
if last := res.Steps[len(res.Steps)-1]; !last.MeetsSLO {
|
||||
fmt.Fprintf(out, "\n%d concurrent was the first level to miss it (TTFT p99 %s).\n",
|
||||
last.Concurrency, ms(last.TTFT.P99))
|
||||
} else {
|
||||
fmt.Fprintf(out, "\nEvery level tried met the SLO, so %d is a floor rather than a\n",
|
||||
res.MaxConcurrencyAtSLO)
|
||||
fmt.Fprintln(out, "ceiling — raise --concurrency to find where it actually breaks.")
|
||||
}
|
||||
if cfg.SharedPrefix {
|
||||
fmt.Fprintln(out, "\nRun with --shared-prefix: this measures the engine's prefix cache,")
|
||||
fmt.Fprintln(out, "not its hardware. Do not use these numbers as capacity.")
|
||||
}
|
||||
}
|
||||
|
||||
func ms(d time.Duration) string {
|
||||
switch {
|
||||
case d == 0:
|
||||
return "-"
|
||||
case d < time.Millisecond:
|
||||
return d.Round(10 * time.Microsecond).String()
|
||||
case d < time.Second:
|
||||
return d.Round(time.Millisecond).String()
|
||||
default:
|
||||
return d.Round(10 * time.Millisecond).String()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
)
|
||||
|
||||
// newCapacityCmd shows what benchmarks have found out.
|
||||
//
|
||||
// `ctl bench` has always been able to measure this; until it could be saved,
|
||||
// the number lived in whoever ran it's scrollback. This is the other half:
|
||||
// somewhere to read it back, so sizing a model is looking something up rather
|
||||
// than re-running an hour of load.
|
||||
func newCapacityCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "capacity",
|
||||
Short: "What a replica of each model was measured to serve",
|
||||
Long: "Recorded by `llmgw ctl bench --save`, per model and profile — the same\n" +
|
||||
"model on CPU and on a GPU are different numbers.\n\n" +
|
||||
"Age matters. Hardware is replaced and engine builds change, so a\n" +
|
||||
"measurement is an observation with a date on it rather than a fact about\n" +
|
||||
"the model. The column is there to be read.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
var rows []adminapi.CapacityView
|
||||
if err := client().get(cmd.Context(), "/api/v1/capacity", &rows); err != nil {
|
||||
return err
|
||||
}
|
||||
if *asJSON {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(rows)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(),
|
||||
"Nothing has been measured. Run `llmgw ctl bench <model> --target <replica> --save`.")
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "MODEL\tPROFILE\tCONCURRENT\tTOK/S\tTTFT p99\tTPOT p99\tWAIT\tKV\tMEASURED\tBY")
|
||||
for _, c := range rows {
|
||||
concurrent := fmt.Sprint(c.MaxConcurrencyAtSLO)
|
||||
if !c.Usable() {
|
||||
// Zero is a real result — this hardware cannot serve this
|
||||
// model at this SLO — and printing it as a number invites
|
||||
// somebody to divide by it.
|
||||
concurrent = "none at SLO"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%.0f\t%s\t%s\t%d\t%.0f%%\t%s\t%s\n",
|
||||
c.Model, c.Profile, concurrent, c.ThroughputTokPerSec,
|
||||
ms(c.TTFTp99), ms(c.TPOTp99),
|
||||
c.SuggestedTargetWaiting, c.SuggestedKVCeiling*100,
|
||||
age(c.MeasuredAt), or(c.MeasuredBy, "—"))
|
||||
}
|
||||
return w.Flush()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func age(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "unknown"
|
||||
}
|
||||
return time.Since(t).Round(time.Minute).String() + " ago"
|
||||
}
|
||||
|
||||
func or(v, fallback string) string {
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
|
||||
)
|
||||
|
||||
// defaultPKIDir is where the authority lives on the machine that issues
|
||||
// certificates. Only that machine ever holds ca-key.pem; everything else gets
|
||||
// ca.pem and its own leaf.
|
||||
const defaultPKIDir = "/etc/llmgw/pki"
|
||||
|
||||
func newCertCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "cert",
|
||||
Short: "Issue the certificates the control plane and agents authenticate with",
|
||||
Long: "llmgw's internal connections use mutual TLS, and a public authority cannot\n" +
|
||||
"supply them: Let's Encrypt and its peers stopped issuing certificates\n" +
|
||||
"carrying the clientAuth usage, so nothing they hand out can authenticate an\n" +
|
||||
"agent. These commands run a small private authority instead.\n\n" +
|
||||
"They work on files and talk to nothing, so they run wherever you keep the\n" +
|
||||
"authority — not necessarily on a cluster machine.",
|
||||
}
|
||||
cmd.AddCommand(newInitCACmd(), newIssueServerCmd(), newIssueClientCmd(), newCertInspectCmd())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newInitCACmd() *cobra.Command {
|
||||
var (
|
||||
dir string
|
||||
cn string
|
||||
force bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "init-ca",
|
||||
Short: "Create the cluster's certificate authority",
|
||||
Long: "Writes ca.pem and ca-key.pem. Copy ca.pem to every machine; leave\n" +
|
||||
"ca-key.pem here, because whoever holds it can mint any node's identity.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
keyPath := filepath.Join(dir, "ca-key.pem")
|
||||
if _, err := os.Stat(keyPath); err == nil && !force {
|
||||
// Overwriting the key orphans every certificate ever issued from
|
||||
// it, across every machine, with no warning until connections
|
||||
// start failing.
|
||||
return fmt.Errorf("%s already exists; re-running would invalidate every "+
|
||||
"certificate issued from it (pass --force if that is what you want)", keyPath)
|
||||
}
|
||||
|
||||
ca, err := tlsconf.NewCA(cn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ca.Write(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Printf("authority %q written to %s\n", cn, dir)
|
||||
cmd.Printf(" ca.pem trust this everywhere (valid until %s)\n",
|
||||
ca.Cert.NotAfter.Format(time.DateOnly))
|
||||
cmd.Printf(" ca-key.pem keep it here, mode 0600\n")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&dir, "dir", defaultPKIDir, "directory holding the authority")
|
||||
cmd.Flags().StringVar(&cn, "cn", "llmgw cluster CA", "common name for the authority")
|
||||
cmd.Flags().BoolVar(&force, "force", false, "replace an existing authority")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newIssueServerCmd() *cobra.Command {
|
||||
var (
|
||||
dir string
|
||||
out string
|
||||
hosts []string
|
||||
name string
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "issue-server <common-name>",
|
||||
Short: "Issue the control plane's certificate",
|
||||
Long: "Every address an agent might dial must appear in --host, including bare\n" +
|
||||
"IP addresses. Hostname verification reads subject alternative names and\n" +
|
||||
"ignores the common name entirely, so a certificate that names the host\n" +
|
||||
"only in its subject is rejected by every client.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ca, err := loadCA(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
leaf, err := ca.IssueServer(args[0], hosts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if name == "" {
|
||||
name = "server"
|
||||
}
|
||||
if err := leaf.Write(out, name); err != nil {
|
||||
return err
|
||||
}
|
||||
return describeIssued(cmd, out, name, hosts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&dir, "dir", defaultPKIDir, "directory holding the authority")
|
||||
cmd.Flags().StringVar(&out, "out", ".", "directory to write the certificate into")
|
||||
cmd.Flags().StringVar(&name, "name", "server", "base filename for the certificate")
|
||||
cmd.Flags().StringArrayVar(&hosts, "host", nil, "address agents dial; repeatable (IP or DNS name)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newIssueClientCmd() *cobra.Command {
|
||||
var dir, out string
|
||||
cmd := &cobra.Command{
|
||||
Use: "issue-client <node-id>",
|
||||
Short: "Issue one agent's certificate",
|
||||
Long: "The node id becomes the certificate's common name, and the control plane\n" +
|
||||
"refuses any agent whose claimed id does not match it. That is what makes a\n" +
|
||||
"node's identity something it proves rather than something it picks.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ca, err := loadCA(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
leaf, err := ca.IssueClient(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := leaf.Write(out, args[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
return describeIssued(cmd, out, args[0], nil)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&dir, "dir", defaultPKIDir, "directory holding the authority")
|
||||
cmd.Flags().StringVar(&out, "out", ".", "directory to write the certificate into")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCertInspectCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "inspect <file.pem>",
|
||||
Short: "Print what a certificate actually says",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cert, err := readCert(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("subject %s\n", cert.Subject)
|
||||
cmd.Printf("issuer %s\n", cert.Issuer)
|
||||
cmd.Printf("valid %s .. %s", cert.NotBefore.Format(time.DateOnly),
|
||||
cert.NotAfter.Format(time.DateOnly))
|
||||
if d := time.Until(cert.NotAfter); d < 0 {
|
||||
cmd.Printf(" (EXPIRED)")
|
||||
} else {
|
||||
cmd.Printf(" (%d days left)", int(d.Hours()/24))
|
||||
}
|
||||
cmd.Println()
|
||||
|
||||
if cert.IsCA {
|
||||
cmd.Println("role certificate authority")
|
||||
}
|
||||
if u := usageNames(cert.ExtKeyUsage); u != "" {
|
||||
cmd.Printf("usage %s\n", u)
|
||||
}
|
||||
if sans := sanNames(cert); sans != "" {
|
||||
cmd.Printf("hosts %s\n", sans)
|
||||
} else if !cert.IsCA && !hasUsage(cert, x509.ExtKeyUsageClientAuth) {
|
||||
cmd.Println("hosts none — no client will accept this as a server certificate")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loadCA(dir string) (*tlsconf.CA, error) {
|
||||
ca, err := tlsconf.LoadCA(filepath.Join(dir, "ca.pem"), filepath.Join(dir, "ca-key.pem"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("no authority in %s — run 'llmgw ctl cert init-ca --dir %s' first", dir, dir)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return ca, nil
|
||||
}
|
||||
|
||||
func describeIssued(cmd *cobra.Command, dir, name string, hosts []string) error {
|
||||
cert, err := readCert(filepath.Join(dir, name+".pem"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Printf("issued %s\n", cert.Subject.CommonName)
|
||||
cmd.Printf(" %s\n", filepath.Join(dir, name+".pem"))
|
||||
cmd.Printf(" %s\n", filepath.Join(dir, name+"-key.pem"))
|
||||
if len(hosts) > 0 {
|
||||
cmd.Printf(" hosts: %s\n", strings.Join(hosts, ", "))
|
||||
}
|
||||
cmd.Printf(" expires %s\n", cert.NotAfter.Format(time.DateOnly))
|
||||
return nil
|
||||
}
|
||||
|
||||
func readCert(path string) (*x509.Certificate, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, _ := pem.Decode(b)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("%s is not PEM", path)
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
func hasUsage(cert *x509.Certificate, want x509.ExtKeyUsage) bool {
|
||||
for _, u := range cert.ExtKeyUsage {
|
||||
if u == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func usageNames(usages []x509.ExtKeyUsage) string {
|
||||
var out []string
|
||||
for _, u := range usages {
|
||||
switch u {
|
||||
case x509.ExtKeyUsageServerAuth:
|
||||
out = append(out, "serverAuth (identifies the control plane)")
|
||||
case x509.ExtKeyUsageClientAuth:
|
||||
out = append(out, "clientAuth (identifies an agent)")
|
||||
default:
|
||||
out = append(out, fmt.Sprintf("%v", u))
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
func sanNames(cert *x509.Certificate) string {
|
||||
names := append([]string{}, cert.DNSNames...)
|
||||
for _, ip := range cert.IPAddresses {
|
||||
names = append(names, ip.String())
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
+184
-25
@@ -37,6 +37,22 @@ func (c *ctlClient) get(ctx context.Context, path string, out any) error {
|
||||
return c.do(ctx, http.MethodGet, path, nil, out)
|
||||
}
|
||||
|
||||
// post sends an already-encoded body, so a caller that has marshalled a typed
|
||||
// value keeps its field names rather than having them re-derived here.
|
||||
func (c *ctlClient) post(ctx context.Context, path string, body []byte, out any) error {
|
||||
return c.do(ctx, http.MethodPost, path, json.RawMessage(body), out)
|
||||
}
|
||||
|
||||
// put replaces a resource. Used where a second call is a better answer to the
|
||||
// same question rather than another one: re-measuring a model's capacity.
|
||||
func (c *ctlClient) put(ctx context.Context, path string, body []byte, out any) error {
|
||||
return c.do(ctx, http.MethodPut, path, json.RawMessage(body), out)
|
||||
}
|
||||
|
||||
func (c *ctlClient) delete(ctx context.Context, path string, out any) error {
|
||||
return c.do(ctx, http.MethodDelete, path, nil, out)
|
||||
}
|
||||
|
||||
func (c *ctlClient) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
@@ -54,6 +70,9 @@ func (c *ctlClient) do(ctx context.Context, method, path string, body, out any)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
// Who made the change, kept with a stored model so a catalog that grew
|
||||
// over months can say where each entry came from.
|
||||
req.Header.Set("X-Llmgw-Actor", actor())
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
@@ -97,6 +116,7 @@ func newCtlCmd() *cobra.Command {
|
||||
|
||||
cmd.AddCommand(
|
||||
newNodesCmd(client, &asJSON),
|
||||
newLogsCmd(client, &asJSON),
|
||||
newModelsCmd(client, &asJSON),
|
||||
newReplicasCmd(client, &asJSON),
|
||||
newExplainCmd(client, &asJSON),
|
||||
@@ -106,49 +126,168 @@ func newCtlCmd() *cobra.Command {
|
||||
"Allow placement on a node again"),
|
||||
newNodeStateCmd(client, "drain", "draining",
|
||||
"Move work off a node, one replica at a time"),
|
||||
// cert works on files rather than the admin API, so --server does not
|
||||
// apply to it; it lives here because this is where operators look.
|
||||
newBenchCmd(client, &asJSON),
|
||||
newStatusCmd(client, &asJSON),
|
||||
newModelCmd(client),
|
||||
newScaleCmd(client),
|
||||
newEngineCheckCmd(),
|
||||
newToolCheckCmd(),
|
||||
newWeightsCmd(client, &asJSON),
|
||||
newCapacityCmd(client, &asJSON),
|
||||
newUsageCmd(client, &asJSON),
|
||||
newCertCmd(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newNodesCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
var f nodeFilter
|
||||
c := &cobra.Command{
|
||||
Use: "nodes",
|
||||
Short: "List nodes and their capacity",
|
||||
Long: "On a fleet too large to read a whole table of, the filters are how a\n" +
|
||||
"question gets asked: which nodes on this fabric, which of this GPU class,\n" +
|
||||
"which are not ready. They combine, and they apply to --json too.",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
var nodes []adminapi.NodeView
|
||||
if err := client().get(cmd.Context(), "/api/v1/nodes", &nodes); err != nil {
|
||||
return err
|
||||
}
|
||||
total := len(nodes)
|
||||
nodes, err := f.apply(nodes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *asJSON {
|
||||
return emitJSON(nodes)
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
// A filter that matched nothing and an empty fleet call for
|
||||
// opposite next steps: one is a cluster to look at, the other
|
||||
// is a word to correct.
|
||||
if !f.empty() && total > 0 {
|
||||
fmt.Println(noMatches("nodes", f.describe(), total))
|
||||
return nil
|
||||
}
|
||||
fmt.Println("No nodes have registered.")
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tab()
|
||||
fmt.Fprintln(w, "NODE\tSTATE\tGPUS\tCLASSES\tFABRICS\tREPLICAS\tLAST SEEN")
|
||||
fmt.Fprintln(w, "NODE\tSTATE\tGPUS\tMEM\tDISK\tENGINES\tCLASSES\tFABRICS\tREPLICAS\tLAST SEEN")
|
||||
for _, n := range nodes {
|
||||
fmt.Fprintf(w, "%s\t%s\t%d/%d free\t%s\t%s\t%d\t%s\n",
|
||||
n.ID, n.State, n.GPUFree, n.GPUTotal,
|
||||
fmt.Fprintf(w, "%s\t%s\t%d/%d free\t%s\t%s\t%s\t%s\t%s\t%d\t%s\n",
|
||||
n.ID, n.State, n.GPUFree, n.GPUTotal, memColumn(n.MemoryGB),
|
||||
diskColumn(n.DiskFreeGB, n.DiskTotalGB),
|
||||
// What the node can run, which is the first thing to look
|
||||
// at when a model will not place and the hardware looks
|
||||
// fine.
|
||||
join(n.Engines),
|
||||
join(n.Classes), join(n.Fabrics), n.Replicas, n.LastSeenAgo)
|
||||
}
|
||||
_ = w.Flush()
|
||||
|
||||
// Unhealthy GPUs are usually the answer when a node looks idle but
|
||||
// nothing schedules onto it.
|
||||
// nothing schedules onto it. An engine still installing, or one
|
||||
// that failed to, is the other usual answer.
|
||||
said := false
|
||||
for _, n := range nodes {
|
||||
for _, p := range n.Unhealthy {
|
||||
fmt.Printf("\n%s: %s (kept out of placement)", n.ID, p)
|
||||
said = true
|
||||
}
|
||||
for _, p := range n.Provision {
|
||||
if p.State == "ready" {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("\n%s: %s %s %s: %s", n.ID, p.Engine, p.Version, p.State, p.Detail)
|
||||
said = true
|
||||
}
|
||||
for _, w := range n.Warnings {
|
||||
fmt.Printf("\n%s: %s", n.ID, w)
|
||||
said = true
|
||||
}
|
||||
}
|
||||
if hasUnhealthy(nodes) {
|
||||
if said {
|
||||
fmt.Println()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&f.fabric, "fabric", "", "only nodes attached to this fabric")
|
||||
c.Flags().StringVar(&f.class, "class", "", "only nodes with this GPU class")
|
||||
c.Flags().StringVar(&f.state, "state", "", "only nodes in this state (prefix match)")
|
||||
c.Flags().StringArrayVar(&f.labels, "label", nil,
|
||||
"only nodes carrying key=value; repeatable, and key alone matches any value")
|
||||
return c
|
||||
}
|
||||
|
||||
// modelStatus says what is happening to a model, in the words that decide what
|
||||
// to do about it.
|
||||
//
|
||||
// The previous rendering distinguished "a replica is coming up" from "nothing
|
||||
// could be placed" by a parenthetical — "0/1 ready (1 total)" against "0/1
|
||||
// ready" — and those need opposite responses: one means wait, the other means
|
||||
// look at explain-placement. On four models the difference is findable; on
|
||||
// forty it is invisible, and a model that will never start looks exactly like
|
||||
// one that is thirty seconds away.
|
||||
//
|
||||
// A model appearing here before it serves is deliberate: this is the operator's
|
||||
// view, and a model coming up is precisely what someone wants to see. Clients
|
||||
// are told separately and only when it is true — /v1/models lists what is
|
||||
// actually served, never what is merely catalogued.
|
||||
func modelStatus(m adminapi.ModelView) string {
|
||||
switch {
|
||||
case m.Min == 0 && m.Total == 0:
|
||||
return "stopped"
|
||||
case m.Ready >= m.Min && m.Min > 0:
|
||||
return "ready"
|
||||
case m.Total > m.Ready:
|
||||
return "starting"
|
||||
default:
|
||||
return "not placed"
|
||||
}
|
||||
}
|
||||
|
||||
// idleColumn shows how long a replica has gone unused.
|
||||
//
|
||||
// "-" for a replica nothing has reported use of, which is emphatically not the
|
||||
// same as idle forever: it is what every replica looks like before its first
|
||||
// request, and what they all look like after a router restart. Showing that as
|
||||
// a very large number would make an untouched fleet read as an abandoned one.
|
||||
func idleColumn(sec int64) string {
|
||||
if sec < 0 {
|
||||
return "-"
|
||||
}
|
||||
return (time.Duration(sec) * time.Second).Round(time.Second).String()
|
||||
}
|
||||
|
||||
// memColumn shows a node's memory, distinguishing "did not say" from a number.
|
||||
// An older agent reports nothing, and that is not the same as a small machine.
|
||||
func memColumn(gb int) string {
|
||||
if gb <= 0 {
|
||||
return "-"
|
||||
}
|
||||
return fmt.Sprintf("%dG", gb)
|
||||
}
|
||||
|
||||
// diskColumn renders free space against the size it is free out of.
|
||||
//
|
||||
// Both, because they answer different questions and the first is useless
|
||||
// without the second: 8 GB free is comfortable on a 10 TB filer and nearly
|
||||
// full on a 10 GB root partition, and it is the second case that stops a
|
||||
// model from being placed.
|
||||
//
|
||||
// A dash for a node that reported nothing, which an older agent and a node
|
||||
// with no model directories both do. It has to read as "not known" rather
|
||||
// than as "no space", or every unconfigured node looks like it is full.
|
||||
func diskColumn(freeGB, totalGB int) string {
|
||||
if totalGB <= 0 {
|
||||
return "-"
|
||||
}
|
||||
return fmt.Sprintf("%dG/%dG", freeGB, totalGB)
|
||||
}
|
||||
|
||||
func newModelsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
@@ -169,14 +308,15 @@ func newModelsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
}
|
||||
|
||||
w := tab()
|
||||
fmt.Fprintln(w, "MODEL\tREPLICAS\tALIASES\tPROFILES\tFALLBACKS")
|
||||
fmt.Fprintln(w, "MODEL\tSERVING\tREPLICAS\tSTATUS\tALIASES\tPROFILES\tFALLBACKS")
|
||||
for _, m := range models {
|
||||
replicas := fmt.Sprintf("%d/%d ready", m.Ready, m.Min)
|
||||
if m.Total > m.Ready {
|
||||
replicas += fmt.Sprintf(" (%d total)", m.Total)
|
||||
serving := "no"
|
||||
if m.Ready > 0 {
|
||||
serving = "yes"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||
m.Name, replicas, join(m.Aliases), join(m.Profiles), join(m.Fallbacks))
|
||||
fmt.Fprintf(w, "%s\t%s\t%d/%d\t%s\t%s\t%s\t%s\n",
|
||||
m.Name, serving, m.Ready, m.Min, modelStatus(m),
|
||||
join(m.Aliases), join(m.Profiles), join(m.Fallbacks))
|
||||
}
|
||||
return w.Flush()
|
||||
},
|
||||
@@ -185,6 +325,7 @@ func newModelsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
|
||||
func newReplicasCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
var model string
|
||||
var f replicaFilter
|
||||
c := &cobra.Command{
|
||||
Use: "replicas",
|
||||
Short: "List replicas and the shards they are made of",
|
||||
@@ -197,24 +338,48 @@ func newReplicasCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
if err := client().get(cmd.Context(), path, &reps); err != nil {
|
||||
return err
|
||||
}
|
||||
total := len(reps)
|
||||
reps = f.apply(reps)
|
||||
if *asJSON {
|
||||
return emitJSON(reps)
|
||||
}
|
||||
if len(reps) == 0 {
|
||||
if !f.empty() && total > 0 {
|
||||
fmt.Println(noMatches("replicas", f.describe(), total))
|
||||
return nil
|
||||
}
|
||||
fmt.Println("No replicas are running.")
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tab()
|
||||
fmt.Fprintln(w, "REPLICA\tMODEL\tSTATE\tPOOL\tNODES\tAGE\tENDPOINT")
|
||||
fmt.Fprintln(w, "REPLICA\tMODEL\tSTATE\tPOOL\tNODES\tAGE\tIDLE\tENDPOINT")
|
||||
for _, r := range reps {
|
||||
var nodes []string
|
||||
for _, s := range r.Shards {
|
||||
nodes = append(nodes, fmt.Sprintf("%s:%v", s.Node, s.GPUs))
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
r.ID, r.Model, r.State, r.Pool, join(nodes),
|
||||
(time.Duration(r.AgeSec) * time.Second).String(), r.Endpoint)
|
||||
// The state says stale rather than a column of its own: most
|
||||
// replicas are not, a column would be empty for all of them,
|
||||
// and when one is it is the first thing worth knowing about it.
|
||||
state := r.State
|
||||
if r.Stale {
|
||||
state += " (stale profile)"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
r.ID, r.Model, state, r.Pool, join(nodes),
|
||||
(time.Duration(r.AgeSec) * time.Second).String(),
|
||||
idleColumn(r.IdleSec), r.Endpoint)
|
||||
// A line rather than a column, because most replicas are not
|
||||
// downloading anything and a column would be empty for all of
|
||||
// them. When one is, it is the only thing worth reading.
|
||||
for _, s := range r.Shards {
|
||||
if s.Fetch == nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, " %s: fetching %s\t\t\t\t\t\t\t\n",
|
||||
s.Node, fetchColumn(s.Fetch))
|
||||
}
|
||||
}
|
||||
_ = w.Flush()
|
||||
|
||||
@@ -232,6 +397,9 @@ func newReplicasCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&model, "model", "", "only this model's replicas")
|
||||
c.Flags().StringVar(&f.node, "node", "", "only replicas with a shard on this node")
|
||||
c.Flags().StringVar(&f.state, "state", "", "only replicas in this state (prefix match)")
|
||||
c.Flags().StringVar(&f.pool, "pool", "", "only this pool; matches either half of fabric/class")
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -325,12 +493,3 @@ func dash(s string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func hasUnhealthy(nodes []adminapi.NodeView) bool {
|
||||
for _, n := range nodes {
|
||||
if len(n.Unhealthy) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
)
|
||||
|
||||
func newLogsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
var (
|
||||
source string
|
||||
lines int
|
||||
)
|
||||
c := &cobra.Command{
|
||||
Use: "logs NODE",
|
||||
Short: "Show the recent lines of a node's agent log, or of an engine on it",
|
||||
Long: "Read through the agent's own stream, so it needs no access to the node:\n" +
|
||||
"no ssh, no jump host, no root for the journal. The agent keeps its last\n" +
|
||||
"2000 lines and each engine's last few hundred.\n\n" +
|
||||
" llmgw ctl logs llmgw-node06 the agent\n" +
|
||||
" llmgw ctl logs llmgw-node06 --source SHARD an engine's output",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
q := url.Values{}
|
||||
q.Set("source", source)
|
||||
q.Set("lines", strconv.Itoa(lines))
|
||||
var v adminapi.LogsView
|
||||
err := client().get(cmd.Context(),
|
||||
"/api/v1/nodes/"+url.PathEscape(args[0])+"/logs?"+q.Encode(), &v)
|
||||
if *asJSON {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(v)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, l := range v.Lines {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), l)
|
||||
}
|
||||
if v.Error != "" {
|
||||
return fmt.Errorf("%s", v.Error)
|
||||
}
|
||||
// Where else to look, since the source list is the one thing a
|
||||
// person cannot guess.
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "\n-- %s on %s; other sources: %s\n",
|
||||
v.Source, v.Node, strings.Join(v.Sources, ", "))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&source, "source", "agent", `"agent", or a shard id for that engine's output`)
|
||||
c.Flags().IntVarP(&lines, "lines", "n", 200, "how many of the most recent lines")
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
)
|
||||
|
||||
// newWeightsCmd reports and reclaims model files nothing needs.
|
||||
//
|
||||
// The dangerous half of storage management, so its shape is defensive: listing
|
||||
// is the default and deleting takes a flag, only files llmgw fetched are ever
|
||||
// candidates, and the node checks every path again before unlinking it.
|
||||
//
|
||||
// What it will never touch is the more important half of the contract. Weights
|
||||
// an operator put on a node — by scp, by a shared mount, by hand — are none of
|
||||
// this system's business however unreferenced they look, and a model that took
|
||||
// a week to copy must not be removable by a command someone ran to tidy up.
|
||||
func newWeightsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "weights",
|
||||
Short: "Show cached model files no model in the catalog needs",
|
||||
Long: "Lists weights llmgw downloaded for models that are no longer in the\n" +
|
||||
"catalog, per node, with what deleting them would free.\n\n" +
|
||||
"Only files llmgw fetched are considered. Anything an operator put on a\n" +
|
||||
"node is left alone, whatever the catalog says — llmgw observes storage\n" +
|
||||
"rather than owning it, and only takes back what it put there itself.\n\n" +
|
||||
"Nothing is deleted without --collect.",
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
var collect bool
|
||||
var node string
|
||||
|
||||
cmd.Flags().BoolVar(&collect, "collect", false, "delete them, rather than only listing")
|
||||
cmd.Flags().StringVar(&node, "node", "", "limit to one node")
|
||||
|
||||
cmd.RunE = func(cmd *cobra.Command, _ []string) error {
|
||||
if collect {
|
||||
return collectOrphans(cmd, client(), node, *asJSON)
|
||||
}
|
||||
return listOrphanWeights(cmd, client(), node, *asJSON)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func listOrphanWeights(cmd *cobra.Command, c *ctlClient, node string, asJSON bool) error {
|
||||
var found []adminapi.OrphanView
|
||||
if err := c.get(cmd.Context(), "/api/v1/weights/orphans", &found); err != nil {
|
||||
return err
|
||||
}
|
||||
if node != "" {
|
||||
found = onlyNode(found, node)
|
||||
}
|
||||
if asJSON {
|
||||
return emitJSON(found)
|
||||
}
|
||||
if len(found) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(),
|
||||
"Nothing to reclaim: every file llmgw fetched is still wanted by a model.")
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tab()
|
||||
fmt.Fprintln(w, "NODE\tFILES\tWOULD FREE")
|
||||
var total int64
|
||||
for _, o := range found {
|
||||
fmt.Fprintf(w, "%s\t%d\t%s\n", o.Node, len(o.Paths), fileSize(o.Bytes))
|
||||
total += o.Bytes
|
||||
}
|
||||
_ = w.Flush()
|
||||
|
||||
for _, o := range found {
|
||||
for _, p := range o.Paths {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "\n %s: %s", o.Node, p)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "\n\n%s in total. Delete with --collect.\n", fileSize(total))
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectOrphans(cmd *cobra.Command, c *ctlClient, node string, asJSON bool) error {
|
||||
body, err := json.Marshal(map[string]string{"node": node})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var results []struct {
|
||||
Node string `json:"node"`
|
||||
Files int `json:"files"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
if err := c.post(cmd.Context(), "/api/v1/weights/collect", body, &results); err != nil {
|
||||
return err
|
||||
}
|
||||
if asJSON {
|
||||
return emitJSON(results)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Nothing to reclaim.")
|
||||
return nil
|
||||
}
|
||||
var freed int64
|
||||
for _, r := range results {
|
||||
if r.Error != "" {
|
||||
// Reported rather than fatal: the other nodes' space is still worth
|
||||
// freeing, and this one will be offered again next time.
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", r.Node, r.Error)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s: asked to remove %d files, %s\n",
|
||||
r.Node, r.Files, fileSize(r.Bytes))
|
||||
freed += r.Bytes
|
||||
}
|
||||
// "asked", not "removed": each node decides, and one that refuses a path it
|
||||
// cannot prove it fetched is the feature working rather than failing.
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "\n%s requested. The nodes verify each path before deleting it.\n",
|
||||
fileSize(freed))
|
||||
return nil
|
||||
}
|
||||
|
||||
func onlyNode(in []adminapi.OrphanView, node string) []adminapi.OrphanView {
|
||||
out := make([]adminapi.OrphanView, 0, len(in))
|
||||
for _, o := range in {
|
||||
if o.Node == node {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fileSize renders a size the way someone reads one when deciding whether a
|
||||
// file is worth deleting.
|
||||
//
|
||||
// Separate from the per-token cache size renderer in modelplan, which never
|
||||
// leaves KiB and MiB. Model files are tens of gigabytes, and "8601.6 MiB" is
|
||||
// not a number anyone weighs a decision against.
|
||||
func fileSize(b int64) string {
|
||||
switch {
|
||||
case b >= 1<<30:
|
||||
return fmt.Sprintf("%.1f GB", float64(b)/(1<<30))
|
||||
case b >= 1<<20:
|
||||
return fmt.Sprintf("%.0f MB", float64(b)/(1<<20))
|
||||
case b > 0:
|
||||
return fmt.Sprintf("%d B", b)
|
||||
default:
|
||||
// The node reported no size. Saying "0 B" would suggest deleting it
|
||||
// gains nothing, which is a different claim from not knowing.
|
||||
return "size unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// fetchColumn renders a download's progress.
|
||||
//
|
||||
// A percentage when the size is known and a byte count when it is not, because
|
||||
// a server that does not send Content-Length is common enough — and an
|
||||
// invented percentage that stops moving is worse than a number that rises.
|
||||
func fetchColumn(f *adminapi.FetchView) string {
|
||||
if f.Percent != nil {
|
||||
return fmt.Sprintf("%s %d%% of %s", shortSource(f.Source), *f.Percent, fileSize(f.BytesTotal))
|
||||
}
|
||||
return fmt.Sprintf("%s %s so far", shortSource(f.Source), fileSize(f.BytesDone))
|
||||
}
|
||||
|
||||
// shortSource keeps the end of a source, which is the part that identifies it.
|
||||
func shortSource(s string) string {
|
||||
if i := strings.LastIndex(s, "/"); i >= 0 && i < len(s)-1 {
|
||||
return s[i+1:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
Loaded 100 of 360 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user