Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e0465f10a | ||
|
|
cba65f5be7 | ||
|
|
6dba6bd93a | ||
|
|
40c3d83110 | ||
|
|
069d334652 | ||
|
|
4865a96ec2 | ||
|
|
2a381ad669 |
No files matched your search
@@ -55,6 +55,13 @@ jobs:
|
||||
(cd dist && sha256sum "${name}" >> checksums.txt)
|
||||
done
|
||||
|
||||
# The reverse proxy configurations, so a release page carries
|
||||
# everything install.md asks for and the repository is not needed.
|
||||
for f in deploy/proxy/Caddyfile deploy/proxy/nginx.conf; do
|
||||
cp "$f" dist/
|
||||
(cd dist && sha256sum "$(basename "$f")" >> checksums.txt)
|
||||
done
|
||||
|
||||
# An NVML build for nodes with NVLink, which the portable binary
|
||||
# cannot serve.
|
||||
#
|
||||
|
||||
@@ -18,6 +18,75 @@ is only released once its **exit criterion** is met, not when its code lands.
|
||||
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
|
||||
APIs change between minors without a deprecation period.
|
||||
|
||||
## v0.8.7 — 2026-09-28
|
||||
|
||||
### PostgreSQL in one command
|
||||
|
||||
```
|
||||
sudo llmgw server init --postgres
|
||||
```
|
||||
|
||||
Installs PostgreSQL with the machine's own package manager when there is none,
|
||||
creates the `llmgw` role and database with a generated password kept in
|
||||
`/etc/llmgw/secrets/store-dsn`, and checks that it connects. On a control plane
|
||||
already running on SQLite it stops the gateway, copies the store across so
|
||||
every account and key survives, points `auth.store` at PostgreSQL and starts
|
||||
the gateway again; if the move fails, the gateway is started again on SQLite.
|
||||
Given on the first run, there is never a SQLite store.
|
||||
|
||||
`server.yaml` is edited in place with its comments intact, and the previous
|
||||
file is kept beside it. A PostgreSQL that init did not install is left sized as
|
||||
it was, since something else may depend on it.
|
||||
|
||||
The PostgreSQL installation itself has not yet run on a real machine; the move,
|
||||
the configuration edit and the rerun behaviour are tested.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **A new control plane with no models did not start.** A configuration with
|
||||
no static endpoints and no models was refused as having nothing to serve, so
|
||||
under systemd the unit restarted every two seconds and nothing listened on
|
||||
8080. It now runs as a control plane with an empty catalog.
|
||||
|
||||
## v0.8.6 — 2026-09-28
|
||||
|
||||
### Reverse proxy configurations
|
||||
|
||||
`deploy/proxy/` has a Caddyfile and an nginx configuration for the client
|
||||
port, and both are attached to every release beside the binaries. They do not
|
||||
buffer streamed replies, their read timeout outlasts a model's wait before its
|
||||
first token, their body limit matches the router's, and they set
|
||||
`X-Forwarded-Proto` and `X-Forwarded-Host` themselves — llmgw marks session
|
||||
cookies `Secure` from the first and shows the second as the base URL on its
|
||||
landing page.
|
||||
|
||||
## v0.8.5 — 2026-09-28
|
||||
|
||||
### A control plane in one command
|
||||
|
||||
```
|
||||
sudo llmgw server init --admin you@example.com
|
||||
```
|
||||
|
||||
Creates the llmgw user and its directories, the certificate authority, the
|
||||
control plane's certificate for the machine's hostname and every address it
|
||||
has, `server.yaml`, the default project, and an administrator whose password is
|
||||
generated and printed once — then installs the systemd unit, starts it and
|
||||
waits until it answers. What used to be two steps of install.md is now this.
|
||||
|
||||
Each step is skipped when its result exists, so running it again finishes an
|
||||
install that stopped and changes nothing on one that did not. `--host` adds an
|
||||
address the machine cannot see of itself (a DNS alias, a NAT or floating
|
||||
address) and reissues the certificate keeping the old ones. It never overwrites
|
||||
`server.yaml` or an authority, and certificates issued by an authority kept on
|
||||
another machine are left as they are.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **The configuration install.md showed could not start.** An empty catalog
|
||||
without `control.enabled: true` is refused as having nothing to serve. The
|
||||
documentation and the file `init` writes both set it.
|
||||
|
||||
## v0.8.4 — 2026-09-28
|
||||
|
||||
### Node logs on the admin page
|
||||
|
||||
@@ -66,6 +66,8 @@ on a machine that has them.
|
||||
does not exist or miss one that does
|
||||
- [deploy/mirror/](deploy/mirror/) — a model mirror, which is the cheapest
|
||||
and most useful thing to build around llmgw
|
||||
- [deploy/proxy/](deploy/proxy/) — Caddy and nginx in front of the client
|
||||
port, with streaming and the forwarded headers right
|
||||
- [deploy/ha/](deploy/ha/) — running two control planes, and the one health
|
||||
check that has to be right
|
||||
- [docs/node-onboarding.md](docs/node-onboarding.md),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# llmgw behind Caddy: the client-facing port (8080) on a public name, with a
|
||||
# certificate Caddy obtains and renews by itself.
|
||||
#
|
||||
# Replace llm.example.com, point its DNS at this machine, open 80 and 443, and
|
||||
# run: caddy run --config /etc/caddy/Caddyfile
|
||||
#
|
||||
# Only 8080 goes through here. 7443 (agents) is mutual TLS with the cluster's
|
||||
# own authority and must reach llmgw directly; 9090 and 9091 stay private.
|
||||
|
||||
llm.example.com {
|
||||
# The router reads at most 32 MiB of body (router.max_body_bytes). Refusing
|
||||
# more here gives the client a 413 before the upload, not after it.
|
||||
request_body {
|
||||
max_size 32MB
|
||||
}
|
||||
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
# Tokens are written the moment they are generated. Without this a
|
||||
# streamed reply can arrive in bursts, and a client measuring time to
|
||||
# first token measures the proxy instead.
|
||||
flush_interval -1
|
||||
|
||||
# Caddy sets X-Forwarded-Proto and X-Forwarded-Host itself and, with
|
||||
# no trusted_proxies, discards whatever the client sent. llmgw relies
|
||||
# on that: the proto decides whether session cookies are Secure, and
|
||||
# the host is the base URL the landing page tells people to use.
|
||||
# Behind a load balancer of your own, name it in trusted_proxies.
|
||||
|
||||
transport http {
|
||||
# A long generation can be silent for a while before the first
|
||||
# token. llmgw has its own limits (timeouts.*); this must not be
|
||||
# the shorter one.
|
||||
response_header_timeout 15m
|
||||
}
|
||||
}
|
||||
|
||||
# No `encode` on purpose: compressing a stream buffers it.
|
||||
|
||||
# Access log to stderr, which under systemd is the journal.
|
||||
log
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# A reverse proxy in front of llmgw
|
||||
|
||||
The client-facing port (8080) is plain HTTP. On anything but a trusted network,
|
||||
put it behind a proxy that holds a publicly trusted certificate.
|
||||
|
||||
| File | For |
|
||||
| --- | --- |
|
||||
| [Caddyfile](Caddyfile) | Caddy — obtains and renews its own certificate; the least work |
|
||||
| [nginx.conf](nginx.conf) | nginx, with a certificate from certbot |
|
||||
|
||||
Both are also attached to every release.
|
||||
|
||||
Four things both files get right, and any other proxy has to as well:
|
||||
|
||||
- **Streaming is not buffered.** Replies are server-sent events; a buffering
|
||||
proxy delivers them in one burst at the end.
|
||||
- **The read timeout is longer than a model's silence.** nginx's default of 60
|
||||
seconds turns a slow first token into a 504.
|
||||
- **`X-Forwarded-Proto` and `X-Forwarded-Host` are set by the proxy**, replacing
|
||||
whatever the client sent. llmgw uses the first to mark session cookies
|
||||
`Secure` and the second for the base URL on its landing page.
|
||||
- **The body limit matches llmgw's** (32 MiB, `router.max_body_bytes`), so an
|
||||
oversized request is refused before it is uploaded.
|
||||
|
||||
Only port 8080 is proxied. Agents reach 7443 directly — it is mutual TLS with
|
||||
the cluster's own authority, and a proxy terminating it would take away the
|
||||
node identity it carries. 9090 (admin, pprof) stays on loopback; 9091
|
||||
(metrics) goes to your monitoring host only.
|
||||
|
||||
With a proxy on the same machine, bind llmgw to loopback so nothing reaches it
|
||||
around the proxy:
|
||||
|
||||
```yaml
|
||||
# /etc/llmgw/server.yaml
|
||||
listen: "127.0.0.1:8080"
|
||||
```
|
||||
@@ -0,0 +1,63 @@
|
||||
# llmgw behind nginx: the client-facing port (8080) on a public name.
|
||||
#
|
||||
# Put this in /etc/nginx/sites-available/llmgw (or conf.d/llmgw.conf), replace
|
||||
# llm.example.com, and get a certificate — certbot --nginx -d llm.example.com
|
||||
# fills in the ssl_ lines below. Then: nginx -t && systemctl reload nginx
|
||||
#
|
||||
# Only 8080 goes through here. 7443 (agents) is mutual TLS with the cluster's
|
||||
# own authority and must reach llmgw directly; 9090 and 9091 stay private.
|
||||
|
||||
upstream llmgw {
|
||||
server 127.0.0.1:8080;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name llm.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
# `listen ... http2` rather than `http2 on;`: the latter needs nginx
|
||||
# 1.25.1, and Ubuntu 22.04 and Debian 12 ship older ones. Newer nginx
|
||||
# accepts this form with a deprecation warning.
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name llm.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/llm.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/llm.example.com/privkey.pem;
|
||||
|
||||
# The router reads at most 32 MiB of body (router.max_body_bytes).
|
||||
client_max_body_size 32m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://llmgw;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# Set, never appended: llmgw decides from X-Forwarded-Proto whether
|
||||
# session cookies are Secure, and builds the base URL the landing page
|
||||
# shows from X-Forwarded-Host. A value passed through from the client
|
||||
# would be the client's choice.
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
# Streamed replies: tokens go out as they are generated. Buffering
|
||||
# turns a stream into one late burst, and gzip buffers too.
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
gzip off;
|
||||
|
||||
# nginx's default is 60 seconds between two reads from upstream. A
|
||||
# large model can think longer than that before its first token, and
|
||||
# the client then sees a 504 for a request that was about to succeed.
|
||||
# llmgw has its own limits (timeouts.*); this must not be the shorter.
|
||||
proxy_read_timeout 15m;
|
||||
proxy_send_timeout 15m;
|
||||
}
|
||||
}
|
||||
+112
-128
@@ -29,7 +29,7 @@ The reason is boring and unavoidable: drivers and engines are platform decisions
|
||||
with kernel modules and version matrices attached.
|
||||
|
||||
Weights are the one item on that list llmgw does handle. It includes a mirror —
|
||||
step 4 — which holds the files on your own network and hands them to the nodes,
|
||||
step 3 — which holds the files on your own network and hands them to the nodes,
|
||||
so getting weights onto every machine is not a problem you solve before
|
||||
starting. What llmgw will not do is choose the models for you.
|
||||
|
||||
@@ -58,7 +58,8 @@ them, so nodes need no inbound access except your own SSH.
|
||||
| 8082 | the control plane administering the mirror | the control plane only — this port can delete models and start downloads |
|
||||
|
||||
Decide now which address nodes will use to reach the control plane. It goes into
|
||||
a certificate and changing it later means reissuing one.
|
||||
a certificate: the machine's own addresses are found for you, and any other name
|
||||
— a DNS alias, a NAT or floating address — has to be given with `--host`.
|
||||
|
||||
## 1. The binary
|
||||
|
||||
@@ -83,141 +84,110 @@ git clone https://gitea.mynodes.xyz/ilker/llmgw && cd llmgw
|
||||
CGO_ENABLED=0 go build -o llmgw ./cmd/llmgw
|
||||
```
|
||||
|
||||
## 2. The certificate authority
|
||||
## 2. The control plane
|
||||
|
||||
Agents authenticate to the control plane with mutual TLS, and a node's identity
|
||||
is taken from its certificate's common name. A public authority cannot supply
|
||||
these: Let's Encrypt and its peers stopped issuing certificates carrying the
|
||||
`clientAuth` usage, so nothing they hand out can authenticate an agent.
|
||||
|
||||
Run this wherever you intend to keep the authority. It talks to nothing and
|
||||
works on files, so it does not have to be a cluster machine — and there is an
|
||||
argument that it should not be.
|
||||
On the machine that will be the control plane, as root:
|
||||
|
||||
```
|
||||
sudo llmgw ctl cert init-ca --dir /etc/llmgw/pki
|
||||
sudo llmgw server init --admin you@example.com
|
||||
```
|
||||
|
||||
That writes `ca.pem` and `ca-key.pem`. **Whoever holds `ca-key.pem` can mint any
|
||||
node's identity**, so it stays here; `ca.pem` is copied to every machine and is
|
||||
not a secret.
|
||||
That is the whole step. In order, it:
|
||||
|
||||
Now the control plane's own certificate. Every address an agent might dial must
|
||||
appear in `--host`, bare IP addresses included — hostname verification reads
|
||||
subject alternative names and ignores the common name entirely, so a certificate
|
||||
naming the host only in its subject is rejected by every client:
|
||||
1. creates the `llmgw` system user, `/etc/llmgw` and `/var/lib/llmgw`
|
||||
2. creates the cluster's certificate authority in `/etc/llmgw/pki`
|
||||
3. issues the control plane's certificate for this machine's hostname and
|
||||
every address it has, and copies `ca.pem` beside it
|
||||
4. writes `/etc/llmgw/server.yaml`: mutual TLS, authentication, default limits
|
||||
and a price, and a control plane with an empty catalog
|
||||
5. creates the store, the `default` project, and your account, already approved
|
||||
and promoted to administrator — the password is generated and **printed
|
||||
once**
|
||||
6. installs the systemd unit, starts it, and waits until `/health` answers
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
**The addresses in the certificate matter.** Agents check that the address
|
||||
they dial is named in it, and the certificate names only what this machine
|
||||
could see of itself. If nodes will reach it by another name — a DNS alias, a
|
||||
NAT address, a floating IP — give it:
|
||||
|
||||
```
|
||||
sudo llmgw ctl cert issue-server llmgw-ctl \
|
||||
--host 10.0.0.140 --host llmgw-ctl.internal \
|
||||
--dir /etc/llmgw/pki --out /etc/llmgw
|
||||
sudo llmgw server init --host llmgw-ctl.internal --host 203.0.113.10
|
||||
```
|
||||
|
||||
Run that at any time: the certificate is reissued with the new names added to
|
||||
the old ones, and the service restarted.
|
||||
|
||||
**`ca-key.pem` signs every node's identity.** `init` leaves it in
|
||||
`/etc/llmgw/pki`, because onboarding a node needs it and the control plane is
|
||||
where most people run onboarding from. Back it up; there is no recovering it.
|
||||
If this machine is exposed, move the directory elsewhere once the nodes are in
|
||||
and pass `--pki` to onboarding.
|
||||
|
||||
This covers the fleet-facing port only. The client-facing API is public and
|
||||
belongs behind an ordinary publicly trusted certificate, usually terminated by a
|
||||
reverse proxy in front of port 8080.
|
||||
|
||||
## 3. The control plane
|
||||
|
||||
### The user and directories
|
||||
|
||||
```
|
||||
sudo useradd --system --home /var/lib/llmgw --shell /usr/sbin/nologin llmgw
|
||||
sudo install -d -o llmgw -g llmgw -m 0750 /var/lib/llmgw
|
||||
sudo install -d -m 0755 /etc/llmgw
|
||||
sudo chown root:llmgw /etc/llmgw/ca.pem /etc/llmgw/server.pem /etc/llmgw/server-key.pem
|
||||
sudo chmod 0640 /etc/llmgw/server-key.pem
|
||||
```
|
||||
|
||||
### The configuration
|
||||
|
||||
`/etc/llmgw/server.yaml`. This is the smallest file that is not a toy: mutual
|
||||
TLS on, authentication on, one model, and limits that apply to anyone nobody has
|
||||
given limits to.
|
||||
|
||||
```yaml
|
||||
listen: ":8080"
|
||||
admin_listen: "127.0.0.1:9090"
|
||||
agent_listen: ":7443"
|
||||
|
||||
agent_tls:
|
||||
ca: /etc/llmgw/ca.pem
|
||||
cert: /etc/llmgw/server.pem
|
||||
key: /etc/llmgw/server-key.pem
|
||||
|
||||
auth:
|
||||
enabled: true
|
||||
store: /var/lib/llmgw/llmgw.db
|
||||
signup_open: true
|
||||
# Applied to any key no scope gives a limit to. Without these, a project
|
||||
# created without limits is metered by nothing at all, and the first sign of
|
||||
# that is the bill or the outage.
|
||||
default_rpm: 60
|
||||
default_tpm: 200000
|
||||
# What a request costs, which is what makes a budget mean anything. Without
|
||||
# a price every request costs zero, so a budget is displayed, counted against
|
||||
# nothing, and never binds — and nobody finds out until they expected it to
|
||||
# stop something. Self-hosted models have no per-token price, so the unit is
|
||||
# yours to choose: GPU-seconds, a notional rate, or 1000 everywhere, which
|
||||
# turns a budget into a plain token quota.
|
||||
default_price:
|
||||
prompt_per_1k: 1000
|
||||
completion_per_1k: 1000
|
||||
# pricing:
|
||||
# qwen2.5-coder-7b-instruct: { prompt_per_1k: 500, completion_per_1k: 1500 }
|
||||
|
||||
landing:
|
||||
contact: "platform@example.com"
|
||||
|
||||
# Start with none. Models are added while the cluster runs — see step 8 —
|
||||
# and anything listed here is declarative: it is in the catalog on every start
|
||||
# and cannot be removed without editing this file.
|
||||
models: []
|
||||
```
|
||||
|
||||
`admin_listen` is on loopback on purpose. It serves pprof, which can dump the
|
||||
process heap, and API keys sit in that heap in plaintext. To see the dashboard
|
||||
from elsewhere, promote an account and use `/admin` on the client port — do not
|
||||
move this listener.
|
||||
|
||||
The store file is created on first start; there is no separate migration step.
|
||||
|
||||
### The service
|
||||
|
||||
```
|
||||
sudo cp deploy/systemd/llmgw-server.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now llmgw-server
|
||||
systemctl status llmgw-server
|
||||
```
|
||||
|
||||
### Your account
|
||||
|
||||
Accounts are created and approved from a shell on this machine, deliberately:
|
||||
having a shell here is what makes someone an administrator, and there are no
|
||||
roles to get subtly wrong.
|
||||
|
||||
```
|
||||
sudo -u llmgw llmgw users create you@example.com --password 'change me now'
|
||||
# prints a user id, usr-xxxxxxxx
|
||||
sudo -u llmgw llmgw users promote usr-xxxxxxxx
|
||||
```
|
||||
|
||||
`promote` is what grants `/admin`. Sign in at `http://<control-plane>:8080/login`,
|
||||
change the password, and issue yourself a key from the profile page.
|
||||
belongs behind an ordinary publicly trusted certificate, terminated by a
|
||||
reverse proxy in front of port 8080. [deploy/proxy/](../deploy/proxy/) has a
|
||||
Caddyfile and an nginx configuration that get streaming, timeouts and the
|
||||
forwarded headers right; both are attached to every release.
|
||||
|
||||
### Check it
|
||||
|
||||
```
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/health # 200, with an empty body
|
||||
sudo -u llmgw llmgw ctl status
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/health # 200
|
||||
llmgw ctl status
|
||||
```
|
||||
|
||||
`ctl status` will report no nodes and no models, which is correct — nothing has
|
||||
been added yet. It is worth running now so its output is familiar before you
|
||||
need it.
|
||||
been added yet. Sign in at `http://<control-plane>:8080/login` with the printed
|
||||
password, change it, and issue yourself a key from the profile page.
|
||||
|
||||
## 4. The mirror
|
||||
### The configuration
|
||||
|
||||
`/etc/llmgw/server.yaml` is yours from here on. Three things in it are worth
|
||||
knowing before you edit it:
|
||||
|
||||
- `admin_listen` is on loopback on purpose. It serves pprof, which can dump the
|
||||
process heap, and API keys sit in that heap in plaintext. To see the dashboard
|
||||
from elsewhere use `/admin` on the client port — do not move this listener.
|
||||
- `default_rpm`, `default_tpm` and `default_price` are what make limits and
|
||||
budgets mean anything. Without a price every request costs zero and a budget
|
||||
never binds; without the defaults, a project created without limits is
|
||||
metered by nothing at all.
|
||||
- `control.enabled: true` is what makes this a control plane while its catalog
|
||||
is empty. Without it, a file with no models is refused as having nothing to
|
||||
serve.
|
||||
|
||||
Every key is in [reference.md](reference.md).
|
||||
|
||||
### Keeping the authority off the control plane
|
||||
|
||||
Create it on another machine, issue the control plane's certificate there, and
|
||||
bring only the results:
|
||||
|
||||
```
|
||||
# on the authority's machine
|
||||
llmgw ctl cert init-ca --dir /etc/llmgw/pki
|
||||
llmgw ctl cert issue-server llmgw-ctl --host 10.0.0.140 --host llmgw-ctl.internal \
|
||||
--dir /etc/llmgw/pki --out ./ctl
|
||||
|
||||
# copy ca.pem, ctl/server.pem and ctl/server-key.pem into /etc/llmgw on the
|
||||
# control plane, then there:
|
||||
sudo llmgw server init --admin you@example.com
|
||||
```
|
||||
|
||||
`init` finds certificates in `/etc/llmgw` and no authority beside them, and
|
||||
keeps them as they are. Adding an address later is then done where the
|
||||
authority is; `init --host` says so rather than minting a second authority no
|
||||
node trusts.
|
||||
|
||||
## 3. The mirror
|
||||
|
||||
Do this before the nodes, not after. Every node fetches its own copy of a
|
||||
model's weights, and where they fetch from decides whether bringing up a cluster
|
||||
@@ -313,7 +283,7 @@ llmgw mirror fetch --model qwen2.5-14b-instruct --version 2026-03 \
|
||||
or from the admin page, or by copying files into the directory by hand — the
|
||||
mirror is a directory, and anything that puts files in it works.
|
||||
|
||||
## 5. Preparing a node
|
||||
## 4. Preparing a node
|
||||
|
||||
**Do this before onboarding.** A node missing its driver or engine registers
|
||||
healthy and fails on first placement, and the reason is three logs away.
|
||||
@@ -418,7 +388,7 @@ llmgw ctl engine-check vllm --bin /opt/venvs/vllm/bin/vllm
|
||||
|
||||
### Weights
|
||||
|
||||
Nothing to do here if you built the mirror in step 4 — the agent fetches what a
|
||||
Nothing to do here if you built the mirror in step 3 — the agent fetches what a
|
||||
model's `source` names, and that source is your mirror. This section is for the
|
||||
arrangements where the files are already on the machine.
|
||||
|
||||
@@ -441,7 +411,7 @@ model's `source` at a file to download and the agent fetches it once, before the
|
||||
engine is launched:
|
||||
|
||||
```yaml
|
||||
# your mirror, which is what step 4 was for
|
||||
# your mirror, which is what step 3 was for
|
||||
source: http://models.internal:8081/qwen2.5-coder-3b-instruct/2026-03/q4_k_m.gguf
|
||||
# a whole version, for a model of several files — the trailing slash is the signal
|
||||
source: http://models.internal:8081/llama-3.1-70b-instruct/2026-03/
|
||||
@@ -498,7 +468,7 @@ How much disk a node needs follows from the models rather than a rule of thumb.
|
||||
`llmgw ctl model plan <repo>` prints the sizes; as a starting point, a 70B at
|
||||
bf16 is 141 GB and the same model at q4 is 43 GB.
|
||||
|
||||
## 6. Adding the node
|
||||
## 5. Adding the node
|
||||
|
||||
Two halves, because they need different access: building the machine needs the
|
||||
hypervisor, and issuing its identity needs the certificate authority. Where one
|
||||
@@ -555,7 +525,7 @@ Repeat for each node. Nodes in the same fabric must be given the same `--fabric`
|
||||
name; nodes with no InfiniBand take no `--fabric` at all and are each their own
|
||||
island, which is fine for single-node models.
|
||||
|
||||
## 7. Setting the node's CPU model, if it is a VM
|
||||
## 6. Setting the node's CPU model, if it is a VM
|
||||
|
||||
Proxmox defaults a VM to `kvm64`, which hides AVX, AVX2, FMA and even SSE4.2
|
||||
from the guest. Inference then falls back to scalar code and is not slightly
|
||||
@@ -572,7 +542,7 @@ only change being `--cpu host`:
|
||||
probably did not, and nothing about it looks broken — every number is simply
|
||||
small. Check with `grep -qw avx2 /proc/cpuinfo` on the node.
|
||||
|
||||
## 8. Adding a model
|
||||
## 7. Adding a model
|
||||
|
||||
Write the definition. This is the same shape a model takes in `server.yaml`, so
|
||||
an entry moves between the two by being copied:
|
||||
@@ -714,7 +684,7 @@ An engine that publishes no metrics endpoint is measured from the outside only.
|
||||
The run says so, and queue depth and KV occupancy are then not part of the
|
||||
answer.
|
||||
|
||||
## 9. The first request
|
||||
## 8. The first request
|
||||
|
||||
```
|
||||
curl http://<control-plane>:8080/v1/chat/completions \
|
||||
@@ -777,6 +747,20 @@ turn, which is the first per-request load this store has had; and SQLite takes
|
||||
one writer at a time, so those turns serialise. And a second control plane is
|
||||
impossible while the store is a file on one machine's disk.
|
||||
|
||||
On the control plane, one command does all of it — installs PostgreSQL if the
|
||||
machine has none, creates its role and database with a generated password kept
|
||||
in `/etc/llmgw/secrets/store-dsn`, stops the gateway, copies the SQLite store
|
||||
into it so every account and key survives, points `server.yaml` at it, and
|
||||
starts the gateway again:
|
||||
|
||||
```
|
||||
sudo llmgw server init --postgres
|
||||
```
|
||||
|
||||
It can be given on the first run too, and then there is never a SQLite store.
|
||||
The rest of this section is what that command does, for a PostgreSQL somewhere
|
||||
else.
|
||||
|
||||
The setting is the same one, and the address decides:
|
||||
|
||||
```yaml
|
||||
@@ -957,7 +941,7 @@ the choice is yours rather than silent.
|
||||
|
||||
Three things, in descending order of how badly you want them.
|
||||
|
||||
**The certificate authority.** `ca-key.pem`, wherever you put it in step 2.
|
||||
**The certificate authority.** `ca-key.pem`, `/etc/llmgw/pki` unless you kept it elsewhere (step 2).
|
||||
Losing it does not stop the cluster, and it means every node identity has to be
|
||||
reissued the next time you touch one. Nothing in llmgw can reconstruct it.
|
||||
|
||||
@@ -1028,7 +1012,7 @@ output, which is the actual diagnosis.
|
||||
node and the reason: wrong GPU class, not enough free cards, too little memory,
|
||||
no fabric, a label it does not carry.
|
||||
|
||||
**Everything is slow.** On a VM, check AVX2 (step 7). On CPU, check the
|
||||
**Everything is slow.** On a VM, check AVX2 (step 6). On CPU, check the
|
||||
quantisation — the difference between `q8_0` and `q4_k_m` is roughly a factor of
|
||||
two. `llmgw ctl status` shows how full the cluster is.
|
||||
|
||||
@@ -1065,7 +1049,7 @@ requests. On a slow model, set `timeouts.generation` higher.
|
||||
those eight local, which is most of the problem; on a fabric where the nodes
|
||||
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 3) or every request costs zero, and a budget is something the dashboard
|
||||
(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
|
||||
where it can, which is not the same as being told before you rely on it.
|
||||
- **Engines are yours to install.** Onboarding verifies one runs; it installs
|
||||
|
||||
@@ -290,6 +290,14 @@ func (in *Installer) packages(ctx context.Context, byFamily map[string][]string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstallPackages installs whichever of these packages the machine lacks,
|
||||
// from its own package sources, waiting out another package manager's lock.
|
||||
// byFamily is keyed by discovery.Host.Family: "deb" or "rpm".
|
||||
func (in *Installer) InstallPackages(ctx context.Context, byFamily map[string][]string) error {
|
||||
var st Status
|
||||
return in.packages(ctx, byFamily, &st)
|
||||
}
|
||||
|
||||
// lockWait bounds how long a package command waits for another to finish.
|
||||
const lockWait = 10 * time.Minute
|
||||
|
||||
|
||||
@@ -350,6 +350,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())
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
|
||||
)
|
||||
|
||||
// serverUnit is deploy/systemd/llmgw-server.service. A test holds the two
|
||||
// together, because a machine set up by init and one set up from the docs
|
||||
// should not differ in a way nobody chose.
|
||||
const serverUnit = `[Unit]
|
||||
Description=llmgw control plane
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/llmgw server --config /etc/llmgw/server.yaml
|
||||
User=llmgw
|
||||
Group=llmgw
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
StateDirectory=llmgw
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
|
||||
// serverInit is everything a control plane needs before it can start, done in
|
||||
// the order it has to be done in. Every step looks before it acts, so running
|
||||
// it again finishes an install that stopped halfway and changes nothing on
|
||||
// one that did not.
|
||||
type serverInit struct {
|
||||
etcDir string // /etc/llmgw
|
||||
stateDir string // /var/lib/llmgw
|
||||
pkiDir string // where the authority lives
|
||||
unitPath string // "" leaves systemd alone
|
||||
|
||||
// user owns the state and reads the key; "" means no user is created and
|
||||
// nothing is chowned, which is what a test wants.
|
||||
user string
|
||||
hosts []string
|
||||
admin string
|
||||
// postgres keeps the store in a PostgreSQL on this machine, installed if
|
||||
// need be, moving whatever SQLite already holds.
|
||||
postgres bool
|
||||
// pgSetup prepares PostgreSQL and returns the store address; a seam for
|
||||
// tests. Nil does it for real.
|
||||
pgSetup func(context.Context) (string, error)
|
||||
|
||||
out io.Writer
|
||||
|
||||
uid, gid int
|
||||
changed bool
|
||||
}
|
||||
|
||||
func newServerInitCmd() *cobra.Command {
|
||||
in := serverInit{
|
||||
etcDir: "/etc/llmgw",
|
||||
stateDir: "/var/lib/llmgw",
|
||||
pkiDir: defaultPKIDir,
|
||||
unitPath: "/etc/systemd/system/llmgw-server.service",
|
||||
user: "llmgw",
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Set this machine up as a control plane and start it",
|
||||
Long: "Creates the llmgw user and its directories, a certificate authority, the\n" +
|
||||
"control plane's certificate for every address this machine has, a\n" +
|
||||
"configuration, an administrator account, and the systemd unit — then\n" +
|
||||
"starts it and waits until it answers.\n\n" +
|
||||
"With --postgres the store is a PostgreSQL database on this machine:\n" +
|
||||
"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" +
|
||||
"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" +
|
||||
"The authority's key stays in " + defaultPKIDir + " on this machine. Move it\n" +
|
||||
"somewhere safer once the nodes are onboarded if this machine is exposed.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if runtime.GOOS != "linux" {
|
||||
return fmt.Errorf("init sets up a Linux machine; this is %s", runtime.GOOS)
|
||||
}
|
||||
if os.Geteuid() != 0 {
|
||||
return errors.New("init creates a user, writes /etc and starts a service: run it as root")
|
||||
}
|
||||
in.out = cmd.OutOrStdout()
|
||||
return in.run(cmd.Context())
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringArrayVar(&in.hosts, "host", nil,
|
||||
"an address agents dial besides the ones found on this machine; repeatable")
|
||||
f.StringVar(&in.admin, "admin", "", "email of the first administrator; a password is generated and printed once")
|
||||
f.StringVar(&in.pkiDir, "pki", in.pkiDir, "directory holding the certificate authority")
|
||||
f.BoolVar(&in.postgres, "postgres", false,
|
||||
"keep the store in PostgreSQL on this machine, installing it and moving an existing SQLite store into it")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (in *serverInit) run(ctx context.Context) error {
|
||||
steps := []struct {
|
||||
name string
|
||||
fn func(context.Context) error
|
||||
}{
|
||||
{"user", in.ensureUser},
|
||||
{"directories", in.ensureDirs},
|
||||
{"certificates", in.ensureCerts},
|
||||
{"configuration", in.ensureConfig},
|
||||
{"database", in.ensureDatabase},
|
||||
{"store", in.ensureStore},
|
||||
}
|
||||
for _, s := range steps {
|
||||
if err := s.fn(ctx); err != nil {
|
||||
return fmt.Errorf("%s: %w", s.name, err)
|
||||
}
|
||||
}
|
||||
if in.unitPath == "" {
|
||||
return nil
|
||||
}
|
||||
if err := in.ensureService(ctx); err != nil {
|
||||
return fmt.Errorf("service: %w", err)
|
||||
}
|
||||
in.nextSteps()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (in *serverInit) done(format string, a ...any) {
|
||||
fmt.Fprintf(in.out, " ok "+format+"\n", a...)
|
||||
in.changed = true
|
||||
}
|
||||
|
||||
func (in *serverInit) kept(format string, a ...any) {
|
||||
fmt.Fprintf(in.out, " kept "+format+"\n", a...)
|
||||
}
|
||||
|
||||
func (in *serverInit) ensureUser(context.Context) error {
|
||||
if in.user == "" {
|
||||
return nil
|
||||
}
|
||||
u, err := user.Lookup(in.user)
|
||||
if err != nil {
|
||||
var unknown user.UnknownUserError
|
||||
if !errors.As(err, &unknown) {
|
||||
return err
|
||||
}
|
||||
out, err := exec.Command("useradd", "--system", "--home", in.stateDir,
|
||||
"--shell", "/usr/sbin/nologin", in.user).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("useradd: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
if u, err = user.Lookup(in.user); err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("system user %s", in.user)
|
||||
} else {
|
||||
in.kept("system user %s", in.user)
|
||||
}
|
||||
in.uid, _ = strconv.Atoi(u.Uid)
|
||||
in.gid, _ = strconv.Atoi(u.Gid)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (in *serverInit) ensureDirs(context.Context) error {
|
||||
for _, d := range []struct {
|
||||
path string
|
||||
mode os.FileMode
|
||||
owned bool
|
||||
}{
|
||||
{in.etcDir, 0o755, false},
|
||||
{in.pkiDir, 0o700, false},
|
||||
{in.stateDir, 0o750, true},
|
||||
} {
|
||||
if err := os.MkdirAll(d.path, d.mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if d.owned {
|
||||
if err := in.chown(d.path, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// chown gives a path to the service user, or only its group when the user
|
||||
// should read and not write.
|
||||
func (in *serverInit) chown(path string, own bool) error {
|
||||
if in.user == "" {
|
||||
return nil
|
||||
}
|
||||
uid := 0
|
||||
if own {
|
||||
uid = in.uid
|
||||
}
|
||||
return os.Chown(path, uid, in.gid)
|
||||
}
|
||||
|
||||
func (in *serverInit) ensureCerts(context.Context) error {
|
||||
certPath := filepath.Join(in.etcDir, "server.pem")
|
||||
_, keyErr := os.Stat(filepath.Join(in.pkiDir, "ca-key.pem"))
|
||||
_, caErr := os.Stat(filepath.Join(in.etcDir, "ca.pem"))
|
||||
_, certErr := os.Stat(certPath)
|
||||
if keyErr != nil && caErr == nil && certErr == nil {
|
||||
// Issued by an authority kept elsewhere, which is the arrangement for
|
||||
// anyone who will not have the CA key on the control plane. Making a
|
||||
// new authority here would replace their certificate with one no node
|
||||
// trusts.
|
||||
if len(in.hosts) > 0 {
|
||||
return fmt.Errorf("%s was issued by an authority that is not in %s; "+
|
||||
"reissue it there with the new --host", certPath, in.pkiDir)
|
||||
}
|
||||
in.kept("certificates in %s, issued elsewhere", in.etcDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
ca *tlsconf.CA
|
||||
err error
|
||||
)
|
||||
if keyErr == nil {
|
||||
if ca, err = loadCA(in.pkiDir); err != nil {
|
||||
return err
|
||||
}
|
||||
in.kept("certificate authority in %s", in.pkiDir)
|
||||
} else {
|
||||
if ca, err = tlsconf.NewCA("llmgw cluster CA"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ca.Write(in.pkiDir); err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("certificate authority in %s (ca-key.pem signs every node: back it up)", in.pkiDir)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(in.etcDir, "ca.pem"), ca.PEM, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
want := in.certHosts()
|
||||
if cert, err := readCert(certPath); err == nil {
|
||||
have := strings.Split(sanNames(cert), ", ")
|
||||
var missing []string
|
||||
for _, h := range want {
|
||||
if !slices.Contains(have, h) {
|
||||
missing = append(missing, h)
|
||||
}
|
||||
}
|
||||
// A certificate from this authority naming every address is left
|
||||
// alone. One naming fewer is reissued with the union, so an address
|
||||
// that worked yesterday is never dropped by adding one today.
|
||||
if len(missing) == 0 && cert.CheckSignatureFrom(ca.Cert) == nil {
|
||||
in.kept("server certificate for %s", strings.Join(have, ", "))
|
||||
return nil
|
||||
}
|
||||
for _, h := range have {
|
||||
if h != "" && !slices.Contains(want, h) {
|
||||
want = append(want, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
name, _ := os.Hostname()
|
||||
if name == "" {
|
||||
name = "llmgw-control"
|
||||
}
|
||||
leaf, err := ca.IssueServer(name, want)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := leaf.Write(in.etcDir, "server"); err != nil {
|
||||
return err
|
||||
}
|
||||
// The service runs as llmgw and must read its key; nothing else should.
|
||||
keyPath := filepath.Join(in.etcDir, "server-key.pem")
|
||||
if err := in.chown(keyPath, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.user != "" {
|
||||
if err := os.Chmod(keyPath, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
in.done("server certificate for %s", strings.Join(want, ", "))
|
||||
return nil
|
||||
}
|
||||
|
||||
// certHosts is every address an agent could plausibly dial: the hostname and
|
||||
// each address on this machine, plus whatever --host added. Asking the
|
||||
// operator to list them is how a certificate ends up naming the one address
|
||||
// nobody uses, and that is found out by the first node refusing it.
|
||||
func (in *serverInit) certHosts() []string {
|
||||
var out []string
|
||||
add := func(h string) {
|
||||
if h != "" && !slices.Contains(out, h) {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
for _, h := range in.hosts {
|
||||
add(h)
|
||||
}
|
||||
if name, err := os.Hostname(); err == nil {
|
||||
add(name)
|
||||
}
|
||||
for _, ip := range localAddrs() {
|
||||
add(ip.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// localAddrs are this machine's addresses another machine could reach:
|
||||
// loopback and link-local are left out.
|
||||
func localAddrs() []net.IP {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []net.IP
|
||||
for _, a := range addrs {
|
||||
n, ok := a.(*net.IPNet)
|
||||
if !ok || !n.IP.IsGlobalUnicast() {
|
||||
continue
|
||||
}
|
||||
out = append(out, n.IP)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (in *serverInit) configPath() string { return filepath.Join(in.etcDir, "server.yaml") }
|
||||
|
||||
func (in *serverInit) ensureConfig(context.Context) error {
|
||||
path := in.configPath()
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
in.kept("%s (never overwritten)", path)
|
||||
} else {
|
||||
contact := in.admin
|
||||
if contact == "" {
|
||||
contact = "platform@example.com"
|
||||
}
|
||||
body := fmt.Sprintf(serverConfigTemplate, in.etcDir, in.etcDir, in.etcDir,
|
||||
in.storeAddress(), contact)
|
||||
if err := os.WriteFile(path, []byte(body), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := in.chown(path, false); err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("%s", path)
|
||||
}
|
||||
|
||||
// Checked whether written now or years ago: a configuration that will not
|
||||
// load is better found here than as a unit restarting every two seconds.
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// serverConfigTemplate is the smallest file that is not a toy: mutual TLS,
|
||||
// authentication, default limits and a price, and a control plane with an
|
||||
// empty catalog.
|
||||
const serverConfigTemplate = `# Written by 'llmgw server init'. Every key: docs/reference.md
|
||||
listen: ":8080"
|
||||
admin_listen: "127.0.0.1:9090" # serves pprof, which can dump API keys: keep it local
|
||||
agent_listen: ":7443"
|
||||
|
||||
agent_tls:
|
||||
ca: %s/ca.pem
|
||||
cert: %s/server.pem
|
||||
key: %s/server-key.pem
|
||||
|
||||
# A control plane with nothing in its catalog yet. Models are added while it
|
||||
# runs, with 'llmgw ctl model add' or from /admin/mirror.
|
||||
control:
|
||||
enabled: true
|
||||
|
||||
auth:
|
||||
enabled: true
|
||||
store: "%s" # a SQLite path, or @file holding a PostgreSQL address
|
||||
signup_open: true
|
||||
# Applied to any key no scope gives a limit to. Without them, a project
|
||||
# created without limits is metered by nothing at all.
|
||||
default_rpm: 60
|
||||
default_tpm: 200000
|
||||
# Without a price every request costs zero and no budget ever binds. 1000
|
||||
# everywhere turns a budget into a plain token quota.
|
||||
default_price:
|
||||
prompt_per_1k: 1000
|
||||
completion_per_1k: 1000
|
||||
|
||||
landing:
|
||||
contact: "%s"
|
||||
|
||||
# The engines every node installs for itself. Omitted, each gets the llama.cpp
|
||||
# build this release was checked against.
|
||||
# engines:
|
||||
# llamacpp: b10991
|
||||
|
||||
# A model mirror, once there is one (docs/install.md, step 3):
|
||||
# mirror:
|
||||
# api: https://models.internal:8082
|
||||
# tls: { ca: /etc/llmgw/ca.pem, cert: /etc/llmgw/server.pem, key: /etc/llmgw/server-key.pem }
|
||||
`
|
||||
|
||||
func (in *serverInit) ensureStore(ctx context.Context) error {
|
||||
cfg, err := config.Load(in.configPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
address := cfg.Auth.Store
|
||||
if address == "" {
|
||||
address = filepath.Join(in.stateDir, "llmgw.db")
|
||||
}
|
||||
s, err := store.Open(ctx, address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening %s: %w", address, err)
|
||||
}
|
||||
// Opened as root, so a SQLite file created just now belongs to root and
|
||||
// the service could not write it. Given back before anything else runs.
|
||||
defer in.chownSQLite(address)
|
||||
defer s.Close()
|
||||
|
||||
// The tree an approved account hangs off. Approving into a project that
|
||||
// does not exist succeeds, and the first key that person makes does not.
|
||||
if err := s.PutOrg(ctx, store.Org{ID: "default", Name: "default"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.PutTeam(ctx, store.Team{ID: "default", OrgID: "default", Name: "default"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.PutProject(ctx, store.Project{ID: "default", TeamID: "default", Name: "default"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if in.admin == "" {
|
||||
in.kept("store at %s (no --admin: create an account with 'llmgw users create')", address)
|
||||
return nil
|
||||
}
|
||||
if u, err := s.UserByEmail(ctx, in.admin); err == nil {
|
||||
if !u.IsAdmin {
|
||||
if err := s.SetAdmin(ctx, u.ID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
in.kept("administrator %s (%s); its password is unchanged", in.admin, u.ID)
|
||||
return nil
|
||||
} else if !errors.Is(err, store.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
password := randomPassword()
|
||||
hash, err := account.HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := randomID("usr")
|
||||
now := time.Now()
|
||||
if err := s.CreateUser(ctx, store.User{
|
||||
ID: id, Email: in.admin, Name: in.admin,
|
||||
PasswordHash: hash, State: store.UserPending, CreatedAt: now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ApproveUser(ctx, id, "default", "init", now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.SetAdmin(ctx, id, true); err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("administrator %s (%s)", in.admin, id)
|
||||
fmt.Fprintf(in.out, "\n password: %s\n shown once — sign in and change it from /profile\n\n", password)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (in *serverInit) chownSQLite(address string) {
|
||||
if strings.Contains(address, "://") || strings.HasPrefix(address, "@") {
|
||||
return
|
||||
}
|
||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||
if _, err := os.Stat(address + suffix); err == nil {
|
||||
_ = in.chown(address+suffix, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func randomPassword() string {
|
||||
var buf [15]byte
|
||||
_, _ = rand.Read(buf[:])
|
||||
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf[:]))
|
||||
}
|
||||
|
||||
func (in *serverInit) ensureService(ctx context.Context) error {
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
fmt.Fprintf(in.out, " skip no systemd here; start it with:\n"+
|
||||
" sudo -u %s llmgw server --config %s\n", in.user, in.configPath())
|
||||
return nil
|
||||
}
|
||||
if b, err := os.ReadFile(in.unitPath); err == nil && string(b) == serverUnit {
|
||||
in.kept("%s", in.unitPath)
|
||||
} else {
|
||||
if err == nil {
|
||||
// Somebody's edits. Kept beside it, because the unit is also where
|
||||
// people put environment overrides and those are not ours to lose.
|
||||
_ = os.WriteFile(in.unitPath+".bak", b, 0o644)
|
||||
}
|
||||
if err := os.WriteFile(in.unitPath, []byte(serverUnit), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("%s", in.unitPath)
|
||||
}
|
||||
|
||||
systemctl := func(args ...string) error {
|
||||
out, err := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := systemctl("daemon-reload"); err != nil {
|
||||
return err
|
||||
}
|
||||
active := exec.CommandContext(ctx, "systemctl", "is-active", "--quiet", "llmgw-server").Run() == nil
|
||||
switch {
|
||||
case !active:
|
||||
if err := systemctl("enable", "--now", "llmgw-server"); err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("llmgw-server started")
|
||||
case in.changed:
|
||||
// A certificate or configuration it has not read is one it is not
|
||||
// using, and the difference shows up as a node that cannot connect.
|
||||
if err := systemctl("restart", "llmgw-server"); err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("llmgw-server restarted to pick up the changes")
|
||||
default:
|
||||
in.kept("llmgw-server running")
|
||||
}
|
||||
return in.waitHealthy(ctx)
|
||||
}
|
||||
|
||||
func (in *serverInit) waitHealthy(ctx context.Context) error {
|
||||
cfg, err := config.Load(in.configPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, port, err := net.SplitHostPort(cfg.Listen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %q: %w", cfg.Listen, err)
|
||||
}
|
||||
url := "http://127.0.0.1:" + port + "/health"
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
in.kept("answering on %s", url)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("not answering on %s after 30s; 'journalctl -u llmgw-server -n 50' says why", url)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (in *serverInit) nextSteps() {
|
||||
server := "https://<this machine>:7443"
|
||||
if ips := localAddrs(); len(ips) > 0 {
|
||||
server = "https://" + net.JoinHostPort(ips[0].String(), "7443")
|
||||
}
|
||||
fmt.Fprintf(in.out, `
|
||||
The control plane is up. Next:
|
||||
|
||||
llmgw ctl status # no nodes, no models: correct for now
|
||||
|
||||
# each node, from this machine (it holds the authority):
|
||||
deploy/bootstrap/onboard-node.sh --node <name> --addr <node ip> --server %s
|
||||
|
||||
# then a model:
|
||||
llmgw ctl model add -f model.yaml
|
||||
|
||||
The web UI is on port 8080; /admin once you are signed in as the administrator.
|
||||
`, server)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// pgRole and pgDatabase are what init creates in a PostgreSQL it manages.
|
||||
const (
|
||||
pgRole = "llmgw"
|
||||
pgDatabase = "llmgw"
|
||||
)
|
||||
|
||||
// dsnPath is where the PostgreSQL address is kept, out of server.yaml, so the
|
||||
// password in it is not in a file that gets copied, diffed and pasted.
|
||||
func (in *serverInit) dsnPath() string { return filepath.Join(in.etcDir, "secrets", "store-dsn") }
|
||||
|
||||
// storeAddress is what auth.store says for the store init manages.
|
||||
func (in *serverInit) storeAddress() string {
|
||||
if in.postgres {
|
||||
return "@" + in.dsnPath()
|
||||
}
|
||||
return filepath.Join(in.stateDir, "llmgw.db")
|
||||
}
|
||||
|
||||
// ensureDatabase puts the store in PostgreSQL when asked to, moving whatever
|
||||
// the SQLite store already holds.
|
||||
//
|
||||
// The move is the point. Keys are stored hashed, so starting PostgreSQL empty
|
||||
// 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 !in.postgres {
|
||||
return nil
|
||||
}
|
||||
setup := in.pgSetup
|
||||
if setup == nil {
|
||||
setup = in.setupPostgres
|
||||
}
|
||||
target, err := setup(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := config.Load(in.configPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current := cfg.Auth.Store
|
||||
if current == target {
|
||||
in.kept("store in PostgreSQL (%s)", in.dsnPath())
|
||||
return nil
|
||||
}
|
||||
if isPostgresAddress(current) {
|
||||
// Two PostgreSQL stores, and a guess about which is meant would lose
|
||||
// one of them.
|
||||
return fmt.Errorf("server.yaml already names a PostgreSQL store (%s); "+
|
||||
"init moves SQLite into PostgreSQL, not one PostgreSQL into another", current)
|
||||
}
|
||||
|
||||
// Nothing may write the store while it is copied: a row landing after its
|
||||
// table has been passed would be absent, and nothing would say so.
|
||||
stopped, err := in.stopService(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
// A move that failed leaves the configuration naming SQLite, which
|
||||
// still holds everything: start the service on it again rather than
|
||||
// leave the gateway down over a database that did not work out.
|
||||
if err != nil && stopped {
|
||||
_ = exec.Command("systemctl", "start", "llmgw-server").Run()
|
||||
fmt.Fprintf(in.out, " .. llmgw-server started again on its SQLite store\n")
|
||||
}
|
||||
}()
|
||||
|
||||
if current != "" {
|
||||
if _, err := os.Stat(current); err == nil {
|
||||
if err := in.copyStore(ctx, current, target); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := setAuthStore(in.configPath(), target); err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("server.yaml now keeps the store in PostgreSQL")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (in *serverInit) copyStore(ctx context.Context, from, to string) error {
|
||||
src, err := store.Open(ctx, from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening %s: %w", from, err)
|
||||
}
|
||||
defer src.Close()
|
||||
dst, err := store.Open(ctx, to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening PostgreSQL: %w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
empty, err := dst.Empty(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !empty {
|
||||
// A previous run that copied and then stopped before changing the
|
||||
// configuration. The service was stopped for that copy too, so what
|
||||
// is there is what SQLite held.
|
||||
in.kept("PostgreSQL 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 %s into PostgreSQL: %w", from, err)
|
||||
}
|
||||
var rows int64
|
||||
for _, r := range res {
|
||||
rows += r.Rows
|
||||
}
|
||||
in.done("copied %d rows from %s into PostgreSQL (the SQLite file is kept)", rows, from)
|
||||
return nil
|
||||
}
|
||||
|
||||
func isPostgresAddress(a string) bool {
|
||||
return strings.HasPrefix(a, "@") || strings.HasPrefix(a, "postgres://") ||
|
||||
strings.HasPrefix(a, "postgresql://")
|
||||
}
|
||||
|
||||
// setAuthStore changes auth.store in server.yaml and nothing else.
|
||||
//
|
||||
// Edited as text rather than unmarshalled and written back, because writing
|
||||
// it back would drop every comment in a file people annotate. The old file is
|
||||
// kept beside it.
|
||||
func setAuthStore(path, address string) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lines := strings.Split(string(b), "\n")
|
||||
value := fmt.Sprintf("%q", address)
|
||||
|
||||
authAt, storeAt, indent := -1, -1, " "
|
||||
for i, l := range lines {
|
||||
trimmed := strings.TrimSpace(l)
|
||||
top := l != "" && l[0] != ' ' && l[0] != '\t' && !strings.HasPrefix(trimmed, "#")
|
||||
if top {
|
||||
if authAt >= 0 && i > authAt {
|
||||
break // past the auth block
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "auth:") {
|
||||
authAt = i
|
||||
}
|
||||
continue
|
||||
}
|
||||
if authAt >= 0 && strings.HasPrefix(trimmed, "store:") {
|
||||
storeAt = i
|
||||
indent = l[:len(l)-len(strings.TrimLeft(l, " \t"))]
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case storeAt >= 0:
|
||||
lines[storeAt] = indent + "store: " + value
|
||||
case authAt >= 0:
|
||||
lines = append(lines[:authAt+1], append([]string{indent + "store: " + value}, lines[authAt+1:]...)...)
|
||||
default:
|
||||
lines = append(lines, "auth:", " enabled: true", " store: "+value)
|
||||
}
|
||||
|
||||
backup := fmt.Sprintf("%s.bak-%s", path, time.Now().Format("20060102-150405"))
|
||||
if err := os.WriteFile(backup, b, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), info.Mode().Perm())
|
||||
}
|
||||
|
||||
// setupPostgres makes sure a PostgreSQL on this machine holds a database for
|
||||
// llmgw, installing the server if there is none, and returns the store
|
||||
// address.
|
||||
func (in *serverInit) setupPostgres(ctx context.Context) (string, error) {
|
||||
installed, err := in.installPostgres(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := waitForPostgres(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
exists, err := psqlBool(ctx, "SELECT 1 FROM pg_roles WHERE rolname = '"+pgRole+"'")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, statErr := os.Stat(in.dsnPath())
|
||||
if exists && statErr == nil {
|
||||
in.kept("PostgreSQL role %s", pgRole)
|
||||
} else {
|
||||
// A role with no address written down is one whose password nobody
|
||||
// knows; giving it a new one is the only way to use it.
|
||||
password := make([]byte, 24)
|
||||
_, _ = rand.Read(password)
|
||||
pass := hex.EncodeToString(password)
|
||||
verb := "CREATE ROLE"
|
||||
if exists {
|
||||
verb = "ALTER ROLE"
|
||||
}
|
||||
if _, err := psql(ctx, verb+" "+pgRole+" WITH LOGIN PASSWORD '"+pass+"'"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dsn := fmt.Sprintf("postgres://%s:%s@127.0.0.1:5432/%s?sslmode=disable", pgRole, pass, pgDatabase)
|
||||
if err := in.writeDSN(dsn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
in.done("PostgreSQL role %s, its address in %s", pgRole, in.dsnPath())
|
||||
}
|
||||
|
||||
exists, err = psqlBool(ctx, "SELECT 1 FROM pg_database WHERE datname = '"+pgDatabase+"'")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exists {
|
||||
in.kept("PostgreSQL database %s", pgDatabase)
|
||||
} else {
|
||||
if _, err := psql(ctx, "CREATE DATABASE "+pgDatabase+" OWNER "+pgRole); err != nil {
|
||||
return "", err
|
||||
}
|
||||
in.done("PostgreSQL database %s", pgDatabase)
|
||||
}
|
||||
|
||||
if installed {
|
||||
// Its defaults assume it owns the machine. A gateway swapped out to
|
||||
// give the database cache is worse than a smaller cache, and a store
|
||||
// whose working set is megabytes needs little. Only a PostgreSQL init
|
||||
// installed itself: one that was already here may serve something
|
||||
// else that needs its connections.
|
||||
for _, q := range []string{
|
||||
"ALTER SYSTEM SET shared_buffers = '96MB'",
|
||||
"ALTER SYSTEM SET max_connections = 40",
|
||||
} {
|
||||
if _, err := psql(ctx, q); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if out, err := exec.CommandContext(ctx, "systemctl", "restart", "postgresql").CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("restarting PostgreSQL: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
if err := waitForPostgres(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
in.done("PostgreSQL sized for a machine it shares (shared_buffers 96MB, 40 connections)")
|
||||
}
|
||||
|
||||
// Proof the address works, before anything is moved into it.
|
||||
address := "@" + in.dsnPath()
|
||||
s, err := store.Open(ctx, address)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connecting with %s: %w", in.dsnPath(), err)
|
||||
}
|
||||
_ = s.Close()
|
||||
return address, nil
|
||||
}
|
||||
|
||||
func (in *serverInit) writeDSN(dsn string) error {
|
||||
dir := filepath.Dir(in.dsnPath())
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := in.chown(dir, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(in.dsnPath(), []byte(dsn+"\n"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
// Read by the service, which runs as llmgw; root reads it anyway.
|
||||
return in.chown(in.dsnPath(), true)
|
||||
}
|
||||
|
||||
// installPostgres installs the server when this machine has none, and says
|
||||
// whether it did.
|
||||
func (in *serverInit) installPostgres(ctx context.Context) (bool, error) {
|
||||
if exec.CommandContext(ctx, "systemctl", "cat", "postgresql.service").Run() == nil {
|
||||
in.kept("PostgreSQL server")
|
||||
return false, nil
|
||||
}
|
||||
host := discovery.DiscoverHost()
|
||||
family := host.Family()
|
||||
if family == "" {
|
||||
return false, fmt.Errorf("no PostgreSQL here and no package manager init knows (%s); "+
|
||||
"install PostgreSQL and run init again", host.OSID)
|
||||
}
|
||||
fmt.Fprintf(in.out, " .. installing PostgreSQL (a minute or two)\n")
|
||||
pi := &provision.Installer{Host: host, Log: slog.Default()}
|
||||
if err := pi.InstallPackages(ctx, map[string][]string{
|
||||
"deb": {"postgresql"},
|
||||
"rpm": {"postgresql-server", "postgresql"},
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if family == "rpm" {
|
||||
// Debian's package creates and starts a cluster; RHEL's leaves an
|
||||
// empty data directory. Password authentication over TCP is asked
|
||||
// for, because RHEL's default for it is ident, which no service
|
||||
// account passes.
|
||||
if _, err := os.Stat("/var/lib/pgsql/data/PG_VERSION"); err != nil {
|
||||
cmd := exec.CommandContext(ctx, "postgresql-setup", "--initdb")
|
||||
cmd.Env = append(os.Environ(), "PGSETUP_INITDB_OPTIONS=--auth-host=scram-sha-256 --auth-local=peer")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return false, fmt.Errorf("postgresql-setup --initdb: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
}
|
||||
}
|
||||
if out, err := exec.CommandContext(ctx, "systemctl", "enable", "--now", "postgresql").CombinedOutput(); err != nil {
|
||||
return false, fmt.Errorf("starting PostgreSQL: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
in.done("PostgreSQL installed and started")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// psql runs one statement as the postgres superuser, over the local socket.
|
||||
func psql(ctx context.Context, query string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "runuser", "-u", "postgres", "--",
|
||||
"psql", "-v", "ON_ERROR_STOP=1", "-qAtX", "-c", query)
|
||||
// postgres cannot enter root's home, and psql says so on every call.
|
||||
cmd.Dir = "/"
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// The statement can hold a password; the error names only its verb.
|
||||
verb := strings.Join(strings.Fields(query)[:2], " ")
|
||||
return "", fmt.Errorf("psql (%s): %v: %s", verb, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func psqlBool(ctx context.Context, query string) (bool, error) {
|
||||
out, err := psql(ctx, query)
|
||||
return out == "1", err
|
||||
}
|
||||
|
||||
// waitForPostgres waits for a server that was just started or restarted to
|
||||
// take connections.
|
||||
func waitForPostgres(ctx context.Context) error {
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
for {
|
||||
_, err := psql(ctx, "SELECT 1")
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("PostgreSQL is not taking connections after 60s: %w", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stopService stops the control plane if it runs, so the store is still.
|
||||
// It says whether it stopped it, so a failed move can start it again.
|
||||
func (in *serverInit) stopService(ctx context.Context) (bool, error) {
|
||||
if in.unitPath == "" {
|
||||
return false, nil
|
||||
}
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
if exec.CommandContext(ctx, "systemctl", "is-active", "--quiet", "llmgw-server").Run() != nil {
|
||||
return false, nil
|
||||
}
|
||||
if out, err := exec.CommandContext(ctx, "systemctl", "stop", "llmgw-server").CombinedOutput(); err != nil {
|
||||
return false, fmt.Errorf("stopping llmgw-server to move its store: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
in.done("llmgw-server stopped while its store moves")
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func TestSetAuthStoreChangesOnlyAuthStore(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "server.yaml")
|
||||
body := `# my notes
|
||||
threads:
|
||||
store: /var/lib/llmgw/threads.db # not this one
|
||||
retain_hours: 24
|
||||
auth:
|
||||
enabled: true
|
||||
# the store
|
||||
store: /var/lib/llmgw/llmgw.db
|
||||
signup_open: true
|
||||
landing:
|
||||
contact: "x"
|
||||
`
|
||||
if err := os.WriteFile(p, []byte(body), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setAuthStore(p, "@/etc/llmgw/secrets/store-dsn"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := os.ReadFile(p)
|
||||
want := strings.Replace(body, " store: /var/lib/llmgw/llmgw.db",
|
||||
` store: "@/etc/llmgw/secrets/store-dsn"`, 1)
|
||||
if string(got) != want {
|
||||
t.Errorf("got:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
cfg, err := config.Load(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Auth.Store != "@/etc/llmgw/secrets/store-dsn" || cfg.Threads.Store != "/var/lib/llmgw/threads.db" {
|
||||
t.Errorf("auth.store %q, threads.store %q", cfg.Auth.Store, cfg.Threads.Store)
|
||||
}
|
||||
backups, _ := filepath.Glob(p + ".bak-*")
|
||||
if len(backups) != 1 {
|
||||
t.Errorf("the old file should be kept beside it, found %v", backups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAuthStoreAddsTheLineWhenAuthHasNone(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "server.yaml")
|
||||
if err := os.WriteFile(p, []byte("auth:\n enabled: true\nlanding:\n contact: x\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setAuthStore(p, "@/x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Auth.Store != "@/x" || !cfg.Auth.Enabled {
|
||||
t.Errorf("auth = %+v", cfg.Auth)
|
||||
}
|
||||
}
|
||||
|
||||
// fakePostgres stands in for setupPostgres the way it really answers: an
|
||||
// address file under /etc/llmgw/secrets, returned as "@file". What the file
|
||||
// holds is a SQLite path, so everything after the setup runs for real.
|
||||
func fakePostgres(t *testing.T, in *serverInit) (address, db string) {
|
||||
t.Helper()
|
||||
db = filepath.Join(t.TempDir(), "pg-standin.db")
|
||||
in.pgSetup = func(context.Context) (string, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(in.dsnPath()), 0o750); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "@" + in.dsnPath(), os.WriteFile(in.dsnPath(), []byte(db+"\n"), 0o600)
|
||||
}
|
||||
return "@" + in.dsnPath(), db
|
||||
}
|
||||
|
||||
// Moving an install that began on SQLite: the administrator it created must
|
||||
// be in the new store, the configuration must name it, and the SQLite file
|
||||
// must still be there. The new store is SQLite too here — what PostgreSQL
|
||||
// adds is the setup, which needs a machine to run on.
|
||||
func TestInitMovesAnExistingStore(t *testing.T) {
|
||||
in, out := newTestInit(t)
|
||||
if err := in.run(context.Background()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
old := filepath.Join(in.stateDir, "llmgw.db")
|
||||
|
||||
in.postgres = true
|
||||
target, db := fakePostgres(t, in)
|
||||
out.Reset()
|
||||
if err := in.run(context.Background()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out.String(), "copied") {
|
||||
t.Errorf("nothing was copied:\n%s", out)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(in.configPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Auth.Store != target {
|
||||
t.Fatalf("server.yaml still names %q", cfg.Auth.Store)
|
||||
}
|
||||
s, err := store.Open(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, err := s.UserByEmail(context.Background(), "admin@example.com")
|
||||
s.Close()
|
||||
if err != nil || !u.IsAdmin {
|
||||
t.Fatalf("the administrator did not move: %v %+v", err, u)
|
||||
}
|
||||
if _, err := os.Stat(old); err != nil {
|
||||
t.Error("the SQLite store should be kept")
|
||||
}
|
||||
|
||||
// And once moved, again is nothing.
|
||||
in.changed = false
|
||||
out.Reset()
|
||||
if err := in.run(context.Background()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if in.changed {
|
||||
t.Errorf("a run after the move changed something:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitWritesAPostgresConfigurationFromTheStart(t *testing.T) {
|
||||
in, out := newTestInit(t)
|
||||
in.postgres = true
|
||||
target, _ := fakePostgres(t, in)
|
||||
if err := in.run(context.Background()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
cfg, _ := config.Load(in.configPath())
|
||||
if cfg.Auth.Store != target {
|
||||
t.Errorf("auth.store = %q", cfg.Auth.Store)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(in.stateDir, "llmgw.db")); err == nil {
|
||||
t.Error("a PostgreSQL install should not leave a SQLite store behind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitWillNotMoveOnePostgresIntoAnother(t *testing.T) {
|
||||
in, out := newTestInit(t)
|
||||
if err := os.MkdirAll(in.etcDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := "control:\n enabled: true\nauth:\n enabled: true\n store: postgres://elsewhere/llmgw\n"
|
||||
if err := os.WriteFile(in.configPath(), []byte(cfg), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.postgres = true
|
||||
in.pgSetup = func(context.Context) (string, error) { return "@/somewhere/else", nil }
|
||||
err := in.run(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "already names a PostgreSQL store") {
|
||||
t.Fatalf("expected a refusal, got %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func newTestInit(t *testing.T) (*serverInit, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
var out bytes.Buffer
|
||||
return &serverInit{
|
||||
etcDir: filepath.Join(root, "etc"),
|
||||
stateDir: filepath.Join(root, "state"),
|
||||
pkiDir: filepath.Join(root, "etc", "pki"),
|
||||
admin: "admin@example.com",
|
||||
out: &out,
|
||||
}, &out
|
||||
}
|
||||
|
||||
func TestInitLeavesAControlPlaneThatStarts(t *testing.T) {
|
||||
in, out := newTestInit(t)
|
||||
if err := in.run(context.Background()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(in.configPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The configuration install.md used to show had an empty catalog and no
|
||||
// control.enabled, which the server refuses as having nothing to serve.
|
||||
if !cfg.ControlPlaneMode() {
|
||||
t.Error("the written configuration does not run a control plane")
|
||||
}
|
||||
if !cfg.AgentTLS.Enabled() || !cfg.Auth.Enabled {
|
||||
t.Error("mutual TLS and authentication should both be on")
|
||||
}
|
||||
for _, f := range []string{"ca.pem", "server.pem", "server-key.pem"} {
|
||||
if _, err := os.Stat(filepath.Join(in.etcDir, f)); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
s, err := store.Open(context.Background(), cfg.Auth.Store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
u, err := s.UserByEmail(context.Background(), "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !u.IsAdmin || u.State != store.UserActive || u.ProjectID != "default" {
|
||||
t.Errorf("administrator not ready to use: %+v", u)
|
||||
}
|
||||
if !strings.Contains(out.String(), "password: ") {
|
||||
t.Error("the generated password was not shown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitAgainChangesNothing(t *testing.T) {
|
||||
in, out := newTestInit(t)
|
||||
if err := in.run(context.Background()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
cert, _ := os.ReadFile(filepath.Join(in.etcDir, "server.pem"))
|
||||
|
||||
again := *in
|
||||
again.changed = false
|
||||
out.Reset()
|
||||
if err := again.run(context.Background()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if again.changed {
|
||||
t.Errorf("a second run changed something:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out.String(), "password: ") {
|
||||
t.Error("a second run must not reset the administrator's password")
|
||||
}
|
||||
if now, _ := os.ReadFile(filepath.Join(in.etcDir, "server.pem")); !bytes.Equal(now, cert) {
|
||||
t.Error("the certificate was reissued without reason")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitWithANewHostKeepsTheOldOnes(t *testing.T) {
|
||||
in, out := newTestInit(t)
|
||||
in.hosts = []string{"old.internal"}
|
||||
if err := in.run(context.Background()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
in.hosts = []string{"new.internal"}
|
||||
if err := in.run(context.Background()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
cert, err := readCert(filepath.Join(in.etcDir, "server.pem"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, h := range []string{"old.internal", "new.internal"} {
|
||||
if !slices.Contains(cert.DNSNames, h) {
|
||||
t.Errorf("certificate lost %s: %v", h, cert.DNSNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitNeverOverwritesTheConfiguration(t *testing.T) {
|
||||
in, out := newTestInit(t)
|
||||
if err := os.MkdirAll(in.etcDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mine := "# mine\ncontrol:\n enabled: true\nauth:\n enabled: true\n store: " +
|
||||
filepath.Join(in.stateDir, "mine.db") + "\n"
|
||||
if err := os.WriteFile(in.configPath(), []byte(mine), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := in.run(context.Background()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if got, _ := os.ReadFile(in.configPath()); string(got) != mine {
|
||||
t.Error("an existing server.yaml was rewritten")
|
||||
}
|
||||
// And the store it names is the one used.
|
||||
if _, err := os.Stat(filepath.Join(in.stateDir, "mine.db")); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheInitUnitIsTheShippedUnit(t *testing.T) {
|
||||
b, err := os.ReadFile("../../deploy/systemd/llmgw-server.service")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != serverUnit {
|
||||
t.Error("deploy/systemd/llmgw-server.service and the unit 'server init' writes have drifted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitKeepsCertificatesFromAnAuthorityElsewhere(t *testing.T) {
|
||||
in, out := newTestInit(t)
|
||||
// Issued on another machine by an authority that never comes here.
|
||||
elsewhere := &serverInit{etcDir: in.etcDir, pkiDir: filepath.Join(t.TempDir(), "pki"), out: &bytes.Buffer{}}
|
||||
if err := os.MkdirAll(in.etcDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := elsewhere.ensureCerts(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := os.ReadFile(filepath.Join(in.etcDir, "server.pem"))
|
||||
|
||||
if err := in.run(context.Background()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if after, _ := os.ReadFile(filepath.Join(in.etcDir, "server.pem")); !bytes.Equal(before, after) {
|
||||
t.Error("a certificate from another authority was replaced")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(in.pkiDir, "ca-key.pem")); err == nil {
|
||||
t.Error("an authority was created beside one that already exists elsewhere")
|
||||
}
|
||||
|
||||
in.hosts = []string{"new.internal"}
|
||||
if err := in.run(context.Background()); err == nil {
|
||||
t.Error("--host cannot be honoured without the authority, and should say so")
|
||||
}
|
||||
}
|
||||
@@ -540,8 +540,14 @@ func (s Server) Catalog() (*catalog.Catalog, error) {
|
||||
// A model with no profiles cannot be placed, so a configuration of bare names
|
||||
// plus a static endpoint list is the router-only mode used for testing against
|
||||
// sim-engines.
|
||||
//
|
||||
// With no static endpoints there is nothing a router-only process could serve,
|
||||
// so a configuration naming neither endpoints nor models is a control plane
|
||||
// with an empty catalog — which is what every new cluster is. It used to be
|
||||
// refused as having nothing to serve, and under systemd that is a unit
|
||||
// restarting every two seconds with nothing on 8080.
|
||||
func (s Server) ControlPlaneMode() bool {
|
||||
if s.Control.Enabled {
|
||||
if s.Control.Enabled || len(s.Endpoints) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, m := range s.Models {
|
||||
|
||||
@@ -155,19 +155,26 @@ func TestValidateAcceptsDistinctAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAControlPlaneCanBeAskedForWithNoModelsInTheFile(t *testing.T) {
|
||||
// Without this, a deployment whose catalog lives entirely in the database
|
||||
// cannot start: the mode is inferred from the file, an empty models block
|
||||
// reads as "router only", and the process refuses to start for want of
|
||||
// anything to serve — so the stored models it would have loaded are never
|
||||
// read at all.
|
||||
func TestAnEmptyConfigurationIsAControlPlane(t *testing.T) {
|
||||
// A new cluster has no models and no endpoints. Refusing it as having
|
||||
// nothing to serve left a systemd unit restarting every two seconds and
|
||||
// nothing listening on 8080 — on the first start anyone ever made.
|
||||
var s Server
|
||||
if !s.ControlPlaneMode() {
|
||||
t.Fatal("a configuration with no endpoints and no models should run a control plane")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticEndpointsAloneAreRouterOnly(t *testing.T) {
|
||||
var s Server
|
||||
s.Endpoints = []Endpoint{{ID: "e", URL: "http://127.0.0.1:1"}}
|
||||
s.Models = []catalog.Model{{Name: "m"}}
|
||||
if s.ControlPlaneMode() {
|
||||
t.Fatal("an empty configuration is not a control plane")
|
||||
t.Error("static endpoints and bare model names are the router-only mode")
|
||||
}
|
||||
s.Control.Enabled = true
|
||||
if !s.ControlPlaneMode() {
|
||||
t.Error("control.enabled should run the control plane with no models declared")
|
||||
t.Error("control.enabled should run the control plane alongside static endpoints")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,13 @@ func (s *DB) CopyTo(ctx context.Context, dst *DB) ([]CopyResult, error) {
|
||||
}
|
||||
|
||||
// isEmpty reports whether any table has rows, and which one first does.
|
||||
// Empty reports whether the store holds no rows at all, which is what CopyTo
|
||||
// needs of its destination.
|
||||
func (s *DB) Empty(ctx context.Context) (bool, error) {
|
||||
empty, _, err := s.isEmpty(ctx)
|
||||
return empty, err
|
||||
}
|
||||
|
||||
func (s *DB) isEmpty(ctx context.Context) (bool, string, error) {
|
||||
for _, table := range copyOrder {
|
||||
var n int64
|
||||
|
||||
Reference in new issue
Block a user