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:
@@ -27,6 +27,7 @@ TTS_ENDPOINT= # e.g. http://127.0.0.1:5005 — empty disable
|
||||
TTS_ENDPOINT_ZH= # e.g. http://127.0.0.1:5006 — Chinese Piper instance
|
||||
TTS_VOICE_EN=en_US-amy-medium # Piper voice id for English
|
||||
TTS_VOICE_ZH=zh_CN-huayan-medium # Piper voice id for Chinese
|
||||
TTS_PATH=/ # path Piper serves synthesis on: "/" up to piper-tts 1.5, "/synthesize" from 1.6.0
|
||||
TTS_CACHE_DIR=./data/tts # on-disk store for synthesized clips (content-addressed)
|
||||
TTS_TIMEOUT=15s
|
||||
TTS_AUDIO_FORMAT=mp3 # mp3 | opus | wav — mp3/opus transcode Piper's WAV via ffmpeg
|
||||
|
||||
+16
-13
@@ -137,7 +137,7 @@ Multi-session build. **Source of truth for what's done and what's next.** Update
|
||||
### Deferred (post-v1-local)
|
||||
- [x] **Multi-user groundwork** (2026-07-26) — request-scoped identity. New `internal/auth`: `Middleware(Resolver)` resolves the caller once per API request and stores the id in the context; handlers read it via `auth.UserID(r.Context())` instead of naming `db.LocalUserID`. `StaticResolver(db.LocalUserID)` keeps Petal single-user today. **Auth itself is still deferred** — but every query is now scoped to whoever the resolver says is calling, so landing Authentik is a one-line change in `main.go` plus a `Resolver` implementation.
|
||||
- [ ] Copyleaks Tier-2 + webhook HMAC (still parked — needs a public webhook; revisit after Phase 15)
|
||||
- Authentik OIDC + deploy: **no longer deferred — expanded into Phases 15–17 below** (decisions ratified 2026-07-26; see `MULTIUSER_PLAN.md`)
|
||||
- Authentik OIDC + deploy: **no longer deferred — expanded into Phases 15–17 below** (decisions ratified 2026-07-26; see `MULTIUSER_PLAN.md`). Deploy landed 2026-07-26 (Phase 15); Authentik itself already runs on the same VPS, so Phase 16 has its IdP waiting.
|
||||
|
||||
## Execution phases 15–22 (added 2026-07-26)
|
||||
|
||||
@@ -147,15 +147,16 @@ Decisions behind these are ratified in `MULTIUSER_PLAN.md` (all OPENs settled) a
|
||||
- **Aesthetic + bilingual-in-the-pair copy remain acceptance criteria** on every user-visible change.
|
||||
- Verify per project convention: go build/vet/test, tsc, vite build, vitest, live smoke on a throwaway DB/port.
|
||||
|
||||
### Phase 15 — Deploy plumbing (parodia.dev + headscale)
|
||||
Petal hosted on the parodia.dev VPS; vLLM stays on millenia over headscale. Auth (Phase 16) needs the stable `BASE_URL`/redirect URI this phase creates.
|
||||
- [ ] Dockerfile (multi-stage: `npm run build` → `go build`, single binary + data dir volume) + docker-compose
|
||||
- [ ] Traefik route + HTTPS on the chosen hostname (e.g. `write.parodia.dev`); stable `BASE_URL` env
|
||||
- [ ] `LLM_ENDPOINT` → millenia's **headscale** address; verify vLLM binds only to the headscale interface (not LAN/0.0.0.0); revisit `LLM_TIMEOUT` for the WAN+VPN round trip
|
||||
- [ ] TTS → the Piper already installed on parodia (VPS-local; per-language instances as on millenia — EN now, zh; see `deploy/` artifacts for the service pattern)
|
||||
- [ ] Off-VPS nightly backup of `petal.db` (+`-wal`/`-shm` coherently, e.g. `sqlite3 .backup`) to millenia over the VPN; document restore
|
||||
- [ ] Migrate the live millenia data to the VPS (app stopped, backup first, counts verified) — or decide the millenia instance stays canonical until Phase 17 completes
|
||||
- [ ] Acceptance: full editor works with the VPN link **down** (spell/gloss/garden/search/export/TTS all fine; checkpoint shows the warm 小助手在休息 state); `/api/health` public; HTTPS end-to-end
|
||||
### Phase 15 — Deploy plumbing (parodia.dev + headscale) ✅ (2026-07-26, two items outstanding on millenia)
|
||||
Petal hosted on the parodia.dev VPS; vLLM stays on millenia over headscale. Auth (Phase 16) needs the stable `BASE_URL`/redirect URI this phase creates. **Hostname: `petal.parodia.dev`** (DNS already pointed at the VPS). Runbook: `deploy/README.md`.
|
||||
- [x] Dockerfile (multi-stage: `npm run build` → `go build` → alpine runtime) + docker-compose. CGO stays off (modernc SQLite is pure Go), so the runtime layer exists only for **ffmpeg** (read-aloud transcode) and **tzdata** (the bedtime nag + night mode read the local clock). Non-root; `/data` is the single writable mount. **`.dockerignore`** keeps the live DB and a stale local `web/dist` out of the image.
|
||||
- [x] Traefik route + HTTPS on `petal.parodia.dev` — labels follow the host's existing convention (external `traefik` network, `web-secure` entrypoint, `default` cert resolver, `compression@file`) plus Petal's own header middleware. No host port is published; Traefik is the only way in. `BASE_URL=https://petal.parodia.dev` set for Phase 16's redirect URI.
|
||||
- [x] `LLM_ENDPOINT` → millenia's headscale address (`100.64.0.2:8000`); `LLM_TIMEOUT` **30s → 90s** for the WAN+VPN round trip (the voice/collocation passes send a whole document and the timeout is a hard deadline on `Complete`). ⚠️ **Outstanding, needs millenia access:** vLLM currently refuses connections from the VPS — it isn't bound to the headscale interface. Petal degrades correctly meanwhile (verified). Fix + model-id capture documented in `deploy/README.md` §3.
|
||||
- [x] TTS — **deviation from the plan, deliberate**: Piper was *not* actually installed on parodia, and the `reala` account has no lingering session to keep user systemd units alive. Runs as **two sibling containers** (`piper-en`, `piper-zh`) off one image, models cached in a shared volume, on an internal network with no published ports. pt-PT in Phase 21 is a fourth service, not a new image. **Found + fixed while wiring**: piper-tts 1.6.0 moved synthesis from `POST /` to `POST /synthesize` (identical body); rather than pin both deployments to one release, the path is now config (`TTS_PATH`, default `/` so millenia is untouched).
|
||||
- [x] Off-VPS nightly backup + restore — `db.Backup` uses **`VACUUM INTO`**, not a file copy: in WAL mode the newest committed pages may live in `petal.db-wal`, 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 (safe against the live app), and emits a single file with no companions; it refuses an existing destination so a failed run can't destroy the last good backup. Driven by a `-backup` flag on the binary, so the nightly job snapshots the *running container*. `deploy/backup-petal.sh` gzips, pushes to millenia over headscale with a post-transfer size check, and prunes both sides (7d local / 30d remote). Cron installed at 03:15. Restore documented + verified. ⚠️ **Outstanding, needs millenia access:** parodia's ssh key isn't authorized on millenia, so `REMOTE_HOST` is empty and backups are VPS-local only — the one command to fix it is in `deploy/README.md` §5.
|
||||
- [x] Migration decision: **millenia stays canonical** (user's call). The VPS runs an empty staging DB so she moves accounts exactly once, when Phase 16/17 land.
|
||||
- [x] **Interim edge gate** (not in the original plan; added once the instance was live). Petal authenticates nobody yet — `StaticResolver` hands every request the same `local` user — so on a public host the whole API was open to read/write and image upload. Traefik basic auth holds the door until Phase 16, with `/api/health` exempt on its own higher-priority router. Deleted when OIDC lands.
|
||||
- [x] Acceptance — verified over public HTTPS **with the LLM link down** (it genuinely is): Hunspell dictionaries 200, gloss + word lookup (incl. phonetic) 200, doc create/save, FTS search on 春天, md + docx export, vocab capture/list, read-aloud EN + zh (real mp3 via ffmpeg, cache hit on repeat, 404 for an unconfigured language so the client falls back). `POST /check` → the warm 502 that renders as 小助手在休息. `/api/health` public; HTTP 301 → HTTPS with a valid cert.
|
||||
|
||||
### Phase 16 — Auth (in-app OIDC) + image-store ownership
|
||||
Option B ratified. `go-oidc` + `x/oauth2`; config fields already exist. The `Resolver` seam from Phase 0 is the only integration point.
|
||||
@@ -186,12 +187,13 @@ Pure refactor, zero visible change; prerequisite for every new pair (SUGGESTIONS
|
||||
### Phase 20 — DreamDict as a lexicon provider
|
||||
Option 3 ratified (import package, read-only `dict.db`). **Prerequisite in the dreamdict repo:** rename its module path (or add a `replace` for dev).
|
||||
- [ ] Provider seam behind the existing `lexicon` interface; DreamDict provider opens `dict.db` read-only (modernc driver, second handle beside `petal.db`); graceful "no data" when the file is absent
|
||||
- [ ] pt-PT + fr wired to DreamDict (nothing to regress); **zh stays on ECDICT** until compared on real lookups from her documents — converge only if quality holds
|
||||
- [ ] pt-PT + fr + **es** wired to DreamDict (nothing to regress); **zh stays on ECDICT** until compared on real lookups from her documents — converge only if quality holds
|
||||
- **es is no longer gated** (2026-07-26): DreamDict grew Spanish support, so the "maybe es" in the pair model is now a real option and the provider seam should cover it from day one — it costs nothing here and saves re-opening the package later.
|
||||
- [ ] Surface the new fields where cheap: frequency/difficulty chip in WordCard; etymology line (cognate hook for en-natives)
|
||||
- [ ] Deploy: `dict.db` ships in the data dir alongside `petal.db`
|
||||
|
||||
### Phase 21 — The pt-PT pair (first Latin pair, proves the model)
|
||||
SUGGESTIONS §1/§3/§3a. French follows the same groove afterwards; Spanish stays gated on DreamDict es data.
|
||||
SUGGESTIONS §1/§3/§3a. French and **Spanish** follow the same groove afterwards — es is no longer gated now that DreamDict has Spanish data (2026-07-26). pt-PT still goes first: it's the pair with a real user behind it, and it's the one that proves the langpack + both-dictionaries model.
|
||||
- [ ] Hunspell pt-PT vendored like en-US; **both-dictionaries spellcheck** (flag only if wrong in both; pills from both) — the no-detector stance, Q1 settled
|
||||
- [ ] Gloss/WordCard both directions; on en/pt collisions show both compactly, never hide either
|
||||
- [ ] Prompts pinned to **European Portuguese, never pt-BR** (explicit in every prompt); pt-PT langpack copy written and **reviewed by a pt-PT speaker before trusted**
|
||||
@@ -210,7 +212,7 @@ Each item independent and small; order within is free (SUGGESTIONS §5–§6).
|
||||
|
||||
### Later / explicitly not now
|
||||
- Learner-facing Chinese writing (the zh pair's second direction) — own phase with its own spec (SUGGESTIONS §4); only after Phases 19–21 prove the pair model
|
||||
- Spanish pair — gated on DreamDict growing an es dataset
|
||||
- ~~Spanish pair — gated on DreamDict growing an es dataset~~ **ungated 2026-07-26** (DreamDict added Spanish). Now a normal follow-on pair after pt-PT, alongside fr — see Phases 20/21.
|
||||
- Reactive-animation puppy companion — wishlist, low priority; `companions.ts` roster + mood engine is the drop-in point
|
||||
- Copyleaks Tier-2 — revisit once Phase 15 provides a public webhook endpoint
|
||||
|
||||
@@ -222,6 +224,7 @@ Each item independent and small; order within is free (SUGGESTIONS §5–§6).
|
||||
- [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above)
|
||||
|
||||
## Session log
|
||||
- 2026-07-26: **Phase 15 complete — Petal is deployed at https://petal.parodia.dev** (user: "let's start this build plan"; scope confirmed as artifacts **plus** the actual deploy, millenia stays canonical, hostname `petal.parodia.dev`). Stack: `Dockerfile` (node → go → alpine; CGO off, so the runtime layer carries only ffmpeg + tzdata), `docker-compose.yml` behind the host's existing Traefik, and **two Piper sidecars** instead of the planned host systemd units — Piper turned out never to have been installed on the VPS and the account has no lingering session, so containers on an internal network with no published ports are both simpler and tighter. **Three real problems found by deploying rather than by planning:** (1) the image's `petal` user (uid 10001) has no claim on a bind-mounted host directory → SQLite `unable to open database file (14)` and a restart loop; the container now runs as the stack directory's owner (still non-root, and the host account keeps write access the backup script needs); (2) piper-tts **1.6.0 moved synthesis from `POST /` to `POST /synthesize`** with an identical body → every read-aloud 405'd; rather than pin both deployments to one Piper release the path became config (`TTS_PATH`, default `/`, so millenia is untouched); (3) once the instance was live it was **a public, writable, unauthenticated API** — Petal authenticates nobody yet, so Traefik basic auth now holds the door until Phase 16, with `/api/health` exempt on its own higher-priority router. Backups: `db.Backup` via **`VACUUM INTO`** (WAL-coherent, no write lock, single file, refuses to overwrite) behind a `-backup` flag so the nightly job snapshots the running container; `deploy/backup-petal.sh` compresses, pushes to millenia with a size check, prunes both sides; cron at 03:15; restore documented and verified by round-tripping an archive through the binary. Tests: `internal/db/backup_test.go` (WAL capture, seeded user survives, no `-wal`/`-shm` companions, refuses an existing destination, missing source), `internal/tts` path-normalisation + configured-path. go build/vet/test clean. **Acceptance verified over public HTTPS with the LLM link genuinely down**: dictionaries, gloss, word lookup + phonetic, doc create/save, CJK FTS search, md/docx export, vocab capture, read-aloud EN + zh (real mp3, cache hit, 404-fallback for an unconfigured language) — all fine; `/check` → the warm 502 that renders as 小助手在休息; health public, HTTP→HTTPS with a valid cert. **Two items outstanding, both needing millenia access I don't have**: vLLM isn't bound to its headscale interface (so no AI pass works yet), and parodia's ssh key isn't authorized on millenia (so backups are VPS-local only — not yet a real off-box backup). Both have one-command fixes in `deploy/README.md` §3 and §5. Also this session: **DreamDict gained Spanish**, so the es pair is no longer gated — folded into Phases 20/21 and the "Later" bucket. Next: **Phase 16 (auth)** — Authentik already runs on the same VPS.
|
||||
- 2026-07-26: **Product direction + execution plan ratified** (user: "make it so, number one"). New `SUGGESTIONS.md` (product rationale for the language-learning direction): the **pair model** — every user gets one (English + X) pair, X ∈ {zh, pt-PT, fr, maybe es}, bilingual UI in the pair, type in either language, direction inferred (no detector: both-dictionaries spellcheck, show-both gloss on collision); **langpacks** keyed by X; **LLM-minimalism** as a standing principle (LLM is garnish, never a gatekeeper — grammar-lite rule pack + embedded miscollocation list planned as code-first layers). Deployment settled: Petal on the **parodia.dev VPS**, vLLM on millenia over **headscale** (the only cross-VPN dependency; Piper is VPS-local). All `MULTIUSER_PLAN.md` OPENs ratified: in-app OIDC (B), 30-day sliding sessions, allowlist, migration script, image-store fix with auth, DreamDict via package import (Option 3, module rename prereq in the dreamdict repo), zh stays on ECDICT until compared. Everything expanded into **Phases 15–22** above with standing rules (isolation tests same-commit, LLM-minimalism, bilingual aesthetic). Ready for implementation handoff starting at Phase 15.
|
||||
- 2026-07-26: **Multi-user groundwork** (user: "let's start preparing Petal for multi-user support"; scope agreed as plumbing-only, aimed at Authentik). New **`internal/auth`** package — context-carried identity (`WithUser`/`UserID`), a `Resolver` seam (`Resolve(*http.Request) (string, error)`), `StaticResolver` for today's single user, and `Middleware` that 401s anything unresolved. `main.go` splits `/api` into a **public group** (`/health`, `/version` — a monitoring probe must not need a session) and an **authenticated group** carrying everything else. All ~35 `db.LocalUserID` call sites across `docs`/`suggestions`/`vocab` now read the caller from the request; helpers that had no request in scope (`fetch`, `ownsDoc`, `ownsTag`, `tagsByDoc`, `fetchVersion`, `passportVersions`, `fetchPending`, vocab `fetch`) take an explicit `userID` param. `UserID` returns `""` rather than panicking when middleware is absent, so a mis-wired route **fails closed** (every query is `WHERE user_id = ?` → matches nothing). **Two real access-control gaps found and fixed while threading**: `setStatus` (accept/dismiss) updated a suggestion by bare id with **no ownership check at all**, and `fetchPending`/`listForDoc` read a document's suggestions by `doc_id` alone — a leak of the quoted source sentences. Both now scope through `documents.user_id`. **A third bug was caught by the new tests, not by the compiler**: `docs.fetch` gained a `userID` parameter but kept binding `db.LocalUserID` in the query — legal Go (unused params compile), silently unscoped, and it would have shipped. New tests: `internal/auth/auth_test.go` (round-trip, absent-context, both 401 paths) and **two-user isolation suites** (`docs/isolation_test.go`, `suggestions/isolation_test.go`) that mount the same routers twice behind two resolvers over one DB and assert a stranger gets 404 on get/update/delete/export/passport/snapshot/version-preview/restore/tag-assign/tag-rename/tag-delete/suggestion-accept/dismiss, sees nothing in list/search/version-list, and leaves the owner's data untouched. go build/vet/test all clean. **Still global, deliberately out of scope** (flagged for the auth phase): the image store is content-addressed with no per-user association or DB row — any authenticated user holding a hash can fetch any image (capability-URL security, needs a table + migration to fix); `export-all` is correctly scoped; frontend `localStorage` keys (`petal.spell.personal`, `petal.companion`, sound/petals prefs) are per-browser, not per-account, so they'd bleed across users sharing a device.
|
||||
- 2026-06-26: **Phases 12 + 13 complete** (collocation coach + vocabulary garden — "finish the rest of the build plan except Authentik/Traefik"). **Phase 12**: collocation drops in as a third suggestion family reusing the whole `runPass`/`pendingScope` machinery — `llm/collocation.go` (`RunCollocation`, 25s floor, reuses `ParseCheckpoint`), `collocationSystemPrompt`/`CollocationMessages` (warm "Natives usually say…" + Mandarin gloss, defers grammar elsewhere), migration `0005` **rebuilds** the suggestions table to extend the `type` CHECK (SQLite can't ALTER a CHECK), `collocationScope` + `CollocationLimit` + `POST /{id}/collocation`. **Caught a latent bug**: `grammarScope` was `type != 'voice'` → would wipe collocation flags; fixed to `type NOT IN ('voice','collocation')`. Frontend: `--color-blossom` pink, "Make it sound natural 🌸" toolbar pill, `collocating`/`runCollocation` in `useCheckpoint`, StatusBar dot — all into the existing rail/card. **Phase 13**: new `internal/vocab` package — migration `0006_vocab_garden` (`vocab_words`, SM-2-lite columns, doc_id `ON DELETE SET NULL`, `UNIQUE(user_id,word)`), `scheduler.go` (Leitner ladder 1/3/7/16/35 → geometric; gentle "again", no streak-shaming), `handlers.go` (capture-upsert/list/due/review/delete, all owner-scoped, time math via SQLite `datetime()` so stored values stay canonical-UTC). Auto-capture wired into `EditorCore.openWordLookup` (only dictionary-known words, captures the surrounding sentence + doc_id) + a 🤍/💚 toggle on `WordCard`. `GardenPanel` slide-over: blossom grid (bloom stage by reps), flashcard review (sentence blanked, flip, again/good/easy, direction alternates recognition↔production), sleepy-kitten footer; opened from a global 🌷 header button. Tests: `TestCollocationPassCoexists`, vocab `scheduler_test.go` + `handlers_test.go`, db CHECK test extended. go build/vet/test + tsc + vite + vitest (51/51) all clean; migration verified against a copy of the live DB; live backend smoke (throwaway DB) walked the full vocab lifecycle + the warm-502 collocation path. **Remaining: only the deferred infra bucket** — Authentik auth, Copyleaks Tier-2 (needs a public webhook), Docker/Traefik/deploy — all on hold per the user's "except Authentik/Traefik".
|
||||
|
||||
@@ -32,6 +32,16 @@ cd .. && go build -o petal ./cmd/server
|
||||
|
||||
Configuration is via environment variables — copy `.env.example` to `.env`.
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
docker compose up -d --build # petal + the two Piper read-aloud sidecars
|
||||
```
|
||||
|
||||
Behind Traefik on the parodia.dev VPS; vLLM stays on millenia over headscale.
|
||||
Full runbook — first deploy, the LLM link, backups and restore — in
|
||||
[`deploy/README.md`](./deploy/README.md).
|
||||
|
||||
## Status
|
||||
Early build, multi-session. Auth (Authentik), Copyleaks plagiarism, and Docker/Traefik
|
||||
deployment are deferred — see `BUILD_PLAN.md`.
|
||||
Early build, multi-session. Auth (Authentik OIDC) is next; Copyleaks plagiarism is
|
||||
still parked — see `BUILD_PLAN.md`.
|
||||
|
||||
+226
-48
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user