Compare commits

...
68 Commits
Author SHA1 Message Date
Ilker ManapandClaude Opus 5.5 36cdbce125 Merge ha: replicated store, second control plane, join codes, setup and console screens
Release / build (push) Successful in 4m26s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 5m14s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 22:59:49 +02:00
Ilker ManapandClaude Opus 5.5 211688fe7c Merge usage-rollups: usage sums, per-tenant stats, alerts and dashboard
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 22:56:50 +02:00
Ilker ManapandClaude Opus 5.5 9ab93a0aa6 Watch the cluster and edit every setting from the console
The console gains live views of nodes, GPUs, models, replicas, usage and
agent logs, and a settings tree over every key of server.yaml (agent.yaml on
a node), generated from the configuration structs: values with their
defaults and doc comments, edited in dialogs, saved only once the service's
own loader accepts them, comments kept. The generator now tells blocks apart
by package, which brings the TLS, engine and supervisor settings back into
the reference.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 22:54:09 +02:00
Ilker ManapandClaude Opus 5.5 aa3efcebbe Add a standby control plane with server add, and draw the screens as windows
server add gives another machine this llmgw, a certificate, the
configuration and store address, lets it into the store through Patroni, and
starts it; --vip keeps a floating address on the leader with keepalived,
following a new /leader endpoint. Nodes learn every control plane from the
leader and notice a dead one by HTTP/2 ping instead of after the kernel's
fifteen minutes. The setup, console, node and init screens are drawn full
screen in one frame: title bar, sidebar, bordered panel, key bar, dialogs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 22:29:12 +02:00
Ilker ManapandClaude Opus 5.5 f5c79052b1 Let a machine join a setup by code, dialling out, with nothing logging in
The setup screens can reach a database machine by a join code instead of
ssh: they listen on 7444 and show a short code; llmgw join dials out,
checks the setup by fingerprint, admits itself with the code's secret, and
runs what the setup sends until it is done. A joined machine is one more
onboard.Remote, so checks and the Patroni install run over it unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 21:49:15 +02:00
Ilker ManapandClaude Opus 5.5 38f2671ed9 Open setup screens or a console when llmgw runs bare in a terminal
With nothing installed, sudo llmgw asks for one machine or three, takes how
to reach the other two (ssh agent, key or password, and a sudo password used
only for the install), checks every machine first, shows the plan and its
command line, and installs. On a control plane it opens a console with the
cluster's state and the next steps. Machines are reached with Go's ssh so a
password works; the store's etcd and access rules use this machine's routed
address rather than a client-facing name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 21:33:59 +02:00
Ilker ManapandClaude Opus 5.5 975c93994f Set up a replicated store with server init --patroni, and watch it
init --patroni user@host ... installs PostgreSQL, Patroni and etcd on the
given machines over ssh, with this one as the third etcd vote, makes the role
and database, and moves the store in use into the cluster. Each step checks
first, so it changes nothing on a cluster that is up. For a store of several
hosts the control plane publishes each member's role, state and lag from
Patroni, a stopped member as missing, prints it in ctl status, and alerts on
it. Making an administrator on PostgreSQL sent a bool to an integer column;
it sends an integer now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 20:57:41 +02:00
Ilker ManapandClaude Opus 5.5 7a562ac1e7 Keep the store under Patroni, and back up from a store of several hosts
deploy/ha documents PostgreSQL under Patroni with a three-member etcd, as
running on the reference cluster, and the address that follows the primary.
Backups read such an address as libpq host and port lists and prefer a
standby, rather than resolving the list as one name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 20:31:19 +02:00
Ilker ManapandClaude Opus 5.5 17cc2e379b Add a Grafana dashboard per tenant
Requests and tokens by tenant, the shares failing and refused by a limit,
first-token p95, budgets spent this period and the day's heaviest users,
filtered by a tenant chooser. Installed beside the cluster dashboard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:58:39 +02:00
Ilker ManapandClaude Opus 5.5 ce006a6e6f Publish per-tenant request and budget series, and alert on them
Requests by outcome, tokens and first-token time per tenant, at the level
router.tenant_label names (team by default), and every budget's share spent
this period. Rules alert on budgets at 80% and 100%, tenants hitting limits,
failing, spiking past their weekly average, or waiting on first tokens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:55:24 +02:00
Ilker ManapandClaude Opus 5.5 9f479b9a70 Show each team's first-token and latency percentiles, errors and refusals
Percentiles are read from the histograms kept with the usage sums and shown
per group on the usage tab and in the people page's panels. Requests refused
by a limit are recorded as 429s against their owner, free of charge, so a team
hitting its limits shows as such. Usage can be grouped by model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:50:16 +02:00
Ilker ManapandClaude Opus 5.5 4454e1322c Read usage from sums kept as it is written, not from every request
Charts, totals and budgets summed the request table on every view. Usage is
now also summed in the writing transaction into five-minute, hourly and daily
tables per key, person, project and model, with latency and first-token
histograms. Charts read the coarsest tier their slices allow; totals and
budgets compose rows, slices, hours and days, and stay exact. Request records
can be given a retention without moving any total.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:43:10 +02:00
Ilker ManapandClaude Opus 5.5 febcf251e4 Send an administrator from /org to the people page's panel
Release / build (push) Successful in 1m16s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 3m56s
An organization's members, limits and use are a panel of the people page,
where they can also be changed, so the administrator's menu loses its
Organizations entry and /org redirects there. Managers keep /org.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:15:25 +02:00
Ilker ManapandClaude Opus 5.5 fffed716be Make people, limits and usage one page, with a panel per branch
Release / build (push) Successful in 1m14s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m57s
A team's members, quota and spend were on three pages that linked to each
other. They are three tabs of one page now, each at its old address, and
choosing anything in the tree opens its limits, capacity and use beside it,
editable in place.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:10:48 +02:00
Ilker ManapandClaude Opus 5.5 1b89d5cdea Say on the chat page whether a GPU or a CPU answered
Release / build (push) Successful in 1m12s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m54s
A model with replicas on a card and on CPUs answered at either speed with no
way to tell which. The picker names where each model runs, each reply names
the hardware that served it, and every answer carries x-llmgw-hardware.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 18:57:50 +02:00
Ilker ManapandClaude Opus 5.5 477d0bf19d Judge a card shared with another process every heartbeat, not once
Release / build (push) Successful in 1m14s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 3m50s
A card with something else on it when the agent started stayed out of
placement after that process let go of it, until the agent restarted. The
agent now sends what processes other than its own engines hold on each card,
and the control plane replaces the registration verdict with it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 18:46:23 +02:00
Ilker ManapandClaude Opus 5.5 372ef64889 Count the GPU time each owner's models held, on the usage page
Release / build (push) Successful in 1m11s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m48s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:07:59 +02:00
Ilker ManapandClaude Opus 5.5 82a63cfc6e Read what wears a GPU out: XIDs, remapped rows, PCIe and NVLink errors
Every minute the agent reads nvidia-smi's full report, its NVLink error
counters and the kernel's XID lines. They are published per card,
shown on the node page and the GPU map, charted in Grafana, and five
alerts watch them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:02:07 +02:00
Ilker ManapandClaude Opus 5.5 ff554c7062 Show what each model gets from its GPUs, per card and per watt-hour
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 15:56:42 +02:00
Ilker ManapandClaude Opus 5.5 b6a802c03b Map the fleet's GPUs, and name the cards held and idle
The cluster page draws every card coloured by utilisation, memory or
temperature, and lists replicas whose every card is under 5% busy.
llmgw_gpu_assigned says which replica, model and owner hold each card.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 15:54:58 +02:00
Ilker ManapandClaude Opus 5.5 bd36798b15 Sample GPU utilisation and memory every heartbeat, and chart them
Release / build (push) Successful in 1m12s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 3m52s
They were read once, at agent start, and published unchanged since.
Each sample now carries utilisation, memory and bandwidth in use, the SM
clock and uncorrected ECC errors. The node page charts each card, the
cluster page the fleet by class, Grafana has a GPU row, and three GPU
alerts are added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 15:42:17 +02:00
Ilker ManapandClaude Opus 5.5 dc483d8c68 Choose each engine's build from the node's hardware, for every GPU generation
Release / build (push) Successful in 1m12s
CI / test (push) Successful in 3m52s
Release / notes (push) Successful in 3s
Each engine has a list of builds, best first, from what its project
publishes and how each was compiled: llama.cpp CUDA 13.3, CUDA 12.8
(driver 570 for the GPUs it ships as PTX), then Vulkan, never the CPU
build on an NVIDIA node; vLLM CUDA 13, then CUDA 12.9, and for GPUs
older than every build of the version asked for, the newest built for
them (V100: 0.19.1). The node takes the first its hardware meets and
that installs and computes on the GPU, moving on when one does not, and
reports the version it runs. GPU processes are read from nvidia-smi's
full report, so Vulkan engines count as on their GPU.

Refs #2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 14:35:58 +02:00
Ilker ManapandClaude Opus 5.5 43d7c4eac1 Let the upgrade repair what the field's catalog already holds
Release / build (push) Successful in 1m11s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m51s
tp on a llama.cpp GPU profile is read as gpus_per_node rather than
refused, and a Hugging Face repository page as a vLLM source as the
repository id, so models written that way are placed after the upgrade
instead of dropped from the catalog. Engines adopted after an upgrade
are checked for computing on their GPUs too.

Refs #2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 14:20:11 +02:00
Ilker ManapandClaude Opus 5.5 86e9a1cb83 Give V100s the last vLLM built for them, not a build without their kernels
Release / build (push) Successful in 1m13s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 3m47s
Read from vLLM's release pipeline: every x86 build from 0.20.0 has
kernels for compute 7.5 and up, the CUDA 12.9 one included, and 0.19.1
is the last built for 7.0. PyPI's build is CUDA 13 from 0.20, not 0.26.
A V100 asked for a newer vLLM is refused, naming 0.19.1 for
engine_overrides; the CUDA 12.9 fallback is for older drivers on
Turing and newer. The cu129 wheel is found among the release's assets,
since its manylinux tag differs between versions.

Refs #2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 14:04:45 +02:00
Ilker ManapandClaude Opus 5.5 4f280a3546 Give each GPU node a vLLM build it can run, chosen on the cluster page
Release / build (push) Successful in 1m12s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m55s
A node that cannot run the CUDA 13 build installs the same version's
CUDA 12.9 build from vLLM's GitHub release, with PyTorch's cu129 torch;
the install computes on the GPU before it counts. The build can be
chosen per node — auto, cu13, cu129, none — on the cluster page, with
ctl engine-build, or over the admin API; the control plane keeps the
choice and tells the node at once.

Refs #2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:39:14 +02:00
Ilker ManapandClaude Opus 5.5 2735c5a318 Mirror a many-file model whole, with its checksums, for vLLM
CI / test (push) Successful in 3m58s
Release / build (push) Successful in 1m12s
Release / notes (push) Successful in 3s
The explorer fetches a safetensors set with its config and tokenizer;
the mirror gives out a version's manifest only once nothing is
downloading and every shard its index names is there, with each file's
sha256, which nodes check; mirror add takes several URLs; a repository
page is refused as a source.

Refs #2 (item 9).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:21:04 +02:00
Ilker ManapandClaude Opus 5.5 8957db30a2 Recognise a duplicate sign-up on PostgreSQL, and say what to do
A taken address on PostgreSQL fell through to a generic error, because
the store looked for SQLite's "UNIQUE". It checks code 23505 too. The
done page tells someone whose address had an account to sign in or ask
for a reset, without saying which case it was; other failures give a
logged reference.

Refs #2 (item 6).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:15:37 +02:00
Ilker ManapandClaude Opus 5.5 2ef4220643 Fail a node only for the fabrics it declares
A RoCE port without a v2 GID and a missing ib_uverbs failed doctor, and
node add, whatever fabric used them. They are now failures only for a
declared fabric and warnings otherwise; node add passes its --fabric
list to doctor so those are checked as declared.

Refs #2 (item 5).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:13:18 +02:00
Ilker ManapandClaude Opus 5.5 6014d1fee8 Say how a restart with no room beside it goes
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:11:46 +02:00
Ilker ManapandClaude Opus 5.5 a3ee48aced Restart in place at once when only the old replica is in the way
A restart with no room beside it waited the drain grace period before
stopping the old replica, and stopped it even when the replacement
would fit nowhere. It now stops at once when the replica alone blocks
its replacement, gives the restart up (saying why) when nothing would
fit, and ctl restart --in-place stops first on request.

Refs #2 (item 3).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:11:37 +02:00
Ilker ManapandClaude Opus 5.5 e313f32696 Decide engines per node, and let a node join without one it cannot run
engine_overrides give nodes picked by GPU class, id or labels their own
engine versions; node add --disable-engine keeps one off a node; what
vLLM cannot do on a node is a warning rather than a refused node; the
CUDA requirement follows the version; a hand-named binary is no longer
replaced by an installed one.

Refs #2 (items 4 and 7).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:07:25 +02:00
Ilker ManapandClaude Opus 5.5 cba3e329fc Let the mirror's serve form place a model on a GPU
The form offered no placement and named every GGUF profile cpu. It now
lists the fleet's GPU classes (the freest chosen) and the CPU, a GPU
count and parallel requests, names the profile from the placement, shows
what the model will run as, and refuses the CPU while GPUs are free
unless that is ticked.

Refs #2 (item 1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:59:22 +02:00
Ilker ManapandClaude Opus 5.5 cbd5521f4f Place llama.cpp across several GPUs of a node with gpus_per_node
tp on a llama.cpp profile was a width nothing placed. gpus_per_node is
now the count: placement reserves that many cards on one node and the
engine splits layers across them. tp, pp and nodes are refused with
what to write instead.

Refs #2 (item 8).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:56:34 +02:00
Ilker ManapandClaude Opus 5.5 057be16a92 Do not call a GPU shard ready while it computes on the CPU
llama.cpp whose CUDA backend cannot load runs on the CPU without an
error. The CUDA build is now asked for its devices when installed, a
shard on GPUs must hold memory on each before it is ready, and
engine-check reports the devices. Shards get CUDA_DEVICE_ORDER=PCI_BUS_ID
so CUDA numbers GPUs as placement does.

Refs #2 (item 2).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:54:39 +02:00
Ilker ManapandClaude Opus 5.5 0c7934ba96 v0.10.6
Release / build (push) Successful in 1m11s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 3m51s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 08:52:01 +02:00
Ilker ManapandClaude Opus 5.5 e40b9ac202 Load LoRA adapters at scale zero
Release / build (push) Successful in 1m17s
CI / test (push) Successful in 4m0s
Release / notes (push) Successful in 5s
--lora with --lora-init-without-apply leaves an adapter's scale at 1,
and llama-server applies it at that scale to any request without a
"lora" list of its own: every plain request for the model was served
with its adapters on. Found live on qwen2.5-coder-7b-instruct. Each
adapter is now loaded with --lora-scaled FILE:0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 07:39:12 +02:00
Ilker ManapandClaude Opus 5.5 323d172538 Attach LoRA adapters from the command line
Release / build (push) Successful in 1m14s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 3m52s
llmgw ctl model adapters lists a model's adapters and adds or removes
them through POST /api/v1/models/{name}/adapters, which reads each new
file first and refuses one that is not an adapter for the model's
architecture, as the mirror page's card does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 07:30:13 +02:00
Ilker ManapandClaude Opus 5.5 6b1fa65f59 Format kind_live_test.go
Release / build (push) Successful in 1m12s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 4m3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 07:20:35 +02:00
Ilker ManapandClaude Opus 5.5 663770226e Find LoRA adapters on Hugging Face and attach them from the mirror page
CI / test (push) Failing after 33s
Release / build (push) Successful in 1m3s
Release / notes (push) Successful in 3s
An adapters view in the explorer, filtered by the model a held model was
made from; GGUF files told apart as adapter or model by their first bytes;
held adapter files marked; and a model's card attaches and detaches held
adapters of its architecture, refusing models and mismatched architectures.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 07:19:08 +02:00
Ilker ManapandClaude Opus 5.5 cf53904cc5 Serve LoRA adapters on llama.cpp models, asked for by name
Release / notes (push) Successful in 3s
Release / build (push) Successful in 1m11s
CI / test (push) Successful in 3m47s
A model's adapters are fetched by each node with its weights and loaded
unapplied at start; a request naming an adapter reaches the model's
replicas with llama.cpp's per-request lora field. Adapter names share the
model namespace, are listed, and follow allowlists on their own. The
router's config seed and pushed catalog now build the name map in one place.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 23:40:47 +02:00
Ilker ManapandClaude Opus 5.5 94eec557ad Serve a model from an external OpenAI-compatible provider
CI / test (push) Successful in 3m47s
Release / build (push) Successful in 1m11s
Release / notes (push) Successful in 3s
A catalog model with external {url, model, api_key_file} and no profiles
is published as an endpoint by the control plane, the key read from its
file each time; the router rewrites the request's model to the provider's
name and sends its key. It is never scraped nor stale, and nothing is
placed for it. Named in fallbacks, it serves when the cluster has no room.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 23:29:01 +02:00
Ilker ManapandClaude Opus 5.5 d5f02c0a4b Spill a saturated pool's requests to the next pool
CI / test (push) Successful in 3m40s
The pool-priority filter kept the highest-priority pool however full it
was. It now takes the first pool, by priority, that is not saturated by
admission's own measure, and the preferred one when all are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 23:23:32 +02:00
Ilker ManapandClaude Opus 5.5 3d3c96e422 Displace the newest lowest-band request when the gateway queue is full
Release / notes (push) Successful in 3s
Release / build (push) Successful in 1m9s
CI / test (push) Successful in 3m50s
A total bound across bands (admission.queue_capacity_total, default half of
all bands' capacity together); reached, a request is not refused while a
lower band has requests queued, and the newest of the lowest band gets a 503
with Retry-After instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 23:07:53 +02:00
Ilker ManapandClaude Opus 5.5 623cdb5ba3 Sign releases, and install only signed ones once a key is trusted
CI / test (push) Successful in 4m6s
ed25519 over checksums.txt: llmgw release keygen makes the pair, CI signs
with the RELEASE_SIGNING_KEY secret when it is set, and release.Install
refuses an unsigned or wrongly signed release when the build embeds a
trusted key (internal/release/trusted.pub, empty for now) or
LLMGW_RELEASE_KEY names one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 23:03:48 +02:00
Ilker ManapandClaude Opus 5.5 561667c9be Name only the replicas that would make room in headroom
CI / test (push) Successful in 3m58s
Candidates tried on the way to finding no room were listed as whose replicas
a placement would stop.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 23:01:40 +02:00
Ilker ManapandClaude Opus 5.5 cf0178bf05 Test that a restart borrows a lower class's room and returns it
CI / test (push) Successful in 3m41s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 22:47:12 +02:00
Ilker ManapandClaude Opus 5.5 f288f3c423 Set capacity quotas, class, owner and reservation from the admin pages
Release / build (push) Successful in 1m9s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m49s
The quotas page gains a capacity section over the same API methods the CLI
uses; a model's card sets its class, owner, memory and cores, applying only
what changed. Headroom counts the replicas a model could get by preempting
what its class outranks, and the card and ctl model target use it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 22:40:45 +02:00
Ilker ManapandClaude Opus 5.5 5cf0003e74 Release v0.10.5
Release / notes (push) Successful in 3s
Release / build (push) Successful in 1m12s
CI / test (push) Successful in 3m53s
Collects rc.1 to rc.14: the mirror page in tabs with a card per model,
one line per record in every admin table, reserved CPU cores and required
memory, capacity classes and preemption, tenant capacity quotas, sizing by
measured throughput, and node sensor readings. Adds `llmgw upgrade` for the
mirror host, which `ctl upgrade` does not reach, and says so when it ends.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 22:26:19 +02:00
Ilker ManapandClaude Opus 5.5 d10fad064e Mark facility steps 1-5 deferred; node sensors collected
CI / test (push) Successful in 3m45s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:25:50 +02:00
Ilker ManapandClaude Opus 5.5 cd10e086c8 Read nodes' heat and power: GPUs, CPUs, board inlet, RAPL
Release / build (push) Successful in 1m10s
CI / test (push) Successful in 3m51s
Release / notes (push) Successful in 3s
Every agent samples, at most every ten seconds: nvidia-smi's temperature,
HBM temperature, power draw, enforced limit and throttle reasons per GPU;
hwmon's CPU package and board inlet temperatures; and CPU package power
from RAPL's energy counters. Sent with heartbeats, published as metrics
and shown on the node page; unmeasured sensors are absent, never zero.
Observe-only, step 0 of docs/facility-aware-placement.md, which now
describes the agent source and what these readings can and cannot say.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 19:49:08 +02:00
Ilker ManapandClaude Opus 5.5 8724dc027a Plan facility-aware placement: rack power, temperature and cooling
CI / test (push) Successful in 3m41s
A design document for step 5 of resource allocation, deferred: location
labels and rack spreading, power budgets with estimated then measured draw,
thermal and cooling conditions with hysteresis, opt-in shedding through
preemption, pluggable facility sources, and a rollout that is useful at
each step and safe when data is missing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 19:25:31 +02:00
Ilker ManapandClaude Opus 5.5 e7a8105aa4 Log a preempted replica's stop with the grace it had
Release / build (push) Successful in 1m7s
Release / notes (push) Successful in 2s
CI / test (push) Successful in 3m47s
It said "stopping a drained replica without a replacement after=5m0s" when
it had been given the 30-second preemption grace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:41:43 +02:00
Ilker ManapandClaude Opus 5.5 d0e9813ce9 Of equally idle victims, try the smallest first
Release / build (push) Successful in 1m11s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m55s
Idle times are unknown after a control plane restart, which left the choice
between a 1-core and a 4-core replica to map order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:23:08 +02:00
Ilker ManapandClaude Opus 5.5 0e73f37128 Stop only the replicas that make the room
Release / build (push) Successful in 1m4s
CI / test (push) Successful in 4m32s
Release / notes (push) Successful in 3s
Candidates were added in order until the model fitted, and every one added
was stopped, including those tried first that freed room on the wrong node.
A set that works is now pruned to the replicas it needs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:21:58 +02:00
Ilker ManapandClaude Opus 5.5 78e41460f9 Scale and place by what a replica was measured to serve
Release / build (push) Successful in 1m8s
CI / test (push) Successful in 4m24s
Release / notes (push) Successful in 3s
Stored ctl bench results reach the reconciler: requests per replica past
the measured SLO concurrency scale a model up, a replica is taken back only
if the rest stay under 70% of theirs, and among profiles of equal priority
the most measured tokens per GPU or core is tried first. ctl model target
sizes a model for a throughput against the room there is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:21:08 +02:00
Ilker ManapandClaude Opus 5.5 9dc1b62fdc Guarantee and limit capacity per part of the tenancy tree
Release / build (push) Successful in 1m8s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m51s
A model has an owner (org, team or project). Quotas guarantee a scope
GPUs, cores and memory and may limit it; what a scope holds is summed from
its models' replicas and those beneath it. A replica within its owner's
guarantee is not taken for another scope; what is held beyond a guarantee
is borrowed and taken back for a scope within its own. A limit stops a
model before it starts, and headroom stops there too. ctl quota and ctl
model owner set them; metrics and a CapacityAtLimit alert report them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:15:38 +02:00
Ilker ManapandClaude Opus 5.5 ac24e204a6 Add capacity classes and preempt lower ones to place a model
Release / build (push) Successful in 1m7s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m46s
A model's class (critical, standard, opportunistic; unset follows its
scaling) decides what may be stopped to make room for it: a lower class
for a higher, and within a class only what another grew into under load,
for a model below its minimum. Victims are tried on a copy of the
inventory and stopped only if they make the room. Preempted replicas are
drained with a short grace and their model is held back until the room is
used. ctl model resources and class move under ctl model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:02:59 +02:00
Ilker ManapandClaude Opus 5.5 990d82d801 Reserve CPU cores and require memory for CPU models
Release / build (push) Successful in 1m5s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m47s
A profile's cpu_cores is reserved out of its node's CPUs by placement, as
memory is, and becomes the engine's thread count. Agents report their CPU
count in registration and heartbeats. New CPU models must declare memory;
model views show reserved against measured memory with warnings, and
ctl model resources sets both for a stored model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 17:43:13 +02:00
Ilker ManapandClaude Opus 5.5 26151ec25a Format web.go
Release / build (push) Successful in 1m4s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m50s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:02:32 +02:00
Ilker ManapandClaude Opus 5.5 235bf33dcd Keep every admin table to one line per record
CI / test (push) Failing after 31s
Release / notes (push) Successful in 4s
Release / build (push) Successful in 1m4s
Headers never wrap, free text is clipped with the whole on hover, full-width
rows are only notes and forms. The cluster page hides columns empty on every
row and shortens node names; People shows two memberships and a count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:02:18 +02:00
Ilker ManapandClaude Opus 5.5 7099043c75 Keep each held row on one line
Release / build (push) Successful in 1m8s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m53s
The replica count, its nodes and the mode selector sit side by side instead
of stacking in a fixed-width cell, and node names drop the prefix every node
shares, with the full name on hover.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:50:02 +02:00
Ilker ManapandClaude Opus 5.5 a32ea0e5ab Say on each held row how its model runs and where
Release / build (push) Successful in 1m8s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 4m0s
Ready of wanted, how many are starting, and the nodes, from the fleet data
the card already reads, so the list answers it without a click.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:38:49 +02:00
Ilker ManapandClaude Opus 5.5 d7f1c1d545 Open a held model to its replicas, nodes and room for more
Release / build (push) Successful in 1m6s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m48s
The Held tab's model name opens a card: each replica with its node, state,
age, CPU and memory, the room for more found by dry-running the scheduler
on a copy of the inventory, and a form for the always-running and
under-load replica counts. An always-running count placement cannot meet
is refused with the reason. Headroom is also on the admin API.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:19:31 +02:00
Ilker ManapandClaude Opus 5.5 2ae12ae1fe Split the mirror page into Held, Explore and Fetch tabs
CI / test (push) Successful in 3m45s
Release / build (push) Successful in 58s
Release / notes (push) Successful in 3s
One long page made every visit wait on Hugging Face for a list it might not
show. Each tab now has its own address and does only its own work; only
Held reloads while a download runs, and a refused fetch returns to the form
it came from.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 08:31:28 +02:00
Ilker ManapandClaude Opus 5.5 62657f4c2a Release v0.10.4: CPU threads, tracing, and the rest of the review
Release / notes (push) Successful in 3s
CI / test (push) Successful in 4m57s
Release / build (push) Successful in 4m11s
CPU replicas start on half the machine's CPUs, not all of them: two busy
llama.cpp servers each spinning on all 8 vCPUs fell to 0.8 tok/s apiece,
where at 4 threads they keep 5.5. llamacpp.threads sets it per node.

Inference requests can be traced over OTLP/HTTP (tracing.endpoint), with
admission, schedule and upstream spans and traceparent sent to the engine.

Security: onboarding no longer puts the cluster root in the system trust
store and removes it where it was; the mirror's mutual-TLS API can be
limited to named certificates (--api-client); init.log no longer keeps the
administrator's password; a warning when auth is off on a public listen;
the recorded actor is sanitised and sudo-aware; Ray's dashboard is off, and
the remaining engine gaps are documented.

reconciler.go and agent.go are split by topic, the status mapping of each
failure is tested, and the plan's checklist is brought up to date.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 04:00:35 +02:00
Ilker ManapandClaude Opus 5.5 adb1b3cf2a Add replicas under load, up to a model's max, and take them back when quiet
Release / build (push) Successful in 1m2s
Release / notes (push) Successful in 2s
CI / test (push) Successful in 3m48s
A model's max never did anything: desiredFor returned the minimum, or one
for an on-demand model someone was asking for, whatever the queue. Six small
CPU nodes run out of slots quickly, and a model with room to grow stayed at
its minimum while requests waited.

The router already scrapes each engine's running and waiting requests. The
control plane now reads them per model on every tick, alongside when each
replica was last used, and the reconciler keeps how many replicas load has
added above a model's base. One or more requests waiting per ready replica,
held for a minute, adds one; ten minutes with nothing waiting removes one of
those again. It never goes above max or below the base, moves one step at a
time, and does not add while the last addition is still placing or starting
— on this fleet a replica is minutes of weights and gigabytes of memory, and
a spike should not leave a pile of replicas behind it. Decisions are logged
with the load behind them.

Nothing live changes until a model's max is raised above its min.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:18:07 +02:00
Ilker ManapandClaude Opus 5.5 bd12b0909a Serve a model from the registry with one command
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m50s
Release / build (push) Successful in 1m8s
Adding a model took four steps and a YAML file — plan it, fetch it into the
mirror, write its catalog entry with a memory figure somebody estimated,
scale it — and whether it would load was found out at the end.

`llmgw ctl model serve OWNER/REPO` does it as one job in the control plane,
where the registry, the mirror, the catalog and the fleet already are:

- the registry: the GGUF file set matching --quant (Q4_K_M by default) or
  --file, leaving out vision projectors and importance matrices, and naming
  what the repository does have when nothing matches; gated repositories are
  refused;
- the mirror: fetched at the repository's revision, with progress, and each
  file's sha256 compared with what the registry published — the mirror is
  the one place a corrupted download is caught once rather than loaded on
  every node;
- the check: whether the nodes' llama.cpp loads the architecture, and the
  memory it needs from its own header;
- the catalog, then a wait for a replica that stops early, with the reason,
  when the model is parked or fits on no node;
- a smoke test: one short request to the replica, with its engine key, and
  the answer and tokens per second.

ctl starts the job and prints each step as it changes; a download of hours
does not depend on the terminal. --on-demand adds the model without starting
a replica. Jobs are kept in memory until the control plane restarts.

The catalog name a repository suggests moved into the registry package, so
the mirror page and this agree on it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:05:13 +02:00
Ilker ManapandClaude Opus 5.5 116e75f8cb Upgrade the cluster with one command, over the agents' own streams
Release / build (push) Successful in 1m5s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m42s
Every upgrade so far has been a script of mine copying a binary to each
machine over ssh, restarting it and polling until it came back — which is
the job, but not something the product could do, and which needed ssh into
every node.

`sudo llmgw ctl upgrade VERSION` on the control plane does it. This machine
first: the release for its platform, checked against the release's
checksums, the old binary kept beside it, llmgw-server restarted, and a wait
until every node that was ready has reconnected. Then each node in turn: the
control plane tells its agent the version over the stream it already holds;
the agent fetches and checks the release itself, swaps it in with a rename,
answers, and exits with status 75 so systemd starts the new binary. Engines
keep running, as for any agent restart, and are adopted again.

A node is done when it is back on the new version, ready, with as many ready
replicas as before; one that is not within the timeout stops the run before
the next node, so a bad release reaches one machine. Nodes already on the
version are skipped, so running it again resumes.

The fetch-and-verify is one package now, shared with onboarding's idea of
what a release version looks like.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 02:54:36 +02:00
270 changed files with 31691 additions and 2709 deletions

No files matched your search

+18
View File
@@ -93,6 +93,24 @@ jobs:
./dist/llmgw-${version}-linux-amd64 --version
cat dist/checksums.txt
# Signed when the repository has a signing key, so a machine that trusts
# it installs nothing the server alone could have put there. Without the
# secret nothing is signed and releases are checked on their checksums,
# as before; a build that trusts a key refuses those, so the secret has
# to be in place before trusted.pub is filled in.
- name: Sign the checksums
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
run: |
set -euo pipefail
version="${GITHUB_REF_NAME:-$(git describe --tags --always)}"
if [ -z "${RELEASE_SIGNING_KEY:-}" ]; then
echo "no RELEASE_SIGNING_KEY secret; releases are not signed"
exit 0
fi
./dist/llmgw-${version}-linux-amd64 release sign dist/checksums.txt
echo "signed: dist/checksums.txt.sig"
- name: Attach to the release
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
+893 -3
View File
@@ -15,15 +15,848 @@ is only released once its **exit criterion** is met, not when its code lands.
| v0.8.0 | — | PostgreSQL, leader election, agent failover | released |
| v0.9.0 | — | Managing people and the tenancy tree | released |
| v0.10.0 | — | InfiniBand fabrics and NVLink, doctor, node add, engines installed by the nodes | released |
| v0.11.0 | — | High availability from setup, the setup and console screens, usage sums and per-tenant alerts | 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.
## Unreleased
## v0.11.0 — 2026-10-03
Changes since v0.10.3, published as release candidates (v0.10.4-rc.N) until
there are enough for v0.10.4.
A minor version: the store can be replicated and the control plane doubled,
both set up by llmgw itself; `sudo llmgw` opens setup screens and a console;
usage is read from sums kept as it is written. Released without candidates.
**Upgrading.** Migrations 0015 (usage sums, filled from the request table
already recorded — once, at the first start) and 0016 (control planes) run by
themselves. An agent older than this ignores the list of control planes and
keeps its configured one. Nothing in `server.yaml` or `agent.yaml` changes
meaning; `auth.usage_retention` and `router.tenant_label` are new and optional.
The GPU and fabric work below began from the field report in issue #2, after
a deployment on GPU nodes.
### GPU metrics that move, and charts of them
A GPU's utilisation and memory in use were read once, when the agent
started, and published from then on: a card busy since looked as idle as it
was at registration, and one idle since as busy. They are now sampled with
the temperatures and power every heartbeat (ten seconds at most), with the
memory bandwidth in use, the SM clock and uncorrected ECC errors besides.
An agent too old to send them still has its registration figures published,
and a figure the driver does not give is left out rather than shown as zero.
- **New series**: `llmgw_gpu_memory_utilization_percent`,
`llmgw_gpu_sm_clock_mhz`; `llmgw_gpu_utilization_percent`,
`llmgw_gpu_memory_used_bytes` and `llmgw_gpu_ecc_errors` now from the
sample.
- **The node page** shows each card's utilisation, memory, clock and ECC
errors in its table, and charts of utilisation, memory, temperature and
power, one line per card.
- **The cluster page** charts the fleet's GPUs by class: average utilisation
and memory in use.
- **Grafana** (`deploy/monitoring/grafana/llmgw-overview.json`) has a GPU row:
utilisation, memory, memory bandwidth, temperature, power against the
limit, clock, and what holds cards back.
- **Alerts**: `GPUThermalThrottling` (ten minutes slowed by heat),
`GPUUncorrectedECCErrors`, `GPUHot` (above 87 °C for ten minutes).
- **The cluster page's GPU map**: every card a square, a row per node,
coloured by how busy, how full or how hot it is (`?gpumap=util|memory|temp`),
with what holds it and its figures under the pointer; free cards dashed.
- **Cards held and doing nothing**: the replicas whose every card reads under
5% busy, with when they were last asked — the first place to find room.
- **What each model gets from its cards**, on the cluster page and in
Grafana: output tokens a second per GPU and per watt-hour, with the cards'
draw and average utilisation, over the last quarter hour.
- **What wears a card out**, read every minute: XID errors from the kernel's
log (counted from when the agent started), memory rows remapped or pages
retired and whether either waits for a reset, a card out of spare rows,
PCIe link retries and NVLink errors. Published as `llmgw_gpu_xid_errors`,
`llmgw_gpu_last_xid`, `llmgw_gpu_remapped_rows`, `llmgw_gpu_remap_pending`,
`llmgw_gpu_remap_failure`, `llmgw_gpu_retired_pages`,
`llmgw_gpu_retirement_pending`, `llmgw_gpu_pcie_replays`,
`llmgw_gpu_nvlink_errors`; said in words on the node page; a red outline
on the GPU map; a Grafana panel; and alerts — `GPUXidError`,
`GPUOutOfSpareRows`, `GPUNeedsReset`, `GPUPCIeRetriesClimbing`,
`GPUNVLinkErrorsClimbing`.
- **GPU time by owner** on the usage page: the cards each owner's models
held over the window, charted and summed into GPU-hours, whether or not the
models were asked anything — the time a GPU quota is about. From the
metrics store, as far back as it keeps history.
- **`llmgw_gpu_assigned{node,gpu,model,replica,owner}`**: which replica holds
each card and whose quota it counts against, for joining the GPU series to
models and tenants.
### Usage is read from sums kept as it is written
Every chart and total — the usage tab, quotas, the dashboard, a person's own
page, and budgets restored at start — summed the request table again on each
view: a row per request, scanned whole. Usage is now also summed as it is
written, in the same transaction, into five-minute slices, hours and days per
key, person, project and model (`usage_5m`, `usage_1h`, `usage_1d`; migration
0015 fills them from what is already recorded).
- **Charts** read the coarsest of the three their slices are made of: a day in
20-minute slices from the five-minute table, a week or a month from hours.
- **Totals and budgets** read the rows up to the next five-minute slice, then
slices, hours and days — exact, not rounded to a slice. On three million
requests over thirty days, a month's total per person went from 705 ms to
18 ms on PostgreSQL.
- **Latency and time to first token** are kept as histograms in the sums, so a
percentile per team or model can be read after the rows are gone.
- **`auth.usage_retention`** (e.g. `2160h`) deletes request records older than
that, hourly. Totals, charts and budgets are unchanged by it — they read the
sums — but `ctl request` finds nothing older. Unset keeps every record, as
before.
### Alerts per tenant and per budget
- **`llmgw_tenant_requests_total{tenant,outcome}`**, `llmgw_tenant_tokens_total`
and `llmgw_tenant_ttft_seconds`: requests by outcome (ok, error, limited),
tokens and time to first token per tenant. The tenant is the level
`router.tenant_label` names — `team` by default, or `org`, `project`, or
`none` — named by its path, "acme/platform". One level rather than every
project, so a cluster of thousands of projects does not become a Prometheus
of millions of series.
- **`llmgw_budget_spent_ratio{scope,id,name,period}`**: this period's spend
over the budget for every organization, team, project and person given one,
refreshed every minute.
- **Alerts** in `deploy/monitoring/llmgw-rules.yml`: `BudgetMostlySpent` (80%),
`BudgetSpent`, `TenantHittingLimits` (a tenth of requests refused),
`TenantErrors`, `TenantTrafficSpike` (three times the weekly average) and
`TenantSlowFirstToken`.
- **A Grafana dashboard per tenant**, `deploy/monitoring/grafana/llmgw-tenants.json`
(uid `llmgw-tenants`): requests and tokens by tenant, the share failing and
refused by a limit, first-token p95, every budget's share spent, and who used
most in the last day — with a tenant chooser at the top.
### How each team's requests fared, not only what they cost
- **Percentiles from the sums.** First token and latency, p50, p95 and p99,
per organization, team, project, person or model, read from the histograms
kept with the usage sums: on the usage tab's table, and in a branch's or
person's panel on the people page, beside the error rate and requests
refused by a limit.
- **Refused requests are counted against their owner.** A request a rate,
token or budget limit refused was counted only in Prometheus, by model; a
team running into its limit looked like a team that had gone quiet. It is
now recorded as a 429 against its key, person and project — no tokens, no
cost — and shown as "Limited".
- **Usage by model** is a grouping of the usage tab, by the model that
answered.
### The console watches the cluster and holds every setting
`sudo llmgw` on a control plane is a console in three groups down the left:
- **Watch**, refreshed every three seconds: the overview; nodes, with CPU,
memory, GPUs free, disk and agent version, and a node's detail — load,
engines, each card's busy, memory, temperature and power; every GPU in the
fleet and which model holds it; models, with a model's profiles, warnings
and replicas; replicas and where they run; the last day's use by team,
project or organization; and a node's agent log.
- **Settings**: every key of `server.yaml`, block by block, with what applies
when the file sets nothing and what each key is for, from its comment in
the code. Enter changes a key or opens a block; lists and maps of blocks
(endpoints, models, prices) take entries added and removed. A save is read
by the same loader the service uses first — nothing is written that would
not load — keeps the file's comments, leaves the old one beside it, and
offers to restart the service.
- **Do**: add a node, replicate the store, leave.
On a node, `sudo llmgw` opens the same settings for its `agent.yaml`.
The keys come from the configuration structs themselves, through the
generator that writes `docs/reference.md`, so a setting added to the code is on
the screen without anyone listing it, and a test fails if the two drift.
That generator told blocks apart by name alone, so `tlsconf.Config`,
`engines.Config` and `supervisor.Config` were each taken for the agent's
`Config` and dropped: the reference had no TLS, engine or supervisor settings
at all. It now has 37 blocks, six more than before.
### A second control plane with `llmgw server add`, and the screens as windows
- **`sudo llmgw server add [user@]host [--vip 10.0.0.160/24]`**, run on a
control plane, makes another machine a standby: this llmgw copied over, a
certificate from the cluster's authority, the same configuration and store
address, the machine let into the store (for a store under Patroni, one
change through Patroni's API), and the service started. Every node is told
of it within half a minute and fails over to it untouched. `--vip` keeps a
floating address on whichever control plane leads, with keepalived on both.
Tried on new machines: switching the leader off, the standby led after 14
seconds and the floating address answered from it after 23.
- **Nodes learn every control plane** from the leader (a new `ControlPlanes`
message, kept in `/var/lib/llmgw-agent/control-planes.json`), after the
ones in their configuration.
- **A node notices a dead control plane within a quarter of a minute.** A
leader whose machine stopped dead left its nodes' streams open until the
kernel gave up retransmitting, about fifteen minutes; the agent now pings
the stream after ten quiet seconds, and bounds dialling too.
- **`GET /leader`**: 200 on the control plane that leads, 503 on a standby,
open like `/ready` — what keepalived follows.
- **The store's access rules live in Patroni's shared configuration** for a
cluster set up by `server init --patroni`, so another control plane is let
in without touching each member.
- **The screens are windows**: full screen, a title bar, the steps or actions
down the left, the content in a bordered panel, the keys along the bottom,
questions and errors as dialogs over it — the setup, the console, adding a
node and one-machine init alike. Keyboard only.
### Machines nothing may log into: `llmgw join`
For sites that allow no ssh into a machine, the setup screens offer a fourth
way to reach one: a join code. The setup listens on port 7444 while it waits
and shows a code for each such machine, with the commands that fetch llmgw
onto it; there, `sudo llmgw join <code>` dials out — nothing dials in —
checks by the certificate fingerprint in the code that it reached the setup
that made it, admits itself with the code's secret, and runs what the setup
sends, printing each step, until the setup says it is done. A code is good
for one machine and an hour, and is under 90 characters so it can be copied
whole from one screen into another.
To the setup a joined machine is one more way of running a script there:
the checks, PostgreSQL, Patroni and etcd go over it unchanged. Tried with one
machine joining by code and another logged into with a password, in one
setup.
### Setting up from nothing in screens: `sudo llmgw`
Run with nothing after it in a terminal, as root, `llmgw` opens full-screen
setup on a machine with nothing installed, and a console on a control plane.
In a pipe or a script it prints its help, as before.
- **Setup** asks one machine or three. For three — this one as the control
plane and etcd's third vote, two more for the store under Patroni — it asks
how to reach the other two: the ssh agent, a key file, or a password, and a
sudo password where sudo asks for one. Passwords stay in memory for the run;
a sudo password grants the user passwordless sudo until the install
finishes, and the grant is removed after, failed or not.
- **Every machine is checked before anything is installed**: login, system
(Ubuntu 22.04/24.04, Debian 12), architecture, CPUs, memory and disk for its
role, clock against this one, NTP, the ports its role needs, internet, and
whether it reaches the other machines. Failures stop it, with what to do;
warnings let it go on.
- **Then the plan, and the command line that does the same** from a script,
then the install with its progress, and the administrator's password.
- **The console** shows `ctl status`, refreshed every five seconds, and offers
to add a node or, for a store on one machine, to replicate it.
- Machines are reached with Go's ssh client rather than the system's, so a
password can be used; a host key is recorded on first sight and refused if it
changes.
Tried end to end on three new machines whose user logs in and sudoes only with
a password.
### A store that survives losing its machine
`deploy/ha/` now covers the store as well as the control planes: PostgreSQL
under Patroni on two machines, its leader lock in a three-member etcd, and
llmgw given every member with `target_session_attrs=read-write`. Measured: a
planned switchover cost one failed lease renewal; switching off the primary's
machine promoted the standby in 24 seconds with the gateway serving
throughout. Example configurations are beside it.
**`llmgw server init --patroni user@host --patroni user@host`** sets it up:
PostgreSQL, Patroni and etcd on those machines over ssh — this one as etcd's
third vote — the role and database, and the store already in use moved in.
Every step looks first; run against a cluster that is up, it changes nothing.
Tried from scratch on three new machines (two minutes, then a failover by
switching the primary off) and against the reference cluster (nothing
touched).
**Watching it**: for a store of several hosts the control plane publishes
`llmgw_store_member`, `llmgw_store_replica_lag_bytes` and
`llmgw_store_patroni_up` from each member's Patroni, a member that stopped
showing as `missing` rather than vanishing; `llmgw ctl status` has a STORE
line; and four alerts — `StoreHasNoLeader`, `StorePatroniUnreachable`,
`StoreReplicaNotStreaming`, `StoreReplicaBehind`.
**Making an administrator on PostgreSQL** (`init --admin`, `users promote`)
failed: the flag was sent as a bool to an integer column, which SQLite takes
and PostgreSQL refuses. Every existing PostgreSQL store got its administrator
by being copied from SQLite, which is why nobody had met it.
`llmgw server backup` reads a store address of several hosts. It took the
list for one host name and reached the database only by falling through to
the second; it now hands libpq the list, and dumps from a standby when there
is one.
### People, limits and usage are one page
Managing a team meant its members on the people page, its quota on the quotas
page and its spend on the usage page, each linking to the others. They are now
one page in three tabs — **Organizations & people**, **Limits**, **Usage** —
each at the address it had before, so links and bookmarks still land. The menu
has one entry for them, People.
Choosing an organization, team, project or person in the tree, or a person in
the account list, opens a panel beside it with everything about that one
thing: its own limits and what it is held to from above, spend against its
budget, its per-member limit, its capacity quota, and its requests, tokens
and spend over the last week, drawn. Its limits can be set or changed there,
and the answer comes back to the same panel.
The administrator's menu no longer has an Organizations entry beside it: `/org`
and `/org/<id>` send an administrator to that organization's panel here. The
page stays as it was for an organization's managers, who cannot see this one.
### The chat page says whether a GPU or a CPU answered
A model can have replicas on a card and on CPUs at once, and the two answer
at very different speeds; the chat page gave no way to tell which one a reply
came from. The picker now says where each model runs — `GPU`, `CPU` or
`GPU + CPU` — and the line under each reply names what answered it: `GPU
6000-24g` or `CPU`, beside its tokens a second.
Every answer through the gateway carries the same as `x-llmgw-hardware`:
`cpu`, or the GPU class. An endpoint configured by hand, with no pool, sends
none.
### A card shared with something else is judged every heartbeat
A card with another process on it when its agent started was kept out of
placement, and stayed out — "5014 MiB already allocated by another process" —
after that process let go of it, until the agent was restarted. Found on a
node shared with ComfyUI. The other way round, a tenant arriving after
registration was never seen, and a card the agent's own engine held when the
agent restarted read as someone else's.
The agent now reads every card's memory per process on each heartbeat,
leaves out what its own engines hold, and sends the rest; the control plane
keeps a card out of placement on that reading, and lets it back in when it
falls under 512 MiB. `llmgw_gpu_memory_used_bytes` reads the same figure. An
agent too old to send it keeps the reading from registration.
### A GPU placement no longer runs on the CPU unnoticed
llama.cpp placed on a GPU, with its CUDA backend unable to load (cuBLAS not
where the loader looks), came up on the CPU, answered at CPU speed, and was
reported ready while its GPU sat reserved and idle. Three checks now stop it:
- **Installing** the CUDA build asks it which devices it sees
(`llama-server --list-devices`). None, on a node with GPUs, is a failed
install, with what `ldd` says `libggml-cuda.so` is missing. The binary's
own `ldd` could not say it: the backend is opened at run time.
- **After the upgrade**, engines the agent finds already running are held to
the same check when it adopts them: one serving a GPU placement on the CPU
is stopped, and its replacement starts through the check. The installed
CUDA build is checked again within ten minutes, and installed afresh if it
sees no GPU.
- **Starting** a shard on GPUs is ready only when its process holds memory on
each of them (`nvidia-smi --query-compute-apps`, its process group
included). One that does not is stopped and reported failed: "placed on GPU
0 but computing on the CPU", with the devices the engine lists. This covers
a hand-installed binary (`engines.llamacpp.bin`), which nothing else checks.
- **`llmgw ctl engine-check llamacpp`**, run on a GPU node, now also fails
when the engine sees no GPU, and otherwise prints the devices it computes on.
Shards are also given `CUDA_DEVICE_ORDER=PCI_BUS_ID`: placement numbers GPUs
as nvidia-smi does, by PCI bus, and CUDA's own order is fastest first — on a
node with two kinds of GPU, a shard could get a different card from the one it
was given.
### A model of many files, mirrored whole for vLLM
vLLM loads a directory — shards, config.json, the tokenizer — and does not
read GGUF. Mirroring one could go wrong several ways: the explorer offered the
shards alone, a node could be handed a version still downloading or missing a
shard, and a repository's page given as a model's source was fetched as one
file of HTML.
- **The explorer fetches a safetensors set with what the engine reads beside
it**: config.json, generation_config.json, the tokenizer files, the shard
index, the quantisation config. Never code: a `.py` would be the engine
running what the repository ships, which is to be chosen on purpose.
- **A version is offered to nodes only whole.** The mirror refuses its
manifest (409, with what is missing) while a file is still downloading, or
while a shard its `model.safetensors.index.json` names, or a part of a
split (`-00002-of-00004`), is absent. A node reports "not complete on the
mirror yet" and is tried again.
- **Checksums go with the files.** The mirror keeps each file's sha256 beside
it and the manifest carries it; a node checks every file of a set against
it before using the set, and fetches one that differs again.
- **`llmgw mirror add` takes several URLs** into one version.
- **A Hugging Face repository page given as a source** is read as the
repository id for a model vLLM or SGLang runs, which the engine downloads
itself (the node then needs to reach Hugging Face), and refused for one
llama.cpp runs, which needs a file.
With a vLLM profile of `tp: 4`, placement reserves four GPUs of one node for
such a model, as before.
### Signing up with an address that has an account
On PostgreSQL, signing up with an address already registered showed
"something went wrong; try again": the store recognised a duplicate by the
word "UNIQUE", which is SQLite's, and PostgreSQL says it in lower case under
code 23505. It now knows both, and the address is answered as on SQLite —
with the same page as a new sign-up, so the page cannot be used to find out
who has an account. That page now also says what to do if the address had
one: sign in, or ask an operator to reset the password.
An error the page does not explain says the account was not created and
gives a reference (`err-…`), logged beside the error, for an operator to find
it; the database's own words stay in the log.
### An adapter no fabric uses does not keep a node out
`llmgw doctor` failed a node for a RoCE port with no RoCE v2 GID entry, or for
`ib_uverbs` not being loaded, whether or not any fabric used them; on a node
added for single-node inference, with no fabric given, that refused it for
RDMA nobody had asked for.
- Those are failures only for ports a declared fabric uses, and only when a
fabric is declared. Otherwise they are warnings saying nothing uses them yet.
- Which fabric an undeclared InfiniBand port is on, and whether it has a
subnet manager LID, are notes rather than warnings; `node add` still warns
once that adapters are up with no fabric given.
- `node add --fabric` hands its fabrics to doctor (`doctor --fabric
name:mlx5_0,…`), so they are checked as declared even though agent.yaml is
written afterwards.
### A restart with no room beside it
`ctl restart` places each replacement beside the replica it replaces. With
no room for it — one GPU, every card that fits held by the replica itself —
the old replica was stopped only after the drain grace period, five minutes
of "restart replacing" with nothing saying why, which read as a restart that
never happened; scaling to zero and back was the way out. And a replacement
that fit nowhere even with the old replica gone was stopped for all the same,
leaving the model with nothing. Now:
- **The replica is the only thing in the way**, found by placing the
replacement on a copy of the inventory without it: stopped at once, the
replacement taking its place.
- **The replacement fits nowhere even then**: the restart is given up and the
replica keeps serving. `ctl replicas` shows `restart refused:` and why.
- **Room being made** — a lower class preempted, an idle on-demand replica
stopping — is waited for, as before.
- **`ctl restart --in-place`** stops each replica first, whatever room there is.
### Every GPU node gets engine builds it can run, by itself
Which build of an engine a node installs follows its hardware — the CPU
architecture, the driver, and the oldest GPU on it — from what each project
publishes and how it was compiled. Each engine has a list of builds, best
first; the node takes the first its hardware meets, and the first of those
that installs and is seen to compute on the GPU. A build the table is wrong
about costs its download, and the next one is tried.
| | llama.cpp | vLLM |
|---|---|---|
| Driver 580+, Turing and newer | CUDA 13.3 | the version asked for, CUDA 13 (PyPI) |
| Driver 525–579 | CUDA 12.8 (x64): its machine code for 8.6, 8.9, 12.0; the GPUs it ships as PTX need 570 | the version asked for, CUDA 12.9 (vLLM's GitHub release, PyTorch's cu129 torch) |
| Volta (V100) | CUDA 12.8 on driver 570+, else Vulkan | **0.19.1**, the newest vLLM built for 7.0: no build of 0.20 or later has its kernels |
| Pascal, Maxwell | CUDA 12.8 on 570+, else Vulkan | none: no vLLM is built for them |
| arm64 GPU (GH200, Grace) | CUDA 13.3, else Vulkan — never the CPU build | CUDA 13 or 12.9, 8.0 and newer |
| No NVIDIA GPU | the CPU build | none |
Read from the projects' release pipelines: vLLM's x86 builds start at 7.5
from 0.20.0, its PyPI build is CUDA 13 from 0.20, 0.19.1 was the last
compiled for 7.0 (PyPI's torch 2.10 is too); llama.cpp's CUDA 12.8 build
carries 5.0–9.0 as PTX and 8.6/8.9/12.0 as machine code, its CUDA 13.3 build
starts at 7.5.
- **A node running an older vLLM says so**: the cluster page's *vLLM build*
column shows the version installed, and the installation's detail says why
("vLLM 0.19.1, the newest built for compute capability 7.0, instead of
0.30.0"). Profiles choose GPUs by class, so a model meant for the newer
vLLM is not placed on a V100 by accident.
- **The install computes on the GPU before it counts**: a small tensor
operation for vLLM, the devices llama.cpp lists for its GPU builds.
- **The build can still be chosen per node** on the cluster page (auto, CUDA
13, CUDA 12.9, none), with `llmgw ctl engine-build NODE vllm BUILD`, or
`POST /api/v1/nodes/{id}/engines`; kept in the store and sent to the node
at once.
- **Whether an engine computes on its GPUs is read from every process
nvidia-smi lists**, not compute processes alone: the Vulkan build is a
graphics one. A llama.cpp shard is given `GGML_VK_VISIBLE_DEVICES` beside
`CUDA_VISIBLE_DEVICES`.
### Engines per node, and nodes that do not run them all
A cluster with older GPUs could not add them cleanly: `node add` refused a
V100 node for the vLLM it could not run, though it was meant for llama.cpp,
and `--ignore-checks` passed over every other problem with it.
- **What vLLM cannot do is a warning**, in `llmgw doctor` and `node add`: the
node joins without it and serves what it does run. `node add` checks the
vLLM the control plane installs, not the binary's own default.
- **`node add --disable-engine vllm`** keeps an engine off a node for good:
written as `engines.disabled` in its `agent.yaml`, never installed or
advertised there, and not checked by doctor.
- **`engine_overrides`** in `server.yaml` give nodes picked by GPU class, id
or labels a different version of an engine, or none — for exceptions; the
build each GPU generation needs is chosen without it (above).
- **The requirements follow the version**, as the table above sets out; the
threshold had been CUDA 13's for every version.
- **An engine binary named in `agent.yaml` is used.** The agent had installed
its own beside it, which was then used first.
- The node page shows each GPU's compute capability.
### Serving from the mirror says where a model will run
The serve form put every GGUF model on the CPU: it offered no choice, and its
"Profile name" field, pre-filled "cpu", read as though renaming it would move
the model to a GPU. Placement is decided by `gpu_class`, which the form never
set. Now:
- **Runs on** lists the fleet's GPU classes with how many cards are free, and
for llama.cpp the CPU. On a fleet with GPUs the class with the most free
cards is chosen.
- **GPUs per replica** sets how many cards a replica takes on one node
(`gpus_per_node` for llama.cpp, `tp` for vLLM), and **parallel requests**
how many a llama.cpp replica serves at once.
- The profile is named for where it runs (`t4`, `t4-x4`, `cpu`) rather than
typed.
- A line under the form says what the model will run as — engine, CPU or how
many of which GPU, memory, context pool and how it is divided — and turns
red when the CPU is chosen with GPUs free. The message after adding repeats
it.
- Choosing the CPU while GPUs are free is refused unless **run on the CPU
anyway** is ticked.
### llama.cpp on several GPUs of one node
A llama.cpp profile takes several of a node's GPUs with `gpus_per_node: N`:
placement reserves N cards of its `gpu_class` on one node, the engine sees
those N (`CUDA_VISIBLE_DEVICES`) and spreads the layers over them
(`--split-mode layer`, in proportion to each card's free memory, which for
whole cards is even). `tp` or `pp` on a llama.cpp profile was a width nothing
placed — the replica stayed unplaced or ran on one card. It is now read as
`gpus_per_node`, so a profile written that way is placed on that many cards
after the upgrade, rather than dropped; only both given and disagreeing, or
`nodes` above one, is refused.
## v0.10.6 — 2026-10-01
Published first as release candidates v0.10.6-rc.1 to rc.8; rc.5 failed its
format check and has no binaries.
Tried on the PoC cluster before release: LoRA adapters asked for by name
through the gateway, two on one model, each answering as trained and the model
itself unchanged; a saturated pool spilling to the next (8 of 12 concurrent
requests on the preferred replica, 4 on the other); and a bronze request
displaced from a full queue by a gold one, with 503 and Retry-After 5. Not tried
live: a model served from an external provider, which needs a provider key.
### Capacity from the admin pages
What v0.10.5 made settable from the command line is now on the pages too:
- **Quotas** has a Capacity section: every scope's guaranteed and most GPUs,
cores and memory beside what it holds, with a form to set, change or remove
one. It goes through the same checks as `llmgw ctl quota`.
- **A model's card** on the mirror page sets its class, its owner (when there
are quotas to count against), and the memory and cores a replica reserves,
beside its replica counts. Only what changed is applied, and each change is
said.
- **Room counts what a class may take.** The card, `llmgw ctl model target` and
the headroom API count the replicas a model could get by stopping those its
class outranks, separately from free room, and name whose they would be. A
critical model's card no longer refuses a replica that preemption would make
room for.
### LoRA adapters
A llama.cpp model can carry `adapters`, each a name and a GGUF LoRA file. The
nodes fetch them with the model's weights and llama.cpp loads them all at start,
applied to nothing (`--lora-scaled FILE:0`); a request that names an
adapter goes to the model's replicas with `"lora": [{"id": i, "scale": s}]`,
so one replica serves the model and all its adapters. Adapter names share the
namespace of models, are listed in `/v1/models`, and follow key allowlists on
their own — a key allowed an adapter does not reach the model under it. Adapter
files are kept from weight cleanup like the model's own. Not yet for vLLM or
SGLang profiles, whose adapters are loaded differently.
### Finding and attaching adapters from the mirror page
- **Explore** has a *LoRA adapters* view: adapters for any model, or — from a
held model's card, *find adapters on Hugging Face* — those made for the model
its repository was made from, since a GGUF repository is a quantisation and
adapters are made for the original.
- **Opening a repository** reads the first bytes of each GGUF file and marks it
a *LoRA adapter* or a *model*. Repositories tagged "lora" hold a model with
the adapter merged in about as often as an adapter, and only the file says.
- **Held adapter files** are marked in the list, and a model's card lists its
adapters, offers the held adapter files of its architecture to attach under a
name and scale, and removes them. A file that is not an adapter, or is for
another architecture, is refused before it reaches the catalog.
- **`llmgw ctl model adapters MODEL [--add NAME=URL[@SCALE]] [--remove NAME]`**
lists and changes them from the command line, through
`POST /api/v1/models/{name}/adapters`, with the same checks.
### Models served outside the cluster
A catalog model can be an OpenAI-compatible API elsewhere: `external: {url,
model, api_key_file}` and no profiles. Nothing is placed for it; the router
sends its requests to the provider, rewritten to the provider's name for the
model and with its key, read from the file on every publish so a rotated key is
picked up. Named in another model's `fallbacks`, it is used only when the
cluster has no capacity left. It is never scraped for metrics, and never reads
as stale. Prices, budgets, limits, usage and key allowlists apply as to any
model — and since its prompts leave the cluster, the allowlist is how to decide
who may send them (docs/install.md). `ctl models` and the cluster page mark it
external.
### Pool spillover
A model served from pools of different priority — H100 before A100, say — was
routed to the preferred pool however full it was. Now a pool whose replicas are
saturated, by the same measure admission uses (queue past its healthy depth, or
KV cache past its ceiling), passes requests to the next pool that is not. When
every pool is saturated the preferred one is kept: a pool that is just as full
gains nothing.
### Displacement: a higher band is not refused while a lower one waits
Each band's queue was bounded, and nothing bounded them together, so a full
gateway turned an interactive request away while batch requests sat queued
behind it. `admission.queue_capacity_total` now bounds everything queued —
by default half of every band's capacity together. Reached, a request is not
refused while a lower band has any queued: the newest request of the lowest
band is turned away instead, with a 503 and `Retry-After: 5`, so it backs off
and returns. The newest, because the oldest have waited longest and are
nearest their TTL. Counted as `displaced` in the admission rejection metric.
### Releases can be signed
`llmgw release keygen` makes an ed25519 key pair. With the private key in the
CI secret `RELEASE_SIGNING_KEY`, releases carry `checksums.txt.sig`; with the
public key in `internal/release/trusted.pub`, every binary built after refuses
a release not signed with it, wherever it installs one. Off until then, and
docs/install.md says in which order to turn it on.
### Restarts borrow a lower class's room
A restart places the replacement beside the replica it replaces, and where
there is no free room it now makes some the way any placement does: by
stopping a replica its class outranks, which gets its room back once the old
replica has gone. The in-place restart — the model unserved while its
replacement loads — is left for when nothing of a lower class is running. On a
cluster where every model is standard, that is every full one: mark the models
that can wait `opportunistic` (`llmgw ctl model class`, or the model's card).
## v0.10.5 — 2026-09-30
Published first as release candidates v0.10.5-rc.1 to rc.14; rc.5 failed its
format check and has no binaries.
After upgrading:
- **Declare what CPU models need.** A CPU model added from now on must say its
memory; one stored before this still loads and is flagged in `ctl models`
until `llmgw ctl model resources MODEL --memory GB --cores N` is run. Cores
are then reserved per replica and become the engine's thread count, for
replicas started after the change: `llmgw ctl restart --model MODEL`.
- **Upgrade the mirror on its own host**, now with one command:
`sudo llmgw upgrade v0.10.5 --service llmgw-mirror` (by hand once, for a
mirror older than this). `llmgw ctl upgrade` says so at the end.
### CPU cores are reserved, and memory has to be declared
The first step of resource allocation for larger clusters: what a replica
holds of a node is reserved, not assumed.
- **Cores.** A profile's `cpu_cores` is reserved out of its node's CPUs, as
memory is, and is what its engine computes on (llama.cpp's `--threads`). A
node holds as many CPU replicas as it has cores for, and two on one node no
longer spin against each other. Agents report their CPU count; a node that
does not, and a profile that declares no cores, constrain nothing.
- **Memory is required of new CPU models.** A model with no GPU is all host
memory, and one declaring none looked to placement as if it fitted
everywhere, any number of times. Adding one without `min_memory_gb` is now
refused. Models stored before this still load, and say what is missing.
- **Declared against measured.** Each model shows what one replica reserves
and the most one of its replicas uses — in `ctl models` (RESERVES) and on the
cluster page — with a warning when nothing is declared or when it uses more
than a tenth over what it reserves.
- **Setting them.** `llmgw ctl model resources MODEL --memory GB --cores N`
(and `POST /api/v1/models/{name}/resources`) for a model added at runtime.
`ctl model serve` and the mirror's serve form fill in cores — half the
smallest node the model fits on — beside the memory they already estimate.
Running replicas keep their old figures until restarted.
The commands are `llmgw ctl model resources` and `llmgw ctl model class`; in
rc.7 the first was briefly `llmgw ctl resources`.
### Capacity classes, and preemption
A model has a class — `critical`, `standard` or `opportunistic` — saying what
its capacity is worth against another's when there is not enough for both.
Unset, an on-demand model is opportunistic and every other model standard,
which is how the catalog behaved before. `llmgw ctl model class MODEL CLASS`
sets it.
When a model cannot be placed, replicas are stopped to make room for it, by
these rules:
- A lower class gives way to a higher one.
- Within a class, only replicas above a model's minimum — room it grew into
under load — and only for a model still below its own minimum.
- A critical model's minimum is never taken, nor a replica that has only just
loaded, nor one spanning several nodes.
- Idle on-demand replicas go first, then what lower classes grew into, then
the same within the class, then lower classes' minimums; the longest unused
first within each, and of equally idle ones the smallest.
- The set is tried on a copy of the inventory first, at most three replicas,
and nothing is stopped unless it makes the room. Of a set that does, each
replica whose stopping turns out not to be needed is left running, so an
idle replica tried first on the wrong node is not stopped with the one that
made the room.
A replica stopped this way is drained and given 30 seconds to finish what it
is serving. Its model is held back for up to five minutes, or until the model
it gave way to has its replicas, so it does not take the room straight back.
`ctl models` shows each model's class and "gave room to X" while held; the
cluster page shows the class and how many replicas were lost; the metric is
`llmgw_model_preempted_total{model,class}`, and every preemption is logged.
### Capacity quotas for the tenancy tree
An organization, team or project can be guaranteed GPUs, CPU cores and
memory, and limited to at most some amount of each. What it holds is what the
replicas of the models it owns hold — and those of the teams and projects
beneath it. `llmgw ctl model owner MODEL team:Acme/ml` says whose a model is;
one with no owner is shared and counts against nobody.
- **A guarantee is kept.** A replica within its owner's guarantee is never
taken for another scope's model, whatever the classes: a guarantee a higher
class elsewhere could take would not be one.
- **Beyond it is borrowing.** Free room is used; when a scope within its own
guarantee needs room, what others hold beyond theirs is taken back first,
whatever their class — except a critical model's minimum.
- **A limit is a ceiling.** A model whose owner would go past a limit anywhere
up the tree gets no further replica, and says which limit; headroom and the
mirror card stop there too.
`llmgw ctl quota set team:Acme/ml --gpus 8 --limit-gpus 16` (also `--cores`,
`--memory` and their limits), `llmgw ctl quota list` with what each scope holds,
`llmgw ctl quota clear`. Scopes are named by path or ID; a name that could mean
two is refused. Changes take effect within half a minute. Metrics:
`llmgw_capacity_holds`, `llmgw_capacity_guarantee` and `llmgw_capacity_limit`
by scope and resource; alert: CapacityAtLimit.
### Sized by what a replica was measured to serve
What `llmgw ctl bench MODEL --save` measured — the most requests at once one
replica served while meeting its SLO, and its throughput there — now drives
decisions rather than only being printed:
- **Scaling up** when requests per replica pass what was measured, not only
when the engines' queues fill: an engine takes requests into its slots long
after latency has gone.
- **Scaling down** only when the replicas left would stay under 70% of what
they were measured to hold, so a replica taken back does not put the rest
straight over.
- **Choosing a profile.** Among profiles of equal priority, the one measured
to serve the most per GPU — or per core — is tried first. Priority set by an
operator still wins.
- **Sizing.** `llmgw ctl model target MODEL --tokens-per-sec N` says how many
replicas that takes, how many run, and whether the rest fit, with the reason
when they do not; `--apply` sets it if they do. Headroom reports what one
more replica would serve.
A model never measured behaves exactly as before. Measurements are read from
the store every half minute.
### Upgrading a machine the control plane does not reach
`sudo llmgw upgrade VERSION --service UNIT` upgrades the machine it runs on —
the mirror's host above all, whose service runs unprivileged and cannot replace
its own binary as an agent does. The release's checksums are checked, the old
binary kept, the unit restarted and watched until it stays up.
### Nodes report their own heat and power
The first step of facility-aware placement (docs/facility-aware-placement.md),
observe-only: nothing is decided on these yet. Every agent reads, at most every
ten seconds, and sends with its heartbeat:
- **GPUs** from nvidia-smi: temperature, HBM temperature where the driver has
it, power draw and the enforced limit, and what is holding the clocks down —
power cap, thermal slowdown, power brake. A GPU's temperature was read once,
at registration; it is now current.
- **CPUs** from hwmon, which is what lm_sensors reads, with nothing to install:
the package temperature, and the board's inlet air sensor where it has one.
- **CPU power** from RAPL's energy counters.
A sensor a machine does not have is not reported, never shown as zero. New
metrics: `llmgw_gpu_power_watts`, `llmgw_gpu_power_limit_watts`,
`llmgw_gpu_memory_temperature_celsius`, `llmgw_gpu_throttled{reason}`,
`llmgw_node_cpu_temperature_celsius`, `llmgw_node_cpu_power_watts`,
`llmgw_node_inlet_temperature_celsius`. Each node's page shows them, with a GPU
table of temperature, power against its limit, and what is throttling it.
### The mirror page in tabs
/admin/mirror was one long page: what the mirror holds, the Hugging Face
explorer and the fetch form, one under another, with every visit waiting on
Hugging Face for a list it might not show. It is now three tabs — Held (with
how many are held, downloading and failed), Explore Hugging Face, and Fetch by
URL — each with its own address. Only the tab shown does its work, and only
Held reloads while something downloads. A download started from the explorer
links to Held, where its progress is; a fetch the mirror refused comes back on
the form it was sent from.
### A held model opens to where it runs, and how many
Clicking a model under Held opens a card beneath it: each replica with its
node, state, age and the CPU and memory its engine uses, and how many more
replicas the cluster has room for now, on which nodes, and why not more. The
replicas it always runs and the most it grows to under load are changed there.
Room is found by trying the scheduler itself on a copy of the inventory — the
same decisions the next passes would make, not an estimate beside them — and a
number of always-running replicas it cannot place is refused with the reason,
rather than accepted and left short. A ceiling above the room is taken, and
said. The same answer is at `GET /api/v1/models/{name}/headroom` on the admin
API.
Each row also says how its model runs without being opened: replicas ready of
those it should have, how many are still starting, and the nodes they are on —
in amber when short, in red when parked or when it does not fit. It is one line
per model beside its mode, and nodes drop the part of the name every node
shares (node05 for llmgw-node05, the full name on hover).
### One line per record, in every table
The same rule across the admin pages. Headers do not wrap; a record is one
line; free text is cut to its column with the whole of it on hover rather than
given a row of its own; a row across the whole table is only a note or an open
form. On the cluster page that means a node is one line (its address and logs
beside its name, not under it), a model's description is a column rather than a
second row, node names drop the prefix they all share, and the GPUs, classes,
fabrics and fallbacks columns appear only when some row has something in them —
on a CPU-only cluster they were three columns of dashes pushing the table past
the page. On People, a person in many teams shows two and a count, the rest on
hover.
## v0.10.4 — 2026-09-30
Published first as release candidates v0.10.4-rc.1 to rc.9.
After upgrading, run `llmgw ctl restart --all` once: CPU replicas take the new
thread count below only when they start.
### CPU replicas no longer fight over the CPUs
llama.cpp computes on every CPU of the machine unless told otherwise, and the
agent did not tell it. Two busy CPU replicas on one node then spin against each
other: on an 8-vCPU node, 7B generation fell from 10 tokens a second alone to
0.8 each with both busy, in two rounds of the same test. The agent now starts a
CPU replica on half the machine's CPUs, where one alone makes 6.5 and two busy
make 5.5 each. `llamacpp.threads` in the agent's configuration sets it for a
node — all of them suits a node that only ever runs one CPU replica — and a
profile's own `--threads` still wins. Measured on VMs whose host is
oversubscribed; the proportions, not the numbers, are the point.
### Tracing
With `tracing.endpoint` set (or `OTEL_EXPORTER_OTLP_ENDPOINT`), each inference
request is traced over OTLP/HTTP: a span for the request, carrying its
`X-Request-Id`, with admission, scheduling and each attempt at an engine under
it. A caller's own `traceparent` is continued, and the engine is sent one for
its attempt. `tracing.sample_ratio` traces a share of the requests that arrive
without a decision. Off, it costs nothing.
### Restarting replicas
@@ -77,6 +910,40 @@ more than 26 hours old or there has never been one. docs/install.md now says how
to restore, including why running engines survive it: the engine-key secret
comes back with the store.
### Replicas follow load between min and max
A model's `max` was a ceiling nothing reached: every model ran exactly its
minimum however long its queue. Now, when requests are waiting at the engines —
every slot of every replica busy — at one or more per replica for a minute, one
replica is added, up to `max`; ten minutes with nothing waiting takes one of
those away, down to `min`. One step at a time, and not while the previous one
is still starting, so a spike that is over before a replica loads does not
leave several behind. Each decision is logged with the load that made it.
`llmgw ctl scale MODEL --min 1 --max 3` turns it on for a model.
### A model from the registry in one command
`llmgw ctl model serve OWNER/REPO` takes a model from the registry to a replica
that answers. It picks the GGUF file (`--quant`, Q4_K_M by default), has the
mirror fetch it and checks what arrived against the registry's published
sha256, checks that the nodes' llama.cpp can load it, sizes its memory from its
header, adds it to the catalog, waits for a replica — stopping early with the
reason if it fits nowhere or keeps failing — and asks it to say hello. Each step
is shown as it happens. It runs in the control plane as a job, so a download of
hours does not depend on a terminal staying open. `--on-demand` adds it without
starting a replica.
### Upgrading from one command
`sudo llmgw ctl upgrade VERSION`, run on the control plane, upgrades that
machine and then every node in turn. Each agent is told the version over its
existing stream, fetches the release for its own platform, checks it against
the release's checksums, keeps the old binary beside the new and restarts,
while its engines keep serving. A node that does not come back on the new
version, ready, with its replicas, stops the upgrade before the next one.
Running it again resumes. Agents from before this have to be moved by hand
once.
### Following a request
Every response carries `X-Request-Id` — the client's own when it sends a plain
@@ -123,6 +990,27 @@ half again plus two" could not see.
links.** Archives are unpacked through `os.Root`, and a link may only point
at a file beside it. Every llama.cpp b10991 archive, CPU and CUDA, already
follows that rule.
- **Onboarding made every node trust the cluster's root for everything.** It
was added to the system store, so a certificate the control plane signed
would pass for any site with every TLS client on the node. The agent trusts
it through its own configuration only; onboarding no longer adds it, and
takes it out of a node that an older version onboarded.
- **Any node could manage a mirror running mutual TLS**, since one root signs
every node's certificate. `llmgw mirror serve --api-client NAME` answers only
the certificate with that common name, and the mirror warns when mutual TLS
runs without one.
- **The administrator's password stayed in `init.log`.** It is shown once, on
the screen, and the log keeps a placeholder.
- **Auth off on a public address was silent.** The server now warns at start
when the API listens beyond loopback without keys.
- **The actor recorded with a change was whatever the caller sent**, line
breaks included. It is cut to a short printable name, and `sudo llmgw ctl`
records the person who ran sudo rather than root.
- **vLLM's Ray head ran its dashboard**, whose job API runs code for anyone who
reaches it. It is off. The head itself still has no authentication, and
SGLang still takes its key as a flag; docs/install.md says what to do about
both. The monitoring README says how to keep Prometheus's queries to the
control plane.
### Fixes
@@ -131,6 +1019,8 @@ half again plus two" could not see.
loaded runner.
- `ctl` reported a change the API had queued (202 Accepted) as an error, as
`ctl restart` did on its first real use.
- The status each failure is answered with — 499, 502, 503 with and without
Retry-After, 504, 429 — is now tested case by case.
### Releases
File diff suppressed because it is too large. Load diff
+115
View File
@@ -26,6 +26,7 @@ message AgentMessage {
LogsReply logs = 4;
FabricScanReply fabric_scan = 5;
FabricTestReply fabric_test = 6;
UpgradeReply upgrade = 7;
}
}
@@ -41,9 +42,19 @@ message ControlMessage {
LogsRequest logs = 7;
FabricScanRequest fabric_scan = 8;
FabricTestRequest fabric_test = 9;
UpgradeAgent upgrade = 10;
ControlPlanes control_planes = 11;
}
}
// ControlPlanes is every control plane an agent may dial, sent by the leader
// when an agent registers and whenever the list changes. The agent keeps it
// beside its configuration, so a second control plane added after the node
// was onboarded is one it fails over to without anybody editing the node.
message ControlPlanes {
repeated string servers = 1;
}
// Register is the first message on every stream. It carries the node's full
// hardware inventory, so the control plane needs no separate discovery call and
// a reconnecting agent automatically refreshes what the scheduler knows.
@@ -118,6 +129,10 @@ message Register {
// 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;
// cpu_count is the CPUs the agent may run on. Placement reserves cores
// for CPU replicas out of it, the way it reserves memory out of
// memory_total_mb; zero, from an older agent, reserves nothing.
int32 cpu_count = 18;
// imex is the service that lets a multi-node NVLink domain share memory.
IMEX imex = 15;
@@ -279,6 +294,54 @@ message NodeLoad {
int64 memory_used_bytes = 6;
int64 memory_available_bytes = 7;
repeated ShardLoad shards = 8;
// What the machine's own sensors say; zero is not measured. cpu_temp_c is
// the hottest CPU sensor, cpu_power_w the CPU packages' draw from RAPL,
// inlet_temp_c the board's inlet or ambient sensor where it has one.
double cpu_temp_c = 9;
double cpu_power_w = 10;
double inlet_temp_c = 11;
repeated GPUSensor gpus = 12;
}
// GPUSensor is one card's heat, power, and what is holding it back.
message GPUSensor {
int32 index = 1;
double temp_c = 2;
double memory_temp_c = 3;
double power_w = 4;
double power_limit_w = 5;
// throttle names what is holding the clocks down now: sw_power_cap,
// hw_slowdown, sw_thermal, hw_thermal, power_brake.
repeated string throttle = 6;
// How busy the card is, sampled with the rest, rather than read once at
// registration. -1 is not reported by the driver. memory_total_mb is set
// by every agent that sends these, so zero there is an older agent.
double utilization_percent = 7;
double memory_utilization_percent = 8;
double memory_used_mb = 9;
double memory_total_mb = 10;
double sm_clock_mhz = 11;
// ecc_uncorrected counts uncorrected ECC errors since the driver loaded.
double ecc_uncorrected = 12;
// What wears the card out, read every minute; has_wear says these are
// present. Counts of -1 are not reported for this card.
bool has_wear = 13;
int64 pcie_replays = 14;
int64 retired_pages_sbe = 15;
int64 retired_pages_dbe = 16;
bool retirement_pending = 17;
int64 remapped_rows_correctable = 18;
int64 remapped_rows_uncorrectable = 19;
bool remap_pending = 20;
bool remap_failure = 21;
int64 nvlink_crc_errors = 22;
int64 nvlink_replay_errors = 23;
int64 nvlink_recovery_errors = 24;
// xid_errors are the driver's error events since the agent started.
int64 xid_errors = 25;
int32 last_xid = 26;
string last_xid_text = 27;
int64 last_xid_unix = 28;
}
// ShardLoad is one engine process group's use.
@@ -418,6 +481,17 @@ message Heartbeat {
// domain, and the node has to stop being offered work that spans it.
repeated GPUFabricState gpu_fabric = 16;
IMEX imex = 17;
// cpu_count as in Register, for a VM given more CPUs while it runs.
int32 cpu_count = 18;
// gpu_foreign_memory_mb, indexed by GPU, is what processes that are not
// this agent's shards hold on each card, read afresh every heartbeat. What
// keeps a card out of placement for another tenant is decided from this
// rather than from registration: a card that had something else on it when
// the agent started is not taken forever, and one that gains a tenant later
// is not handed out. Empty from an agent that does not measure it, or from
// one that could not this time.
repeated int32 gpu_foreign_memory_mb = 19;
}
// Provision is the software a node should have: which engines, at which
@@ -438,6 +512,9 @@ message Provision {
message EngineWant {
string engine = 1; // "llamacpp"
string version = 2; // "b10991"
// variant is the build to install, when one is chosen for the node rather
// than left to its hardware: "cu13", "cu129". Empty is the agent's choice.
string variant = 3;
}
// ProvisionStatus is where one engine's installation stands on a node.
@@ -528,6 +605,9 @@ message StartShard {
// control plane from the replica id. The router sends it; nothing else
// that can reach the node's ports has it.
string api_key = 16;
// cpu_threads is how many CPU threads a shard with no GPU computes on:
// the cores placement reserved for it. Zero leaves it to the agent.
int32 cpu_threads = 17;
}
message EngineProfile {
@@ -542,6 +622,9 @@ message EngineProfile {
map<string, string> env = 9;
string model_path = 10;
string served_model_name = 11;
// adapters are LoRA adapters loaded beside the model, in order: the index
// is the id a request applies one by.
repeated LoraAdapter adapters = 12;
}
// RemoveWeights deletes cached model files a node no longer needs.
@@ -649,3 +732,35 @@ message FabricTestReply {
string error = 4;
int64 took_ms = 5;
}
// UpgradeAgent tells an agent to replace its own binary with a release and
// restart.
//
// The agent fetches the release for its own platform and checks it against
// the release's checksums before replacing anything, keeps the old binary
// beside the new, then exits so systemd starts the new one. Engines keep
// running throughout, as for any agent restart, and are adopted on the way
// back. Over the stream the agent already holds, so upgrading a fleet needs
// no ssh into it.
message UpgradeAgent {
string request_id = 1;
// version must be a release: v0.10.4 or v0.10.4-rc.2.
string version = 2;
// releases is the base URL release files are downloaded from.
string releases = 3;
}
// UpgradeReply says whether the new binary is in place. Sent before the
// agent exits; an empty error means it will be back on the new version.
message UpgradeReply {
string request_id = 1;
string version = 2;
string error = 3;
}
// LoraAdapter is one adapter: the name clients ask for it by, and its source,
// which the agent fetches as it does the model's weights.
message LoraAdapter {
string name = 1;
string source = 2;
}
+8 -5
View File
@@ -122,9 +122,10 @@ 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"
# 2 and 3. Identity. The root is trusted by the agent's configuration only,
# never system-wide: it signs whatever the control plane asks, and every
# TLS client on the machine would take it for any site.
say "installing the certificate"
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
@@ -132,8 +133,10 @@ $SSH "set -e
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
if [ -f /usr/local/share/ca-certificates/llmgw-ca.crt ]; then
sudo rm -f /usr/local/share/ca-certificates/llmgw-ca.crt
sudo update-ca-certificates --fresh >/dev/null 2>&1 || true
fi
rm -f /tmp/ca.pem /tmp/$NODE.pem /tmp/$NODE-key.pem"
# 4. The binary, the configuration and the unit.
+5
View File
@@ -92,6 +92,11 @@ agent_listen: ":7443"
# flush_interval: 200ms
# batch_size: 500
# buffer_size: 8192
#
# # How long the record of each request is kept (what `ctl request` reads).
# # Charts, totals and budgets come from five-minute sums that are kept, so
# # this only bounds the table that grows by a row a request. 0 keeps all.
# usage_retention: 2160h # 90 days
router:
scrape_interval: 2s
+60
View File
@@ -85,3 +85,63 @@ auth:
See [docs/install.md](../../docs/install.md) for moving an existing
installation onto PostgreSQL without reissuing every key.
## The store itself: PostgreSQL under Patroni
Two control planes on one PostgreSQL have moved the single point of failure,
not removed it. Patroni keeps PostgreSQL on two machines, one streaming to the
other, and promotes the standby when the primary is lost. It keeps its leader
lock in etcd, and etcd decides only with a majority — so three members, not
two: two machines that lose sight of each other must not both decide they are
the primary. The third member can be any machine that is up; it holds a vote,
not data.
On the reference cluster:
| machine | etcd | PostgreSQL |
|---|---|---|
| llmgw-ctl (control plane) | member | — |
| llmgw-pg01 | member | Patroni |
| llmgw-ctl02 (second control plane) | member | Patroni |
`llmgw server init --patroni user@host --patroni user@host` sets all of it up
over ssh and moves the store in — see [install.md](../../docs/install.md).
It looks before it acts at every step, so it is also how an existing cluster
is checked: run against one that is up, it changes nothing. What it writes
is in `etcd.default.example` (one member's `/etc/default/etcd`) and
`patroni.yml.example` (one node's `/etc/patroni/config.yml`), for anyone
doing it by hand; passwords are generated into `/root/llmgw-pg` on each
database machine.
**Give llmgw every member, and ask for the writable one:**
```
postgres://llmgw:…@10.0.0.149:5432,10.0.0.150:5432/llmgw?sslmode=disable&target_session_attrs=read-write
```
The driver connects to whichever member accepts writes, and reconnects to the
new one after a failover. No floating address or HAProxy is needed for that.
`llmgw server backup` reads the same address and dumps from a standby when
there is one.
Measured on the reference cluster, with the gateway serving throughout:
- **Planned switchover** (`patronictl switchover`): the control plane logged
one failed lease renewal, reconnected to the new primary, and kept its
leadership; nothing else noticed.
- **The primary's machine switched off**: the standby was promoted 24 seconds
later — the 30-second TTL in `patroni.yml` — and the control plane rode it
out without losing leadership or a replica. Started again, the old primary
rejoined as a standby by itself.
**Watching it.** For a store of several hosts the control plane reads the
cluster from each member's Patroni (port 8008) and publishes
`llmgw_store_member{member,host,role,state}`, `llmgw_store_replica_lag_bytes`
and `llmgw_store_patroni_up`; a host of the address that the cluster no longer
lists is a member in state `missing`. `llmgw ctl status` prints it as one
line, and `deploy/monitoring/llmgw-rules.yml` alerts on no leader, a member
not streaming or missing, and a standby too far behind to be promoted.
Not done here, and worth doing in production: a watchdog (`softdog`) so a
primary that has lost the lock but not died cannot keep accepting writes, TLS
on etcd and between PostgreSQL members, and more than one standby.
+10
View File
@@ -0,0 +1,10 @@
# llmgw: one member of the three that hold Patroni's leader lock (140, 149, 150).
ETCD_NAME=llmgw-pg01
ETCD_DATA_DIR=/var/lib/etcd/default
ETCD_LISTEN_PEER_URLS=http://10.0.0.149:2380
ETCD_LISTEN_CLIENT_URLS=http://10.0.0.149:2379,http://127.0.0.1:2379
ETCD_INITIAL_ADVERTISE_PEER_URLS=http://10.0.0.149:2380
ETCD_ADVERTISE_CLIENT_URLS=http://10.0.0.149:2379
ETCD_INITIAL_CLUSTER=llmgw-ctl=http://10.0.0.140:2380,llmgw-pg01=http://10.0.0.149:2380,llmgw-ctl02=http://10.0.0.150:2380
ETCD_INITIAL_CLUSTER_STATE=new
ETCD_INITIAL_CLUSTER_TOKEN=llmgw-patroni
+68
View File
@@ -0,0 +1,68 @@
# llmgw: the control planes' store, kept by Patroni on llmgw-pg01 and
# llmgw-ctl02, with the leader lock in etcd on 140, 149 and 150.
scope: llmgw
namespace: /service/
name: llmgw-pg01
restapi:
listen: 10.0.0.149:8008
connect_address: 10.0.0.149:8008
etcd3:
hosts: 10.0.0.140:2379,10.0.0.149:2379,10.0.0.150:2379
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576
postgresql:
use_pg_rewind: true
use_slots: true
parameters:
wal_level: replica
hot_standby: "on"
max_wal_senders: 10
max_replication_slots: 10
wal_keep_size: 1GB
wal_log_hints: "on"
password_encryption: scram-sha-256
initdb:
- encoding: UTF8
- data-checksums
postgresql:
listen: 127.0.0.1,10.0.0.149:5432
connect_address: 10.0.0.149:5432
data_dir: /var/lib/postgresql/16/llmgw
bin_dir: /usr/lib/postgresql/16/bin
pgpass: /var/lib/postgresql/.pgpass_patroni
authentication:
superuser:
username: postgres
password: "<from /root/llmgw-pg>"
replication:
username: replicator
password: "<from /root/llmgw-pg>"
rewind:
username: postgres
password: "<from /root/llmgw-pg>"
parameters:
unix_socket_directories: /var/run/postgresql
pg_hba:
- local all all peer
- host all all 127.0.0.1/32 scram-sha-256
- host replication replicator 10.0.0.149/32 scram-sha-256
- host replication replicator 10.0.0.150/32 scram-sha-256
- host all postgres 10.0.0.149/32 scram-sha-256
- host all postgres 10.0.0.150/32 scram-sha-256
- host llmgw llmgw 10.0.0.140/32 scram-sha-256
- host llmgw llmgw 10.0.0.149/32 scram-sha-256
- host llmgw llmgw 10.0.0.150/32 scram-sha-256
tags:
nofailover: false
noloadbalance: false
clonefrom: false
nosync: false
+9
View File
@@ -52,6 +52,13 @@ install -m 0644 alertmanager.yml /etc/prometheus/alertmanager.yml
# client; one Alertmanager needs no cluster port.
echo 'ARGS="--web.listen-address=127.0.0.1:9093 --cluster.listen-address="' \
> /etc/default/prometheus-alertmanager
# Prometheus itself is read-only as installed: its admin and lifecycle APIs
# stay off (no --web.enable-admin-api, no --web.enable-lifecycle). It still
# answers any query from anyone who reaches :9090, which says which models and
# nodes there are and how busy each is. Its readers are the control plane
# (monitoring.prometheus) and Grafana beside it; to keep it to those, allow
# the control plane and drop the rest, e.g. with nftables:
# nft add rule inet filter input tcp dport 9090 ip saddr != { 127.0.0.1, CONTROL_PLANE_IP } drop
promtool check config /etc/prometheus/prometheus.yml
systemctl restart prometheus prometheus-alertmanager
@@ -59,6 +66,8 @@ install -m 0644 grafana/datasource.yaml /etc/grafana/provisioning/datasources/
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
install -m 0644 -o grafana -g grafana grafana/llmgw-tenants.json \
/var/lib/grafana/dashboards/llmgw-tenants.json
systemctl restart grafana-server
```
@@ -487,6 +487,427 @@
"defaults": {},
"overrides": []
}
},
{
"type": "row",
"title": "GPUs",
"id": 14,
"gridPos": {
"x": 0,
"y": 30,
"w": 24,
"h": 1
},
"collapsed": false,
"panels": []
},
{
"type": "timeseries",
"title": "GPU busy, by card",
"description": "Share of each sample period a kernel was running, sampled every heartbeat.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 0,
"y": 31,
"w": 12,
"h": 7
},
"id": 15,
"targets": [
{
"expr": "llmgw_gpu_utilization_percent",
"legendFormat": "{{node}} gpu {{gpu}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "GPU memory in use, by card",
"description": "Framebuffer in use. Engines reserve most of it at start.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 12,
"y": 31,
"w": 12,
"h": 7
},
"id": 16,
"targets": [
{
"expr": "llmgw_gpu_memory_used_bytes / llmgw_gpu_memory_total_bytes",
"legendFormat": "{{node}} gpu {{gpu}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "GPU memory bandwidth busy, by card",
"description": "Decode is bound by memory: high here with compute low is a card waiting on memory.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 0,
"y": 38,
"w": 12,
"h": 7
},
"id": 17,
"targets": [
{
"expr": "llmgw_gpu_memory_utilization_percent",
"legendFormat": "{{node}} gpu {{gpu}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "GPU temperature",
"description": "Throttling usually starts in the high 80s.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 12,
"y": 38,
"w": 12,
"h": 7
},
"id": 18,
"targets": [
{
"expr": "llmgw_gpu_temperature_celsius",
"legendFormat": "{{node}} gpu {{gpu}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "celsius"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "GPU power against its limit",
"description": "1 is the enforced limit; a card held there is power-capped.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 0,
"y": 45,
"w": 12,
"h": 7
},
"id": 19,
"targets": [
{
"expr": "llmgw_gpu_power_watts / llmgw_gpu_power_limit_watts",
"legendFormat": "{{node}} gpu {{gpu}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1.1
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "GPU clock",
"description": "A clock held low under load is the card throttled; see the next panel for why.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 12,
"y": 45,
"w": 12,
"h": 7
},
"id": 20,
"targets": [
{
"expr": "llmgw_gpu_sm_clock_mhz",
"legendFormat": "{{node}} gpu {{gpu}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "rotmhz"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "What holds GPUs back",
"description": "Cards throttled now, by reason, and uncorrected ECC errors since the driver loaded.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 0,
"y": 52,
"w": 24,
"h": 7
},
"id": 21,
"targets": [
{
"expr": "sum by (reason) (llmgw_gpu_throttled)",
"legendFormat": "throttled: {{reason}}",
"refId": "A"
},
{
"expr": "sum by (node, gpu) (llmgw_gpu_ecc_errors)",
"legendFormat": "ECC errors {{node}} gpu {{gpu}}",
"refId": "B"
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "Output tokens/s per GPU, by model",
"description": "What each model gets out of each card it holds.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 0,
"y": 59,
"w": 12,
"h": 7
},
"id": 22,
"targets": [
{
"expr": "sum by (model) (rate(llmgw_tokens_total{direction=\"completion\"}[5m])) / count by (model) (llmgw_gpu_assigned)",
"legendFormat": "{{model}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "Output tokens per watt-hour, by model",
"description": "Tokens for the energy the model's cards draw.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 12,
"y": 59,
"w": 12,
"h": 7
},
"id": 23,
"targets": [
{
"expr": "sum by (model) (rate(llmgw_tokens_total{direction=\"completion\"}[5m])) * 3600 / sum by (model) (llmgw_gpu_power_watts * on (node, gpu) group_left (model) llmgw_gpu_assigned)",
"legendFormat": "{{model}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "GPU wear",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"id": 24,
"description": "What wears cards out: XID errors in the last 15 minutes, memory rows remapped, PCIe retries an hour. Anything above zero here is worth a look on that card's node page.",
"gridPos": {
"x": 0,
"y": 66,
"w": 24,
"h": 7
},
"targets": [
{
"expr": "increase(llmgw_gpu_xid_errors[15m]) > 0",
"legendFormat": "XID {{node}} gpu {{gpu}}",
"refId": "A"
},
{
"expr": "sum by (node, gpu) (llmgw_gpu_remapped_rows) > 0",
"legendFormat": "rows remapped {{node}} gpu {{gpu}}",
"refId": "B"
},
{
"expr": "increase(llmgw_gpu_pcie_replays[1h]) > 0",
"legendFormat": "PCIe retries/h {{node}} gpu {{gpu}}",
"refId": "C"
},
{
"expr": "increase(llmgw_gpu_nvlink_errors[1h]) > 0",
"legendFormat": "NVLink {{kind}}/h {{node}} gpu {{gpu}}",
"refId": "D"
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
}
]
}
@@ -0,0 +1,650 @@
{
"uid": "llmgw-tenants",
"title": "llmgw \u2014 tenants",
"schemaVersion": 39,
"version": 1,
"editable": true,
"time": {
"from": "now-24h",
"to": "now"
},
"refresh": "1m",
"tags": [
"llmgw"
],
"description": "Requests, tokens, failures, refusals and first-token time per tenant \u2014 the level router.tenant_label names \u2014 and every budget's share spent.",
"templating": {
"list": [
{
"name": "tenant",
"label": "Tenant",
"type": "query",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"query": {
"query": "label_values(llmgw_tenant_requests_total, tenant)",
"refId": "tenant"
},
"definition": "label_values(llmgw_tenant_requests_total, tenant)",
"multi": true,
"includeAll": true,
"allValue": ".*",
"current": {
"text": "All",
"value": "$__all"
},
"refresh": 2,
"sort": 1
}
]
},
"panels": [
{
"id": 1,
"title": "Requests a second",
"type": "stat",
"description": "Across the tenants chosen above.",
"gridPos": {
"x": 0,
"y": 0,
"w": 6,
"h": 4
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum(rate(llmgw_tenant_requests_total{tenant=~\"$tenant\"}[5m]))",
"legendFormat": "",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"textMode": "auto",
"colorMode": "value"
}
},
{
"id": 2,
"title": "Failing",
"type": "stat",
"description": "Requests that failed, not counting refusals by a limit.",
"gridPos": {
"x": 6,
"y": 0,
"w": 6,
"h": 4
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum(rate(llmgw_tenant_requests_total{tenant=~\"$tenant\",outcome=\"error\"}[5m])) / sum(rate(llmgw_tenant_requests_total{tenant=~\"$tenant\"}[5m]))",
"legendFormat": "",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 0.02
},
{
"color": "red",
"value": 0.05
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"textMode": "auto",
"colorMode": "value"
}
},
{
"id": 3,
"title": "Refused by a limit",
"type": "stat",
"description": "Rate, token and budget limits; 429 to the caller.",
"gridPos": {
"x": 12,
"y": 0,
"w": 6,
"h": 4
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum(rate(llmgw_tenant_requests_total{tenant=~\"$tenant\",outcome=\"limited\"}[5m])) / sum(rate(llmgw_tenant_requests_total{tenant=~\"$tenant\"}[5m]))",
"legendFormat": "",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 0.02
},
{
"color": "red",
"value": 0.1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"textMode": "auto",
"colorMode": "value"
}
},
{
"id": 4,
"title": "First token, p95",
"type": "stat",
"description": "What people wait for before anything appears.",
"gridPos": {
"x": 18,
"y": 0,
"w": 6,
"h": 4
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum by (le) (rate(llmgw_tenant_ttft_seconds_bucket{tenant=~\"$tenant\"}[5m])))",
"legendFormat": "",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "s",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 3
},
{
"color": "red",
"value": 10
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"textMode": "auto",
"colorMode": "value"
}
},
{
"id": 5,
"title": "Requests by tenant",
"type": "timeseries",
"description": "Stacked, so the top edge is the whole of it.",
"gridPos": {
"x": 0,
"y": 4,
"w": 12,
"h": 9
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum by (tenant) (rate(llmgw_tenant_requests_total{tenant=~\"$tenant\"}[5m]))",
"legendFormat": "{{tenant}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps",
"custom": {
"stacking": {
"mode": "normal"
},
"fillOpacity": 30
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": [
"mean",
"max"
]
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 6,
"title": "Tokens by tenant",
"type": "timeseries",
"description": "Prompt and completion together: what was consumed.",
"gridPos": {
"x": 12,
"y": 4,
"w": 12,
"h": 9
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum by (tenant) (rate(llmgw_tenant_tokens_total{tenant=~\"$tenant\"}[5m]))",
"legendFormat": "{{tenant}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"custom": {
"stacking": {
"mode": "normal"
},
"fillOpacity": 30
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": [
"mean",
"max"
]
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 7,
"title": "Refused by a limit, by tenant",
"type": "timeseries",
"description": "The share of each tenant's requests a limit turned away.",
"gridPos": {
"x": 0,
"y": 13,
"w": 12,
"h": 8
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum by (tenant) (rate(llmgw_tenant_requests_total{tenant=~\"$tenant\",outcome=\"limited\"}[5m])) / sum by (tenant) (rate(llmgw_tenant_requests_total{tenant=~\"$tenant\"}[5m]))",
"legendFormat": "{{tenant}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": [
"mean",
"max"
]
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 8,
"title": "Failing, by tenant",
"type": "timeseries",
"description": "One tenant alone failing is usually what it sends; all of them together is a model.",
"gridPos": {
"x": 12,
"y": 13,
"w": 12,
"h": 8
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum by (tenant) (rate(llmgw_tenant_requests_total{tenant=~\"$tenant\",outcome=\"error\"}[5m])) / sum by (tenant) (rate(llmgw_tenant_requests_total{tenant=~\"$tenant\"}[5m]))",
"legendFormat": "{{tenant}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": [
"mean",
"max"
]
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 9,
"title": "First token p95, by tenant",
"type": "timeseries",
"description": "",
"gridPos": {
"x": 0,
"y": 21,
"w": 12,
"h": 8
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum by (tenant, le) (rate(llmgw_tenant_ttft_seconds_bucket{tenant=~\"$tenant\"}[10m])))",
"legendFormat": "{{tenant}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": [
"mean",
"max"
]
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 10,
"title": "Budgets spent this period",
"type": "bargauge",
"description": "Every organization, team, project and person with a budget. Requests are refused at 100%.",
"gridPos": {
"x": 12,
"y": 21,
"w": 12,
"h": 8
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sort_desc(llmgw_budget_spent_ratio)",
"legendFormat": "{{scope}} {{name}} ({{period}})",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 0.8
},
{
"color": "red",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"displayMode": "gradient",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"showUnfilled": true
}
},
{
"id": 11,
"title": "Who used most, last 24 hours",
"type": "table",
"description": "Spend in money is on the people page's usage tab, which reads it from the store.",
"gridPos": {
"x": 0,
"y": 29,
"w": 24,
"h": 8
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sort_desc(sum by (tenant) (increase(llmgw_tenant_requests_total{tenant=~\"$tenant\"}[24h])))",
"legendFormat": "requests",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"format": "table",
"instant": true
},
{
"refId": "B",
"expr": "sum by (tenant) (increase(llmgw_tenant_tokens_total{tenant=~\"$tenant\"}[24h]))",
"legendFormat": "tokens",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"format": "table",
"instant": true
},
{
"refId": "C",
"expr": "sum by (tenant) (increase(llmgw_tenant_requests_total{tenant=~\"$tenant\",outcome=\"limited\"}[24h]))",
"legendFormat": "limited",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"format": "table",
"instant": true
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"showHeader": true
},
"transformations": [
{
"id": "merge",
"options": {}
},
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true
},
"renameByName": {
"Value #A": "Requests",
"Value #B": "Tokens",
"Value #C": "Refused by a limit"
}
}
}
]
}
]
}
+237
View File
@@ -84,6 +84,28 @@ groups:
<model>` says what each node lacks. For an on-demand model, requests
for it are told this rather than asked to retry.
- alert: ModelPreempted
expr: increase(llmgw_model_preempted_total[15m]) > 0
labels: { severity: info }
annotations:
summary: "{{ $labels.model }} ({{ $labels.class }}) gave up replicas to a higher class"
description: >-
Capacity ran short and a model of a higher class took this one's
room. Expected now and then; if it keeps firing for the same model,
the cluster is too small for what is asked of it at these classes.
`llmgw ctl models` shows who it gave way to.
- alert: CapacityAtLimit
expr: llmgw_capacity_holds >= on(scope, resource) llmgw_capacity_limit
for: 15m
labels: { severity: info }
annotations:
summary: "{{ $labels.scope }} holds all the {{ $labels.resource }} it may"
description: >-
Its models get no further replicas, under load or not, until
something of theirs stops or the limit is raised: `llmgw ctl quota
list`, `llmgw ctl quota set`.
- alert: EngineInstallFailed
expr: llmgw_node_engine_install{state="failed"} == 1
for: 15m
@@ -178,6 +200,94 @@ groups:
is skipped by placement rather than failing, so this is quiet until
somebody wonders why a model will not land there.
- name: llmgw-gpus
rules:
- alert: GPUThermalThrottling
expr: llmgw_gpu_throttled{reason=~"sw_thermal|hw_thermal|hw_slowdown"} == 1
for: 10m
labels: { severity: warning }
annotations:
summary: "{{ $labels.node }} GPU {{ $labels.gpu }} is slowed by heat ({{ $labels.reason }})"
description: >-
Ten minutes held back by temperature: every replica on the card is
slower than its measurements say. Usually airflow, a fan, or the
room — the node page shows the card's temperature over time.
- alert: GPUUncorrectedECCErrors
expr: llmgw_gpu_ecc_errors > 0
for: 1m
labels: { severity: critical }
annotations:
summary: "{{ $labels.node }} GPU {{ $labels.gpu }} has {{ $value }} uncorrected ECC errors"
description: >-
Uncorrected memory errors since the driver loaded: what runs on the
card may be computing on corrupted memory. Drain the node
(llmgw ctl drain) and have the card looked at.
- alert: GPUXidError
expr: increase(llmgw_gpu_xid_errors[15m]) > 0
for: 0m
labels: { severity: warning }
annotations:
summary: "{{ $labels.node }} GPU {{ $labels.gpu }} logged an XID error"
description: >-
The driver reported an error on the card (its code is
llmgw_gpu_last_xid, and the node page says it in words). 79 is a
card fallen off the bus, 48 and 63/64 memory errors, 74 NVLink,
94/95 contained and uncontained ECC errors: those need the card
looked at. Others are often an engine's own fault, and pass.
- alert: GPUOutOfSpareRows
expr: llmgw_gpu_remap_failure == 1
for: 1m
labels: { severity: critical }
annotations:
summary: "{{ $labels.node }} GPU {{ $labels.gpu }} has no spare memory rows left"
description: >-
The card cannot remap another failing row. Drain the node and
have the card replaced.
- alert: GPUNeedsReset
expr: llmgw_gpu_remap_pending == 1 or llmgw_gpu_retirement_pending == 1
for: 5m
labels: { severity: warning }
annotations:
summary: "{{ $labels.node }} GPU {{ $labels.gpu }} has memory repairs waiting for a reset"
description: >-
Rows remapped or pages retired take effect only after the card is
reset. Drain the node (llmgw ctl drain), reset or reboot, and bring
it back.
- alert: GPUPCIeRetriesClimbing
expr: increase(llmgw_gpu_pcie_replays[1h]) > 100
for: 0m
labels: { severity: warning }
annotations:
summary: "{{ $labels.node }} GPU {{ $labels.gpu }}: {{ $value }} PCIe retries in an hour"
description: >-
The card's link to the host is retrying. A riser, the slot or a
cable is the usual cause, and transfers to the card slow down.
- alert: GPUNVLinkErrorsClimbing
expr: increase(llmgw_gpu_nvlink_errors{kind="crc"}[1h]) > 10
for: 0m
labels: { severity: warning }
annotations:
summary: "{{ $labels.node }} GPU {{ $labels.gpu }}: NVLink CRC errors climbing"
description: >-
Errors on the card's NVLink links: tensor-parallel replicas on it
slow down, and a link that keeps failing takes a replica with it.
- alert: GPUHot
expr: llmgw_gpu_temperature_celsius > 87
for: 10m
labels: { severity: warning }
annotations:
summary: "{{ $labels.node }} GPU {{ $labels.gpu }} at {{ $value }} °C"
description: >-
Ten minutes above 87 °C, where most data-centre cards begin to
throttle.
- name: monitoring-host
rules:
- alert: MonitoringDiskFilling
@@ -240,3 +350,130 @@ groups:
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.
# Per tenant: the level router.tenant_label names (a team by default), and
# every organization, team, project and person given a budget.
- name: llmgw-tenants
rules:
- alert: BudgetMostlySpent
expr: llmgw_budget_spent_ratio >= 0.8 and llmgw_budget_spent_ratio < 1
for: 5m
labels: { severity: warning }
annotations:
summary: "{{ $labels.scope }} {{ $labels.name }} has spent {{ $value | humanizePercentage }} of its {{ $labels.period }}ly budget"
description: >-
Its requests are refused once it reaches all of it, until the
period turns. Raise it on the people page, or tell them now
rather than when they are refused.
- alert: BudgetSpent
expr: llmgw_budget_spent_ratio >= 1
for: 1m
labels: { severity: critical }
annotations:
summary: "{{ $labels.scope }} {{ $labels.name }} has spent its {{ $labels.period }}ly budget"
description: >-
Every request under it is refused with 429 until the period
turns, unless a cheaper model is named as its fallback.
- alert: TenantHittingLimits
expr: >-
sum by (tenant) (rate(llmgw_tenant_requests_total{outcome="limited"}[10m]))
/ sum by (tenant) (rate(llmgw_tenant_requests_total[10m])) > 0.1
for: 10m
labels: { severity: warning }
annotations:
summary: "{{ $value | humanizePercentage }} of {{ $labels.tenant }}'s requests are refused by a limit"
description: >-
A rate, token or budget limit is turning their traffic away. Either
the limit is too low for what they now do, or something of theirs
is looping.
- alert: TenantErrors
expr: >-
sum by (tenant) (rate(llmgw_tenant_requests_total{outcome="error"}[10m]))
/ sum by (tenant) (rate(llmgw_tenant_requests_total[10m])) > 0.05
and sum by (tenant) (rate(llmgw_tenant_requests_total[10m])) > 0.05
for: 10m
labels: { severity: warning }
annotations:
summary: "{{ $value | humanizePercentage }} of {{ $labels.tenant }}'s requests fail"
description: >-
Not counting refusals by a limit. If every tenant fails alike it
is a model (ModelFailing); one alone is usually what they send —
prompts past the context, a model they may not use.
- alert: TenantTrafficSpike
expr: >-
sum by (tenant) (rate(llmgw_tenant_requests_total[30m]))
> 3 * sum by (tenant) (rate(llmgw_tenant_requests_total[7d]))
and sum by (tenant) (rate(llmgw_tenant_requests_total[30m])) > 0.2
for: 15m
labels: { severity: info }
annotations:
summary: "{{ $labels.tenant }} is sending three times its weekly average"
description: >-
A new workload, a batch job, or a client retrying in a loop. Only
for a tenant sending more than one request every five seconds, so
a quiet team starting work in the morning does not page anybody.
- alert: TenantSlowFirstToken
expr: >-
histogram_quantile(0.95,
sum by (tenant, le) (rate(llmgw_tenant_ttft_seconds_bucket[15m]))) > 10
and sum by (tenant) (rate(llmgw_tenant_ttft_seconds_count[15m])) > 0.02
for: 15m
labels: { severity: warning }
annotations:
summary: "{{ $labels.tenant }} waits {{ $value | humanizeDuration }} for a first token at p95"
description: >-
What their people feel. Compare with SlowFirstToken per model: if
only this tenant is slow, it is the models it uses or its queue
band, not the cluster.
# The store, when it is a replicated cluster under Patroni (llmgw server
# init --patroni). Published only for a store of several hosts.
- name: llmgw-store
rules:
- alert: StoreHasNoLeader
expr: llmgw_store_patroni_up == 1 and on() count(llmgw_store_member{role="leader"}) == 0
for: 1m
labels: { severity: critical }
annotations:
summary: "The store's cluster has no primary"
description: >-
Every write fails — keys, usage, budgets, leadership. Patroni
promotes a standby within its 30-second TTL; a minute without one
means it cannot: etcd has lost its majority, or no standby is fit.
`patronictl -c /etc/patroni/config.yml list` on a member says which.
- alert: StorePatroniUnreachable
expr: llmgw_store_patroni_up == 0
for: 2m
labels: { severity: warning }
annotations:
summary: "No member of the store's cluster answers on its Patroni port"
description: >-
The control plane reads the cluster's state from port 8008 on each
member. The database may still be serving; nothing here can tell.
- alert: StoreReplicaNotStreaming
expr: llmgw_store_member{role!="leader", state!="streaming"} == 1
for: 5m
labels: { severity: warning }
annotations:
summary: "{{ $labels.member }} is {{ $labels.state }}, not streaming from the primary"
description: >-
A standby that is not streaming is not a standby: if the primary
is lost now, there is nothing to promote. "missing" is a host of
the store address the cluster no longer lists — usually a machine
that is down.
- alert: StoreReplicaBehind
expr: llmgw_store_replica_lag_bytes > 64 * 1024 * 1024 or llmgw_store_replica_lag_bytes == -1
for: 10m
labels: { severity: warning }
annotations:
summary: "{{ $labels.member }} is {{ $value | humanize1024 }}B behind the primary"
description: >-
Patroni will not promote a standby more than 1 MB behind, so a
failover now would find none fit. -1 is a lag Patroni cannot read.
+303
View File
@@ -0,0 +1,303 @@
# Facility-aware placement: rack power, temperature and cooling
**Status: step 0, reading the nodes' own sensors, is built; steps 1–5 are
deferred** until the rack, PDU and power information of a real site is at
hand — without it they would be designed against guesses.
This is the design for step 5 of resource allocation, after reserved CPU cores
and memory (1), capacity classes and preemption (2), tenant capacity quotas (3)
and sizing by measured throughput (4), all of which shipped in v0.10.5. Of
what follows, only the node sensors (rollout step 0) exist in the code: they
are read, published and shown, and change no decision.
## Why
In a large installation the scarce thing is often not a GPU but the room it
sits in. Three failures are invisible to placement today:
- **A rack goes over its power budget.** Every node in it reports healthy;
placement sees free cards and fills them; the breaker, or the facility's
power cap, decides the rest. A rack with A and B feeds is worse: it runs
fine until one feed fails and the other cannot carry the load.
- **Hot hardware is slow hardware.** GPUs throttle long before they fail. A
replica placed in a rack whose inlet air is too warm serves fewer tokens per
second than its measured capacity says, and nothing connects the two.
- **Correlated failure.** Two replicas of a model on two nodes in the same rack
or on the same PDU are one failure away from zero. Placement spreads replicas
across nodes, not across racks.
The goal is for placement to treat facility limits the way it already treats
memory and cores: reserve against a budget, refuse what does not fit, say why,
and never let missing data stop the cluster.
## What it builds on
| Already in place | Used for |
|---|---|
| Node labels, reported by agents, matched by `node_selector` | Where a node is: its rack, row, PDU |
| `inventory.Allocation` carries GPUs, memory and cores per shard | Adding watts, summed per rack and PDU |
| Placement rejections (`explain-placement`, headroom's "why not") | Naming the rack that is full or hot |
| Headroom: the scheduler dry-run on a copy of the inventory | Room that respects power budgets, for free |
| Preemption with classes, minimums and a dry run (step 2) | Shedding load from a hot rack in the right order |
| Measured throughput per profile (step 4) | Tokens per watt as a placement preference |
| Node sensors, read by every agent (step 0, built) | Heat and power with no facility integration: see below |
| `internal/promq`, a Prometheus client | Reading facility metrics that DCIM exporters publish |
## Concepts
**Location.** Each node has a place in a tree: site, room, row, rack. Power has
its own tree beside it: a rack draws from one or more PDUs, and each PDU from a
feed. Both are read from node labels with well-known keys:
```yaml
# agent.yaml
labels:
llmgw.io/site: sto1
llmgw.io/room: hall-b
llmgw.io/row: "12"
llmgw.io/rack: r12-07
llmgw.io/pdu: r12-07-a,r12-07-b # both feeds, for a dual-fed rack
```
A node without these labels is its own rack of one: placement behaves as it
does today.
**Domain.** A rack, a row, a PDU, a room: anything with a budget or a
condition. Domains are declared in a facility file (below) or discovered from
node labels; a label naming a domain the file does not declare creates it with
no budget and no condition.
**Budget.** A domain may have a power cap in watts. Placement reserves each
replica's estimated draw against every domain its node belongs to and refuses a
placement that would take any of them past its cap less a safety margin. For a
dual-fed rack the cap that matters is what one feed can carry alone, since that
is what the rack must survive.
**Condition.** A domain has a thermal and cooling condition: `ok`, `warm`, `hot`
or `unknown`, derived from readings against thresholds with hysteresis.
Conditions change what placement prefers and allows; they are not amounts to
fill.
**Reading.** A measured value with a time: inlet temperature, power draw, a
cooling unit's alarm state. Readings older than their staleness limit are
treated as absent.
## Estimating a replica's draw
What placement reserves has to be known before the replica runs, so it is an
estimate first and a measurement later:
1. **Declared.** A profile may say `power_watts`. The operator's figure wins.
2. **Derived.** Otherwise GPU profiles take each card's power limit from NVML
(the agent already reads the cards; the power limit is one more field) plus
a per-node share for CPU, memory and fans; CPU profiles take their reserved
cores times a per-core figure for the node's CPU model.
3. **Measured.** Once replicas run, the agent reports their cards' actual draw
(NVML) and, where the BMC allows, the node's (Redfish or IPMI DCMI). A
domain's headroom is computed from the larger of what is reserved and what
is measured, so an estimate that was too low is corrected by the room it
stops offering, not by an outage.
## Decisions
### Power
- **Hard limit.** A placement that would take any domain past `cap × (1 −
margin)` is rejected, with the domain and the figures: *rack r12-07: 14.2 of
16.0 kW reserved, replica needs ~2.8, margin 10%*.
- **Feeds.** For a domain with two feeds the cap is the smaller feed's rating
times its derating (typically 80%), not the sum.
- **Already over.** A domain found over its cap, because the estimate was low
or the cap was lowered, gets no new placements, and an alert. It is not
emptied automatically; shedding power is the facility's decision, and
stopping inference to do it is ours only if asked (see shedding below).
### Temperature and cooling
Thresholds are per domain with defaults, and each condition needs to hold for a
while before it changes, in both directions, so a single reading neither
starts nor ends anything:
| Condition | Enters when | Placement |
|---|---|---|
| `ok` | below the warm threshold for 5 min | normal |
| `warm` | inlet above the warm threshold for 2 min, GPU temperatures near throttling, or any GPU thermally throttled | allowed, scored down: other domains first |
| `hot` | inlet above the hot threshold for 2 min, a cooling unit in alarm, or a sustained share of the domain's GPUs thermally throttled | no new placements, as a cordon would, but automatic and reversible |
| `unknown` | no fresh reading | as `ok`, and reported as unknown |
**Shedding** (optional, off by default): a domain `hot` for longer than its
shed delay has load moved off it using step 2's machinery. The lowest class goes
first, and replicas above their model's minimum go before any at it. A critical
minimum is never touched, and moves are rate limited to one replica per domain
per interval. The replica is placed elsewhere first where there is room, so
shedding moves work rather than dropping it. GPU power capping (`nvidia-smi
-pl`) is a gentler alternative worth measuring against shedding: less
throughput, no restart.
### Spreading
Replicas of one model are spread across racks, and across PDUs, the way they
are spread across nodes today. This is a preference, dropped when it is the only
thing standing in the way, as node spreading already is.
### Scoring
Placement today filters, then takes the first candidate in a fixed order
(fewest free GPUs for GPU profiles, fewest replicas for CPU ones, nodes holding
the weights first). Facility signals are better as a score than as more
filters. Candidates that pass the hard limits are ordered by:
1. condition: `ok` before `warm`;
2. power headroom left in the rack after this replica, more first, so load
levels across racks;
3. the existing order.
Once measurements include power, step 4's profile preference can use tokens per
watt instead of tokens per GPU where both are known.
## Where the data comes from
A `FacilitySource` interface, implemented several ways, chosen in
`server.yaml`:
```go
type FacilitySource interface {
// Domains is what the source knows: budgets, thresholds, members.
Domains(ctx) ([]Domain, error)
// Readings is the latest value of each signal, per domain.
Readings(ctx) ([]Reading, error)
}
```
- **Static file.** Domains, caps and thresholds, written by hand or generated
from a DCIM export. Enough for power budgets with no live data at all.
- **Prometheus.** DCIM, PDU (SNMP exporter) and BMC (Redfish exporter)
readings usually end up in Prometheus. The source runs configured instant
queries and maps their labels onto domains:
```yaml
facility:
source: prometheus
prometheus: http://monitoring:9090
signals:
inlet_temp_c:
query: max by (rack) (dcim_rack_inlet_temperature_celsius)
domain_label: rack
stale_after: 2m
power_watts:
query: sum by (pdu) (pdu_outlet_power_watts)
domain_label: pdu
cooling_alarm:
query: max by (row) (crac_alarm)
domain_label: row
```
- **Agents (built, step 0).** What each node reads about itself, every ten
seconds at most, sent with its heartbeat:
- **GPUs, from nvidia-smi:** temperature, memory (HBM) temperature where the
driver reports it, power draw, the enforced power limit, and what is
holding the clocks down now — `sw_power_cap`, `hw_slowdown`, `sw_thermal`,
`hw_thermal`, `power_brake`.
- **CPUs, from hwmon** (what lm_sensors reads, without it installed): the
package temperature, or the hottest core; and the board's inlet or ambient
sensor where it has one (`SYSTIN`, `Inlet`, `Ambient`).
- **CPU power, from RAPL:** the packages' energy counters over time,
subdomains not counted twice, counter wrap handled.
A machine without a sensor reports nothing for it, never a zero; a VM has
none of them. Published as `llmgw_gpu_temperature_celsius`,
`llmgw_gpu_memory_temperature_celsius`, `llmgw_gpu_power_watts`,
`llmgw_gpu_power_limit_watts`, `llmgw_gpu_throttled{reason}`,
`llmgw_node_cpu_temperature_celsius`, `llmgw_node_cpu_power_watts` and
`llmgw_node_inlet_temperature_celsius`, and on each node's page.
What they can and cannot say:
- **Throttling is the strongest signal there is.** A temperature is a proxy;
a card holding its clocks down is the fact that invalidates its measured
capacity, and it needs no threshold to be chosen.
- **Measured power** is GPU draw plus CPU packages; memory, disks, fans and
power-supply losses are not in it, so a node's draw is that plus an
estimated remainder. A rack's is its llmgw nodes' plus a declared figure
for everything else in it: switches and storage report nothing here.
- **CPU and GPU temperatures follow the load, not the room.** A hot rack is
inferred from many nodes running hot at comparable load, or from
throttling, not from one node's CPU. Only a board inlet sensor reads the
air, and most boards have none.
- **No budget comes from a sensor.** A reading is a draw; the cap it is
measured against still comes from the facility.
- **Simulated.** Readings from a script, for tests and for rehearsing a
thermal event on a cluster without facility data.
## Safety
- **Missing data never blocks.** A source that is down, a reading that is stale,
a node without location labels: placement behaves as it does today, and says
the data is missing (a metric, a line on the cluster page). Facility
awareness can make placement more careful, never impossible.
- **Hysteresis and hold times** on every condition, so a noisy sensor does not
flap a rack between allowed and forbidden.
- **Observe-only first.** A mode where every decision is computed, logged and
shown ("would have refused: rack r12-07 hot") but nothing is enforced, run
for long enough to trust the thresholds before turning enforcement on.
- **Manual override.** `llmgw ctl facility hold RACK` and `release RACK`, for the
facility team to mark a domain unavailable, or to clear a condition they know
to be a sensor fault.
- **Shedding is opt-in**, per domain or cluster-wide, and rate limited.
## What people see
- `explain-placement` and headroom name the domain: *rack r12-07 is hot (inlet
31°C for 6 min)*, *pdu r12-07-a: 7.1 of 7.4 kW*.
- `llmgw ctl facility` lists domains with budget, reserved, measured and
condition. The cluster page gets a rack summary beside the nodes table.
- Metrics: `llmgw_domain_power_budget_watts`, `_reserved_watts`,
`_measured_watts`, `llmgw_domain_condition{condition}`,
`llmgw_domain_reading_age_seconds`; alerts for a domain near its cap, a
domain hot, a domain over its cap, and facility data gone stale.
## Rollout
Each step ships on its own and is useful without the next:
0. **Node sensors, observe-only (built).** GPU heat, power and throttling,
CPU heat and power, inlet air where the board has a sensor; published and
shown, no decision changed.
1. **Location and spreading.** Well-known labels, domains from labels, replicas
spread across racks and PDUs, locations on the node views. No facility data
needed.
2. **Power budgets from a static file.** Caps and margins, estimated draw per
replica (declared or derived), hard limit in placement and headroom.
3. **Readings, observe-only.** The Prometheus source beside the agents',
conditions with hysteresis (throttling first among them), measured power
folded into headroom; decisions shown, not enforced.
4. **Thermal enforcement.** `warm` scored down, `hot` refused, manual hold and
release.
5. **Shedding and tokens per watt.** Opt-in shedding through step 2's
preemption; measured power in profile preference.
## Testing
- The simulated source drives unit tests of every condition transition, the
hysteresis, staleness, and the placement outcomes, with mutation checks as
for steps 1–4.
- Property tests: whatever the readings, placement never exceeds a declared
cap with fresh data, and never refuses everything because data is missing.
- On the PoC cluster, with no facility data: step 1 with labelled VMs, step 2
with a static file of made-up caps, steps 3–4 with the simulated source
replaying a thermal event.
- Before enforcement anywhere real: a period in observe-only mode against the
site's actual data.
## Open questions
- **What the sites have.** Which DCIM, which PDUs, which BMCs, and whether any
of it already reaches a Prometheus. The source design assumes Prometheus is
the common ground; a site without it needs another source.
- **Who owns the numbers.** Caps, deratings and thresholds belong to the
facility team. Whether they edit llmgw's file, or llmgw reads theirs.
- **Phase balance.** Three-phase PDUs have per-phase limits; whether placement
should see phases or only the PDU total.
- **Cooling granularity.** Cooling is often per row or per aisle, not per rack;
the domain tree allows either, but the defaults should match what sites
actually measure.
- **Power capping instead of shedding.** Whether to lower GPU power limits in a
warm rack rather than move replicas, and how that interacts with measured
capacity from step 4, which would no longer hold at the lower limit.
+237 -38
View File
@@ -87,7 +87,24 @@ CGO_ENABLED=0 go build -o llmgw ./cmd/llmgw
## 2. The control plane
On the machine that will be the control plane, as root:
**The short way is the screens.** On the machine that will be the control
plane, run `sudo llmgw` with nothing after it. With nothing installed it asks
whether this is one machine or three for high availability; for three, it asks
for the other two — address, user, and an ssh key, the ssh agent, or a
password, and the sudo password if sudo asks for one; or, for a machine
nothing may log into, a join code to run there with `sudo llmgw join <code>`,
which dials out to this one on port 7444 — checks all three before
installing anything (system, room, clock, ports, internet, whether they reach
one another), shows the plan, and installs. Passwords are held in memory for
the run only; a sudo password lets the user sudo without one until the install
finishes, and is then taken back. At the end it shows the administrator's
password and the command that does the same from a script.
Run again later, `sudo llmgw` is the console: the cluster's state, refreshed,
and what to do next — add a node, replicate the store. A setup that stopped
halfway opens the setup screens again, which finish it.
The rest of this section is the same by command, as root:
```
sudo llmgw server init --admin you@example.com
@@ -121,6 +138,20 @@ Add `--postgres` to keep accounts and keys in PostgreSQL rather than SQLite:
init installs it and sets it up, and on a machine already running on SQLite it
moves everything across. See [PostgreSQL](#postgresql-when-sqlite-is-no-longer-the-right-shape).
Or `--patroni`, given two machines (or more), to keep it in PostgreSQL
replicated across them, so losing one loses nothing:
```
sudo --preserve-env=SSH_AUTH_SOCK llmgw server init --patroni ubuntu@10.0.0.149 --patroni ubuntu@10.0.0.150
```
Over ssh with passwordless sudo, as `node add` works, it installs PostgreSQL,
Patroni and etcd there — this machine is etcd's third vote — and moves the
store already in use, SQLite or PostgreSQL, into the cluster. A standby is
promoted within about thirty seconds of the primary being lost, and the
control plane follows it by itself. `llmgw ctl status` says who leads; see
[deploy/ha](../deploy/ha/README.md) for what it sets up.
Every step looks before it acts. Running it again finishes an install that
stopped halfway and changes nothing on one that did not; it never overwrites
`server.yaml`, the authority, or an existing account's password.
@@ -367,7 +398,7 @@ Nothing to do: the agent installs them once the node joins, under
| Engine | On | How |
|---|---|---|
| llama.cpp | every node | the release build for its processor, CUDA where there is a GPU, checked against GitHub's checksum |
| llama.cpp | every node | the release build for its processor and GPU — CUDA 13.3, CUDA 12.8 (x64), or Vulkan for GPUs and drivers the CUDA builds do not cover; the CPU build only where there is no NVIDIA GPU — checked against GitHub's checksum and seen to list the GPU |
| vLLM | nodes with an NVIDIA GPU it can run on | a Python environment built by uv from PyPI, about 8 GB |
| SGLang | only when you ask for it | the same way as vLLM |
@@ -376,8 +407,17 @@ node gets the same dependencies whenever it is built, and installed with
`--require-hashes` against the sha256 PyPI publishes. The resolved list stays in
the environment as `llmgw-requirements.txt`. It needs:
- **driver 580 or newer and a Turing GPU or newer** (compute capability 7.5):
vLLM is built for CUDA 13. A V100 cannot run it;
- **the build its hardware runs**, chosen by the node. Driver 580 and newer:
the CUDA 13 build from PyPI. Driver 525 to 579: the same version built for
CUDA 12.9, from vLLM's GitHub release (checked against the digest GitHub
publishes) with PyTorch's CUDA 12.9 torch. Both have kernels for Turing
(7.5) and newer, 8.0 and newer on aarch64. **A V100** (Volta, 7.0) gets
**vLLM 0.19.1**, the newest built for it — no build from 0.20 on has its
kernels — and the cluster page shows it running that version; run `llmgw
ctl engine-check vllm` there once, since llmgw's flags are checked against
the current one. Pascal and older run no vLLM. Whatever is chosen, the
install ends by computing something on the GPU; a build that cannot is
passed over for the next;
- **about 25 GB free** under the engines directory while it installs;
- **gcc**, which Triton builds its kernel launcher with. The agent installs it.
@@ -394,8 +434,51 @@ engines:
```
A changed version reaches every node when it next connects. To use an engine
you installed yourself instead, set its version to `""` and name its path in
the node's `agent.yaml` (`engines.vllm.bin`, `engines.sglang.python`).
you installed yourself on a node, name its path in that node's `agent.yaml`
(`engines.llamacpp.bin`, `engines.vllm.bin`, `engines.sglang.python`): the agent
then installs none of its own beside it.
#### Choosing a node's vLLM build
Which build a GPU node installs is chosen on the cluster page, in the **vLLM
build** column — *auto* (what its hardware runs, the default), *CUDA 13*,
*CUDA 12.9*, or *none* — or from the command line:
```
llmgw ctl engine-build gpu07 vllm cu129
```
The choice is kept by the control plane and the node is told at once; it
installs the build in the background, and the column shows how that stands.
#### Different GPUs, different engines
A cluster of two GPU generations may need two builds of an engine, or one on
only some nodes. `engine_overrides` picks nodes — by a GPU class they have, by
id, or by labels, every one given having to match — and lays engines over the
cluster's for them, in order:
```yaml
engines:
vllm: "0.30.0" # CUDA 13: Turing and newer
engine_overrides:
- gpu_class: a10g # pin a class to a version you have tested
engines: { vllm: "0.29.0" }
- labels: { role: gguf } # nodes meant for llama.cpp alone
engines: { vllm: "" } # none
```
A node told nothing it can run of an engine joins without it: `llmgw doctor`
and `node add` warn, and the node serves what it does run. To keep an engine
off a node for good, whatever the control plane says, add the node with
`llmgw node add --disable-engine vllm`, which writes `engines.disabled: [vllm]`
into its `agent.yaml` and leaves vLLM out of doctor's checks. That is narrower
than `--ignore-checks`, which passes over every other problem as well.
A llama.cpp CUDA build is checked for the GPU it is meant for: installing it
asks it which devices it sees, and a replica placed on GPUs is not ready until
its process holds memory on each of them. A build missing a CUDA library fails
there, naming the library, rather than serving on the CPU.
After changing a version, check it still takes the flags llmgw builds for it,
which drift between releases:
@@ -671,6 +754,74 @@ llmgw ctl explain-placement qwen2.5-coder-3b-instruct
which names every candidate and why each was rejected, rather than leaving you
to infer it.
### LoRA adapters
A llama.cpp model can carry LoRA adapters, each asked for by its own name as
though it were a model:
```yaml
- name: qwen2.5-coder-7b-instruct
source: http://mirror:8081/qwen2.5-coder-7b-instruct/5d567d26/model.gguf
adapters:
- name: coder-sql
source: http://mirror:8081/coder-sql/v1/adapter.gguf
scale: 1.0 # optional; 1 when left out
profiles: [...]
```
Every replica of the model fetches every adapter and loads it at start, applied
to nothing. A request for `coder-sql` goes to the model's replicas with the
adapter applied to it alone; a request for the model gets the model. Adapter
names share the namespace of models and aliases, appear in `/v1/models`, and
follow key allowlists on their own: a key allowed `coder-sql` reaches that
adapter and not the model under it, while a key allowed the model reaches its
adapters too. Adding or removing an adapter is a change to the model; running
replicas load the new set when restarted (`llmgw ctl restart --model M`).
Besides the mirror page's model card, adapters are attached from the command
line, which reads the file first and refuses one that is not an adapter for
this model's architecture:
llmgw ctl model adapters coder --add coder-sql=http://mirror:8081/sql-lora/v1/sql.gguf@0.8
llmgw ctl model adapters coder --remove coder-sql
The adapter file must be a GGUF LoRA made for this model — llama.cpp's
`convert_lora_to_gguf.py` makes one from a PEFT adapter — and not a model with
the adapter already merged in, which is what most "LoRA GGUF" repositories hold.
### Models served outside the cluster
A model can be an OpenAI-compatible API elsewhere — a hosted provider, or
another gateway — rather than replicas here. Nothing is placed for it; the
router sends its requests on, with the provider's key and the provider's name
for the model:
```yaml
- name: cloud-mini
source: external
external:
url: https://api.openai.com # without /v1
model: gpt-4o-mini # what the provider calls it
api_key_file: /etc/llmgw/secrets/openai.key
```
Named in another model's `fallbacks`, it is used only when the cluster has no
capacity left for that model:
```yaml
- name: qwen2.5-coder-7b-instruct
fallbacks: [cloud-mini]
...
```
The key is read from its file on the control plane, never stored in the
catalog, and a key rotated in the file is used within seconds. A model whose key
file cannot be read is not offered. Everything else applies as to any model:
prices and budgets, rate limits, usage records, and key allowlists.
**Its prompts leave the cluster.** Decide who may send them: a key or project
whose allowlist leaves the external model out cannot reach it, as a fallback or
by name. The cluster page and `ctl models` mark it `external`.
### How many replicas, and how much each one carries
Replica count is changed while the cluster runs, without editing the
@@ -909,55 +1060,94 @@ refuses every request.
## Upgrading
One binary on every machine, so an upgrade is a copy and a restart. The part
worth care is the copy, not the order.
On the control plane:
```
sudo llmgw ctl upgrade v0.10.4
```
It upgrades this machine first — fetching the release for its platform,
checking it against the release's checksums, keeping the old binary as
`/usr/local/bin/llmgw.bak-pre-<version>`, restarting `llmgw-server` and waiting
for every node to reconnect — and then each node in turn. A node's agent is told
the version over the stream it already holds, fetches and checks the release
itself, swaps it in and restarts; no ssh into the nodes is needed. Release
candidates (`v0.10.4-rc.2`) work the same way.
A node counts as done when it is back on the new version, ready, with as many
ready replicas as before. One that is not within `--timeout` (three minutes)
stops the upgrade there, so a bad release reaches one node rather than all of
them; `llmgw ctl logs <node>` says why, and the node keeps its previous binary
beside the new one. Nodes already on the version are skipped, so running the
command again resumes where it stopped. `--nodes a,b` limits it to some nodes,
and `--skip-control-plane` leaves this machine alone.
**Nothing stops serving.** Engines are started with `KillMode=process`, so they
keep running when the agent restarts, and the agent reports them back on its
next connection. The control plane holds no replica state across its own
restart either — it rebuilds the fleet from what the agents report. A rolling
upgrade of the whole cluster interrupts no request that is not in flight at the
moment of the restart.
restart either — it rebuilds the fleet from what the agents report.
**A mixed fleet is fine while you roll.** The agent protocol only ever gains
fields, and both sides treat an absent one as unknown rather than as a default.
Order does not matter; finish the roll the same day anyway, because
`ctl nodes` reports each agent's version and a fleet that disagrees with itself
is one more thing to hold in your head.
Agents older than v0.10.4 cannot upgrade themselves: move those by hand once,
installing the release binary and restarting `llmgw-agent`, and every upgrade
after that is the command above.
**The mirror is upgraded on its own host**, since it is not an agent and its
service cannot replace its own binary:
```
VERSION=v0.9.0
# copy to every machine first, install nowhere yet
for h in 10.0.0.140 10.0.0.141 10.0.0.142 10.0.0.146; do
ssh $h 'cat > /tmp/llmgw-new; stat -c %s /tmp/llmgw-new' < llmgw-$VERSION-linux-amd64
done
sudo llmgw upgrade v0.10.5 --service llmgw-mirror
```
**Check every size before installing anything.** This is not defensive writing:
on a host whose login shell prints anything, `scp` fails outright, and the
obvious workaround — `ssh -n host 'cat > file'` — points stdin at `/dev/null`
and writes a *zero-byte file* while reporting success. Installing that on a
fleet takes the fleet down. Compare the number on each machine with the local
one, then:
```
sudo install -m0755 /tmp/llmgw-new /usr/local/bin/llmgw
sudo systemctl restart llmgw-agent # or llmgw-server, or llmgw-mirror
llmgw --version
```
The same checks — the release's checksums, the old binary kept beside the new —
then a restart of the unit, waiting for it to stay up. A mirror older than
v0.10.5 does not have the command: install the release binary by hand once.
Then confirm what you have, rather than assuming:
```
llmgw ctl nodes # every agent back, LAST SEEN in seconds
llmgw ctl nodes # every agent on the new version, LAST SEEN in seconds
llmgw ctl replicas # same replicas, adopted rather than replaced
```
A replica may come back marked **`ready (stale profile)`**. That means it is
running a profile that has since been edited — the reconciler leaves a serving
replica alone on purpose, because churning one costs more than the difference.
Restart it when you want the new profile to take effect; the flag is there so
the choice is yours rather than silent.
`llmgw ctl restart <replica>` replaces it with one on the new profile, starting
the new one before stopping the old. Where the old one is all that stands in
the new one's way — a single GPU — it is stopped first, at once, and the model
is unserved while the new one loads; where the new profile fits nowhere even
then, the restart is refused and the old one keeps serving, which `ctl
replicas` shows as `restart refused:` with the reason. `--in-place` asks for
stop-then-start whatever room there is.
### Signed releases
Every install above checks a release against its published checksums, which
proves the download is what the server holds — not that the server holds what
was built. Signing closes that: CI signs the checksums, and a binary that
trusts a key installs only releases signed with it. Off until a key exists;
turning it on, once:
```
llmgw release keygen
```
1. Put the **private** key in the repository's Actions secret
`RELEASE_SIGNING_KEY`. From the next tag, releases carry
`checksums.txt.sig`.
2. Put the **public** key in `internal/release/trusted.pub` and release. Every
binary built from then on carries it and refuses releases that are not
signed with it — `ctl upgrade`, agents upgrading themselves, and
`llmgw upgrade` alike.
In that order: a build that trusts a key refuses unsigned releases, so the
secret has to be signing before the key is trusted. The first trusting release
is itself installed by a binary that trusts nothing; every one after is checked
by the one before. `LLMGW_RELEASE_KEY` overrides the built-in key, for rotating
it. The onboarding script fetches with `curl` and checks checksums only; a node
is checked from its first upgrade on.
## Backing up
@@ -1112,11 +1302,12 @@ requests. On a slow model, set `timeouts.generation` higher.
can see each other, seeding between them is the obvious next thing to build.
- **A budget without a price never binds.** Set `default_price` or `pricing`
(step 2) or every request costs zero, and a budget is something the dashboard
displays rather than something that stops anything. The quotas page says so
displays rather than something that stops anything. The Limits tab says so
where it can, which is not the same as being told before you rely on it.
- **vLLM needs CUDA 13.** The version the agent installs needs driver 580
and a Turing GPU or newer. On older hardware, set `engines.vllm` to `""` and
install a vLLM built for your CUDA yourself.
- **A V100 runs vLLM 0.19.1**, not the version the rest of the cluster runs:
no build from 0.20 on has Volta kernels, and the node picks the newest that
has. llmgw's flags are checked against the current version, so run `llmgw
ctl engine-check vllm` on a V100 node once.
- **Two control planes have not been run for long.** The leader election and
agent failover work and there is a pattern in `deploy/ha/`, but every cluster
this has been run on in anger has had one. A single control plane is the
@@ -1127,3 +1318,11 @@ requests. On a slow model, set `timeouts.generation` higher.
has them. Single-node placement on real GPUs is the well-trodden path; if you
are the first to run a TP group across real cards, run `llmgw doctor --json`
on each node first and keep the reports.
- **Two engine setups are weaker than the rest.** An engine's key is passed
in its environment, where only root and its own user can read it, except
SGLang's, which takes it only as a flag: other users on the node can see it
in the process list. Mount `/proc` with `hidepid=2` on nodes where that
matters. vLLM's `launch_mode: ray` starts a Ray head without
authentication, so anyone who can reach its port can run code on the
node. The dashboard is off, but firewall the port to the other nodes in the
replica, or use the default `mp` launch mode, which needs no Ray at all.
+16 -6
View File
@@ -191,7 +191,8 @@ project or person, the teams and projects with their limits and spend, and every
member. Only what was spent in that organization's projects is on it; what its
people do elsewhere is not. The page is read-only — a manager is not an
administrator of the cluster, and limits and memberships stay with those who
are.
are. An administrator following `/org` lands on the same organization's panel
on the people page instead, where all of that can be changed.
`llmgw keys init` still works for provisioning scripts and is idempotent, but it
will no longer move a team or project that already exists under another parent;
@@ -259,7 +260,7 @@ 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 given in whole units, as the quotas page shows them, and what a unit
Budgets are given in whole units, as the Limits tab shows them, 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 one unit per thousand tokens everywhere —
a price of 1 000 000 in `auth.pricing`, which is in micro-units — and a budget
@@ -409,10 +410,19 @@ page exists.
opening onto its members and its keys. From there an admin approves people
(into a project or on their own), adds them to and takes them out of
organizations, teams and projects, suspends and removes them, revokes keys, and
adds, renames, moves and removes organizations, teams and projects. `/admin/quotas`
sets limits on the tree, per member of any part of it, and per person. A searchable list of every account is at the
bottom. The page works as plain forms; with scripts on, a change updates it in
place and the projects you had open stay open.
adds, renames, moves and removes organizations, teams and projects. A
searchable list of every account is at the bottom. Choosing any organization,
team, project or person opens a panel beside the tree with its limits, its
capacity quota and its use over the last week, and its limits can be changed
there.
The same page has two more tabs, each at the address it had as a page of its
own: **Limits** (`/admin/quotas`) is every limit in the tree at once — per
branch, per member of any part of it, and per person — with the capacity quotas;
**Usage** (`/admin/usage`) is the cluster's use over time by organization, team,
project or person, and the GPU time each owner held. The page works as plain
forms; with scripts on, a change updates it in place and the projects you had
open stay open.
Three things are deliberately not there:
+626 -194
View File
@@ -15,12 +15,16 @@ rather than repeated.
| Key | Type | In |
|---|---|---|
| `name` | `string` | [Adapter](#adapter) |
| `source` | `string` | [Adapter](#adapter) |
| `scale` | `float64` | [Adapter](#adapter) |
| `enabled` | `bool` | [Admission](#admission) |
| `bands` | `[]Band` | [Admission](#admission) |
| `default_band` | `string` | [Admission](#admission) |
| `band_header` | `string` | [Admission](#admission) |
| `queue_ttl` | `time.Duration` | [Admission](#admission) |
| `queue_capacity_per_band` | `int` | [Admission](#admission) |
| `queue_capacity_total` | `int` | [Admission](#admission) |
| `interval` | `time.Duration` | [Admission](#admission) |
| `enabled` | `bool` | [Auth](#auth) |
| `store` | `string` | [Auth](#auth) |
@@ -35,31 +39,10 @@ rather than repeated.
| `flush_interval` | `time.Duration` | [Auth](#auth) |
| `batch_size` | `int` | [Auth](#auth) |
| `buffer_size` | `int` | [Auth](#auth) |
| `name` | `string` | [Band](#band) |
| `priority` | `int` | [Band](#band) |
| `saturation_ceiling` | `float64` | [Band](#band) |
| `usage_retention` | `time.Duration` | [Auth](#auth) |
| `tools` | `bool` | [Capabilities](#capabilities) |
| `vision` | `bool` | [Capabilities](#capabilities) |
| `embeddings` | `bool` | [Capabilities](#capabilities) |
| `node_id` | `string` | [Config](#config) |
| `server` | `string` | [Config](#config) |
| `servers` | `[]string` | [Config](#config) |
| `hostname` | `string` | [Config](#config) |
| `mgmt_addr` | `string` | [Config](#config) |
| `labels` | `map[string]string` | [Config](#config) |
| `mgmt_interface` | `string` | [Config](#config) |
| `fabrics` | `[]inventory.FabricAttachment` | [Config](#config) |
| `heartbeat_interval` | `time.Duration` | [Config](#config) |
| `reconnect_base` | `time.Duration` | [Config](#config) |
| `reconnect_max` | `time.Duration` | [Config](#config) |
| `adopt_ready_timeout` | `time.Duration` | [Config](#config) |
| `tls` | `tlsconf.Config` | [Config](#config) |
| `model_dirs` | `[]string` | [Config](#config) |
| `engines_dir` | `string` | [Config](#config) |
| `weights_cache` | `string` | [Config](#config) |
| `discovery` | `discovery.Options` | [Config](#config) |
| `engines` | `engines.Config` | [Config](#config) |
| `supervisor` | `supervisor.Config` | [Config](#config) |
| `interval` | `time.Duration` | [Control](#control) |
| `base_backoff` | `time.Duration` | [Control](#control) |
| `max_backoff` | `time.Duration` | [Control](#control) |
@@ -77,10 +60,18 @@ rather than repeated.
| `pool` | `string` | [Endpoint](#endpoint) |
| `weight` | `float64` | [Endpoint](#endpoint) |
| `priority` | `int` | [Endpoint](#endpoint) |
| `gpu_class` | `string` | [EngineOverride](#engineoverride) |
| `nodes` | `[]string` | [EngineOverride](#engineoverride) |
| `labels` | `map[string]string` | [EngineOverride](#engineoverride) |
| `engines` | `map[string]string` | [EngineOverride](#engineoverride) |
| `url` | `string` | [External](#external) |
| `model` | `string` | [External](#external) |
| `api_key_file` | `string` | [External](#external) |
| `contact` | `string` | [Landing](#landing) |
| `notice` | `string` | [Landing](#landing) |
| `bin` | `string` | [LlamaCppConfig](#llamacppconfig) |
| `parallel` | `int` | [LlamaCppConfig](#llamacppconfig) |
| `threads` | `int` | [LlamaCppConfig](#llamacppconfig) |
| `api` | `string` | [Mirror](#mirror) |
| `tls` | `tlsconf.Config` | [Mirror](#mirror) |
| `registry` | `string` | [Mirror](#mirror) |
@@ -91,6 +82,10 @@ rather than repeated.
| `source` | `string` | [Model](#model) |
| `profiles` | `[]Profile` | [Model](#model) |
| `scaling` | `Scaling` | [Model](#model) |
| `class` | `string` | [Model](#model) |
| `owner` | `string` | [Model](#model) |
| `external` | `*External` | [Model](#model) |
| `adapters` | `[]Adapter` | [Model](#model) |
| `fallbacks` | `[]string` | [Model](#model) |
| `routing_profile` | `string` | [Model](#model) |
| `slo` | `SLO` | [Model](#model) |
@@ -116,8 +111,6 @@ rather than repeated.
| `match_ratio` | `float64` | [Prefix](#prefix) |
| `balance_abs` | `float64` | [Prefix](#prefix) |
| `balance_rel` | `float64` | [Prefix](#prefix) |
| `prompt_per_1k` | `int64` | [Price](#price) |
| `completion_per_1k` | `int64` | [Price](#price) |
| `name` | `string` | [Profile](#profile) |
| `engine` | `string` | [Profile](#profile) |
| `gpu_class` | `string` | [Profile](#profile) |
@@ -128,6 +121,7 @@ rather than repeated.
| `args` | `[]string` | [Profile](#profile) |
| `env` | `map[string]string` | [Profile](#profile) |
| `min_memory_gb` | `int` | [Profile](#profile) |
| `cpu_cores` | `int` | [Profile](#profile) |
| `min_disk_gb` | `int` | [Profile](#profile) |
| `node_selector` | `map[string]string` | [Profile](#profile) |
| `weight` | `float64` | [Profile](#profile) |
@@ -144,6 +138,7 @@ rather than repeated.
| `prefix` | `Prefix` | [Router](#router) |
| `session_header` | `string` | [Router](#router) |
| `inject_usage` | `bool` | [Router](#router) |
| `tenant_label` | `string` | [Router](#router) |
| `max_body_bytes` | `int64` | [Router](#router) |
| `timeouts` | `Timeouts` | [Router](#router) |
| `scoring` | `Scoring` | [Router](#router) |
@@ -167,9 +162,12 @@ rather than repeated.
| `landing` | `Landing` | [Server](#server) |
| `agent_tls` | `tlsconf.Config` | [Server](#server) |
| `monitoring` | `Monitoring` | [Server](#server) |
| `tracing` | `Tracing` | [Server](#server) |
| `mirror` | `Mirror` | [Server](#server) |
| `engines` | `map[string]string` | [Server](#server) |
| `engine_overrides` | `[]EngineOverride` | [Server](#server) |
| `backup_dir` | `string` | [Server](#server) |
| `releases` | `string` | [Server](#server) |
| `router` | `Router` | [Server](#server) |
| `control` | `Control` | [Server](#server) |
| `models` | `[]catalog.Model` | [Server](#server) |
@@ -183,9 +181,82 @@ rather than repeated.
| `first_token` | `time.Duration` | [Timeouts](#timeouts) |
| `idle` | `time.Duration` | [Timeouts](#timeouts) |
| `total` | `time.Duration` | [Timeouts](#timeouts) |
| `endpoint` | `string` | [Tracing](#tracing) |
| `sample_ratio` | `float64` | [Tracing](#tracing) |
| `bin` | `string` | [VLLMConfig](#vllmconfig) |
| `launch_mode` | `string` | [VLLMConfig](#vllmconfig) |
| `ray_bin` | `string` | [VLLMConfig](#vllmconfig) |
| `prompt_per_1k` | `int64` | [accounting.Price](#accountingprice) |
| `completion_per_1k` | `int64` | [accounting.Price](#accountingprice) |
| `name` | `string` | [admission.Band](#admissionband) |
| `priority` | `int` | [admission.Band](#admissionband) |
| `saturation_ceiling` | `float64` | [admission.Band](#admissionband) |
| `bands` | `[]Band` | [admission.Config](#admissionconfig) |
| `default_band` | `string` | [admission.Config](#admissionconfig) |
| `queue_ttl` | `time.Duration` | [admission.Config](#admissionconfig) |
| `queue_capacity_per_band` | `int` | [admission.Config](#admissionconfig) |
| `queue_capacity_total` | `int` | [admission.Config](#admissionconfig) |
| `interval` | `time.Duration` | [admission.Config](#admissionconfig) |
| `node_id` | `string` | [agent.Config](#agentconfig) |
| `server` | `string` | [agent.Config](#agentconfig) |
| `servers` | `[]string` | [agent.Config](#agentconfig) |
| `hostname` | `string` | [agent.Config](#agentconfig) |
| `mgmt_addr` | `string` | [agent.Config](#agentconfig) |
| `labels` | `map[string]string` | [agent.Config](#agentconfig) |
| `mgmt_interface` | `string` | [agent.Config](#agentconfig) |
| `fabrics` | `[]inventory.FabricAttachment` | [agent.Config](#agentconfig) |
| `heartbeat_interval` | `time.Duration` | [agent.Config](#agentconfig) |
| `reconnect_base` | `time.Duration` | [agent.Config](#agentconfig) |
| `reconnect_max` | `time.Duration` | [agent.Config](#agentconfig) |
| `adopt_ready_timeout` | `time.Duration` | [agent.Config](#agentconfig) |
| `tls` | `tlsconf.Config` | [agent.Config](#agentconfig) |
| `model_dirs` | `[]string` | [agent.Config](#agentconfig) |
| `engines_dir` | `string` | [agent.Config](#agentconfig) |
| `weights_cache` | `string` | [agent.Config](#agentconfig) |
| `discovery` | `discovery.Options` | [agent.Config](#agentconfig) |
| `engines` | `engines.Config` | [agent.Config](#agentconfig) |
| `supervisor` | `supervisor.Config` | [agent.Config](#agentconfig) |
| `name` | `string` | [config.Band](#configband) |
| `priority` | `int` | [config.Band](#configband) |
| `saturation_ceiling` | `float64` | [config.Band](#configband) |
| `prompt_per_1k` | `int64` | [config.Price](#configprice) |
| `completion_per_1k` | `int64` | [config.Price](#configprice) |
| `disabled` | `[]string` | [engines.Config](#enginesconfig) |
| `vllm` | `VLLMConfig` | [engines.Config](#enginesconfig) |
| `sglang` | `SGLangConfig` | [engines.Config](#enginesconfig) |
| `llamacpp` | `LlamaCppConfig` | [engines.Config](#enginesconfig) |
| `sim` | `SimConfig` | [engines.Config](#enginesconfig) |
| `work_dir` | `string` | [supervisor.Config](#supervisorconfig) |
| `log_lines` | `int` | [supervisor.Config](#supervisorconfig) |
| `stop_timeout` | `time.Duration` | [supervisor.Config](#supervisorconfig) |
| `health_interval` | `time.Duration` | [supervisor.Config](#supervisorconfig) |
| `rendezvous_timeout` | `time.Duration` | [supervisor.Config](#supervisorconfig) |
| `alive_grace` | `time.Duration` | [supervisor.Config](#supervisorconfig) |
| `ca` | `string` | [tlsconf.Config](#tlsconfconfig) |
| `cert` | `string` | [tlsconf.Config](#tlsconfconfig) |
| `key` | `string` | [tlsconf.Config](#tlsconfconfig) |
| `insecure` | `bool` | [tlsconf.Config](#tlsconfconfig) |
## Adapter
### `name`
`string`
Name is what clients ask for, like a model name.
### `source`
`string`
Source is the adapter file (GGUF for llama.cpp), fetched by each node
as the model's weights are.
### `scale`
`float64`
Scale is how strongly it is applied; zero takes 1.
## Admission
@@ -220,6 +291,15 @@ should not be able to promote themselves.
`int`
### `queue_capacity_total`
`int`
QueueCapacityTotal bounds everything queued across bands. Reached, a
request is not refused while a lower band has any queued: the newest of
the lowest band is turned away instead, with a 503 and Retry-After.
Zero takes half of all the bands' capacities together.
### `interval`
`time.Duration`
@@ -295,7 +375,7 @@ table costs whatever DefaultPrice says, which is nothing unless set.
`Price`
Takes a [Price](#price) block.
Takes a [config.Price](#configprice) block.
### `flush_interval`
@@ -315,19 +395,16 @@ BufferSize bounds the queue. Beyond it records are dropped rather than
blocking a request: losing accounting is bad, turning a stalled database
into an outage is worse.
## Band
### `usage_retention`
### `name`
`time.Duration`
`string`
### `priority`
`int`
### `saturation_ceiling`
`float64`
UsageRetention is how long the record of each request is kept: who sent
it, which replica answered, how long it queued — what `ctl request`
reads. Zero keeps it for ever. Totals, charts and budgets are read from
five-minute sums kept beside the records, which are not pruned, so this
bounds the table that grows by a row a request without changing any
figure a page shows. At least a day when set.
## Capabilities
@@ -343,152 +420,6 @@ into an outage is worse.
`bool`
## Config
### `node_id`
`string`
NodeID defaults to the hostname. It must equal the common name of the
agent's certificate, so set it only where the two differ.
### `server`
`string`
Server is the control plane, and Servers is the same thing when there is
more than one. Both are accepted: every configuration written before
there could be a second control plane names one address, and none of
them should have to change.
The order is tried in order, and a refusal moves to the next. Only the
leader accepts an agent, so a standby is skipped in the time it takes to
be told so — which is what makes failover a reconnect rather than an
edit to every node's configuration.
### `servers`
`[]string`
### `hostname`
`string`
Hostname defaults to the machine's.
### `mgmt_addr`
`string`
MgmtAddr is the address engines are reached on. It defaults to this
machine's address on the route to the control plane, which is the one
the control plane and the router can reach.
### `labels`
`map[string]string`
Labels are free-form facts about a node for a profile's node_selector.
Nothing needs them: memory, GPUs, engines and disk are measured, and a
label restating one of those goes stale the day the machine changes.
### `mgmt_interface`
`string`
MgmtInterface is the ethernet interface NCCL bootstraps over. It
defaults to the interface holding MgmtAddr, then eth0.
### `fabrics`
`[]inventory.FabricAttachment`
Fabrics labels this node's HCAs. It is explicit on purpose: OpenSM's
default subnet prefix is the same on every fabric, so nothing can be
inferred from the wire (plan §5.2).
### `heartbeat_interval`
`time.Duration`
### `reconnect_base`
`time.Duration`
ReconnectBase is the first reconnect delay; it backs off from there.
### `reconnect_max`
`time.Duration`
### `adopt_ready_timeout`
`time.Duration`
AdoptReadyTimeout bounds how long an adopted engine is given to answer a
health check before the agent stops waiting on it.
Shorter than a startup timeout on purpose: this engine is already
running, so it is not loading a model, it is either serving or it is not.
### `tls`
`tlsconf.Config`
TLS authenticates both ends of the control-plane stream. Leaving it unset
keeps the plaintext behaviour, which only belongs on a trusted network.
Takes a [Config](#config) block.
### `model_dirs`
`[]string`
ModelDirs are the directories this node keeps model weights in, beyond
WeightsCache, which is always looked in. Storage differs at every installation,
so llmgw observes it rather than owning it: a Lustre mount, an image
exported over the fabric, local NVMe and a copy made by hand all look
identical from here, and placement only needs to know whether the file
is present.
### `engines_dir`
`string`
EnginesDir is where the agent installs engines, one directory per
build. Defaults to /opt/llmgw/engines.
### `weights_cache`
`string`
WeightsCache is where a remote source is downloaded to. Defaults to the
first entry of ModelDirs, because a node that says where its models live
has already answered this question, and otherwise to
/var/lib/llmgw-weights.
Fetching is the simple arrangement: every node keeps its own copy. The
exotic ones need no code — weights on a shared filer or an image exported
over the fabric are simply present, and present means nothing is fetched.
### `discovery`
`discovery.Options`
Takes a [Options](#options) block.
### `engines`
`engines.Config`
Takes a [Config](#config) block.
### `supervisor`
`supervisor.Config`
Takes a [Config](#config) block.
## Control
### `interval`
@@ -598,6 +529,54 @@ called. Empty means vLLM's names.
`int`
## EngineOverride
### `gpu_class`
`string`
GPUClass picks nodes with a GPU of this class ("v100-32g").
### `nodes`
`[]string`
Nodes picks nodes by id.
### `labels`
`map[string]string`
Labels picks nodes carrying all of these.
### `engines`
`map[string]string`
Engines are laid over the cluster's: a version, or "" for none.
## External
### `url`
`string`
URL is the provider's base address, without /v1: https://api.openai.com.
### `model`
`string`
Model is what the provider calls it; requests are rewritten to it.
### `api_key_file`
`string`
APIKeyFile holds the provider's key, on the control plane. A file
rather than the key itself, so the key is in neither the catalog, the
store nor the audit log; empty for a provider that asks for none.
## Landing
### `contact`
@@ -633,6 +612,20 @@ 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.
### `threads`
`int`
Threads is how many CPU threads a shard with no GPU computes on. Zero
takes half this machine's CPUs.
Not all of them, which is llama.cpp's own default: two busy servers
that each spin on every CPU fight for them, and on an 8-vCPU node
that made 7B generation fall from 10 tokens a second to under 1 each.
At half, one server alone is about a third slower and two together
keep 5.5 each. A node that only ever runs one CPU replica does better
with all of them; say so here.
## Mirror
### `api`
@@ -655,7 +648,7 @@ agents use. Empty leaves the connection in plain HTTP, which is only
acceptable on a network where nothing untrusted can reach that port —
the mirror says as much in its own log when it starts that way.
Takes a [Config](#config) block.
Takes a [tlsconf.Config](#tlsconfconfig) block.
### `registry`
@@ -716,6 +709,45 @@ Source is the weights path or a HuggingFace id.
Takes a [Scaling](#scaling) block.
### `class`
`string`
Class is how much this model's capacity is worth against another's,
when there is not enough for both: critical, standard or opportunistic.
Empty is standard, or opportunistic for a model loaded on demand. See
EffectiveClass.
### `owner`
`string`
Owner is the part of the tenancy tree whose capacity quota this model
counts against, written org:ID, team:ID or project:ID. Empty is shared:
held by the cluster, counted against nobody.
### `external`
`*External`
External is a model served by someone else — an OpenAI-compatible API
outside the cluster — rather than by replicas placed here. Such a model
has no profiles and nothing is placed for it; the router sends its
requests to the provider. Most useful named in another model's
fallbacks: used only when the cluster has no capacity left. Its
prompts leave the cluster, so which keys may name it is worth an
allowlist.
Takes a [External](#external) block.
### `adapters`
`[]Adapter`
Adapters are LoRA adapters served on this model's replicas, each asked
for by its own name as though it were a model. Every replica loads
every adapter, applying one only to a request that names it.
### `fallbacks`
`[]string`
@@ -878,16 +910,6 @@ before affinity is abandoned.
`float64`
## Price
### `prompt_per_1k`
`int64`
### `completion_per_1k`
`int64`
## Profile
### `name`
@@ -947,6 +969,20 @@ this. A node that does not report its memory is not excluded either —
an older agent must not have its nodes quietly emptied by an upgrade of
the control plane.
### `cpu_cores`
`int`
CPUCores is the cores a replica of a profile with no GPU is given:
reserved out of its node's CPUs by placement, as memory is, and what
its engine computes on (llama.cpp's --threads).
Reserved because an engine takes every core it is told about, and two
on one node that each think they have all of them spin against each
other: on an 8-vCPU node, two 7B replicas fell from 10 tokens a second
to under one each. Zero reserves nothing and leaves the thread count to
the node, which is every profile from before this.
### `min_disk_gb`
`int`
@@ -1075,6 +1111,16 @@ SessionHeader carries the session id for affinity (plan §7.5 tier 0).
InjectUsage asks engines for usage even when the client did not, so token
accounting works regardless of client behaviour.
### `tenant_label`
`string`
TenantLabel is the level of the tree the per-tenant request series are
labelled by — llmgw_tenant_requests_total and its siblings: "org",
"team" or "project", or "none" for no such series. A label per project
on a cluster of thousands is more series than Prometheus wants, and a
team is what an alert is usually about; "team" is the default.
### `max_body_bytes`
`int64`
@@ -1235,7 +1281,7 @@ AgentTLS authenticates the fleet-facing port. It deliberately does not
cover the client-facing one: that surface is fronted by a publicly
trusted certificate, while this one is mutual and private.
Takes a [Config](#config) block.
Takes a [tlsconf.Config](#tlsconfconfig) block.
### `monitoring`
@@ -1245,6 +1291,16 @@ Monitoring is where the dashboard reads its history from.
Takes a [Monitoring](#monitoring) block.
### `tracing`
`Tracing`
Tracing sends a trace of each inference request to an OpenTelemetry
collector. Off unless an endpoint is set here or in
OTEL_EXPORTER_OTLP_ENDPOINT.
Takes a [Tracing](#tracing) block.
### `mirror`
`Mirror`
@@ -1266,6 +1322,16 @@ never trusted to already have what somebody once put there.
Unset keeps DefaultEngines. A version replaces the default; an empty
one leaves that engine to be installed by hand.
### `engine_overrides`
`[]EngineOverride`
EngineOverrides give some nodes different engines from the rest: a
vLLM built for CUDA 12 on the Volta cards CUDA 13 dropped, or none at
all on nodes meant only for llama.cpp. Each picks nodes and lays its
engines over the cluster's, in order, later ones last; an empty version
removes that engine from the nodes it picks.
### `backup_dir`
`string`
@@ -1274,6 +1340,14 @@ BackupDir is where `llmgw server backup` writes, and where the newest
archive's age is read for llmgw_backup_last_success_timestamp_seconds.
Empty means /var/backups/llmgw-server.
### `releases`
`string`
Releases is where `llmgw ctl upgrade` fetches release binaries from,
for this machine and — through their agents — every node. Empty means
the project's own releases.
### `router`
`Router`
@@ -1357,6 +1431,22 @@ about a hundred tokens on a slow model.
`time.Duration`
## Tracing
### `endpoint`
`string`
Endpoint is the collector, for example http://otel.internal:4318.
Empty falls back to OTEL_EXPORTER_OTLP_ENDPOINT, and to no tracing.
### `sample_ratio`
`float64`
SampleRatio is the share of requests traced, 0 to 1, when the caller
did not decide it with a sampled traceparent of its own. Zero takes 1.
## VLLMConfig
### `bin`
@@ -1383,3 +1473,345 @@ node and has not been checked against a real cluster.
RayBin is used when LaunchMode is "ray".
## accounting.Price
### `prompt_per_1k`
`int64`
### `completion_per_1k`
`int64`
## admission.Band
### `name`
`string`
### `priority`
`int`
Priority orders bands; higher is served first.
### `saturation_ceiling`
`float64`
SaturationCeiling is the pool saturation above which this band waits.
1.0 means "never hold this band back".
## admission.Config
### `bands`
`[]Band`
### `default_band`
`string`
### `queue_ttl`
`time.Duration`
QueueTTL drops a request that has waited this long. Sending the GPU work
the client has already given up on helps nobody.
### `queue_capacity_per_band`
`int`
QueueCapacityPerBand bounds each band's queue. Rejecting immediately
beats accumulating silently.
### `queue_capacity_total`
`int`
QueueCapacityTotal bounds everything queued, across bands. When it is
reached, a request is not refused while a lower band has requests
queued: the newest of the lowest band is turned away instead
(displacement), since the oldest have waited longest and are nearest
their TTL. Zero takes half of every band's capacity together.
### `interval`
`time.Duration`
Interval is how often the dispatcher re-checks saturation. Releases also
wake it, so this is only the backstop for saturation falling on its own.
## agent.Config
### `node_id`
`string`
NodeID defaults to the hostname. It must equal the common name of the
agent's certificate, so set it only where the two differ.
### `server`
`string`
Server is the control plane, and Servers is the same thing when there is
more than one. Both are accepted: every configuration written before
there could be a second control plane names one address, and none of
them should have to change.
The order is tried in order, and a refusal moves to the next. Only the
leader accepts an agent, so a standby is skipped in the time it takes to
be told so — which is what makes failover a reconnect rather than an
edit to every node's configuration.
### `servers`
`[]string`
### `hostname`
`string`
Hostname defaults to the machine's.
### `mgmt_addr`
`string`
MgmtAddr is the address engines are reached on. It defaults to this
machine's address on the route to the control plane, which is the one
the control plane and the router can reach.
### `labels`
`map[string]string`
Labels are free-form facts about a node for a profile's node_selector.
Nothing needs them: memory, GPUs, engines and disk are measured, and a
label restating one of those goes stale the day the machine changes.
### `mgmt_interface`
`string`
MgmtInterface is the ethernet interface NCCL bootstraps over. It
defaults to the interface holding MgmtAddr, then eth0.
### `fabrics`
`[]inventory.FabricAttachment`
Fabrics labels this node's HCAs. It is explicit on purpose: OpenSM's
default subnet prefix is the same on every fabric, so nothing can be
inferred from the wire (plan §5.2).
### `heartbeat_interval`
`time.Duration`
### `reconnect_base`
`time.Duration`
ReconnectBase is the first reconnect delay; it backs off from there.
### `reconnect_max`
`time.Duration`
### `adopt_ready_timeout`
`time.Duration`
AdoptReadyTimeout bounds how long an adopted engine is given to answer a
health check before the agent stops waiting on it.
Shorter than a startup timeout on purpose: this engine is already
running, so it is not loading a model, it is either serving or it is not.
### `tls`
`tlsconf.Config`
TLS authenticates both ends of the control-plane stream. Leaving it unset
keeps the plaintext behaviour, which only belongs on a trusted network.
Takes a [tlsconf.Config](#tlsconfconfig) block.
### `model_dirs`
`[]string`
ModelDirs are the directories this node keeps model weights in, beyond
WeightsCache, which is always looked in. Storage differs at every installation,
so llmgw observes it rather than owning it: a Lustre mount, an image
exported over the fabric, local NVMe and a copy made by hand all look
identical from here, and placement only needs to know whether the file
is present.
### `engines_dir`
`string`
EnginesDir is where the agent installs engines, one directory per
build. Defaults to /opt/llmgw/engines.
### `weights_cache`
`string`
WeightsCache is where a remote source is downloaded to. Defaults to the
first entry of ModelDirs, because a node that says where its models live
has already answered this question, and otherwise to
/var/lib/llmgw-weights.
Fetching is the simple arrangement: every node keeps its own copy. The
exotic ones need no code — weights on a shared filer or an image exported
over the fabric are simply present, and present means nothing is fetched.
### `discovery`
`discovery.Options`
Takes a [Options](#options) block.
### `engines`
`engines.Config`
Takes a [engines.Config](#enginesconfig) block.
### `supervisor`
`supervisor.Config`
Takes a [supervisor.Config](#supervisorconfig) block.
## config.Band
### `name`
`string`
### `priority`
`int`
### `saturation_ceiling`
`float64`
## config.Price
### `prompt_per_1k`
`int64`
### `completion_per_1k`
`int64`
## engines.Config
### `disabled`
`[]string`
Disabled are engines this node never installs or advertises, whatever
the control plane asks: vLLM on a node of cards no build of it runs on,
that is meant for llama.cpp alone.
### `vllm`
`VLLMConfig`
Takes a [VLLMConfig](#vllmconfig) block.
### `sglang`
`SGLangConfig`
Takes a [SGLangConfig](#sglangconfig) block.
### `llamacpp`
`LlamaCppConfig`
Takes a [LlamaCppConfig](#llamacppconfig) block.
### `sim`
`SimConfig`
Takes a [SimConfig](#simconfig) block.
## supervisor.Config
### `work_dir`
`string`
WorkDir holds pid files and per-shard scratch space.
### `log_lines`
`int`
LogLines is how many lines of engine output to retain per shard.
### `stop_timeout`
`time.Duration`
StopTimeout is how long a process gets to exit after SIGTERM before it is
killed. Engines need time to free GPU memory cleanly.
### `health_interval`
`time.Duration`
HealthInterval is how often a starting shard is polled.
### `rendezvous_timeout`
`time.Duration`
RendezvousTimeout bounds the wait for a multi-node rendezvous point to
start listening.
### `alive_grace`
`time.Duration`
AliveGrace is how long a non-serving rank must stay up before it counts
as ready. There is no external signal for a worker, so staying alive is
the only evidence available.
## tlsconf.Config
### `ca`
`string`
CA is the bundle used to verify the other side. In a real deployment this
is whichever authority issued the fleet's certificates.
### `cert`
`string`
Cert and Key are this component's own identity.
### `key`
`string`
### `insecure`
`bool`
Insecure disables TLS entirely. It exists for local runs against
sim-engines; anything using it must stay on a trusted network.
+18 -2
View File
@@ -19,6 +19,11 @@ require (
github.com/prometheus/common v0.71.0
github.com/spf13/cobra v1.10.2
github.com/yuin/goldmark v1.8.6
go.opentelemetry.io/otel v1.46.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0
go.opentelemetry.io/otel/sdk v1.46.0
go.opentelemetry.io/otel/trace v1.46.0
go.yaml.in/yaml/v3 v3.0.5
golang.org/x/crypto v0.57.0
golang.org/x/net v0.59.0
golang.org/x/sync v0.23.0
@@ -29,6 +34,7 @@ require (
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
@@ -39,13 +45,17 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.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/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
@@ -61,9 +71,15 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
go.opentelemetry.io/otel/metric v1.46.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/grpc v1.83.1 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
+41 -2
View File
@@ -6,6 +6,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
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/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -34,14 +36,23 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
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/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
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/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
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=
@@ -117,6 +128,24 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM=
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
@@ -137,10 +166,20 @@ golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
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=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI=
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
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=
+11
View File
@@ -316,6 +316,17 @@ func (a *Accountant) Record(res Reservation, u store.Usage) {
}
}
// RecordRefused queues the row of a request a limit refused: nothing is
// charged — it used nothing — but it is counted, so a team running into its
// limits shows as one rather than as a team that went quiet.
func (a *Accountant) RecordRefused(u store.Usage) {
if u.KeyID == "" || a.recorder == nil {
return
}
u.PromptTokens, u.CompletionTokens, u.CostMicros = 0, 0, 0
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 == "" {
+131 -821
View File
File diff suppressed because it is too large. Load diff
+286
View File
@@ -0,0 +1,286 @@
package agent
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"slices"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
// Config configures the agent.
type Config struct {
// NodeID defaults to the hostname. It must equal the common name of the
// agent's certificate, so set it only where the two differ.
NodeID string `koanf:"node_id"`
// Server is the control plane, and Servers is the same thing when there is
// more than one. Both are accepted: every configuration written before
// there could be a second control plane names one address, and none of
// them should have to change.
//
// The order is tried in order, and a refusal moves to the next. Only the
// leader accepts an agent, so a standby is skipped in the time it takes to
// be told so — which is what makes failover a reconnect rather than an
// edit to every node's configuration.
Server string `koanf:"server"`
Servers []string `koanf:"servers"`
// Hostname defaults to the machine's.
Hostname string `koanf:"hostname"`
// MgmtAddr is the address engines are reached on. It defaults to this
// machine's address on the route to the control plane, which is the one
// the control plane and the router can reach.
MgmtAddr string `koanf:"mgmt_addr"`
// Labels are free-form facts about a node for a profile's node_selector.
// Nothing needs them: memory, GPUs, engines and disk are measured, and a
// label restating one of those goes stale the day the machine changes.
Labels map[string]string `koanf:"labels"`
// MgmtInterface is the ethernet interface NCCL bootstraps over. It
// defaults to the interface holding MgmtAddr, then eth0.
MgmtInterface string `koanf:"mgmt_interface"`
// Fabrics labels this node's HCAs. It is explicit on purpose: OpenSM's
// default subnet prefix is the same on every fabric, so nothing can be
// inferred from the wire (plan §5.2).
Fabrics []inventory.FabricAttachment `koanf:"fabrics"`
HeartbeatInterval time.Duration `koanf:"heartbeat_interval"`
// ReconnectBase is the first reconnect delay; it backs off from there.
ReconnectBase time.Duration `koanf:"reconnect_base"`
ReconnectMax time.Duration `koanf:"reconnect_max"`
// AdoptReadyTimeout bounds how long an adopted engine is given to answer a
// health check before the agent stops waiting on it.
//
// Shorter than a startup timeout on purpose: this engine is already
// running, so it is not loading a model, it is either serving or it is not.
AdoptReadyTimeout time.Duration `koanf:"adopt_ready_timeout"`
// TLS authenticates both ends of the control-plane stream. Leaving it unset
// keeps the plaintext behaviour, which only belongs on a trusted network.
TLS tlsconf.Config `koanf:"tls"`
// ModelDirs are the directories this node keeps model weights in, beyond
// WeightsCache, which is always looked in. Storage differs at every installation,
// so llmgw observes it rather than owning it: a Lustre mount, an image
// exported over the fabric, local NVMe and a copy made by hand all look
// identical from here, and placement only needs to know whether the file
// is present.
ModelDirs []string `koanf:"model_dirs"`
// EnginesDir is where the agent installs engines, one directory per
// build. Defaults to /opt/llmgw/engines.
EnginesDir string `koanf:"engines_dir"`
// WeightsCache is where a remote source is downloaded to. Defaults to the
// first entry of ModelDirs, because a node that says where its models live
// has already answered this question, and otherwise to
// /var/lib/llmgw-weights.
//
// Fetching is the simple arrangement: every node keeps its own copy. The
// exotic ones need no code — weights on a shared filer or an image exported
// over the fabric are simply present, and present means nothing is fetched.
WeightsCache string `koanf:"weights_cache"`
Discovery discovery.Options `koanf:"discovery"`
Engines engines.Config `koanf:"engines"`
Superv supervisor.Config `koanf:"supervisor"`
Version string
}
// DefaultConfig returns sensible defaults.
func DefaultConfig() Config {
// No MgmtInterface: normalize finds the one holding the management
// address, and falls back to eth0 only when it cannot. Defaulted here, it
// was never found — every node said eth0 unless told otherwise, and a node
// on ens3 or bond0 had NCCL bootstrap over an interface it does not have.
return Config{
HeartbeatInterval: 5 * time.Second,
ReconnectBase: time.Second,
ReconnectMax: 30 * time.Second,
AdoptReadyTimeout: 60 * time.Second,
Superv: supervisor.DefaultConfig(),
// NVML gives NVLink topology, so prefer it when this build has it.
Discovery: discovery.Options{PreferNVML: true},
}
}
func (c *Config) normalize() error {
d := DefaultConfig()
if err := c.TLS.Validate(); err != nil {
return err
}
if err := c.normalizeServerURL(); err != nil {
return err
}
// Everything below is found rather than written. A file that has to say
// which machine it is on, and at what address, is a file that differs on
// every node — and a copy taken from one node to the next registers the
// second as the first.
host, _ := os.Hostname()
if c.NodeID == "" {
c.NodeID = host
}
if c.NodeID == "" {
return errors.New("node_id is not set and the hostname could not be read")
}
if c.Hostname == "" {
c.Hostname = host
}
if c.MgmtAddr == "" {
c.MgmtAddr = addrToward(c.Server)
}
if c.MgmtInterface == "" {
c.MgmtInterface = interfaceOf(c.MgmtAddr)
}
if c.MgmtInterface == "" {
c.MgmtInterface = "eth0"
}
// The directory downloads land in is always one that is looked in. When
// they were two settings, a node could fill its disk with fetched weights
// that placement never saw — which is what every node here was doing.
if c.WeightsCache == "" && len(c.ModelDirs) > 0 {
c.WeightsCache = c.ModelDirs[0]
}
if c.WeightsCache == "" {
c.WeightsCache = DefaultWeightsCache
}
if !slices.Contains(c.ModelDirs, c.WeightsCache) {
c.ModelDirs = append(c.ModelDirs, c.WeightsCache)
}
if c.HeartbeatInterval <= 0 {
c.HeartbeatInterval = d.HeartbeatInterval
}
if c.ReconnectBase <= 0 {
c.ReconnectBase = d.ReconnectBase
}
if c.AdoptReadyTimeout <= 0 {
c.AdoptReadyTimeout = d.AdoptReadyTimeout
}
if c.ReconnectMax < c.ReconnectBase {
c.ReconnectMax = max(c.ReconnectBase, d.ReconnectMax)
}
return nil
}
// normalizeServerURL makes the configured address a URL connect can use, and
// refuses the combination that would quietly undo the certificates: a scheme
// that says plaintext while TLS is configured. Silently connecting anyway would
// mean a cluster believing it has mutual authentication when it has none.
func (c *Config) normalizeServerURL() error {
// One list, however it was written. Server first when both are given,
// because an operator adding a standby to an existing file expects the
// one already there to stay preferred.
all := make([]string, 0, 1+len(c.Servers))
if c.Server != "" {
all = append(all, c.Server)
}
all = append(all, c.Servers...)
seen := map[string]struct{}{}
out := make([]string, 0, len(all))
for _, s := range all {
fixed, err := normalizeOne(s, c.TLS.Enabled())
if err != nil {
return err
}
// A duplicate would be tried twice in a row on failover, which reads
// as the rotation not working.
if _, dup := seen[fixed]; dup {
continue
}
seen[fixed] = struct{}{}
out = append(out, fixed)
}
if len(out) == 0 {
return errors.New("server is required")
}
c.Servers = out
c.Server = out[0]
return nil
}
// DefaultWeightsCache is where fetched weights go when nothing says otherwise.
const DefaultWeightsCache = "/var/lib/llmgw-weights"
// addrToward is this machine's address on the route to the control plane.
//
// The address engines are reached on is whichever one the control plane can
// reach, and the kernel already knows which that is: a UDP "connection" picks
// the source address for the route without sending a packet. Asking the
// interfaces instead would have to guess among docker bridges, VPNs and
// link-local addresses.
func addrToward(server string) string {
u, err := url.Parse(server)
if err != nil || u.Host == "" {
return ""
}
host := u.Hostname()
port := u.Port()
if port == "" {
port = "443"
}
conn, err := net.Dial("udp", net.JoinHostPort(host, port))
if err != nil {
return ""
}
defer conn.Close()
if a, ok := conn.LocalAddr().(*net.UDPAddr); ok {
return a.IP.String()
}
return ""
}
// interfaceOf names the interface that holds an address, or "" when none does.
func interfaceOf(addr string) string {
ip := net.ParseIP(addr)
if ip == nil {
return ""
}
ifaces, err := net.Interfaces()
if err != nil {
return ""
}
for _, ifc := range ifaces {
addrs, err := ifc.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok && n.IP.Equal(ip) {
return ifc.Name
}
}
}
return ""
}
// normalizeOne applies the scheme rules to a single address.
func normalizeOne(addr string, secure bool) (string, error) {
switch {
case strings.HasPrefix(addr, "https://"):
if !secure {
return "", fmt.Errorf("%s is an https:// address but no tls certificates are configured", addr)
}
case strings.HasPrefix(addr, "http://"):
if secure {
return "", fmt.Errorf("tls is configured but %s is an http:// address; use https://", addr)
}
case strings.Contains(addr, "://"):
return "", fmt.Errorf("server %q must be an http:// or https:// address", addr)
case secure:
return "https://" + addr, nil
default:
return "http://" + addr, nil
}
return addr, nil
}
+77
View File
@@ -0,0 +1,77 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"slices"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
)
// controlPlanesFile keeps the control planes the leader last named, beside
// the agent's configuration rather than in it: the configuration is the
// operator's, and a list rewritten in it would be a file nobody wrote.
var controlPlanesFile = "/var/lib/llmgw-agent/control-planes.json"
// servers is every address to dial, in the order to try them: the
// configuration's, then any the leader named that it lacks.
func (a *Agent) servers() []string {
a.mu.Lock()
defer a.mu.Unlock()
if len(a.serverList) == 0 {
return a.cfg.Servers
}
return a.serverList
}
// mergeServers puts the leader's list after the configured one, without
// repeats, and says whether that changed anything.
func mergeServers(configured, told []string, secure bool) []string {
out := slices.Clone(configured)
for _, s := range told {
fixed, err := normalizeOne(s, secure)
if err != nil {
continue
}
if !slices.Contains(out, fixed) {
out = append(out, fixed)
}
}
return out
}
// setControlPlanes takes the leader's list: used from the next reconnect,
// and kept for the next start.
func (a *Agent) setControlPlanes(m *llmgwv1.ControlPlanes) {
told := m.GetServers()
merged := mergeServers(a.cfg.Servers, told, a.cfg.TLS.Enabled())
a.mu.Lock()
changed := !slices.Equal(merged, a.serverList)
a.serverList = merged
a.mu.Unlock()
if !changed {
return
}
a.log.Info("the control planes to dial", "servers", merged)
b, _ := json.Marshal(told)
if err := os.MkdirAll(filepath.Dir(controlPlanesFile), 0o755); err == nil {
tmp := controlPlanesFile + ".new"
if err := os.WriteFile(tmp, b, 0o644); err == nil {
_ = os.Rename(tmp, controlPlanesFile)
}
}
}
// loadControlPlanes is the list kept from the last time the leader named it.
func loadControlPlanes(configured []string, secure bool) []string {
b, err := os.ReadFile(controlPlanesFile)
if err != nil {
return configured
}
var told []string
if json.Unmarshal(b, &told) != nil {
return configured
}
return mergeServers(configured, told, secure)
}
+41
View File
@@ -0,0 +1,41 @@
package agent
import (
"log/slog"
"path/filepath"
"slices"
"testing"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
func TestAControlPlaneTheLeaderNamesIsDialledAndRemembered(t *testing.T) {
old := controlPlanesFile
controlPlanesFile = filepath.Join(t.TempDir(), "control-planes.json")
t.Cleanup(func() { controlPlanesFile = old })
configured := []string{"https://10.0.0.140:7443"}
// TLS on, as every onboarded node has it: an agent without it refuses
// https:// addresses, and the leader's are https://.
a := &Agent{cfg: Config{Servers: configured, TLS: tlsconf.Config{CA: "/etc/llmgw/ca.pem"}}, log: slog.New(slog.DiscardHandler)}
if got := a.servers(); !slices.Equal(got, configured) {
t.Fatalf("before anything is named: %v", got)
}
// The configured one stays first; the new one is added after it, once.
a.setControlPlanes(&llmgwv1.ControlPlanes{Servers: []string{"https://10.0.0.140:7443", "https://10.0.0.150:7443"}})
want := []string{"https://10.0.0.140:7443", "https://10.0.0.150:7443"}
if got := a.servers(); !slices.Equal(got, want) {
t.Errorf("after the leader named two: %v", got)
}
// And after a restart, before the leader says anything.
if got := loadControlPlanes(configured, true); !slices.Equal(got, want) {
t.Errorf("after a restart: %v", got)
}
// A list that drops the configured one does not drop it: the
// configuration is the operator's.
a.setControlPlanes(&llmgwv1.ControlPlanes{Servers: []string{"https://10.0.0.150:7443"}})
if got := a.servers(); got[0] != "https://10.0.0.140:7443" {
t.Errorf("the configured server was dropped: %v", got)
}
}
+32
View File
@@ -0,0 +1,32 @@
package agent
import (
"testing"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
)
func TestAnEngineDisabledOnTheNodeIsNeitherInstalledNorAdvertised(t *testing.T) {
a := &Agent{}
a.cfg.Engines.Disabled = []string{"vllm"}
a.setProvision(&llmgwv1.Provision{Engines: []*llmgwv1.EngineWant{
{Engine: "llamacpp", Version: "b1"}, {Engine: "vllm", Version: "0.30.0"},
}})
if len(a.provWants) != 1 || a.provWants[0].Engine != "llamacpp" {
t.Errorf("wants %+v: vLLM is disabled here and must not be installed", a.provWants)
}
a.configuredVers = map[string]string{"vllm": "0.25.0"}
a.installedVers = map[string]string{"llamacpp": "b1"}
if got := a.engineReportLocked(); got["vllm"] != "" || got["llamacpp"] != "b1" {
t.Errorf("advertised %v: a disabled engine found on disk is still not offered", got)
}
}
func TestAnEngineNamedByHandIsNotInstalledOverIt(t *testing.T) {
a := &Agent{reg: engines.NewRegistry(engines.Config{LlamaCpp: engines.LlamaCppConfig{Bin: "/opt/own/llama-server"}})}
a.setProvision(&llmgwv1.Provision{Engines: []*llmgwv1.EngineWant{{Engine: "llamacpp", Version: "b1"}}})
if len(a.provWants) != 0 {
t.Errorf("wants %+v: an installed build would be used over the operator's own", a.provWants)
}
}
+27 -5
View File
@@ -106,9 +106,27 @@ func parseSMI(out string) ([]GPU, error) {
return gpus, nil
}
// residentMemoryToleranceMB is how much allocated memory is written off as
// ForeignMemoryToleranceMB is how much allocated memory is written off as
// driver overhead rather than a foreign process.
const residentMemoryToleranceMB = 512
const ForeignMemoryToleranceMB = 512
// foreignMemorySuffix ends the problem a card with another tenant on it is
// kept out of placement for. One phrase, so that the control plane can find
// it again and drop it when the tenant leaves.
const foreignMemorySuffix = " MiB already allocated by another process"
// ForeignMemoryProblem is the problem for mb of memory held by something
// that is not one of this agent's engines, or "" when that is too little to
// count.
func ForeignMemoryProblem(mb int) string {
if mb <= ForeignMemoryToleranceMB {
return ""
}
return fmt.Sprintf("%d%s", mb, foreignMemorySuffix)
}
// IsForeignMemoryProblem reports whether a problem is ForeignMemoryProblem's.
func IsForeignMemoryProblem(p string) bool { return strings.HasSuffix(p, foreignMemorySuffix) }
// assessHealth flags conditions that should keep a GPU out of placement.
func assessHealth(g *GPU) {
@@ -119,8 +137,12 @@ func assessHealth(g *GPU) {
// Memory already in use means something else is on the card. Starting an
// engine there would either fail to allocate or quietly evict the other
// process's throughput.
if g.MemoryUsedMB > residentMemoryToleranceMB {
g.Health.Problems = append(g.Health.Problems,
fmt.Sprintf("%d MiB already allocated by another process", g.MemoryUsedMB))
//
// Only a first reading: this runs before the agent knows which processes
// are its own, so an engine still running from before a restart reads as
// foreign here. Every heartbeat measures it again, without the agent's
// own engines, and the control plane replaces this with that.
if p := ForeignMemoryProblem(g.MemoryUsedMB); p != "" {
g.Health.Problems = append(g.Health.Problems, p)
}
}
+280
View File
@@ -0,0 +1,280 @@
package discovery
import (
"context"
"encoding/xml"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// GPUWear is what wears a card out or takes it down, beyond temperature:
// read every minute, from nvidia-smi and the kernel's log. The early signs of
// a card failing are here — a growing count of rows its memory had to remap,
// PCIe link retries, XID errors — long before a replica on it fails.
//
// A count of -1 is not reported: a card without row remapping (before
// Ampere) has no such count, which is not the same as none.
type GPUWear struct {
Index int
// PCIeReplays are link-level retries on the card's PCIe link: a few are
// noise, a count that keeps climbing is a riser, slot or cable.
PCIeReplays int64
// RetiredSBE and RetiredDBE are memory pages retired for single and
// double bit errors (cards before Ampere); RetirePending is a page
// waiting for a reset to be retired.
RetiredSBE, RetiredDBE int64
RetirePending bool
// RemappedCorrectable and RemappedUncorrectable are memory rows remapped
// (Ampere and later). RemapPending waits for a reset; RemapFailure is a
// card out of spare rows — it needs replacing.
RemappedCorrectable, RemappedUncorrectable int64
RemapPending, RemapFailure bool
// NVLink errors summed over the card's links, by kind.
NVLinkCRC, NVLinkReplay, NVLinkRecovery int64
// XIDs are the driver's error events for this card since the agent
// started, and the last of them.
XIDs int64
LastXID int
LastXIDText string
LastXIDAt time.Time
}
// healthEvery is how often health is read: it changes over hours, and the
// kernel log and nvidia-smi's full report are the heavier reads.
const healthEvery = time.Minute
// HealthSampler reads GPUWear, keeping the XID counts between reads.
type HealthSampler struct {
run func(ctx context.Context, name string, args ...string) ([]byte, error)
now func() time.Time
mu sync.Mutex
at time.Time
last []GPUWear
since time.Time
xids map[int]*GPUWear
started bool
}
// NewHealthSampler reads the machine's own nvidia-smi and kernel log.
func NewHealthSampler() *HealthSampler {
return &HealthSampler{now: time.Now, xids: map[int]*GPUWear{},
run: func(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).Output() //nolint:gosec // fixed tools
}}
}
// Sample is the cards' health, read again once healthEvery has passed. Nil
// on a machine without nvidia-smi.
func (h *HealthSampler) Sample(ctx context.Context) []GPUWear {
h.mu.Lock()
defer h.mu.Unlock()
now := h.now()
if !h.at.IsZero() && now.Sub(h.at) < healthEvery {
return h.last
}
h.at = now
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
report, err := h.run(ctx, "nvidia-smi", "-q", "-x")
if err != nil {
h.last = nil
return nil
}
cards, bus := parseHealthReport(report)
if out, err := h.run(ctx, "nvidia-smi", "nvlink", "-e"); err == nil {
for i, e := range parseNVLinkErrors(string(out)) {
if i < len(cards) {
cards[i].NVLinkCRC, cards[i].NVLinkReplay, cards[i].NVLinkRecovery = e[0], e[1], e[2]
}
}
}
h.readXIDs(ctx, now, bus)
for i := range cards {
if x := h.xids[i]; x != nil {
cards[i].XIDs, cards[i].LastXID, cards[i].LastXIDText, cards[i].LastXIDAt = x.XIDs, x.LastXID, x.LastXIDText, x.LastXIDAt
}
}
h.last = cards
return cards
}
// readXIDs adds the XID events logged since the last read. The first read
// starts from now: what happened before the agent started was before, and
// counting the whole log would raise every alert on every restart.
func (h *HealthSampler) readXIDs(ctx context.Context, now time.Time, bus map[string]int) {
if !h.started {
h.started, h.since = true, now
return
}
since := h.since
h.since = now
out, err := h.run(ctx, "journalctl", "-k", "--no-pager", "-o", "short-unix",
"--since", "@"+strconv.FormatInt(since.Unix(), 10), "-g", "NVRM: Xid")
if err != nil {
// No journal, or nothing matched (journalctl exits 1 for that).
return
}
for _, e := range parseXIDs(string(out)) {
i, ok := bus[e.bus]
if !ok {
continue
}
x := h.xids[i]
if x == nil {
x = &GPUWear{Index: i}
h.xids[i] = x
}
x.XIDs++
x.LastXID, x.LastXIDText = e.code, e.text
x.LastXIDAt = now
if e.at > 0 {
x.LastXIDAt = time.Unix(int64(e.at), 0)
}
}
}
// healthLog is the part of nvidia-smi's XML report health is read from.
type healthLog struct {
GPUs []struct {
ID string `xml:"id,attr"`
PCI struct {
Replay string `xml:"replay_counter"`
} `xml:"pci"`
Retired struct {
SBE string `xml:"multiple_single_bit_retirement>retired_count"`
DBE string `xml:"double_bit_retirement>retired_count"`
Pending string `xml:"pending_retirement"`
// Older drivers name it pending_blacklist.
Blacklist string `xml:"pending_blacklist"`
} `xml:"retired_pages"`
Remapped struct {
Corr string `xml:"remapped_row_corr"`
Unc string `xml:"remapped_row_unc"`
Pending string `xml:"remapped_row_pending"`
Failure string `xml:"remapped_row_failure"`
} `xml:"remapped_rows"`
} `xml:"gpu"`
}
// parseHealthReport reads each card's health, in index order, and maps each
// card's PCI bus ("3b:00", as the kernel names it) to its index.
func parseHealthReport(report []byte) ([]GPUWear, map[string]int) {
var log healthLog
if err := xml.Unmarshal(report, &log); err != nil {
return nil, nil
}
bus := map[string]int{}
out := make([]GPUWear, 0, len(log.GPUs))
for i, g := range log.GPUs {
bus[busOf(g.ID)] = i
out = append(out, GPUWear{
Index: i,
PCIeReplays: count(g.PCI.Replay),
RetiredSBE: count(g.Retired.SBE),
RetiredDBE: count(g.Retired.DBE),
RetirePending: yes(g.Retired.Pending) || yes(g.Retired.Blacklist),
RemappedCorrectable: count(g.Remapped.Corr),
RemappedUncorrectable: count(g.Remapped.Unc),
RemapPending: yes(g.Remapped.Pending),
RemapFailure: yes(g.Remapped.Failure),
})
}
return out, bus
}
// busOf is a PCI address as the kernel's XID lines carry it: nvidia-smi
// says "00000000:3B:00.0", the kernel "0000:3b:00".
func busOf(id string) string {
id = strings.ToLower(strings.TrimSpace(id))
if i := strings.LastIndex(id, "."); i >= 0 {
id = id[:i]
}
parts := strings.Split(id, ":")
if len(parts) >= 2 {
return parts[len(parts)-2] + ":" + parts[len(parts)-1]
}
return id
}
func count(s string) int64 {
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
if err != nil {
return -1
}
return n
}
func yes(s string) bool { return strings.EqualFold(strings.TrimSpace(s), "yes") }
// nvlinkError matches a line of `nvidia-smi nvlink -e`, whose wording varies
// by driver: "Link 0: Replay Errors: 0", "Link 0: CRC Errors: 0",
// "Link 3: Data CRC Errors: 2".
var (
nvlinkGPU = regexp.MustCompile(`^GPU (\d+):`)
nvlinkError = regexp.MustCompile(`Link \d+:\s*(.*?)\s*Errors?\s*:\s*(\d+)`)
)
// parseNVLinkErrors sums each card's link errors by kind: CRC, replay,
// recovery.
func parseNVLinkErrors(out string) map[int][3]int64 {
res := map[int][3]int64{}
gpu := -1
for _, line := range strings.Split(out, "\n") {
if m := nvlinkGPU.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
gpu, _ = strconv.Atoi(m[1])
continue
}
m := nvlinkError.FindStringSubmatch(line)
if m == nil || gpu < 0 {
continue
}
n, _ := strconv.ParseInt(m[2], 10, 64)
e := res[gpu]
kind := strings.ToLower(m[1])
switch {
case strings.Contains(kind, "crc"):
e[0] += n
case strings.Contains(kind, "replay"):
e[1] += n
case strings.Contains(kind, "recovery"):
e[2] += n
}
res[gpu] = e
}
return res
}
type xidEvent struct {
bus string
code int
text string
at float64
}
// xidLine matches the driver's report of an XID:
// "NVRM: Xid (PCI:0000:3b:00): 79, pid=..., GPU has fallen off the bus."
var xidLine = regexp.MustCompile(`NVRM: Xid \(PCI:([0-9a-fA-F:.]+)\): (\d+),\s*(.*)$`)
// parseXIDs reads journalctl's short-unix lines: a timestamp, then the
// message.
func parseXIDs(out string) []xidEvent {
var evs []xidEvent
for _, line := range strings.Split(out, "\n") {
m := xidLine.FindStringSubmatch(line)
if m == nil {
continue
}
code, _ := strconv.Atoi(m[2])
e := xidEvent{bus: busOf(m[1]), code: code, text: strings.TrimSpace(m[3])}
if f := strings.Fields(line); len(f) > 0 {
e.at, _ = strconv.ParseFloat(f[0], 64)
}
evs = append(evs, e)
}
return evs
}
+101
View File
@@ -0,0 +1,101 @@
package discovery
import (
"context"
"errors"
"strings"
"testing"
"time"
)
const healthXML = `<?xml version="1.0" ?>
<nvidia_smi_log>
<gpu id="00000000:3B:00.0">
<pci><replay_counter>0</replay_counter></pci>
<retired_pages>
<multiple_single_bit_retirement><retired_count>N/A</retired_count></multiple_single_bit_retirement>
<double_bit_retirement><retired_count>N/A</retired_count></double_bit_retirement>
<pending_retirement>N/A</pending_retirement>
</retired_pages>
<remapped_rows>
<remapped_row_corr>2</remapped_row_corr>
<remapped_row_unc>1</remapped_row_unc>
<remapped_row_pending>Yes</remapped_row_pending>
<remapped_row_failure>No</remapped_row_failure>
</remapped_rows>
</gpu>
<gpu id="00000000:5E:00.0">
<pci><replay_counter>4021</replay_counter></pci>
<retired_pages>
<multiple_single_bit_retirement><retired_count>3</retired_count></multiple_single_bit_retirement>
<double_bit_retirement><retired_count>1</retired_count></double_bit_retirement>
<pending_blacklist>Yes</pending_blacklist>
</retired_pages>
<remapped_rows>N/A</remapped_rows>
</gpu>
</nvidia_smi_log>`
const nvlinkOut = `GPU 0: NVIDIA H100 80GB HBM3 (UUID: GPU-aaa)
Link 0: Replay Errors: 1
Link 0: Recovery Errors: 0
Link 0: CRC Errors: 5
Link 1: Data CRC Errors: 2
GPU 1: NVIDIA H100 80GB HBM3 (UUID: GPU-bbb)
Link 0: Replay Errors: 0
`
func TestAGPUsWearAndFailuresAreReadAndCountedFromWhenTheAgentStarted(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
journal := ""
h := &HealthSampler{now: func() time.Time { return now }, xids: map[int]*GPUWear{},
run: func(_ context.Context, name string, args ...string) ([]byte, error) {
switch {
case name == "nvidia-smi" && args[0] == "-q":
return []byte(healthXML), nil
case name == "nvidia-smi" && args[0] == "nvlink":
return []byte(nvlinkOut), nil
case name == "journalctl":
if journal == "" {
return nil, errors.New("exit status 1")
}
return []byte(journal), nil
}
return nil, errors.New("unexpected " + name)
}}
got := h.Sample(t.Context())
if len(got) != 2 {
t.Fatalf("%+v", got)
}
a, b := got[0], got[1]
if a.RemappedCorrectable != 2 || a.RemappedUncorrectable != 1 || !a.RemapPending || a.RemapFailure ||
a.RetiredSBE != -1 || a.PCIeReplays != 0 || a.NVLinkCRC != 7 || a.NVLinkReplay != 1 {
t.Errorf("card 0 %+v", a)
}
if b.PCIeReplays != 4021 || b.RetiredSBE != 3 || b.RetiredDBE != 1 || !b.RetirePending || b.RemappedCorrectable != -1 {
t.Errorf("card 1 %+v", b)
}
// An XID on card 1, logged after the agent started; read on the next
// sample a minute on.
journal = "1800000030.123456 gpu07 kernel: NVRM: Xid (PCI:0000:5e:00): 79, pid=1234, name=llama-server, GPU has fallen off the bus.\n"
now = now.Add(61 * time.Second)
got = h.Sample(t.Context())
if got[1].XIDs != 1 || got[1].LastXID != 79 || !strings.Contains(got[1].LastXIDText, "fallen off the bus") ||
got[1].LastXIDAt.Unix() != 1_800_000_030 || got[0].XIDs != 0 {
t.Errorf("after an XID: %+v / %+v", got[0], got[1])
}
// Within the minute, the last reading.
journal = ""
now = now.Add(10 * time.Second)
if again := h.Sample(t.Context()); again[1].XIDs != 1 {
t.Errorf("%+v", again[1])
}
}
func TestBusIDsAreReadAsTheKernelWritesThem(t *testing.T) {
for in, want := range map[string]string{"00000000:3B:00.0": "3b:00", "0000:3b:00": "3b:00", "0000:5e:00.0": "5e:00"} {
if got := busOf(in); got != want {
t.Errorf("%s: %s, want %s", in, got, want)
}
}
}
+120
View File
@@ -0,0 +1,120 @@
package discovery
import (
"context"
"encoding/xml"
"fmt"
"os/exec"
"strconv"
"strings"
)
// GPUProcesses reports which processes hold memory on each GPU, by the GPU's
// nvidia-smi index: what a process actually computes on, as opposed to what it
// was told to use. A llama.cpp started with --n-gpu-layers on a GPU whose
// backend did not load runs on the CPU without a word, and this is where that
// shows — the process is on no GPU's list.
//
// From the full query rather than --query-compute-apps, which lists compute
// processes only: llama.cpp's Vulkan build is listed as a graphics one, and
// would have read as not on its GPU at all.
//
// A node with no nvidia-smi returns nil and no error, as discovery does.
func GPUProcesses(ctx context.Context, binary string) (map[int][]int, error) {
if binary == "" {
if _, err := exec.LookPath("nvidia-smi"); err != nil {
return nil, nil
}
binary = "nvidia-smi"
}
out, err := exec.CommandContext(ctx, binary, "-q", "-x").Output()
if err != nil {
return nil, fmt.Errorf("running %s: %w", binary, err)
}
return parseGPUProcesses(out)
}
// GPUMemory is one card's memory as nvidia-smi reports it at a moment: what
// is allocated on it, and which processes hold how much of that.
type GPUMemory struct {
UsedMB int
Procs []GPUProcess
}
// GPUProcess is one process holding memory on a card.
type GPUProcess struct {
PID int
UsedMB int
}
// GPUMemoryByProcess reports each GPU's memory, by index, with the processes
// holding it — from the same report as GPUProcesses, so the two never
// disagree about who is on a card.
//
// A node with no nvidia-smi returns nil and no error, as discovery does.
func GPUMemoryByProcess(ctx context.Context, binary string) ([]GPUMemory, error) {
if binary == "" {
if _, err := exec.LookPath("nvidia-smi"); err != nil {
return nil, nil
}
binary = "nvidia-smi"
}
out, err := exec.CommandContext(ctx, binary, "-q", "-x").Output()
if err != nil {
return nil, fmt.Errorf("running %s: %w", binary, err)
}
return parseGPUMemory(out)
}
// smiLog is the part of nvidia-smi's XML report this reads: each GPU, in
// index order, its memory and the processes on it.
type smiLog struct {
GPUs []struct {
Used string `xml:"fb_memory_usage>used"`
Processes []struct {
PID string `xml:"pid"`
Type string `xml:"type"`
UsedMB string `xml:"used_memory"`
} `xml:"processes>process_info"`
} `xml:"gpu"`
}
// parseGPUProcesses reads the report. GPUs are listed in index order, which
// is nvidia-smi's own numbering — the one placement assigns by.
func parseGPUProcesses(report []byte) (map[int][]int, error) {
mem, err := parseGPUMemory(report)
if err != nil {
return nil, err
}
out := map[int][]int{}
for i, g := range mem {
for _, p := range g.Procs {
out[i] = append(out[i], p.PID)
}
}
return out, nil
}
// parseGPUMemory reads the report into each GPU's memory and its holders.
func parseGPUMemory(report []byte) ([]GPUMemory, error) {
var log smiLog
if err := xml.Unmarshal(report, &log); err != nil {
return nil, fmt.Errorf("reading nvidia-smi's report: %w", err)
}
out := make([]GPUMemory, len(log.GPUs))
for i, g := range log.GPUs {
out[i].UsedMB = mib(g.Used)
for _, p := range g.Processes {
if pid, err := strconv.Atoi(strings.TrimSpace(p.PID)); err == nil {
out[i].Procs = append(out[i].Procs, GPUProcess{PID: pid, UsedMB: mib(p.UsedMB)})
}
}
}
return out, nil
}
// mib reads "4932 MiB"; anything else, "N/A" included, is zero.
func mib(s string) int {
n, _ := strconv.Atoi(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), "MiB")))
return n
}
+78
View File
@@ -0,0 +1,78 @@
package discovery
import (
"reflect"
"testing"
)
func TestGPUProcessesOfEveryKindAreListedByIndex(t *testing.T) {
report := `<?xml version="1.0" ?>
<nvidia_smi_log>
<driver_version>580.65.06</driver_version>
<gpu id="00000000:17:00.0"><product_name>Tesla V100-SXM2-32GB</product_name>
<processes></processes>
</gpu>
<gpu id="00000000:65:00.0"><product_name>Tesla V100-SXM2-32GB</product_name>
<processes>
<process_info><pid>4100</pid><type>C</type><process_name>llama-server</process_name></process_info>
<process_info><pid>4300</pid><type>G</type><process_name>llama-server</process_name></process_info>
</processes>
</gpu>
<gpu id="00000000:B3:00.0"><processes>
<process_info><pid>4200</pid><type>C+G</type></process_info>
</processes></gpu>
</nvidia_smi_log>`
got, err := parseGPUProcesses([]byte(report))
if err != nil {
t.Fatal(err)
}
// A Vulkan engine is a graphics process ("G") and is on its GPU all the
// same; a compute-only query would have missed it.
want := map[int][]int{1: {4100, 4300}, 2: {4200}}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
if _, err := parseGPUProcesses([]byte("not xml")); err == nil {
t.Error("a report that does not parse was read as no processes")
}
}
func TestGPUMemoryIsReadPerProcess(t *testing.T) {
report := `<?xml version="1.0" ?>
<nvidia_smi_log>
<gpu id="00000000:01:00.0"><product_name>Quadro RTX 6000</product_name>
<fb_memory_usage><total>24576 MiB</total><used>14514 MiB</used><free>10062 MiB</free></fb_memory_usage>
<processes>
<process_info><pid>71315</pid><type>C</type><used_memory>4932 MiB</used_memory></process_info>
<process_info><pid>4100</pid><type>C</type><used_memory>9500 MiB</used_memory></process_info>
</processes>
</gpu>
<gpu id="00000000:02:00.0">
<fb_memory_usage><used>N/A</used></fb_memory_usage>
<processes></processes>
</gpu>
</nvidia_smi_log>`
got, err := parseGPUMemory([]byte(report))
if err != nil {
t.Fatal(err)
}
want := []GPUMemory{
{UsedMB: 14514, Procs: []GPUProcess{{PID: 71315, UsedMB: 4932}, {PID: 4100, UsedMB: 9500}}},
{},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %+v, want %+v", got, want)
}
}
func TestForeignMemoryBelowTheToleranceIsNoProblem(t *testing.T) {
// A CUDA context that has let go of its model still holds a few hundred
// MiB: ComfyUI after /free held 324.
if p := ForeignMemoryProblem(330); p != "" {
t.Errorf("330 MiB is a problem: %q", p)
}
p := ForeignMemoryProblem(5014)
if !IsForeignMemoryProblem(p) || IsForeignMemoryProblem("2 uncorrected ECC errors") {
t.Errorf("%q is not recognised as the foreign memory problem, or ECC is", p)
}
}
+287
View File
@@ -0,0 +1,287 @@
package discovery
import (
"context"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// Sensors is what a node's own hardware says about heat and power: the CPUs,
// the board where it has an inlet sensor, and each GPU. A zero is not
// measured, never a reading of zero — a VM has none of these, and must not
// look like a machine running at 0 °C on no power.
type Sensors struct {
// CPUTempC is the hottest CPU sensor: the package, or the hottest core
// where no package reading exists.
CPUTempC float64
// CPUPowerW is the CPU packages' draw, from RAPL: the change in their
// energy counters over the time between two samples.
CPUPowerW float64
// InletTempC is the air coming in, where the board has a sensor it calls
// that. Most do not, and a CPU's temperature is not a substitute: it
// follows the load, not the room.
InletTempC float64
GPUs []GPUSensor
}
// GPUSensor is one card's heat, power and whether it is slowing itself down.
type GPUSensor struct {
Index int
TempC float64
MemoryTempC float64
PowerW float64
PowerLimitW float64
// Throttle names what is holding the card's clocks down now:
// sw_power_cap, hw_slowdown, sw_thermal, hw_thermal, power_brake. Empty
// is running free. Idle and application clock settings are left out;
// they are not the card being held back.
Throttle []string
// How busy it is, read with the rest every sample. Discovery reads the
// same once, at start, which made a busy card look as idle as it was
// when the agent came up. -1 is not reported by the driver; zero
// MemoryTotalMB is a sample without these at all.
UtilPercent, MemUtilPercent float64
MemoryUsedMB, MemoryTotalMB float64
SMClockMHz float64
ECCUncorrected float64
}
// sensorEvery bounds how often nvidia-smi is run. Heartbeats come more often
// than temperatures change, and each run costs a few hundred milliseconds.
const sensorEvery = 10 * time.Second
// SensorSampler reads Sensors. The first sample has no CPU power, which needs
// two readings of the energy counters.
type SensorSampler struct {
sys string // /sys, or a test's copy of it
smi func(ctx context.Context, fields string) ([]byte, error)
now func() time.Time
mu sync.Mutex
last Sensors
lastAt time.Time
prevEnergy map[string]uint64
prevAt time.Time
}
// NewSensorSampler reads this machine.
func NewSensorSampler() *SensorSampler {
return &SensorSampler{sys: "/sys", smi: runSMI, now: time.Now}
}
func runSMI(ctx context.Context, fields string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
//nolint:gosec // fixed binary, fields from this file
return exec.CommandContext(ctx, "nvidia-smi", "--query-gpu="+fields,
"--format=csv,noheader,nounits").Output()
}
// Sample reads the sensors, or returns the last reading when that is recent.
func (s *SensorSampler) Sample(ctx context.Context) Sensors {
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
if !s.lastAt.IsZero() && now.Sub(s.lastAt) < sensorEvery {
return s.last
}
out := Sensors{}
out.CPUTempC, out.InletTempC = s.hwmon()
out.CPUPowerW = s.rapl(now)
out.GPUs = s.gpus(ctx)
s.last, s.lastAt = out, now
return out
}
// hwmon reads the hottest CPU sensor and an inlet sensor, from every hwmon
// device the kernel exposes — what lm_sensors reads, without it installed.
func (s *SensorSampler) hwmon() (cpu, inlet float64) {
devs, _ := filepath.Glob(filepath.Join(s.sys, "class/hwmon/hwmon*"))
var pkg, core float64
for _, d := range devs {
name := readTrim(filepath.Join(d, "name"))
inputs, _ := filepath.Glob(filepath.Join(d, "temp*_input"))
for _, in := range inputs {
milli, err := strconv.ParseFloat(readTrim(in), 64)
if err != nil || milli <= 0 {
continue
}
c := milli / 1000
label := strings.ToLower(readTrim(strings.TrimSuffix(in, "_input") + "_label"))
switch {
case isCPUSensor(name) && (strings.HasPrefix(label, "package") || label == "tctl" || label == "tdie"):
pkg = max(pkg, c)
case isCPUSensor(name):
core = max(core, c)
case label == "cpu":
// A board's own reading of the CPU, as Dell's SMM gives.
core = max(core, c)
case strings.Contains(label, "inlet") || strings.Contains(label, "ambient") ||
strings.Contains(label, "intake") || label == "systin":
inlet = max(inlet, c)
}
}
}
if pkg > 0 {
return pkg, inlet
}
return core, inlet
}
func isCPUSensor(name string) bool {
switch name {
case "coretemp", "k10temp", "zenpower", "cpu_thermal":
return true
}
return false
}
// rapl is the CPU packages' power from their energy counters. Package
// domains only: the dram and core subdomains are parts of a package, and
// adding them would count the same energy twice.
func (s *SensorSampler) rapl(now time.Time) float64 {
doms, _ := filepath.Glob(filepath.Join(s.sys, "class/powercap/intel-rapl:*"))
energy := map[string]uint64{}
wrap := map[string]uint64{}
for _, d := range doms {
if strings.Count(filepath.Base(d), ":") != 1 {
continue // intel-rapl:0:0 is a subdomain of intel-rapl:0
}
e, err := strconv.ParseUint(readTrim(filepath.Join(d, "energy_uj")), 10, 64)
if err != nil {
continue // unreadable without root on newer kernels
}
energy[d] = e
wrap[d], _ = strconv.ParseUint(readTrim(filepath.Join(d, "max_energy_range_uj")), 10, 64)
}
prev, prevAt := s.prevEnergy, s.prevAt
s.prevEnergy, s.prevAt = energy, now
dt := now.Sub(prevAt).Seconds()
if prev == nil || dt <= 0 || len(energy) == 0 {
return 0
}
var joules float64
for d, e := range energy {
p, ok := prev[d]
if !ok {
return 0 // a domain appeared: no fair difference this time
}
if e < p { // the counter wrapped
e += wrap[d]
}
joules += float64(e-p) / 1e6
}
return joules / dt
}
// The throttle reasons nvidia-smi reports, as bits. Only those that mean the
// card is being held back are named.
var throttleBits = []struct {
bit uint64
name string
}{
{0x4, "sw_power_cap"},
{0x8, "hw_slowdown"},
{0x20, "sw_thermal"},
{0x40, "hw_thermal"},
{0x80, "power_brake"},
}
// gpus asks nvidia-smi for each card's heat and power. The memory temperature
// is not known to every driver, and a field nvidia-smi does not know fails the
// whole query, so it is asked for first and dropped on failure.
// gpuFields are what each sample asks nvidia-smi for, in order. The busy
// figures follow the throttle reasons; the memory temperature is last, being
// the one field a driver may reject outright.
const (
gpuBase = "index,temperature.gpu,power.draw,enforced.power.limit,clocks_throttle_reasons.active"
gpuBusy = ",utilization.gpu,utilization.memory,memory.used,memory.total,clocks.sm,ecc.errors.uncorrected.volatile.total"
gpuHBMTC = ",temperature.memory"
)
func (s *SensorSampler) gpus(ctx context.Context) []GPUSensor {
// Most fields first, fewer on a driver that refuses one: a query naming
// a field the driver does not know fails as a whole.
var out []byte
var busy bool
for _, q := range []struct {
fields string
busy bool
}{{gpuBase + gpuBusy + gpuHBMTC, true}, {gpuBase + gpuBusy, true}, {gpuBase + gpuHBMTC, false}, {gpuBase, false}} {
var err error
if out, err = s.smi(ctx, q.fields); err == nil {
busy = q.busy
break
}
out = nil
}
if out == nil {
return nil
}
var gs []GPUSensor
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
f := strings.Split(line, ",")
if len(f) < 5 {
continue
}
for i := range f {
f[i] = strings.TrimSpace(f[i])
}
idx, err := strconv.Atoi(f[0])
if err != nil {
continue
}
g := GPUSensor{Index: idx, TempC: num(f[1]), PowerW: num(f[2]), PowerLimitW: num(f[3])}
if bits, err := strconv.ParseUint(strings.TrimPrefix(f[4], "0x"), 16, 64); err == nil {
for _, b := range throttleBits {
if bits&b.bit != 0 {
g.Throttle = append(g.Throttle, b.name)
}
}
}
rest := f[5:]
if busy && len(rest) >= 6 {
g.UtilPercent, g.MemUtilPercent = numOrUnknown(rest[0]), numOrUnknown(rest[1])
g.MemoryUsedMB, g.MemoryTotalMB = num(rest[2]), num(rest[3])
g.SMClockMHz, g.ECCUncorrected = num(rest[4]), numOrUnknown(rest[5])
rest = rest[6:]
}
if len(rest) > 0 {
g.MemoryTempC = num(rest[0])
}
gs = append(gs, g)
}
return gs
}
// numOrUnknown is a figure, or -1 where nvidia-smi says it has none: a card
// without ECC has no error count, which is not the same as none.
func numOrUnknown(s string) float64 {
v, err := strconv.ParseFloat(s, 64)
if err != nil || v < 0 {
return -1
}
return v
}
// num reads a figure nvidia-smi may give as "[N/A]" or "N/A".
func num(s string) float64 {
v, err := strconv.ParseFloat(s, 64)
if err != nil || v < 0 {
return 0
}
return v
}
func readTrim(path string) string {
b, err := os.ReadFile(path) //nolint:gosec // sysfs
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
+210
View File
@@ -0,0 +1,210 @@
package discovery
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func put(t *testing.T, root, rel, body string) {
t.Helper()
p := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
// dellHost is the layout of the Proxmox host these tests were written
// against: Dell's SMM with a CPU and SODIMM sensors, coretemp with a package
// and cores, and RAPL with a package and its dram subdomain.
func dellHost(t *testing.T) string {
root := t.TempDir()
put(t, root, "class/hwmon/hwmon0/name", "dell_smm")
put(t, root, "class/hwmon/hwmon0/temp1_input", "55000")
put(t, root, "class/hwmon/hwmon0/temp1_label", "CPU")
put(t, root, "class/hwmon/hwmon0/temp2_input", "47000")
put(t, root, "class/hwmon/hwmon0/temp2_label", "SODIMM")
put(t, root, "class/hwmon/hwmon1/name", "coretemp")
put(t, root, "class/hwmon/hwmon1/temp1_input", "58000")
put(t, root, "class/hwmon/hwmon1/temp1_label", "Package id 0")
put(t, root, "class/hwmon/hwmon1/temp10_input", "61000")
put(t, root, "class/hwmon/hwmon1/temp10_label", "Core 8")
put(t, root, "class/powercap/intel-rapl:0/energy_uj", "1000000000")
put(t, root, "class/powercap/intel-rapl:0/max_energy_range_uj", "262143328850")
put(t, root, "class/powercap/intel-rapl:0:0/energy_uj", "5000000")
return root
}
func sampler(sys string, smi func(context.Context, string) ([]byte, error), clock *time.Time) *SensorSampler {
return &SensorSampler{sys: sys, smi: smi, now: func() time.Time { return *clock }}
}
func noSMI(context.Context, string) ([]byte, error) { return nil, errors.New("not found") }
func TestAHostsCPUHeatAndPowerAreReadAsLmSensorsWould(t *testing.T) {
root := dellHost(t)
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
s := sampler(root, noSMI, &now)
first := s.Sample(t.Context())
// The package reading, not the hotter core: the package is what the
// chip itself reports as its temperature.
if first.CPUTempC != 58 {
t.Errorf("CPU %.1f °C, expected the package's 58", first.CPUTempC)
}
if first.CPUPowerW != 0 {
t.Error("power from one reading of a counter is invented")
}
if first.InletTempC != 0 || first.GPUs != nil {
t.Errorf("sensors this host does not have were reported: %+v", first)
}
// 10 s later the package has used 650 J, and its dram subdomain some of
// that — counted once.
put(t, root, "class/powercap/intel-rapl:0/energy_uj", "1650000000")
put(t, root, "class/powercap/intel-rapl:0:0/energy_uj", "99000000")
now = now.Add(10 * time.Second)
if got := s.Sample(t.Context()).CPUPowerW; got < 64.9 || got > 65.1 {
t.Errorf("CPU power %.2f W, expected 65", got)
}
}
func TestACounterThatWrapsIsNotANegativeDraw(t *testing.T) {
root := dellHost(t)
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
put(t, root, "class/powercap/intel-rapl:0/energy_uj", "262143000000")
s := sampler(root, noSMI, &now)
s.Sample(t.Context())
// 328,850 µJ to the wrap, then 499,671,150 past it: 500 J in 10 s.
put(t, root, "class/powercap/intel-rapl:0/energy_uj", "499671150")
now = now.Add(10 * time.Second)
if got := s.Sample(t.Context()).CPUPowerW; got < 49.9 || got > 50.1 {
t.Errorf("across a wrap: %.2f W, expected 50", got)
}
}
func TestAVMHasNoSensorsAndSaysNothing(t *testing.T) {
root := t.TempDir()
put(t, root, "class/thermal/cooling_device0/type", "Processor")
now := time.Now()
got := sampler(root, noSMI, &now).Sample(t.Context())
if got.CPUTempC != 0 || got.CPUPowerW != 0 || got.InletTempC != 0 || got.GPUs != nil {
t.Errorf("a machine with no sensors reported %+v", got)
}
}
func TestABoardsInletSensorIsTheRoomNotTheCPU(t *testing.T) {
root := t.TempDir()
put(t, root, "class/hwmon/hwmon2/name", "nct6798")
put(t, root, "class/hwmon/hwmon2/temp1_input", "24500")
put(t, root, "class/hwmon/hwmon2/temp1_label", "SYSTIN")
put(t, root, "class/hwmon/hwmon3/name", "k10temp")
put(t, root, "class/hwmon/hwmon3/temp1_input", "71250")
put(t, root, "class/hwmon/hwmon3/temp1_label", "Tctl")
now := time.Now()
got := sampler(root, noSMI, &now).Sample(t.Context())
if got.InletTempC != 24.5 || got.CPUTempC != 71.25 {
t.Errorf("inlet %.2f, CPU %.2f", got.InletTempC, got.CPUTempC)
}
}
func TestEachGPUSaysHowHotHowHungryAndWhatHoldsItBack(t *testing.T) {
var asked []string
smi := func(_ context.Context, fields string) ([]byte, error) {
asked = append(asked, fields)
return []byte(
"0, 71, 612.45, 700.00, 0x0000000000000000, 84\n" +
"1, 88, 698.10, 700.00, 0x0000000000000024, 95\n" + // sw_power_cap + sw_thermal
"2, 45, [N/A], 700.00, 0x0000000000000001, [N/A]\n"), nil // idle only
}
now := time.Now()
ss := sampler(t.TempDir(), smi, &now)
got := ss.Sample(t.Context())
if len(got.GPUs) != 3 {
t.Fatalf("%+v", got.GPUs)
}
g0, g1, g2 := got.GPUs[0], got.GPUs[1], got.GPUs[2]
if g0.TempC != 71 || g0.PowerW != 612.45 || g0.PowerLimitW != 700 || g0.MemoryTempC != 84 || g0.Throttle != nil {
t.Errorf("card 0 %+v", g0)
}
if strings.Join(g1.Throttle, ",") != "sw_power_cap,sw_thermal" {
t.Errorf("card 1 throttled by %v", g1.Throttle)
}
if g2.PowerW != 0 || g2.MemoryTempC != 0 || g2.Throttle != nil {
t.Errorf("card 2: N/A is not a reading, and idle is not being held back: %+v", g2)
}
// Within ten seconds the last reading is reused rather than running
// nvidia-smi on every heartbeat; after, it is asked again.
now = now.Add(5 * time.Second)
ss.Sample(t.Context())
if len(asked) != 1 {
t.Errorf("nvidia-smi was asked %d times within ten seconds", len(asked))
}
now = now.Add(6 * time.Second)
ss.Sample(t.Context())
if len(asked) != 2 {
t.Errorf("nvidia-smi was asked %d times after eleven seconds, expected 2", len(asked))
}
}
func TestADriverThatDoesNotKnowMemoryTemperatureStillReportsTheRest(t *testing.T) {
smi := func(_ context.Context, fields string) ([]byte, error) {
if strings.Contains(fields, "temperature.memory") {
return nil, errors.New(`Field "temperature.memory" is not a valid field to query.`)
}
return []byte("0, 66, 250.0, 300.0, 0x0000000000000040\n"), nil
}
now := time.Now()
got := sampler(t.TempDir(), smi, &now).Sample(t.Context())
if len(got.GPUs) != 1 || got.GPUs[0].TempC != 66 || strings.Join(got.GPUs[0].Throttle, ",") != "hw_thermal" {
t.Errorf("%+v", got.GPUs)
}
}
func TestEachSampleSaysHowBusyEachGPUIs(t *testing.T) {
// What the card is doing now, every sample: discovery read it once, at
// start, and a card busy since looked as idle as it was then.
smi := func(_ context.Context, fields string) ([]byte, error) {
if !strings.Contains(fields, "utilization.gpu") {
return nil, errors.New("asked without how busy the cards are")
}
return []byte(
"0, 71, 612.45, 700.00, 0x0000000000000000, 97, 64, 71234, 81559, 1980, 0, 84\n" +
"1, 30, 70.10, 700.00, 0x0000000000000001, 0, 0, 512, 81559, 345, [N/A], [N/A]\n"), nil
}
now := time.Now()
got := sampler(t.TempDir(), smi, &now).Sample(t.Context()).GPUs
if len(got) != 2 {
t.Fatalf("%+v", got)
}
g0, g1 := got[0], got[1]
if g0.UtilPercent != 97 || g0.MemUtilPercent != 64 || g0.MemoryUsedMB != 71234 || g0.MemoryTotalMB != 81559 ||
g0.SMClockMHz != 1980 || g0.ECCUncorrected != 0 || g0.MemoryTempC != 84 {
t.Errorf("card 0 %+v", g0)
}
// Idle is zero; a count the card does not keep is unknown, not zero.
if g1.UtilPercent != 0 || g1.ECCUncorrected != -1 || g1.MemoryTempC != 0 {
t.Errorf("card 1 %+v", g1)
}
}
func TestADriverThatDoesNotKnowTheBusyFieldsStillReportsHeatAndPower(t *testing.T) {
smi := func(_ context.Context, fields string) ([]byte, error) {
if strings.Contains(fields, "utilization.gpu") {
return nil, errors.New(`Field "ecc.errors.uncorrected.volatile.total" is not a valid field to query.`)
}
return []byte("0, 66, 250.0, 300.0, 0x0000000000000000, 70\n"), nil
}
now := time.Now()
got := sampler(t.TempDir(), smi, &now).Sample(t.Context()).GPUs
if len(got) != 1 || got[0].TempC != 66 || got[0].MemoryTempC != 70 || got[0].MemoryTotalMB != 0 {
t.Errorf("%+v", got)
}
}
+33
View File
@@ -9,6 +9,7 @@
package engines
import (
"context"
"fmt"
"os"
"slices"
@@ -40,6 +41,9 @@ type Profile struct {
ModelPath string
// ServedModelName is the name clients use, which need not match the path.
ServedModelName string
// Adapters are LoRA adapters to load beside the model, in order: an
// adapter's index is the id requests apply it by.
Adapters []Adapter
}
// ShardSpec is one shard of one replica on this node.
@@ -76,6 +80,10 @@ type ShardSpec struct {
// and accounting. Given to the engine in its environment rather than on
// its command line, which every user on the node can read.
APIKey string `json:"api_key,omitempty"`
// Threads is the CPU cores placement reserved for a shard with no GPU,
// and so what its engine computes on. Zero leaves it to the engine's own
// configuration.
Threads int `json:"threads,omitempty"`
// Pool and Fabric are control plane concepts the agent cannot derive. They
// are carried so the agent can report them back after a restart; Fabric
@@ -139,6 +147,15 @@ type Engine interface {
MetricsPath() string
}
// CPUFallback is an engine that can come up on the CPU when it was placed on
// GPUs, and keep serving there without an error. The agent checks such a shard
// is on its GPUs before calling it ready.
type CPUFallback interface {
FallsBackToCPU() bool
// Devices lists the GPUs the engine's build can see, for saying why.
Devices(ctx context.Context) ([]string, error)
}
// GPUInfo is the subset of GPU facts validation needs.
type GPUInfo struct {
Index int
@@ -196,6 +213,10 @@ func (r *Registry) Names() []string {
// Config configures the engine adapters.
type Config struct {
// Disabled are engines this node never installs or advertises, whatever
// the control plane asks: vLLM on a node of cards no build of it runs on,
// that is meant for llama.cpp alone.
Disabled []string `koanf:"disabled"`
VLLM VLLMConfig `koanf:"vllm"`
SGLang SGLangConfig `koanf:"sglang"`
LlamaCpp LlamaCppConfig `koanf:"llamacpp"`
@@ -225,6 +246,11 @@ func NCCLEnv(s ShardSpec) map[string]string { return baseEnv(s) }
func baseEnv(s ShardSpec) map[string]string {
env := map[string]string{
"CUDA_VISIBLE_DEVICES": joinInts(s.GPUIndices),
// The indices above are nvidia-smi's, which counts by PCI bus. CUDA
// counts fastest first unless told otherwise, and on a node with two
// kinds of GPU the shard would get a different card than placement
// gave it — one another shard may hold.
"CUDA_DEVICE_ORDER": "PCI_BUS_ID",
}
if len(s.HCAs) > 0 {
@@ -369,3 +395,10 @@ func quantizationOf(args []string) string {
}
return ""
}
// Adapter is a LoRA adapter an engine loads beside its model. Path is a
// source until the agent has fetched it, a local file after.
type Adapter struct {
Name string `json:"name"`
Path string `json:"path"`
}
+100 -13
View File
@@ -1,9 +1,12 @@
package engines
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
@@ -21,8 +24,21 @@ type LlamaCppConfig struct {
// 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"`
// Threads is how many CPU threads a shard with no GPU computes on. Zero
// takes half this machine's CPUs.
//
// Not all of them, which is llama.cpp's own default: two busy servers
// that each spin on every CPU fight for them, and on an 8-vCPU node
// that made 7B generation fall from 10 tokens a second to under 1 each.
// At half, one server alone is about a third slower and two together
// keep 5.5 each. A node that only ever runs one CPU replica does better
// with all of them; say so here.
Threads int `koanf:"threads"`
}
// numCPU is the CPUs this process may run on; a variable for tests.
var numCPU = runtime.NumCPU
// LlamaCpp builds llama.cpp launch commands.
//
// It is the engine that makes a cluster without GPUs useful: llama.cpp runs a
@@ -51,25 +67,25 @@ 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.
// llama.cpp spreads a model over a node's GPUs by layer (--split-mode layer),
// so gpus_per_node is how many it gets. It has no tensor parallelism and no
// group spanning machines, and accepting tp or nodes would produce a replica
// that serves from fewer cards, or one machine, while the catalog believes
// otherwise.
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)
if p.TP > 1 || p.PP > 1 {
return fmt.Errorf("profile %q: llamacpp splits a model across a node's GPUs by layer, not by tp or pp; "+
"write gpus_per_node: %d instead", p.Name, max(p.TP, 1)*max(p.PP, 1))
}
return nil
// The common checks count a replica's GPUs as tp; for llama.cpp the
// count is gpus_per_node.
q := p
q.TP = max(p.GPUsPerNode, 1)
return validateCommon(q, gpus)
}
// Commands returns the processes for one shard, which is always one process.
@@ -118,12 +134,48 @@ func (l *LlamaCpp) Commands(s ShardSpec) ([]Command, error) {
if len(s.GPUIndices) > 0 && !hasFlag(p.Args, "--n-gpu-layers", "-ngl") {
args = append(args, "--n-gpu-layers", "999")
}
// Several GPUs: the layers spread across all of them, which is the only
// split llama.cpp does without a matched interconnect. In proportion to
// each card's free memory, which for the whole cards placement gives is
// an even split.
if len(s.GPUIndices) > 1 && !hasFlag(p.Args, "--split-mode", "-sm") {
args = append(args, "--split-mode", "layer")
}
if len(s.GPUIndices) == 0 && !hasFlag(p.Args, "--threads", "-t") {
// What placement reserved first: it is what the node's other
// replicas were placed around.
n := s.Threads
if n <= 0 {
n = l.cfg.Threads
}
if n <= 0 {
n = max(numCPU()/2, 1)
}
args = append(args, "--threads", strconv.Itoa(n))
}
// Loaded at start, applied per request: a request that names an adapter
// carries "lora": [{"id": i, "scale": s}], and one that names none is the
// base model. Each is loaded at scale zero, not with --lora and
// --lora-init-without-apply: that leaves an adapter's scale at 1, and
// llama-server applies it at that scale to every request with no "lora"
// of its own — every request for the model itself.
for _, a := range p.Adapters {
args = append(args, "--lora-scaled", a.Path+":0")
}
args = append(args, p.Args...)
env := baseEnv(s)
if s.APIKey != "" {
env["LLAMA_API_KEY"] = s.APIKey
}
// The Vulkan build — what a GPU older than the CUDA builds gets — does
// not read CUDA_VISIBLE_DEVICES; this is its own. Vulkan numbers devices
// as the driver lists them, which on NVIDIA follows the same bus order
// as nvidia-smi; were it not to, the shard would not hold memory on its
// own GPUs, and starting it checks exactly that.
if len(s.GPUIndices) > 0 {
env["GGML_VK_VISIBLE_DEVICES"] = joinInts(s.GPUIndices)
}
return []Command{{
Name: "llama-server", Path: bin, Args: args, Env: materializeEnv(env),
}}, nil
@@ -199,3 +251,38 @@ func (l *LlamaCpp) bin() string {
}
return "llama-server"
}
// FallsBackToCPU says that llama.cpp keeps running when its GPU backend does
// not load: --n-gpu-layers asks for layers on a GPU it then has none of, and
// the server comes up and answers on the CPU, at a tenth of the speed, with no
// error anywhere. Its readiness on a GPU placement needs checking separately.
func (*LlamaCpp) FallsBackToCPU() bool { return true }
// Devices lists the GPUs this build can compute on, as llama-server's
// --list-devices names them ("CUDA0: NVIDIA L4 (22994 MiB, 22713 MiB free)").
// Empty, on a machine with GPUs, is a build whose GPU backend did not load:
// the CPU build, or a CUDA build missing a library its backend links.
func (l *LlamaCpp) Devices(ctx context.Context) ([]string, error) {
out, err := exec.CommandContext(ctx, l.bin(), "--list-devices").CombinedOutput() //nolint:gosec // the engine's own binary
if err != nil {
return nil, fmt.Errorf("%s --list-devices: %v: %s", l.bin(), err, strings.TrimSpace(string(out)))
}
return parseDevices(string(out)), nil
}
// parseDevices reads --list-devices: the lines after "Available devices:",
// which says "(none)" when there are none. Log lines before it are skipped.
func parseDevices(out string) []string {
var devs []string
listing := false
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "Available devices"):
listing = true
case listing && line != "" && line != "(none)":
devs = append(devs, line)
}
}
return devs
}
+100
View File
@@ -261,3 +261,103 @@ func TestLlamaCppIsGivenItsKeyOutOfSightOfTheProcessList(t *testing.T) {
t.Error("llama-server was not given its key")
}
}
func TestACPUShardLeavesHalfTheMachineForAnother(t *testing.T) {
prev := numCPU
numCPU = func() int { return 8 }
t.Cleanup(func() { numCPU = prev })
threads := func(l *LlamaCpp, s ShardSpec) (string, bool) {
cmds, err := l.Commands(s)
if err != nil {
t.Fatal(err)
}
return flagValue(cmds[0].Args, "--threads")
}
if v, _ := threads(&LlamaCpp{}, llamaSpec(nil)); v != "4" {
t.Errorf("--threads %q on 8 CPUs, expected 4", v)
}
if v, _ := threads(&LlamaCpp{cfg: LlamaCppConfig{Threads: 8}}, llamaSpec(nil)); v != "8" {
t.Errorf("--threads %q with 8 configured", v)
}
if _, ok := threads(&LlamaCpp{}, llamaSpec([]int{0})); ok {
t.Error("a GPU shard was given a thread count")
}
s := llamaSpec(nil)
s.Profile.Args = []string{"-t", "2"}
cmds, _ := (&LlamaCpp{}).Commands(s)
if slices.Contains(cmds[0].Args, "--threads") {
t.Error("the profile's own -t was overridden")
}
numCPU = func() int { return 1 }
if v, _ := threads(&LlamaCpp{}, llamaSpec(nil)); v != "1" {
t.Errorf("--threads %q on one CPU", v)
}
}
func TestTheCoresPlacementReservedAreTheThreads(t *testing.T) {
s := llamaSpec(nil)
s.Threads = 3
cmds, err := (&LlamaCpp{cfg: LlamaCppConfig{Threads: 8}}).Commands(s)
if err != nil {
t.Fatal(err)
}
if v, _ := flagValue(cmds[0].Args, "--threads"); v != "3" {
t.Errorf("--threads %q; what placement reserved should win over the node's setting", v)
}
}
func TestAdaptersAreLoadedAtStartAndAppliedToNoneByDefault(t *testing.T) {
s := llamaSpec(nil)
s.Profile.Adapters = []Adapter{{Name: "sql", Path: "/w/sql.gguf"}, {Name: "docs", Path: "/w/docs.gguf"}}
cmds, err := (&LlamaCpp{}).Commands(s)
if err != nil {
t.Fatal(err)
}
args := strings.Join(cmds[0].Args, " ")
if !strings.Contains(args, "--lora-scaled /w/sql.gguf:0 --lora-scaled /w/docs.gguf:0") {
t.Errorf("args %s: both adapters, in order, loaded at scale zero", args)
}
plain, _ := (&LlamaCpp{}).Commands(llamaSpec(nil))
if strings.Contains(strings.Join(plain[0].Args, " "), "--lora") {
t.Error("a model with no adapters was given lora flags")
}
}
func TestDevicesAreReadFromListDevices(t *testing.T) {
cuda := "ggml_cuda_init: found 2 CUDA devices:\nAvailable devices:\n CUDA0: NVIDIA L4 (22994 MiB, 22713 MiB free)\n CUDA1: NVIDIA L4 (22994 MiB, 22713 MiB free)\n"
if got := parseDevices(cuda); len(got) != 2 || !strings.HasPrefix(got[0], "CUDA0: NVIDIA L4") {
t.Errorf("CUDA build: %q", got)
}
// A CUDA build whose backend did not load says what the CPU build says.
if got := parseDevices("Available devices:\n (none)\n"); len(got) != 0 {
t.Errorf("no devices: %q", got)
}
}
func TestLlamaCppSpreadsAReplicaOverItsNodesGPUsByLayer(t *testing.T) {
four := Profile{Name: "t4x4", Engine: "llamacpp", GPUClass: "t4", TP: 1, GPUsPerNode: 4, ModelPath: "/m"}
if err := (&LlamaCpp{}).Validate(four, h100(4)); err != nil {
t.Fatalf("four GPUs on a four-GPU node: %v", err)
}
if err := (&LlamaCpp{}).Validate(four, h100(2)); err == nil {
t.Error("four GPUs were accepted on a node with two")
}
s := llamaSpec([]int{0, 1, 2, 3})
s.Profile.GPUsPerNode = 4
cmds, err := (&LlamaCpp{}).Commands(s)
if err != nil {
t.Fatal(err)
}
if v, _ := flagValue(cmds[0].Args, "--split-mode"); v != "layer" {
t.Errorf("--split-mode %q over four GPUs", v)
}
if !slices.Contains(cmds[0].Env, "CUDA_VISIBLE_DEVICES=0,1,2,3") {
t.Errorf("env %v: the replica must see all four of its GPUs", cmds[0].Env)
}
one, _ := (&LlamaCpp{}).Commands(llamaSpec([]int{2}))
if hasFlag(one[0].Args, "--split-mode") {
t.Error("a one-GPU replica was given a split mode")
}
}
+4 -1
View File
@@ -169,7 +169,10 @@ func (v *VLLM) rayCommand(s ShardSpec) (Command, bool) {
args := []string{"start", "--block"}
if s.Leader() {
args = append(args, "--head", "--port", strconv.Itoa(s.LeaderPort))
// Ray's head has no authentication: whoever reaches its port can run
// code on the node. Its dashboard, and the job API behind it, is the
// easiest way in, and nothing here uses it.
args = append(args, "--head", "--port", strconv.Itoa(s.LeaderPort), "--include-dashboard=false")
} else {
args = append(args, "--address", fmt.Sprintf("%s:%d", s.LeaderAddr, s.LeaderPort))
}
+345
View File
@@ -0,0 +1,345 @@
package agent
import (
"context"
"fmt"
"os/exec"
"slices"
"strconv"
"strings"
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
)
// usableFabrics reports the fabrics this node can actually carry traffic on.
//
// Loud about what it drops. A fabric silently missing from a registration looks
// identical to one never configured, and the operator who typed the adapter
// name is the only person who can fix it — so the reason is logged here, on the
// machine where both halves are visible, rather than inferred later from a
// replica that will not converge.
func (a *Agent) usableFabrics(inv discovery.Inventory) []inventory.FabricAttachment {
out := a.checkFabrics(inv.HCAs, true)
a.mu.Lock()
a.fabricsSent = fabricsFingerprint(out)
a.mu.Unlock()
return out
}
// checkFabrics is the declared fabrics this node can carry traffic on, from
// the adapters as they are now. Loud logs every problem; the heartbeat checks
// quietly and speaks only when the answer changes.
func (a *Agent) checkFabrics(hcas []discovery.HCA, loud bool) []inventory.FabricAttachment {
declared := map[string][]string{}
for _, f := range a.cfg.Fabrics {
declared[f.Fabric] = f.HCAs
}
var out []inventory.FabricAttachment
for _, c := range discovery.CheckFabrics(declared, hcas) {
if loud {
for _, p := range c.Problems {
a.log.Error("a declared fabric adapter cannot carry traffic",
"fabric", c.Fabric, "problem", p)
}
}
if !c.OK() {
if loud {
a.log.Error("fabric not advertised; no declared adapter on it can carry traffic",
"fabric", c.Fabric,
"consequence", "no multi-node replica will be placed here")
}
continue
}
out = append(out, inventory.FabricAttachment{
Fabric: c.Fabric, HCAs: c.Usable, RateGbps: c.RateGbps,
})
}
return out
}
// fabricsFingerprint is enough to notice a fabric, an adapter or a rate
// changing.
func fabricsFingerprint(fs []inventory.FabricAttachment) string {
var b strings.Builder
for _, f := range fs {
fmt.Fprintf(&b, "%s:%s:%d;", f.Fabric, strings.Join(f.HCAs, ","), f.RateGbps)
}
return b.String()
}
// sampleIB re-reads the adapters for the heartbeat: every port's state and
// counters, and the fabrics again when they no longer match what the control
// plane was told.
//
// Every heartbeat, because it is a few small files per port, and because a
// port that went down used to be invisible until the agent restarted — the
// node went on being offered multi-node work that would hang on its first
// collective.
func (a *Agent) sampleIB(hb *llmgwv1.Heartbeat) {
a.mu.Lock()
had := len(a.inv.HCAs) > 0
a.mu.Unlock()
if !had && len(a.cfg.Fabrics) == 0 {
return
}
hcas, err := discovery.DiscoverIB(a.cfg.Discovery.IBRoot)
if err != nil {
return
}
a.attachSM(hcas)
for _, h := range hcas {
for _, p := range h.Ports {
ip := &llmgwv1.IBPort{
Hca: h.Name, Port: int32(p.Num), State: p.State, PhysState: p.PhysState,
RateGbps: int32(p.RateGbps), LinkLayer: string(p.LinkLayer), Counters: p.Counters,
NodeGuid: h.NodeGUID, PortGuid: p.PortGUID, Network: p.Network, GidPrefix: p.GIDPrefix,
}
a.mu.Lock()
if p.SM != nil {
ip.SmGuid, ip.SmLid, ip.SmPriority, ip.SmState = p.SM.GUID, int32(p.SM.LID), int32(p.SM.Priority), p.SM.State
} else {
ip.SmError = a.smErr[smKey(h.Name, p.Num)]
}
for _, r := range a.sms[smKey(h.Name, p.Num)] {
ip.SubnetManagers = append(ip.SubnetManagers, &llmgwv1.SubnetManager{
Guid: r.GUID, Lid: int32(r.LID), Priority: int32(r.Priority), State: r.State})
}
ip.SubnetManagersError = a.smsErr[smKey(h.Name, p.Num)]
a.mu.Unlock()
hb.IbPorts = append(hb.IbPorts, ip)
}
}
a.mu.Lock()
a.inv.HCAs = hcas // a reconnect registers what is true now
a.mu.Unlock()
if len(a.cfg.Fabrics) == 0 {
return
}
now := a.checkFabrics(hcas, false)
fp := fabricsFingerprint(now)
a.mu.Lock()
changed := fp != a.fabricsSent
a.fabricsSent = fp
a.mu.Unlock()
if !changed {
return
}
// Said once, with the reasons, the moment it happens.
a.checkFabrics(hcas, true)
a.log.Warn("the fabrics this node can carry traffic on changed", "now", fp)
hb.FabricsChanged = true
for _, f := range now {
hb.Fabrics = append(hb.Fabrics, &llmgwv1.FabricAttachment{
Fabric: f.Fabric, Hcas: f.HCAs, RateGbps: int32(f.RateGbps),
})
}
}
func smKey(hca string, port int) string { return fmt.Sprintf("%s/%d", hca, port) }
// scanFabric is discovery.ScanFabric, replaced in tests.
var scanFabric = discovery.ScanFabric
// querySMs is discovery.QuerySMs, replaced in tests.
var querySMs = func(hca string, port int) ([]discovery.SMRecord, error) {
return discovery.QuerySMs(context.Background(), hca, port)
}
func splitSMKey(k string) (string, int) {
i := strings.LastIndexByte(k, '/')
port, _ := strconv.Atoi(k[i+1:])
return k[:i], port
}
// querySM is discovery.QuerySM, replaced in tests.
var querySM = func(hca string, port int) (*discovery.SMInfo, error) {
return discovery.QuerySM(context.Background(), hca, port)
}
// attachSM puts on each active InfiniBand port the subnet manager it sees,
// asking again once a minute and otherwise reusing the last answer.
func (a *Agent) attachSM(hcas []discovery.HCA) {
a.mu.Lock()
stale := time.Since(a.smAsked) > time.Minute || a.sm == nil
a.mu.Unlock()
if stale {
sm, errs := map[string]*discovery.SMInfo{}, map[string]string{}
_ = discovery.AttachSM(hcas, func(hca string, port int) (*discovery.SMInfo, error) {
got, err := querySM(hca, port)
if err != nil {
errs[smKey(hca, port)] = err.Error()
return nil, err
}
sm[smKey(hca, port)] = got
return got, nil
})
// Every subnet manager on each fabric, through one port per master:
// the same list through every port of one fabric is one query too
// many for each.
sms, smsErr := map[string][]discovery.SMRecord{}, map[string]string{}
asked := map[string]bool{}
for k, v := range sm {
if asked[v.GUID] {
continue
}
asked[v.GUID] = true
hca, port := splitSMKey(k)
if recs, err := querySMs(hca, port); err != nil {
smsErr[k] = err.Error()
} else {
sms[k] = recs
}
}
a.mu.Lock()
for k, v := range sm {
if old := a.sm[k]; old != nil && old.GUID != v.GUID {
a.log.Warn("the master subnet manager changed", "port", k, "from", old.GUID, "to", v.GUID)
}
}
a.sm, a.smErr, a.smAsked = sm, errs, time.Now()
a.sms, a.smsErr = sms, smsErr
a.mu.Unlock()
return
}
a.mu.Lock()
defer a.mu.Unlock()
for i := range hcas {
for j := range hcas[i].Ports {
if sm := a.sm[smKey(hcas[i].Name, hcas[i].Ports[j].Num)]; sm != nil && hcas[i].Ports[j].Active() {
hcas[i].Ports[j].SM = sm
}
}
}
}
// ensureDiags installs infiniband-diags on a node with adapters and without
// it. sminfo is what tells which physical fabric a port is on; without it,
// two separate fabrics given one name cannot be caught. Installed the way
// engine dependencies are, from the machine's own package sources.
func (a *Agent) ensureDiags(ctx context.Context) {
a.mu.Lock()
hasIB := len(a.inv.HCAs) > 0
a.mu.Unlock()
if !hasIB || a.prov == nil {
return
}
if _, err := exec.LookPath("sminfo"); err == nil {
return
}
a.log.Info("installing infiniband-diags, to tell which fabric each port is on")
pkgs := map[string][]string{"deb": {"infiniband-diags"}, "rpm": {"infiniband-diags"}}
if err := a.prov.InstallPackages(ctx, pkgs); err != nil {
a.log.Warn("could not install infiniband-diags; fabrics cannot be told apart by their subnet manager",
"err", err)
}
}
// FeatureFabricTest is the agent answering FabricTestRequest.
const FeatureFabricTest = "fabric-test"
// runWriteBW is discovery.RunWriteBW, and haveWriteBW whether it is
// installed; both replaced in tests.
var (
runWriteBW = discovery.RunWriteBW
haveWriteBW = func() bool { _, err := exec.LookPath("ib_write_bw"); return err == nil }
)
// fabricTest runs this node's side of a bandwidth test with another.
func (a *Agent) fabricTest(ctx context.Context, req *llmgwv1.FabricTestRequest) *llmgwv1.FabricTestReply {
start := time.Now()
reply := &llmgwv1.FabricTestReply{RequestId: req.GetRequestId()}
defer func() { reply.TookMs = time.Since(start).Milliseconds() }()
w := discovery.WriteBW{HCA: req.GetHca(), Port: int(req.GetPort()), GIDIndex: -1,
TCPPort: int(req.GetTcpPort()), Seconds: int(req.GetSeconds())}
if req.GetRole() == "client" {
w.Peer = req.GetPeer()
}
a.mu.Lock()
found := false
for _, h := range a.inv.HCAs {
for _, p := range h.Ports {
if h.Name == w.HCA && p.Num == w.Port {
found = true
// RoCE needs its v2 GID named, as NCCL does; InfiniBand uses the LID.
if p.LinkLayer == discovery.LinkEthernet && p.RoCEv2GID >= 0 {
w.GIDIndex = p.RoCEv2GID
}
}
}
}
a.mu.Unlock()
if !found {
reply.Error = fmt.Sprintf("this node has no port %s/%d", w.HCA, w.Port)
return reply
}
if !haveWriteBW() && a.prov != nil {
a.log.Info("installing perftest, for a bandwidth test with another node")
pkgs := map[string][]string{"deb": {"perftest"}, "rpm": {"perftest"}}
if err := a.prov.InstallPackages(ctx, pkgs); err != nil {
reply.Error = "installing perftest: " + err.Error()
return reply
}
}
gbps, out, err := runWriteBW(ctx, w)
reply.Gbps = gbps
reply.Output = tailLines(out, 12)
if err != nil {
reply.Error = err.Error()
}
return reply
}
func tailLines(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}
// discoverGPUFabric and discoverIMEX are replaced in tests.
var (
discoverGPUFabric = discovery.DiscoverGPUFabric
discoverIMEX = discovery.DiscoverIMEX
)
// sampleNVLink reads, once a minute, which NVLink domain each GPU is joined
// to and whether IMEX is up. nvidia-smi -q takes a second or two, too long
// for every heartbeat, and a fabric that was joined an hour ago rarely
// changes in five seconds — but when a fabric manager restarts it does, for
// every card at once.
func (a *Agent) sampleNVLink(hb *llmgwv1.Heartbeat) {
a.mu.Lock()
gpus := slices.Clone(a.inv.GPUs)
due := time.Since(a.nvlAsked) > time.Minute
if due {
a.nvlAsked = time.Now()
}
a.mu.Unlock()
if len(gpus) == 0 || !due {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
fab, err := discoverGPUFabric(ctx, a.cfg.Discovery.NvidiaSMI)
if err != nil || len(fab) == 0 {
return
}
discovery.ApplyFabric(gpus, fab)
for _, g := range gpus {
if g.Fabric == nil {
continue
}
hb.GpuFabric = append(hb.GpuFabric, &llmgwv1.GPUFabricState{
Index: int32(g.Index), State: g.Fabric.State, Status: g.Fabric.Status,
ClusterUuid: g.Fabric.ClusterUUID, CliqueId: g.Fabric.CliqueID, Bandwidth: g.Fabric.Bandwidth,
})
}
m := discoverIMEX(ctx)
hb.Imex = &llmgwv1.IMEX{State: m.State, Channels: int32(m.Channels)}
}
+155
View File
@@ -0,0 +1,155 @@
package agent
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
)
// gpuProcesses and processGroup are variables so tests can stand in for
// nvidia-smi and /proc.
var (
gpuProcesses = discovery.GPUProcesses
gpuMemory = discovery.GPUMemoryByProcess
processGroup = pgidOf
// gpuCheckWait is how long a shard has, once it answers, to show up on
// its GPUs: the weights are on them by then, but nvidia-smi can lag.
gpuCheckWait = 15 * time.Second
)
// onItsGPUs checks that a shard placed on GPUs computes on them.
//
// For an engine that falls back to the CPU when its GPU backend does not load,
// "it answers" is not "it is ready": llama.cpp built for CUDA but missing
// cuBLAS comes up on the CPU, answers at a tenth of the speed, and the GPU it
// was given sits idle and reserved. What it computes on is what nvidia-smi
// lists it on, so a shard none of whose processes hold memory on one of its
// GPUs is refused, with what the engine itself can see.
func (a *Agent) onItsGPUs(ctx context.Context, eng engines.Engine, spec engines.ShardSpec, pid int) error {
fb, ok := eng.(engines.CPUFallback)
if !ok || !fb.FallsBackToCPU() || len(spec.GPUIndices) == 0 || pid <= 0 {
return nil
}
group := processGroup(pid)
deadline := time.Now().Add(gpuCheckWait)
var missing []int
for {
procs, err := gpuProcesses(ctx, a.cfg.Discovery.NvidiaSMI)
if err != nil || procs == nil {
// No nvidia-smi to ask is no evidence either way, and a node that
// was given GPUs has one; say so and let the shard serve.
a.log.Warn("could not check that a GPU shard runs on its GPUs", "shard", spec.ShardID, "err", err)
return nil
}
missing = missing[:0]
for _, i := range spec.GPUIndices {
if !holds(procs[i], pid, group) {
missing = append(missing, i)
}
}
if len(missing) == 0 {
return nil
}
if time.Now().After(deadline) {
break
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
}
}
seen := "it lists no GPU at all"
if devs, err := fb.Devices(ctx); err != nil {
seen = "listing its devices failed: " + err.Error()
} else if len(devs) > 0 {
seen = "it lists " + strings.Join(devs, "; ")
}
return fmt.Errorf("placed on GPU %s but computing on the CPU: the engine holds no memory there. "+
"Its GPU backend did not load (%s). Check the build with `ldd` on its libggml-cuda.so, "+
"or run `llmgw doctor` on this node", joinIndices(missing), seen)
}
// foreignGPUMemory is what each GPU holds for anything other than this
// agent's shards, by index, and each GPU's memory in use. Nil when nvidia-smi
// cannot be asked, which is no reading rather than an empty card.
//
// Everything allocated less what the shards' processes hold, rather than the
// sum of the other processes: a process in another container's PID namespace
// holds memory nvidia-smi lists under no PID this node can see, and it is
// still not memory placement can hand out.
func (a *Agent) foreignGPUMemory(ctx context.Context, shards []int) (foreign, used []int32) {
mem, err := gpuMemory(ctx, a.cfg.Discovery.NvidiaSMI)
if err != nil || mem == nil {
return nil, nil
}
groups := make([]int, len(shards))
for i, pid := range shards {
groups[i] = processGroup(pid)
}
ours := func(p int) bool {
for i, pid := range shards {
if holds([]int{p}, pid, groups[i]) {
return true
}
}
return false
}
for _, g := range mem {
f := g.UsedMB
for _, p := range g.Procs {
if ours(p.PID) {
f -= p.UsedMB
}
}
foreign = append(foreign, int32(max(f, 0)))
used = append(used, int32(g.UsedMB))
}
return foreign, used
}
// holds reports whether any of pids is the shard's process or in its group:
// an engine that computes in a child process is still the shard.
func holds(pids []int, pid, group int) bool {
for _, p := range pids {
if p == pid || (group > 0 && processGroup(p) == group) {
return true
}
}
return false
}
// pgidOf reads a process's group from /proc; zero when it cannot.
func pgidOf(pid int) int {
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
if err != nil {
return 0
}
// The command name, in parentheses, may hold spaces; the fields after its
// closing parenthesis are state, ppid, pgrp.
s := string(b)
i := strings.LastIndexByte(s, ')')
if i < 0 {
return 0
}
f := strings.Fields(s[i+1:])
if len(f) < 3 {
return 0
}
n, _ := strconv.Atoi(f[2])
return n
}
func joinIndices(is []int) string {
s := make([]string, len(is))
for k, i := range is {
s[k] = strconv.Itoa(i)
}
return strings.Join(s, ", ")
}
+124
View File
@@ -0,0 +1,124 @@
package agent
import (
"context"
"log/slog"
"slices"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
)
// fallbackEngine is an engine that comes up on the CPU when its GPU backend
// does not load, and lists the devices it can see.
type fallbackEngine struct {
engines.Engine
devices []string
}
func (fallbackEngine) FallsBackToCPU() bool { return true }
func (f fallbackEngine) Devices(context.Context) ([]string, error) {
return f.devices, nil
}
func TestAGPUShardComputingOnTheCPUIsNotReady(t *testing.T) {
procs := map[int][]int{}
oldProcs, oldGroup, oldWait := gpuProcesses, processGroup, gpuCheckWait
gpuProcesses = func(context.Context, string) (map[int][]int, error) { return procs, nil }
processGroup = func(pid int) int {
if pid >= 500 && pid < 600 {
return 500 // the shard's group: 500 and its children
}
return pid
}
gpuCheckWait = 0
t.Cleanup(func() { gpuProcesses, processGroup, gpuCheckWait = oldProcs, oldGroup, oldWait })
a := &Agent{log: slog.Default()}
spec := engines.ShardSpec{ShardID: "s", GPUIndices: []int{0, 1}}
eng := fallbackEngine{}
// On no GPU at all: refused, saying what the engine sees.
err := a.onItsGPUs(context.Background(), eng, spec, 500)
if err == nil || !strings.Contains(err.Error(), "computing on the CPU") || !strings.Contains(err.Error(), "lists no GPU") {
t.Fatalf("a shard on no GPU was let through: %v", err)
}
// On one of its two: refused, naming the one it is missing.
procs[0] = []int{500}
if err := a.onItsGPUs(context.Background(), eng, spec, 500); err == nil || !strings.Contains(err.Error(), "GPU 1 ") {
t.Fatalf("a shard on half its GPUs: %v", err)
}
// Another process on GPU 1 is not the shard.
procs[1] = []int{900}
if err := a.onItsGPUs(context.Background(), eng, spec, 500); err == nil {
t.Fatal("another process's memory was taken for the shard's")
}
// A child of the shard holding GPU 1 is the shard.
procs[1] = []int{900, 512}
if err := a.onItsGPUs(context.Background(), eng, spec, 500); err != nil {
t.Fatalf("a shard on both its GPUs: %v", err)
}
// An engine that fails rather than falling back is not checked, nor is a
// shard with no GPUs.
procs = map[int][]int{}
if err := a.onItsGPUs(context.Background(), struct{ engines.Engine }{}, spec, 500); err != nil {
t.Errorf("checked an engine that does not fall back: %v", err)
}
if err := a.onItsGPUs(context.Background(), eng, engines.ShardSpec{}, 500); err != nil {
t.Errorf("checked a CPU shard: %v", err)
}
}
func TestTheCheckWaitsForTheShardToShowUpOnItsGPU(t *testing.T) {
calls := 0
oldProcs, oldWait := gpuProcesses, gpuCheckWait
gpuProcesses = func(context.Context, string) (map[int][]int, error) {
calls++
if calls < 2 {
return map[int][]int{}, nil
}
return map[int][]int{0: {77}}, nil
}
gpuCheckWait = 5 * time.Second
t.Cleanup(func() { gpuProcesses, gpuCheckWait = oldProcs, oldWait })
a := &Agent{log: slog.Default()}
if err := a.onItsGPUs(context.Background(), fallbackEngine{}, engines.ShardSpec{GPUIndices: []int{0}}, 77); err != nil {
t.Fatalf("nvidia-smi lagging by a second failed the shard: %v", err)
}
}
func TestForeignGPUMemoryLeavesOutTheAgentsOwnShards(t *testing.T) {
// cuda01: ComfyUI and one of our engines on the same card. Only ComfyUI's
// share — and whatever no listed process accounts for — is foreign.
oldMem, oldGroup := gpuMemory, processGroup
gpuMemory = func(context.Context, string) ([]discovery.GPUMemory, error) {
return []discovery.GPUMemory{
{UsedMB: 15000, Procs: []discovery.GPUProcess{{PID: 71315, UsedMB: 4932}, {PID: 512, UsedMB: 9500}}},
{UsedMB: 300},
}, nil
}
processGroup = func(pid int) int {
if pid >= 500 && pid < 600 {
return 500
}
return pid
}
t.Cleanup(func() { gpuMemory, processGroup = oldMem, oldGroup })
a := &Agent{log: slog.Default()}
foreign, used := a.foreignGPUMemory(context.Background(), []int{500})
if !slices.Equal(foreign, []int32{5500, 300}) || !slices.Equal(used, []int32{15000, 300}) {
t.Errorf("foreign %v, used %v; want [5500 300] and [15000 300]", foreign, used)
}
// No nvidia-smi to ask is no reading, not an empty card.
gpuMemory = func(context.Context, string) ([]discovery.GPUMemory, error) { return nil, nil }
if foreign, _ := a.foreignGPUMemory(context.Background(), nil); foreign != nil {
t.Errorf("no reading came back as %v", foreign)
}
}
+34
View File
@@ -0,0 +1,34 @@
package agent
import (
"slices"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
)
// logsReply answers a request for one of this node's logs.
func (a *Agent) logsReply(req *llmgwv1.LogsRequest) *llmgwv1.LogsReply {
n := int(req.GetLines())
if n <= 0 {
n = 200
}
n = min(n, agentLogLines)
shards := a.sup.IDs()
slices.Sort(shards)
out := &llmgwv1.LogsReply{
RequestId: req.GetRequestId(),
Sources: append([]string{"agent"}, shards...),
}
switch src := req.GetSource(); src {
case "", "agent":
out.Lines = a.logRing.tail(n)
default:
sh, err := a.sup.Get(src)
if err != nil {
out.Error = "no engine " + src + " runs on this node"
return out
}
out.Lines = sh.Logs(n)
}
return out
}
+227
View File
@@ -0,0 +1,227 @@
package agent
import (
"context"
"fmt"
"log/slog"
"maps"
"slices"
"strings"
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
)
// engines is what this node can run: what the agent installed and saw run,
// and what the configuration names and answers. Nothing merely found on the
// machine — a llama-server on PATH is somebody's, not necessarily one that
// works, and placing on it is how a replica fails on a node nobody watches.
func (a *Agent) engines(ctx context.Context) map[string]string {
a.engineOnce.Do(func() {
vers := a.reg.ProbeConfigured(ctx)
a.mu.Lock()
a.configuredVers, a.lastEngineProbe = vers, time.Now()
a.mu.Unlock()
if len(vers) > 0 {
a.log.Info("engines named in the configuration",
"engines", engines.ProbedNames(vers))
}
})
a.mu.Lock()
defer a.mu.Unlock()
vers := a.engineReportLocked()
// Whatever registration sends is what the control plane now knows, so a
// heartbeat repeats it only once it differs.
a.enginesSent = enginesFingerprint(vers)
return vers
}
// engineReportLocked merges the two sources. Installed wins: it is the one
// that was seen to run. Called with mu held.
func (a *Agent) engineReportLocked() map[string]string {
out := maps.Clone(a.configuredVers)
if out == nil {
out = map[string]string{}
}
maps.Copy(out, a.installedVers)
maps.DeleteFunc(out, func(e, _ string) bool { return a.disabled(e) })
return out
}
// byHand reports whether agent.yaml names this engine's binary, which opts
// it out of being installed.
func (a *Agent) byHand(engine string) bool {
if a.reg == nil {
return false
}
e, err := a.reg.Get(engine)
if err != nil {
return false
}
c, ok := e.(engines.Configured)
return ok && c.Configured()
}
// disabled reports whether agent.yaml says this node never runs an engine:
// not installed when asked, and not advertised when found.
func (a *Agent) disabled(engine string) bool {
return slices.Contains(a.cfg.Engines.Disabled, engine)
}
// provisionEvery is how often installed engines are checked again. A library
// removed by an unrelated upgrade is found here rather than by the next
// replica to start.
const provisionEvery = 10 * time.Minute
// setProvision takes the control plane's list of wanted engines.
func (a *Agent) setProvision(p *llmgwv1.Provision) {
wants := []provision.Want{}
for _, e := range p.GetEngines() {
if a.disabled(e.GetEngine()) {
continue // a control plane older than the node's say in it
}
if a.byHand(e.GetEngine()) {
// The operator named a binary for it: a local build, a CUDA
// runtime of their own. Installing one beside it would win —
// the installed build is used first — and quietly replace theirs.
continue
}
wants = append(wants, provision.Want{Engine: e.GetEngine(), Version: e.GetVersion(), Variant: e.GetVariant()})
}
a.mu.Lock()
a.provWants = wants
a.mu.Unlock()
select {
case a.provKick <- struct{}{}:
default:
}
}
// provisionLoop installs and re-checks the wanted engines, one pass at a time.
func (a *Agent) provisionLoop(ctx context.Context) {
// After a failure the next pass comes sooner, and backs off: a lock held
// by a new machine's own updates clears in a minute or two, while ten
// minutes' wait left a node idle for no reason.
next := provisionEvery
retry := time.Minute
for {
select {
case <-ctx.Done():
return
case <-a.provKick:
case <-time.After(next):
}
a.mu.Lock()
wants := slices.Clone(a.provWants)
managed := a.provWants != nil
a.mu.Unlock()
if !managed {
continue
}
statuses := a.prov.Converge(ctx, wants)
next = provisionEvery
for _, st := range statuses {
if st.State == provision.Failed {
next, retry = retry, min(retry*2, provisionEvery)
break
}
}
if next == provisionEvery {
retry = time.Minute
}
installed := map[string]string{}
keep := map[string]bool{}
for _, st := range statuses {
keep[st.Engine] = true
if st.State == provision.Ready {
a.reg.UseInstalled(st.Engine, st.Binary)
installed[st.Engine] = st.Version
}
}
a.mu.Lock()
if enginesFingerprint(installed) != enginesFingerprint(a.installedVers) {
a.log.Info("engines installed and running", "engines", engines.ProbedNames(installed))
}
a.installedVers = installed
// An engine no longer asked for is no longer reported on.
for e := range a.provStatus {
if !keep[e] {
delete(a.provStatus, e)
}
}
a.mu.Unlock()
}
}
// provisionStatus records a change in one engine's installation.
func (a *Agent) provisionStatus(st provision.Status) {
a.mu.Lock()
prev, had := a.provStatus[st.Engine]
a.provStatus[st.Engine] = st
a.mu.Unlock()
if !had || prev.State != st.State || prev.Detail != st.Detail {
lvl := slog.LevelInfo
if st.State == provision.Failed || st.State == provision.Unsupported {
lvl = slog.LevelWarn
}
a.log.Log(context.Background(), lvl, "engine installation",
"engine", st.Engine, "version", st.Version, "variant", st.Variant,
"state", st.State, "detail", st.Detail)
}
}
func provisionFingerprint(m map[string]provision.Status) string {
var b strings.Builder
for _, k := range slices.Sorted(maps.Keys(m)) {
st := m[k]
fmt.Fprintf(&b, "%s|%s|%s|%s|%s\n", st.Engine, st.Version, st.Variant, st.State, st.Detail)
}
return b.String()
}
// engineReprobe is how often the installed engines are looked for again.
//
// Minutes, not seconds: an engine is installed by somebody at a keyboard, so a
// minute's delay in noticing is invisible, while SGLang's probe starts a
// Python interpreter that imports it.
const engineReprobe = time.Minute
// reprobeEngines looks for installed engines again when it is time, off the
// heartbeat goroutine so a slow probe never delays a heartbeat.
func (a *Agent) reprobeEngines() {
a.mu.Lock()
due := !a.probingEngines && !a.lastEngineProbe.IsZero() &&
time.Since(a.lastEngineProbe) >= engineReprobe
if due {
a.probingEngines = true
}
a.mu.Unlock()
if !due {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
vers := a.reg.ProbeConfigured(ctx)
a.mu.Lock()
changed := enginesFingerprint(vers) != enginesFingerprint(a.configuredVers)
a.configuredVers, a.lastEngineProbe, a.probingEngines = vers, time.Now(), false
a.mu.Unlock()
if changed {
a.log.Info("engines named in the configuration changed",
"engines", engines.ProbedNames(vers))
}
}()
}
func enginesFingerprint(vers map[string]string) string {
var b strings.Builder
for _, k := range slices.Sorted(maps.Keys(vers)) {
fmt.Fprintf(&b, "%s=%s\n", k, vers[k])
}
return b.String()
}
+114 -16
View File
@@ -27,6 +27,9 @@ import (
type Want struct {
Engine string
Version string
// Variant is a build chosen for this node ("cu129"); empty leaves it to
// the node's hardware.
Variant string
}
// The states an engine's installation can be in.
@@ -135,7 +138,12 @@ func (in *Installer) one(ctx context.Context, w Want) Status {
"; install it by hand and name it in the agent's configuration"
return st
}
build, err := recipe.Choose(w.Version, in.Host)
if w.Variant != "" && !validVersion.MatchString(w.Variant) {
st.State = Unsupported
st.Detail = fmt.Sprintf("%q is not a build this agent will install", w.Variant)
return st
}
builds, err := recipe.Candidates(w.Version, w.Variant, in.Host)
if err != nil {
st.State, st.Detail = Unsupported, err.Error()
var nh NotHere
@@ -144,14 +152,18 @@ func (in *Installer) one(ctx context.Context, w Want) Status {
}
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 {
// Installed before: whichever candidate is, best first, checked that it
// still runs and trusted for nothing else.
for _, build := range builds {
dir := in.dirFor(w, build)
if _, err := os.Stat(filepath.Join(dir, marker)); err != nil {
continue
}
st := st.as(w, build)
// 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.
// 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
@@ -161,24 +173,64 @@ func (in *Installer) one(ctx context.Context, w Want) Status {
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)
"engine", w.Engine, "version", st.Version, "variant", build.Variant, "err", err)
break
}
if err := in.install(ctx, recipe, w, build, dir, &st); err != nil {
st.State, st.Detail, st.Binary = Failed, err.Error(), ""
return st
// Each candidate in turn until one installs and is seen to work: a build
// the table was wrong about — a driver too old for it after all, a GPU
// it has no kernels for — costs its download, and the next is tried.
var tried []string
for _, build := range builds {
st := st.as(w, build)
if err := in.install(ctx, recipe, w, build, in.dirFor(w, build), &st); err != nil {
tried = append(tried, build.Variant+": "+err.Error())
in.log().Warn("an engine build did not work here; trying the next",
"engine", w.Engine, "variant", build.Variant, "err", err)
continue
}
bin, version, err := in.verify(ctx, in.dirFor(w, build), build)
if err != nil {
tried = append(tried, build.Variant+": "+err.Error())
continue
}
return in.ready(st, build, bin, version)
}
bin, version, err := in.verify(ctx, dir, build)
if err != nil {
st.State, st.Detail = Failed, err.Error()
return st
st.State, st.Binary = Failed, ""
st.Detail = strings.Join(tried, "; then ")
if len(builds) == 1 {
_, st.Detail, _ = strings.Cut(st.Detail, ": ")
}
return in.ready(st, build, bin, version)
return st
}
// dirFor is where a build of an engine is installed: by the version it is
// and its variant, so two builds of one version sit side by side.
func (in *Installer) dirFor(w Want, b Build) string {
return filepath.Join(in.root(), w.Engine, b.versionOr(w.Version)+"-"+b.Variant)
}
// versionOr is the version a build is: its own, when it is not the one asked
// for.
func (b Build) versionOr(asked string) string {
if b.Version != "" {
return b.Version
}
return asked
}
// as is a status for one build of what was asked for.
func (st Status) as(w Want, b Build) Status {
st.Variant, st.Version = b.Variant, b.versionOr(w.Version)
return st
}
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)
if b.Note != "" {
st.Detail += "; " + b.Note
}
var missing []string
for _, f := range b.CPUFlags {
if !in.Host.HasCPUFlag(f) {
@@ -299,9 +351,55 @@ func (in *Installer) verify(ctx context.Context, dir string, b Build) (bin, vers
if err != nil {
return "", "", fmt.Errorf("%s --version failed: %v: %s", b.Binary, err, lastLine(out))
}
if b.GPUBackend != "" {
if err := in.verifyGPUBackend(tctx, dir, bin, b); err != nil {
return "", "", err
}
}
return bin, firstVersionLine(out), nil
}
// verifyGPUBackend checks a GPU build can compute on this node's GPUs.
//
// The binary's own ldd says nothing about it: the GPU backend is a library
// the server opens at run time, and when it cannot — cuBLAS missing, a driver
// too old — the server carries on with its CPU backend. So the build is asked
// which devices it sees, and when it sees none, ldd on the backend says why.
func (in *Installer) verifyGPUBackend(ctx context.Context, dir, bin string, b Build) error {
out, err := in.run(ctx, nil, bin, "--list-devices")
if err != nil {
return fmt.Errorf("%s --list-devices failed: %v: %s", b.Binary, err, lastLine(out))
}
if listsDevice(string(out)) {
return nil
}
why := "its GPU backend did not load"
if lib, err := findFile(dir, b.GPUBackend); err != nil {
why = "the build has no " + b.GPUBackend
} else if out, err := in.run(ctx, nil, "ldd", lib); err == nil {
if missing := lddMissing(string(out)); len(missing) > 0 {
why = b.GPUBackend + " cannot load: " + strings.Join(missing, "; ")
}
}
return fmt.Errorf("the %s build of %s sees no GPU, and would run on the CPU: %s", b.Variant, b.Binary, why)
}
// listsDevice reads llama-server --list-devices: a device line after
// "Available devices:", which holds "(none)" when there is none.
func listsDevice(out string) bool {
listing := false
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "Available devices"):
listing = true
case listing && line != "" && line != "(none)":
return true
}
}
return false
}
// packages installs the operating system packages a build needs.
//
// Only those missing, and from the node's own package sources: a package the
+67 -5
View File
@@ -89,7 +89,11 @@ type fakeSystem struct {
installed map[string]bool
tools map[string]bool
lddOut string
calls []string
// devices is what llama-server --list-devices prints, and backendLdd
// what ldd says of its GPU backend.
devices string
backendLdd string
calls []string
// locked is how many more package commands find the lock held.
locked int
}
@@ -127,8 +131,12 @@ func (f *fakeSystem) run(_ context.Context, _ []string, name string, args ...str
}
}
return nil, nil
case name == "ldd" && strings.HasSuffix(args[0], "libggml-cuda.so"):
return []byte(f.backendLdd), nil
case name == "ldd":
return []byte(f.lddOut), nil
case strings.HasSuffix(name, "llama-server") && args[0] == "--list-devices":
return []byte(f.devices), nil
case strings.HasSuffix(name, "llama-server"):
return []byte("version: 10991 (abc)\n"), nil
}
@@ -247,13 +255,38 @@ func TestAnArchiveCannotWriteOutsideItsDirectory(t *testing.T) {
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)
got, err := llamaCppCandidates("b1", "", gpu)
if err != nil || got[0].Variant != "cuda-12.8" || len(got[0].Assets) != 2 || got[len(got)-1].Variant != "vulkan" {
t.Errorf("an NVIDIA node on driver 570 got %+v, %v; expected CUDA 12.8 with its runtime, then Vulkan", got, err)
}
if _, err := chooseLlamaCpp("b1", discovery.Host{OSID: "ubuntu", Arch: "riscv64"}); err == nil {
if _, err := llamaCppCandidates("b1", "", discovery.Host{OSID: "ubuntu", Arch: "riscv64"}); err == nil {
t.Error("a build was chosen for an architecture llama.cpp does not publish for")
}
for _, c := range []struct {
name string
host discovery.Host
want []string
}{
// Driver 580 and Turing: CUDA 13.3 first.
{"current", discovery.Host{Arch: "amd64", NVIDIADriver: "580.65.06", MinComputeCapability: 8.0}, []string{"cuda-13.3", "cuda-12.8", "vulkan"}},
// A V100 on driver 550: CUDA 12.8 ships 7.0 as PTX, which needs 570.
{"V100, 550", discovery.Host{Arch: "amd64", NVIDIADriver: "550.54.15", MinComputeCapability: 7.0}, []string{"vulkan"}},
// An RTX 3090 on 550: 8.6 is machine code in the CUDA 12.8 build.
{"3090, 550", discovery.Host{Arch: "amd64", NVIDIADriver: "550.54.15", MinComputeCapability: 8.6}, []string{"cuda-12.8", "vulkan"}},
// A Pascal card: CUDA 12.8, not 13.3.
{"P40", discovery.Host{Arch: "amd64", NVIDIADriver: "575.51.03", MinComputeCapability: 6.1}, []string{"cuda-12.8", "vulkan"}},
// arm64 with a GPU: never the CPU build.
{"GH200", discovery.Host{Arch: "arm64", NVIDIADriver: "580.65.06", MinComputeCapability: 9.0}, []string{"cuda-13.3", "vulkan"}},
} {
got, err := llamaCppCandidates("b1", "", c.host)
var names []string
for _, b := range got {
names = append(names, b.Variant)
}
if err != nil || strings.Join(names, " ") != strings.Join(c.want, " ") {
t.Errorf("%s: %v, %v; want %v", c.name, names, err, c.want)
}
}
slow := ubuntu
slow.CPUFlags = nil
@@ -388,3 +421,32 @@ func TestALibrarysVersionLinkIsStillUnpacked(t *testing.T) {
t.Errorf("link %q %v", target, err)
}
}
func TestACUDABuildThatSeesNoGPUIsNotInstalled(t *testing.T) {
// Found in the field: libggml-cuda.so in the build, cuBLAS nowhere the
// loader looks, and llama-server serving a GPU placement on the CPU.
cuda := "bin-ubuntu-cuda-12.8-x64.tar.gz"
rel := newRelease(map[string][]byte{
"llama-b1-" + cuda: tarGz(t, map[string]string{
"llama-b1/llama-server": "#!/bin/sh\n", "llama-b1/libggml-cuda.so": "x"}, nil),
"cudart-llama-b1-" + cuda: tarGz(t, map[string]string{"libcudart.so.12": "x"}, nil),
})
gpu := ubuntu
gpu.NVIDIADriver = "570.86.15"
sys := &fakeSystem{installed: map[string]bool{"libgomp1": true},
devices: "Available devices:\n (none)\n",
backendLdd: "\tlibcublas.so.12 => not found\n\tlibcudart.so.12 => /x/libcudart.so.12\n"}
in, _ := installer(t, rel.serve(t), sys, gpu)
got := in.Converge(t.Context(), []Want{{Engine: "llamacpp", Version: "b1"}})
if got[0].State != Failed || !strings.Contains(got[0].Detail, "sees no GPU") ||
!strings.Contains(got[0].Detail, "libcublas.so.12 is not installed") {
t.Fatalf("expected a failure naming the missing library: %+v", got[0])
}
// The same build seeing its GPU is installed.
sys.devices = "Available devices:\n CUDA0: NVIDIA L4 (22994 MiB, 22713 MiB free)\n"
if got := in.Converge(t.Context(), []Want{{Engine: "llamacpp", Version: "b1"}}); got[0].State != Ready {
t.Fatalf("a CUDA build that sees its GPU: %+v", got[0])
}
}
+76 -10
View File
@@ -36,6 +36,10 @@ const lockName = "llmgw-requirements.txt"
func (in *Installer) installPython(ctx context.Context, r Recipe, w Want, b Build, dir string, st *Status) error {
ctx, cancel := context.WithTimeout(ctx, pythonInstallTimeout)
defer cancel()
// The version this build is, which for a GPU older than every build of
// the one asked for is an older one: its publication date is the one
// the dependencies are resolved as of.
w.Version = b.versionOr(w.Version)
if err := in.packages(ctx, b.Packages, st); err != nil {
return err
@@ -86,28 +90,67 @@ func (in *Installer) installPython(ctx context.Context, r Recipe, w Want, b Buil
}
python := filepath.Join(tmp, "bin", "python")
in.progress(st, "resolving %s against PyPI as of %s", strings.Join(b.Requirements, " "), asOf[:10])
reqs := b.Requirements
if b.Wheel != nil {
// The engine from its GitHub release, pinned to the digest GitHub
// publishes for the file: the same trust as an archive, and uv
// refuses a download that does not match it.
in.progress(st, "asking GitHub for %s %s's %s…%s", b.Wheel.Repo, b.Wheel.Tag, b.Wheel.Prefix, b.Wheel.Suffix)
sums, err := in.checksums(ctx, b.Wheel.Repo, b.Wheel.Tag)
if err != nil {
return err
}
var asset, sum string
for name, s := range sums {
if strings.HasPrefix(name, b.Wheel.Prefix) && strings.HasSuffix(name, b.Wheel.Suffix) {
asset, sum = name, s
}
}
if asset == "" {
return fmt.Errorf("%s %s publishes no %s…%s", b.Wheel.Repo, b.Wheel.Tag, b.Wheel.Prefix, b.Wheel.Suffix)
}
url := strings.TrimSuffix(in.downloadBase(), "/") + "/" + b.Wheel.Repo + "/releases/download/" +
b.Wheel.Tag + "/" + asset
reqs = append([]string{b.Wheel.Project + " @ " + url + "#sha256=" + sum}, reqs...)
}
// A second index, for the torch the engine was built on. Every package
// is taken from whichever of the two has the version resolved — both are
// the publishers' own — and each file is still pinned by its hash.
var index []string
if b.ExtraIndex != "" {
index = []string{"--extra-index-url", b.ExtraIndex, "--index-strategy", "unsafe-best-match"}
}
in.progress(st, "resolving %s against PyPI as of %s", strings.Join(reqs, " "), asOf[:10])
lock := filepath.Join(tmp, lockName)
args := []string{"pip", "compile", "--python", python, "--exclude-newer", asOf,
"--generate-hashes", "--quiet", "--output-file", lock, "-"}
if out, err := in.runStdin(ctx, env, strings.Join(b.Requirements, "\n")+"\n", uv.Binary, args...); err != nil {
return fmt.Errorf("resolving %s: %v: %s", strings.Join(b.Requirements, " "), err, tail(out))
args := append([]string{"pip", "compile", "--python", python, "--exclude-newer", asOf,
"--generate-hashes", "--quiet", "--output-file", lock}, index...)
args = append(args, "-")
if out, err := in.runStdin(ctx, env, strings.Join(reqs, "\n")+"\n", uv.Binary, args...); err != nil {
return fmt.Errorf("resolving %s: %v: %s", strings.Join(reqs, " "), err, tail(out))
}
in.progress(st, "downloading and installing %s and its dependencies: several gigabytes, which takes minutes",
strings.Join(b.Requirements, " "))
if out, err := in.run(ctx, env, uv.Binary, "pip", "install", "--python", python,
"--require-hashes", "--no-deps", "-r", lock); err != nil {
return fmt.Errorf("installing %s: %v: %s", strings.Join(b.Requirements, " "), err, tail(out))
strings.Join(reqs, " "))
install := append([]string{"pip", "install", "--python", python,
"--require-hashes", "--no-deps", "-r", lock}, index...)
if out, err := in.run(ctx, env, uv.Binary, install...); err != nil {
return fmt.Errorf("installing %s: %v: %s", strings.Join(reqs, " "), err, tail(out))
}
in.progress(st, "checking that %s runs", w.Engine)
if _, _, err := in.verify(ctx, tmp, b); err != nil {
return err
}
if b.GPUCheck && in.Host.NVIDIADriver != "" {
in.progress(st, "checking that %s (%s) computes on this node's GPU", w.Engine, b.Variant)
if err := in.gpuCheck(ctx, python, w, b); err != nil {
return err
}
}
m, _ := json.MarshalIndent(markerFile{
Engine: w.Engine, Version: w.Version, Variant: b.Variant,
Binary: filepath.Join("bin", b.Binary), Requirements: b.Requirements,
Binary: filepath.Join("bin", b.Binary), Requirements: reqs,
ExcludeNewer: asOf, UV: UVVersion, Installed: time.Now().UTC(),
}, "", " ")
if err := os.WriteFile(filepath.Join(tmp, marker), m, 0o644); err != nil { //nolint:gosec // not secret
@@ -203,3 +246,26 @@ func tail(b []byte) string {
}
return strings.Join(lines, " / ")
}
// gpuProbe is what the environment is asked to run on the GPU: one small
// operation, which needs the torch to have kernels for the card.
const gpuProbe = `import torch
assert torch.cuda.is_available(), "torch sees no CUDA device"
x = torch.ones(64, device="cuda")
print(float((x * 2).sum()), torch.cuda.get_device_name(0), torch.cuda.get_device_capability(0))`
// gpuCheck runs gpuProbe in a freshly installed environment.
func (in *Installer) gpuCheck(ctx context.Context, python string, w Want, b Build) error {
ctx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
out, err := in.run(ctx, nil, python, "-c", gpuProbe)
if err != nil {
hint := ""
if strings.Contains(string(out), "no kernel image") {
hint = "; this build has no kernels for this GPU, so try the other build for this node"
}
return fmt.Errorf("%s %s (%s) installed but cannot compute on this node's GPU: %s%s",
w.Engine, w.Version, b.Variant, tail(out), hint)
}
return nil
}
+130 -2
View File
@@ -2,6 +2,7 @@ package provision
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
@@ -23,6 +24,10 @@ type fakePython struct {
pmu sync.Mutex
commands []string
failPip bool
// noKernel makes the GPU check fail as a torch without kernels for the
// card does: in every build, or in noKernelIn only ("-cu13.").
noKernel bool
noKernelIn string
}
func (f *fakePython) run(ctx context.Context, env []string, name string, args ...string) ([]byte, error) {
@@ -43,6 +48,11 @@ func (f *fakePython) run(ctx context.Context, env []string, name string, args ..
return nil, os.WriteFile(filepath.Join(venv, "bin", "vllm"), []byte("#!/bin/sh\n"), 0o755)
case strings.HasSuffix(name, "/bin/vllm") && args[0] == "--version":
return []byte("0.30.0\n"), nil
case strings.HasSuffix(name, "/bin/python") && args[0] == "-c":
if f.noKernel || (f.noKernelIn != "" && strings.Contains(name, f.noKernelIn)) {
return []byte("RuntimeError: CUDA error: no kernel image is available for execution on the device\n"), errExit
}
return []byte("128.0 Tesla V100-SXM2-32GB (7, 0)\n"), nil
}
return f.fakeSystem.run(ctx, env, name, args...)
}
@@ -59,6 +69,8 @@ func pythonInstaller(t *testing.T, host discovery.Host, sys *fakePython) (*Insta
uv := newRelease(map[string][]byte{
"uv-x86_64-unknown-linux-gnu.tar.gz": tarGz(t,
map[string]string{"uv-x86_64-unknown-linux-gnu/uv": "#!/bin/sh\n"}, nil),
// The fake GitHub serves every repository's release from this one.
"vllm-0.30.0+cu129-cp38-abi3-manylinux_2_28_x86_64.whl": []byte("the cu129 wheel"),
})
gh := uv.serve(t)
mux := http.NewServeMux()
@@ -66,6 +78,9 @@ func pythonInstaller(t *testing.T, host discovery.Host, sys *fakePython) (*Insta
_, _ = w.Write([]byte(`{"urls":[{"upload_time_iso_8601":"2026-09-22T05:22:21.647189Z"},
{"upload_time_iso_8601":"2026-09-22T05:23:10.039243Z"}]}`))
})
mux.HandleFunc("/pypi/vllm/0.19.1/json", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"urls":[{"upload_time_iso_8601":"2026-04-02T10:00:00Z"}]}`))
})
pypi := httptest.NewServer(mux)
t.Cleanup(pypi.Close)
return &Installer{
@@ -130,8 +145,10 @@ func TestVLLMIsNotForEveryNode(t *testing.T) {
says string
}{
{"no GPU", ubuntu, Skipped, "has none"},
{"old driver", discovery.Host{Arch: "amd64", NVIDIADriver: "550.54.15"}, Unsupported, "driver 580"},
{"V100", discovery.Host{Arch: "amd64", NVIDIADriver: "580.65.06", MinComputeCapability: 7.0}, Unsupported, "7.5"},
// Too old for CUDA 13 and for its CUDA 12.9 build both.
{"a driver too old for either", discovery.Host{Arch: "amd64", NVIDIADriver: "470.256.02"}, Unsupported, "driver 525"},
// No vLLM was ever built for Pascal.
{"P100", discovery.Host{Arch: "amd64", NVIDIADriver: "580.65.06", MinComputeCapability: 6.0}, Unsupported, "no vLLM is built for GPUs of compute capability 6.0"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -182,3 +199,114 @@ func TestAVersionPyPIDoesNotHaveIsSaidSo(t *testing.T) {
t.Errorf("got %s %q", got[0].State, got[0].Detail)
}
}
func TestAnOlderDriverGetsTheSameVLLMBuiltForCUDA12(t *testing.T) {
// A Turing card on a driver too old for CUDA 13: the same vLLM's CUDA
// 12.9 build, from the project's GitHub release, with the torch PyTorch
// publishes for 12.9.
volta := discovery.Host{OSID: "ubuntu", Arch: "amd64", NVIDIADriver: "550.54.15", MinComputeCapability: 7.5}
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
in, gh := pythonInstaller(t, volta, sys)
got := in.Converge(t.Context(), []Want{{Engine: "vllm", Version: "0.30.0"}})
if got[0].State != Ready || got[0].Variant != "cu129" {
t.Fatalf("a Turing node on driver 550: %+v", got[0])
}
if _, err := os.Stat(filepath.Join(in.Root, "vllm", "0.30.0-cu129", marker)); err != nil {
t.Errorf("not installed under its own build's name: %v", err)
}
all := strings.Join(sys.commands, "\n")
wheel := "vllm-0.30.0+cu129-cp38-abi3-manylinux_2_28_x86_64.whl"
for _, want := range []string{
// Pinned to the digest GitHub publishes for the wheel.
"vllm @ " + in.DownloadBase + "/vllm-project/vllm/releases/download/v0.30.0/" + wheel + "#sha256=" +
strings.TrimPrefix(gh.digests[wheel], "sha256:"),
"--extra-index-url https://download.pytorch.org/whl/cu129 --index-strategy unsafe-best-match -",
"--require-hashes --no-deps -r",
// Computing on the card, not only importing.
"/bin/python -c import torch",
} {
if !strings.Contains(all, want) {
t.Errorf("no %q in:\n%s", want, all)
}
}
for _, c := range sys.commands {
if strings.Contains(c, "pip install") && !strings.Contains(c, "download.pytorch.org/whl/cu129") {
t.Errorf("installed without the index its torch comes from: %s", c)
}
}
}
func TestABuildChosenForANodeIsTheOneInstalled(t *testing.T) {
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
in, _ := pythonInstaller(t, gpuNode, sys)
if got := in.Converge(t.Context(), []Want{{Engine: "vllm", Version: "0.30.0", Variant: "cu129"}}); got[0].Variant != "cu129" || got[0].State != Ready {
t.Errorf("cu129 chosen for a node that runs cu13 too: %+v", got[0])
}
if _, err := vllmCandidates("0.30.0", "cu200", gpuNode); err == nil {
t.Error("a build that does not exist was accepted")
}
}
func TestABuildThatCannotComputeOnTheCardIsNotInstalled(t *testing.T) {
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}, noKernel: true}
in, _ := pythonInstaller(t, gpuNode, sys)
got := in.Converge(t.Context(), []Want{{Engine: "vllm", Version: "0.30.0"}})
if got[0].State != Failed || !strings.Contains(got[0].Detail, "no kernels for this GPU") {
t.Fatalf("a build without kernels for the card: %+v", got[0])
}
if _, err := os.Stat(filepath.Join(in.Root, "vllm", "0.30.0-cu13", marker)); err == nil {
t.Error("marked installed")
}
}
func TestOnlyACUDARequirementFallsBackToTheCUDA12Build(t *testing.T) {
// A machine vLLM is not published for is refused, not given the other
// build to fail with.
if _, err := vllmCandidates("0.30.0", "", discovery.Host{NVIDIADriver: "580.65.06"}); err == nil ||
!strings.Contains(err.Error(), "no Linux build") {
t.Errorf("an unknown architecture: %v", err)
}
if _, err := vllmCandidates("0.30.0", "cu129", ubuntu); !errors.As(err, new(NotHere)) {
t.Errorf("a node with no GPU chosen cu129: %v", err)
}
// Driver 580 and Turing: CUDA 13 first, the CUDA 12.9 build behind it.
got, err := vllmCandidates("0.30.0", "", gpuNode)
if err != nil || len(got) != 2 || got[0].Variant != "cu13" || got[1].Variant != "cu129" {
t.Errorf("candidates on a current node: %+v, %v", got, err)
}
}
func TestAV100GetsTheNewestVLLMBuiltForItByItself(t *testing.T) {
// 0.30.0 asked for; no build of it has Volta kernels. The node installs
// 0.19.1, the newest that has, and says so.
v100 := discovery.Host{OSID: "ubuntu", Arch: "amd64", NVIDIADriver: "550.54.15", MinComputeCapability: 7.0}
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
in, _ := pythonInstaller(t, v100, sys)
got := in.Converge(t.Context(), []Want{{Engine: "vllm", Version: "0.30.0"}})[0]
if got.State != Ready || got.Version != "0.19.1" || got.Variant != "cu12" ||
!strings.Contains(got.Detail, "the newest built for compute capability 7.0, instead of 0.30.0") {
t.Fatalf("a V100: %+v", got)
}
if _, err := os.Stat(filepath.Join(in.Root, "vllm", "0.19.1-cu12", marker)); err != nil {
t.Errorf("not installed as what it is: %v", err)
}
all := strings.Join(sys.commands, "\n")
if !strings.Contains(all, "<<< vllm==0.19.1") || strings.Contains(all, "download.pytorch.org") {
t.Errorf("0.19.1 is PyPI's CUDA 12 build, with PyPI's torch:\n%s", all)
}
// Chosen cu13, which 0.19.1 has none of: refused, saying why.
if _, err := vllmCandidates("0.30.0", "cu13", v100); err == nil {
t.Error("a CUDA 13 build was offered to a V100")
}
}
func TestABuildThatFailsOnTheCardGivesWayToTheNext(t *testing.T) {
// The table says CUDA 13 runs here; the card says otherwise. The next
// build is installed rather than the node going without.
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}, noKernelIn: "-cu13."}
in, _ := pythonInstaller(t, gpuNode, sys)
got := in.Converge(t.Context(), []Want{{Engine: "vllm", Version: "0.30.0"}})[0]
if got.State != Ready || got.Variant != "cu129" {
t.Fatalf("after the CUDA 13 build failed on the card: %+v", got)
}
}
+345 -87
View File
@@ -43,12 +43,41 @@ type Build struct {
// VersionArgs is what Binary is run with to prove the build works, when
// --version is not it.
VersionArgs []string
// GPUBackend, when set, is the shared library the build computes on GPUs
// with, loaded at run time rather than linked: "libggml-cuda.so". The
// build must list a device with it, or it is not installed — a llama.cpp
// whose CUDA backend cannot load runs on the CPU without complaint.
GPUBackend string
// Wheel, when set, is the engine itself as a wheel from a GitHub
// release rather than from PyPI, checked against the digest GitHub
// publishes for it. ExtraIndex is a package index besides PyPI that the
// rest resolves from too: the CUDA 12.9 torch the wheel was built on.
Wheel *Wheel
ExtraIndex string
// GPUCheck, on a node with an NVIDIA GPU, has the installed environment
// compute something on one before the build counts as installed. A build
// with no kernels for the card — a GPU generation its torch dropped —
// imports and prints its version, and fails on its first model.
GPUCheck bool
// Version is the engine version this build is, when it is not the one
// asked for: the newest built for this machine's GPUs, where the one
// asked for has no kernels for them. Empty is the version asked for.
Version string
// Note says why this build, when it is not the plain one: shown beside
// it wherever the installation is.
Note string
// FreeGB is the disk space the installation needs before it starts: a
// Python engine is several gigabytes, and one that fills the disk halfway
// leaves the node worse off than one that never began.
FreeGB int
}
// Wheel is a package published as a GitHub release asset: the asset whose
// name starts with Prefix and ends with Suffix.
type Wheel struct {
Project, Repo, Tag, Prefix, Suffix string
}
// Recipe knows how to install one engine.
type Recipe struct {
// Repo is the GitHub repository whose releases carry the builds; empty
@@ -56,8 +85,12 @@ type Recipe struct {
Repo string
// PyPI is the project an engine installed from PyPI is published as.
PyPI string
// Choose picks the build for a machine, or says why none fits.
Choose func(version string, h discovery.Host) (Build, error)
// Candidates are the builds that may run on a machine, best first, or
// why none can. The installer takes the first that installs and is seen
// to work — computing on the GPU, where there is one — so a build this
// table is wrong about costs a download, not a node. variant is a build
// chosen for the node; empty leaves the choice to the machine.
Candidates func(version, variant string, h discovery.Host) ([]Build, error)
}
// Recipes are the engines this agent can install.
@@ -66,10 +99,10 @@ type Recipe struct {
// from PyPI by uv, which is itself installed like llama.cpp: a GitHub release
// checked against its published checksum.
var Recipes = map[string]Recipe{
"llamacpp": {Repo: "ggml-org/llama.cpp", Choose: chooseLlamaCpp},
"vllm": {PyPI: "vllm", Choose: chooseVLLM},
"sglang": {PyPI: "sglang", Choose: chooseSGLang},
"uv": {Repo: "astral-sh/uv", Choose: chooseUV},
"llamacpp": {Repo: "ggml-org/llama.cpp", Candidates: llamaCppCandidates},
"vllm": {PyPI: "vllm", Candidates: vllmCandidates},
"sglang": {PyPI: "sglang", Candidates: sglangCandidates},
"uv": {Repo: "astral-sh/uv", Candidates: uvCandidates},
}
// Installable are the engines this agent can install, as it tells the
@@ -99,7 +132,15 @@ type NotHere struct{ Reason string }
func (e NotHere) Error() string { return e.Reason }
func chooseUV(version string, h discovery.Host) (Build, error) {
func uvCandidates(version, _ string, h discovery.Host) ([]Build, error) {
b, err := chooseUV(version, h)
if err != nil {
return nil, err
}
return []Build{b}, nil
}
func chooseUV(_ string, h discovery.Host) (Build, error) {
var arch string
switch h.Arch {
case "amd64":
@@ -116,33 +157,227 @@ func chooseUV(version string, h discovery.Host) (Build, error) {
}, nil
}
// minDriverForCUDA13 is the oldest NVIDIA driver CUDA 13 runs on. Every vLLM
// since 0.26 and SGLang 0.5 is built on torch for CUDA 13.
const minDriverForCUDA13 = 580
// What builds need of the NVIDIA side of a machine, read from what each
// project publishes and how it was built:
//
// - CUDA 13 needs driver 580; CUDA 12 builds run on driver 525 and newer,
// by CUDA's minor version compatibility — except code shipped only as
// PTX, which the driver compiles and so needs a driver as new as the
// CUDA it was compiled with.
// - Which GPUs a build has kernels for is the build's own list, not what
// its CUDA supports.
const (
minDriverForCUDA13 = 580
minDriverForCUDA12 = 525
)
// minComputeForCUDA13 is the oldest GPU CUDA 13 supports: Turing. It dropped
// Volta, so a V100 cannot run these builds at all.
const minComputeForCUDA13 = 7.5
// gpuNeeds is what one build needs of a machine's GPUs and driver.
type gpuNeeds struct {
driver int
cc float64
}
// cudaGate says whether a node can run an engine built on CUDA 13.
func cudaGate(engine string, h discovery.Host) error {
// fits says why a machine cannot run a build, or nil.
func (n gpuNeeds) fits(what string, h discovery.Host) error {
if major := leadingInt(h.NVIDIADriver); n.driver > 0 && major > 0 && major < n.driver {
return fmt.Errorf("%s needs NVIDIA driver %d or newer; this node has %s", what, n.driver, h.NVIDIADriver)
}
if cc := h.MinComputeCapability; n.cc > 0 && cc > 0 && cc < n.cc {
return fmt.Errorf("%s has kernels for compute capability %.1f and newer; this node has %.1f", what, n.cc, cc)
}
return nil
}
// candidate is a build and what it needs.
type candidate struct {
b Build
needs gpuNeeds
what string
}
// pick keeps the candidates a machine can run, in order, and those a
// variant chosen for the node names; with none left, it says why each went.
func pick(engine, variant string, cands []candidate, h discovery.Host) ([]Build, error) {
var out []Build
var why []string
for _, c := range cands {
if variant != "" && c.b.Variant != variant {
continue
}
if err := c.needs.fits(c.what, h); err != nil {
why = append(why, err.Error())
continue
}
out = append(out, c.b)
}
switch {
case len(out) > 0:
return out, nil
case len(why) == 0 && variant != "":
var names []string
for _, c := range cands {
names = append(names, c.b.Variant)
}
return nil, fmt.Errorf("%s has no build %q here; it has %s", engine, variant, strings.Join(names, ", "))
}
return nil, fmt.Errorf("no build of %s runs on this node: %s", engine, strings.Join(why, "; "))
}
// --- vLLM ---
// VLLMVariants are the builds of vLLM a node can be given by choice: CUDA
// 13 from PyPI, and CUDA 12.9 from the project's GitHub release — the same
// version, built for drivers CUDA 13 does not run on.
var VLLMVariants = []string{"cu13", "cu129"}
// pytorchCU129 is where the CUDA 12.9 build's torch is published. PyPI's
// torch is built for CUDA 13; the cu129 vLLM wheel needs this one.
const pytorchCU129 = "https://download.pytorch.org/whl/cu129"
// vllmCUDA13Since is the first vLLM whose PyPI build is CUDA 13, read from
// its releases: 0.19.1 publishes "+cu130" beside a CUDA 12 default, 0.20.0
// "+cu129" beside a CUDA 13 one.
const vllmCUDA13Since = "0.20"
// vllmMinCompute is the oldest GPU a vLLM version's builds have kernels for,
// from its release pipeline: on x86, 7.0 (Volta) up to 0.19.1 — the
// Dockerfile's default "7.0 7.5 8.0 8.9 9.0 10.0 12.0" — and 7.5 from 0.20.0,
// CUDA 12.9 build included; on arm64, built for Grace machines, 8.7 before
// 0.20 and 8.0 since.
func vllmMinCompute(version, arch string) float64 {
old := versionBefore(version, vllmCUDA13Since)
switch {
case arch == "arm64" && old:
return 8.7
case arch == "arm64":
return 8.0
case old:
return 7.0
}
return 7.5
}
// vllmNewestFor is the newest vLLM built for GPUs of compute capability cc
// on an architecture, for GPUs older than every build of the version asked
// for: a V100 gets 0.19.1, where 0.20 and later have nothing for it. Nothing
// for a GPU no vLLM was built for.
var vllmNewestFor = []struct {
arch string
from float64 // the oldest GPU it covers
version string
}{
{"amd64", 7.0, "0.19.1"},
}
// vllmCandidates are the vLLM builds for a machine.
//
// The version asked for, in the build the machine runs: CUDA 13 where the
// driver allows, the same version's CUDA 12.9 build where it does not. One
// version across the cluster wherever the GPUs allow it, because the flags
// llmgw builds are checked against it. Where they do not — GPUs older than
// every build of that version — the newest version built for them, said so
// beside the installation: vLLM on a V100 at all, rather than not.
func vllmCandidates(version, variant string, h discovery.Host) ([]Build, error) {
arch, err := gpuArch("vLLM", h)
if err != nil {
return nil, err
}
v, note := version, ""
if cc := h.MinComputeCapability; cc > 0 && cc < vllmMinCompute(version, h.Arch) {
older := ""
for _, n := range vllmNewestFor {
if n.arch == h.Arch && cc >= n.from {
older = n.version
}
}
if older == "" {
return nil, fmt.Errorf("no vLLM is built for GPUs of compute capability %.1f on %s; "+
"the node serves llama.cpp models", cc, arch)
}
v, note = older, fmt.Sprintf("vLLM %s, the newest built for compute capability %.1f, instead of %s", older, cc, version)
}
pypi := func(variant string) Build {
b := Build{
Variant: variant,
Python: pythonForEngines,
Requirements: []string{"vllm==" + v},
Binary: "vllm",
Packages: compilerPackages,
FreeGB: 25,
GPUCheck: true,
Note: note,
}
if v != version {
b.Version = v
}
return b
}
minCC := vllmMinCompute(v, h.Arch)
if versionBefore(v, vllmCUDA13Since) {
// PyPI's own build is CUDA 12, and is the one there is; asked for
// cu129, this is it.
if variant == "cu129" {
variant = "cu12"
}
return pick("vLLM "+v, variant, []candidate{
{pypi("cu12"), gpuNeeds{minDriverForCUDA12, minCC}, "vLLM " + v},
}, h)
}
cu129 := pypi("cu129")
cu129.Requirements = nil
// Found in the release's assets rather than named: the manylinux tag
// in the file's name changes between versions (2_28, 2_31, 2_34).
cu129.Wheel = &Wheel{
Project: "vllm", Repo: "vllm-project/vllm", Tag: "v" + v,
Prefix: "vllm-" + v + "+cu129-", Suffix: "_" + map[string]string{"amd64": "x86_64", "arm64": "aarch64"}[h.Arch] + ".whl",
}
cu129.ExtraIndex = pytorchCU129
return pick("vLLM "+v, variant, []candidate{
{pypi("cu13"), gpuNeeds{minDriverForCUDA13, minCC}, "vLLM " + v + " built for CUDA 13"},
{cu129, gpuNeeds{minDriverForCUDA12, minCC}, "vLLM " + v + " built for CUDA 12.9"},
}, h)
}
// gpuArch says whether a Python GPU engine is published for this machine
// and whether it has an NVIDIA GPU at all.
func gpuArch(engine string, h discovery.Host) (string, error) {
switch h.Arch {
case "amd64", "arm64":
default:
return fmt.Errorf("%s publishes no Linux build for %s", engine, orUnknown(h.Arch))
return "", fmt.Errorf("%s publishes no Linux build for %s", engine, orUnknown(h.Arch))
}
if h.NVIDIADriver == "" {
return NotHere{engine + " runs on NVIDIA GPUs and this node has none; it serves llama.cpp models"}
return "", NotHere{engine + " runs on NVIDIA GPUs and this node has none; it serves llama.cpp models"}
}
if major := leadingInt(h.NVIDIADriver); major > 0 && major < minDriverForCUDA13 {
return fmt.Errorf("%s is built for CUDA 13, which needs NVIDIA driver %d or newer; this node has %s",
engine, minDriverForCUDA13, h.NVIDIADriver)
return h.Arch, nil
}
// --- SGLang ---
// sglangCUDA13Since is the first SGLang built for CUDA 13.
const sglangCUDA13Since = "0.5"
func sglangCandidates(version, variant string, h discovery.Host) ([]Build, error) {
if _, err := gpuArch("SGLang", h); err != nil {
return nil, err
}
if cc := h.MinComputeCapability; cc > 0 && cc < minComputeForCUDA13 {
return fmt.Errorf("%s is built for CUDA 13, which supports GPUs of compute capability %.1f and newer; this node has %.1f",
engine, minComputeForCUDA13, cc)
b := Build{
Variant: "cu13",
Python: pythonForEngines,
Requirements: []string{"sglang==" + version},
// SGLang is launched as a module, so what the agent is given is the
// environment's interpreter, and the proof is importing it.
Binary: "python",
VersionArgs: []string{"-c", "import sglang; print('sglang', sglang.__version__)"},
Packages: compilerPackages,
FreeGB: 25,
GPUCheck: true,
}
return nil
needs := gpuNeeds{minDriverForCUDA13, 7.5}
if versionBefore(version, sglangCUDA13Since) {
b.Variant, needs = "cu12", gpuNeeds{minDriverForCUDA12, 7.0}
}
return pick("SGLang "+version, variant, []candidate{{b, needs, "SGLang " + version}}, h)
}
// Python engines need a C compiler at run time: Triton builds a small
@@ -150,35 +385,27 @@ func cudaGate(engine string, h discovery.Host) error {
// that load fails long after the installation looked finished.
var compilerPackages = map[string][]string{"deb": {"gcc"}, "rpm": {"gcc"}}
func chooseVLLM(version string, h discovery.Host) (Build, error) {
if err := cudaGate("vLLM", h); err != nil {
return Build{}, err
// versionBefore reports whether version a is older than b, comparing the
// dotted numbers in turn ("0.25.1" before "0.26"). A part that is not a
// number counts as zero.
func versionBefore(a, b string) bool {
if b == "" {
return false
}
return Build{
Variant: "cu13",
Python: pythonForEngines,
Requirements: []string{"vllm==" + version},
Binary: "vllm",
Packages: compilerPackages,
FreeGB: 25,
}, nil
}
func chooseSGLang(version string, h discovery.Host) (Build, error) {
if err := cudaGate("SGLang", h); err != nil {
return Build{}, err
pa, pb := strings.Split(strings.TrimPrefix(a, "v"), "."), strings.Split(b, ".")
for i := 0; i < max(len(pa), len(pb)); i++ {
x, y := 0, 0
if i < len(pa) {
x = leadingInt(pa[i])
}
if i < len(pb) {
y = leadingInt(pb[i])
}
if x != y {
return x < y
}
}
// SGLang is launched as a module, so what the agent is given is the
// environment's interpreter, and the proof is importing it.
return Build{
Variant: "cu13",
Python: pythonForEngines,
Requirements: []string{"sglang==" + version},
Binary: "python",
VersionArgs: []string{"-c", "import sglang; print('sglang', sglang.__version__)"},
Packages: compilerPackages,
FreeGB: 25,
}, nil
return false
}
// leadingInt is the number a version string starts with: 580 of "580.65.06".
@@ -193,17 +420,30 @@ func leadingInt(s string) int {
return n
}
// 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"
// --- llama.cpp ---
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.
// llamaCUDA12Real are the GPUs llama.cpp's CUDA 12.8 build carries machine
// code for. The rest of what it covers — 5.0, 6.1, 7.0, 7.5, 8.0, 9.0 — it
// carries as PTX, which a driver older than the CUDA it came from cannot
// compile.
var llamaCUDA12Real = []float64{8.6, 8.9, 12.0}
// llamaCppCandidates are the llama.cpp builds for a machine.
//
// On an NVIDIA node, the GPU builds best first: CUDA 13.3 (driver 580,
// Turing and newer), CUDA 12.8 on x64 (Maxwell to Blackwell; driver 570 for
// the GPUs it ships as PTX, 525 for those it ships as machine code), and
// Vulkan, which runs on any card whose driver has Vulkan — the generations
// CUDA builds no longer cover. Never the CPU build on an NVIDIA node: that is
// a GPU placement served on the CPU, which is what installing checks for.
// The CPU build where there is no NVIDIA GPU.
//
// 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.
func llamaCppCandidates(version, variant string, h discovery.Host) ([]Build, error) {
var arch string
switch h.Arch {
case "amd64":
@@ -211,35 +451,53 @@ func chooseLlamaCpp(version string, h discovery.Host) (Build, error) {
case "arm64":
arch = "arm64"
default:
return Build{}, fmt.Errorf("llama.cpp publishes no Linux build for %s", orUnknown(h.Arch))
return nil, 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),
base := func(variant string, assets ...string) Build {
b := Build{
Variant: variant,
Assets: assets,
// 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"}
}
return b
}
return b, nil
if h.NVIDIADriver == "" {
return pick("llama.cpp", variant, []candidate{
{base("cpu", fmt.Sprintf("llama-%s-bin-ubuntu-%s.tar.gz", version, arch)), gpuNeeds{}, "llama.cpp"},
}, h)
}
cuda := func(cv string) Build {
name := fmt.Sprintf("bin-ubuntu-cuda-%s-%s.tar.gz", cv, arch)
// The release carries the CUDA runtime beside the build, so the node
// needs a driver and nothing else — no toolkit.
b := base("cuda-"+cv, fmt.Sprintf("llama-%s-%s", version, name), fmt.Sprintf("cudart-llama-%s-%s", version, name))
b.GPUBackend = "libggml-cuda.so"
return b
}
vulkan := base("vulkan", fmt.Sprintf("llama-%s-bin-ubuntu-vulkan-%s.tar.gz", version, arch))
vulkan.GPUBackend = "libggml-vulkan.so"
vulkan.Packages = map[string][]string{"deb": {"libgomp1", "libvulkan1"}, "rpm": {"libgomp", "vulkan-loader"}}
cuda12Driver := 570
if slices.Contains(llamaCUDA12Real, h.MinComputeCapability) {
cuda12Driver = minDriverForCUDA12
}
cands := []candidate{{cuda("13.3"), gpuNeeds{minDriverForCUDA13, 7.5}, "llama.cpp built for CUDA 13.3"}}
if arch == "x64" {
cands = append(cands, candidate{cuda("12.8"), gpuNeeds{cuda12Driver, 5.0}, "llama.cpp built for CUDA 12.8"})
}
cands = append(cands, candidate{vulkan, gpuNeeds{}, "llama.cpp built for Vulkan"})
return pick("llama.cpp", variant, cands, h)
}
func orUnknown(s string) string {
+58
View File
@@ -0,0 +1,58 @@
package agent
import (
"context"
"os"
"runtime"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
)
// upgradeExit is the exit status after an upgrade: non-zero, so systemd's
// Restart=on-failure starts the new binary, and distinct, so the journal says
// why the agent went.
const upgradeExit = 75
// exitForUpgrade ends the process; a variable so a test can see it called
// rather than have its own process end.
var exitForUpgrade = func() { os.Exit(upgradeExit) }
// upgradeTarget is the binary an upgrade replaces; a variable so a test
// replaces a file of its own rather than the test binary running it.
var upgradeTarget = os.Executable
// upgrade replaces this agent's binary with a release and restarts into it.
//
// Engines are not touched: an agent restart leaves them running and adopts
// them again, which is what makes upgrading a node something that can be done
// while it serves.
func (a *Agent) upgrade(ctx context.Context, m *llmgwv1.UpgradeAgent, out chan<- *llmgwv1.AgentMessage) {
reply := &llmgwv1.UpgradeReply{RequestId: m.GetRequestId(), Version: m.GetVersion()}
send := func() {
select {
case out <- &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_Upgrade{Upgrade: reply}}:
case <-ctx.Done():
}
}
a.log.Info("upgrading", "to", m.GetVersion(), "from", a.cfg.Version)
self, err := upgradeTarget()
if err == nil {
dctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
_, err = release.Install(dctx, nil, m.GetReleases(), m.GetVersion(), runtime.GOOS, runtime.GOARCH, self)
cancel()
}
if err != nil {
reply.Error = err.Error()
a.log.Error("upgrade failed; still running the old version", "to", m.GetVersion(), "err", err)
send()
return
}
send()
a.log.Info("upgraded; restarting into the new version", "to", m.GetVersion())
// A moment for the reply to leave before the stream goes.
time.Sleep(500 * time.Millisecond)
exitForUpgrade()
}
+78
View File
@@ -0,0 +1,78 @@
package agent
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
)
func TestAnAgentUpgradesItselfAndSaysSoBeforeRestarting(t *testing.T) {
body := []byte("the new agent")
sum := sha256.Sum256(body)
asset := release.AssetName("v9.9.9", runtime.GOOS, runtime.GOARCH)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v9.9.9/checksums.txt":
_, _ = w.Write([]byte(hex.EncodeToString(sum[:]) + " " + asset + "\n"))
case "/v9.9.9/" + asset:
_, _ = w.Write(body)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
bin := filepath.Join(t.TempDir(), "llmgw")
_ = os.WriteFile(bin, []byte("the old agent"), 0o755)
exited := make(chan struct{}, 1)
oldTarget, oldExit := upgradeTarget, exitForUpgrade
upgradeTarget = func() (string, error) { return bin, nil }
exitForUpgrade = func() { exited <- struct{}{} }
t.Cleanup(func() { upgradeTarget, exitForUpgrade = oldTarget, oldExit })
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)
}
out := make(chan *llmgwv1.AgentMessage, 1)
go a.upgrade(t.Context(), &llmgwv1.UpgradeAgent{RequestId: "u1", Version: "v9.9.9", Releases: srv.URL}, out)
select {
case m := <-out:
if r := m.GetUpgrade(); r.GetRequestId() != "u1" || r.GetError() != "" {
t.Fatalf("reply %+v", r)
}
case <-time.After(10 * time.Second):
t.Fatal("no reply")
}
select {
case <-exited:
case <-time.After(5 * time.Second):
t.Fatal("the agent did not restart into the new binary")
}
if b, _ := os.ReadFile(bin); string(b) != "the new agent" {
t.Errorf("binary holds %q", b)
}
// A version with no release answers with why, and does not restart.
go a.upgrade(t.Context(), &llmgwv1.UpgradeAgent{RequestId: "u2", Version: "v9.9.8", Releases: srv.URL}, out)
if r := (<-out).GetUpgrade(); r.GetError() == "" {
t.Error("an upgrade to a release that does not exist reported success")
}
select {
case <-exited:
t.Error("a failed upgrade restarted the agent")
case <-time.After(time.Second):
}
}
+43 -2
View File
@@ -2,6 +2,8 @@ package weights
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
@@ -26,8 +28,9 @@ type manifest struct {
Model string `json:"model"`
Version string `json:"version"`
Files []struct {
Name string `json:"name"`
Bytes int64 `json:"bytes"`
Name string `json:"name"`
Bytes int64 `json:"bytes"`
SHA256 string `json:"sha256"`
} `json:"files"`
Bytes int64 `json:"bytes"`
}
@@ -87,6 +90,25 @@ func (f *Fetcher) ensureSet(ctx context.Context, source string) (string, error)
}
}
// Each file against the checksum the mirror took when it fetched it,
// before the set is called complete: a shard damaged on the way loads as
// a model that answers nonsense, not as an error.
for _, file := range m.Files {
if file.SHA256 == "" {
continue
}
target := filepath.Join(dir, file.Name)
got, err := fileSHA256(target)
if err != nil {
return "", err
}
if !strings.EqualFold(got, file.SHA256) {
_ = os.Remove(target)
return "", fmt.Errorf("%s%s: sha256 %s, the mirror has %s; removed, to be fetched again",
source, file.Name, got, file.SHA256)
}
}
if err := os.WriteFile(done, []byte(source+"\n"), 0o644); err != nil {
return "", err
}
@@ -108,6 +130,12 @@ func (f *Fetcher) manifestFor(ctx context.Context, source string) (manifest, err
return manifest{}, fmt.Errorf("asking %s what it holds: %w", source, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusConflict {
// Still downloading on the mirror, or a shard missing: said in the
// mirror's words, which name what is not there yet.
why, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return manifest{}, fmt.Errorf("%s is not complete on the mirror yet: %s", source, strings.TrimSpace(string(why)))
}
if resp.StatusCode != http.StatusOK {
return manifest{}, fmt.Errorf("%s answered %d", source, resp.StatusCode)
}
@@ -127,3 +155,16 @@ func (f *Fetcher) manifestFor(ctx context.Context, source string) (manifest, err
}
return m, nil
}
func fileSHA256(path string) (string, error) {
f, err := os.Open(path) //nolint:gosec // a file this fetcher wrote
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
+39
View File
@@ -1,6 +1,8 @@
package weights
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
@@ -165,3 +167,40 @@ func TestOnlyATrailingSlashMeansASet(t *testing.T) {
}
}
}
func TestASetFileThatDoesNotMatchTheMirrorsChecksumIsRefused(t *testing.T) {
good := "the shard as mirrored"
sum := sha256.Sum256([]byte(good))
served := "the shard damaged on the way"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/") {
fmt.Fprintf(w, `{"model":"m","version":"v","files":[{"name":"s.safetensors","bytes":%d,"sha256":%q}]}`,
len(served), hex.EncodeToString(sum[:]))
return
}
_, _ = w.Write([]byte(served))
}))
t.Cleanup(srv.Close)
f := &Fetcher{CacheDir: t.TempDir()}
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err == nil || !strings.Contains(err.Error(), "sha256") {
t.Fatalf("a damaged shard was accepted: %v", err)
}
served = good
// The size differs, so it is fetched again; this time it matches.
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err != nil {
t.Fatalf("the shard as mirrored: %v", err)
}
}
func TestASetStillDownloadingOnTheMirrorSaysSo(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "m/v: this version is not complete: still downloading model-00002-of-00002.safetensors", http.StatusConflict)
}))
t.Cleanup(srv.Close)
f := &Fetcher{CacheDir: t.TempDir()}
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
if err == nil || !strings.Contains(err.Error(), "not complete on the mirror yet") ||
!strings.Contains(err.Error(), "model-00002-of-00002") {
t.Fatalf("got %v", err)
}
}
+29 -11
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"io"
"io/fs"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -178,30 +177,49 @@ func addStore(ctx context.Context, tw *tar.Writer, addr, scratch string) error {
// command line: a DSN carries the password, and every user on the machine
// can read another process's arguments.
func pgDump(ctx context.Context, dsn, out string) error {
u, err := url.Parse(dsn)
vars, err := pgEnv(dsn)
if err != nil {
return fmt.Errorf("the store address is not a URL")
return err
}
env := os.Environ()
cmd := exec.CommandContext(ctx, "pg_dump", "--format=custom", "--no-owner", "--file="+out) //nolint:gosec // fixed binary, our own file
cmd.Env = append(os.Environ(), vars...)
if b, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("pg_dump: %v: %s", err, strings.TrimSpace(string(b)))
}
return nil
}
// pgEnv turns a store address into libpq's environment. Several hosts — the
// members of a replicated cluster — become libpq's own lists in PGHOST and
// PGPORT; read as one URL host, the list was one name libpq could not
// resolve, and a backup worked only by falling through to the second.
func pgEnv(dsn string) ([]string, error) {
hosts, ports, u, err := store.SplitPostgres(dsn)
if err != nil {
return nil, err
}
var env []string
set := func(k, v string) {
if v != "" {
env = append(env, k+"="+v)
}
}
set("PGHOST", u.Hostname())
set("PGPORT", u.Port())
set("PGHOST", strings.Join(hosts, ","))
set("PGPORT", strings.Join(ports, ","))
set("PGUSER", u.User.Username())
if pw, ok := u.User.Password(); ok {
set("PGPASSWORD", pw)
}
set("PGDATABASE", strings.TrimPrefix(u.Path, "/"))
set("PGSSLMODE", u.Query().Get("sslmode"))
cmd := exec.CommandContext(ctx, "pg_dump", "--format=custom", "--no-owner", "--file="+out) //nolint:gosec // fixed binary, our own file
cmd.Env = env
if b, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("pg_dump: %v: %s", err, strings.TrimSpace(string(b)))
// A standby if there is one, else whichever answers — not the address's
// read-write: a dump only reads, it need not load the primary, and one
// that insists on the primary fails exactly while the cluster is failing
// over.
if len(hosts) > 1 {
set("PGTARGETSESSIONATTRS", "prefer-standby")
}
return nil
return env, nil
}
func addTree(tw *tar.Writer, root, as string) error {
+23
View File
@@ -139,3 +139,26 @@ func TestNoScratchCopyOfTheStoreIsLeftBehind(t *testing.T) {
}
}
}
func TestAStoreOfSeveralHostsIsGivenToPgDumpAsLibpqLists(t *testing.T) {
// The address of a Patroni cluster: url.Parse read its host as one name,
// and pg_dump reached the database only by falling through to the second.
env, err := pgEnv("postgres://llmgw:s3cret@10.0.0.149:5432,10.0.0.150/llmgw?sslmode=disable&target_session_attrs=read-write")
if err != nil {
t.Fatal(err)
}
got := strings.Join(env, " ")
for _, want := range []string{"PGHOST=10.0.0.149,10.0.0.150", "PGPORT=5432,5432", "PGUSER=llmgw",
"PGPASSWORD=s3cret", "PGDATABASE=llmgw", "PGSSLMODE=disable", "PGTARGETSESSIONATTRS=prefer-standby"} {
if !strings.Contains(got, want) {
t.Errorf("%s is not in %s", want, got)
}
}
one, err := pgEnv("postgres://u:p@db.internal:6432/llmgw")
if err != nil || !strings.Contains(strings.Join(one, " "), "PGHOST=db.internal PGPORT=6432") {
t.Errorf("one host: %v, %v", one, err)
}
if _, err := pgEnv("postgres://u:p@a:5432,:5432/x"); err == nil {
t.Error("an empty host in the list was accepted")
}
}
+41 -5
View File
@@ -137,14 +137,17 @@ func newCtlCmd() *cobra.Command {
// apply to it; it lives here because this is where operators look.
newBenchCmd(client, &asJSON),
newStatusCmd(client, &asJSON),
newModelCmd(client),
newModelCmd(client, &asJSON),
newScaleCmd(client),
newRestartCmd(client),
newRetryCmd(client),
newUpgradeCmd(client),
newEngineCheckCmd(),
newEngineBuildCmd(client),
newToolCheckCmd(),
newWeightsCmd(client, &asJSON),
newCapacityCmd(client, &asJSON),
newQuotaCmd(client, &asJSON),
newUsageCmd(client, &asJSON),
newCertCmd(),
)
@@ -252,6 +255,10 @@ func modelStatus(m adminapi.ModelView) string {
switch {
case m.Parked:
return "parked"
case m.External != "":
return "external: " + m.External
case m.HeldFor != "":
return "gave room to " + m.HeldFor
case m.Unplaceable:
return "cannot place"
case m.Min == 0 && m.Total == 0:
@@ -322,17 +329,27 @@ func newModelsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
}
w := tab()
fmt.Fprintln(w, "MODEL\tSERVING\tREPLICAS\tSTATUS\tALIASES\tPROFILES\tFALLBACKS")
fmt.Fprintln(w, "MODEL\tSERVING\tREPLICAS\tSTATUS\tCLASS\tRESERVES\tALIASES\tPROFILES\tFALLBACKS")
var warnings []string
for _, m := range models {
serving := "no"
if m.Ready > 0 {
serving = "yes"
}
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),
fmt.Fprintf(w, "%s\t%s\t%d/%d\t%s\t%s\t%s\t%s\t%s\t%s\n",
m.Name, serving, m.Ready, m.Min, modelStatus(m), m.Class, reserves(m),
join(m.Aliases), join(m.Profiles), join(m.Fallbacks))
for _, x := range m.Warnings {
warnings = append(warnings, m.Name+": "+x)
}
}
return w.Flush()
if err := w.Flush(); err != nil {
return err
}
for _, x := range warnings {
fmt.Println("warning:", x)
}
return nil
},
}
}
@@ -510,3 +527,22 @@ func dash(s string) string {
}
return s
}
// reserves is what one replica of a model holds of a node, with what it was
// measured using beside it: "6G 2c (5.1G used)".
func reserves(m adminapi.ModelView) string {
if m.MemoryGB == 0 && m.CPUCores == 0 {
return "-"
}
out := fmt.Sprintf("%dG", m.MemoryGB)
if m.CPUCores > 0 {
out += fmt.Sprintf(" %dc", m.CPUCores)
}
if m.MeasuredMemoryGB > 0 {
out += fmt.Sprintf(" (%.1fG used)", m.MeasuredMemoryGB)
}
if len(m.Warnings) > 0 {
out += " !"
}
return out
}
+13 -7
View File
@@ -12,8 +12,9 @@ import (
func newRestartCmd(client func() *ctlClient) *cobra.Command {
var (
models []string
all bool
models []string
all bool
inPlace bool
)
c := &cobra.Command{
Use: "restart [REPLICA...]",
@@ -22,10 +23,14 @@ func newRestartCmd(client func() *ctlClient) *cobra.Command {
"an edited profile, a new engine version.\n\n" +
"Each replacement is placed beside the replica it replaces, and the old one\n" +
"keeps serving until the new one is ready. Replicas of one model are\n" +
"replaced one at a time. Where nothing else has room for a second copy —\n" +
"the usual case on a small CPU fleet — the old one is stopped after the\n" +
"drain grace period (5 minutes by default) and the new one starts in its\n" +
"place; a model with only that replica is unserved while it loads.\n\n" +
"replaced one at a time.\n\n" +
"Where there is no room for a second copy and the old replica is all that\n" +
"is in the way — one GPU, every card that fits held — it is stopped at once\n" +
"and the new one starts in its place; a model with only that replica is\n" +
"unserved while it loads. Where the new one would not fit even then — a\n" +
"profile edited to need more than any node has — the restart is given up\n" +
"and the old replica keeps serving: llmgw ctl replicas says so, and why.\n\n" +
"--in-place stops each replica first, whatever room there is.\n\n" +
" llmgw ctl restart qwen2.5-7b-3f2a91c0\n" +
" llmgw ctl restart --model qwen2.5-7b\n" +
" llmgw ctl restart --all",
@@ -33,7 +38,7 @@ func newRestartCmd(client func() *ctlClient) *cobra.Command {
if len(args) == 0 && len(models) == 0 && !all {
return errors.New("name replicas, --model, or --all")
}
body, _ := json.Marshal(adminapi.RestartRequest{Replicas: args, Models: models, All: all})
body, _ := json.Marshal(adminapi.RestartRequest{Replicas: args, Models: models, All: all, InPlace: inPlace})
var res adminapi.RestartResult
if err := client().post(cmd.Context(), "/api/v1/restart", body, &res); err != nil {
return err
@@ -44,6 +49,7 @@ func newRestartCmd(client func() *ctlClient) *cobra.Command {
}
c.Flags().StringArrayVar(&models, "model", nil, "every replica of this model; repeatable")
c.Flags().BoolVar(&all, "all", false, "every ready replica in the cluster")
c.Flags().BoolVar(&inPlace, "in-place", false, "stop each replica before starting its replacement")
return c
}
+29
View File
@@ -1,9 +1,14 @@
package cli
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"github.com/spf13/cobra"
)
func TestAQueuedChangeIsNotReportedAsAnError(t *testing.T) {
@@ -22,3 +27,27 @@ func TestAQueuedChangeIsNotReportedAsAnError(t *testing.T) {
t.Errorf("the body of a 202 was not read: %+v", out)
}
}
func TestStatusSaysWhoLeadsTheStoreAndWhetherTheRestKeepUp(t *testing.T) {
render := func(s adminapi.Summary) string {
cmd := &cobra.Command{}
var b bytes.Buffer
cmd.SetOut(&b)
printSummary(cmd, s)
return b.String()
}
out := render(adminapi.Summary{Store: []adminapi.StoreMember{
{Name: "llmgw-ctl02", Role: "replica", State: "streaming", Lag: 2048},
{Name: "llmgw-pg01", Role: "leader", State: "running"},
}})
if !strings.Contains(out, "STORE llmgw-ctl02 streaming (2 KB behind), llmgw-pg01 leads") {
t.Errorf("status:\n%s", out)
}
out = render(adminapi.Summary{Store: []adminapi.StoreMember{{Name: "llmgw-pg01", Role: "replica", State: "stopped"}}})
if !strings.Contains(out, "NO LEADER") {
t.Errorf("a store with no leader was not called out:\n%s", out)
}
if out := render(adminapi.Summary{}); strings.Contains(out, "STORE") {
t.Error("a single database got a store line")
}
}
+201
View File
@@ -0,0 +1,201 @@
package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"slices"
"strings"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
)
func newUpgradeCmd(client func() *ctlClient) *cobra.Command {
var (
nodes []string
skipCP bool
releases string
perNode time.Duration
serverSvc string
)
c := &cobra.Command{
Use: "upgrade VERSION",
Short: "Upgrade this control plane, then every node, one at a time",
Long: "Moves the cluster to a release: v0.10.4, or a candidate like v0.10.4-rc.2.\n\n" +
"This machine first — run it with sudo on the control plane, since it\n" +
"replaces /usr/local/bin/llmgw and restarts llmgw-server. Then each node, in\n" +
"turn: its agent fetches the release for its own platform, checks it\n" +
"against the release's checksums, swaps it in and restarts. Engines keep\n" +
"serving through all of it and are adopted again; no ssh into the nodes is\n" +
"needed.\n\n" +
"A node counts as done when it is back on the new version, ready, with as\n" +
"many ready replicas as before. One that is not within --timeout stops the\n" +
"upgrade there, so a bad release reaches one node rather than all of them.\n" +
"Nodes already on the version are skipped, so running it again resumes.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
v := args[0]
if !release.Tag.MatchString(v) {
return fmt.Errorf("%q is not a release version (v1.2.3 or v1.2.3-rc.4)", v)
}
out := cmd.OutOrStdout()
ctx := cmd.Context()
if !skipCP {
if err := upgradeControlPlane(ctx, out, client, v, releases, serverSvc); err != nil {
return err
}
}
return upgradeNodes(ctx, out, client, v, nodes, perNode)
},
}
c.Flags().StringSliceVar(&nodes, "nodes", nil, "only these nodes (default: all)")
c.Flags().BoolVar(&skipCP, "skip-control-plane", false, "leave this machine as it is and upgrade only nodes")
c.Flags().StringVar(&releases, "releases", release.DefaultBase, "where release binaries are fetched from, for this machine")
c.Flags().DurationVar(&perNode, "timeout", 3*time.Minute, "how long a node may take to come back")
c.Flags().StringVar(&serverSvc, "service", "llmgw-server", "this machine's systemd unit")
return c
}
func upgradeControlPlane(ctx context.Context, out io.Writer, client func() *ctlClient, v, releases, svc string) error {
if version.Version == v {
fmt.Fprintf(out, "this machine already runs %s\n", v)
return nil
}
if os.Geteuid() != 0 {
return errors.New("replacing this machine's binary needs root: run it with sudo, " +
"or pass --skip-control-plane to upgrade only the nodes")
}
self, err := os.Executable()
if err != nil {
return err
}
fmt.Fprintf(out, "== this machine: %s -> %s\n", version.Version, v)
backup, err := release.Install(ctx, nil, releases, v, runtime.GOOS, runtime.GOARCH, self)
if err != nil {
return err
}
fmt.Fprintf(out, " installed, checksum verified; the old binary is %s\n", backup)
var before []adminapi.NodeView
_ = client().get(ctx, "/api/v1/nodes", &before)
if b, err := exec.CommandContext(ctx, "systemctl", "restart", svc).CombinedOutput(); err != nil { //nolint:gosec // fixed command
return fmt.Errorf("restarting %s: %v: %s", svc, err, strings.TrimSpace(string(b)))
}
fmt.Fprintf(out, " %s restarted; waiting for its nodes to reconnect\n", svc)
// Back when the admin API answers and every node that was ready is ready
// again: agents reconnect within seconds of the control plane returning.
want := 0
for _, n := range before {
if n.State == "ready" {
want++
}
}
deadline := time.Now().Add(2 * time.Minute)
for {
var now []adminapi.NodeView
if err := client().get(ctx, "/api/v1/nodes", &now); err == nil {
ready := 0
for _, n := range now {
if n.State == "ready" {
ready++
}
}
if ready >= want {
fmt.Fprintf(out, " back: %d of %d nodes ready\n", ready, len(now))
return nil
}
}
if time.Now().After(deadline) {
return fmt.Errorf("%s did not come back with its %d nodes within two minutes; "+
"see journalctl -u %s, and %s holds the previous binary", svc, want, svc, backup)
}
time.Sleep(2 * time.Second)
}
}
func upgradeNodes(ctx context.Context, out io.Writer, client func() *ctlClient, v string, only []string, perNode time.Duration) error {
var nodes []adminapi.NodeView
if err := client().get(ctx, "/api/v1/nodes", &nodes); err != nil {
return err
}
slices.SortFunc(nodes, func(a, b adminapi.NodeView) int { return strings.Compare(a.ID, b.ID) })
done, skipped := 0, 0
for _, n := range nodes {
if len(only) > 0 && !slices.Contains(only, n.ID) {
continue
}
if n.AgentVersion == v {
skipped++
continue
}
start := time.Now()
before := readyReplicasOn(ctx, client, n.ID)
fmt.Fprintf(out, "== %s: %s -> %s\n", n.ID, n.AgentVersion, v)
body, _ := json.Marshal(adminapi.UpgradeRequest{Version: v})
if err := client().post(ctx, "/api/v1/nodes/"+n.ID+"/upgrade", body, nil); err != nil {
return fmt.Errorf("%s: %w\nstopped here; `llmgw ctl logs %s` says more", n.ID, err, n.ID)
}
fmt.Fprintf(out, " new binary in place; waiting for it to come back with its %d replica(s)\n", before)
deadline := time.Now().Add(perNode)
for {
var now []adminapi.NodeView
_ = client().get(ctx, "/api/v1/nodes", &now)
var cur adminapi.NodeView
for _, x := range now {
if x.ID == n.ID {
cur = x
}
}
after := readyReplicasOn(ctx, client, n.ID)
if cur.AgentVersion == v && cur.State == "ready" && after >= before {
fmt.Fprintf(out, " done in %s: ready, replicas %d/%d\n",
time.Since(start).Round(time.Second), after, before)
done++
break
}
if time.Now().After(deadline) {
return fmt.Errorf("%s did not come back within %s (version %q, state %q, replicas %d/%d)\n"+
"stopped here, before the next node; `llmgw ctl logs %s` says why, and the node "+
"keeps its previous binary beside the new one", n.ID, perNode, cur.AgentVersion,
cur.State, after, before, n.ID)
}
time.Sleep(2 * time.Second)
}
}
fmt.Fprintf(out, "%d node(s) upgraded to %s, %d already on it\n", done, v, skipped)
// The one machine this does not reach. Said every time, because it is
// exactly the kind of thing that stays three versions behind unnoticed.
fmt.Fprintf(out, "The model mirror's host is not upgraded by this; on it, run:\n"+
" sudo llmgw upgrade %s --service llmgw-mirror\n", v)
return nil
}
// readyReplicasOn counts the ready replicas with a shard on a node.
func readyReplicasOn(ctx context.Context, client func() *ctlClient, node string) int {
var reps []adminapi.ReplicaView
if err := client().get(ctx, "/api/v1/replicas", &reps); err != nil {
return 0
}
n := 0
for _, r := range reps {
if r.State != "ready" {
continue
}
for _, s := range r.Shards {
if s.Node == node {
n++
break
}
}
}
return n
}
+88
View File
@@ -0,0 +1,88 @@
package cli
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
// fakeFleet is an admin API whose nodes come back on the version they are told
// to upgrade to — except those listed in stuck.
type fakeFleet struct {
mu sync.Mutex
versions map[string]string
stuck map[string]bool
told []string
}
func (f *fakeFleet) server(t *testing.T) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
switch {
case r.URL.Path == "/api/v1/nodes":
var out []adminapi.NodeView
for id, v := range f.versions {
out = append(out, adminapi.NodeView{ID: id, State: "ready", AgentVersion: v})
}
_ = json.NewEncoder(w).Encode(out)
case r.URL.Path == "/api/v1/replicas":
var out []adminapi.ReplicaView
for id := range f.versions {
out = append(out, adminapi.ReplicaView{ID: "r-" + id, State: "ready",
Shards: []adminapi.ShardView{{Node: id}}})
}
_ = json.NewEncoder(w).Encode(out)
case strings.HasSuffix(r.URL.Path, "/upgrade"):
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/v1/nodes/"), "/upgrade")
var req adminapi.UpgradeRequest
_ = json.NewDecoder(r.Body).Decode(&req)
f.told = append(f.told, id)
if !f.stuck[id] {
f.versions[id] = req.Version
}
_, _ = w.Write([]byte(`{}`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv
}
func TestAnUpgradeGoesNodeByNodeAndSkipsTheDone(t *testing.T) {
f := &fakeFleet{versions: map[string]string{"a": "v1.0.0", "b": "v1.0.1", "c": "v1.0.0"}}
srv := f.server(t)
var out bytes.Buffer
err := upgradeNodes(t.Context(), &out, func() *ctlClient { return newCtlClient(srv.URL) }, "v1.0.1", nil, 5*time.Second)
if err != nil {
t.Fatal(err)
}
if strings.Join(f.told, ",") != "a,c" {
t.Errorf("told %v; b was already on the version", f.told)
}
if !strings.Contains(out.String(), "2 node(s) upgraded to v1.0.1, 1 already on it") {
t.Errorf("output:\n%s", out.String())
}
}
func TestAnUpgradeStopsAtTheFirstNodeThatDoesNotComeBack(t *testing.T) {
// A bad release reaches one node, not all of them.
f := &fakeFleet{versions: map[string]string{"a": "v1.0.0", "b": "v1.0.0"}, stuck: map[string]bool{"a": true}}
srv := f.server(t)
var out bytes.Buffer
err := upgradeNodes(t.Context(), &out, func() *ctlClient { return newCtlClient(srv.URL) }, "v1.0.1", nil, 3*time.Second)
if err == nil || !strings.Contains(err.Error(), "a did not come back") {
t.Fatalf("err %v", err)
}
if len(f.told) != 1 {
t.Errorf("went on to %v after a node failed to come back", f.told)
}
}
+17 -2
View File
@@ -23,6 +23,7 @@ func newDoctorCmd() *cobra.Command {
opts doctor.Options
asJSON bool
verbose bool
fabrics []string
)
cmd := &cobra.Command{
Use: "doctor",
@@ -38,8 +39,21 @@ that file when the report is not enough to go on.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
doctor.SetConfigLoader(config.LoadAgent)
if opts.VLLMVersion == "" {
for _, spec := range fabrics {
name, hcas, _ := strings.Cut(spec, ":")
if name == "" || hcas == "" {
return fmt.Errorf("--fabric %q: give it as name:mlx5_0,mlx5_1", spec)
}
if opts.Fabrics == nil {
opts.Fabrics = map[string][]string{}
}
opts.Fabrics[name] = strings.Split(hcas, ",")
}
switch opts.VLLMVersion {
case "":
opts.VLLMVersion = config.DefaultEngines["vllm"]
case "none":
opts.VLLMVersion = ""
}
ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second)
defer cancel()
@@ -66,7 +80,8 @@ that file when the report is not enough to go on.`,
f.StringVar(&opts.ConfigPath, "config", "", "agent configuration (default /etc/llmgw/agent.yaml when present)")
f.StringVar(&opts.IBRoot, "ib-root", discovery.SysfsRoot, "sysfs InfiniBand directory")
f.StringVar(&opts.NvidiaSMI, "nvidia-smi", "", "path to nvidia-smi")
f.StringVar(&opts.VLLMVersion, "vllm", "", "the vLLM version to check for (default: the one this llmgw installs)")
f.StringVar(&opts.VLLMVersion, "vllm", "", "the vLLM version to check for (default: the one this llmgw installs; none to skip)")
f.StringArrayVar(&fabrics, "fabric", nil, "a fabric about to be declared, name:mlx5_0,mlx5_1, checked as if it were; repeatable")
f.BoolVar(&asJSON, "json", false, "write every fact and finding as JSON, to send back")
f.BoolVar(&verbose, "verbose", false, "also print the raw nvidia-smi topology")
return cmd
+39
View File
@@ -0,0 +1,39 @@
package cli
import (
"encoding/json"
"fmt"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
func newEngineBuildCmd(client func() *ctlClient) *cobra.Command {
return &cobra.Command{
Use: "engine-build <node> <engine> <build>",
Short: "Choose which build of an engine a GPU node installs",
Long: "A node installs the build of vLLM its hardware runs: CUDA 13 where the\n" +
"driver and GPUs allow, else the same version built for CUDA 12.9. This\n" +
"chooses instead, as the cluster page's vLLM build column does:\n\n" +
" auto what the hardware runs (the default)\n" +
" cu13 the CUDA 13 build, from PyPI\n" +
" cu129 the CUDA 12.9 build, from vLLM's GitHub release\n" +
" none no vLLM on this node\n\n" +
"The node is told at once and installs it in the background; a build it\n" +
"cannot compute with on its GPU fails to install, saying why, and the\n" +
"cluster page shows it. A V100 runs no build of vLLM 0.20 or later, and\n" +
"gets 0.19.1, the newest built for it, by itself.\n\n" +
" llmgw ctl engine-build gpu07 vllm cu129",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
body, _ := json.Marshal(adminapi.NodeEngineRequest{Engine: args[1], Build: args[2]})
if err := client().post(cmd.Context(), "/api/v1/nodes/"+args[0]+"/engines", body, nil); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s build %s; the node installs it in the background (llmgw ctl nodes shows it)\n",
args[0], args[1], args[2])
return nil
},
}
}
+29 -1
View File
@@ -3,6 +3,7 @@ package cli
import (
"context"
"fmt"
"io"
"os/exec"
"path/filepath"
"sort"
@@ -126,6 +127,14 @@ func newEngineCheckCmd() *cobra.Command {
}
sort.Strings(unknown)
// Flags accepted is not the same as running where it is placed: a
// GPU build whose backend does not load accepts every flag and
// computes on the CPU. Asked of the engine here, not of a help.
var deviceErr error
if fb, ok := eng.(engines.CPUFallback); ok && help == "" {
deviceErr = checkDevices(cmd.Context(), name, fb, out)
}
fmt.Fprintf(out, "%s: %d flags checked", name, checked)
if skipped > 0 {
fmt.Fprintf(out, ", %d commands skipped", skipped)
@@ -137,7 +146,7 @@ func newEngineCheckCmd() *cobra.Command {
return fmt.Errorf("nothing was checked; install the engine here or pass --help-output")
}
fmt.Fprintln(out, "every flag llmgw builds is accepted")
return nil
return deviceErr
}
fmt.Fprintf(out, "\n%d flags not mentioned by the program they are passed to:\n", len(unknown))
for _, f := range unknown {
@@ -249,3 +258,22 @@ func isFlagChar(b byte) bool {
return b == '-' || b == '_' || (b >= 'a' && b <= 'z') ||
(b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
}
// checkDevices says which GPUs an engine that can fall back to the CPU sees on
// this machine, and fails when the machine has NVIDIA GPUs and it sees none.
func checkDevices(ctx context.Context, name string, fb engines.CPUFallback, out io.Writer) error {
if _, err := exec.LookPath("nvidia-smi"); err != nil {
fmt.Fprintln(out, "no NVIDIA GPU here: which devices it computes on was not checked")
return nil
}
devs, err := fb.Devices(ctx)
if err != nil {
return err
}
if len(devs) == 0 {
return fmt.Errorf("this machine has NVIDIA GPUs and %s sees none: it would serve a GPU placement on the CPU. "+
"Its GPU backend did not load; ldd on its libggml-cuda.so names the missing library", name)
}
fmt.Fprintf(out, "computes on: %s\n", strings.Join(devs, "; "))
return nil
}
+61
View File
@@ -0,0 +1,61 @@
package cli
import (
"errors"
"fmt"
"os"
"runtime"
"github.com/spf13/cobra"
joinpkg "gitea.mynodes.xyz/ilker/llmgw/internal/join"
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
)
func newJoinCmd() *cobra.Command {
return &cobra.Command{
Use: "join <code>",
Short: "Let a setup on another machine set this one up, with the code it gave",
Long: "For a machine nothing may log into: the setup screens (sudo llmgw on\n" +
"the control plane) show a code for each machine they expect. Run this\n" +
"with it, as root, on that machine. It dials out to the setup — nothing\n" +
"dials in — checks it is the setup the code was made by, and runs what\n" +
"the setup sends until it is done: the same checks and installs ssh\n" +
"would carry. Each step is printed here as it runs.\n\n" +
"A code is good for one machine, for an hour, and only while the setup\n" +
"that made it is waiting.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if runtime.GOOS != "linux" {
return fmt.Errorf("join sets up a Linux machine; this is %s", runtime.GOOS)
}
if os.Geteuid() != 0 {
return errors.New("run it as root — sudo llmgw join … — since the setup installs software here")
}
code, err := joinpkg.Parse(args[0])
if err != nil {
return err
}
out := cmd.OutOrStdout()
role := code.Role
if role == "" {
role = "part of a cluster"
}
fmt.Fprintf(out, "Joining the setup at %s, as %s. It runs as root here until it is done.\n", code.Addr, role)
return joinpkg.Serve(cmd.Context(), code, func(s string) { fmt.Fprintln(out, s) })
},
}
}
// joinInstall is what to run on a machine that has no llmgw yet, to fetch
// this version for its architecture: short lines, since it is copied from a
// screen that cuts long ones off.
func joinInstall() []string {
v := version.Version
return []string{
`a=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')`,
fmt.Sprintf("sudo curl -fsSL -o /usr/local/bin/llmgw \\\n %s/%s/llmgw-%s-linux-$a", release.DefaultBase, v, v),
"sudo chmod 755 /usr/local/bin/llmgw",
}
}
+85 -31
View File
@@ -2,12 +2,14 @@ package cli
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/netip"
"os"
"slices"
"strings"
"time"
@@ -55,52 +57,69 @@ func newMirrorAddCmd(dir *string) *cobra.Command {
var model, version, as string
c := &cobra.Command{
Use: "add <url>",
Use: "add <url>...",
Short: "Download a model into the mirror",
Long: "Downloads to a temporary name and renames when complete, so an\n" +
"interrupted download never leaves something a catalog could be pointed\n" +
"at. Reports the checksum: a mirror is the one place in this system where\n" +
"a corrupted file is copied to every node rather than caught once.\n\n" +
"Several URLs go into one version, as the files of one model: the shards\n" +
"of a safetensors set with its config.json and tokenizer, for vLLM. The\n" +
"version is offered to nodes only once it is whole — nothing still\n" +
"downloading, every shard its index names present — and its source is\n" +
"the version, ending in a slash.\n\n" +
"Refuses to replace a version that is already here. Add a new one beside\n" +
"it instead — that is what makes the old one still available when the new\n" +
"one turns out to be worse.",
Args: cobra.ExactArgs(1),
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
source := args[0]
file := as
if file == "" {
file = mirror.LastPathElement(source)
}
if model == "" || version == "" {
return fmt.Errorf("--model and --version are required: they are the layout")
}
e := mirror.Entry{Model: model, Version: version, File: file}
out := cmd.OutOrStdout()
fmt.Fprintf(out, "fetching %s\n", source)
added, err := mirror.Add(cmd.Context(), *dir, source, e, func(done, total int64) {
if total > 0 {
fmt.Fprintf(out, " %s of %s (%d%%)\n",
fileSize(done), fileSize(total), done*100/total)
return
}
fmt.Fprintf(out, " %s so far\n", fileSize(done))
})
if err != nil {
return err
if as != "" && len(args) > 1 {
return fmt.Errorf("--as names one file; with %d URLs each keeps its own name", len(args))
}
fmt.Fprintf(out, "\n%s/%s/%s %s\n sha256 %s\n",
added.Entry.Model, added.Entry.Version, added.Entry.File,
fileSize(added.Entry.Bytes), added.SHA256)
fmt.Fprintf(out, "\nPoint a model at it with:\n source: %s\n",
added.Entry.URL("http://<this-host>:8081"))
out := cmd.OutOrStdout()
var last mirror.Added
for _, source := range args {
file := as
if file == "" {
file = mirror.LastPathElement(source)
}
e := mirror.Entry{Model: model, Version: version, File: file}
fmt.Fprintf(out, "fetching %s\n", source)
added, err := mirror.Add(cmd.Context(), *dir, source, e, func(done, total int64) {
if total > 0 {
fmt.Fprintf(out, " %s of %s (%d%%)\n",
fileSize(done), fileSize(total), done*100/total)
return
}
fmt.Fprintf(out, " %s so far\n", fileSize(done))
})
if err != nil {
return err
}
fmt.Fprintf(out, "%s/%s/%s %s\n sha256 %s\n",
added.Entry.Model, added.Entry.Version, added.Entry.File,
fileSize(added.Entry.Bytes), added.SHA256)
last = added
}
src := last.Entry.URL("http://<this-host>:8081")
if len(args) > 1 {
src = strings.TrimSuffix(mirror.Entry{Model: model, Version: version}.URL("http://<this-host>:8081"), "/") + "/"
if m, err := mirror.ManifestFor(*dir, model, version); err != nil {
fmt.Fprintf(out, "\nNot yet offered to nodes: %v\n", err)
} else {
fmt.Fprintf(out, "\n%d files, %s, whole.\n", len(m.Files), fileSize(m.Bytes))
}
}
fmt.Fprintf(out, "\nPoint a model at it with:\n source: %s\n", src)
return nil
},
}
c.Flags().StringVar(&model, "model", "", "the model's name, one directory level")
c.Flags().StringVar(&version, "version", "", "the version, one directory level; a date works well")
c.Flags().StringVar(&as, "as", "", "the filename to save as; taken from the URL when omitted")
c.Flags().StringVar(&as, "as", "", "the filename to save as, for one URL; taken from the URL when omitted")
return c
}
@@ -232,7 +251,7 @@ func newMirrorRemoveCmd(dir *string) *cobra.Command {
func newMirrorServeCmd(dir *string) *cobra.Command {
var listen, apiListen, base string
var tlsc tlsconf.Config
var allow []string
var allow, clients []string
var allowPrivate bool
c := &cobra.Command{
@@ -259,6 +278,9 @@ func newMirrorServeCmd(dir *string) *cobra.Command {
if err := tlsc.Validate(); err != nil {
return err
}
if len(clients) > 0 && !tlsc.Enabled() {
return fmt.Errorf("--api-client names a certificate, and needs --tls-ca, --tls-cert and --tls-key")
}
out := cmd.OutOrStdout()
log := slog.Default()
@@ -282,7 +304,7 @@ func newMirrorServeCmd(dir *string) *cobra.Command {
}
admin := &http.Server{
Addr: apiListen,
Handler: apiGate(tlsc.Enabled(), allowed, api.Handler()),
Handler: onlyClients(clients, apiGate(tlsc.Enabled(), allowed, api.Handler())),
ReadHeaderTimeout: 10 * time.Second,
}
@@ -294,6 +316,12 @@ func newMirrorServeCmd(dir *string) *cobra.Command {
switch {
case tlsc.Enabled():
fmt.Fprintf(out, "management API on %s, mutual TLS against %s\n", apiListen, tlsc.CA)
if len(clients) == 0 {
// The same root signs every node's certificate, so without a
// name any node could delete models here.
log.Warn("the management API takes any certificate from " + tlsc.CA +
", every node's included; name the control plane's with --api-client")
}
case len(allowed) > 0:
fmt.Fprintf(out, "management API on %s, for %s and this machine only\n", apiListen, strings.Join(allow, ", "))
default:
@@ -317,6 +345,8 @@ func newMirrorServeCmd(dir *string) *cobra.Command {
"the address nodes use to reach this mirror, as it should appear in catalog sources")
c.Flags().StringArrayVar(&allow, "api-allow", nil,
"an address or network the management API answers, besides this machine; repeatable (default: this machine only)")
c.Flags().StringArrayVar(&clients, "api-client", nil,
"with mutual TLS, the common name of a certificate the management API answers; repeatable (default: any from --tls-ca)")
c.Flags().BoolVar(&allowPrivate, "allow-private-sources", false,
"let downloads reach loopback, private and link-local addresses")
c.Flags().StringVar(&tlsc.CA, "tls-ca", "", "CA bundle that management clients must present a certificate from")
@@ -393,7 +423,14 @@ func withManifest(root string, next http.Handler) http.Handler {
return
}
m, err := mirror.ManifestFor(root, model, version)
if err != nil {
switch {
case errors.Is(err, mirror.ErrIncomplete):
// Said, not hidden behind a 404: a node asking now is told why
// it cannot have the version yet, and tries again later.
w.Header().Set("Retry-After", "60")
http.Error(w, err.Error(), http.StatusConflict)
return
case err != nil:
http.NotFound(w, r)
return
}
@@ -452,6 +489,23 @@ func apiGate(mtls bool, allowed []netip.Prefix, next http.Handler) http.Handler
return onlyFrom(allowed, next)
}
// onlyClients answers only certificates with one of these common names, when
// any are given. The handshake has already checked the certificate against
// the root; this narrows which of the root's certificates may manage.
func onlyClients(names []string, next http.Handler) http.Handler {
if len(names) == 0 {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS == nil || len(r.TLS.VerifiedChains) == 0 ||
!slices.Contains(names, r.TLS.VerifiedChains[0][0].Subject.CommonName) {
http.Error(w, "this mirror is managed by its control plane only", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// onlyFrom answers only this machine and the addresses allowed: the management
// API can delete models and start downloads, and a mirror on its own machine
// is reached over the network by one control plane and no one else.
+27
View File
@@ -1,6 +1,9 @@
package cli
import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"net/http"
"net/http/httptest"
"net/netip"
@@ -37,3 +40,27 @@ func TestTheMirrorAPIAnswersOnlyThisMachineUnlessTold(t *testing.T) {
t.Errorf("mTLS without an allowlist refused a client: %d", got)
}
}
func TestOnlyANamedCertificateManagesTheMirror(t *testing.T) {
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})
with := func(cn string) *http.Request {
r := httptest.NewRequest("GET", "/", nil)
if cn != "" {
r.TLS = &tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{{Subject: pkix.Name{CommonName: cn}}}}}
}
return r
}
h := onlyClients([]string{"control-plane"}, ok)
for cn, want := range map[string]int{"control-plane": 200, "node01": 403, "": 403} {
w := httptest.NewRecorder()
h.ServeHTTP(w, with(cn))
if w.Code != want {
t.Errorf("%q: %d, expected %d", cn, w.Code, want)
}
}
w := httptest.NewRecorder()
onlyClients(nil, ok).ServeHTTP(w, with("node01"))
if w.Code != 200 {
t.Error("no names given narrowed it anyway")
}
}
+149 -2
View File
@@ -3,7 +3,11 @@ package cli
import (
"encoding/json"
"fmt"
"os"
"os/user"
"slices"
"strconv"
"strings"
"time"
"github.com/knadh/koanf/parsers/yaml"
@@ -23,12 +27,15 @@ import (
// minutes of outage. For a fixed production set that is a reasonable trade; for
// a platform whose promise is that people can try things, it is the difference
// between trying one and not bothering.
func newModelCmd(client func() *ctlClient) *cobra.Command {
func newModelCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
c := &cobra.Command{
Use: "model",
Short: "Add or remove models while the cluster is running",
}
c.AddCommand(newModelAddCmd(client), newModelRemoveCmd(client), newModelPlanCmd(client))
c.AddCommand(newModelAddCmd(client), newModelRemoveCmd(client), newModelPlanCmd(client),
newResourcesCmd(client), newClassCmd(client), newOwnerCmd(client),
newTargetCmd(client, asJSON), newAdaptersCmd(client),
newModelServeCmd(client))
return c
}
@@ -126,6 +133,10 @@ func newModelRemoveCmd(client func() *ctlClient) *cobra.Command {
// actor names who made a change, for the audit trail on a stored model.
func actor() string {
// Under sudo, the person who ran it rather than root.
if u := os.Getenv("SUDO_USER"); u != "" && os.Geteuid() == 0 {
return u
}
if u, err := user.Current(); err == nil && u.Username != "" {
return u.Username
}
@@ -186,3 +197,139 @@ func newScaleCmd(client func() *ctlClient) *cobra.Command {
"how long an on-demand model stays up unasked-for; zero takes the cluster default")
return c
}
func newResourcesCmd(client func() *ctlClient) *cobra.Command {
var memory, cores int
var profile string
c := &cobra.Command{
Use: "resources <model> --memory GB [--cores N]",
Short: "Set what a model reserves of a node: memory, and CPU cores",
Long: "--memory is the host RAM a replica needs: for a model with no GPU, its\n" +
"weights, KV cache and engine together. Placement reserves it on the node,\n" +
"so a node holds only as many replicas as it has memory for.\n\n" +
"--cores is the CPU cores a replica with no GPU computes on. They are\n" +
"reserved the same way, and become the engine's thread count, so two\n" +
"replicas on one node stop competing for the same cores. Zero reserves\n" +
"none and leaves the thread count to the node.\n\n" +
"Running replicas keep what they were started with; `llmgw ctl restart\n" +
"--model M` moves them to the new figures.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if !cmd.Flags().Changed("memory") {
return fmt.Errorf("--memory is required")
}
body, err := json.Marshal(adminapi.ResourcesRequest{Profile: profile, MemoryGB: memory, CPUCores: cores})
if err != nil {
return err
}
var m catalog.Model
if err := client().post(cmd.Context(), "/api/v1/models/"+args[0]+"/resources", body, &m); err != nil {
return err
}
for _, p := range m.Profiles {
fmt.Fprintf(cmd.OutOrStdout(), "%s/%s: %d GB memory, %d cores\n", m.Name, p.Name, p.MinMemoryGB, p.CPUCores)
}
fmt.Fprintf(cmd.OutOrStdout(), "Running replicas keep their old figures until restarted: llmgw ctl restart --model %s\n", m.Name)
return nil
},
}
c.Flags().IntVar(&memory, "memory", 0, "host RAM a replica needs, in GB")
c.Flags().IntVar(&cores, "cores", 0, "CPU cores a replica with no GPU computes on; 0 reserves none")
c.Flags().StringVar(&profile, "profile", "", "one profile; every profile when left out")
return c
}
func newClassCmd(client func() *ctlClient) *cobra.Command {
return &cobra.Command{
Use: "class <model> critical|standard|opportunistic|default",
Short: "Set how much a model's capacity is worth against another's",
Long: "When a model cannot be placed, replicas of a lower class are stopped to\n" +
"make room for it:\n\n" +
" critical never gives up the replicas of its minimum\n" +
" standard gives way to critical\n" +
" opportunistic gives way to both; the default for an on-demand model\n\n" +
"Within a class, a model below its minimum may take what another grew into\n" +
"under load, but not its minimum. \"default\" goes back to what the model's\n" +
"scaling implies: opportunistic when on demand, standard otherwise.",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
class := args[1]
if class == "default" {
class = ""
}
body, _ := json.Marshal(map[string]string{"class": class})
var res map[string]string
if err := client().post(cmd.Context(), "/api/v1/models/"+args[0]+"/class", body, &res); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s is %s\n", res["model"], res["class"])
return nil
},
}
}
func newAdaptersCmd(client func() *ctlClient) *cobra.Command {
var add, remove []string
c := &cobra.Command{
Use: "adapters <model> [--add NAME=URL[@SCALE]] [--remove NAME]",
Short: "List, add or remove a model's LoRA adapters",
Long: "An adapter is asked for by its NAME as though it were a model, and is served\n" +
"by this model's replicas with it applied. URL is the adapter file, usually in\n" +
"the mirror; it must be a GGUF LoRA adapter for this model's architecture,\n" +
"which is checked before it is attached. Replicas load a changed set when\n" +
"restarted: llmgw ctl restart --model MODEL.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
var models []adminapi.ModelView
if err := client().get(cmd.Context(), "/api/v1/models", &models); err != nil {
return err
}
var current []catalog.Adapter
found := false
for _, m := range models {
if m.Name == name {
current, found = m.Adapters, true
}
}
if !found {
return fmt.Errorf("no model %q", name)
}
out := cmd.OutOrStdout()
if len(add) == 0 && len(remove) == 0 {
if len(current) == 0 {
fmt.Fprintf(out, "%s has no adapters\n", name)
}
for _, a := range current {
fmt.Fprintf(out, "%s\tscale %g\t%s\n", a.Name, a.EffectiveScale(), a.Source)
}
return nil
}
next := slices.DeleteFunc(slices.Clone(current), func(a catalog.Adapter) bool { return slices.Contains(remove, a.Name) })
for _, spec := range add {
n, rest, ok := strings.Cut(spec, "=")
if !ok || n == "" || rest == "" {
return fmt.Errorf("--add %q is not NAME=URL[@SCALE]", spec)
}
ad := catalog.Adapter{Name: n, Source: rest}
if i := strings.LastIndex(rest, "@"); i > 0 {
if s, err := strconv.ParseFloat(rest[i+1:], 64); err == nil {
ad.Source, ad.Scale = rest[:i], s
}
}
next = append(next, ad)
}
body, _ := json.Marshal(adminapi.AdaptersRequest{Adapters: next})
var res adminapi.AdaptersRequest
if err := client().post(cmd.Context(), "/api/v1/models/"+name+"/adapters", body, &res); err != nil {
return err
}
fmt.Fprintf(out, "%s serves %d adapter(s); restart its replicas to load them: llmgw ctl restart --model %s\n",
name, len(res.Adapters), name)
return nil
},
}
c.Flags().StringArrayVar(&add, "add", nil, "NAME=URL[@SCALE]; repeatable")
c.Flags().StringArrayVar(&remove, "remove", nil, "an adapter's name; repeatable")
return c
}
+81
View File
@@ -0,0 +1,81 @@
package cli
import (
"encoding/json"
"errors"
"fmt"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
)
func newModelServeCmd(client func() *ctlClient) *cobra.Command {
var (
req modelserve.Request
onDemand bool
)
c := &cobra.Command{
Use: "serve OWNER/REPO",
Short: "Take a model from the registry to a replica that answers, in one command",
Long: "Picks the GGUF file (--quant, Q4_K_M by default, or --file), has the mirror\n" +
"fetch it and checks what arrived against the registry's checksum, checks\n" +
"that the nodes' llama.cpp can load it and sizes its memory from its own\n" +
"header, adds it to the catalog, waits for a replica, and asks it to say\n" +
"hello. Each step is shown as it happens; the first that fails stops it.\n\n" +
" llmgw ctl model serve bartowski/Qwen2.5-3B-Instruct-GGUF\n" +
" llmgw ctl model serve Qwen/Qwen2.5-7B-Instruct-GGUF --quant Q5_K_M --name qwen-7b\n" +
" llmgw ctl model serve owner/big-GGUF --on-demand",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
req.Repo = args[0]
if onDemand {
req.Replicas = 0
}
body, _ := json.Marshal(req)
var job modelserve.Job
if err := client().post(cmd.Context(), "/api/v1/models/serve", body, &job); err != nil {
return err
}
out := cmd.OutOrStdout()
fmt.Fprintf(out, "job %s\n", job.ID)
shown := map[string]string{}
for {
for _, s := range job.Steps {
line := s.State + " " + s.Detail
if shown[s.Name] == line {
continue
}
shown[s.Name] = line
mark := map[string]string{modelserve.Running: "..", modelserve.Done: "ok",
modelserve.Failed: "!!"}[s.State]
fmt.Fprintf(out, "%-3s %-11s %s\n", mark, s.Name, s.Detail)
}
switch job.State {
case modelserve.Done:
fmt.Fprintf(out, "\n%s is served.\n", job.Model)
return nil
case modelserve.Failed:
return errors.New(job.Error)
}
time.Sleep(2 * time.Second)
if err := client().get(cmd.Context(), "/api/v1/jobs/"+job.ID, &job); err != nil {
return err
}
}
},
}
f := c.Flags()
f.StringVar(&req.Quant, "quant", "", "the quantisation to serve (default Q4_K_M)")
f.StringVar(&req.File, "file", "", "the file to serve, instead of choosing by --quant")
f.StringVar(&req.Name, "name", "", "the catalog name (default: from the repository)")
f.StringSliceVar(&req.Aliases, "alias", nil, "other names clients may use; repeatable")
f.StringVar(&req.Description, "description", "", "what it is for, shown to clients in /v1/models")
f.IntVar(&req.Replicas, "replicas", 1, "how many replicas to run")
f.BoolVar(&onDemand, "on-demand", false, "run none until first asked for")
f.IntVar(&req.CtxPool, "ctx-pool", 16384, "llama.cpp's --ctx-size, shared by its parallel slots")
f.IntVar(&req.CPUCores, "cores", 0, "CPU cores a replica computes on; 0 takes half the smallest node it fits")
f.IntVar(&req.ContextLength, "context-length", 4096, "what one request may use")
return c
}
+20 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
@@ -113,13 +114,14 @@ engines are kept.`,
f.StringVar(&na.opts.MgmtAddr, "mgmt-addr", "", "address engines are reached at (default: found by the agent)")
f.StringVar(&na.opts.MgmtIface, "mgmt-if", "", "interface NCCL bootstraps over (default: the one holding that address)")
f.StringVar(&na.opts.LlamaCppBin, "llamacpp-bin", "", "a llama-server you installed, instead of the one the agent installs")
f.StringVar(&na.opts.VLLMBin, "vllm-bin", "", "a vllm you installed; also set engines.vllm: \"\" in server.yaml")
f.StringVar(&na.opts.VLLMBin, "vllm-bin", "", "a vllm you installed, instead of the one the agent installs")
f.StringVar(&na.opts.SGLangPython, "sglang-python", "", "python of an SGLang virtualenv you installed")
f.StringVar(&na.opts.NvidiaSMI, "nvidia-smi", "", "where nvidia-smi is, when it is not on PATH")
f.StringVar(&na.opts.IBRoot, "ib-root", "", "the sysfs InfiniBand directory, when not /sys/class/infiniband")
f.StringVar(&na.opts.PKI, "pki", defaultPKIDir, "directory holding the certificate authority")
f.StringVar(&na.opts.Releases, "releases", "https://gitea.mynodes.xyz/ilker/llmgw/releases/download", "where release files are downloaded from")
f.BoolVar(&na.opts.IgnoreChecks, "ignore-checks", false, "add the node even when doctor finds a problem")
f.StringArrayVar(&na.opts.DisableEngines, "disable-engine", nil, "an engine this node never installs, e.g. vllm on GPUs it does not run on; repeatable")
f.StringVar(&na.admin, "admin", "", "the control plane's admin API, to watch the node join (default: from server.yaml, else 127.0.0.1:9090)")
f.StringVar(&na.serverCfg, "config", "/etc/llmgw/server.yaml", "the control plane's configuration, for the admin API's address")
f.BoolVar(&na.waitAll, "wait", false, "wait for every engine, vLLM included, not only llama.cpp")
@@ -148,6 +150,23 @@ func (na *nodeAdd) prepare() error {
}
}
}
for _, e := range o.DisableEngines {
if !slices.Contains(provision.Installable(), e) {
return fmt.Errorf("--disable-engine %s: the engines are %s", e, strings.Join(provision.Installable(), ", "))
}
}
// What the control plane will ask the node to install, so doctor checks
// that vLLM rather than its own default. Its overrides for a GPU class
// are not known until the node says what it has, and are not applied
// here; doctor's verdict on vLLM is a warning either way.
if o.VLLMVersion == "" {
if cfg, err := config.Load(na.serverCfg); err == nil {
o.VLLMVersion = cfg.WantedEngines()["vllm"]
if o.VLLMVersion == "" {
o.VLLMVersion = "none"
}
}
}
o.Version = version.Version
if o.Binary == "" {
exe, err := os.Executable()
+234
View File
@@ -0,0 +1,234 @@
package cli
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func newQuotaCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
c := &cobra.Command{
Use: "quota",
Short: "What parts of the tenancy tree are guaranteed of the cluster, and may hold",
Long: "A capacity quota belongs to an organization, team or project, and counts the\n" +
"GPUs, CPU cores and memory held by replicas of the models it owns (see\n" +
"`llmgw ctl model owner`), and of those its teams and projects own.\n\n" +
"The guarantee is kept for it: when it holds less and a model of its needs\n" +
"room, replicas other scopes hold beyond their own guarantees are stopped to\n" +
"make it — whatever their class. What it holds within its guarantee is\n" +
"never taken by another scope's model. Beyond it, it borrows: free room is\n" +
"used, and given back when asked for.\n\n" +
"The limit is the most it may hold, borrowing included; a model that would\n" +
"take it past is not given another replica.\n\n" +
"Scopes are written org:Acme, team:Acme/ml or project:Acme/ml/training, or\n" +
"with IDs. Changes take effect within half a minute.",
}
c.AddCommand(newQuotaListCmd(client, asJSON), newQuotaSetCmd(client), newQuotaClearCmd(client))
return c
}
func newQuotaListCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "Every quota, with what its scope holds now",
RunE: func(cmd *cobra.Command, _ []string) error {
var qs []adminapi.QuotaView
if err := client().get(cmd.Context(), "/api/v1/quotas", &qs); err != nil {
return err
}
if *asJSON {
return json.NewEncoder(cmd.OutOrStdout()).Encode(qs)
}
if len(qs) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No capacity quotas: everything shares the cluster by class alone.")
return nil
}
w := tab()
fmt.Fprintln(w, "SCOPE\tNAME\tGPUS\tCORES\tMEMORY GB")
for _, q := range qs {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", q.Scope, q.Name,
quotaCell(q.Holds.GPUs, q.Guarantee.GPUs, q.Limit.GPUs),
quotaCell(q.Holds.CPUCores, q.Guarantee.CPUCores, q.Limit.CPUCores),
quotaCell(q.Holds.MemoryGB, q.Guarantee.MemoryGB, q.Limit.MemoryGB))
}
if err := w.Flush(); err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), "\nEach cell: holds / guaranteed (at most limit). - is not governed.")
return nil
},
}
}
// quotaCell is "6 / 8 (≤ 16)": what is held, what is guaranteed, the limit.
func quotaCell(holds int, guarantee, limit *int) string {
if guarantee == nil && limit == nil {
return "-"
}
out := strconv.Itoa(holds) + " / "
if guarantee != nil {
out += strconv.Itoa(*guarantee)
} else {
out += "-"
}
if limit != nil {
out += fmt.Sprintf(" (≤ %d)", *limit)
}
return out
}
func newQuotaSetCmd(client func() *ctlClient) *cobra.Command {
var gpus, cores, mem, lgpus, lcores, lmem int
c := &cobra.Command{
Use: "set SCOPE [--gpus N] [--cores N] [--memory GB] [--limit-gpus N] ...",
Short: "Set a scope's guarantee and limit, replacing what it had",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
f := cmd.Flags()
pick := func(name string, v int) *int {
if !f.Changed(name) {
return nil
}
return &v
}
req := adminapi.QuotaRequest{Scope: args[0],
Guarantee: store.Resources{GPUs: pick("gpus", gpus), CPUCores: pick("cores", cores), MemoryGB: pick("memory", mem)},
Limit: store.Resources{GPUs: pick("limit-gpus", lgpus), CPUCores: pick("limit-cores", lcores), MemoryGB: pick("limit-memory", lmem)},
}
body, _ := json.Marshal(req)
var res map[string]string
if err := client().put(cmd.Context(), "/api/v1/quotas", body, &res); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s: set; it takes effect within half a minute\n", res["scope"])
return nil
},
}
f := c.Flags()
f.IntVar(&gpus, "gpus", 0, "GPUs guaranteed")
f.IntVar(&cores, "cores", 0, "CPU cores guaranteed")
f.IntVar(&mem, "memory", 0, "memory guaranteed, in GB")
f.IntVar(&lgpus, "limit-gpus", 0, "the most GPUs it may hold")
f.IntVar(&lcores, "limit-cores", 0, "the most CPU cores it may hold")
f.IntVar(&lmem, "limit-memory", 0, "the most memory it may hold, in GB")
return c
}
func newQuotaClearCmd(client func() *ctlClient) *cobra.Command {
return &cobra.Command{
Use: "clear SCOPE",
Short: "Remove a scope's quota",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var res map[string]string
if err := client().delete(cmd.Context(), "/api/v1/quotas/"+url.PathEscape(args[0]), &res); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s: quota removed\n", res["removed"])
return nil
},
}
}
func newOwnerCmd(client func() *ctlClient) *cobra.Command {
return &cobra.Command{
Use: "owner <model> SCOPE|shared",
Short: "Set whose capacity quota a model counts against",
Long: "SCOPE is org:Acme, team:Acme/ml or project:Acme/ml/training. A shared\n" +
"model belongs to the cluster and counts against nobody's quota.",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
owner := args[1]
if owner == "shared" {
owner = ""
}
body, _ := json.Marshal(map[string]string{"owner": owner})
var res map[string]string
if err := client().post(cmd.Context(), "/api/v1/models/"+args[0]+"/owner", body, &res); err != nil {
return err
}
if res["owner"] == "" {
fmt.Fprintf(cmd.OutOrStdout(), "%s is shared\n", res["model"])
} else {
fmt.Fprintf(cmd.OutOrStdout(), "%s counts against %s\n", res["model"], res["owner"])
}
return nil
},
}
}
func newTargetCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
var tps float64
var apply bool
c := &cobra.Command{
Use: "target <model> --tokens-per-sec N [--apply]",
Short: "How many replicas a model needs for a throughput, and whether they fit",
Long: "Sized from what one replica was measured to serve while meeting its SLO\n" +
"(`llmgw ctl bench MODEL --save`), on the profile its replicas run on.\n" +
"--apply makes that the number it always runs, if the cluster has room.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if tps <= 0 {
return fmt.Errorf("--tokens-per-sec is required")
}
var v adminapi.TargetView
path := "/api/v1/models/" + url.PathEscape(args[0]) + "/target?tokens_per_sec=" +
strconv.FormatFloat(tps, 'f', -1, 64)
if err := client().get(cmd.Context(), path, &v); err != nil {
return err
}
if *asJSON {
return json.NewEncoder(cmd.OutOrStdout()).Encode(v)
}
out := cmd.OutOrStdout()
fmt.Fprintf(out, "%s: %.0f tokens/s needs %d replicas at %.1f each (measured on %s); %d running\n",
v.Model, v.TokPerSec, v.Needed, v.PerReplica, v.Profile, v.Current)
switch {
case v.More == 0:
fmt.Fprintln(out, "It already has them.")
case v.Fits && len(v.Preempts) > 0:
fmt.Fprintf(out, "%d more; there is room once replicas of %s are stopped, which its class may do.\n",
v.More, strings.Join(v.Preempts, ", "))
case v.Fits:
fmt.Fprintf(out, "%d more, and there is room for them.\n", v.More)
default:
fmt.Fprintf(out, "%d more, and there is room for %d:\n", v.More, v.Room)
for _, b := range v.Blocked {
fmt.Fprintln(out, " "+b)
}
}
if !apply || v.More == 0 {
return nil
}
if !v.Fits {
return fmt.Errorf("not applied: the cluster has no room for them")
}
var models []adminapi.ModelView
if err := client().get(cmd.Context(), "/api/v1/models", &models); err != nil {
return err
}
top := v.Needed
for _, m := range models {
if m.Name == v.Model {
top = max(top, m.Max)
}
}
body, _ := json.Marshal(adminapi.ScaleRequest{Min: v.Needed, Max: top})
if err := client().post(cmd.Context(), "/api/v1/models/"+url.PathEscape(v.Model)+"/scale", body, nil); err != nil {
return err
}
fmt.Fprintf(out, "Applied: %s runs %d (up to %d under load).\n", v.Model, v.Needed, top)
return nil
},
}
c.Flags().Float64Var(&tps, "tokens-per-sec", 0, "the throughput to size for")
c.Flags().BoolVar(&apply, "apply", false, "set it as the number of replicas the model always runs")
return c
}
+67
View File
@@ -0,0 +1,67 @@
package cli
import (
"errors"
"fmt"
"os"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
)
func newReleaseCmd() *cobra.Command {
c := &cobra.Command{
Use: "release",
Short: "Sign releases, and make the key they are signed with",
}
c.AddCommand(&cobra.Command{
Use: "keygen",
Short: "Make a release signing key pair",
Long: "Prints a public key and a private key. The private key goes into the CI\n" +
"secret RELEASE_SIGNING_KEY and nowhere else; the public key into\n" +
"internal/release/trusted.pub, which every build from then on carries\n" +
"and checks releases against. Run once, on a machine you trust.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
pub, priv, err := release.GenerateKey()
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "public: %s\nprivate: %s\n", pub, priv)
fmt.Fprintln(cmd.ErrOrStderr(), "\nThe private key signs every release: put it in the CI secret "+
"RELEASE_SIGNING_KEY, then clear this terminal's scrollback.")
return nil
},
})
c.AddCommand(&cobra.Command{
Use: "sign FILE",
Short: "Sign a release's checksums, writing FILE.sig",
Long: "Reads the private key from RELEASE_SIGNING_KEY, never from an argument,\n" +
"so it does not land in a shell history or a process list.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
key := os.Getenv("RELEASE_SIGNING_KEY")
if key == "" {
return errors.New("RELEASE_SIGNING_KEY is not set")
}
data, err := os.ReadFile(args[0])
if err != nil {
return err
}
sig, err := release.Sign(key, data)
if err != nil {
return err
}
if pub := release.TrustedKey(); pub != "" {
// Signed with a key this build would not accept is a release
// every upgraded machine refuses: stopped here instead.
if err := release.Verify(pub, data, sig); err != nil {
return fmt.Errorf("the signing key does not match this build's trusted key: %w", err)
}
}
return os.WriteFile(args[0]+".sig", []byte(sig+"\n"), 0o644) //nolint:gosec // public
},
})
return c
}
+11
View File
@@ -30,6 +30,14 @@ func Root() *cobra.Command {
PersistentPreRun: func(_ *cobra.Command, _ []string) {
obs.Setup(obs.Options{Level: global.logLevel, Format: global.logFormat})
},
// Bare, in a terminal: the setup screens or the console. Otherwise
// the help, as before.
RunE: func(cmd *cobra.Command, _ []string) error {
if interactive(cmd) {
return runHome(cmd)
}
return cmd.Help()
},
}
pf := root.PersistentFlags()
@@ -47,6 +55,9 @@ func Root() *cobra.Command {
newSimEngineCmd(),
newDoctorCmd(),
newNodeCmd(),
newSelfUpgradeCmd(),
newReleaseCmd(),
newJoinCmd(),
)
return root
}
+87
View File
@@ -0,0 +1,87 @@
package cli
import (
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
)
// newSelfUpgradeCmd upgrades this machine's llmgw on its own, for the machines
// `llmgw ctl upgrade` does not reach: a mirror host, above all, whose service
// runs unprivileged with the filesystem read-only and so cannot replace its own
// binary the way an agent does.
func newSelfUpgradeCmd() *cobra.Command {
var releases, service string
c := &cobra.Command{
Use: "upgrade VERSION [--service UNIT]",
Short: "Upgrade this machine's llmgw, and restart the service that runs it",
Long: "For a machine the control plane does not upgrade — the model mirror's host.\n" +
"The control plane and the nodes are upgraded with `llmgw ctl upgrade`.\n\n" +
"Fetches the release for this platform, checks it against the release's\n" +
"checksums, keeps the old binary beside the new one, and restarts --service,\n" +
"waiting for it to be running again. Run it with sudo.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
v := args[0]
out := cmd.OutOrStdout()
if !release.Tag.MatchString(v) {
return fmt.Errorf("%q is not a release version (v1.2.3 or v1.2.3-rc.4)", v)
}
if version.Version == v {
fmt.Fprintf(out, "this machine already runs %s\n", v)
return nil
}
if os.Geteuid() != 0 {
return errors.New("replacing this machine's binary needs root: run it with sudo")
}
self, err := os.Executable()
if err != nil {
return err
}
backup, err := release.Install(cmd.Context(), nil, releases, v, runtime.GOOS, runtime.GOARCH, self)
if err != nil {
return err
}
fmt.Fprintf(out, "%s -> %s: installed, checksum verified; the old binary is %s\n",
version.Version, v, backup)
if service == "" {
return nil
}
//nolint:gosec // the unit name is the operator's own flag
if b, err := exec.CommandContext(cmd.Context(), "systemctl", "restart", service).CombinedOutput(); err != nil {
return fmt.Errorf("restarting %s: %v: %s", service, err, strings.TrimSpace(string(b)))
}
// Running, and still running a few seconds on: a unit that starts
// and dies on a bad flag is "active" for a moment first.
deadline := time.Now().Add(30 * time.Second)
steady := 0
for time.Now().Before(deadline) {
//nolint:gosec // as above
b, _ := exec.CommandContext(cmd.Context(), "systemctl", "is-active", service).Output()
if strings.TrimSpace(string(b)) == "active" {
if steady++; steady >= 3 {
fmt.Fprintf(out, "%s restarted and running\n", service)
return nil
}
} else {
steady = 0
}
time.Sleep(2 * time.Second)
}
return fmt.Errorf("%s is not running after the upgrade; see journalctl -u %s, and %s holds the previous binary",
service, service, backup)
},
}
c.Flags().StringVar(&releases, "releases", release.DefaultBase, "where release binaries are fetched from")
c.Flags().StringVar(&service, "service", "", "the systemd unit to restart afterwards, e.g. llmgw-mirror")
return c
}
+119 -7
View File
@@ -5,18 +5,24 @@ import (
"crypto/tls"
"errors"
"fmt"
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
"gitea.mynodes.xyz/ilker/llmgw/internal/sourcecheck"
"github.com/prometheus/client_golang/prometheus"
"log/slog"
"maps"
"net"
"net/http"
"os/signal"
"slices"
"strings"
"sync"
"syscall"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
"gitea.mynodes.xyz/ilker/llmgw/internal/sourcecheck"
"github.com/prometheus/client_golang/prometheus"
"github.com/spf13/cobra"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
@@ -34,6 +40,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
"gitea.mynodes.xyz/ilker/llmgw/internal/obs"
"gitea.mynodes.xyz/ilker/llmgw/internal/promq"
"gitea.mynodes.xyz/ilker/llmgw/internal/router"
storepkg "gitea.mynodes.xyz/ilker/llmgw/internal/store"
@@ -89,6 +96,21 @@ func newServerCmd() *cobra.Command {
"or give --endpoint for a router-only run")
}
// Before the router, so its tracer is the configured one.
shutdownTracing, err := obs.SetupTracing(cmd.Context(), obs.TracingOptions{
Endpoint: cfg.Tracing.Endpoint, SampleRatio: cfg.Tracing.SampleRatio,
Service: "llmgw-server", Version: version.Version,
})
if err != nil {
return err
}
defer func() {
// Bounded: a collector that is gone must not hold up a shutdown.
sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = shutdownTracing(sctx)
}()
rt, err := router.New(cfg, log)
if err != nil {
return err
@@ -106,6 +128,9 @@ func newServerCmd() *cobra.Command {
var agentHandler http.Handler
var agentPath string
var admin *control.Controller
// The store's cluster, when it is one; read by the metrics and
// by `ctl status`.
var storeHA *pgha.Collector
if controlMode {
cat, err := cfg.Catalog()
if err != nil {
@@ -158,6 +183,17 @@ func newServerCmd() *cobra.Command {
if err := rt.RegisterMetrics(fleetmetrics.New(ctl)); err != nil {
return err
}
// A store of several hosts is a replicated cluster: what state
// it is in is published beside everything else, read from its
// Patroni. One host is a single PostgreSQL, which has none.
if addr, err := storepkg.ResolveAddress(cfg.Auth.Store); err == nil && storepkg.IsPostgres(addr) {
if hosts, _, _, err := storepkg.SplitPostgres(strings.TrimSpace(addr)); err == nil && len(hosts) > 1 {
storeHA = pgha.NewCollector(hosts)
if err := rt.RegisterMetrics(storeHA); err != nil {
return err
}
}
}
// So a request for a model that is configured but not running
// starts it, rather than being told nothing serves it.
rt.SetActivator(ctl.Reconciler())
@@ -171,8 +207,27 @@ func newServerCmd() *cobra.Command {
if err != nil {
return err
}
// Kept past this block: serving a model from the registry needs
// both the admin API and the mirror, and the mirror is set up
// below.
var (
adminAPI *adminapi.API
catalogSvc *modelsvc.Service
)
if admin != nil {
api := adminapi.New(admin)
if storeHA != nil {
api.SetStoreStatus(func() ([]adminapi.StoreMember, error) {
c, err := storeHA.Read()
var out []adminapi.StoreMember
for _, m := range c.Members {
out = append(out, adminapi.StoreMember{Name: m.Name, Host: m.Host, Role: m.Role, State: m.State, Lag: m.Lag})
}
return out, err
})
}
adminAPI = api
api.SetReleases(cfg.Releases)
// Catalog changes need somewhere to persist, so this is wired
// only when there is a store. Without one, the endpoints say so
// rather than accepting a model that vanishes on restart.
@@ -189,6 +244,7 @@ func newServerCmd() *cobra.Command {
admin.ReplaceCatalog(ctx, built)
}
api.SetModels(svc)
catalogSvc = svc
// And the mirror page, so a model that has just been
// fetched can be served without writing YAML by hand.
rt.SetCatalogAdmin(svc)
@@ -199,6 +255,8 @@ func newServerCmd() *cobra.Command {
// And what each group of the tenancy tree spent, which is
// the other half of a quota being reviewable.
api.SetUsage(st)
// Capacity quotas, set and read over this API.
api.SetQuotas(st)
}
api.Register(adminMux)
@@ -258,7 +316,16 @@ func newServerCmd() *cobra.Command {
// Browsing the registry is what turns "paste a resolve URL"
// into "find the model". It needs a way out to the internet
// from this machine, which the mirror's own fetching does not.
rt.SetRegistry(hf.New(cfg.Mirror.Registry, cfg.Mirror.Timeout))
registry := hf.New(cfg.Mirror.Registry, cfg.Mirror.Timeout)
rt.SetRegistry(registry)
// One command from a registry repository to a replica that
// answers: `llmgw ctl model serve`.
if adminAPI != nil && catalogSvc != nil {
adminAPI.SetServe(modelserve.New(modelserve.Deps{
Registry: registry, Mirror: mc, Catalog: catalogSvc, Fleet: admin,
LlamaVersion: cfg.WantedEngines()["llamacpp"], Log: log,
}))
}
log.Info("model mirror configured",
"api", cfg.Mirror.API, "mtls", cfg.Mirror.TLS.Enabled())
}
@@ -384,7 +451,7 @@ func newServerCmd() *cobra.Command {
f.StringVar(&metricsListen, "metrics-listen", "", "serve metrics only on this address, safe to expose")
f.StringArrayVar(&endpoints, "endpoint", nil, "static endpoint: id=URL=model[,model] (repeatable)")
cmd.AddCommand(newServerInitCmd())
cmd.AddCommand(newServerInitCmd(), newServerAddCmd())
cmd.AddCommand(newBackupCmd())
return cmd
}
@@ -446,14 +513,31 @@ func startControlPlane(ctx context.Context, cfg config.Server, cat *catalog.Live
if h, ok := sink.(interface{ Store() *storepkg.DB }); ok {
if st := h.Store(); st != nil {
lead = control.NewLeadership(st, cfg.Control.LeaseTTL, log)
if r, ok := sink.(interface{ SetLeader(func() bool) }); ok {
r.SetLeader(lead.IsLeader)
}
ctl.SetLeadership(lead)
// The tenancy tree's capacity quotas: what each part is
// guaranteed and may hold.
ctl.Reconciler().SetQuotas(control.QuotaPolicy(st, log))
// And what each model was measured to serve, for scaling and for
// choosing between profiles.
ctl.Reconciler().SetMeasurements(control.Measurements(st, log))
// The engine builds chosen per node on the cluster page.
ctl.SetEngineChoices(control.NewEngineChoices(ctx, st, log))
log.Info("competing to be the control plane that places work",
"owner", lead.Owner())
}
}
svc := agentsvc.New(ctl.Inventory(), ctl.Reconciler(), requireIdentity, log)
svc.SetEngines(cfg.WantedEngines())
log.Info("nodes install these engines", "engines", cfg.WantedEngines())
svc.SetEnginesFor(func(n inventory.Node) map[string]string {
return cfg.EnginesFor(n.ID, slices.Sorted(maps.Keys(n.GPUsByClass())), n.Labels)
})
log.Info("nodes install these engines", "engines", cfg.WantedEngines(), "overrides", len(cfg.EngineOverrides))
if ch := ctl.EngineChoices(); ch != nil {
svc.SetEngineChoices(ch.For)
ch.OnChange(svc.Reprovision)
}
// A standby must refuse agents as well as refusing to place. 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
@@ -461,6 +545,21 @@ func startControlPlane(ctx context.Context, cfg config.Server, cat *catalog.Live
if lead != nil {
svc.SetLeadership(lead)
}
// The control planes agents may dial, from the store every control plane
// shares: told to each agent as it registers, and to all of them when
// `server add` records another.
if h, ok := sink.(interface{ Store() *storepkg.DB }); ok && h.Store() != nil {
st := h.Store()
svc.SetControlPlanes(func(ctx context.Context) ([]string, error) {
cps, err := st.ControlPlanes(ctx)
urls := make([]string, len(cps))
for i, c := range cps {
urls[i] = c.URL
}
return urls, err
})
go svc.WatchControlPlanes(ctx, 30*time.Second)
}
fwd.set(svc)
// Every InfiniBand fabric mapped every five minutes, through one node.
go svc.ScanFabrics(ctx, 5*time.Minute)
@@ -546,6 +645,19 @@ func (r *dispatcherRef) FabricTest(ctx context.Context, nodeID string, req *llmg
return ft.FabricTest(ctx, nodeID, req)
}
// Upgrade forwards a node upgrade, as Logs does.
func (r *dispatcherRef) Upgrade(ctx context.Context, nodeID, version, releases string) error {
d, err := r.get()
if err != nil {
return err
}
u, ok := d.(control.Upgrader)
if !ok {
return errors.New("this dispatcher cannot upgrade nodes")
}
return u.Upgrade(ctx, nodeID, version, releases)
}
func (r *dispatcherRef) StartShard(ctx context.Context, cmd reconciler.ShardCommand) error {
d, err := r.get()
if err != nil {
+459
View File
@@ -0,0 +1,459 @@
package cli
import (
"bytes"
"context"
"crypto/rand"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
)
// serverAdd makes another machine a control plane of this cluster: a standby
// that takes over when this one is lost. Run on a control plane that holds
// the authority, as root, like `node add`.
type serverAdd struct {
etcDir, pkiDir string
// hosts are more names to put in the new control plane's certificate.
hosts []string
// vip is a floating address, "10.0.0.160/24", to keep on whichever of the
// two leads; "" leaves client traffic to whatever is in front.
vip string
remote onboard.Remote
// addr is the machine's address as given; ip what the others reach it at.
addr, ip, name string
out io.Writer
}
func newServerAddCmd() *cobra.Command {
sa := serverAdd{etcDir: "/etc/llmgw", pkiDir: defaultPKIDir}
cmd := &cobra.Command{
Use: "add [user@]address",
Short: "Make another machine a control plane of this cluster: a standby that takes over",
Long: "Run on a control plane, as root. It logs in to the machine with ssh, as\n" +
"node add does, and gives it this llmgw, a certificate from this\n" +
"cluster's authority, this control plane's configuration and the store's\n" +
"address, lets it into the store, and starts it. It stands by while this\n" +
"one leads, and takes over within seconds when this one is lost; every\n" +
"node is told of it and fails over to it without being touched.\n\n" +
"The store must be one both can reach — replicated with 'server init\n" +
"--patroni' — not a database on this machine.\n\n" +
"--vip keeps a floating address on whichever control plane leads, with\n" +
"keepalived on both: the address clients use for the gateway.\n\n" +
"Run it again at any time: it changes only what is missing.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if os.Geteuid() != 0 {
return errors.New("run it as root: it reads the certificate authority and the store's address")
}
user, host, ok := strings.Cut(args[0], "@")
if !ok {
user, host = "", args[0]
}
sa.remote = &onboard.SSH{Addr: host, User: user}
sa.addr = host
sa.out = cmd.OutOrStdout()
return sa.run(cmd.Context())
},
}
f := cmd.Flags()
f.StringArrayVar(&sa.hosts, "host", nil, "another name for the new control plane's certificate; repeatable")
f.StringVar(&sa.vip, "vip", "", "a floating address for whichever control plane leads, as 10.0.0.160/24")
f.StringVar(&sa.pkiDir, "pki", sa.pkiDir, "directory holding the certificate authority")
return cmd
}
func (sa *serverAdd) done(format string, a ...any) {
fmt.Fprintf(sa.out, " ok "+format+"\n", a...)
}
func (sa *serverAdd) kept(format string, a ...any) {
fmt.Fprintf(sa.out, " kept "+format+"\n", a...)
}
func (sa *serverAdd) sh(ctx context.Context, script string, stdin []byte) (string, error) {
out, err := sa.remote.Run(ctx, script, stdin)
if err != nil {
return string(out), fmt.Errorf("on %s: %v: %s", sa.addr, err, strings.TrimSpace(string(out)))
}
return string(out), nil
}
// put writes a file there, through standard input, with its mode and owner.
func (sa *serverAdd) put(ctx context.Context, path, mode, owner string, body []byte) error {
_, err := sa.sh(ctx, fmt.Sprintf(`set -e
sudo install -d -m 0755 %[1]s
sudo sh -c 'umask 077; cat > %[2]s.new'
sudo chmod %[3]s %[2]s.new
sudo chown %[4]s %[2]s.new
sudo mv -f %[2]s.new %[2]s`, filepath.Dir(path), path, mode, owner), body)
return err
}
func (sa *serverAdd) run(ctx context.Context) error {
steps := []struct {
name string
fn func(context.Context) error
}{
{"connect", sa.connect},
{"binary", sa.binary},
{"directories", sa.dirs},
{"certificate", sa.certificate},
{"configuration", sa.configuration},
{"store", sa.storeAccess},
{"service", sa.service},
}
if sa.vip != "" {
steps = append(steps, struct {
name string
fn func(context.Context) error
}{"floating address", sa.floating})
}
for _, s := range steps {
if err := s.fn(ctx); err != nil {
return fmt.Errorf("%s: %w", s.name, err)
}
}
fmt.Fprintf(sa.out, "\n%s is a control plane of this cluster, standing by while this one leads.\n", sa.name)
return nil
}
func (sa *serverAdd) connect(ctx context.Context) error {
out, err := sa.sh(ctx, "hostname; sudo -n true && echo sudo-ok", nil)
if err != nil {
return fmt.Errorf("%w — it needs ssh and sudo without a password", err)
}
lines := strings.Split(strings.TrimSpace(out), "\n")
sa.name = strings.TrimSpace(lines[0])
if sa.ip == "" {
if sa.ip, err = ipv4Of(sa.addr); err != nil {
return err
}
}
sa.done("%s (%s)", sa.name, sa.ip)
return nil
}
// binary gives it this very llmgw, so the two control planes never differ.
func (sa *serverAdd) binary(ctx context.Context) error {
have, _ := sa.sh(ctx, "llmgw --version 2>/dev/null || true", nil)
if strings.TrimSpace(have) == "llmgw version "+version.Version {
sa.kept("llmgw %s", version.Version)
return nil
}
exe, err := os.Executable()
if err != nil {
return err
}
b, err := os.ReadFile(exe)
if err != nil {
return err
}
if _, err := sa.sh(ctx, `set -e; t=$(mktemp); cat > "$t"; sudo install -m 0755 "$t" /usr/local/bin/llmgw; rm -f "$t"`, b); err != nil {
return err
}
sa.done("llmgw %s, copied from this machine", version.Version)
return nil
}
func (sa *serverAdd) dirs(ctx context.Context) error {
_, err := sa.sh(ctx, `set -e
id llmgw >/dev/null 2>&1 || sudo useradd --system --home /var/lib/llmgw --shell /usr/sbin/nologin llmgw
sudo install -d -m 0755 /etc/llmgw
sudo install -d -o llmgw -g llmgw -m 0700 /etc/llmgw/secrets
sudo install -d -o llmgw -g llmgw -m 0750 /var/lib/llmgw`, nil)
if err == nil {
sa.done("user llmgw, /etc/llmgw and /var/lib/llmgw")
}
return err
}
// certificate issues it a server certificate from this cluster's authority,
// for its name and addresses, unless it holds one that already names them.
func (sa *serverAdd) certificate(ctx context.Context) error {
ca, err := loadCA(sa.pkiDir)
if err != nil {
return fmt.Errorf("the authority's key is not in %s: run this where it is (%v)", sa.pkiDir, err)
}
ips, _ := sa.sh(ctx, "hostname -I", nil)
want := []string{sa.name, sa.ip}
for _, ip := range strings.Fields(ips) {
if net.ParseIP(ip).To4() != nil && !slices.Contains(want, ip) {
want = append(want, ip)
}
}
want = append(want, sa.hosts...)
if have, _ := sa.sh(ctx, "sudo cat /etc/llmgw/server.pem 2>/dev/null || true", nil); have != "" {
if blk, _ := pem.Decode([]byte(have)); blk != nil {
if cert, err := x509.ParseCertificate(blk.Bytes); err == nil && cert.CheckSignatureFrom(ca.Cert) == nil {
names := strings.Split(sanNames(cert), ", ")
missing := false
for _, w := range want {
missing = missing || !slices.Contains(names, w)
}
if !missing {
sa.kept("certificate for %s", strings.Join(names, ", "))
return sa.put(ctx, "/etc/llmgw/ca.pem", "0644", "root:root", ca.PEM)
}
}
}
}
leaf, err := ca.IssueServer(sa.name, want)
if err != nil {
return err
}
if err := sa.put(ctx, "/etc/llmgw/ca.pem", "0644", "root:root", ca.PEM); err != nil {
return err
}
if err := sa.put(ctx, "/etc/llmgw/server.pem", "0644", "root:root", leaf.CertPEM); err != nil {
return err
}
if err := sa.put(ctx, "/etc/llmgw/server-key.pem", "0640", "root:llmgw", leaf.KeyPEM); err != nil {
return err
}
sa.done("certificate for %s, from this cluster's authority", strings.Join(want, ", "))
return nil
}
// configuration copies this control plane's, and the secrets beside it: the
// two must agree on everything, the store above all.
func (sa *serverAdd) configuration(ctx context.Context) error {
if have, _ := sa.sh(ctx, "sudo test -f /etc/llmgw/server.yaml && echo yes || true", nil); strings.TrimSpace(have) == "yes" {
sa.kept("/etc/llmgw/server.yaml (never overwritten)")
} else {
b, err := os.ReadFile(filepath.Join(sa.etcDir, "server.yaml"))
if err != nil {
return err
}
if err := sa.put(ctx, "/etc/llmgw/server.yaml", "0644", "root:root", b); err != nil {
return err
}
sa.done("/etc/llmgw/server.yaml, as this control plane's")
}
secrets, err := os.ReadDir(filepath.Join(sa.etcDir, "secrets"))
if err != nil && !os.IsNotExist(err) {
return err
}
for _, e := range secrets {
if e.IsDir() || strings.Contains(e.Name(), ".pre-") || strings.HasSuffix(e.Name(), ".new") {
continue
}
path := "/etc/llmgw/secrets/" + e.Name()
mine, err := os.ReadFile(filepath.Join(sa.etcDir, "secrets", e.Name()))
if err != nil {
return err
}
theirs, _ := sa.sh(ctx, "sudo cat "+path+" 2>/dev/null || true", nil)
switch {
case theirs == string(mine):
sa.kept("%s", path)
case theirs != "":
// Two control planes on two stores are two clusters.
return fmt.Errorf("%s there differs from this one's; remove it there if that machine is to join this cluster", path)
default:
if err := sa.put(ctx, path, "0600", "llmgw:llmgw", mine); err != nil {
return err
}
sa.done("%s", path)
}
}
return nil
}
// storeAccess lets it into the store, and records both control planes for
// the nodes to be told.
func (sa *serverAdd) storeAccess(ctx context.Context) error {
cfg, err := config.Load(filepath.Join(sa.etcDir, "server.yaml"))
if err != nil {
return err
}
addr, err := store.ResolveAddress(cfg.Auth.Store)
if err != nil {
return err
}
addr = strings.TrimSpace(addr)
if !store.IsPostgres(addr) {
return errors.New("the store is SQLite on this machine; a second control plane needs one both reach — run 'llmgw server init --patroni' first")
}
hosts, _, _, err := store.SplitPostgres(addr)
if err != nil {
return err
}
for _, h := range hosts {
if ip := net.ParseIP(h); ip != nil && ip.IsLoopback() || h == "localhost" {
return fmt.Errorf("the store is at %s, on this machine only; replicate it first with 'llmgw server init --patroni'", h)
}
}
switch {
case slices.Contains(hosts, sa.ip):
sa.kept("store: %s is one of its members", sa.ip)
case len(hosts) > 1:
changed, err := pgha.AllowClient(ctx, hosts, sa.ip)
if err != nil {
return err
}
if changed {
sa.done("store: %s let in, through Patroni", sa.ip)
} else {
sa.kept("store: %s already let in", sa.ip)
}
default:
sa.kept("store at %s: make sure it lets %s in", hosts[0], sa.ip)
}
st, err := store.Open(ctx, addr)
if err != nil {
return err
}
defer st.Close()
_, port, _ := net.SplitHostPort(cfg.AgentListen)
if port == "" {
port = "7443"
}
self, err := sourceIPToward(sa.ip)
if err != nil {
return err
}
me, _ := os.Hostname()
for _, cp := range []store.ControlPlane{
{URL: "https://" + net.JoinHostPort(self, port), Name: me},
{URL: "https://" + net.JoinHostPort(sa.ip, port), Name: sa.name, AddedAt: time.Now().Add(time.Second)},
} {
if err := st.PutControlPlane(ctx, cp); err != nil {
return err
}
}
sa.done("nodes will be told of both control planes within half a minute")
return nil
}
// service installs the unit and starts it, then waits for it to answer.
func (sa *serverAdd) service(ctx context.Context) error {
if err := sa.put(ctx, "/etc/systemd/system/llmgw-server.service", "0644", "root:root", []byte(serverUnit)); err != nil {
return err
}
if _, err := sa.sh(ctx, "sudo systemctl daemon-reload && sudo systemctl enable llmgw-server >/dev/null 2>&1 && sudo systemctl restart llmgw-server", nil); err != nil {
return err
}
deadline := time.Now().Add(90 * time.Second)
for {
code, _ := sa.sh(ctx, "curl -s -o /dev/null -m 2 -w '%{http_code}' http://127.0.0.1:8080/leader || true", nil)
switch strings.TrimSpace(code) {
case "200":
sa.done("llmgw-server running, and leading")
return nil
case "503":
sa.done("llmgw-server running, standing by")
return nil
}
if time.Now().After(deadline) {
logs, _ := sa.sh(ctx, "sudo journalctl -u llmgw-server -n 15 --no-pager", nil)
return fmt.Errorf("it did not answer within 90s:\n%s", logs)
}
time.Sleep(2 * time.Second)
}
}
// floating keeps an address on whichever of the two leads, with keepalived
// on both, following /leader.
func (sa *serverAdd) floating(ctx context.Context) error {
vip, nw, err := net.ParseCIDR(sa.vip)
if err != nil || vip.To4() == nil {
return fmt.Errorf("--vip %q is not an IPv4 address with its prefix, like 10.0.0.160/24", sa.vip)
}
prefix, _ := nw.Mask.Size()
pass, err := vrrpPassword(sa.etcDir)
if err != nil {
return err
}
here := pgha.Local{}
for _, m := range []struct {
r onboard.Remote
name string
priority int
}{{here, "this machine", 110}, {sa.remote, sa.name, 100}} {
out, err := m.r.Run(ctx, fmt.Sprintf(`set -e
command -v keepalived >/dev/null 2>&1 || { sudo DEBIAN_FRONTEND=noninteractive apt-get -qq update >/dev/null; sudo DEBIAN_FRONTEND=noninteractive apt-get -qq install -y keepalived >/dev/null; }
ip -o route get %s | awk '{for(i=1;i<=NF;i++) if($i=="dev") {print $(i+1); exit}}'`, vip), nil)
if err != nil {
return fmt.Errorf("on %s: %v: %s", m.name, err, strings.TrimSpace(string(out)))
}
iface := strings.TrimSpace(string(out))
conf := keepalivedConf(iface, vip.String(), prefix, m.priority, pass)
if out, err := m.r.Run(ctx, `set -e
sudo install -d -m 0755 /etc/keepalived
sudo sh -c 'umask 077; cat > /etc/keepalived/keepalived.conf'
sudo systemctl enable keepalived >/dev/null 2>&1
sudo systemctl restart keepalived`, []byte(conf)); err != nil {
return fmt.Errorf("on %s: %v: %s", m.name, err, strings.TrimSpace(string(out)))
}
}
sa.done("%s follows whichever control plane leads (keepalived on both)", sa.vip)
return nil
}
// vrrpPassword is the shared secret of the two keepalived, made once and
// kept with the other secrets, so running this again does not split them.
func vrrpPassword(etcDir string) (string, error) {
p := filepath.Join(etcDir, "secrets", "vrrp")
if b, err := os.ReadFile(p); err == nil && len(bytes.TrimSpace(b)) > 0 {
return string(bytes.TrimSpace(b)), nil
}
b := make([]byte, 4)
if _, err := rand.Read(b); err != nil {
return "", err
}
pass := hex.EncodeToString(b) // keepalived reads eight characters
if err := os.WriteFile(p, []byte(pass+"\n"), 0o600); err != nil {
return "", err
}
return pass, nil
}
func keepalivedConf(iface, vip string, prefix, priority int, pass string) string {
return fmt.Sprintf(`# llmgw: the gateway's floating address, on whichever control plane leads.
vrrp_script llmgw_leader {
script "/usr/bin/curl -fsS -o /dev/null -m 2 http://127.0.0.1:8080/leader"
interval 2
fall 2
rise 2
}
vrrp_instance llmgw {
state BACKUP
interface %s
virtual_router_id 51
priority %d
advert_int 1
authentication {
auth_type PASS
auth_pass %s
}
virtual_ipaddress {
%s/%d
}
track_script {
llmgw_leader
}
}
`, iface, priority, pass, vip, prefix)
}
+24 -2
View File
@@ -23,6 +23,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
@@ -69,6 +70,13 @@ type serverInit struct {
// postgres keeps the store in a PostgreSQL on this machine, installed if
// need be, moving whatever SQLite already holds.
postgres bool
// patroni are user@host of the machines to keep the store on under
// Patroni, replicated; this machine is the third etcd vote for two.
patroni []string
// patroniNodes are the same machines already connected to, which the
// setup screen does with the password it was given; when set, patroni
// only names them.
patroniNodes []pgha.Node
// noMirror, noNode and noModel turn off the parts of a first install that
// make it serve something: a mirror, this machine as a node, and a small
// model on it.
@@ -86,14 +94,19 @@ type serverInit struct {
changed bool
}
func newServerInitCmd() *cobra.Command {
in := serverInit{
// newDefaultServerInit is init as it sets up a real machine.
func newDefaultServerInit() serverInit {
return serverInit{
etcDir: "/etc/llmgw",
stateDir: "/var/lib/llmgw",
pkiDir: defaultPKIDir,
unitPath: "/etc/systemd/system/llmgw-server.service",
user: "llmgw",
}
}
func newServerInitCmd() *cobra.Command {
in := newDefaultServerInit()
var plain bool
cmd := &cobra.Command{
Use: "init",
@@ -111,6 +124,12 @@ func newServerInitCmd() *cobra.Command {
"installed if there is none, with its own role and a generated password\n" +
"kept in /etc/llmgw/secrets. An existing SQLite store is copied into it,\n" +
"so accounts and keys survive, and server.yaml is pointed at it.\n\n" +
"With --patroni, given two or more machines, the store is PostgreSQL\n" +
"replicated across them under Patroni, which promotes a standby when the\n" +
"primary is lost. etcd holds its leader lock on those machines — and on\n" +
"this one, when they are two, as the third vote. Installed over ssh with\n" +
"passwordless sudo, as 'llmgw node add' does; the store already in use,\n" +
"SQLite or PostgreSQL, is moved in. Run again, it changes nothing.\n\n" +
"Each step is skipped when its result already exists, so it is safe to run\n" +
"again: to finish an install that stopped, or with another --host to\n" +
"reissue the certificate. It never overwrites server.yaml or the authority.\n\n" +
@@ -144,6 +163,9 @@ func newServerInitCmd() *cobra.Command {
f.BoolVar(&in.noModel, "no-model", false, "do not fetch and serve the starter model ("+starterName+")")
f.BoolVar(&in.postgres, "postgres", false,
"keep the store in PostgreSQL on this machine, installing it and moving an existing SQLite store into it")
f.StringArrayVar(&in.patroni, "patroni", nil,
"user@host of a machine to keep the store on, replicated under Patroni; give two or more. "+
"Installs PostgreSQL, Patroni and etcd there over ssh, and moves the existing store in")
return cmd
}
+182
View File
@@ -0,0 +1,182 @@
package cli
import (
"context"
"fmt"
"net"
"os"
"os/exec"
"strings"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// ensurePatroni puts the store in PostgreSQL under Patroni on the machines
// given with --patroni, and moves whatever store server.yaml names into it:
// SQLite, or a PostgreSQL on one machine. This machine is the third etcd vote
// when they are two.
func (in *serverInit) ensurePatroni(ctx context.Context) (err error) {
setup := pgha.Setup{Emit: func(step, detail string, changed bool) {
if changed {
in.done("%s: %s", step, detail)
} else {
in.kept("%s: %s", step, detail)
}
}}
setup.DB = in.patroniNodes
for _, t := range in.patroni {
if len(in.patroniNodes) > 0 {
break
}
user, host, ok := strings.Cut(t, "@")
if !ok {
user, host = "", t
}
remote := &onboard.SSH{Addr: host, User: user}
out, err := remote.Run(ctx, "hostname && sudo -n true", nil)
if err != nil {
return fmt.Errorf("%s: cannot log in and use sudo without a password: %v: %s",
t, err, strings.TrimSpace(string(out)))
}
ip, err := ipv4Of(host)
if err != nil {
return err
}
setup.DB = append(setup.DB, pgha.Node{
Member: pgha.Member{Name: strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]), IP: ip},
Remote: remote,
})
}
self, err := sourceIPToward(setup.DB[0].IP)
if err != nil {
return err
}
name, _ := os.Hostname()
setup.Witness = &pgha.Node{Member: pgha.Member{Name: name, IP: self}, Remote: pgha.Local{}}
setup.Clients = []string{self}
dsn, err := setup.Run(ctx)
if err != nil {
return err
}
cfg, err := config.Load(in.configPath())
if err != nil {
return err
}
current := cfg.Auth.Store
if current == "" {
current = in.storeAddress()
}
resolved, _ := store.ResolveAddress(current)
if strings.TrimSpace(resolved) == dsn {
in.kept("store in the Patroni cluster (%s)", in.dsnPath())
return nil
}
// Still while it moves, and started again on the old store if the move
// fails: a gateway down over a database that did not work out is worse
// than one left where it was.
stopped, err := in.stopService(ctx)
if err != nil {
return err
}
defer func() {
if err != nil && stopped {
_ = exec.Command("systemctl", "start", "llmgw-server").Run()
fmt.Fprintf(in.out, " .. llmgw-server started again on its old store\n")
}
}()
if err := in.moveInto(ctx, current, dsn); err != nil {
return err
}
// The old address is kept beside the new one: the way back, if the
// cluster is not what was wanted.
if b, err := os.ReadFile(in.dsnPath()); err == nil && strings.TrimSpace(string(b)) != dsn {
if err := os.WriteFile(in.dsnPath()+".pre-patroni", b, 0o600); err != nil {
return err
}
_ = in.chown(in.dsnPath()+".pre-patroni", true)
}
if err := in.writeDSN(dsn); err != nil {
return err
}
if current != "@"+in.dsnPath() {
if err := setAuthStore(in.configPath(), "@"+in.dsnPath()); err != nil {
return err
}
}
// Started again by the service step that follows, on the new address.
in.done("server.yaml now keeps the store in the Patroni cluster")
return nil
}
// moveInto copies a store into the cluster, unless the cluster already holds
// one — from a run that copied and stopped before changing the address.
func (in *serverInit) moveInto(ctx context.Context, from, to string) error {
src, err := store.Open(ctx, from)
if err != nil {
// Nothing there yet: a first install, with nothing to move.
in.kept("no store at %s to move", from)
return nil
}
defer src.Close()
dst, err := store.Open(ctx, to)
if err != nil {
return fmt.Errorf("opening the cluster: %w", err)
}
defer dst.Close()
empty, err := dst.Empty(ctx)
if err != nil {
return err
}
if !empty {
in.kept("the cluster already holds a store; %s is left as it is", from)
return nil
}
res, err := src.CopyTo(ctx, dst)
if err != nil {
return fmt.Errorf("copying the store into the cluster: %w", err)
}
var rows int64
for _, r := range res {
rows += r.Rows
}
in.done("copied %d rows into the cluster (the old store is kept as it was)", rows)
return nil
}
// sourceIPToward is this machine's address on the route to another: the one
// the other machine sees it connect from, which is what etcd advertises and
// what the database lets in. Not the first --host, which names the gateway
// for its clients and may not resolve yet, or resolve to a floating address.
func sourceIPToward(ip string) (string, error) {
c, err := net.Dial("udp", net.JoinHostPort(ip, "5432"))
if err != nil {
return "", fmt.Errorf("no route from this machine to %s: %w", ip, err)
}
defer c.Close()
return c.LocalAddr().(*net.UDPAddr).IP.String(), nil
}
// ipv4Of is the address a host name or address stands for, as the other
// machines will be told to reach it.
func ipv4Of(host string) (string, error) {
if ip := net.ParseIP(host); ip != nil {
return ip.String(), nil
}
ips, err := net.LookupIP(host)
if err != nil {
return "", fmt.Errorf("%s does not resolve: %w", host, err)
}
for _, ip := range ips {
if v4 := ip.To4(); v4 != nil {
return v4.String(), nil
}
}
return "", fmt.Errorf("%s has no IPv4 address", host)
}
+6
View File
@@ -44,6 +44,12 @@ func (in *serverInit) storeAddress() string {
// means reissuing every key and being unable to say which was which; and the
// administrator init created a minute ago would be gone too.
func (in *serverInit) ensureDatabase(ctx context.Context) (err error) {
if len(in.patroni) > 0 || len(in.patroniNodes) > 0 {
if in.postgres {
return fmt.Errorf("--postgres keeps the store on this machine and --patroni on others: give one")
}
return in.ensurePatroni(ctx)
}
if !in.postgres {
return nil
}
+39
View File
@@ -2,6 +2,7 @@ package cli
import (
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
@@ -94,6 +95,31 @@ func printSummary(cmd *cobra.Command, s adminapi.Summary) {
fmt.Fprintf(out, "\nMODELS %d of %d have the replicas they asked for\n",
s.Models.Satisfied, s.Models.Total)
// The store, when it is a replicated cluster: one line, saying who leads
// and whether every other member keeps up.
switch {
case s.StoreError != "":
fmt.Fprintf(out, "STORE cannot read its cluster: %s\n", s.StoreError)
case len(s.Store) > 0:
var parts []string
leader := false
for _, m := range s.Store {
switch {
case m.Role == "leader":
leader = true
parts = append(parts, m.Name+" leads")
case m.State == "streaming" && m.Lag >= 0:
parts = append(parts, fmt.Sprintf("%s streaming (%s behind)", m.Name, bytesShort(m.Lag)))
default:
parts = append(parts, fmt.Sprintf("%s %s", m.Name, m.State))
}
}
if !leader {
parts = append([]string{"NO LEADER — writes fail"}, parts...)
}
fmt.Fprintf(out, "STORE %s\n", strings.Join(parts, ", "))
}
if len(s.Parked) > 0 {
fmt.Fprintln(out, "\nPARKED — failed every time they started; not retried until fixed")
for _, p := range s.Parked {
@@ -169,3 +195,16 @@ func unhealthyCell(n int) string {
}
return fmt.Sprintf("%d", n)
}
// bytesShort is a byte count as a person reads it: 0 B, 12 KB, 3.4 MB.
func bytesShort(n int64) string {
switch {
case n < 1024:
return fmt.Sprintf("%d B", n)
case n < 1<<20:
return fmt.Sprintf("%d KB", n>>10)
case n < 1<<30:
return fmt.Sprintf("%.1f MB", float64(n)/(1<<20))
}
return fmt.Sprintf("%.1f GB", float64(n)/(1<<30))
}
File diff suppressed because it is too large. Load diff
+237
View File
@@ -0,0 +1,237 @@
package cli
import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
// The frame every full-screen llmgw screen is drawn in: a title bar, a
// sidebar of steps or actions on the left, the content in a bordered panel on
// the right, the keys along the bottom — and a dialog over it all when one is
// open. One frame, so the setup and the console read as one program.
//
// Keyboard only: these run on serial consoles and IPMI as often as in a
// desktop terminal.
// navItem is one line of the sidebar.
type navItem struct {
Label string
// State is "done", "current", "todo" for steps; "" for menu entries,
// where Current marks the one focused.
State string
Current bool
// Dim greys an entry that cannot be chosen now.
Dim bool
// Heading is a title between groups of entries, not an entry.
Heading bool
}
type frame struct {
Width, Height int
Title string // left of the title bar
Where string // right of the title bar: the machine
NavTitle string
Nav []navItem
PanelTitle string
Body string
// Scroll is how many of the body's lines are scrolled off the top.
Scroll int
Keys string
// Dialog, when set, is drawn over the middle of the screen.
Dialog string
DialogTitle string
}
var (
fBar = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#0B1B1B"}).Background(tuiAccent)
fBorder = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(tuiDim)
fFocus = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(tuiAccent)
fKeys = lipgloss.NewStyle().Foreground(tuiDim)
fSelected = lipgloss.NewStyle().Bold(true).Foreground(tuiAccent)
fDialog = lipgloss.NewStyle().Border(lipgloss.DoubleBorder()).BorderForeground(tuiAccent).Padding(1, 2)
)
const navWidth = 24
// render draws the whole screen, exactly Width by Height.
func (f frame) render() string {
w, h := max(f.Width, 60), max(f.Height, 16)
title := " " + f.Title
right := f.Where + " "
gap := max(1, w-ansi.StringWidth(title)-ansi.StringWidth(right))
bar := fBar.Render(title + strings.Repeat(" ", gap) + right)
// Panels fill what the bars leave: one line each above and below.
ph := h - 2
inner := ph - 2 // borders
var nav strings.Builder
for _, it := range f.Nav {
mark := " "
label := it.Label
if it.Heading {
nav.WriteString(sDim.Render(label) + "\n")
continue
}
switch {
case it.State == "done":
mark = sOK.Render("✓ ")
case it.State == "current":
mark = fSelected.Render("› ")
label = fSelected.Render(label)
case it.Current:
mark = fSelected.Render("› ")
label = fSelected.Render(label)
case it.Dim || it.State == "todo":
label = sDim.Render(label)
}
nav.WriteString(truncateWidth(mark+label, navWidth-2) + "\n")
}
left := box(fBorder, f.NavTitle, nav.String(), navWidth, inner)
pw := w - navWidth - 2 - 2
lines := strings.Split(strings.TrimRight(f.Body, "\n"), "\n")
scroll := min(max(f.Scroll, 0), max(0, len(lines)-inner))
visible := lines[scroll:]
if len(visible) > inner {
visible = visible[:inner]
// Said in the last line, where it is seen.
visible[inner-1] = sDim.Render(" ↓ more — PgDn")
}
for i := range visible {
visible[i] = truncateWidth(visible[i], pw)
}
panelTitle := f.PanelTitle
if scroll > 0 {
panelTitle += " ↑"
}
main := box(fFocus, panelTitle, strings.Join(visible, "\n"), pw, inner)
keys := fKeys.Render(" " + truncateWidth(f.Keys, w-2))
screen := bar + "\n" + lipgloss.JoinHorizontal(lipgloss.Top, left, main) + "\n" + keys
if f.Dialog != "" {
dw := min(w-8, max(50, maxLineWidth(f.Dialog)+6))
d := fDialog.Width(dw).Render(f.Dialog)
if f.DialogTitle != "" {
d = titled(d, f.DialogTitle)
}
screen = overlay(screen, d, w, h)
}
return screen
}
// box draws content in a border of the given inner size, with a title set
// into the top edge.
func box(style lipgloss.Style, title, content string, width, height int) string {
b := style.Width(width).Height(height).Render(content)
if title == "" {
return b
}
return titled(b, title)
}
// titled sets " title " into a box's top border, after its corner.
func titled(b, title string) string {
lines := strings.Split(b, "\n")
if len(lines) == 0 {
return b
}
top := lines[0]
t := " " + title + " "
tw := ansi.StringWidth(t)
if ansi.StringWidth(top) < tw+4 {
return b
}
lines[0] = ansi.Truncate(top, 2, "") + sTitle.Render(t) + ansi.TruncateLeft(top, 2+tw, "")
return strings.Join(lines, "\n")
}
// overlay draws top over the middle of base, both w by h.
func overlay(base, top string, w, h int) string {
bl := strings.Split(base, "\n")
tl := strings.Split(top, "\n")
tw := maxLineWidth(top)
x := max(0, (w-tw)/2)
y := max(0, (h-len(tl))/2)
for i, l := range tl {
row := y + i
if row >= len(bl) {
break
}
under := bl[row]
left := ansi.Truncate(under, x, "")
if pad := x - ansi.StringWidth(left); pad > 0 {
left += strings.Repeat(" ", pad)
}
right := ansi.TruncateLeft(under, x+ansi.StringWidth(l), "")
bl[row] = left + l + right
}
return strings.Join(bl, "\n")
}
func maxLineWidth(s string) int {
n := 0
for _, l := range strings.Split(s, "\n") {
n = max(n, ansi.StringWidth(l))
}
return n
}
// truncateWidth cuts a line, colours and all, to w cells.
func truncateWidth(s string, w int) string {
if ansi.StringWidth(s) <= w {
return s
}
return ansi.Truncate(s, max(w-1, 0), "…")
}
// table draws rows under a header in columns as wide as they need, cut to
// width; the selected row is shown reversed when the panel has the focus.
func table(headers []string, rows [][]string, selected int, width int, focused bool) string {
widths := make([]int, len(headers))
for i, h := range headers {
widths[i] = ansi.StringWidth(h)
}
for _, r := range rows {
for i := range headers {
if i < len(r) {
widths[i] = min(max(widths[i], ansi.StringWidth(r[i])), 44)
}
}
}
line := func(cells []string) string {
var b strings.Builder
for i := range headers {
c := ""
if i < len(cells) {
c = truncateWidth(cells[i], widths[i])
}
b.WriteString(c)
if i < len(headers)-1 {
b.WriteString(strings.Repeat(" ", widths[i]-ansi.StringWidth(c)+2))
}
}
return truncateWidth(b.String(), width)
}
var b strings.Builder
b.WriteString(" " + lipgloss.NewStyle().Bold(true).Foreground(tuiDim).Render(line(headers)) + "\n")
for i, r := range rows {
l := line(r)
switch {
case i == selected && focused:
pad := max(0, width-ansi.StringWidth(ansi.Strip(l)))
l = fBar.Render(ansi.Strip(l) + strings.Repeat(" ", pad))
case i == selected:
l = lipgloss.NewStyle().Bold(true).Render(l)
}
b.WriteString(" " + l + "\n")
}
if len(rows) == 0 {
b.WriteString(" " + sDim.Render("nothing yet") + "\n")
}
return b.String()
}
+48
View File
@@ -0,0 +1,48 @@
package cli
import (
"errors"
"fmt"
"os"
"runtime"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
)
// A bare `llmgw` in a terminal: the setup screens on a machine with nothing
// installed, the console on a control plane. Anywhere else — a pipe, a
// script, another operating system — it prints its help, as it always did.
func interactive(cmd *cobra.Command) bool {
return isTerminal(cmd.OutOrStdout()) && isatty.IsTerminal(os.Stdin.Fd())
}
func runHome(cmd *cobra.Command) error {
out := cmd.OutOrStdout()
if runtime.GOOS != "linux" {
fmt.Fprintln(out, "The setup and console screens run on the Linux machine they manage.")
fmt.Fprintln(out)
return cmd.Help()
}
if os.Geteuid() != 0 {
return errors.New("run it as root — sudo llmgw — since it installs software and holds the certificate authority")
}
switch {
// Installed is the service in place, not only its configuration: a setup
// that stopped halfway has written server.yaml and nothing that runs, and
// the setup screens are what finish it.
case exists("/etc/llmgw/server.yaml") && exists("/etc/systemd/system/llmgw-server.service"):
return runConsole(cmd.Context(), out, "/etc/llmgw/server.yaml")
case exists("/etc/llmgw/agent.yaml"):
// A node: its own settings. The cluster is watched from the
// control plane.
return runConsole(cmd.Context(), out, "/etc/llmgw/agent.yaml")
}
return runSetupTUI(cmd.Context(), out)
}
func exists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
+57 -12
View File
@@ -77,10 +77,14 @@ type initModel struct {
next []string // the closing "Next:" text
spin spinnerModel
width int
done bool
err error
quit bool
start func()
height int
// framed draws it in the full-screen frame; off when another screen
// shows it in its own panel.
framed bool
done bool
err error
quit bool
start func()
}
func newInitModel(in *serverInit, found []string, askForm bool) initModel {
@@ -118,7 +122,10 @@ type startInitMsg struct{}
func (m initModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.width, m.height = msg.Width, msg.Height
if m.framed {
m.width = msg.Width - navWidth - 6
}
case spinTickMsg:
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
@@ -132,12 +139,18 @@ func (m initModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.addLine(string(msg))
case initDoneMsg:
m.done, m.err = true, msg.err
if m.framed {
return m, nil // left on screen until a key: it is gone once closed
}
return m, tea.Quit
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
m.quit = true
return m, tea.Quit
}
if m.done && m.framed && (msg.Type == tea.KeyEnter || msg.String() == "q") {
return m, tea.Quit
}
if m.form {
return m.formKey(msg)
}
@@ -240,6 +253,30 @@ func (m *initModel) moveFocus(d int) {
}
func (m initModel) View() string {
if !m.framed {
return m.plainView()
}
body := m.plainView()
if i := strings.Index(body, "\n\n"); i >= 0 {
body = body[i+1:]
}
steps := []navItem{{Label: "Start", State: "done"}, {Label: "Settings", State: "current"}, {Label: "Install", State: "todo"}}
keys := "Tab/↑↓ move · Space tick · Enter on Set up · Ctrl+C leave"
if !m.form {
steps[1].State, steps[2].State = "done", "current"
keys = "Ctrl+C stop"
if m.done {
steps[2].State = "done"
keys = "Enter close"
}
}
host, _ := os.Hostname()
return frame{Width: m.width + navWidth + 6, Height: m.height, Title: "llmgw setup", Where: host,
NavTitle: "Steps", Nav: steps, PanelTitle: "This machine as a control plane", Body: body, Keys: keys}.render()
}
// plainView is the screen as text, for a panel or a terminal that is not one.
func (m initModel) plainView() string {
var b strings.Builder
b.WriteString(sTitle.Render("llmgw · setting up this machine as a control plane") + "\n\n")
if m.form {
@@ -338,7 +375,9 @@ func (m initModel) formView() string {
if m.formError != "" {
b.WriteString("\n" + sBad.Render(m.formError) + "\n")
}
b.WriteString("\n" + sDim.Render("tab or ↑↓ to move · space to tick · enter on Set up to start · ctrl+c to leave") + "\n")
if !m.framed { // the frame's key bar says it
b.WriteString("\n" + sDim.Render("tab or ↑↓ to move · space to tick · enter on Set up to start · ctrl+c to leave") + "\n")
}
return b.String()
}
@@ -350,6 +389,7 @@ func (in *serverInit) runInitTUI(ctx context.Context, out io.Writer) error {
found = append(found, ip.String())
}
m := newInitModel(in, found, in.admin == "")
m.framed = true
var p *tea.Program
var (
@@ -373,7 +413,7 @@ func (in *serverInit) runInitTUI(ctx context.Context, out io.Writer) error {
p.Send(initDoneMsg{err})
}()
}
p = tea.NewProgram(m, tea.WithOutput(out))
p = tea.NewProgram(m, tea.WithOutput(out), tea.WithAltScreen())
final, err := p.Run()
if err != nil {
return err
@@ -389,17 +429,22 @@ func (in *serverInit) runInitTUI(ctx context.Context, out io.Writer) error {
return fmt.Errorf("stopped; run init again to finish what was started")
}
// The whole run is kept, root's only, since it holds the password. On a
// failure it is printed too, for the lines the screen had no room for;
// otherwise the screen's last frame says it, and only what must outlive
// the screen is printed again: the password, and what to do next.
// The whole run is kept, root's only, with the password taken out: it is
// shown once, on the screen, and a file on disk would outlive that. On a
// failure the run is printed too, for the lines the screen had no room
// for; otherwise the screen's last frame says it, and only what must
// outlive the screen is printed again: the password, and what to do next.
logMu.Lock()
defer logMu.Unlock()
whole := strings.Join(log, "\n") + "\n"
kept := whole
if fm.password != "" {
kept = strings.ReplaceAll(kept, fm.password, "[password shown once, not kept]")
}
saved := ""
if in.stateDir != "" {
p := filepath.Join(in.stateDir, "init.log")
if err := os.WriteFile(p, []byte(whole), 0o600); err == nil {
if err := os.WriteFile(p, []byte(kept), 0o600); err == nil {
saved = p
}
}
+54 -19
View File
@@ -68,6 +68,7 @@ type nodeModel struct {
spin spinnerModel
cancel context.CancelFunc
width int
height int
done bool
err error
node string
@@ -91,7 +92,7 @@ func (m nodeModel) Init() tea.Cmd { return m.spin.Tick }
func (m nodeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.width, m.height = msg.Width, msg.Height
case spinTickMsg:
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
@@ -131,9 +132,13 @@ func (m nodeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case askContinueMsg:
m.confirm = &msg
case finishMsg:
// Left on the screen until a key: the frame is gone once it closes.
m.done, m.err, m.node, m.summary = true, msg.err, msg.node, msg.summary
return m, tea.Quit
return m, nil
case tea.KeyMsg:
if m.done && (msg.Type == tea.KeyEnter || msg.String() == "q" || msg.Type == tea.KeyEsc) {
return m, tea.Quit
}
return m.key(msg)
}
return m, nil
@@ -197,10 +202,52 @@ func (m *nodeModel) move(d int) {
m.inputs[m.focus].Focus()
}
// View draws the onboarding in the frame: its steps down the side, what each
// did in the panel, a question as a dialog.
func (m nodeModel) View() string {
var nav []navItem
current := false
for _, step := range onboard.Steps {
st, ok := m.steps[step]
state := "todo"
switch {
case ok && st.level == onboard.Running && !m.done && !current:
state, current = "current", true
case ok && st.level != onboard.Running:
state = "done"
}
nav = append(nav, navItem{Label: step, State: state})
}
keys := "Ctrl+C stop"
if m.done {
keys = "Enter close"
}
host, _ := os.Hostname()
f := frame{
Width: m.width, Height: m.height, Title: "llmgw · add a node", Where: host,
NavTitle: "Steps", Nav: nav, PanelTitle: m.title, Body: "\n" + m.content(), Keys: keys,
}
switch {
case m.fabrics != nil:
var q strings.Builder
q.WriteString("These adapters are up. Which fabric is each group on?\n")
q.WriteString(sDim.Render("Give each the name it has on every other node attached to it.\nLeave a name empty to leave that group out.") + "\n\n")
for i, fb := range m.fabrics.suggested {
fmt.Fprintf(&q, " %s %s\n", m.inputs[i].View(), sDim.Render(strings.Join(fb.HCAs, ", ")))
}
q.WriteString("\n" + sDim.Render("Tab next · Enter use these · Esc none"))
f.DialogTitle, f.Dialog = "Fabrics", q.String()
case m.confirm != nil:
f.DialogTitle = "Problems found"
f.Dialog = fmt.Sprintf("doctor found %d problem(s) on the machine.\nAdd the node anyway?\n\n%s", m.confirm.fails, sDim.Render("Y yes · N no"))
}
return f.render()
}
// content is the onboarding's steps and notes, as the panel shows them.
func (m nodeModel) content() string {
var b strings.Builder
b.WriteString(sTitle.Render(m.title) + "\n\n")
detailWidth := max(20, m.width-22)
detailWidth := max(20, m.width-navWidth-26)
for _, step := range onboard.Steps {
st, ok := m.steps[step]
var mark string
@@ -238,26 +285,12 @@ func (m nodeModel) View() string {
}
switch {
case m.fabrics != nil:
var q strings.Builder
q.WriteString("These adapters are up. Which fabric is each group on?\n")
q.WriteString(sDim.Render("Give each the name it has on every other node attached to it.\nLeave a name empty to leave that group out.") + "\n\n")
for i, f := range m.fabrics.suggested {
fmt.Fprintf(&q, " %s %s\n", m.inputs[i].View(), sDim.Render(strings.Join(f.HCAs, ", ")))
}
q.WriteString("\n" + sDim.Render("tab next · enter use these · esc none"))
b.WriteString("\n" + sBox.Render(q.String()) + "\n")
case m.confirm != nil:
q := fmt.Sprintf("doctor found %d problem(s) above.\nAdd the node anyway? %s", m.confirm.fails, sDim.Render("y / N"))
b.WriteString("\n" + sBox.BorderForeground(tuiWarn).Render(q) + "\n")
case m.done && m.err != nil:
b.WriteString("\n" + sBad.Render("✗ ") + wrap(m.err.Error(), m.width-4, " ") + "\n")
case m.done:
b.WriteString("\n" + sOK.Render("✓ ") + m.summary + "\n")
case m.quitAsked:
b.WriteString("\n" + sDim.Render("stopped") + "\n")
default:
b.WriteString("\n" + sDim.Render("ctrl+c to stop") + "\n")
}
return b.String()
}
@@ -298,7 +331,7 @@ func (na *nodeAdd) runTUI(ctx context.Context, o onboard.Options, title string)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
m := newNodeModel(title, cancel)
p := tea.NewProgram(m)
p := tea.NewProgram(m, tea.WithAltScreen())
emit := func(e onboard.Event) { p.Send(evMsg(e)) }
o.AskFabrics = na.rememberFabrics(func(s []onboard.Fabric) []onboard.Fabric {
@@ -343,6 +376,8 @@ func (na *nodeAdd) runTUI(ctx context.Context, o onboard.Options, title string)
return "", err
}
fm := final.(nodeModel)
// The screen is gone; what it ended with is said again where it stays.
fmt.Println(fm.content())
if fm.quitAsked && !fm.done {
cancel()
return fm.node, fmt.Errorf("stopped; the node may be half set up, and running this again finishes it")
+949
View File
@@ -0,0 +1,949 @@
package cli
import (
"context"
"fmt"
"io"
"net/mail"
"os"
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
joinpkg "gitea.mynodes.xyz/ilker/llmgw/internal/join"
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
"gitea.mynodes.xyz/ilker/llmgw/internal/preflight"
)
// The setup screens: what a bare `llmgw` shows on a machine with nothing
// installed. One machine, or three for high availability — this one as the
// control plane and etcd's third vote, two more for the store under Patroni —
// with every machine checked before anything is installed on any of them.
//
// The other machines are reached with Go's own ssh rather than the system's,
// so a password can be typed here when there is no key; it is held in memory
// for the run and written nowhere. A sudo password, likewise, is used once to
// let the user sudo for the length of the install, and that is taken back
// when it finishes.
type setupPage int
const (
pWelcome setupPage = iota
pAdmin
pMachines
// pWait shows the codes for machines joining rather than logged into,
// until they have all dialled in.
pWait
pCheck
pPlan
pRun
)
// How a database machine is logged into.
const (
authAgent = iota
authKey
authPassword
// authJoin is no login at all: the machine runs `llmgw join` with a code
// shown here and dials out to this one.
authJoin
authCount
)
var authNames = [authCount]string{"ssh agent", "key file", "password", "join code"}
// Fields of one database machine, in order.
const (
mfAddr = iota
mfUser
mfAuth
mfSecret
mfSudo
mfCount
)
type dbMachine struct {
addr, user, secret, sudo textField
auth int
// For a machine joining: its code, and it once it has dialled in.
code string
joined *joinpkg.Remote
arrive <-chan *joinpkg.Remote
}
// visible reports whether a field is shown, and so can be moved to: a joining
// machine is asked nothing but how it joins.
func (d dbMachine) visible(f int) bool {
switch {
case d.auth == authJoin:
return f == mfAuth
case f == mfSecret:
return d.auth != authAgent
}
return true
}
// dbConn is a database machine as it is reached, whichever way.
type dbConn struct {
remote onboard.Remote
addr string
ssh *onboard.GoSSH
joined *joinpkg.Remote
}
func newDBMachine(n int) dbMachine {
f := func(ph string, mask bool) textField {
t := newTextField()
t.Placeholder, t.Mask, t.CharLimit, t.Width = ph, mask, 200, 34
return t
}
m := dbMachine{
addr: f(fmt.Sprintf("10.0.0.%d or db%d.internal", 10+n, n), false),
user: f("ubuntu", false),
secret: f("", true),
sudo: f("only if sudo asks for one", true),
}
if os.Getenv("SSH_AUTH_SOCK") == "" {
m.auth = authKey
}
return m
}
// field is the text field at a machine's position, nil for the selector.
func (d *dbMachine) field(i int) *textField {
switch i {
case mfAddr:
return &d.addr
case mfUser:
return &d.user
case mfSecret:
return &d.secret
case mfSudo:
return &d.sudo
}
return nil
}
// Messages from the work behind the screens.
type (
checkDoneMsg struct{ rep preflight.Report }
joinedMsg struct {
i int
r *joinpkg.Remote
}
)
// progRef lets work started from Update send to the program it runs in.
type progRef struct{ p *tea.Program }
type setupModel struct {
ref *progRef
page setupPage
host string
ips []string
choice int // 0 one machine, 1 high availability
admin, names textField
afocus int // admin, names, next
db [2]dbMachine
mfocus int // over both machines' fields, then the check button
conns []dbConn
listener *joinpkg.Listener
report preflight.Report
checking bool
in *serverInit
run initModel
finished bool
log *[]string
logMu *sync.Mutex
spin spinnerModel
formErr string
width int
height int
// scroll is how far the panel is scrolled, reset with each page.
scroll int
quit bool
// single is the choice of one machine: the screen closes and init's own
// form takes over.
single bool
}
func newSetupModel(host string, ips []string) setupModel {
admin := newTextField()
admin.Placeholder, admin.CharLimit, admin.Width = "you@example.com", 200, 40
names := newTextField()
names.Placeholder, names.CharLimit, names.Width = "llm.example.com, 203.0.113.10", 400, 40
return setupModel{
ref: &progRef{}, host: host, ips: ips, admin: admin, names: names,
db: [2]dbMachine{newDBMachine(1), newDBMachine(2)}, spin: newSpinner(), width: 100, height: 30,
log: &[]string{}, logMu: &sync.Mutex{},
}
}
func (m setupModel) Init() tea.Cmd { return m.spin.Tick }
func (m setupModel) thisAddr() string {
if len(m.ips) > 0 {
return m.ips[0]
}
return m.host
}
func (m setupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.run.width = msg.Width - navWidth - 6
case spinTickMsg:
var cmd tea.Cmd
m.spin, cmd = m.spin.Update(msg)
m.run.spin = m.spin
return m, cmd
case checkDoneMsg:
m.checking, m.report = false, msg.rep
case joinedMsg:
m.db[msg.i].joined = msg.r
if m.page == pWait && m.allJoined() {
m.page = pCheck
cmd := m.startCheck()
return m, cmd
}
case initLineMsg:
m.run.addLine(string(msg))
case initDoneMsg:
m.run.done, m.run.err, m.finished = true, msg.err, true
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
m.quit = true
return m, tea.Quit
}
before := m.page
next, cmd := m.key(msg)
if nm := next.(setupModel); nm.page != before {
// A new page starts at its top.
nm.scroll = 0
return nm, cmd
}
return next, cmd
}
return m, nil
}
func (m setupModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
// A dialog open takes the next key, whatever it is, to close it.
if m.formErr != "" {
m.formErr = ""
return m, nil
}
switch k.Type {
case tea.KeyPgDown:
m.scroll += max(1, m.height-8)
return m, nil
case tea.KeyPgUp:
m.scroll = max(0, m.scroll-max(1, m.height-8))
return m, nil
}
if k.Type == tea.KeyEsc && m.page > pWelcome && m.page < pRun && !m.checking {
m.page--
if m.page == pWait {
m.page = pMachines
}
m.formErr = ""
return m, nil
}
switch m.page {
case pWelcome:
switch k.String() {
case "up", "down", "tab", "k", "j":
m.choice = 1 - m.choice
case "enter":
if m.choice == 0 {
m.single = true
return m, tea.Quit
}
m.page = pAdmin
m.afocus = 0
m.admin.Focus()
}
case pAdmin:
return m.adminKey(k)
case pMachines:
return m.machinesKey(k)
case pCheck:
switch k.String() {
case "r":
if !m.checking {
cmd := m.startCheck()
return m, cmd
}
case "enter":
if !m.checking && m.report.Worst() < preflight.Fail {
m.page = pPlan
}
}
case pPlan:
if k.String() == "enter" {
m.page = pRun
cmd := m.startRun()
return m, cmd
}
case pRun:
if m.finished && (k.String() == "q" || k.String() == "enter") {
return m, tea.Quit
}
}
return m, nil
}
func (m setupModel) adminKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
move := func(d int) {
m.afocus = (m.afocus + d + 3) % 3
m.admin.Blur()
m.names.Blur()
switch m.afocus {
case 0:
m.admin.Focus()
case 1:
m.names.Focus()
}
}
switch k.Type {
case tea.KeyTab, tea.KeyDown:
move(1)
return m, nil
case tea.KeyShiftTab, tea.KeyUp:
move(-1)
return m, nil
case tea.KeyEnter:
if m.afocus < 2 {
move(1)
return m, nil
}
if _, err := mail.ParseAddress(strings.TrimSpace(m.admin.Value())); err != nil {
m.formErr = "the administrator's email is needed, and that is not one"
m.afocus = 0
move(0)
return m, nil
}
m.formErr = ""
m.page, m.mfocus = pMachines, 0
m.db[0].addr.Focus()
return m, nil
}
var cmd tea.Cmd
switch m.afocus {
case 0:
m.admin, cmd = m.admin.Update(k)
case 1:
m.names, cmd = m.names.Update(k)
}
return m, cmd
}
func (m *setupModel) focusMachine() {
for i := range m.db {
for f := 0; f < mfCount; f++ {
if t := m.db[i].field(f); t != nil {
t.Blur()
}
}
}
if m.mfocus < 2*mfCount {
if t := m.db[m.mfocus/mfCount].field(m.mfocus % mfCount); t != nil {
t.Focus()
}
}
}
func (m setupModel) machinesKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
n := 2*mfCount + 1
switch k.Type {
case tea.KeyTab, tea.KeyDown:
m.step(1, n)
return m, nil
case tea.KeyShiftTab, tea.KeyUp:
m.step(-1, n)
return m, nil
case tea.KeyEnter:
if m.mfocus < 2*mfCount {
m.step(1, n)
return m, nil
}
if err := m.validateMachines(); err != "" {
m.formErr = err
return m, nil
}
m.formErr = ""
if cmd, waiting := m.startJoins(); waiting {
m.page = pWait
return m, cmd
}
m.page = pCheck
cmd := m.startCheck()
return m, cmd
}
if m.mfocus == 2*mfCount {
return m, nil
}
d := &m.db[m.mfocus/mfCount]
if m.mfocus%mfCount == mfAuth {
switch k.String() {
case "left", "h":
d.auth = (d.auth + authCount - 1) % authCount
case "right", "l", " ":
d.auth = (d.auth + 1) % authCount
}
if d.auth != authJoin && d.joined != nil {
_ = d.joined.Close()
d.joined, d.code, d.arrive = nil, "", nil
}
d.secret.SetValue("")
d.secret.Mask = d.auth == authPassword
d.secret.Placeholder = map[int]string{authKey: "/root/.ssh/id_ed25519", authPassword: "the user's ssh password"}[d.auth]
return m, nil
}
var cmd tea.Cmd
t := d.field(m.mfocus % mfCount)
*t, cmd = t.Update(k)
return m, cmd
}
// step moves the focus over the fields that are shown.
func (m *setupModel) step(d, n int) {
for range n {
m.mfocus = (m.mfocus + d + n) % n
if m.mfocus == 2*mfCount || m.db[m.mfocus/mfCount].visible(m.mfocus%mfCount) {
break
}
}
m.focusMachine()
}
func (m setupModel) allJoined() bool {
for _, d := range m.db {
if d.auth == authJoin && d.joined == nil {
return false
}
}
return true
}
// startJoins listens for the machines that join with a code, makes their
// codes, and waits for them; it says whether there is anything to wait for.
func (m *setupModel) startJoins() (tea.Cmd, bool) {
var cmds []tea.Cmd
for i := range m.db {
d := &m.db[i]
if d.auth != authJoin || d.joined != nil {
continue
}
if m.listener == nil {
l, err := joinpkg.Listen(fmt.Sprintf(":%d", joinpkg.DefaultPort),
fmt.Sprintf("%s:%d", m.thisAddr(), joinpkg.DefaultPort), time.Hour)
if err != nil {
m.formErr = err.Error()
return nil, false
}
m.listener = l
}
if d.arrive == nil {
c, ch := m.listener.Code("database")
d.code, d.arrive = c.String(), ch
}
ch, i := d.arrive, i
cmds = append(cmds, func() tea.Msg { return joinedMsg{i, <-ch} })
}
return tea.Batch(cmds...), len(cmds) > 0
}
func (m setupModel) validateMachines() string {
seen := map[string]bool{m.thisAddr(): true, m.host: true}
for i, d := range m.db {
if d.auth == authJoin {
continue
}
addr := strings.TrimSpace(d.addr.Value())
switch {
case addr == "":
return fmt.Sprintf("database machine %d has no address", i+1)
case seen[addr]:
return fmt.Sprintf("%s is given twice, or is this machine: the three must be different machines", addr)
case strings.TrimSpace(d.user.Value()) == "":
return fmt.Sprintf("database machine %d has no user to log in as", i+1)
case d.auth != authAgent && d.secret.Value() == "":
return fmt.Sprintf("database machine %d needs its %s", i+1, authNames[d.auth])
}
seen[addr] = true
}
return ""
}
// startCheck connects to the database machines and runs the checks on all
// three, this one included.
func (m *setupModel) startCheck() tea.Cmd {
for _, c := range m.conns {
if c.ssh != nil {
_ = c.ssh.Close()
}
}
m.conns = nil
machines := []preflight.Machine{{
Name: m.host + " (this machine)", Addr: m.thisAddr(), Local: true, Remote: pgha.Local{},
Roles: []preflight.Role{preflight.RoleControl, preflight.RoleWitness},
}}
for _, d := range m.db {
if d.auth == authJoin {
m.conns = append(m.conns, dbConn{remote: d.joined, addr: d.joined.Addr, joined: d.joined})
machines = append(machines, preflight.Machine{Name: d.joined.Hostname + " (" + d.joined.Addr + ", joined)",
Addr: d.joined.Addr, Roles: []preflight.Role{preflight.RoleDatabase}, Remote: d.joined})
continue
}
g := &onboard.GoSSH{
Addr: strings.TrimSpace(d.addr.Value()), User: strings.TrimSpace(d.user.Value()),
UseAgent: d.auth == authAgent, SudoPassword: d.sudo.Value(),
}
switch d.auth {
case authKey:
g.KeyFile = strings.TrimSpace(d.secret.Value())
case authPassword:
g.Password = d.secret.Value()
}
m.conns = append(m.conns, dbConn{remote: g, addr: g.Addr, ssh: g})
machines = append(machines, preflight.Machine{Name: g.Addr, Addr: g.Addr,
Roles: []preflight.Role{preflight.RoleDatabase}, Remote: g})
}
m.checking = true
m.report = preflight.Report{}
conns := m.conns
return func() tea.Msg {
rep := preflight.Run(context.Background(), machines)
// A sudo that asks for a password is fine with one given: the
// install grants itself sudo with it.
for i := range rep.Findings {
f := &rep.Findings[i]
if f.Check == "sudo" && f.Level == preflight.Warn {
for _, c := range conns {
g := c.ssh
switch {
case g == nil || g.Addr != f.Machine:
case g.SudoPassword == "":
f.Level = preflight.Fail
f.Fix = "give this user's sudo password on the previous screen"
default:
f.Level = preflight.OK
f.Detail = "with the password given"
f.Fix = ""
}
}
}
}
return checkDoneMsg{rep}
}
}
// targets are the database machines as user@address, for the command line.
func (m setupModel) targets() []string {
var out []string
for _, d := range m.db {
if d.joined != nil {
out = append(out, "root@"+d.joined.Addr)
continue
}
out = append(out, strings.TrimSpace(d.user.Value())+"@"+strings.TrimSpace(d.addr.Value()))
}
return out
}
func (m setupModel) namesList() []string {
var out []string
for _, h := range strings.Split(m.names.Value(), ",") {
if h = strings.TrimSpace(h); h != "" {
out = append(out, h)
}
}
return out
}
// command is what this setup is on the command line, for doing it again
// from a script — with keys in place, since a password cannot be given there.
func (m setupModel) command() string {
parts := []string{"sudo --preserve-env=SSH_AUTH_SOCK llmgw server init",
"--admin " + strings.TrimSpace(m.admin.Value())}
for _, h := range m.namesList() {
parts = append(parts, "--host "+h)
}
for _, t := range m.targets() {
parts = append(parts, "--patroni "+t)
}
parts = append(parts, "--no-node", "--no-model")
return strings.Join(parts, " \\\n ")
}
// startRun installs: sudo granted where it asks for a password, the control
// plane here, the store on the other two, then sudo taken back.
func (m *setupModel) startRun() tea.Cmd {
in := newDefaultServerInit()
in.admin = strings.TrimSpace(m.admin.Value())
in.hosts = m.namesList()
in.patroni = m.targets()
// The control plane of a replicated setup is not where models run;
// nodes are added once it is up.
in.noNode, in.noModel = true, true
m.in = &in
m.run = newInitModel(&in, nil, false)
m.run.running, m.run.width, m.run.spin = true, m.width, m.spin
ref, conns, logp, mu, listener := m.ref, m.conns, m.log, m.logMu, m.listener
return func() tea.Msg {
send := func(l string) {
mu.Lock()
*logp = append(*logp, l)
mu.Unlock()
ref.p.Send(initLineMsg(l))
}
in.out = &lineWriter{send: send}
err := func() error {
ctx := context.Background()
for _, c := range conns {
if c.ssh != nil {
fmt.Fprintf(in.out, " .. sudo on %s\n", c.addr)
if err := c.ssh.PrepareSudo(ctx); err != nil {
return err
}
}
out, err := c.remote.Run(ctx, "hostname", nil)
if err != nil {
return fmt.Errorf("%s: %v", c.addr, err)
}
ip, err := ipv4Of(c.addr)
if err != nil {
return err
}
in.patroniNodes = append(in.patroniNodes, pgha.Node{
Member: pgha.Member{Name: strings.TrimSpace(string(out)), IP: ip}, Remote: c.remote})
}
return in.run(ctx)
}()
// Whatever happened: a sudo grant taken back, a joined machine's
// `llmgw join` told it is done, the port for joining closed.
for _, c := range conns {
if c.ssh != nil {
_ = c.ssh.Close()
}
if c.joined != nil {
_ = c.joined.Close()
}
}
if listener != nil {
_ = listener.Close()
}
return initDoneMsg{err}
}
}
// setupSteps are the sidebar's steps, by page.
var setupSteps = []struct {
label string
page setupPage
}{
{"Start", pWelcome}, {"Administrator", pAdmin}, {"Machines", pMachines},
{"Checks", pCheck}, {"Plan", pPlan}, {"Install", pRun},
}
func (m setupModel) View() string {
at := m.page
if at == pWait {
at = pMachines
}
var nav []navItem
for _, st := range setupSteps {
if m.choice == 0 && st.page > pWelcome {
break
}
state := "todo"
switch {
case st.page < at || (st.page == pRun && m.finished && m.run.err == nil):
state = "done"
case st.page == at:
state = "current"
}
nav = append(nav, navItem{Label: st.label, State: state})
}
title, body, keys := m.pageView()
f := frame{
Width: m.width, Height: m.height,
Title: "llmgw setup", Where: m.host + " · " + m.thisAddr(),
NavTitle: "Steps", Nav: nav,
PanelTitle: title, Body: body, Scroll: m.scroll, Keys: keys,
}
if m.formErr != "" {
f.DialogTitle, f.Dialog = "Not yet", m.formErr+"\n\n"+sDim.Render("any key to go back")
}
return f.render()
}
// pageView is the panel for the page: its title, what is in it, and its keys.
func (m setupModel) pageView() (string, string, string) {
switch m.page {
case pWelcome:
return "Start", m.welcomeView(), "↑↓ choose · Enter go on · Ctrl+C leave"
case pAdmin:
return "Administrator", m.adminView(), "Tab/↑↓ move · Enter next · Esc back · Ctrl+C leave"
case pMachines:
return "Database machines", m.machinesView(), "Tab/↑↓ move · ←→ how to log in · Enter next · Esc back · PgUp/PgDn scroll"
case pWait:
return "Waiting for machines to join", m.waitView(), "Esc back · PgUp/PgDn scroll · Ctrl+C leave"
case pCheck:
keys := "R check again · Esc back · PgUp/PgDn scroll"
if !m.checking && m.report.Worst() < preflight.Fail {
keys = "Enter see the plan · " + keys
}
return "Checks", m.checkView(), keys
case pPlan:
return "Plan", m.planView(), "Enter install · Esc back · PgUp/PgDn scroll · Ctrl+C leave"
}
v := m.run.View()
if i := strings.Index(v, "\n\n"); i >= 0 {
v = v[i+2:] // init's own title; the frame has one
}
keys := "Ctrl+C stop"
if m.finished {
keys = "Enter or Q leave · PgUp/PgDn scroll"
if m.run.err == nil {
v += "\n" + sDim.Render("The same, from a script, with ssh keys in place:") + "\n" + m.command() + "\n"
}
}
return "Install", v, keys
}
func (m setupModel) welcomeView() string {
var b strings.Builder
fmt.Fprintf(&b, "\n Nothing of llmgw is installed on %s.\n\n", lipgloss.NewStyle().Bold(true).Render(m.host))
opt := func(i int, title, detail string) {
mark := "( )"
if m.choice == i {
mark = "(" + sOK.Render("•") + ")"
title = fSelected.Render(title)
}
fmt.Fprintf(&b, " %s %s\n %s\n\n", mark, title, sDim.Render(detail))
}
opt(0, "One machine", "This machine runs everything: control plane, model mirror,\n and models if it has room.")
opt(1, "High availability — three machines",
"This one is the control plane; two more keep the store, replicated,\n so losing any one machine loses nothing.")
return b.String()
}
func (m setupModel) adminView() string {
var b strings.Builder
label := func(i int, s string) string {
if m.afocus == i {
return fSelected.Render("› " + s)
}
return " " + s
}
b.WriteString("\n")
fmt.Fprintf(&b, "%s\n %s\n %s\n\n", label(0, "Administrator's email"), m.admin.View(),
sDim.Render("A password is generated and shown once, at the end."))
fmt.Fprintf(&b, "%s\n %s\n %s\n\n", label(1, "Other names nodes will reach this machine by"), m.names.View(),
sDim.Render("Known: "+strings.Join(append([]string{m.host}, m.ips...), ", ")+". Add a DNS name or a floating address."))
b.WriteString(button(m.afocus == 2, "Next") + "\n")
return b.String()
}
// button is a [ Label ] that shows when it has the focus.
func button(focused bool, label string) string {
if focused {
return " " + fBar.Render(" "+label+" ")
}
return " " + lipgloss.NewStyle().Bold(true).Render("[ "+label+" ]")
}
func (m setupModel) machinesView() string {
var b strings.Builder
b.WriteString("\n The two machines that keep the store: Ubuntu 22.04/24.04 or Debian 12.\n" +
" PostgreSQL, Patroni and etcd are installed on them.\n\n")
for i, d := range m.db {
base := i * mfCount
// Padded before it is coloured: the colour's escape codes count
// toward a width and threw the focused row out of line.
label := func(f int, s string) string {
s = fmt.Sprintf("%-15s", s)
if m.mfocus == base+f {
return fSelected.Render("› " + s)
}
return " " + s
}
fmt.Fprintf(&b, " %s\n", lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf("Database machine %d", i+1)))
if d.visible(mfAddr) {
fmt.Fprintf(&b, " %s %s\n", label(mfAddr, "Address"), d.addr.View())
fmt.Fprintf(&b, " %s %s\n", label(mfUser, "User"), d.user.View())
}
var auth []string
for a := 0; a < authCount; a++ {
if a == d.auth {
auth = append(auth, "("+sOK.Render("•")+") "+authNames[a])
} else {
auth = append(auth, "( ) "+sDim.Render(authNames[a]))
}
}
fmt.Fprintf(&b, " %s %s\n", label(mfAuth, "Log in with"), strings.Join(auth, " "))
if d.visible(mfSecret) {
fmt.Fprintf(&b, " %s %s\n", label(mfSecret, strings.ToUpper(authNames[d.auth][:1])+authNames[d.auth][1:]), d.secret.View())
}
if d.visible(mfSudo) {
fmt.Fprintf(&b, " %s %s\n", label(mfSudo, "Sudo password"), d.sudo.View())
}
if d.auth == authJoin {
b.WriteString(" " + sDim.Render("Nothing logs into it: it runs llmgw join with a code shown next.") + "\n")
}
b.WriteString("\n")
}
b.WriteString(button(m.mfocus == 2*mfCount, "Check the three machines") + "\n\n")
b.WriteString(" " + sDim.Render("Passwords are kept in memory only, for this setup.") + "\n")
return b.String()
}
func (m setupModel) waitView() string {
var b strings.Builder
fmt.Fprintf(&b, "\n On each machine below, as root. It dials this machine on port %d;\n nothing dials in. Each code is for one machine and good for an hour.\n\n", joinpkg.DefaultPort)
for i, d := range m.db {
if d.auth != authJoin {
continue
}
title := lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf("Database machine %d", i+1))
if d.joined != nil {
fmt.Fprintf(&b, " %s %s joined as %s (%s)\n\n", title, sOK.Render("✓"), d.joined.Hostname, d.joined.Addr)
continue
}
fmt.Fprintf(&b, " %s %s waiting\n", title, m.spin.View())
b.WriteString(" " + sDim.Render("without llmgw yet:") + "\n")
for _, l := range joinInstall() {
b.WriteString(" " + strings.ReplaceAll(l, "\n", "\n ") + "\n")
}
b.WriteString(" " + sDim.Render("then:") + "\n sudo llmgw join " + d.code + "\n\n")
}
return b.String()
}
func (m setupModel) checkView() string {
var b strings.Builder
b.WriteString("\n")
if m.checking {
b.WriteString(" " + m.spin.View() + " checking the three machines…\n")
return b.String()
}
switch m.report.Worst() {
case preflight.Fail:
b.WriteString(" " + sBad.Render("✗ Fix what is marked ✗, then check again (R).") + "\n")
case preflight.Warn:
b.WriteString(" " + sWarn.Render("! It can go ahead; what is marked ! is worth a look.") + "\n")
default:
b.WriteString(" " + sOK.Render("✓ All three are ready.") + "\n")
}
machine := ""
for _, f := range m.report.Findings {
if f.Machine != machine {
machine = f.Machine
b.WriteString("\n " + lipgloss.NewStyle().Bold(true).Render(machine) + "\n")
}
mark := sOK.Render("✓")
switch f.Level {
case preflight.Warn:
mark = sWarn.Render("!")
case preflight.Fail:
mark = sBad.Render("✗")
}
fmt.Fprintf(&b, " %s %-13s %s\n", mark, f.Check, f.Detail)
if f.Level != preflight.OK && f.Fix != "" {
b.WriteString(" " + sDim.Render(f.Fix) + "\n")
}
}
return b.String()
}
func (m setupModel) planView() string {
var b strings.Builder
b.WriteString("\n What will be installed:\n\n")
fmt.Fprintf(&b, " %s %s\n %s\n", lipgloss.NewStyle().Bold(true).Render(m.host), sDim.Render(m.thisAddr()),
"control plane and gateway, model mirror, etcd (the third vote)")
fmt.Fprintf(&b, " administrator %s\n", strings.TrimSpace(m.admin.Value()))
granted := false
for _, d := range m.db {
addr := strings.TrimSpace(d.addr.Value())
if d.joined != nil {
addr = d.joined.Hostname + " " + sDim.Render(d.joined.Addr+", joined")
}
fmt.Fprintf(&b, " %s\n PostgreSQL under Patroni, etcd\n", lipgloss.NewStyle().Bold(true).Render(addr))
granted = granted || d.sudo.Value() != ""
}
b.WriteString("\n The store is replicated from the first to the second; if the primary is lost\n" +
" the standby takes over in about thirty seconds, and the control plane follows.\n")
if granted {
b.WriteString(" " + sDim.Render("Where a sudo password was given, the user may sudo without it until the\n install finishes, and not after.") + "\n")
}
b.WriteString("\n " + sDim.Render("The same from a script, with ssh keys in place:") + "\n")
for _, l := range strings.Split(m.command(), "\n") {
b.WriteString(" " + l + "\n")
}
for _, d := range m.db {
if d.joined != nil {
b.WriteString(" " + sDim.Render("(a machine that joined with a code needs ssh for that)") + "\n")
break
}
}
b.WriteString("\n" + button(true, "Enter to install") + "\n")
return b.String()
}
// runSetupTUI is the setup screens, until they finish or hand over to init's
// own form for one machine.
func runSetupTUI(ctx context.Context, out io.Writer) error {
host, _ := os.Hostname()
var ips []string
for _, ip := range localAddrs() {
if ip.To4() != nil {
ips = append(ips, ip.String())
}
}
m := newSetupModel(host, ips)
p := tea.NewProgram(m, tea.WithOutput(out), tea.WithAltScreen())
m.ref.p = p
final, err := p.Run()
if err != nil {
return err
}
fm := final.(setupModel)
if fm.single {
in := newDefaultServerInit()
in.out = out
return in.runInitTUI(ctx, out)
}
if fm.page < pRun {
return nil
}
fmt.Fprintln(out)
if fm.run.err != nil {
fm.logMu.Lock()
fmt.Fprintln(out, strings.Join(*fm.log, "\n"))
fm.logMu.Unlock()
return fm.run.err
}
if !fm.finished {
return fmt.Errorf("left while installing; running llmgw again finishes what was started")
}
if fm.run.password != "" {
fmt.Fprintf(out, "Administrator %s, password %s (shown once)\n\n", fm.in.admin, fm.run.password)
}
fmt.Fprintln(out, "Next: add nodes from the console — run llmgw again — or with")
fmt.Fprintln(out, " sudo llmgw node add <user>@<address>")
return nil
}
+173
View File
@@ -0,0 +1,173 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/preflight"
)
func press(m tea.Model, keys ...string) tea.Model {
for _, k := range keys {
var msg tea.KeyMsg
switch k {
case "enter":
msg = tea.KeyMsg{Type: tea.KeyEnter}
case "tab":
msg = tea.KeyMsg{Type: tea.KeyTab}
case "down":
msg = tea.KeyMsg{Type: tea.KeyDown}
case "esc":
msg = tea.KeyMsg{Type: tea.KeyEsc}
case "right":
msg = tea.KeyMsg{Type: tea.KeyRight}
default:
msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)}
}
m, _ = m.Update(msg)
}
return m
}
func TestTheSetupAsksForWhatHighAvailabilityNeedsAndRefusesWhatWillNotDo(t *testing.T) {
m := tea.Model(newSetupModel("ctl", []string{"10.0.0.5"}))
m = press(m, "down", "enter")
if s := m.(setupModel); s.page != pAdmin {
t.Fatalf("high availability did not lead to the administrator: page %d", s.page)
}
// No email: refused, with a reason.
m = press(m, "tab", "tab", "enter")
if s := m.(setupModel); s.page != pAdmin || !strings.Contains(s.formErr, "email") {
t.Fatalf("went on without an administrator: %+v", s.formErr)
}
// The refusal is a dialog; a key closes it, and the cursor is back on
// the email.
if !strings.Contains(m.View(), "Not yet") {
t.Error("the refusal was not shown as a dialog")
}
m = press(m, "x", "a@example.com", "tab", "tab", "enter")
s := m.(setupModel)
if s.page != pMachines {
t.Fatalf("page %d: %s", s.page, s.formErr)
}
// This machine's own address is not a database machine.
s.db[0].addr.SetValue("10.0.0.5")
s.db[0].user.SetValue("ubuntu")
s.db[0].auth = authAgent
s.db[1].addr.SetValue("10.0.0.7")
s.db[1].user.SetValue("ubuntu")
s.db[1].auth = authPassword
if err := s.validateMachines(); !strings.Contains(err, "this machine") {
t.Errorf("this machine as a database machine: %q", err)
}
s.db[0].addr.SetValue("10.0.0.6")
if err := s.validateMachines(); !strings.Contains(err, "password") {
t.Errorf("a password login with no password: %q", err)
}
s.db[1].secret.SetValue("pw")
if err := s.validateMachines(); err != "" {
t.Errorf("a valid pair refused: %s", err)
}
cmd := s.command()
for _, want := range []string{"--admin a@example.com", "--patroni ubuntu@10.0.0.6", "--patroni ubuntu@10.0.0.7", "--no-node"} {
if !strings.Contains(cmd, want) {
t.Errorf("command lacks %q:\n%s", want, cmd)
}
}
if strings.Contains(cmd, "pw") {
t.Error("the password is in the command line")
}
}
func TestChoosingOneMachineHandsOverToInit(t *testing.T) {
m := press(tea.Model(newSetupModel("ctl", nil)), "enter").(setupModel)
if !m.single {
t.Error("one machine did not hand over")
}
}
func TestTheChecksHoldTheSetupBackOnlyForAFailure(t *testing.T) {
m := newSetupModel("ctl", nil)
m.choice, m.page = 1, pCheck
m.report = preflight.Report{Findings: []preflight.Finding{{Machine: "a", Check: "disk", Level: preflight.Fail, Fix: "more"}}}
if s := press(m, "enter").(setupModel); s.page != pCheck {
t.Error("went on past a failed check")
}
m.report.Findings[0].Level = preflight.Warn
if s := press(m, "enter").(setupModel); s.page != pPlan {
t.Error("a warning held it back")
}
}
func TestTheConsoleOffersToReplicateOnlyAStoreThatIsNot(t *testing.T) {
m := consoleModel{}
if !m.entryOK(cReplicate) {
t.Error("a single store is not offered replication")
}
m.data.sum.Store = []adminapi.StoreMember{{Name: "a", Role: "leader"}}
if m.entryOK(cReplicate) {
t.Error("a replicated store is offered replication")
}
// A node is asked for in a dialog, then the console closes with it to do.
var mm tea.Model = consoleModel{menu: cAddNode, loaded: true, width: 100, height: 30}
mm = press(mm, "enter", "ubuntu@10.0.0.9", "enter")
if a := mm.(consoleModel).action; a.kind != "node" || a.targets[0] != "ubuntu@10.0.0.9" {
t.Errorf("%+v", a)
}
}
func TestTheConsoleWatchesTheClusterAndEditsItsSettings(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "server.yaml")
if err := os.WriteFile(file, []byte("# mine\nlisten: \":8080\"\n"), 0o644); err != nil {
t.Fatal(err)
}
m := newConsoleModel("127.0.0.1:1", file)
m.width, m.height, m.loaded = 120, 40, true
m.data.nodes = []adminapi.NodeView{{ID: "cuda01", State: "ready", GPUTotal: 1, GPUs: []adminapi.GPUView{{Index: 0, Name: "Quadro RTX 6000"}}}}
m.data.replicas = []adminapi.ReplicaView{{ID: "r1", Model: "coder-14b", State: "ready",
Shards: []adminapi.ShardView{{Node: "cuda01", GPUs: []int{0}}}}}
// Watching: the GPUs view says which model holds each card.
m.menu = cGPUs
if v := m.View(); !strings.Contains(v, "Quadro RTX 6000") || !strings.Contains(v, "coder-14b") {
t.Errorf("the GPUs view:\n%s", v)
}
// Settings: into router, change retries, save.
var mm tea.Model = m
mm.(consoleModel).View()
cm := mm.(consoleModel)
cm.menu, cm.inPanel = cSettings, true
for i, r := range cm.settingsRows() {
if r.label == "router" {
cm.row = i
}
}
mm = press(cm, "enter") // into router
cm = mm.(consoleModel)
for i, r := range cm.settingsRows() {
if r.label == "retries" {
cm.row = i
}
}
if v := cm.View(); !strings.Contains(v, "(default)") {
t.Errorf("an unset key does not show its default:\n%s", v)
}
mm = press(cm, "enter") // the edit dialog
cm = mm.(consoleModel)
cm.prompt.SetValue("5")
mm = press(cm, "enter", "s")
if d := mm.(consoleModel).dialog; d != "saved" {
t.Fatalf("after saving the dialog is %q: %s", d, mm.(consoleModel).dialogText)
}
b, _ := os.ReadFile(file)
if !strings.Contains(string(b), "retries: 5") || !strings.Contains(string(b), "# mine") {
t.Errorf("server.yaml:\n%s", b)
}
}
+5
View File
@@ -47,6 +47,8 @@ type textField struct {
Placeholder string
CharLimit int
Width int
// Mask shows a dot for every character: a password.
Mask bool
}
func newTextField() textField { return textField{Prompt: "> "} }
@@ -84,6 +86,9 @@ func (f textField) Update(msg tea.Msg) (textField, tea.Cmd) {
func (f textField) View() string {
text := string(f.value)
if f.Mask {
text = strings.Repeat("•", len(f.value))
}
shown := text
if text == "" && f.Placeholder != "" {
shown = sDim.Render(f.Placeholder)
+122 -1
View File
@@ -8,6 +8,7 @@ package config
import (
"fmt"
"os"
"slices"
"strings"
"time"
@@ -67,6 +68,10 @@ type Server struct {
// Monitoring is where the dashboard reads its history from.
Monitoring Monitoring `koanf:"monitoring"`
// Tracing sends a trace of each inference request to an OpenTelemetry
// collector. Off unless an endpoint is set here or in
// OTEL_EXPORTER_OTLP_ENDPOINT.
Tracing Tracing `koanf:"tracing"`
// Mirror points the control plane at a model mirror it can administer.
// Empty means there is none, and /admin/mirror stays unmounted.
@@ -81,11 +86,23 @@ type Server struct {
// one leaves that engine to be installed by hand.
Engines map[string]string `koanf:"engines"`
// EngineOverrides give some nodes different engines from the rest: a
// vLLM built for CUDA 12 on the Volta cards CUDA 13 dropped, or none at
// all on nodes meant only for llama.cpp. Each picks nodes and lays its
// engines over the cluster's, in order, later ones last; an empty version
// removes that engine from the nodes it picks.
EngineOverrides []EngineOverride `koanf:"engine_overrides"`
// BackupDir is where `llmgw server backup` writes, and where the newest
// archive's age is read for llmgw_backup_last_success_timestamp_seconds.
// Empty means /var/backups/llmgw-server.
BackupDir string `koanf:"backup_dir"`
// Releases is where `llmgw ctl upgrade` fetches release binaries from,
// for this machine and — through their agents — every node. Empty means
// the project's own releases.
Releases string `koanf:"releases"`
Router Router `koanf:"router"`
Control Control `koanf:"control"`
Models []catalog.Model `koanf:"models"`
@@ -115,6 +132,21 @@ type Monitoring struct {
Timeout time.Duration `koanf:"timeout"`
}
// Tracing is where request traces go, over OTLP/HTTP.
//
// One span per inference request, with admission, scheduling and each attempt
// at an engine beneath it, and the trace carried on to the engine in a
// traceparent header. The request's X-Request-Id is on the span, so a trace
// and `llmgw ctl request` name the same request.
type Tracing struct {
// Endpoint is the collector, for example http://otel.internal:4318.
// Empty falls back to OTEL_EXPORTER_OTLP_ENDPOINT, and to no tracing.
Endpoint string `koanf:"endpoint"`
// SampleRatio is the share of requests traced, 0 to 1, when the caller
// did not decide it with a sampled traceparent of its own. Zero takes 1.
SampleRatio float64 `koanf:"sample_ratio"`
}
// Mirror is a model mirror this control plane administers over its API.
//
// The mirror is a separate machine with a large disk, not part of the gateway,
@@ -225,6 +257,14 @@ type Auth struct {
// blocking a request: losing accounting is bad, turning a stalled database
// into an outage is worse.
BufferSize int `koanf:"buffer_size"`
// UsageRetention is how long the record of each request is kept: who sent
// it, which replica answered, how long it queued — what `ctl request`
// reads. Zero keeps it for ever. Totals, charts and budgets are read from
// five-minute sums kept beside the records, which are not pruned, so this
// bounds the table that grows by a row a request without changing any
// figure a page shows. At least a day when set.
UsageRetention time.Duration `koanf:"usage_retention"`
}
// Personal configures use that belongs to a person rather than a project.
@@ -331,6 +371,13 @@ type Router struct {
// accounting works regardless of client behaviour.
InjectUsage bool `koanf:"inject_usage"`
// TenantLabel is the level of the tree the per-tenant request series are
// labelled by — llmgw_tenant_requests_total and its siblings: "org",
// "team" or "project", or "none" for no such series. A label per project
// on a cluster of thousands is more series than Prometheus wants, and a
// team is what an alert is usually about; "team" is the default.
TenantLabel string `koanf:"tenant_label"`
// MaxBodyBytes bounds the request body the router will read.
MaxBodyBytes int64 `koanf:"max_body_bytes"`
@@ -354,7 +401,12 @@ type Admission struct {
QueueTTL time.Duration `koanf:"queue_ttl"`
QueueCapacityPerBand int `koanf:"queue_capacity_per_band"`
Interval time.Duration `koanf:"interval"`
// QueueCapacityTotal bounds everything queued across bands. Reached, a
// request is not refused while a lower band has any queued: the newest of
// the lowest band is turned away instead, with a 503 and Retry-After.
// Zero takes half of all the bands' capacities together.
QueueCapacityTotal int `koanf:"queue_capacity_total"`
Interval time.Duration `koanf:"interval"`
}
// Prefix configures approximate prefix routing.
@@ -446,6 +498,7 @@ func Default() Server {
Cooldown: 15 * time.Second,
FailureThreshold: 3,
DefaultProfile: "chat",
TenantLabel: "team",
SessionHeader: "x-session-id",
InjectUsage: true,
MaxBodyBytes: 32 << 20,
@@ -519,6 +572,14 @@ func Load(path string) (Server, error) {
// Validate rejects configurations that would otherwise fail confusingly at
// request time.
func (s Server) Validate() error {
for i, o := range s.EngineOverrides {
if o.GPUClass == "" && len(o.Nodes) == 0 && len(o.Labels) == 0 {
return fmt.Errorf("engine_overrides[%d] picks no nodes: give it a gpu_class, nodes or labels", i)
}
if len(o.Engines) == 0 {
return fmt.Errorf("engine_overrides[%d] changes no engine: give it engines, with \"\" for none", i)
}
}
if s.Listen == "" {
return fmt.Errorf("listen is empty")
}
@@ -528,6 +589,14 @@ func (s Server) Validate() error {
if s.Auth.Enabled && s.Auth.Store == "" {
return fmt.Errorf("auth.store is required when auth is enabled")
}
switch s.Router.TenantLabel {
case "", "none", "org", "team", "project":
default:
return fmt.Errorf("router.tenant_label is %q: org, team, project or none", s.Router.TenantLabel)
}
if s.Auth.UsageRetention != 0 && s.Auth.UsageRetention < 24*time.Hour {
return fmt.Errorf("auth.usage_retention is %s: at least 24h, or 0 to keep every request", s.Auth.UsageRetention)
}
if s.Threads.Store != "" && s.Threads.RetainHours <= 0 {
return fmt.Errorf("threads.retain_hours must be positive when threads.store is set")
}
@@ -597,6 +666,58 @@ var DefaultEngines = map[string]string{
"vllm": "0.30.0",
}
// EngineOverride gives the nodes it picks different engines. Every selector
// set must match; at least one must be set.
type EngineOverride struct {
// GPUClass picks nodes with a GPU of this class ("v100-32g").
GPUClass string `koanf:"gpu_class"`
// Nodes picks nodes by id.
Nodes []string `koanf:"nodes"`
// Labels picks nodes carrying all of these.
Labels map[string]string `koanf:"labels"`
// Engines are laid over the cluster's: a version, or "" for none.
Engines map[string]string `koanf:"engines"`
}
// Picks reports whether the override applies to a node with this id, these
// GPU classes and these labels.
func (o EngineOverride) Picks(node string, classes []string, labels map[string]string) bool {
if o.GPUClass == "" && len(o.Nodes) == 0 && len(o.Labels) == 0 {
return false
}
if o.GPUClass != "" && !slices.Contains(classes, o.GPUClass) {
return false
}
if len(o.Nodes) > 0 && !slices.Contains(o.Nodes, node) {
return false
}
for k, v := range o.Labels {
if labels[k] != v {
return false
}
}
return true
}
// EnginesFor is what one node installs: WantedEngines with every override
// that picks it laid over, in order.
func (s Server) EnginesFor(node string, classes []string, labels map[string]string) map[string]string {
out := s.WantedEngines()
for _, o := range s.EngineOverrides {
if !o.Picks(node, classes, labels) {
continue
}
for k, v := range o.Engines {
if v == "" {
delete(out, k)
continue
}
out[k] = v
}
}
return out
}
// WantedEngines is DefaultEngines with the configuration laid over it.
func (s Server) WantedEngines() map[string]string {
out := map[string]string{}
+14
View File
@@ -206,3 +206,17 @@ func TestEnginesDefaultToTheCheckedVersions(t *testing.T) {
t.Error("an emptied engine is still wanted")
}
}
func TestAUsageRetentionUnderADayIsRefused(t *testing.T) {
// Shorter than a day is almost certainly a unit slip — 90m for 90 days —
// and would delete every request record within the hour.
s := Default()
s.Auth.UsageRetention = 90 * time.Minute
if err := s.Validate(); err == nil || !strings.Contains(err.Error(), "usage_retention") {
t.Errorf("a 90-minute retention was accepted: %v", err)
}
s.Auth.UsageRetention = 90 * 24 * time.Hour
if err := s.Validate(); err != nil {
t.Errorf("a 90-day retention was refused: %v", err)
}
}
+38
View File
@@ -0,0 +1,38 @@
package config
import (
"strings"
"testing"
)
func TestEachNodeGetsTheEnginesItsOverridesSay(t *testing.T) {
s := Server{
Engines: map[string]string{"vllm": "0.30.0"},
EngineOverrides: []EngineOverride{
// Volta: CUDA 13 dropped it, CUDA 12 vLLM still runs.
{GPUClass: "v100-32g", Engines: map[string]string{"vllm": "0.25.1"}},
// Nodes meant for llama.cpp alone.
{Labels: map[string]string{"role": "gguf"}, Engines: map[string]string{"vllm": ""}},
},
}
if got := s.EnginesFor("h1", []string{"h100-80g"}, nil); got["vllm"] != "0.30.0" || got["llamacpp"] == "" {
t.Errorf("an H100 node: %v", got)
}
if got := s.EnginesFor("v1", []string{"v100-32g"}, nil); got["vllm"] != "0.25.1" {
t.Errorf("a V100 node: %v", got)
}
got := s.EnginesFor("v2", []string{"v100-32g"}, map[string]string{"role": "gguf"})
if _, has := got["vllm"]; has || got["llamacpp"] == "" {
t.Errorf("a V100 node for llama.cpp alone, the later override last: %v", got)
}
for _, bad := range []EngineOverride{
{Engines: map[string]string{"vllm": ""}},
{GPUClass: "v100-32g"},
} {
s := Server{Listen: ":8080", EngineOverrides: []EngineOverride{bad}}
if err := s.Validate(); err == nil || !strings.Contains(err.Error(), "engine_overrides[0]") {
t.Errorf("%+v: %v", bad, err)
}
}
}
+45
View File
@@ -0,0 +1,45 @@
package adminapi
import (
"net/http/httptest"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
)
func TestAClaimedActorIsOneShortName(t *testing.T) {
for claimed, want := range map[string]string{
"ilker": "ilker",
"": "unknown",
"ilker\nlevel=ERROR msg=x": "ilkerlevelERRORmsgx",
strings.Repeat("a", 500): strings.Repeat("a", 64),
"ops@example.org": "ops@example.org",
"\x1b[31m": "31m",
} {
r := httptest.NewRequest("POST", "/", nil)
r.Header.Set("X-Llmgw-Actor", claimed)
if got := actorOf(r); got != want {
t.Errorf("%q: %q, expected %q", claimed, got, want)
}
}
}
func TestAReservationThatPlacementCannotTrustIsSaid(t *testing.T) {
cpu := func(mem int) catalog.Model {
return catalog.Model{Name: "m", Profiles: []catalog.Profile{{Name: "cpu", Engine: "llamacpp", MinMemoryGB: mem}}}
}
if w := reservationWarnings(cpu(0), 0); len(w) != 1 || !strings.Contains(w[0], "declares no memory") {
t.Errorf("no memory declared: %v", w)
}
if w := reservationWarnings(cpu(6), 8.2); len(w) != 1 || !strings.Contains(w[0], "uses 8.2 GB and reserves 6") {
t.Errorf("under-declared: %v", w)
}
if w := reservationWarnings(cpu(6), 6.4); len(w) != 0 {
t.Errorf("within a tenth is mapped weights, not a wrong figure: %v", w)
}
gpu := catalog.Model{Name: "g", Profiles: []catalog.Profile{{Name: "tp1", Engine: "vllm", GPUClass: "h100-80g"}}}
if w := reservationWarnings(gpu, 3); len(w) != 0 {
t.Errorf("a GPU model needs no host memory figure: %v", w)
}
}
@@ -0,0 +1,71 @@
package adminapi
import (
"bytes"
"encoding/binary"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
)
// ggufStart is the start of a GGUF file with these string keys.
func ggufStart(kv ...string) []byte {
var b bytes.Buffer
w := func(v any) { _ = binary.Write(&b, binary.LittleEndian, v) }
str := func(s string) { w(uint64(len(s))); b.WriteString(s) }
b.WriteString("GGUF")
w(uint32(3))
w(uint64(0))
w(uint64(len(kv) / 2))
for i := 0; i < len(kv); i += 2 {
str(kv[i])
w(uint32(8)) // string
str(kv[i+1])
}
b.Write(make([]byte, 4096))
return b.Bytes()
}
func TestOnlyAnAdapterForTheModelsArchitectureIsAttached(t *testing.T) {
files := map[string][]byte{
"/base.gguf": ggufStart("general.architecture", "qwen2"),
"/model.gguf": ggufStart("general.architecture", "qwen2", "general.type", "model"),
"/llama.gguf": ggufStart("general.architecture", "llama", "general.type", "adapter"),
"/lora.gguf": ggufStart("general.architecture", "qwen2", "general.type", "adapter"),
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write(files[r.URL.Path])
}))
defer srv.Close()
models := []catalog.Model{{
Name: "coder", Source: srv.URL + "/base.gguf",
Scaling: catalog.Scaling{Min: 1, Max: 1},
Profiles: []catalog.Profile{{Name: "cpu", Engine: "llamacpp", MinMemoryGB: 8}},
}}
for _, tc := range []struct {
file, want string
code int
}{
{"/model.gguf", "not a LoRA adapter", http.StatusBadRequest},
{"/llama.gguf", "for llama models", http.StatusBadRequest},
{"/lora.gguf", "", http.StatusOK},
} {
a, _ := setup(t, models)
fm := &fakeModels{}
a.SetModels(fm)
mux := http.NewServeMux()
a.Register(mux)
req := httptest.NewRequest(http.MethodPost, "/api/v1/models/coder/adapters",
strings.NewReader(`{"adapters":[{"name":"fix","source":"`+srv.URL+tc.file+`"}]}`))
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != tc.code || !strings.Contains(rec.Body.String(), tc.want) {
t.Errorf("%s: %d %s; want %d mentioning %q", tc.file, rec.Code, rec.Body.String(), tc.code, tc.want)
}
}
}
+441 -12
View File
@@ -9,12 +9,20 @@ package adminapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"unicode"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/bench"
@@ -30,9 +38,17 @@ import (
// API serves the management endpoints.
type API struct {
// releases is where release binaries are fetched from, for upgrades.
releases string
// storeStatus reads a replicated store's members; nil for one database.
storeStatus func() ([]StoreMember, error)
// serve runs registry-to-replica jobs; nil without a mirror and a store.
serve *modelserve.Server
models Models
capacity Capacity
usage Usage
quotas Quotas
ctl *control.Controller
}
@@ -48,6 +64,29 @@ func (a *API) Register(mux *http.ServeMux) {
mux.HandleFunc("POST /api/v1/models", a.addModel)
mux.HandleFunc("DELETE /api/v1/models/{name}", a.removeModel)
mux.HandleFunc("POST /api/v1/models/{name}/scale", a.scaleModel)
mux.HandleFunc("POST /api/v1/models/{name}/resources", a.setResources)
a.registerQuotas(mux)
mux.HandleFunc("POST /api/v1/models/{name}/adapters", a.setAdapters)
mux.HandleFunc("GET /api/v1/models/{name}/target", a.target)
mux.HandleFunc("POST /api/v1/models/{name}/class", func(w http.ResponseWriter, r *http.Request) {
if a.models == nil {
writeError(w, http.StatusNotImplemented, "this control plane has no store")
return
}
var req struct {
Class string `json:"class"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error())
return
}
m, err := a.models.SetClass(r.Context(), r.PathValue("name"), req.Class, actorOf(r))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"model": m.Name, "class": m.EffectiveClass()})
})
mux.HandleFunc("PUT /api/v1/models/{name}/capacity", a.putCapacity)
mux.HandleFunc("GET /api/v1/capacity", a.listCapacity)
mux.HandleFunc("GET /api/v1/usage", a.usageByScope)
@@ -74,12 +113,25 @@ func (a *API) Register(mux *http.ServeMux) {
mux.HandleFunc("POST /api/v1/weights/collect", a.collectWeights)
mux.HandleFunc("GET /api/v1/explain-placement", a.explainPlacement)
mux.HandleFunc("POST /api/v1/nodes/{id}/state", a.setNodeState)
mux.HandleFunc("POST /api/v1/nodes/{id}/upgrade", a.upgradeNode)
mux.HandleFunc("POST /api/v1/nodes/{id}/engines", a.setNodeEngine)
mux.HandleFunc("DELETE /api/v1/nodes/{id}", a.removeNode)
mux.HandleFunc("POST /api/v1/fabric-test", a.fabricTest)
mux.HandleFunc("GET /api/v1/nodes/{id}/logs", a.nodeLogs)
mux.HandleFunc("POST /api/v1/restart", a.restart)
mux.HandleFunc("POST /api/v1/models/serve", a.serveModel)
mux.HandleFunc("GET /api/v1/jobs/{id}", a.job)
mux.HandleFunc("GET /api/v1/requests/{id}", a.request)
mux.HandleFunc("POST /api/v1/models/{name}/retry", a.retryModel)
mux.HandleFunc("GET /api/v1/models/{name}/headroom", func(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
v, ok := a.Headroom(r.PathValue("name"), limit)
if !ok {
http.Error(w, "no such model", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, v)
})
}
// --- responses ---
@@ -98,6 +150,8 @@ type NodeView struct {
// what decides whether a CPU profile can be placed here, so it belongs
// where someone looks when a model will not schedule.
MemoryGB int `json:"memory_gb,omitempty"`
// CPUCount is the machine's CPUs, 0 when the agent did not report them.
CPUCount int `json:"cpu_count,omitempty"`
// Engines are the inference engines installed here, as the agent found
// them. Absent means the agent is older than the probe and said nothing,
// which is not the same as having none — placement treats it that way too,
@@ -106,6 +160,10 @@ type NodeView struct {
// Provision is where installing each wanted engine stands. An engine is in
// Engines only once its installation here is ready.
Provision []inventory.EngineInstall `json:"provision,omitempty"`
// EngineBuilds are the builds chosen for this node, by engine: "auto"
// (its hardware decides), a build ("cu129"), or "none". Only on a node
// with GPUs, the only kind with a choice to make.
EngineBuilds map[string]string `json:"engine_builds,omitempty"`
// OS is the operating system and architecture, as the agent read them.
OS string `json:"os,omitempty"`
// Warnings are facts about the machine that make it slow or unusable
@@ -158,10 +216,13 @@ type NodeView struct {
// GPUView is one card as a node's page shows it.
type GPUView struct {
Index int `json:"index"`
Name string `json:"name"`
NUMA int `json:"numa"`
NVLinkPeers []int `json:"nvlink_peers,omitempty"`
Index int `json:"index"`
Name string `json:"name"`
// ComputeCapability is what decides which engine builds run on the card:
// CUDA 13 builds need 7.5, so a V100 (7.0) runs only older ones.
ComputeCapability string `json:"compute_capability,omitempty"`
NUMA int `json:"numa"`
NVLinkPeers []int `json:"nvlink_peers,omitempty"`
// NearestNIC is the adapter closest to the card, and how close.
NearestNIC string `json:"nearest_nic,omitempty"`
NICPath string `json:"nic_path,omitempty"`
@@ -203,6 +264,11 @@ type LoadView struct {
// MemPercent is used over used plus available.
MemPercent float64 `json:"memory_percent"`
AgeSec int64 `json:"age_seconds"`
// What the node's sensors say; zero is not measured.
CPUTempC float64 `json:"cpu_temp_c,omitempty"`
CPUPowerW float64 `json:"cpu_power_w,omitempty"`
InletTempC float64 `json:"inlet_temp_c,omitempty"`
GPUs []inventory.GPUSensor `json:"gpu_sensors,omitempty"`
}
// loadFresh is how old a measurement may be and still be shown as current.
@@ -216,7 +282,8 @@ func loadView(l *inventory.Load, now time.Time) *LoadView {
CPUPercent: l.CPUPercent, CPUs: l.CPUCount,
Load1: l.Load1, Load5: l.Load5, Load15: l.Load15,
MemUsed: l.MemUsedBytes, MemTotal: l.MemUsedBytes + l.MemAvailBytes,
AgeSec: int64(now.Sub(l.At).Seconds()),
AgeSec: int64(now.Sub(l.At).Seconds()),
CPUTempC: l.CPUTempC, CPUPowerW: l.CPUPowerW, InletTempC: l.InletTempC, GPUs: l.GPUs,
}
if v.MemTotal > 0 {
v.MemPercent = float64(v.MemUsed) * 100 / float64(v.MemTotal)
@@ -249,6 +316,26 @@ type ModelView struct {
Unplaceable bool `json:"unplaceable,omitempty"`
Fallbacks []string `json:"fallbacks,omitempty"`
Profiles []string `json:"profiles"`
// MemoryGB and CPUCores are what one replica reserves, from its first
// profile; MeasuredMemoryGB is the most one of its replicas uses now, 0
// when none is measured.
MemoryGB int `json:"memory_gb,omitempty"`
CPUCores int `json:"cpu_cores,omitempty"`
MeasuredMemoryGB float64 `json:"measured_memory_gb,omitempty"`
// Warnings are what makes its reservation wrong: nothing declared where
// placement needs a figure, or less declared than it uses.
Warnings []string `json:"warnings,omitempty"`
// Class is its capacity class; Preempted the replicas it lost to a higher
// class; HeldFor the model it is giving room to now, if any.
Class string `json:"class"`
Owner string `json:"owner,omitempty"`
// External is where a model served outside the cluster goes, provider
// host and its name for the model; empty for one served here.
External string `json:"external,omitempty"`
// Adapters are the LoRA adapters it serves.
Adapters []catalog.Adapter `json:"adapters,omitempty"`
Preempted int `json:"preempted,omitempty"`
HeldFor string `json:"held_for,omitempty"`
}
// ReplicaView is a replica as `ctl replicas` shows it.
@@ -325,6 +412,28 @@ type ExplainView struct {
Rejections []RejectionView `json:"rejections,omitempty"`
}
// HeadroomView is how many more replicas of a model fit now, and where.
type HeadroomView struct {
Model string `json:"model"`
// Current is the replicas holding capacity: starting or ready.
Current int `json:"current"`
// More is how many more placement would find room for now.
More int `json:"more"`
// Nodes is where each of those would go.
Nodes [][]string `json:"nodes,omitempty"`
// Limited is set when More stopped at the limit asked rather than at the
// cluster's edge: there may be room for more.
Limited bool `json:"limited,omitempty"`
// Blocked says why one more does not fit, a line per node considered.
Blocked []string `json:"blocked,omitempty"`
// TokPerSec is what one more replica was measured to serve; 0 unmeasured.
TokPerSec float64 `json:"tok_per_sec,omitempty"`
// ByPreempting is how many more would fit by stopping replicas this
// model's class may take; Preempts the models those belong to.
ByPreempting int `json:"by_preempting,omitempty"`
Preempts []string `json:"preempts,omitempty"`
}
// RejectionView is one rejected candidate.
type RejectionView struct {
Profile string `json:"profile"`
@@ -359,8 +468,8 @@ func (a *API) Nodes() []NodeView {
ID: n.ID, Hostname: n.Hostname, MgmtAddr: n.MgmtAddr,
State: n.State.String(), Labels: n.Labels,
GPUTotal: len(n.GPUs), GPUFree: len(inv.FreeGPUs(n.ID)),
MemoryGB: n.MemoryTotalMB / 1024,
Fabrics: n.FabricNames(), AgentVersion: n.AgentVersion, Features: n.Features,
MemoryGB: n.MemoryTotalMB / 1024, CPUCount: n.CPUCount,
Fabrics: n.FabricNames(), AgentVersion: n.AgentVersion, Features: n.Features,
Replicas: replicasOn[n.ID],
}
if free, total := n.LocalDisk(); total > 0 {
@@ -372,6 +481,13 @@ func (a *API) Nodes() []NodeView {
}
slices.Sort(v.Engines)
v.Provision = n.Provision
if ch := a.ctl.EngineChoices(); ch != nil && len(n.GPUs) > 0 {
chosen := ch.For(n.ID)
v.EngineBuilds = map[string]string{"vllm": "auto"}
for e, b := range chosen {
v.EngineBuilds[e] = b
}
}
if h := n.Host; h.OSID != "" {
v.OS = strings.TrimSpace(h.OSID + " " + h.OSVersion + " " + h.Arch)
if h.Arch == "amd64" && !h.HasCPUFlag("avx2") {
@@ -399,7 +515,8 @@ func (a *API) Nodes() []NodeView {
v.Load = loadView(n.Load, time.Now())
v.NVLinkDomain, v.IMEX, v.IMEXChannels = n.NVLinkDomain(), n.IMEX.State, n.IMEX.Channels
for _, g := range n.GPUs {
gv := GPUView{Index: g.Index, Name: g.Name, NUMA: g.NUMANode, NVLinkPeers: g.NVLinkPeers}
gv := GPUView{Index: g.Index, Name: g.Name, ComputeCapability: g.ComputeCapability,
NUMA: g.NUMANode, NVLinkPeers: g.NVLinkPeers}
for hca, rel := range g.NICPaths {
if gv.NearestNIC == "" || discovery.PathRank(rel) < discovery.PathRank(gv.NICPath) ||
(discovery.PathRank(rel) == discovery.PathRank(gv.NICPath) && hca < gv.NearestNIC) {
@@ -460,6 +577,15 @@ func (a *API) Models() []ModelView {
for _, h := range a.ctl.Reconciler().ModelHealth() {
health[h.Model] = h
}
// The most one replica of each model uses, summed over its shards.
measured := map[string]int64{}
for _, rep := range a.Replicas("") {
var b int64
for _, sh := range rep.Shards {
b += sh.MemoryBytes
}
measured[rep.Model] = max(measured[rep.Model], b)
}
out := make([]ModelView, 0)
for _, m := range a.ctl.Catalog().Models() {
h := health[m.Name]
@@ -470,10 +596,17 @@ func (a *API) Models() []ModelView {
Fallbacks: m.Fallbacks,
Desired: h.Desired, Failures: h.Failures,
Parked: h.Parked, Reason: h.Reason, Unplaceable: h.Unplaceable,
Class: m.EffectiveClass(), Owner: m.Owner, Preempted: h.Preempted, HeldFor: h.HeldFor,
External: externalLabel(m), Adapters: m.Adapters,
}
for _, p := range m.Profiles {
v.Profiles = append(v.Profiles, p.Name)
}
if len(m.Profiles) > 0 {
v.MemoryGB, v.CPUCores = m.Profiles[0].MinMemoryGB, m.Profiles[0].CPUCores
}
v.MeasuredMemoryGB = float64(measured[m.Name]) / (1 << 30)
v.Warnings = reservationWarnings(m, v.MeasuredMemoryGB)
out = append(out, v)
}
return out
@@ -544,6 +677,18 @@ type Models interface {
Add(ctx context.Context, m catalog.Model, by string) error
Remove(ctx context.Context, name string) error
Scale(ctx context.Context, name string, s catalog.Scaling, by string) (catalog.Model, error)
Resources(ctx context.Context, name, profile string, memoryGB, cpuCores int, by string) (catalog.Model, error)
SetClass(ctx context.Context, name, class, by string) (catalog.Model, error)
SetOwner(ctx context.Context, name, owner, by string) (catalog.Model, error)
SetAdapters(ctx context.Context, name string, adapters []catalog.Adapter, by string) (catalog.Model, error)
}
// ResourcesRequest sets what a model's profiles reserve of a node.
type ResourcesRequest struct {
// Profile is one profile's name; empty is every profile.
Profile string `json:"profile,omitempty"`
MemoryGB int `json:"memory_gb"`
CPUCores int `json:"cpu_cores"`
}
// Usage reports what each group of the tenancy tree spent. The store
@@ -555,6 +700,9 @@ type Usage interface {
// SetUsage supplies the usage reporting source.
func (a *API) SetUsage(u Usage) { a.usage = u }
// SetStoreStatus supplies the state of a replicated store, for the summary.
func (a *API) SetStoreStatus(f func() ([]StoreMember, error)) { a.storeStatus = f }
// ScopeUsageView is one group's spend, with its quota when it has one.
type ScopeUsageView struct {
Scope string `json:"scope"`
@@ -675,7 +823,7 @@ func (a *API) putCapacity(w http.ResponseWriter, r *http.Request) {
}
if err := a.capacity.PutCapacity(r.Context(), store.StoredCapacity{
Model: c.Model, Profile: c.Profile, Measurement: body,
MeasuredAt: c.MeasuredAt, MeasuredBy: r.Header.Get("X-Llmgw-Actor"),
MeasuredAt: c.MeasuredAt, MeasuredBy: actorOf(r),
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -726,7 +874,7 @@ func (a *API) addModel(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "the model definition is not valid JSON: "+err.Error())
return
}
if err := a.models.Add(r.Context(), m, r.Header.Get("X-Llmgw-Actor")); err != nil {
if err := a.models.Add(r.Context(), m, actorOf(r)); err != nil {
// Every failure here is the definition being wrong, which is the
// caller's to fix — a collision, a missing fallback, a profile that
// cannot be placed. None of them are server faults.
@@ -775,7 +923,7 @@ func (a *API) scaleModel(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
m, err := a.models.Scale(r.Context(), name,
catalog.Scaling{Min: req.Min, Max: req.Max, IdleTimeout: req.IdleTimeout},
r.Header.Get("X-Llmgw-Actor"))
actorOf(r))
if err != nil {
// Like addModel: every refusal here is the request being wrong — a
// model declared in configuration, one that was never added, a max
@@ -789,6 +937,26 @@ func (a *API) scaleModel(w http.ResponseWriter, r *http.Request) {
})
}
// setResources sets a model's reserved memory and cores. Placed replicas keep
// what they were started with until they are restarted.
func (a *API) setResources(w http.ResponseWriter, r *http.Request) {
if a.models == nil {
writeError(w, http.StatusNotImplemented, "this control plane has no store")
return
}
var req ResourcesRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error())
return
}
m, err := a.models.Resources(r.Context(), r.PathValue("name"), req.Profile, req.MemoryGB, req.CPUCores, actorOf(r))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, m)
}
func (a *API) removeModel(w http.ResponseWriter, r *http.Request) {
if a.models == nil {
writeError(w, http.StatusNotImplemented, "this control plane has no store")
@@ -825,6 +993,21 @@ func (a *API) explainPlacement(w http.ResponseWriter, r *http.Request) {
// It does not check that the model exists; the handler above does that so it
// can answer 404, while the dashboard only ever asks about models it just read
// out of the catalog.
// Headroom tries placing up to limit more replicas of a model, on a copy of
// the inventory, and says how many fit. A limit of zero or less takes 16.
func (a *API) Headroom(model string, limit int) (HeadroomView, bool) {
if limit <= 0 || limit > 64 {
limit = 16
}
h, ok := a.ctl.Reconciler().Headroom(model, limit)
if !ok {
return HeadroomView{}, false
}
return HeadroomView{Model: model, Current: h.Current, More: h.More, Nodes: h.Nodes,
Limited: h.Limited, Blocked: h.Blocked, TokPerSec: h.TokPerSec,
ByPreempting: h.ByPreempting, Preempts: h.Preempts}, true
}
func (a *API) Explain(model string) ExplainView {
v := ExplainView{Model: model}
for _, rep := range a.ctl.Reconciler().Replicas() {
@@ -1077,6 +1260,9 @@ type RestartRequest struct {
Replicas []string `json:"replicas,omitempty"`
Models []string `json:"models,omitempty"`
All bool `json:"all,omitempty"`
// InPlace stops each replica before starting its replacement, rather
// than starting the replacement beside it first.
InPlace bool `json:"in_place,omitempty"`
}
// RestartResult lists the replicas queued.
@@ -1100,7 +1286,11 @@ func (a *API) restart(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "nothing ready matches; name replicas, models, or all")
return
}
if err := a.ctl.Reconciler().Restart(ids...); err != nil {
restart := a.ctl.Reconciler().Restart
if req.InPlace {
restart = a.ctl.Reconciler().RestartInPlace
}
if err := restart(ids...); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
@@ -1165,3 +1355,242 @@ func (a *API) request(w http.ResponseWriter, r *http.Request) {
PromptTokens: u.PromptTokens, CompletionTokens: u.CompletionTokens, CostMicros: u.CostMicros,
})
}
// SetReleases sets where upgrades fetch release binaries from.
func (a *API) SetReleases(base string) { a.releases = base }
// UpgradeRequest names the release a node should run.
type UpgradeRequest struct {
Version string `json:"version"`
}
func (a *API) upgradeNode(w http.ResponseWriter, r *http.Request) {
var req UpgradeRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "the upgrade request is not valid JSON: "+err.Error())
return
}
if !release.Tag.MatchString(req.Version) {
writeError(w, http.StatusBadRequest, req.Version+" is not a release version")
return
}
u := a.ctl.Upgrader()
if u == nil {
writeError(w, http.StatusNotImplemented, "this control plane cannot upgrade nodes")
return
}
base := a.releases
if base == "" {
base = release.DefaultBase
}
if err := u.Upgrade(r.Context(), r.PathValue("id"), req.Version, base); err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"node": r.PathValue("id"), "version": req.Version})
}
// SetServe supplies what serves a model from the registry.
func (a *API) SetServe(s *modelserve.Server) { a.serve = s }
func (a *API) serveModel(w http.ResponseWriter, r *http.Request) {
if a.serve == nil {
writeError(w, http.StatusNotImplemented,
"serving from the registry needs a mirror and a store, and this control plane lacks one")
return
}
var req modelserve.Request
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error())
return
}
req.By = actorOf(r)
job, err := a.serve.Start(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusAccepted, job)
}
func (a *API) job(w http.ResponseWriter, r *http.Request) {
if a.serve == nil {
writeError(w, http.StatusNotFound, "no such job")
return
}
job, ok := a.serve.Get(r.PathValue("id"))
if !ok {
writeError(w, http.StatusNotFound, "no such job; jobs are kept only until the control plane restarts")
return
}
writeJSON(w, http.StatusOK, job)
}
// actorOf is who a change is recorded as made by: what the caller says, since
// the admin API answers only this machine and trusts whoever reaches it. Kept
// to a short printable name, so a claim cannot pass for more than one — no
// line breaks to forge a second log line, no length to bury the rest.
func actorOf(r *http.Request) string {
claimed := r.Header.Get("X-Llmgw-Actor")
var b strings.Builder
for _, c := range claimed {
if b.Len() >= 64 {
break
}
if unicode.IsLetter(c) || unicode.IsDigit(c) || strings.ContainsRune("._-@", c) {
b.WriteRune(c)
}
}
if b.Len() == 0 {
return "unknown"
}
return b.String()
}
// NodeCPUs is the CPU counts of ready nodes with at least this much memory:
// where a replica needing it could go.
func (a *API) NodeCPUs(minMemoryGB int) []int {
var out []int
for _, n := range a.ctl.Inventory().Nodes() {
if n.State.String() != "ready" || n.CPUCount == 0 {
continue
}
if n.MemoryTotalMB > 0 && n.MemoryTotalMB/1024 < minMemoryGB {
continue
}
out = append(out, n.CPUCount)
}
return out
}
// reservationWarnings says what is wrong with what a model reserves.
//
// Resident memory includes the weights mapped from disk, which the kernel can
// take back, so it overstates what a model needs by the part of the weights
// not in use. A tenth over is let pass for that; more is a figure too low for
// placement to be trusted with.
func reservationWarnings(m catalog.Model, measuredGB float64) []string {
var out []string
for _, p := range m.Profiles {
if p.CPUOnly() && p.MinMemoryGB <= 0 {
out = append(out, fmt.Sprintf("profile %s declares no memory: placement cannot tell when a node is full "+
"(llmgw ctl model resources %s --memory GB)", p.Name, m.Name))
}
}
if len(m.Profiles) == 1 && m.Profiles[0].MinMemoryGB > 0 && measuredGB > float64(m.Profiles[0].MinMemoryGB)*1.1 {
out = append(out, fmt.Sprintf("uses %.1f GB and reserves %d: raise it, or nodes will be filled past their memory",
measuredGB, m.Profiles[0].MinMemoryGB))
}
return out
}
// externalLabel is "api.openai.com · gpt-4o-mini" for a model served elsewhere.
func externalLabel(m catalog.Model) string {
if !m.IsExternal() {
return ""
}
host := m.External.URL
if u, err := url.Parse(host); err == nil && u.Host != "" {
host = u.Host
}
return host + " · " + m.External.Model
}
// AdaptersRequest replaces a model's LoRA adapters with these.
type AdaptersRequest struct {
Adapters []catalog.Adapter `json:"adapters"`
}
// setAdapters replaces a model's adapters, after reading each new file to
// check it is an adapter for the model's architecture: one that is not is a
// replica that fails to start, found minutes later on a node.
func (a *API) setAdapters(w http.ResponseWriter, r *http.Request) {
if a.models == nil {
writeError(w, http.StatusNotImplemented, "this control plane has no store")
return
}
var req AdaptersRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error())
return
}
name := r.PathValue("name")
m, ok := a.ctl.Catalog().Resolve(name)
if !ok {
writeError(w, http.StatusBadRequest, "no model "+name)
return
}
base, baseErr := gguf.KindFromURL(r.Context(), nil, m.Source)
have := map[string]bool{}
for _, ad := range m.Adapters {
have[ad.Source] = true
}
for _, ad := range req.Adapters {
if have[ad.Source] {
continue // already checked when it was attached
}
h, err := gguf.KindFromURL(r.Context(), nil, ad.Source)
switch {
case err != nil:
writeError(w, http.StatusBadRequest, fmt.Sprintf("adapter %s: %v", ad.Name, err))
return
case !h.IsAdapter():
writeError(w, http.StatusBadRequest, fmt.Sprintf("adapter %s: %s is a model, not a LoRA adapter", ad.Name, ad.Source))
return
case baseErr == nil && h.Architecture != base.Architecture:
writeError(w, http.StatusBadRequest, fmt.Sprintf("adapter %s is for %s models and %s is %s",
ad.Name, h.Architecture, m.Name, base.Architecture))
return
}
}
out, err := a.models.SetAdapters(r.Context(), m.Name, req.Adapters, actorOf(r))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, AdaptersRequest{Adapters: out.Adapters})
}
// NodeEngineRequest chooses one engine's build for a node.
type NodeEngineRequest struct {
Engine string `json:"engine"`
// Build is "auto", a build such as "cu129", or "none".
Build string `json:"build"`
}
// setNodeEngine chooses an engine's build for a node, and has the node
// install it.
func (a *API) setNodeEngine(w http.ResponseWriter, r *http.Request) {
ch := a.ctl.EngineChoices()
if ch == nil {
writeError(w, http.StatusNotImplemented, "this control plane has no store to keep the choice in")
return
}
id := r.PathValue("id")
if _, ok := a.ctl.Inventory().Get(id); !ok {
writeError(w, http.StatusNotFound, "no node "+id)
return
}
var req NodeEngineRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error())
return
}
if err := ch.Set(r.Context(), id, req.Engine, req.Build, actorOf(r)); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, req)
}
// SetNodeEngine chooses an engine's build for a node, for the cluster page.
func (a *API) SetNodeEngine(ctx context.Context, node, engine, build, by string) error {
ch := a.ctl.EngineChoices()
if ch == nil {
return errors.New("this control plane has no store to keep the choice in")
}
if _, ok := a.ctl.Inventory().Get(node); !ok {
return errors.New("no node " + node)
}
return ch.Set(ctx, node, engine, build, by)
}
+19
View File
@@ -258,6 +258,25 @@ type fakeModels struct {
err error
}
func (f *fakeModels) SetAdapters(_ context.Context, name string, as []catalog.Adapter, _ string) (catalog.Model, error) {
return catalog.Model{Name: name, Adapters: as}, f.err
}
func (f *fakeModels) SetOwner(_ context.Context, name, owner, _ string) (catalog.Model, error) {
return catalog.Model{Name: name, Owner: owner}, f.err
}
func (f *fakeModels) SetClass(_ context.Context, name, class, _ string) (catalog.Model, error) {
return catalog.Model{Name: name, Class: class}, f.err
}
func (f *fakeModels) Resources(_ context.Context, name, profile string, mem, cores int, _ string) (catalog.Model, error) {
if f.err != nil {
return catalog.Model{}, f.err
}
return catalog.Model{Name: name, Profiles: []catalog.Profile{{Name: "p", MinMemoryGB: mem, CPUCores: cores}}}, nil
}
func (f *fakeModels) Scale(_ context.Context, name string, s catalog.Scaling, by string) (catalog.Model, error) {
if f.err != nil {
return catalog.Model{}, f.err
@@ -0,0 +1,63 @@
package adminapi
import (
"context"
"log/slog"
"net/http"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/control"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
)
// choiceStore keeps engine choices in memory.
type choiceStore struct{ m map[string]map[string]string }
func (c *choiceStore) NodeEngines(context.Context) (map[string]map[string]string, error) {
return c.m, nil
}
func (c *choiceStore) SetNodeEngine(_ context.Context, node, engine, variant, _ string) error {
if c.m[node] == nil {
c.m[node] = map[string]string{}
}
c.m[node][engine] = variant
return nil
}
func TestAnEngineBuildIsChosenForANodeAndTheNodeIsTold(t *testing.T) {
a, ctl := setup(t, testModels(), gpuNode("gpu07", 8), inventory.Node{ID: "cpu01", State: inventory.Ready})
ch := control.NewEngineChoices(t.Context(), &choiceStore{m: map[string]map[string]string{}}, slog.New(slog.DiscardHandler))
var told []string
ch.OnChange(func(node string) error { told = append(told, node); return nil })
ctl.SetEngineChoices(ch)
if code := call(t, a, http.MethodPost, "/api/v1/nodes/gpu07/engines", `{"engine":"vllm","build":"cu129"}`, nil); code != http.StatusOK {
t.Fatalf("status %d", code)
}
if len(told) != 1 || told[0] != "gpu07" {
t.Errorf("the node was not told: %v", told)
}
var nodes []NodeView
call(t, a, http.MethodGet, "/api/v1/nodes", "", &nodes)
for _, n := range nodes {
switch n.ID {
case "gpu07":
if n.EngineBuilds["vllm"] != "cu129" {
t.Errorf("gpu07 shows %v", n.EngineBuilds)
}
case "cpu01":
if n.EngineBuilds != nil {
t.Errorf("a node with no GPU has no build to choose: %v", n.EngineBuilds)
}
}
}
for _, bad := range []string{`{"engine":"vllm","build":"cu200"}`, `{"engine":"llamacpp","build":"cu129"}`} {
if code := call(t, a, http.MethodPost, "/api/v1/nodes/gpu07/engines", bad, nil); code != http.StatusBadRequest {
t.Errorf("%s: status %d", bad, code)
}
}
if code := call(t, a, http.MethodPost, "/api/v1/nodes/gpu07/engines", `{"engine":"vllm","build":"auto"}`, nil); code != http.StatusOK ||
ch.For("gpu07")["vllm"] != "" {
t.Errorf("auto should leave it to the node again: %d %v", code, ch.For("gpu07"))
}
}
+285
View File
@@ -0,0 +1,285 @@
package adminapi
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/quota"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// Quotas is where capacity quotas and the tenancy tree they name are kept.
// The store implements it.
type Quotas interface {
CapacityQuotas(ctx context.Context) ([]store.CapacityQuota, error)
PutCapacityQuota(ctx context.Context, q store.CapacityQuota) error
DeleteCapacityQuota(ctx context.Context, scope store.Scope, id string) error
ScopeParents(ctx context.Context) (map[string][]string, error)
AllOrgs(ctx context.Context) ([]store.Org, error)
AllTeams(ctx context.Context) ([]store.TeamPath, error)
AllProjects(ctx context.Context) ([]store.ProjectPath, error)
}
// SetQuotas supplies the quota store, which mounts the quota endpoints'
// behaviour; without it they answer 501.
func (a *API) SetQuotas(q Quotas) { a.quotas = q }
// QuotaView is one scope's quota with what it holds now.
type QuotaView struct {
// Scope is team:ID; Name is how people know it, "Acme / ml".
Scope string `json:"scope"`
Name string `json:"name"`
Guarantee store.Resources `json:"guarantee"`
Limit store.Resources `json:"limit"`
Holds quota.Amount `json:"holds"`
UpdatedBy string `json:"updated_by,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// QuotaRequest sets a scope's quota. Scope may be written with an ID or a
// name: org:Acme, team:Acme/ml, project:Acme/ml/training.
type QuotaRequest struct {
Scope string `json:"scope"`
Guarantee store.Resources `json:"guarantee"`
Limit store.Resources `json:"limit"`
}
func (a *API) registerQuotas(mux *http.ServeMux) {
mux.HandleFunc("GET /api/v1/quotas", func(w http.ResponseWriter, r *http.Request) {
if a.quotas == nil {
writeError(w, http.StatusNotImplemented, "this control plane has no store")
return
}
views, err := a.QuotaViews(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, views)
})
mux.HandleFunc("PUT /api/v1/quotas", func(w http.ResponseWriter, r *http.Request) {
if a.quotas == nil {
writeError(w, http.StatusNotImplemented, "this control plane has no store")
return
}
var req QuotaRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error())
return
}
key, err := a.SetQuota(r.Context(), req, actorOf(r))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"scope": key})
})
mux.HandleFunc("DELETE /api/v1/quotas/{scope}", func(w http.ResponseWriter, r *http.Request) {
if a.quotas == nil {
writeError(w, http.StatusNotImplemented, "this control plane has no store")
return
}
key, err := a.ClearQuota(r.Context(), r.PathValue("scope"))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"removed": key})
})
mux.HandleFunc("POST /api/v1/models/{name}/owner", func(w http.ResponseWriter, r *http.Request) {
if a.models == nil || a.quotas == nil {
writeError(w, http.StatusNotImplemented, "this control plane has no store")
return
}
var req struct {
Owner string `json:"owner"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error())
return
}
owner := ""
if req.Owner != "" {
scope, id, _, err := a.resolveScope(r.Context(), req.Owner)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
owner = quota.Key(scope, id)
}
m, err := a.models.SetOwner(r.Context(), r.PathValue("name"), owner, actorOf(r))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"model": m.Name, "owner": m.Owner})
})
}
// QuotaViews lists every quota with what its scope holds now.
func (a *API) QuotaViews(ctx context.Context) ([]QuotaView, error) {
if a.quotas == nil {
return nil, nil
}
qs, err := a.quotas.CapacityQuotas(ctx)
if err != nil {
return nil, err
}
parents, err := a.quotas.ScopeParents(ctx)
if err != nil {
return nil, err
}
names, err := a.scopeNames(ctx)
if err != nil {
return nil, err
}
usage := quota.New(qs, parents).Usage(a.ctl.Reconciler().Holdings())
out := make([]QuotaView, 0, len(qs))
for _, q := range qs {
key := quota.Key(q.Scope, q.ScopeID)
out = append(out, QuotaView{Scope: key, Name: names[key], Guarantee: q.Guarantee, Limit: q.Limit,
Holds: usage[key], UpdatedBy: q.UpdatedBy, UpdatedAt: q.UpdatedAt})
}
return out, nil
}
// scopeNames maps org:ID, team:ID and project:ID to their paths.
func (a *API) scopeNames(ctx context.Context) (map[string]string, error) {
out := map[string]string{}
orgs, err := a.quotas.AllOrgs(ctx)
if err != nil {
return nil, err
}
for _, o := range orgs {
out["org:"+o.ID] = o.Name
}
teams, err := a.quotas.AllTeams(ctx)
if err != nil {
return nil, err
}
for _, t := range teams {
out["team:"+t.ID] = t.OrgName + " / " + t.Name
}
projects, err := a.quotas.AllProjects(ctx)
if err != nil {
return nil, err
}
for _, p := range projects {
out["project:"+p.ID] = p.Path
}
return out, nil
}
// resolveScope reads org:X, team:X or project:X, where X is an ID or the
// scope's path — Acme, Acme/ml, Acme/ml/training — and returns its ID and
// path. A path that could mean two scopes is refused rather than guessed.
func (a *API) resolveScope(ctx context.Context, s string) (store.Scope, string, string, error) {
kind, what, ok := strings.Cut(strings.TrimSpace(s), ":")
scope := store.Scope(kind)
if !ok || what == "" || (scope != store.ScopeOrg && scope != store.ScopeTeam && scope != store.ScopeProject) {
return "", "", "", fmt.Errorf("%q is not org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT", s)
}
names, err := a.scopeNames(ctx)
if err != nil {
return "", "", "", err
}
norm := func(p string) string {
parts := strings.Split(p, "/")
for i := range parts {
parts[i] = strings.ToLower(strings.TrimSpace(parts[i]))
}
return strings.Join(parts, "/")
}
if name, ok := names[kind+":"+what]; ok {
return scope, what, name, nil
}
match := func(same func(name string) bool) []string {
var found []string
for key, name := range names {
if strings.HasPrefix(key, kind+":") && same(name) {
found = append(found, key)
}
}
return found
}
found := match(func(name string) bool { return norm(name) == norm(what) })
if len(found) == 0 {
// Its own name alone — ml rather than Acme/ml — when that is unique.
found = match(func(name string) bool {
parts := strings.Split(norm(name), "/")
return parts[len(parts)-1] == norm(what)
})
}
switch len(found) {
case 0:
return "", "", "", fmt.Errorf("there is no %s %q", kind, what)
case 1:
_, id, _ := strings.Cut(found[0], ":")
return scope, id, names[found[0]], nil
}
return "", "", "", fmt.Errorf("%q could be any of %d %ss; give its ID", what, len(found), kind)
}
// SetQuota sets a scope's capacity quota, named by path or ID, and returns
// the scope as org:ID.
func (a *API) SetQuota(ctx context.Context, req QuotaRequest, by string) (string, error) {
if a.quotas == nil {
return "", fmt.Errorf("this control plane has no store")
}
scope, id, _, err := a.resolveScope(ctx, req.Scope)
if err != nil {
return "", err
}
if !req.Guarantee.Any() && !req.Limit.Any() {
return "", fmt.Errorf("a quota needs a guarantee or a limit; to remove one, clear it")
}
q := store.CapacityQuota{Scope: scope, ScopeID: id, Guarantee: req.Guarantee, Limit: req.Limit, UpdatedBy: by}
if err := a.quotas.PutCapacityQuota(ctx, q); err != nil {
return "", err
}
return quota.Key(scope, id), nil
}
// ClearQuota removes a scope's capacity quota.
func (a *API) ClearQuota(ctx context.Context, scope string) (string, error) {
if a.quotas == nil {
return "", fmt.Errorf("this control plane has no store")
}
s, id, _, err := a.resolveScope(ctx, scope)
if err != nil {
return "", err
}
if err := a.quotas.DeleteCapacityQuota(ctx, s, id); err != nil {
return "", err
}
return quota.Key(s, id), nil
}
// ScopeName is a part of the tenancy tree: org:ID and its path.
type ScopeName struct {
Key string `json:"scope"`
Name string `json:"name"`
}
// Scopes lists every organization, team and project, by path.
func (a *API) Scopes(ctx context.Context) ([]ScopeName, error) {
if a.quotas == nil {
return nil, nil
}
names, err := a.scopeNames(ctx)
if err != nil {
return nil, err
}
out := make([]ScopeName, 0, len(names))
for k, n := range names {
out = append(out, ScopeName{Key: k, Name: n})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
+107
View File
@@ -0,0 +1,107 @@
package adminapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// fakeQuotaStore is Acme with teams ml and web, and Scania with another ml.
type fakeQuotaStore struct {
quotas []store.CapacityQuota
}
func (f *fakeQuotaStore) CapacityQuotas(context.Context) ([]store.CapacityQuota, error) {
return f.quotas, nil
}
func (f *fakeQuotaStore) PutCapacityQuota(_ context.Context, q store.CapacityQuota) error {
for i := range f.quotas {
if f.quotas[i].Scope == q.Scope && f.quotas[i].ScopeID == q.ScopeID {
f.quotas[i] = q
return nil
}
}
f.quotas = append(f.quotas, q)
return nil
}
func (f *fakeQuotaStore) DeleteCapacityQuota(_ context.Context, s store.Scope, id string) error {
for i, q := range f.quotas {
if q.Scope == s && q.ScopeID == id {
f.quotas = append(f.quotas[:i], f.quotas[i+1:]...)
}
}
return nil
}
func (f *fakeQuotaStore) ScopeParents(context.Context) (map[string][]string, error) {
return map[string][]string{"team:t-ml": {"org:o-acme"}, "team:t-web": {"org:o-acme"},
"team:t-sml": {"org:o-scania"}}, nil
}
func (f *fakeQuotaStore) AllOrgs(context.Context) ([]store.Org, error) {
return []store.Org{{ID: "o-acme", Name: "Acme"}, {ID: "o-scania", Name: "Scania"}}, nil
}
func (f *fakeQuotaStore) AllTeams(context.Context) ([]store.TeamPath, error) {
return []store.TeamPath{{ID: "t-ml", Name: "ml", OrgID: "o-acme", OrgName: "Acme"},
{ID: "t-web", Name: "web", OrgID: "o-acme", OrgName: "Acme"},
{ID: "t-sml", Name: "ml", OrgID: "o-scania", OrgName: "Scania"}}, nil
}
func (f *fakeQuotaStore) AllProjects(context.Context) ([]store.ProjectPath, error) { return nil, nil }
func TestAScopeIsNamedByPathOrIDAndNeverGuessed(t *testing.T) {
a, _ := setup(t, testModels())
a.SetQuotas(&fakeQuotaStore{})
for in, want := range map[string]string{
"org:Acme": "o-acme", "org:o-acme": "o-acme", "team:Acme/ml": "t-ml",
"team: acme / ML ": "t-ml", "team:Scania/ml": "t-sml", "team:web": "t-web",
} {
_, id, _, err := a.resolveScope(t.Context(), in)
if err != nil || id != want {
t.Errorf("%q: %q %v, expected %q", in, id, err, want)
}
}
for _, bad := range []string{"team:ml", "team:Acme/nope", "group:Acme", "Acme", "org:"} {
if _, _, _, err := a.resolveScope(t.Context(), bad); err == nil {
t.Errorf("%q was resolved", bad)
}
}
if _, _, _, err := a.resolveScope(t.Context(), "team:ml"); err == nil || !strings.Contains(err.Error(), "could be any of 2") {
t.Errorf("an ambiguous name should say so: %v", err)
}
}
func TestAQuotaIsSetListedAndCleared(t *testing.T) {
a, _ := setup(t, testModels())
qs := &fakeQuotaStore{}
a.SetQuotas(qs)
mux := http.NewServeMux()
a.Register(mux)
call := func(method, path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Llmgw-Actor", "ops")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
if rec := call("PUT", "/api/v1/quotas", `{"scope":"team:Acme/ml","guarantee":{"gpus":8},"limit":{"gpus":16}}`); rec.Code != 200 {
t.Fatalf("set: %d %s", rec.Code, rec.Body)
}
if len(qs.quotas) != 1 || qs.quotas[0].ScopeID != "t-ml" || *qs.quotas[0].Guarantee.GPUs != 8 || qs.quotas[0].UpdatedBy != "ops" {
t.Errorf("stored %+v", qs.quotas)
}
rec := call("GET", "/api/v1/quotas", "")
var views []QuotaView
if err := json.Unmarshal(rec.Body.Bytes(), &views); err != nil || len(views) != 1 || views[0].Name != "Acme / ml" {
t.Errorf("list %d %s", rec.Code, rec.Body)
}
if rec := call("PUT", "/api/v1/quotas", `{"scope":"team:Acme/ml"}`); rec.Code != 400 {
t.Errorf("an empty quota was accepted: %d", rec.Code)
}
if rec := call("DELETE", "/api/v1/quotas/team:Acme%2Fml", ""); rec.Code != 200 || len(qs.quotas) != 0 {
t.Errorf("clear: %d %s %+v", rec.Code, rec.Body, qs.quotas)
}
}
+21
View File
@@ -31,6 +31,20 @@ type Summary struct {
// IdleReplicas are serving replicas nothing has asked for in a while, which
// on a fleet with more models than GPUs is the first place to find room.
IdleReplicas []IdleReplica `json:"idle_replicas,omitempty"`
// Store is the members of the store's cluster when it is replicated, and
// StoreError why it could not be read; both empty for a single database.
Store []StoreMember `json:"store,omitempty"`
StoreError string `json:"store_error,omitempty"`
}
// StoreMember is one member of a replicated store, as its Patroni reports it.
type StoreMember struct {
Name string `json:"name"`
Host string `json:"host"`
Role string `json:"role"`
State string `json:"state"`
// Lag is bytes behind the leader; -1 unknown, 0 for the leader.
Lag int64 `json:"lag"`
}
// NodeTotals counts nodes by the states that need different responses.
@@ -107,6 +121,13 @@ const idleThreshold = 10 * time.Minute
// Summary builds the fleet summary.
func (a *API) Summary() Summary {
var s Summary
if a.storeStatus != nil {
members, err := a.storeStatus()
s.Store = members
if err != nil {
s.StoreError = err.Error()
}
}
nodes := a.Nodes()
byClass := map[string]*ClassUse{}
+69
View File
@@ -0,0 +1,69 @@
package adminapi
import (
"fmt"
"math"
"net/http"
"strconv"
)
// TargetView is how many replicas a model needs to serve a throughput, from
// what one replica was measured to serve, and whether the cluster has room.
type TargetView struct {
Model string `json:"model"`
// TokPerSec is what was asked for; PerReplica what one replica was
// measured to serve at its SLO, on Profile.
TokPerSec float64 `json:"tok_per_sec"`
PerReplica float64 `json:"per_replica_tok_per_sec"`
Profile string `json:"profile"`
// Needed replicas in all; Current hold capacity now; More is how many of
// Needed are not running, and Room how many more fit.
Needed int `json:"needed"`
Current int `json:"current"`
More int `json:"more"`
Room int `json:"room"`
Fits bool `json:"fits"`
Blocked []string `json:"blocked,omitempty"`
// Preempts is whose replicas would be stopped to make the room, when
// free room alone is not enough; Room counts that room too.
Preempts []string `json:"preempts,omitempty"`
}
// Target sizes a model for a throughput.
func (a *API) Target(model string, tokPerSec float64) (TargetView, error) {
meas, profile, ok := a.ctl.Reconciler().MeasuredFor(model)
if !ok || meas.TokPerSec <= 0 {
return TargetView{}, fmt.Errorf("%s has not been measured; run `llmgw ctl bench %s --save` first", model, model)
}
v := TargetView{Model: model, TokPerSec: tokPerSec, PerReplica: meas.TokPerSec, Profile: profile,
Needed: max(int(math.Ceil(tokPerSec/meas.TokPerSec)), 1)}
h, ok := a.Headroom(model, v.Needed)
if !ok {
return TargetView{}, fmt.Errorf("no model %q", model)
}
v.Current = h.Current
v.More = max(v.Needed-h.Current, 0)
v.Room = h.More + h.ByPreempting
v.Fits = v.More <= v.Room || h.Limited
if v.More > h.More {
v.Preempts = h.Preempts
}
if !v.Fits {
v.Blocked = h.Blocked
}
return v, nil
}
func (a *API) target(w http.ResponseWriter, r *http.Request) {
tps, err := strconv.ParseFloat(r.URL.Query().Get("tokens_per_sec"), 64)
if err != nil || tps <= 0 {
writeError(w, http.StatusBadRequest, "tokens_per_sec must be a positive number")
return
}
v, err := a.Target(r.PathValue("name"), tps)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, v)
}
+30
View File
@@ -0,0 +1,30 @@
package adminapi
import (
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
)
func TestATargetIsSizedFromTheMeasurementAndTheRoom(t *testing.T) {
// Three 8-GPU nodes; one tp8 replica was measured at 500 tokens/s.
a, ctl := setup(t, testModels(), gpuNode("1", 8), gpuNode("2", 8), gpuNode("3", 8))
if _, err := a.Target("llama", 1000); err == nil || !strings.Contains(err.Error(), "ctl bench llama --save") {
t.Errorf("an unmeasured model should say how to measure it: %v", err)
}
ctl.Reconciler().SetMeasurements(func() map[string]reconciler.Measured {
return map[string]reconciler.Measured{"llama/tp8": {MaxConcurrency: 16, TokPerSec: 500}}
})
v, err := a.Target("llama", 1200)
if err != nil {
t.Fatal(err)
}
if v.Needed != 3 || v.PerReplica != 500 || v.Profile != "tp8" || v.More != 3 || !v.Fits {
t.Errorf("1200 tok/s at 500 each is 3 replicas on 3 empty nodes: %+v", v)
}
v, _ = a.Target("llama", 2000)
if v.Needed != 4 || v.Fits || v.Room != 3 || len(v.Blocked) == 0 {
t.Errorf("a fourth does not fit on three nodes, and should say why: %+v", v)
}
}
+5
View File
@@ -31,6 +31,11 @@ func (a *API) Orphans() []OrphanView {
var wanted []string
for _, m := range a.ctl.Catalog().Models() {
wanted = append(wanted, m.Source)
// A model's adapters are its weights too; collecting them would
// leave replicas that cannot restart.
for _, ad := range m.Adapters {
wanted = append(wanted, ad.Source)
}
}
out := make([]OrphanView, 0)
@@ -0,0 +1,65 @@
package agentsvc
import (
"context"
"slices"
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
)
// SetControlPlanes gives the service where the list of control planes comes
// from. Every agent is told it when it registers, and again when it changes.
func (s *Service) SetControlPlanes(list func(context.Context) ([]string, error)) {
s.mu.Lock()
s.controlPlanes = list
s.mu.Unlock()
}
// controlPlanesMsg is the list as an agent is sent it, or nil when there is
// none to send: a cluster with no list recorded keeps what its agents were
// configured with.
func (s *Service) controlPlanesMsg(ctx context.Context) *llmgwv1.ControlMessage {
s.mu.RLock()
list := s.controlPlanes
s.mu.RUnlock()
if list == nil {
return nil
}
servers, err := list(ctx)
if err != nil || len(servers) == 0 {
return nil
}
return &llmgwv1.ControlMessage{Msg: &llmgwv1.ControlMessage_ControlPlanes{
ControlPlanes: &llmgwv1.ControlPlanes{Servers: servers}}}
}
// WatchControlPlanes tells every connected agent the list when it changes,
// checking every so often. A control plane added by `llmgw server add` reaches
// the nodes within that time, without their being touched.
func (s *Service) WatchControlPlanes(ctx context.Context, every time.Duration) {
var last []string
t := time.NewTicker(every)
defer t.Stop()
for {
if msg := s.controlPlanesMsg(ctx); msg != nil {
now := msg.GetControlPlanes().GetServers()
if !slices.Equal(now, last) {
for _, node := range s.ConnectedNodes() {
if err := s.send(node, msg); err != nil {
s.log.Warn("could not tell a node the control planes", "node", node, "err", err)
}
}
if last != nil {
s.log.Info("told the nodes the control planes changed", "servers", now)
}
last = now
}
}
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
+23
View File
@@ -4,6 +4,7 @@ package agentsvc
import (
"fmt"
"maps"
"runtime"
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
@@ -27,6 +28,7 @@ func NodeFromRegister(r *llmgwv1.Register) inventory.Node {
EngineVersions: r.GetEngineVersions(),
EnginesProbed: r.GetEnginesProbed(),
MemoryTotalMB: int(r.GetMemoryTotalMb()),
CPUCount: int(r.GetCpuCount()),
}
if h := r.GetHost(); h != nil {
n.Host = discovery.Host{
@@ -90,6 +92,7 @@ func RegisterFromInventory(nodeID, hostname, mgmtAddr, agentVersion string,
NodeId: nodeID, Hostname: hostname, MgmtAddr: mgmtAddr,
Labels: labels, AgentVersion: agentVersion,
MemoryTotalMb: int64(inv.MemoryTotalMB),
CpuCount: int32(runtime.NumCPU()),
}
for _, w := range inv.Weights {
r.Weights = append(r.Weights, &llmgwv1.LocalWeights{
@@ -186,12 +189,14 @@ func StartShardFromCommand(cmd reconciler.ShardCommand) *llmgwv1.StartShard {
GpusPerNode: int32(p.GPUsPerNode), Nodes: int32(p.Nodes),
Args: p.Args, Env: p.Env,
ModelPath: p.ModelPath, ServedModelName: p.ServedModelName,
Adapters: adaptersToWire(p.Adapters),
},
StartupTimeoutMs: cmd.StartupTimeout.Milliseconds(),
Pool: cmd.Pool,
Fabric: cmd.Fabric,
ProfileFp: cmd.ProfileFP,
ApiKey: cmd.Spec.APIKey,
CpuThreads: int32(cmd.Spec.Threads),
}
}
@@ -213,12 +218,14 @@ func ShardSpecFromStart(m *llmgwv1.StartShard) (engines.ShardSpec, time.Duration
Fabric: m.GetFabric(),
ProfileFP: m.GetProfileFp(),
APIKey: m.GetApiKey(),
Threads: int(m.GetCpuThreads()),
Profile: engines.Profile{
Name: p.GetName(), Engine: p.GetEngine(), GPUClass: p.GetGpuClass(),
TP: int(p.GetTp()), PP: int(p.GetPp()),
GPUsPerNode: int(p.GetGpusPerNode()), Nodes: int(p.GetNodes()),
Args: p.GetArgs(), Env: p.GetEnv(),
ModelPath: p.GetModelPath(), ServedModelName: p.GetServedModelName(),
Adapters: adaptersFromWire(p.GetAdapters()),
},
}
return spec, time.Duration(m.GetStartupTimeoutMs()) * time.Millisecond
@@ -287,3 +294,19 @@ func fabricFrom(state, status, cluster, clique, bandwidth string) *discovery.GPU
}
return &discovery.GPUFabric{State: state, Status: status, ClusterUUID: cluster, CliqueID: clique, Bandwidth: bandwidth}
}
func adaptersToWire(as []engines.Adapter) []*llmgwv1.LoraAdapter {
var out []*llmgwv1.LoraAdapter
for _, a := range as {
out = append(out, &llmgwv1.LoraAdapter{Name: a.Name, Source: a.Path})
}
return out
}
func adaptersFromWire(as []*llmgwv1.LoraAdapter) []engines.Adapter {
var out []engines.Adapter
for _, a := range as {
out = append(out, engines.Adapter{Name: a.GetName(), Path: a.GetSource()})
}
return out
}
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff. Show more