Sync the self-hosting stack
This commit is contained in:
@@ -9,6 +9,65 @@ one of them changed. An entry says which artefact and which version, so `web 0.1
|
|||||||
Every version published to a channel appears here. Nothing is written from memory afterwards: a build
|
Every version published to a channel appears here. Nothing is written from memory afterwards: a build
|
||||||
cannot be published without its entry.
|
cannot be published without its entry.
|
||||||
|
|
||||||
|
## 2026-10-21 — Jarvis runs on .NET `api 1.0.0` `web 1.0.0`
|
||||||
|
|
||||||
|
**The api and the console are rewritten on .NET** — the api on ASP.NET Core, the console in Blazor
|
||||||
|
WebAssembly, served by nginx as before. The images keep their names and their channels. Your data,
|
||||||
|
your users with their passkeys and authenticator codes, your vault and your agents carry over, and
|
||||||
|
what your instance reports is the same, field for field. Read this one before you pull.
|
||||||
|
|
||||||
|
**Before you upgrade**
|
||||||
|
|
||||||
|
- **Be on api 0.151.0 first** — the last TypeScript release — and let it start once. 1.0.0 refuses a
|
||||||
|
database that release has not migrated, changing nothing: its log names the release to install,
|
||||||
|
and it restarts into the same refusal. Pin `JARVIS_IMAGE_API` to 0.151.0, `docker compose up -d`,
|
||||||
|
wait until it serves, then remove the pin.
|
||||||
|
- **Take a dump**, as before any upgrade (README, "Upgrading").
|
||||||
|
- **If you set `DATABASE_URL` yourself**, check its parameters against the README's list: one the
|
||||||
|
api does not read now stops it, naming it, where it used to be ignored; and `sslmode=verify-ca`
|
||||||
|
and `verify-full` now verify the certificate.
|
||||||
|
- **If your reverse proxy adds a Content-Security-Policy**, it must allow `'wasm-unsafe-eval'` and
|
||||||
|
inline scripts, or the console stays blank (README, "Your reverse proxy has three requirements").
|
||||||
|
One that sets none needs nothing.
|
||||||
|
|
||||||
|
**The upgrade**
|
||||||
|
|
||||||
|
- `docker compose pull && docker compose up -d`, as always. If you pin, move `JARVIS_IMAGE_API` and
|
||||||
|
`JARVIS_IMAGE_WEB` together. Nothing to add to `.env`.
|
||||||
|
- **Take the new `docker-compose.yml`**: two lines changed, `init` runs `jarvis init-secrets` and the
|
||||||
|
api's healthcheck `jarvis healthcheck`. Your current file keeps working — the image still answers
|
||||||
|
the three `node` commands the published files give it, and nothing else — until a later release
|
||||||
|
removes them, with `/usr/local/bin/migrate.sh` and `/usr/local/bin/entrypoint.sh`; its entry will
|
||||||
|
say so.
|
||||||
|
- One api restart. Its first start records where the schema stands and changes nothing else. Open
|
||||||
|
tabs lose live updates until reloaded; agents reconnect by themselves and need no update.
|
||||||
|
- **On Kubernetes**, pin both digests in one change, with the new manifests: `01-secret-job.yaml`
|
||||||
|
runs `jarvis mint-k8s-secret` (and no longer sets `NODE_EXTRA_CA_CERTS`), `03-migrate-job.yaml`
|
||||||
|
runs `jarvis migrate`, and `04-api.yaml` recreates the api for this release — every old pod stops
|
||||||
|
before a new one starts, a gap of about thirty seconds, once — and rolls it again from the next.
|
||||||
|
Overlays of your own that call `/usr/local/bin/migrate.sh` keep working until that release.
|
||||||
|
|
||||||
|
**What changes for you**
|
||||||
|
|
||||||
|
- **The console's first load is heavier**: about 6.2 MB on a first visit (it was under 1 MB), cached
|
||||||
|
afterwards; an update downloads what changed, about 1.7 MB.
|
||||||
|
- **The locked-out recipe**: `printf '%s\n' '<new password>' | docker compose exec -T api jarvis
|
||||||
|
hash-password` (README).
|
||||||
|
- **The console's page sends a Content-Security-Policy** that allows its own scripts and its two
|
||||||
|
inline ones, nothing else.
|
||||||
|
- **The api's log is one line per entry, in UTC**, its levels `info:`, `warn:`, `fail:` and
|
||||||
|
`crit:` — search for `fail:` and `crit:`, not `error`.
|
||||||
|
- **SSH host keys are trusted on first use.** A connection's first successful SSH session records the
|
||||||
|
key that answered; a later one presenting another key is refused before a credential is sent,
|
||||||
|
naming both fingerprints. Saving the connection again trusts a reinstalled server's new key.
|
||||||
|
|
||||||
|
**The way back**, until the first release after 1.0.0 that changes the schema (its entry will say
|
||||||
|
so): pin both previous images and set `SCHEMA_ACCEPT_DATA_LOSS=true` for one start — the TypeScript
|
||||||
|
api drops the table 1.0.0 added, `__EFMigrationsHistory`, and nothing else; read its log — or
|
||||||
|
`JARVIS_SKIP_MIGRATIONS=true`. After that release, the dump is the way back.
|
||||||
|
|
||||||
|
Update the API and web images together. No agent update is required.
|
||||||
|
|
||||||
## 2026-10-21 — The last TypeScript release `api 0.151.0` `web 0.185.0`
|
## 2026-10-21 — The last TypeScript release `api 0.151.0` `web 0.185.0`
|
||||||
|
|
||||||
**The last release of the TypeScript api and console, and the one to be on before api 1.0.0** —
|
**The last release of the TypeScript api and console, and the one to be on before api 1.0.0** —
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ target and the API is never reachable from outside the compose network.
|
|||||||
|
|
||||||
| Service | What it is |
|
| Service | What it is |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `web` | nginx serving the React console. The only published port. |
|
| `web` | nginx serving the console — .NET, running in the browser as WebAssembly. The only published port. |
|
||||||
| `api` | REST, auth, the vault, the connectors, the tool-calling loop, and the websocket. |
|
| `api` | REST, auth, the vault, the connectors, the tool-calling loop, and the websocket. |
|
||||||
| `postgres` | Everything except the vault master key. |
|
| `postgres` | Everything except the vault master key. |
|
||||||
| `redis` | Socket fan-out, nonces and rate-limit counters. |
|
| `redis` | Socket fan-out, nonces and rate-limit counters. |
|
||||||
@@ -217,7 +217,7 @@ docker compose up -d
|
|||||||
```
|
```
|
||||||
|
|
||||||
That is the whole first deployment. **There is no `.env` step.** First boot generates the stack's
|
That is the whole first deployment. **There is no `.env` step.** First boot generates the stack's
|
||||||
secrets, syncs the database schema and runs its data backfills before the API listens, so give it
|
secrets, applies the database schema and runs its data backfills before the API listens, so give it
|
||||||
about a minute. Then point your reverse proxy at port 8080 and open the address in a browser.
|
about a minute. Then point your reverse proxy at port 8080 and open the address in a browser.
|
||||||
|
|
||||||
An install screen takes it from there. It welcomes you, creates the first administrator, then asks
|
An install screen takes it from there. It welcomes you, creates the first administrator, then asks
|
||||||
@@ -293,6 +293,24 @@ adopted into the volume rather than replaced, your `WEB_ORIGIN` becomes the stor
|
|||||||
your model settings are adopted the same way, and an instance that already has an administrator is
|
your model settings are adopted the same way, and an instance that already has an administrator is
|
||||||
marked installed by a backfill — so you will never see the install screen. Nothing to do.
|
marked installed by a backfill — so you will never see the install screen. Nothing to do.
|
||||||
|
|
||||||
|
**Your own Postgres.** `DATABASE_URL` in `.env` replaces the stack's: `postgresql://user:password@host:5432/database`,
|
||||||
|
then any of these parameters, read as earlier releases read them: `schema`; `sslmode` (`disable`,
|
||||||
|
`allow`, `prefer`, `require`, `verify-ca`, `verify-full`); `sslaccept` (`strict` or
|
||||||
|
`accept_invalid_certs`); `sslcert` or `sslrootcert`, the certificate to trust, and `sslidentity` with
|
||||||
|
`sslpassword`, a client certificate in PKCS#12 — each an absolute path inside the container; `host`,
|
||||||
|
a Unix socket's directory; `connection_limit`, `connect_timeout`, `max_connection_lifetime`,
|
||||||
|
`max_idle_connection_lifetime`; `application_name`, `channel_binding`, `options`. `pgbouncer`,
|
||||||
|
`pool_timeout`, `statement_cache_size` and `socket_timeout` are accepted and change nothing. Any other
|
||||||
|
parameter stops the api with `DATABASE_URL: unsupported parameter "<name>"` rather than being
|
||||||
|
ignored.
|
||||||
|
|
||||||
|
`sslmode=require` encrypts without checking the server's certificate, as it always has here;
|
||||||
|
`sslaccept=strict` checks it and the host name. `verify-ca` and `verify-full` check it too — before
|
||||||
|
api 1.0.0 they were read as `prefer` — so a URL that carried one may now refuse a certificate the
|
||||||
|
container cannot verify: mount the CA and name it with `sslrootcert`. The migration holds a session
|
||||||
|
lock, so it needs a direct connection to Postgres, not PgBouncer in transaction mode; `pgbouncer=true`
|
||||||
|
in the URL is warned about.
|
||||||
|
|
||||||
## Your reverse proxy has three requirements
|
## Your reverse proxy has three requirements
|
||||||
|
|
||||||
All three are the kind that produce confusing symptoms rather than clean errors.
|
All three are the kind that produce confusing symptoms rather than clean errors.
|
||||||
@@ -323,6 +341,15 @@ All three are the kind that produce confusing symptoms rather than clean errors.
|
|||||||
until somebody attaches a big screenshot or PDF. No other request body Jarvis accepts is larger
|
until somebody attaches a big screenshot or PDF. No other request body Jarvis accepts is larger
|
||||||
than 2 MB, so the higher ceiling can be given to that path alone.
|
than 2 MB, so the higher ceiling can be given to that path alone.
|
||||||
|
|
||||||
|
**If your proxy adds a Content-Security-Policy**, it is enforced beside the one the web container
|
||||||
|
sends on the console's page — a browser applies both. The console is .NET running in WebAssembly, so
|
||||||
|
your policy's `script-src` must allow `'wasm-unsafe-eval'`, and the page's two inline scripts as well:
|
||||||
|
the theme, and the import map without which nothing loads. Their hashes change with every build,
|
||||||
|
which in practice means `'unsafe-inline'` in your policy. Simpler: send no policy of your own on the
|
||||||
|
console's pages, and let the container's — which allows exactly those two scripts and nothing else
|
||||||
|
inline — do the work. A policy that misses either leaves a blank page, with the refusal in the
|
||||||
|
browser's console.
|
||||||
|
|
||||||
Forward `X-Forwarded-For` too. How many proxies rewrite it is a setting, and the install screen is
|
Forward `X-Forwarded-For` too. How many proxies rewrite it is a setting, and the install screen is
|
||||||
where you answer it — **it shows you the chain your own request actually carried and the address the
|
where you answer it — **it shows you the chain your own request actually carried and the address the
|
||||||
API resolved from it**, so you confirm what you see rather than counting hops from memory. That is
|
API resolved from it**, so you confirm what you see rather than counting hops from memory. That is
|
||||||
@@ -347,19 +374,56 @@ follow their own upstream tags, `postgres:16-alpine` and `redis:7-alpine`. Schem
|
|||||||
themselves when the api starts, and the api and the web are versioned independently — their numbers
|
themselves when the api starts, and the api and the web are versioned independently — their numbers
|
||||||
are not meant to match, because usually only one side changed.
|
are not meant to match, because usually only one side changed.
|
||||||
|
|
||||||
**A schema change is one-way.** Jarvis has no migration history: each boot syncs the database to
|
**A schema change is one-way.** Each api image carries its database migrations, and the database
|
||||||
the schema its image carries, adding what a release added. A release that would DROP a table or a
|
records which ones it has (the `__EFMigrationsHistory` table). The api applies the ones it has not
|
||||||
column is refused instead: the schema is not touched, the api logs which columns or tables it would
|
seen before it serves, under a Postgres advisory lock, so two containers never migrate at once. A
|
||||||
drop and stays down, restarting into the same refusal. If those drops are intended, set
|
migration that would DROP a table or a column is refused instead: the schema is not touched, the api
|
||||||
`SCHEMA_ACCEPT_DATA_LOSS=true` in `.env`, `docker compose up -d` once, then remove the line. Pulling
|
logs which tables or columns it would drop and stays down, restarting into the same refusal. If those
|
||||||
an older api image does not undo a change already applied — unlike the image itself, the schema
|
drops are intended, set `SCHEMA_ACCEPT_DATA_LOSS=true` in `.env`, `docker compose up -d` once, then
|
||||||
stays where the newer build left it. Restoring a dump is the only way back, which is what makes the
|
remove the line. Pulling an older api image does not take a migration back — the schema stays where
|
||||||
one below a prerequisite rather than a precaution.
|
the newer build left it. Restoring a dump is the only way back, which is what makes the one below a
|
||||||
|
prerequisite rather than a precaution.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose exec -T postgres pg_dump -U jarvis jarvis | gzip > jarvis-$(date +%F).sql.gz
|
docker compose exec -T postgres pg_dump -U jarvis jarvis | gzip > jarvis-$(date +%F).sql.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Upgrading from the TypeScript releases (api 0.x)
|
||||||
|
|
||||||
|
api 1.0.0 and web 1.0.0 are Jarvis rewritten on .NET. The images keep their names and their channels,
|
||||||
|
so the upgrade is the usual one, with four things to know first.
|
||||||
|
|
||||||
|
- **Be on api 0.151.0 first** — the last 0.x release — and let it start once. A database it has not
|
||||||
|
migrated is refused by 1.0.0 without a change: the api logs `[migrate] This database was built by
|
||||||
|
the TypeScript API, but not by its last release`, the release to install and what is missing, and
|
||||||
|
restarts into the same refusal. Pin `JARVIS_IMAGE_API=git.luxit.be/luxit/jarvis-api:0.151.0` in
|
||||||
|
`.env`, `docker compose up -d`, wait until it serves, remove the pin and `docker compose up -d`
|
||||||
|
again.
|
||||||
|
- **Move the api and the web together** if you pin: `JARVIS_IMAGE_API` and `JARVIS_IMAGE_WEB` both.
|
||||||
|
A 1.x api behind a 0.x console, or the reverse, works without live updates.
|
||||||
|
- **Take the new `docker-compose.yml`.** Two lines changed: `init` runs `jarvis init-secrets` and the
|
||||||
|
api's healthcheck `jarvis healthcheck`. Your current file keeps working — the 1.x api image still
|
||||||
|
answers the three `node` commands the 0.x files give it, and refuses any other — until a later
|
||||||
|
release removes them, with `/usr/local/bin/migrate.sh` and `/usr/local/bin/entrypoint.sh`; its
|
||||||
|
CHANGELOG entry will say so.
|
||||||
|
- **Nothing to add to `.env`.** Every variable keeps its name and its meaning, `NODE_ENV` included.
|
||||||
|
|
||||||
|
The first 1.x start records where the schema stands (it creates `__EFMigrationsHistory` and changes
|
||||||
|
nothing else) and takes seconds. Open tabs lose live updates until they are reloaded; agents
|
||||||
|
reconnect by themselves.
|
||||||
|
|
||||||
|
**On Kubernetes**, pin both digests in one change, with the new manifests: `01-secret-job.yaml` runs
|
||||||
|
`jarvis mint-k8s-secret` (and no longer sets `NODE_EXTRA_CA_CERTS`), `03-migrate-job.yaml` runs
|
||||||
|
`jarvis migrate`, and `04-api.yaml` recreates the api for this release — every old pod stops before
|
||||||
|
a new one starts, a gap of about thirty seconds, once — and rolls it again from the next. Overlays of
|
||||||
|
your own that call `/usr/local/bin/migrate.sh` keep working until that release.
|
||||||
|
|
||||||
|
**The way back**, until the first release after 1.0.0 whose entry says it changes the schema: pin
|
||||||
|
both previous images and either set `SCHEMA_ACCEPT_DATA_LOSS=true` for one start — the 0.x api then
|
||||||
|
drops `__EFMigrationsHistory` and nothing else; its log names what it drops, and anything else in
|
||||||
|
that list means: stop, and restore — or set `JARVIS_SKIP_MIGRATIONS=true` until you upgrade again.
|
||||||
|
After that release, the dump is the way back.
|
||||||
|
|
||||||
### Channels
|
### Channels
|
||||||
|
|
||||||
| Channel | What it means |
|
| Channel | What it means |
|
||||||
@@ -512,8 +576,10 @@ These are deliberate and documented rather than surprises waiting to be found.
|
|||||||
init service writes a new one. Doing that to `jwt-access-secret` or `jwt-refresh-secret` signs
|
init service writes a new one. Doing that to `jwt-access-secret` or `jwt-refresh-secret` signs
|
||||||
everybody out, which is usually the point; doing it to `postgres-password` needs the database's own
|
everybody out, which is usually the point; doing it to `postgres-password` needs the database's own
|
||||||
password changed to match, so plan that one.
|
password changed to match, so plan that one.
|
||||||
- **SSH host keys are not verified.** Every SSH connection trusts whatever key answers. This is the
|
- **SSH host keys are trusted on first use.** A connection's first successful SSH session records
|
||||||
one gap in the execution path with no compensating control.
|
the key that answered; every later one must present the same key or is refused before a credential
|
||||||
|
is sent, naming both fingerprints. Saving the connection again is how you trust a reinstalled
|
||||||
|
server's new key. The first session itself is unverified, as with OpenSSH's `known_hosts`.
|
||||||
- **This compose file runs one api, and that is the shape it is written for.** It gives you one of
|
- **This compose file runs one api, and that is the shape it is written for.** It gives you one of
|
||||||
everything and generates the secrets onto a shared volume, which is right here and is exactly what
|
everything and generates the secrets onto a shared volume, which is right here and is exactly what
|
||||||
must not be done per replica anywhere else — each copy would mint its own vault key, and a
|
must not be done per replica anywhere else — each copy would mint its own vault key, and a
|
||||||
@@ -521,8 +587,8 @@ These are deliberate and documented rather than surprises waiting to be found.
|
|||||||
|
|
||||||
The images themselves no longer require a single replica. The web container's upstream and DNS
|
The images themselves no longer require a single replica. The web container's upstream and DNS
|
||||||
resolver are configurable, there are separate liveness and readiness endpoints, both containers run
|
resolver are configurable, there are separate liveness and readiness endpoints, both containers run
|
||||||
unprivileged, and the schema is a separate entrypoint meant to run once rather than something every
|
unprivileged, and the schema is a separate command (`jarvis migrate`) meant to run once rather than
|
||||||
container does at boot. Everything that used to do real damage across replicas is fixed: a starting
|
something every container does at boot. Everything that used to do real damage across replicas is fixed: a starting
|
||||||
replica's recovery sweep once claimed every run it did not own, so a second instance re-executed —
|
replica's recovery sweep once claimed every run it did not own, so a second instance re-executed —
|
||||||
against your real infrastructure — operations the first was still running. A run now holds its
|
against your real infrastructure — operations the first was still running. A run now holds its
|
||||||
conversation through a unique index in the database, a sweep claims only a run whose heartbeat has
|
conversation through a unique index in the database, a sweep claims only a run whose heartbeat has
|
||||||
@@ -562,7 +628,7 @@ These are deliberate and documented rather than surprises waiting to be found.
|
|||||||
request. The exception is changing your own password: it revokes every *other* session and keeps
|
request. The exception is changing your own password: it revokes every *other* session and keeps
|
||||||
the one you are changing it from, so a token stolen from that session stays valid until it expires
|
the one you are changing it from, so a token stolen from that session stays valid until it expires
|
||||||
— 15 minutes by default (`JWT_ACCESS_TTL`). An admin-forced reset drops every session.
|
— 15 minutes by default (`JWT_ACCESS_TTL`). An admin-forced reset drops every session.
|
||||||
- **Neither container runs as root**: the api runs as `node` (uid 1000) and the web's nginx as
|
- **Neither container runs as root**: the api runs as `jarvis` (uid 1000) and the web's nginx as
|
||||||
`nginx`, listening on 8080. Secret files mounted into them must be readable by that user.
|
`nginx`, listening on 8080. Secret files mounted into them must be readable by that user.
|
||||||
- **`/api/health` answers 200 with `status: "degraded"`** when the database is unreachable, so the
|
- **`/api/health` answers 200 with `status: "degraded"`** when the database is unreachable, so the
|
||||||
container healthcheck alone is not a liveness signal for the database.
|
container healthcheck alone is not a liveness signal for the database.
|
||||||
@@ -630,9 +696,12 @@ wrong on the screen that asks for it.
|
|||||||
| Everything works but one kind of thing cannot be created | A limit is full — the community edition's, or your licence's. The message names which. See below. |
|
| Everything works but one kind of thing cannot be created | A limit is full — the community edition's, or your licence's. The message names which. See below. |
|
||||||
| An agent installer answers 503 | No agent build is published — the overlay is off. That is a supported state. |
|
| An agent installer answers 503 | No agent build is published — the overlay is off. That is a supported state. |
|
||||||
| The remote terminal works and remote desktop never connects | The proxy forwards `/api/agents/ws` but not the paths under it. The media socket is `/api/agents/ws/desktop` and needs the upgrade too. |
|
| The remote terminal works and remote desktop never connects | The proxy forwards `/api/agents/ws` but not the paths under it. The media socket is `/api/agents/ws/desktop` and needs the upgrade too. |
|
||||||
|
| api restarts in a loop, logs `This database was built by the TypeScript API, but not by its last release` | The upgrade skipped a release. Install the one it names, let it start, then this one — see "Upgrading from the TypeScript releases". |
|
||||||
|
| api restarts in a loop, logs `DATABASE_URL: unsupported parameter` | A parameter the api does not read. The list it does read is under "Your own Postgres". |
|
||||||
|
| The console stays blank behind your proxy; the browser's console names a Content Security Policy | Your proxy's policy refuses the console's scripts — see "Your reverse proxy has three requirements". |
|
||||||
| A remote desktop session opens and ends immediately | Almost always the notice. It ends by design if the banner cannot be kept on top, if the person refuses, or if nobody answers the request within the minute. The end reason on the session says which. |
|
| A remote desktop session opens and ends immediately | Almost always the notice. It ends by design if the banner cannot be kept on top, if the person refuses, or if nobody answers the request within the minute. The end reason on the session says which. |
|
||||||
|
|
||||||
The api takes about a minute on first boot, syncing the schema before it listens. `docker compose ps`
|
The api takes about a minute on first boot, applying the schema before it listens. `docker compose ps`
|
||||||
showing `health: starting` for that long is expected, not a fault.
|
showing `health: starting` for that long is expected, not a fault.
|
||||||
|
|
||||||
### When you are locked out of the only administrator account
|
### When you are locked out of the only administrator account
|
||||||
@@ -655,15 +724,16 @@ docker compose exec -T postgres psql -U jarvis -d jarvis \
|
|||||||
They then reset the locked-out password under **Admin Center → Users**.
|
They then reset the locked-out password under **Admin Center → Users**.
|
||||||
|
|
||||||
**If no account can sign in at all**, set a password hash directly. Jarvis stores argon2id, and the
|
**If no account can sign in at all**, set a password hash directly. Jarvis stores argon2id, and the
|
||||||
api image carries the library that makes one — so the hash is generated by the same code that will
|
api image hashes one with the same code that will check it — the password on standard input, never
|
||||||
check it:
|
on the command line:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose exec api node -e \
|
printf '%s\n' 'the-new-password-at-least-12-characters' | docker compose exec -T api jarvis hash-password
|
||||||
"const a=require('argon2');a.hash(process.argv[1],{type:a.argon2id}).then(h=>console.log(h))" \
|
|
||||||
'the-new-password-at-least-12-characters'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
At a terminal, `docker compose exec api jarvis hash-password` asks for it without echoing it. Either
|
||||||
|
way it prints the `$argon2id$…` string and nothing else.
|
||||||
|
|
||||||
Then write it, and make sure the account is active and privileged:
|
Then write it, and make sure the account is active and privileged:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+11
-15
@@ -23,9 +23,8 @@
|
|||||||
#
|
#
|
||||||
# UPGRADING: `docker compose pull && docker compose up -d`. Every Jarvis image here tracks `stable` by
|
# UPGRADING: `docker compose pull && docker compose up -d`. Every Jarvis image here tracks `stable` by
|
||||||
# default, so that is the whole upgrade. Postgres and Redis are not on a Jarvis channel — they follow
|
# default, so that is the whole upgrade. Postgres and Redis are not on a Jarvis channel — they follow
|
||||||
# their own upstream tags. Schema changes apply themselves when the api starts, and they are ONE-WAY:
|
# their own upstream tags. Schema changes are migrations the api applies when it starts, and they are
|
||||||
# there is no migration history, so pulling an older api image does not put the schema back. Take a
|
# ONE-WAY: pulling an older api image does not take one back. Take a dump first. See the README.
|
||||||
# dump first. See the README.
|
|
||||||
#
|
#
|
||||||
# A channel tag does NOT mean the app forgets which build it is: `stable` is a second name on the same
|
# A channel tag does NOT mean the app forgets which build it is: `stable` is a second name on the same
|
||||||
# image as its version tag, and the version is stamped INTO the image when it is built. The footer in
|
# image as its version tag, and the version is stamped INTO the image when it is built. The footer in
|
||||||
@@ -59,7 +58,7 @@ services:
|
|||||||
# key once and will not let you past until you have put it somewhere.
|
# key once and will not let you past until you have put it somewhere.
|
||||||
init:
|
init:
|
||||||
image: ${JARVIS_IMAGE_API:-git.luxit.be/luxit/jarvis-api:stable}
|
image: ${JARVIS_IMAGE_API:-git.luxit.be/luxit/jarvis-api:stable}
|
||||||
entrypoint: ["node", "/app/apps/api/init-secrets.cjs"]
|
entrypoint: ["jarvis", "init-secrets"]
|
||||||
restart: "no"
|
restart: "no"
|
||||||
environment:
|
environment:
|
||||||
# Only read when the corresponding file does not exist yet — the upgrade path for a stack
|
# Only read when the corresponding file does not exist yet — the upgrade path for a stack
|
||||||
@@ -128,6 +127,7 @@ services:
|
|||||||
# sealed under, and nothing about that failure is visible until somebody opens a credential.
|
# sealed under, and nothing about that failure is visible until somebody opens a credential.
|
||||||
- jarvis_secrets:/var/lib/jarvis/secrets:ro
|
- jarvis_secrets:/var/lib/jarvis/secrets:ro
|
||||||
environment:
|
environment:
|
||||||
|
# Read by the api under this name: the refresh cookie is `Secure` only in production.
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
API_PORT: "4000"
|
API_PORT: "4000"
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ services:
|
|||||||
# and every consumer now reads the single stored value.
|
# and every consumer now reads the single stored value.
|
||||||
WEB_ORIGIN: ${WEB_ORIGIN:-}
|
WEB_ORIGIN: ${WEB_ORIGIN:-}
|
||||||
|
|
||||||
# Assembled by the entrypoint from POSTGRES_PASSWORD_FILE, because a password that lives in a
|
# Assembled by the api from POSTGRES_PASSWORD_FILE, because a password that lives in a
|
||||||
# file cannot be interpolated into a URL by compose. Set DATABASE_URL in .env to override it
|
# file cannot be interpolated into a URL by compose. Set DATABASE_URL in .env to override it
|
||||||
# outright — pointing at a managed postgres outside this stack, say.
|
# outright — pointing at a managed postgres outside this stack, say.
|
||||||
DATABASE_URL: ${DATABASE_URL:-}
|
DATABASE_URL: ${DATABASE_URL:-}
|
||||||
@@ -208,11 +208,11 @@ services:
|
|||||||
|
|
||||||
# This container brings the database up to its own schema before serving. Left on, because on
|
# This container brings the database up to its own schema before serving. Left on, because on
|
||||||
# compose one container IS the deployment. Turn it off only where something else runs
|
# compose one container IS the deployment. Turn it off only where something else runs
|
||||||
# `migrate.sh` first — an orchestrator with a run-once Job, say.
|
# `jarvis migrate` first — an orchestrator with a run-once Job, say.
|
||||||
JARVIS_SKIP_MIGRATIONS: ${JARVIS_SKIP_MIGRATIONS:-false}
|
JARVIS_SKIP_MIGRATIONS: ${JARVIS_SKIP_MIGRATIONS:-false}
|
||||||
# Let that phase DROP columns and tables. OFF, and it should stay off between deliberate acts:
|
# Let a migration DROP columns and tables. OFF, and it should stay off between deliberate acts:
|
||||||
# without it the schema sync REFUSES and prints what it would have destroyed, which is the
|
# without it a migration that would drop one is REFUSED, naming what it would have destroyed,
|
||||||
# answer you want from an upgrade that was not expecting to lose anything.
|
# which is the answer you want from an upgrade that was not expecting to lose anything.
|
||||||
SCHEMA_ACCEPT_DATA_LOSS: ${SCHEMA_ACCEPT_DATA_LOSS:-false}
|
SCHEMA_ACCEPT_DATA_LOSS: ${SCHEMA_ACCEPT_DATA_LOSS:-false}
|
||||||
|
|
||||||
# The browser the assistant may use, if you run one. Unset is a supported state: the two web
|
# The browser the assistant may use, if you run one. Unset is a supported state: the two web
|
||||||
@@ -230,15 +230,11 @@ services:
|
|||||||
- default
|
- default
|
||||||
- browsernet
|
- browsernet
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test: ["CMD", "jarvis", "healthcheck"]
|
||||||
- CMD
|
|
||||||
- node
|
|
||||||
- -e
|
|
||||||
- "fetch('http://localhost:4000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 12
|
retries: 12
|
||||||
# First boot syncs the schema and runs the data backfills before it listens.
|
# First boot applies the schema and runs the data backfills before it listens.
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
# The headless browser, and everything about this service is about keeping it away from the rest.
|
# The headless browser, and everything about this service is about keeping it away from the rest.
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
#
|
#
|
||||||
# ─── WHAT THIS REPLACES ───────────────────────────────────────────────────────────────────────
|
# ─── WHAT THIS REPLACES ───────────────────────────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
# On Compose, `init-secrets.cjs` runs as a one-shot writing into a volume every container shares,
|
# On Compose, `jarvis init-secrets` runs as a one-shot writing into a volume every container shares,
|
||||||
# and its guarantee that a key is generated exactly once is `openSync(path, "wx")` — two writers
|
# and its guarantee that a key is generated exactly once is creating a file that must not exist yet
|
||||||
# race, the kernel picks one. There is no shared filesystem across pods, so the exclusion moves to
|
# (`O_EXCL`) — two writers race, the kernel picks one. There is no shared filesystem across pods, so the exclusion moves to
|
||||||
# the one thing every pod does share: the API server. Creating a Secret that already exists is a
|
# the one thing every pod does share: the API server. Creating a Secret that already exists is a
|
||||||
# 409, decided by one serialized writer. Same guarantee, same strength.
|
# 409, decided by one serialized writer. Same guarantee, same strength.
|
||||||
#
|
#
|
||||||
@@ -50,7 +50,7 @@ rules:
|
|||||||
# `create` AND NOTHING ELSE. No `get`, no `list`, no `update`.
|
# `create` AND NOTHING ELSE. No `get`, no `list`, no `update`.
|
||||||
#
|
#
|
||||||
# Deliberate, and worth the paragraph: this identity cannot read any Secret in the namespace,
|
# Deliberate, and worth the paragraph: this identity cannot read any Secret in the namespace,
|
||||||
# including the one it just wrote. That is why the script does not look before it writes — the
|
# including the one it just wrote. That is why the command does not look before it writes — the
|
||||||
# 409 is the check, and it is a stronger one than a read-then-write could be, because a read and
|
# 409 is the check, and it is a stronger one than a read-then-write could be, because a read and
|
||||||
# a write are two operations and something else can happen between them.
|
# a write are two operations and something else can happen between them.
|
||||||
#
|
#
|
||||||
@@ -122,18 +122,16 @@ spec:
|
|||||||
type: RuntimeDefault
|
type: RuntimeDefault
|
||||||
containers:
|
containers:
|
||||||
- name: mint
|
- name: mint
|
||||||
# The api image, because the script ships in it and a first deployment should pull one
|
# The api image, because the command ships in it and a first deployment should pull one
|
||||||
# image rather than three. By digest like every other image here — the same one you put
|
# image rather than three. By digest like every other image here — the same one you put
|
||||||
# in 03-migrate-job.yaml and 04-api.yaml.
|
# in 03-migrate-job.yaml and 04-api.yaml.
|
||||||
image: git.luxit.be/luxit/jarvis-api@sha256:REPLACE_ME
|
image: git.luxit.be/luxit/jarvis-api@sha256:REPLACE_ME
|
||||||
command: ["node", "/app/apps/api/mint-k8s-secret.cjs"]
|
# Exec form: `jarvis` is a bash script that execs the API, and a `/bin/sh -c` (dash) in between
|
||||||
env:
|
# would drop the image's dotted logging variables. It verifies the API server's certificate
|
||||||
# Node verifies the API server's certificate against the cluster CA. Without this the
|
# against the service account's own ca.crt, and trusts nothing else for it — so no variable
|
||||||
# request would fail — which is the correct failure. Do not reach for
|
# names the CA (Node's NODE_EXTRA_CA_CERTS went with the TypeScript image). A bearer token good
|
||||||
# NODE_TLS_REJECT_UNAUTHORIZED: a bearer token good enough to write Secrets is a bearer
|
# enough to write Secrets is a bearer token worth stealing: never turn that check off.
|
||||||
# token worth stealing, and an unverified TLS session is where that happens.
|
command: ["jarvis", "mint-k8s-secret"]
|
||||||
- name: NODE_EXTRA_CA_CERTS
|
|
||||||
value: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
|
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 50m
|
cpu: 50m
|
||||||
|
|||||||
@@ -11,13 +11,13 @@
|
|||||||
# entirely. Do not do both: `04-api.yaml` reads this Secret last on purpose, so a value typed here
|
# entirely. Do not do both: `04-api.yaml` reads this Secret last on purpose, so a value typed here
|
||||||
# wins over a generated one, and the two quietly disagreeing is the shape of the accident.
|
# wins over a generated one, and the two quietly disagreeing is the shape of the accident.
|
||||||
#
|
#
|
||||||
# `apps/api/init-secrets.cjs` is the Compose generator, and it must NEVER become an initContainer
|
# `jarvis init-secrets` is the Compose generator, and it must NEVER become an initContainer
|
||||||
# here. Its guarantee that a key is minted exactly once is every writer sharing one filesystem.
|
# here. Its guarantee that a key is minted exactly once is every writer sharing one filesystem.
|
||||||
# Per-pod, on an `emptyDir`, each replica generates its own — and the consequence is not a crash.
|
# Per-pod, on an `emptyDir`, each replica generates its own — and the consequence is not a crash.
|
||||||
# Pod A seals a vault credential under a key pod B does not have; pod B reports that credential as
|
# Pod A seals a vault credential under a key pod B does not have; pod B reports that credential as
|
||||||
# corrupt; both pods log nothing at boot, because from each one's point of view everything is fine.
|
# corrupt; both pods log nothing at boot, because from each one's point of view everything is fine.
|
||||||
# By the time anybody notices, there are several keys in circulation and no way to tell which
|
# By the time anybody notices, there are several keys in circulation and no way to tell which
|
||||||
# entries belong to which. `apps/api/mint-k8s-secret.cjs` is the same one-shot done correctly, with
|
# entries belong to which. `jarvis mint-k8s-secret` is the same one-shot done correctly, with
|
||||||
# the API server's 409 standing in for the filesystem's exclusion.
|
# the API server's 409 standing in for the filesystem's exclusion.
|
||||||
#
|
#
|
||||||
# Either way, keep the output. The vault key in particular is the thing every stored credential is
|
# Either way, keep the output. The vault key in particular is the thing every stored credential is
|
||||||
@@ -71,7 +71,7 @@ stringData:
|
|||||||
VAULT_KEY_CREATED_AT: "REPLACE_ME"
|
VAULT_KEY_CREATED_AT: "REPLACE_ME"
|
||||||
|
|
||||||
# `connection_limit` is not optional here, and it is the reason this URL is in the Secret rather
|
# `connection_limit` is not optional here, and it is the reason this URL is in the Secret rather
|
||||||
# than assembled from parts. Prisma's default pool is per-process: N pods times that default will
|
# than assembled from parts. The api's pool is per-process: N pods times its default will
|
||||||
# exhaust `max_connections` long before the application is under any real load. Size it as
|
# exhaust `max_connections` long before the application is under any real load. Size it as
|
||||||
# (max_connections − reserve) ÷ (replicas + maxSurge + jobs).
|
# (max_connections − reserve) ÷ (replicas + maxSurge + jobs).
|
||||||
DATABASE_URL: "postgresql://jarvis:REPLACE_ME@postgres.jarvis.svc.cluster.local:5432/jarvis?schema=public&connection_limit=10&pool_timeout=20"
|
DATABASE_URL: "postgresql://jarvis:REPLACE_ME@postgres.jarvis.svc.cluster.local:5432/jarvis?schema=public&connection_limit=10&pool_timeout=20"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ metadata:
|
|||||||
name: jarvis-api-config
|
name: jarvis-api-config
|
||||||
namespace: jarvis
|
namespace: jarvis
|
||||||
data:
|
data:
|
||||||
|
# Read by the api under this name: the refresh cookie is `Secure` only in production.
|
||||||
NODE_ENV: "production"
|
NODE_ENV: "production"
|
||||||
API_PORT: "4000"
|
API_PORT: "4000"
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ data:
|
|||||||
REDIS_URL: "redis://redis.jarvis.svc.cluster.local:6379"
|
REDIS_URL: "redis://redis.jarvis.svc.cluster.local:6379"
|
||||||
|
|
||||||
# Set BOTH or neither. Sentinel needs the sentinels and the name of the primary they watch; a
|
# Set BOTH or neither. Sentinel needs the sentinels and the name of the primary they watch; a
|
||||||
# name is refused rather than defaulted, because ioredis's own default (`mymaster`) against a
|
# name is refused rather than defaulted, because a client's usual default (`mymaster`) against a
|
||||||
# primary called anything else is not an error — it is a pod that never becomes ready, silently.
|
# primary called anything else is not an error — it is a pod that never becomes ready, silently.
|
||||||
# When these are set, REDIS_URL above is ignored. They name what `08-redis-sentinel.yaml` deploys:
|
# When these are set, REDIS_URL above is ignored. They name what `08-redis-sentinel.yaml` deploys:
|
||||||
# its three sentinels, and `jarvis`, the primary they monitor.
|
# its three sentinels, and `jarvis`, the primary they monitor.
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ spec:
|
|||||||
parallelism: 1
|
parallelism: 1
|
||||||
completions: 1
|
completions: 1
|
||||||
# Two retries, because the failures worth retrying are transient (a database not finished failing
|
# Two retries, because the failures worth retrying are transient (a database not finished failing
|
||||||
# over). A schema that Prisma REFUSES is not transient and must not be retried into submission —
|
# over). A schema `jarvis migrate` REFUSES is not transient and must not be retried into submission —
|
||||||
# it is refused for a reason, and the reason is printed.
|
# it is refused for a reason, and the reason is printed.
|
||||||
backoffLimit: 2
|
backoffLimit: 2
|
||||||
# A migration that has not finished in fifteen minutes is stuck, not slow, and the rollout is
|
# A migration that has not finished in fifteen minutes is stuck, not slow, and the rollout is
|
||||||
@@ -70,7 +70,7 @@ spec:
|
|||||||
# THE SAME IMAGE, by the same digest, as the API that is about to roll. A migration from a
|
# THE SAME IMAGE, by the same digest, as the API that is about to roll. A migration from a
|
||||||
# different build is a schema that matches nothing.
|
# different build is a schema that matches nothing.
|
||||||
image: git.luxit.be/luxit/jarvis-api@sha256:REPLACE_ME
|
image: git.luxit.be/luxit/jarvis-api@sha256:REPLACE_ME
|
||||||
command: ["/usr/local/bin/migrate.sh"]
|
command: ["jarvis", "migrate"]
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
name: jarvis-api-config
|
name: jarvis-api-config
|
||||||
@@ -94,8 +94,8 @@ spec:
|
|||||||
# nobody having decided to — survivable on one container, not during a rolling update
|
# nobody having decided to — survivable on one container, not during a rolling update
|
||||||
# where the previous generation is still selecting it.
|
# where the previous generation is still selecting it.
|
||||||
#
|
#
|
||||||
# Without the flag the phase REFUSES, prints which columns it would have dropped, and
|
# Without it a pending migration that drops a table or a column is REFUSED, naming them,
|
||||||
# fails the Job — so the rollout stops and nothing is serving a half-migrated schema.
|
# and fails the Job — so the rollout stops and nothing is serving a half-migrated schema.
|
||||||
# Set it to "true" for the ONE deploy where that drop is intended, then remove it.
|
# Set it to "true" for the ONE deploy where that drop is intended, then remove it.
|
||||||
- name: SCHEMA_ACCEPT_DATA_LOSS
|
- name: SCHEMA_ACCEPT_DATA_LOSS
|
||||||
value: "false"
|
value: "false"
|
||||||
|
|||||||
+34
-50
@@ -14,14 +14,21 @@ spec:
|
|||||||
matchLabels:
|
matchLabels:
|
||||||
app.kubernetes.io/name: jarvis
|
app.kubernetes.io/name: jarvis
|
||||||
app.kubernetes.io/component: api
|
app.kubernetes.io/component: api
|
||||||
|
# RECREATED, FOR api 1.0.0 ONLY — the release that crosses from the TypeScript images. A rolling
|
||||||
|
# update would keep a TypeScript pod and a .NET pod serving side by side behind one Service, and
|
||||||
|
# their realtime protocols differ under /socket.io/ (Socket.IO against SignalR): every old pod stops
|
||||||
|
# before the first new one starts, a gap of about thirty seconds, once. The next release puts the
|
||||||
|
# rolling update back:
|
||||||
|
#
|
||||||
|
# type: RollingUpdate
|
||||||
|
# rollingUpdate:
|
||||||
|
# maxUnavailable: 0 # never fewer pods than are being served now
|
||||||
|
# maxSurge: 1
|
||||||
|
#
|
||||||
|
# With `kubectl apply` or Argo's default client-side apply this switches both ways. Under
|
||||||
|
# server-side apply, a field manager that owns `rollingUpdate` makes the API server refuse it.
|
||||||
strategy:
|
strategy:
|
||||||
type: RollingUpdate
|
type: Recreate
|
||||||
rollingUpdate:
|
|
||||||
# Never fewer pods than are being served now. A run holds its conversation through a unique
|
|
||||||
# index, so a surge pod cannot double-answer anything — the old constraint that made an
|
|
||||||
# overlap dangerous is gone.
|
|
||||||
maxUnavailable: 0
|
|
||||||
maxSurge: 1
|
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
@@ -31,7 +38,7 @@ spec:
|
|||||||
# 90 seconds, and every part of it is accounted for:
|
# 90 seconds, and every part of it is accounted for:
|
||||||
# 10s preStop, so this pod leaves the Service's endpoints BEFORE it starts draining
|
# 10s preStop, so this pod leaves the Service's endpoints BEFORE it starts draining
|
||||||
# 25s RUN_SHUTDOWN_GRACE_SEC — in-flight assistant runs stop and close their transcripts
|
# 25s RUN_SHUTDOWN_GRACE_SEC — in-flight assistant runs stop and close their transcripts
|
||||||
# ~15s agent sockets closed and their rows written, httpServer.close(), Prisma, Redis
|
# ~15s agent sockets closed and their rows written, Kestrel, the database pool, Redis
|
||||||
# the rest is margin
|
# the rest is margin
|
||||||
# The default of 30 would SIGKILL the pod in the middle of the run drain, which is the one
|
# The default of 30 would SIGKILL the pod in the middle of the run drain, which is the one
|
||||||
# thing the drain exists to prevent.
|
# thing the drain exists to prevent.
|
||||||
@@ -75,14 +82,14 @@ spec:
|
|||||||
preStop:
|
preStop:
|
||||||
exec:
|
exec:
|
||||||
# Endpoint removal and SIGTERM race each other, and losing that race is visible:
|
# Endpoint removal and SIGTERM race each other, and losing that race is visible:
|
||||||
# a prompt that arrives after Nest sets `draining` is persisted and broadcast as
|
# a prompt that arrives after the api starts draining is persisted and broadcast as
|
||||||
# queued, and this pod will never answer it. Ten seconds is comfortably more than
|
# queued, and this pod will never answer it. Ten seconds is comfortably more than
|
||||||
# kube-proxy needs to stop sending new connections here.
|
# kube-proxy needs to stop sending new connections here.
|
||||||
command: ["/bin/sh", "-c", "sleep 10"]
|
command: ["/bin/sh", "-c", "sleep 10"]
|
||||||
env:
|
env:
|
||||||
- name: JARVIS_SKIP_MIGRATIONS
|
- name: JARVIS_SKIP_MIGRATIONS
|
||||||
# The schema is the migration Job's business, once, before this rolls. N pods each
|
# The schema is the migration Job's business, once, before this rolls. N pods each
|
||||||
# running `prisma db push` is N writers racing the same DDL while the previous
|
# migrating is N writers racing the same DDL while the previous
|
||||||
# generation is still selecting the columns being altered.
|
# generation is still selecting the columns being altered.
|
||||||
value: "true"
|
value: "true"
|
||||||
envFrom:
|
envFrom:
|
||||||
@@ -139,48 +146,25 @@ spec:
|
|||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 500m
|
cpu: 500m
|
||||||
# Not generous — honest. A terminal recording holds up to 8 MiB of Buffers in heap
|
# Not generous — honest. A terminal recording holds up to 8 MiB in memory for the life
|
||||||
# for the life of the session, and argon2 takes 64 MiB per share verification.
|
# of the session, and argon2 takes 64 MiB per share verification.
|
||||||
#
|
#
|
||||||
# Attached and uploaded files add four holders, every one capped, so that their sum is
|
# Attached and uploaded files are the holders that grow, and every one is capped:
|
||||||
# a number rather than a hope. Their figures are MEASURED on this image, in a cgroup
|
# uploads being checked and stored, 3 at a time per pod (`Files/UploadSlots.cs`); files
|
||||||
# like this pod's, not estimated — the estimates were five to seven times too low.
|
# read back, their bytes or a window of their text; images prepared for the model; and
|
||||||
|
# a PDF's text, read in a worker PROCESS of its own (`Files/PdfText.cs`,
|
||||||
|
# `Files/PdfTextWorker.cs`) that is killed past its own limits, so a hostile file costs
|
||||||
|
# that process and not this one.
|
||||||
#
|
#
|
||||||
# Uploads being checked and stored: 3 at a time (`files/upload-slots.ts`). Prisma is
|
# MEASURED on this image, in a cgroup like this pod's (lot 11 P, proof 2j): every upload
|
||||||
# what costs: its JSON protocol turns the bytes into base64, then into a request
|
# slot of two pods busy with a 10 MB, 500-page PDF — the most pages the worker reads —
|
||||||
# string, and its engine copies that again. A 10 MiB file adds ~200 MiB of RSS while
|
# peaked at 619 MiB and 740 MiB of `memory.current`, the API and its workers together,
|
||||||
# it is written; a workspace image with the 3 MiB copy of it prepared for the model,
|
# from 446 and 496 MiB before. The TypeScript image sized this limit: Prisma's JSON
|
||||||
# ~310 MiB, and ~330 to 360 MiB each with three at once, three bursts in a row. So
|
# protocol copied an upload's bytes several times over (~1.1 GiB for three at once)
|
||||||
# ~1.1 GiB. Six at a time, as this once allowed, took a 2 GiB container to 1.8-1.95
|
# and a PDF worker's buffers could reach ~500 MB, ~2.75 GiB with every holder full.
|
||||||
# GiB with nothing else in it, and three such bursts were OOM-killed.
|
# None of that is in this process; the 3Gi limit stands until it is lowered on purpose
|
||||||
#
|
# (docs/dotnet/PLAN.md, "Left to Antoine after lot 11"). The request is what the
|
||||||
# Files read back — their bytes over HTTP, a window of their text for the viewer or
|
# scheduler sets aside for an ordinary day, not for the worst minute.
|
||||||
# for the assistant's `read_attachment`: 8 at a time, up to ~40 MiB each (a 10 MiB log
|
|
||||||
# is decoded whole, 25 to 39 MiB measured, until its window is cut). So 320 MiB.
|
|
||||||
#
|
|
||||||
# Images for the model: 64 MiB of `data:` URLs, reserved before a byte is read, so the
|
|
||||||
# same 64 MiB also bounds what is being read for them at any instant — at most ~48 MiB
|
|
||||||
# raw, which Prisma's transfer copies a few times more for the milliseconds a request
|
|
||||||
# takes to build. So ~200 MiB at the worst instant.
|
|
||||||
#
|
|
||||||
# A PDF worker is the one without a flat cap. `resourceLimits` bounds its V8 heap
|
|
||||||
# (192 MB, plus 32 MB of young generation) and NOT its ArrayBuffers, where a
|
|
||||||
# decompressed stream lives. Those are read from outside every 50 ms and the worker is
|
|
||||||
# killed past 128 MB, which a buffer doubling between two reads can overshoot to ~256
|
|
||||||
# MB. So one hostile file can hold up to ~500 MB before it dies, two ~1 GB. Measured:
|
|
||||||
# two decompression bombs at once added ~310 MB to the process, both refused in under
|
|
||||||
# two seconds.
|
|
||||||
#
|
|
||||||
# Every holder full at once is therefore ~2.5 GiB (1.1 GiB of uploads, 320 MiB of
|
|
||||||
# files read back, ~200 MiB of images, ~1 GB of PDF workers), on top of the API itself
|
|
||||||
# (~200 MiB once booted): ~2.75 GiB. That cannot fit 2Gi at any upload concurrency
|
|
||||||
# worth having — at 2Gi, one upload at a time beside two hostile PDFs is all there is
|
|
||||||
# room for — so the limit is 3Gi, with ~250 MiB left for the terminal recordings and
|
|
||||||
# share verifications above. It runs well past the room above this request, and that
|
|
||||||
# is deliberate: the request is what the scheduler sets aside for an ordinary day, not
|
|
||||||
# for the worst minute. If more margin is ever wanted, the levers are the PDF worker's
|
|
||||||
# own caps (`files/pdf-text.ts`) and keeping an upload's bytes out of Prisma's JSON
|
|
||||||
# protocol — not fewer upload slots.
|
|
||||||
memory: 1Gi
|
memory: 1Gi
|
||||||
limits:
|
limits:
|
||||||
memory: 3Gi
|
memory: 3Gi
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ metadata:
|
|||||||
# outlast a quiet period comfortably. Too short and a healthy fleet reconnects all day.
|
# outlast a quiet period comfortably. Too short and a healthy fleet reconnects all day.
|
||||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||||
# Matches BODY_LIMIT in apps/api/src/main.ts. A document may hold 400,000 characters, which is
|
# Matches the API's own ceiling on a JSON body, `BodyLimit.MaxBytes` in
|
||||||
# over a megabyte once accented text is UTF-8 encoded, and a diagram export posts back the SVG
|
# src/Jarvis.Api/Common/BodyLimit.cs: 4 MiB, and nginx's `m` is MiB. A document may hold 400,000
|
||||||
# the browser rendered. Below this they are rejected as a bare 413 with no message.
|
# characters, which is over a megabyte once accented text is UTF-8 encoded, and a diagram export
|
||||||
|
# posts back the SVG the browser rendered. Below this they are rejected as a bare 413 with no
|
||||||
|
# message.
|
||||||
#
|
#
|
||||||
# File uploads need more and are NOT raised here: they post under /api/uploads/, which has its
|
# File uploads need more and are NOT raised here: they post under /api/uploads/, which has its
|
||||||
# own Ingress in 06b-ingress-uploads.yaml, because ingress-nginx applies these annotations to
|
# own Ingress in 06b-ingress-uploads.yaml, because ingress-nginx applies these annotations to
|
||||||
@@ -58,8 +60,8 @@ spec:
|
|||||||
# Longest prefix first for readers; the controller sorts by specificity itself.
|
# Longest prefix first for readers; the controller sorts by specificity itself.
|
||||||
#
|
#
|
||||||
# The agent websocket has its own rule even though it sits under /api/, mirroring the
|
# The agent websocket has its own rule even though it sits under /api/, mirroring the
|
||||||
# split in apps/web/nginx.conf: it is the one path where an idle connection legitimately
|
# split in src/Jarvis.Web/nginx.conf: it is the one path where an idle connection
|
||||||
# stays open for an hour between frames.
|
# legitimately stays open for an hour between frames.
|
||||||
- path: /api/agents/ws
|
- path: /api/agents/ws
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ metadata:
|
|||||||
argocd.argoproj.io/sync-wave: "2"
|
argocd.argoproj.io/sync-wave: "2"
|
||||||
# 13376k is 13 MiB and 64 KiB: exactly the most the API itself reads on this path — a workspace
|
# 13376k is 13 MiB and 64 KiB: exactly the most the API itself reads on this path — a workspace
|
||||||
# image of up to 10 MiB, the copy of it prepared for the model (at most 3 MiB), and 64 KiB for
|
# image of up to 10 MiB, the copy of it prepared for the model (at most 3 MiB), and 64 KiB for
|
||||||
# the multipart framing and the name (`BODY_MAX_BYTES.documents` + `MULTIPART_OVERHEAD`, in
|
# the multipart framing and the name (`UploadGate.BodyMaxBytes(UploadRoute.Documents)` +
|
||||||
# apps/api/src/files/upload-gate.interceptor.ts; both refuse strictly above). Not less: a body
|
# `UploadGate.MultipartOverhead`, in src/Jarvis.Api/Files/UploadEndpoint.cs; both refuse
|
||||||
# the API would take would meet this ceiling first, and the controller's answer is an HTML page
|
# strictly above). Not less: a body the API would take would meet this ceiling first, and the
|
||||||
# the client cannot translate. Not more: the API refuses anything larger unread. A composer
|
# controller's answer is an HTML page the client cannot translate. Not more: the API refuses
|
||||||
# attachment over 10 MiB still gets through here, and meets the API's own JSON 413.
|
# anything larger unread. A composer attachment over 10 MiB still gets through here, and meets
|
||||||
|
# the API's own JSON 413.
|
||||||
nginx.ingress.kubernetes.io/proxy-body-size: "13376k"
|
nginx.ingress.kubernetes.io/proxy-body-size: "13376k"
|
||||||
# Everything the API does with a file happens before it answers: a wait for an upload slot, a
|
# Everything the API does with a file happens before it answers: a wait for an upload slot, a
|
||||||
# PDF's text extraction (capped at 20s), a 13 MB row write. Two minutes is headroom over that,
|
# PDF's text extraction (capped at 20s), a 13 MB row write. Two minutes is headroom over that,
|
||||||
|
|||||||
@@ -10,11 +10,19 @@
|
|||||||
# somebody else to hold that, use a managed Redis or an operator. What this directory owes you is
|
# somebody else to hold that, use a managed Redis or an operator. What this directory owes you is
|
||||||
# the shape the application needs, and this is it.
|
# the shape the application needs, and this is it.
|
||||||
#
|
#
|
||||||
# The application half is proven rather than assumed: an ioredis client built exactly the way
|
# The application half is proven rather than assumed, though not all of it on the api that ships
|
||||||
# `apps/api/src/redis/redis-connection.ts` builds one was run against a live three-sentinel
|
# now. On the TypeScript api, an ioredis client built exactly the way its `redis-connection.ts`
|
||||||
# topology while its primary was killed. It followed the promotion to a different server, and 99
|
# built one was run against a live three-sentinel topology while its primary was killed. It
|
||||||
# writes issued across the window were rejected zero times. What it does NOT do is preserve pub/sub
|
# followed the promotion to a different server, and 99 writes issued across the window were
|
||||||
# during the switch — see "What a failover actually costs" at the bottom.
|
# rejected zero times. What it does NOT do is preserve pub/sub during the switch — see the failover
|
||||||
|
# section of the README beside this file.
|
||||||
|
#
|
||||||
|
# The .NET api reaches Redis through StackExchange.Redis, asking these sentinels for the primary
|
||||||
|
# (src/Jarvis.Api/Infrastructure/RedisConnection.cs). Proven for it, on a throwaway cluster synced
|
||||||
|
# from these manifests with 02-config.yaml's two REDIS_SENTINEL* lines: every pod wrote to the
|
||||||
|
# primary — its leases taken, the console's realtime socket open, no READONLY refusal — where
|
||||||
|
# REDIS_URL alone, on the headless Service below, landed writes on a read-only replica. Its
|
||||||
|
# failover has NOT been measured: that it follows a promotion as ioredis did is not known.
|
||||||
#
|
#
|
||||||
# kubectl apply -f 08-redis-sentinel.yaml
|
# kubectl apply -f 08-redis-sentinel.yaml
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
# loud. PowerShell is in the api image and this is not, and the difference is exactly that: the
|
# loud. PowerShell is in the api image and this is not, and the difference is exactly that: the
|
||||||
# Exchange module talks to Microsoft, and a browser talks to whoever the model was told to look at.
|
# Exchange module talks to Microsoft, and a browser talks to whoever the model was told to look at.
|
||||||
#
|
#
|
||||||
# THE NETWORK POLICY BELOW IS THE REAL CONTROL. `browser.client.ts` refuses private addresses before
|
# THE NETWORK POLICY BELOW IS THE REAL CONTROL. The api's `BrowserClient`
|
||||||
# it asks, which is the half that can EXPLAIN itself to the model — but it cannot survive a redirect
|
# (src/Jarvis.Api/Connectors/BrowserClient.cs) refuses private addresses before it asks, which is
|
||||||
# and it cannot survive DNS rebinding, because by then the browser is already connecting. The policy
|
# the half that can EXPLAIN itself to the model — but it cannot survive a redirect and it cannot
|
||||||
# can, because it is enforced on the packet.
|
# survive DNS rebinding, because by then the browser is already connecting. The policy can, because
|
||||||
|
# it is enforced on the packet.
|
||||||
#
|
#
|
||||||
# DELETE THIS FILE (and its line in kustomization.yaml, and BROWSER_URL from 02-config.yaml) if you
|
# DELETE THIS FILE (and its line in kustomization.yaml, and BROWSER_URL from 02-config.yaml) if you
|
||||||
# would rather Jarvis had no web access. Nothing else stops working: the two web tools report
|
# would rather Jarvis had no web access. Nothing else stops working: the two web tools report
|
||||||
@@ -121,8 +122,8 @@ spec:
|
|||||||
#
|
#
|
||||||
# NOTE THIS NEEDS A CNI THAT ENFORCES NetworkPolicy. Calico, Cilium and most managed clusters do;
|
# NOTE THIS NEEDS A CNI THAT ENFORCES NetworkPolicy. Calico, Cilium and most managed clusters do;
|
||||||
# plain kube-proxy with flannel does NOT, and on such a cluster this file is documentation rather
|
# plain kube-proxy with flannel does NOT, and on such a cluster this file is documentation rather
|
||||||
# than a control. Check before relying on it — `browser.client.ts` still refuses private addresses
|
# than a control. Check before relying on it — the api's `BrowserClient` still refuses private
|
||||||
# on the near side either way, but that half cannot survive a redirect.
|
# addresses on the near side either way, but that half cannot survive a redirect.
|
||||||
apiVersion: networking.k8s.io/v1
|
apiVersion: networking.k8s.io/v1
|
||||||
kind: NetworkPolicy
|
kind: NetworkPolicy
|
||||||
metadata:
|
metadata:
|
||||||
|
|||||||
+15
-6
@@ -158,6 +158,10 @@ Two consequences worth knowing:
|
|||||||
|
|
||||||
- **The previous run's Job object, and its logs, are gone** by the time the next one starts. Ship
|
- **The previous run's Job object, and its logs, are gone** by the time the next one starts. Ship
|
||||||
the logs if you need them after the fact.
|
the logs if you need them after the fact.
|
||||||
|
- **A migration waiting on the schema lock is ended by its pod, not its Job.** Under Argo,
|
||||||
|
deleting the `jarvis-migrate` Job leaves it `Terminating` behind the hook's finalizer, its pod still
|
||||||
|
waiting and the sync waiting for good. Delete the pod instead — it stops in about a second — then
|
||||||
|
end the sync's operation (`argocd app terminate-op <application>`).
|
||||||
- **Every sync runs the migration.** That is safe — the phase is idempotent and holds a Postgres
|
- **Every sync runs the migration.** That is safe — the phase is idempotent and holds a Postgres
|
||||||
advisory lock — and it is why the example Application leaves `automated` sync OFF. A commit that
|
advisory lock — and it is why the example Application leaves `automated` sync OFF. A commit that
|
||||||
applies a schema change with nobody watching turns the one thing this directory works hardest to
|
applies a schema change with nobody watching turns the one thing this directory works hardest to
|
||||||
@@ -227,15 +231,16 @@ back.
|
|||||||
|
|
||||||
**`TRUST_PROXY_HOPS` is one hop here, not two.** The Compose files say 2 because a request passes
|
**`TRUST_PROXY_HOPS` is one hop here, not two.** The Compose files say 2 because a request passes
|
||||||
your TLS terminator and then the web container's nginx. Here the Ingress routes `/api/` straight at
|
your TLS terminator and then the web container's nginx. Here the Ingress routes `/api/` straight at
|
||||||
the api Service, so there is exactly one proxy rewriting `X-Forwarded-For`. Left at 2, `req.ip`
|
the api Service, so there is exactly one proxy rewriting `X-Forwarded-For`. Left at 2, the client
|
||||||
becomes **chosen by the caller**: anyone can send `X-Forwarded-For: 198.51.100.7` and have that
|
address the api records becomes **chosen by the caller**: anyone can send `X-Forwarded-For: 198.51.100.7` and have that
|
||||||
written into session records, audit rows and the key the anonymous WebAuthn budget counts on.
|
written into session records, audit rows and the key the anonymous WebAuthn budget counts on.
|
||||||
|
|
||||||
**Do not add session affinity.** It is the reflex the moment websockets are involved and it solves
|
**Do not add session affinity.** It is the reflex the moment websockets are involved and it solves
|
||||||
nothing here: the hard case is co-locating an operator's browser with an *agent's* websocket, which
|
nothing here: the hard case is co-locating an operator's browser with an *agent's* websocket, which
|
||||||
arrives from a different network at a different time and whose id is not even known at the browser's
|
arrives from a different network at a different time and whose id is not even known at the browser's
|
||||||
handshake. No ingress annotation can express that. It is handled in the application, over Redis.
|
handshake. No ingress annotation can express that. It is handled in the application, over Redis.
|
||||||
Both ends pin `transports: ["websocket"]`, so there is no polling to make sticky in the first place.
|
Both ends speak WebSockets only — the api allows no other transport and the console skips
|
||||||
|
negotiation — so there is no polling to make sticky in the first place.
|
||||||
|
|
||||||
## On exactly three nodes
|
## On exactly three nodes
|
||||||
|
|
||||||
@@ -258,7 +263,9 @@ answer to wanting more is a fourth and fifth node, not a different configuration
|
|||||||
|
|
||||||
## What a failover actually costs
|
## What a failover actually costs
|
||||||
|
|
||||||
Measured, not estimated.
|
Measured, not estimated — on the api releases before 1.0.0, which were written in TypeScript. The
|
||||||
|
readiness and liveness design below is unchanged in 1.0.0; the figures have not been measured again
|
||||||
|
since.
|
||||||
|
|
||||||
**Redis**, with its primary killed under a running api:
|
**Redis**, with its primary killed under a running api:
|
||||||
|
|
||||||
@@ -298,8 +305,10 @@ outcome. Run it again.
|
|||||||
websockets, live runs and open shells, and CPU is a poor proxy for a load made of long-lived
|
websockets, live runs and open shells, and CPU is a poor proxy for a load made of long-lived
|
||||||
connections. Raise api replicas deliberately.
|
connections. Raise api replicas deliberately.
|
||||||
- **A rollout still cuts what is in flight.** An assistant run is interrupted and resumed with a
|
- **A rollout still cuts what is in flight.** An assistant run is interrupted and resumed with a
|
||||||
note; a terminal is not resumable and says so; and the web bundle's content-hashed chunks are
|
note; a terminal is not resumable and says so; and the console's files are fingerprinted per
|
||||||
per-image, so a browser holding the old shell can 404 on a lazy import during the rollout window.
|
image, so during the rollout window a tab holding the old console can miss one of the two parts
|
||||||
|
it loads on demand (the Markdown parser, the QR code generator). It then shows the Markdown as
|
||||||
|
plain text and offers the reload.
|
||||||
- **Validation is not admission.** These manifests satisfy the `restricted` Pod Security Standard
|
- **Validation is not admission.** These manifests satisfy the `restricted` Pod Security Standard
|
||||||
that [`00-namespace.yaml`](00-namespace.yaml) enforces. If you edit them, note that a schema
|
that [`00-namespace.yaml`](00-namespace.yaml) enforces. If you edit them, note that a schema
|
||||||
validator cannot see that policy: a missing `seccompProfile` or a container without a
|
validator cannot see that policy: a missing `seccompProfile` or a container without a
|
||||||
|
|||||||
Reference in New Issue
Block a user