Document the Phase 15 deploy and ungate the Spanish pair

deploy/README.md becomes the real runbook: the VPS stack, Traefik, the
headscale LLM link, the interim edge gate, backups and restore. The
millenia Piper notes move to an appendix -- that instance still runs
them, and it is still canonical.

Two items are called out as outstanding rather than done, because both
need access to millenia: vLLM is not bound to its headscale interface,
so no AI pass works from the VPS yet, and parodia's ssh key is not
authorized there, so backups are VPS-local only -- which is not a backup
in the sense that matters. Each has its one-command fix written down.

Also folds in DreamDict gaining Spanish: es was explicitly gated on that
dataset existing, so it moves from "Later / not now" to a normal
follow-on pair after pt-PT, and the Phase 20 provider seam should cover
it from the start.
This commit is contained in:
prosolis
2026-07-26 23:27:06 -07:00
parent 5b221cc7a3
commit d01a0f1f0a
4 changed files with 255 additions and 63 deletions
+226 -48
View File
@@ -1,57 +1,216 @@
# Deploying read-aloud (Piper TTS) to millenia
# Deploying Petal
Petal's read-aloud generates audio with a local **Piper** neural-TTS server and
transcodes it to mp3 with **ffmpeg**. Both run on millenia (192.168.1.212);
nothing leaves the box. If Piper is down or `TTS_ENDPOINT` is unset, the frontend
falls back to the browser's Web Speech API automatically.
Two deployments exist right now:
## 1. Piper as a systemd service (one-time)
| | host | shape | status |
|---|---|---|---|
| **millenia** | `192.168.1.212` / `100.64.0.2` | bare binary in a screen session on `:8088`, Piper as user systemd units | **canonical** — her real writing lives here |
| **parodia** | `petal.parodia.dev` / `100.64.0.1` | docker compose behind the host's Traefik | staging; empty database |
millenia stays canonical until Phase 16's auth lands, so she only moves accounts
once. Everything below is the VPS side; the millenia Piper notes are kept in the
appendix because that instance still runs them.
---
## 1. The VPS stack
`docker-compose.yml` at the repo root brings up three containers:
- **petal** — the single Go binary with the frontend embedded. Publishes no host
port; Traefik is the only way in.
- **piper-en** / **piper-zh** — read-aloud. Each Piper HTTP server loads exactly
one voice, so English and Chinese are separate containers off one image, with
the models cached in a shared volume. They sit on an internal network with no
published ports, so only Petal can reach them. Adding pt-PT in Phase 21 is a
fourth service, not a new image.
They run as containers rather than the host systemd units millenia uses because
Piper was never actually installed on the VPS, and the `reala` account has no
lingering session to keep user units alive across logout.
### Prerequisites on the host
- Docker with the compose plugin, and the existing external `traefik` network
- A DNS A record for the hostname pointing at the VPS (`petal.parodia.dev` is
already in place)
### First deploy
```bash
ssh reala@100.64.0.1
git clone https://gitea.parodia.dev/drwily/petal.git ~/petal
cd ~/petal
cp deploy/petal.env.example .env
```
Then edit `.env`:
- `PETAL_UID` / `PETAL_GID``id -u` / `id -g` for this account. `./data` is a
bind mount, so the image's own `petal` user has no claim on it; a mismatch
shows up as `unable to open database file (14)` and a restart loop.
- `PETAL_BASIC_AUTH` — the interim edge gate, see §4.
- `LLM_MODEL` / `LLM_CHAT_MODEL` — see §3.
```bash
mkdir -p data/backups
docker compose up -d --build
docker compose ps # all three healthy
```
### Updating
```bash
cd ~/petal && git pull && docker compose up -d --build
```
The frontend is embedded in the binary, so a rebuild is the whole deploy. The
client polls `/api/version` (a hash of the built `index.html`) and offers a
refresh when it changes.
---
## 2. What Traefik does
Labels follow the convention the other services on this box use: the external
`traefik` network, the `web-secure` entrypoint, the `default` cert resolver and
`compression@file`. Petal adds its own response-header middleware
(`frame-ancestors 'self'`, HSTS, nosniff, `Referrer-Policy: same-origin`).
`/api/health` is deliberately on its own higher-priority router with no
middleware: a monitoring probe must not need a credential, and the endpoint
carries no user data.
---
## 3. The LLM link over headscale
The vLLM backend stays on millenia and is reached over headscale
(`100.64.0.2`). **This is the only cross-VPN dependency**, and by the
LLM-minimalism principle it never gates essential functionality — spell check,
gloss, vocabulary garden, search, export and read-aloud all keep working with
the link down, and the status bar shows the warm
`🌙 小助手在休息 · Petal's helper is resting · 文字已保存`.
`LLM_TIMEOUT` is raised from the local-network default of 30s to **90s**: the
voice and collocation passes send a whole document, the timeout is a hard
deadline on the completion call, and a WAN+VPN round trip eats the margin.
### Outstanding — vLLM is not yet listening on headscale
As of the Phase 15 deploy, `100.64.0.2:8000` refuses connections from the VPS:
vLLM is bound to loopback or the LAN interface, not the headscale one. Petal
degrades correctly (verified — `POST /api/docs/{id}/check` returns the warm
502), but no AI pass will work until this is fixed on millenia:
- start vLLM with `--host 100.64.0.2` — the headscale address specifically,
**not** `0.0.0.0`; the other end of this link is a public host
- confirm with `ss -lntp | grep 8000` that it binds only that interface
- from the VPS: `curl -s http://100.64.0.2:8000/v1/models`
- put the model id it reports into `LLM_MODEL` / `LLM_CHAT_MODEL` in `.env`,
then `docker compose up -d`
---
## 4. Interim edge gate (delete when Phase 16 lands)
Petal authenticates nobody yet — `StaticResolver` hands every request the same
`local` user. On a public host that means anyone who finds the hostname can read
and write documents and fill the disk with image uploads, so Traefik holds the
door with basic auth until the OIDC flow exists.
Generate a credential:
```bash
htpasswd -nbB petal 'your-password' # or any bcrypt htpasswd generator
```
and put the resulting `user:hash` pair in `.env` as `PETAL_BASIC_AUTH`.
When Phase 16 lands, delete the `petal-auth` middleware label, the
`petal-health` router labels, and this section.
---
## 5. Backups
`deploy/backup-petal.sh` runs nightly from cron at 03:15:
```
15 3 * * * REMOTE_HOST= /bin/bash $HOME/petal/deploy/backup-petal.sh >> $HOME/petal/data/backups/backup.log 2>&1
```
The snapshot goes through `petal -backup`, which uses SQLite's `VACUUM INTO`.
That matters: Petal runs in WAL mode, so the newest committed pages may live in
`petal.db-wal` rather than `petal.db`, and copying the three files separately can
capture a torn mid-checkpoint state. `VACUUM INTO` reads one coherent snapshot
including the WAL, takes no write lock (so it is safe against the live app), and
emits a single file with no `-wal`/`-shm` companions. It refuses an existing
destination, so a failed run cannot destroy the last good backup.
The script then gzips, pushes off-box, verifies the transferred size, and prunes
both sides (7 days local, 30 days remote).
### Outstanding — the off-VPS push is not yet enabled
`REMOTE_HOST` is empty in the cron line, so backups are currently **local to the
VPS only** — which is not a backup in the sense that matters. parodia's ssh key
is not authorized on millenia. To enable it, on millenia:
```bash
echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICGL6fS7IKCs8xFxUoH/bI/0kq4AzW05bcfV2XHoEXYM ditto-deploy@parodia-box' >> ~/.ssh/authorized_keys
```
then on the VPS, prove it works by hand before touching cron:
```bash
cd ~/petal && REMOTE_USER=<millenia-user> ./deploy/backup-petal.sh
```
and set `REMOTE_USER=<millenia-user> REMOTE_HOST=100.64.0.2` in the cron line.
### Restore
```bash
cd ~/petal
docker compose stop petal # stop writers first
mv data/petal.db data/petal.db.before-restore # keep the current state
rm -f data/petal.db-wal data/petal.db-shm # a stale WAL against a new file
gunzip -c data/backups/petal-<stamp>.db.gz > data/petal.db
docker compose start petal
docker compose logs petal --tail 5 # expect "database ready"
```
To sanity-check an archive before committing to it, restore it into a scratch
directory and have Petal open it:
```bash
mkdir -p /tmp/restore-check
gunzip -c data/backups/petal-<stamp>.db.gz > /tmp/restore-check/petal.db
docker run --rm -v /tmp/restore-check:/data --user "$(id -u):$(id -g)" \
--entrypoint sh petal:local -c '/app/petal -backup /data/verify.db'
```
A clean exit means the file opens and reads end to end.
---
## Appendix — Piper on millenia (user systemd units)
millenia still runs Piper as user services; these are the original notes.
```bash
# from this repo, on your workstation:
scp deploy/piper.service deploy/setup-piper.sh 192.168.1.212:/tmp/
ssh 192.168.1.212 'cd /tmp && sudo ./setup-piper.sh'
```
`setup-piper.sh` creates `~/piper/venv`, installs `piper-tts[http]`, downloads the
`en_US-amy-medium` voice into `~/piper/voices`, installs+enables `piper.service`
(loopback :5005), and smoke-tests it. Idempotent.
`setup-piper.sh` creates `~/piper/venv`, installs `piper-tts[http]`, downloads
`en_US-amy-medium` into `~/piper/voices`, installs and enables `piper.service`
(loopback `:5005`), and smoke-tests it. Idempotent. Check it with
`systemctl status piper` / `journalctl -u piper -f`.
Check it any time: `systemctl status piper`, `journalctl -u piper -f`.
## 2. Point petal at Piper + redeploy the binary
Add to petal's environment (its `.env` or launch env):
```
TTS_ENDPOINT=http://127.0.0.1:5005
TTS_VOICE_EN=en_US-amy-medium
TTS_AUDIO_FORMAT=mp3
```
Then ship the rebuilt binary (`go build -o petal ./cmd/server` already done) and
restart the petal `:8088` session. Confirm the log line:
`read-aloud enabled (TTS endpoint=http://127.0.0.1:5005)`.
## 3. Verify
```bash
# on millenia — end-to-end through petal, including ffmpeg transcode:
curl -sf -X POST localhost:8088/api/tts \
-H 'Content-Type: application/json' \
-d '{"text":"hello there","lang":"en-US"}' -o /tmp/petal-tts.mp3 \
&& file /tmp/petal-tts.mp3 # expect: Audio file ... MPEG ... layer III
```
- Second identical call is served from the cache (`~/petal/.../data/tts/*.mp3`).
- A language with no configured instance returns 404 → client uses Web Speech.
- Browser check via the uitest harness (`~/petal/uitest`): tap a word in the
WordCard / select a sentence and hit speak — expect the natural Piper voice.
## Chinese voice (live)
Each Piper HTTP server loads ONE model, so Chinese runs as a **second instance**:
`piper-zh.service` on :5006 with `zh_CN-huayan-medium`. Deployed via:
Chinese runs as a second instance (`piper-zh.service`, `:5006`,
`zh_CN-huayan-medium`):
```bash
scp deploy/piper-zh.service 192.168.1.212:~/.config/systemd/user/
@@ -60,6 +219,25 @@ ssh 192.168.1.212 'export XDG_RUNTIME_DIR=/run/user/$(id -u)
systemctl --user daemon-reload && systemctl --user enable --now piper-zh.service'
```
Then in petal's `start.sh`: `TTS_ENDPOINT_ZH=http://127.0.0.1:5006` and
`TTS_VOICE_ZH=zh_CN-huayan-medium`. The handler maps language → instance from config,
so adding more languages is just another instance + env pair (no code change).
Petal's env then carries `TTS_ENDPOINT=http://127.0.0.1:5005`,
`TTS_ENDPOINT_ZH=http://127.0.0.1:5006` and the matching voice ids. The handler
maps language → instance from config, so another language is another instance
plus an env pair, no code change.
**Piper version note:** piper-tts moved synthesis from `POST /` to
`POST /synthesize` in 1.6.0, with an identical request body. `TTS_PATH` selects
which — it defaults to `/` for millenia's older server, and the VPS compose sets
`/synthesize` for the 1.6.0 sidecars. If read-aloud starts returning 502 after a
Piper upgrade on millenia, that flag is the fix.
Verify end to end (through Petal, including the ffmpeg transcode):
```bash
curl -sf -X POST localhost:8088/api/tts \
-H 'Content-Type: application/json' \
-d '{"text":"hello there","lang":"en-US"}' -o /tmp/petal-tts.mp3 \
&& file /tmp/petal-tts.mp3 # expect: MPEG ADTS, layer III
```
A second identical call is served from the cache; a language with no configured
instance returns 404 so the client falls back to Web Speech.