eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Deploying instances β€” the mental model
2
3> **Not a procedure.** Day-to-day operation goes through **`./deploy/ops`**
4> (releases: `publish`/`promote`/`ship`; branch canaries:
5> `canary create|deploy|destroy`; preflight: `doctor`). Where this page shows
6> command sequences, they document what the verbs do β€” run the verb, and if
7> no verb covers your task, **extend ops** rather than following prose here.
8
9What a box that uses the `lchat` router is made of, and why the pieces are
10shaped this way (`docs/instance-router.md` is the design; `deploy/README.md`
11is the one-page fresh-box bootstrap). Everything here is generic β€” hosts are
12`chat.example.com`, the box is `<box>`.
13
14## The mental model
15
16One static Go binary (`lchat`) plays three roles on the box:
17
18- **The router** (`lchat-router.service`) β€” binds `:80`/`:443`, terminates TLS
19 (Let's Encrypt via autocert HTTP-01), and reverse-proxies each hostname to a
20 unix socket. The route table lives in a small SQLite registry
21 (`/data/registry.db`).
22- **Instances** (`lchat@<host>.service`, one per chat) β€” each is its own process,
23 socket (`/run/lchat/<host>.sock`), and database (`/data/<host>.db`). Fully
24 isolated: its own admin token, its own crash/memory blast radius, its own
25 binary version.
26- **The control CLI** (`/opt/lchat/lchat add-instance | deploy | instances | …`)
27 β€” run on the box over SSH; it edits the registry, writes env files, and drives
28 systemd.
29
30Two directories carry the versioning scheme:
31
32```
33/opt/lchat/versions/<ver>/lchat # the content store: every staged build, keyed by build id
34/opt/lchat/live/<host> # per-instance symlink into the store; the unit execs this
35```
36
37Each instance's unit execs its *own* `live/<host>` symlink, so two instances can
38run two different builds at once. That's what makes canarying a one-liner.
39
40Per-instance config is an **EnvironmentFile** at `/data/<host>.env` (mode 0600),
41written by `add-instance`. It holds the one secret that matters β€” the plaintext
42`LCHAT_ADMIN_TOKEN` (the DB stores only its hash, so this file is the only
43recoverable copy) β€” plus optional per-instance vars (`LCHAT_PUSH_RELAY_URL`/
44`_TOKEN`, `LCHAT_HOME_ORIGIN`, `LCHAT_APP_STORE_URL`).
45
46Nothing here weakens the E2E model: the router and the instance only ever see
47ciphertext, and the admin token authorises roster management, not decryption.
48
49## Prerequisites
50
51- **A bootstrapped box.** `scp` the binary to `/opt/lchat/lchat`, then
52 `lchat bootstrap` (idempotent) β€” it creates the `lchat` service user, the
53 dirs, and the systemd units, and starts the router. See `deploy/README.md`.
54- **DNS** for the new host pointing at the box β€” either a wildcard
55 (`*.example.com A <box-ip>`) so new instances need no DNS step, or one A
56 record per host. Low TTL (60s) makes future moves painless.
57- **Inbound `:80` and `:443` open** (both firewall on the box and any cloud
58 firewall). Port 80 is required β€” autocert's HTTP-01 challenge answers there.
59- Certificates issue **lazily on the host's first HTTPS hit**, in a second or
60 two. There's no pre-issuance step; if DNS resolves and :80 is reachable, the
61 first `curl https://<host>/` just works.
62
63## Adding an instance
64
65One command, run on the box:
66
67```sh
68ssh root@<box> /opt/lchat/lchat add-instance chat.example.com
69```
70
71It mints an admin token, writes `/data/chat.example.com.env`, registers the
72route, stages the current binary and points `live/<host>` at it, starts
73`lchat@chat.example.com`, waits for the socket to answer, reloads the router,
74and prints a one-time setup link (open it and you're the admin) plus the
75admin-token link:
76
77```
78added chat.example.com
79 get started (one-time link, makes you the admin):
80 https://chat.example.com/join/setup#…
81 admin token link: https://chat.example.com/admin#token=…
82 (recover later: lchat admin-link chat.example.com / lchat setup-invite chat.example.com)
83```
84
85Optional flags (all become vars in the instance's env file):
86
87| flag | what it sets |
88|---|---|
89| `--admin-token <tok>` | import an existing token instead of minting β€” for migrations, so saved admin links keep working (see `docs/instance-migration.md`) |
90| `--push-relay-url` / `--push-relay-token` | iOS push via a shared APNs relay (`docs/push-relay.md`); mint the token on the relay first |
91| `--home-origin` | the passkey-backup home server this instance offers members |
92| `--app-store-url` | where the welcome page's "Get the app" button points |
93
94All of these are optional β€” an instance without them is fully functional for
95web + Web Push (VAPID is self-generated into the instance DB; no config).
96
97Then, immediately:
98
99- **Store the admin token somewhere durable** (a password manager). It exists
100 in exactly one recoverable place β€” `/data/<host>.env` on the box β€” and
101 `lchat admin-link <host>` reads it from there, so losing the box means losing
102 the link.
103- **Verify:**
104
105```sh
106ssh root@<box> /opt/lchat/lchat instances # HOST / KIND / STATUS / VERSION / SOCKET
107curl -s https://chat.example.com/api/version # {"build":"<ver>"} over a fresh LE cert
108```
109
110Open the printed admin link in a browser to name the group and mint member
111invites.
112
113If `add-instance` fails partway, the route is left in status `failed` with a
114`last error` line shown by `lchat instances`; fix the cause and re-run β€” a
115failed/creating route is overwritten on retry.
116
117## Adding bots (optional)
118
119Each instance can have a **sidecar** hosting the default agents (Claude /
120Eleven). The sidecar is Node (it reuses `web/static/crypto.js`, so
121its E2E crypto can't drift from the browser's), so the box needs a one-time Node
122setup β€” see `deploy/README.md Β§ Bots` for the `apt`/`rsync`/`npm ci` recipe.
123Then, per instance:
124
125```sh
126ssh root@<box> 'ANTHROPIC_API_KEY=sk-ant-… /opt/lchat/lchat add-bot chat.example.com'
127```
128
129`add-bot` mints one agent invite per identity via the instance's admin API,
130writes `/data/<host>-bot.env` (0600 β€” invites, session paths, the API key), and
131starts `lchat-bot@<host>`. Notes:
132
133- **Instance first, then sidecar** β€” Eleven baselines "who already exists" from
134 the roster on its first tick; booting the sidecar against a not-yet-ready
135 instance can make it welcome-DM long-standing members later.
136- Omit `ANTHROPIC_API_KEY` for deterministic-only bots (Eleven still welcomes
137 members and answers slash commands; no AI replies). Add the key to
138 `/data/<host>-bot.env` and `systemctl restart lchat-bot@<host>` later to give
139 it a brain.
140- The API key is read from the **environment**, deliberately not a flag β€” a
141 flag would leak it into the process table and shell history.
142- The sidecar connects to its instance over the **public URL**. If the sidecar
143 runs on the same box as the router, pin every hosted hostname to loopback in
144 `/etc/hosts` (`127.0.0.1 chat.example.com …`) so on-box clients reach the
145 local router instead of depending on external DNS β€” and disable cloud-init's
146 `manage_etc_hosts` so the pins survive reboots. See
147 `docs/instance-router.md Β§ Bots`.
148- Sidecar logs: `journalctl -u lchat-bot@chat.example.com -f`.
149- Updating sidecar *code* is separate from binary deploys: re-`rsync` `agent/` +
150 `web/static/` to `/opt/lchat/` and restart the `lchat-bot@` units (they
151 backfill on reconnect, so a restart is safe).
152
153## Shipping a new build (canary β†’ promote)
154
155Deploys are **data-safe by construction**: they never touch `/data` β€” DB
156migrations are additive (`CREATE TABLE IF NOT EXISTS`), so new code over old
157data is always fine. The flow is: build locally, stage on the box, prove the
158build on one instance, then promote the fleet.
159
160```sh
161# 1. Build linux/amd64, stamped with the git short sha as its build id
162./deploy/build-linux.sh # β†’ lchat-linux-amd64, prints <ver>
163
164# 2. Copy it up and register it in the version store
165scp lchat-linux-amd64 root@<box>:/opt/lchat/incoming
166ssh root@<box> /opt/lchat/lchat stage --file /opt/lchat/incoming # β†’ prints <ver>
167
168# 3. Canary ONE instance
169ssh root@<box> /opt/lchat/lchat deploy canary.example.com --version <ver>
170# …verify: open the app, send a message, check /api/version reports <ver>…
171
172# 4. Promote everyone (one at a time, health-checked, stops on first failure)
173ssh root@<box> /opt/lchat/lchat deploy --all --version <ver>
174
175# 5. Who runs what
176ssh root@<box> /opt/lchat/lchat instances # VERSION column shows per-instance skew
177```
178
179What `deploy --version` actually does: atomically repoint
180`/opt/lchat/live/<host>` at the staged build, `systemctl restart` the instance
181(SIGTERM drains in-flight HTTP and closes WebSockets so clients reconnect; the
182router retries dials across the sub-second gap, so requests queue rather than
183fail), then poll `/api/version` until the instance reports the **expected build
184id** β€” not merely a 200. On mismatch it **auto-rolls-back** to the previous
185version and errors out. `deploy --all` walks the fleet one instance at a time
186and stops at the first failure, leaving already-promoted instances on the new
187build.
188
189Stepping back is explicit too:
190
191```sh
192ssh root@<box> /opt/lchat/lchat rollback chat.example.com # β†’ its recorded previous version
193```
194
195Other notes:
196
197- `deploy <host>` with **no** `--version` just gracefully restarts the instance
198 on its current build (useful after editing its env file).
199- Build ids are the git short sha by default; `./deploy/build-linux.sh
200 my-label` stamps a custom id (handy for branch builds, below). `lchat stage`
201 reads the id out of the binary itself, so the store can't mislabel a build.
202- The build script targets linux/amd64; on an arm64 box set
203 `GOARCH=arm64` (edit or inline-override the script).
204- **⚠️ `deploy --all` moves *every* instance**, including one you've pinned to
205 a branch build. Before promoting a fleet, check the VERSION column of
206 `lchat instances` β€” if a canary instance is meant to stay on its branch
207 build, deploy the others individually instead of using `--all`.
208- To also refresh the **control/router** binary (rarely needed β€” instance
209 builds don't require it): atomically replace `/opt/lchat/lchat` (`cp` to a
210 temp name + `mv` over it) and `systemctl restart lchat-router`.
211
212### Verifying a canary
213
214"Verify" should be more than a 200. A reasonable checklist for a few minutes on
215the canary instance before promoting:
216
217- `curl -s https://<host>/api/version` reports the new build id.
218- Open the app in a browser: history decrypts, sending a message round-trips,
219 and a WebSocket reconnect happened cleanly (no stuck "connecting").
220- If the change touched uploads/push/bots, exercise that path specifically.
221- `journalctl -u lchat@<host> -e` shows no new errors.
222
223## Standing up a canary *instance* (branch builds)
224
225Sometimes the thing to prove isn't a build of `main` on a real instance, but an
226**unmerged branch** on a **throwaway instance** β€” real TLS, real devices, zero
227real users, and the fleet (and the control binary) untouched. Because versions
228are per-instance, this needs no special machinery:
229
230```sh
231# on your machine, on the feature branch:
232./deploy/build-linux.sh # id = the branch's git short sha
233scp lchat-linux-amd64 root@<box>:/opt/lchat/incoming
234
235# on the box:
236/opt/lchat/lchat stage --file /opt/lchat/incoming # β†’ <branch-ver>
237/opt/lchat/lchat add-instance canary-feature.example.com # starts on the CURRENT fleet build
238/opt/lchat/lchat deploy canary-feature.example.com --version <branch-ver> # pin it to the branch
239```
240
241The branch binary exists only in the version store; nothing else runs it.
242`lchat instances` shows the skew explicitly. When the feature merges and ships:
243
244```sh
245/opt/lchat/lchat deploy canary-feature.example.com --version <new-main-ver> # rejoin the fleet
246# …or tear the whole instance down (below) if it was a throwaway.
247```
248
249Two cautions:
250
251- Data written by a branch build persists in `/data/<host>.db`. Migrations are
252 additive so *moving forward* is safe, but a branch that created tables/rows a
253 later design abandons leaves them behind. For feature trials, prefer a
254 **throwaway** instance you'll delete over pinning a real one.
255- Remember the `deploy --all` warning above β€” a fleet promotion will stomp the
256 pin.
257
258## Removing an instance
259
260`remove` is deliberately data-safe by default β€” it only stops *routing*:
261
262```sh
263/opt/lchat/lchat remove chat.example.com # route deleted; instance still running, DB/env kept
264/opt/lchat/lchat remove chat.example.com --stop # also stop+disable lchat@<host> and lchat-bot@<host>
265```
266
267Even with `--stop`, the DB and env file stay on disk, so the instance can be
268resurrected: re-run `add-instance` passing `--admin-token` with the token still
269in `/data/<host>.env` (a plain `add-instance` would mint a fresh token and
270invalidate saved admin links). **Full teardown** β€” only for an instance you truly mean to destroy β€” is the
271manual part, on the box:
272
273```sh
274rm /data/chat.example.com.db /data/chat.example.com.db-wal /data/chat.example.com.db-shm # the DB
275rm /data/chat.example.com.env # the admin token
276rm -rf /data/chat.example.com-blobs # disk-backed attachments (if present)
277rm -rf /data/chat.example.com-bot /data/chat.example.com-bot.env # sidecar sessions + env (if it had bots)
278rm /opt/lchat/live/chat.example.com # the version pointer
279```
280
281…then delete the host's DNS record (if it wasn't wildcard-covered) and the
282stored admin token. There is no undo after the DB is gone.
283
284## Sibling services (home server, push relay, third-party backends)
285
286The router can front processes it doesn't manage β€” anything already listening
287on a local unix socket:
288
289```sh
290/opt/lchat/lchat add-service home.example.com --socket /run/lchat/home.sock
291```
292
293Unlike an instance, the router only *proxies* a service: no unit is created, no
294version is tracked (`instances` shows `-`), and the backend must already be up
295when you add it. This is how the home server (`home/`) and push relay (`push/`)
296run on the same box β€” each is its own binary + systemd unit
297(`lchat-home.service` / `lchat-push.service`, installed by `bootstrap`, inert
298until their `/data/{home,push}.env` exists), deployed by replacing its binary
299and restarting its unit.
300
301## Migrating an instance in from elsewhere
302
303Moving an existing instance (from another box, or another hosting setup) onto
304this one is a separate, validated procedure β€” `VACUUM INTO` snapshot, copy the
305DB **and its `<host>-blobs/` directory**, `add-instance --admin-token` to
306preserve the token, DNS flip with the old deployment kept as rollback. Follow
307`docs/instance-migration.md`; the key property is that the **hostname must not
308change** (browser keys are per-origin β€” a new origin means every member re-keys
309via add-device links).
310
311## Troubleshooting
312
313- **`lchat instances`** is the first stop: per-route status
314 (`creating`/`ready`/`failed`), the running VERSION, and a `last error` line
315 when something failed.
316- **Logs:** `journalctl -u lchat@<host> -f` (instance),
317 `-u lchat-router -f` (TLS/routing), `-u lchat-bot@<host> -f` (sidecar).
318- **Cert won't issue:** check DNS actually resolves to the box, and `:80` is
319 reachable from the internet (HTTP-01 answers there). The router only issues
320 for hosts in its route table β€” an unknown host is refused by `HostPolicy`
321 before ACME is attempted.
322- **On-box clients (sidecars) can't reach an instance / reach a stale server:**
323 the `/etc/hosts` loopback pins are missing or were regenerated on boot β€” see
324 the bots section above.
325- **Health check by hand:** `curl -s https://<host>/api/version` (build id), or
326 on the box against the socket:
327 `curl -s --unix-socket /run/lchat/<host>.sock http://localhost/api/version`.
328- **Recover an admin link:** `lchat admin-link <host>`.
329- **A deploy "failed" but the fleet is half-promoted:** that's by design β€”
330 `deploy --all` stops at the first unhealthy instance and leaves earlier ones
331 on the new build. Fix the failing instance (its own deploy auto-rolled it
332 back), then re-run.
333