Files
descrybe/docs/ops-runtime.md
T

185 lines
10 KiB
Markdown
Raw Normal View History

# Runtime ops notes (WS7)
Local/dev and production operator notes for platform, mail, billing, and WooCommerce schedules. **Do not commit secrets.**
**Env file policy:** one **root** `.env` (or secrets mapped into process env). Do not duplicate into `apps/api/.env` or tmp placeholders. Platform Stripe / EPREL / feed allowlist → `/admin/settings`; tenant OpenAI / marketing email / stores → `/integrations/ai`, `/integrations/email`, `/stores` — see [README.md](../README.md#environment-one-file).
## Health
- `GET /healthz` — liveness (no DB)
- `GET /readyz` — readiness: Postgres ping **and** a fresh worker heartbeat (`worker_id=processing`, stale after **60s**) plus queue probe; returns `maintenance` / `read_only` / `hypercare` flags and `checks.{database,worker,queue}`
- `GET /metrics` — Prometheus text (HTTP RED on API; worker sync series when `METRICS_ADDR` is set). Production Gate: loopback or `METRICS_PUBLIC=1`. Example scrape + alerts: [`deploy/prometheus/`](../deploy/prometheus/). Worker age for on-call is `/readyz` `worker_last_seen_age_s` (not a Prom series).
When the worker is down or stale, `/readyz` is **503** with short `error` plus operator `reason` (no secrets). Example (API-only / stale heartbeat):
```json
{
"status": "not_ready",
"service": "api",
"checks": { "database": "ok", "worker": "stale", "queue": "ok" },
"error": "worker heartbeat stale",
"reason": "Processing worker heartbeat older than 1m0s. API-only readiness 503 is expected — start or restart the worker (npm run dev includes it, or npm run dev:worker).",
"queue_pending": 5,
"worker_last_seen_age_s": 120
}
```
**Prefer one stack:** `npm run dev` runs **api + web + worker** (ports **28471** / **28472**). Compose starts **Postgres only** — the worker is a host process. Browsers need the web process — API-only leaves `:28472` **CONNECTION_REFUSED** even when `/readyz` is green.
If the API is already up without a worker (`npm run dev:api`), start **one** worker only (or use `npm run dev:backend` for api+worker):
```bash
npm run dev:worker
# equivalent: cd apps/api && go run ./cmd/worker
# api+worker (no web): npm run dev:backend
```
Do **not** start a second worker when `checks.worker=ok` / `worker_last_seen_age_s` is fresh — duplicate claim loops contend on A1 (or any tenant) job rows. Before restarting the full stack: `node scripts/free-dev-ports.mjs` (or rely on `predev`) then `npm run dev`, and stop any leftover standalone `cmd/worker`. Readiness itself is read-only (no A1 catalog writes). API-only → `/readyz` **503** is expected; see [README troubleshooting](../README.md#troubleshooting-readyz-returns-503).
Cutover rehearsal: probes must stay green while `MAINTENANCE_MODE` / `READ_ONLY_MODE` may block app traffic.
## Postgres pgx pool (API + worker)
Shared by `cmd/api` and `cmd/worker` via `internal/db.NewPool`. Size pools for **your** Postgres `max_connections` and replica count — do not copy high defaults blindly.
| Variable | Default | Purpose |
|---|---|---|
| `DB_MAX_CONNS` | `20` | Hard ceiling per process (`MaxConns`) |
| `DB_MIN_CONNS` | `2` | Warm floor (`MinConns`, ~1030% of max) |
| `DB_MAX_CONN_LIFETIME` | `1h` | Recycle connections before server-side idle kills / DNS drift |
| `DB_MAX_CONN_LIFETIME_JITTER` | `6m` | Random extra lifetime (~10% of lifetime) — avoids thundering-herd reconnects; `0` disables |
| `DB_MAX_CONN_IDLE_TIME` | `5m` | Close idle conns during health checks |
| `DB_HEALTH_CHECK_PERIOD` | `1m` | Background idle health check interval |
| `DB_STATEMENT_TIMEOUT` | `30s` | Postgres `statement_timeout` GUC per connection; `0` disables |
Formula sketch: `MaxConns ≈ (max_connections reserved) / instance_count`. Typical per-process range is 2050. Always keep jitter set in multi-instance deploys.
## SMTP (invites + set-password)
Platform invite / set-password mail uses process env (`internal/mail` + `cmd/mailhooks`). This is separate from **tenant** marketing email (`/integrations/email`).
Env (no defaults that embed secrets):
| Variable | Purpose |
|---|---|
| `SMTP_ENABLED` | `true` to send; otherwise no-op log (subject only, no PII) |
| `SMTP_HOST` / `SMTP_PORT` | SMTP server |
| `SMTP_USER` / `SMTP_PASSWORD` | Auth (optional for open relays) |
| `SMTP_FROM` | From address |
| `WEB_ORIGIN` | Base URL for accept-invite links |
| `TOKEN_SIGNING_SECRET` | HMAC for admin-issued set-password tokens (not migrator hooks) |
| `EMAIL_DRY_RUN` | Default **true** when unset (safe). Live `mailhooks` send fails closed until you pass `-dry-run` or set `EMAIL_DRY_RUN=false` (and/or disable dry-run in admin platform mail settings). |
### Migrator hooks → mail
After a live migrator run (or `go run ./cmd/migrator -issue-set-password-invites`), maps-dir contains `password_invites.json` and `set-password-hooks.json` (invite tokens + URLs for `must_set_password` users). See [migration-readiness.md](migration-readiness.md).
#### Operator smoke (no live SMTP)
Rehearse under dry-run **before** flipping SMTP. Prefer process env `EMAIL_DRY_RUN=true` (or leave unset — default is dry-run):
```bash
cd apps/api
# Expect exit 1: fail-closed when dry-run is on and -dry-run is omitted
go run ./cmd/mailhooks -hooks ../../artifacts/set-password-hooks.json
# Expect exit 0: counts subjects only; no SMTP dial
go run ./cmd/mailhooks -hooks ../../artifacts/set-password-hooks.json -dry-run
# log: mailhooks: dry-run subject="Set your Descrybe password"
# stdout: mailhooks: sent=N skipped=… failed=0 smtp_enabled=… total=…
```
Pass criteria: second command exits `0`, `failed=0`, and subjects are logged without contacting an SMTP host. Unit gate: `go test ./cmd/mailhooks/`.
#### Live send (after SMTP proven)
```bash
cd apps/api
# Requires EMAIL_DRY_RUN=false (or admin platform mail dry-run off) + SMTP_ENABLED=true + host/from
go run ./cmd/mailhooks -hooks ../../artifacts/set-password-hooks.json
```
Rate limit via `-delay-ms` (default 100). Team invites use the same SMTP path from `POST /api/team/invites`.
## Session idle policy
- Absolute session lifetime: 7 days
- Idle timeout: `SESSION_IDLE_HOURS` (default 24)
- Set `SESSION_SECURE=true` behind HTTPS in production
## Credentials encryption (WooCommerce)
| Variable | Purpose |
|---|---|
| `CREDENTIALS_ENCRYPTION_KEY` | **Required in production** for AES-GCM at-rest encryption of Woo consumer secrets |
If unset, the API/worker derive a key from `TOKEN_SIGNING_SECRET` + `DATABASE_URL`. That derived key is **local/dev only** — rotate by setting an explicit `CREDENTIALS_ENCRYPTION_KEY` before storing production Woo credentials. Changing the key without re-saving configs makes existing ciphertext unreadable.
## AI (OpenAI-compatible / Green Chat)
**Preferred:** company admin configures the provider in **`/integrations/ai`** (popular BYOK or custom OpenAI-compatible base URL + key). Encrypted at rest with `APP_ENCRYPTION_KEY`.
Optional process-env platform fallback (when the company leaves mode **internal** and a platform key is present in config):
| Variable | Purpose |
|---|---|
| `OPENAI_API_KEY` | Bearer token; non-empty enables the platform Completer fallback |
| `OPENAI_BASE_URL` | Default `https://api.openai.com/v1`; LAN Green Chat e.g. `http://HOST:PORT/v1` |
| `OPENAI_MODEL` | Chat model id (`GET /v1/models`) |
If set, put these in the **root** `.env` only — not `apps/api/.env`. Shared by worker (processing enhance) and API (campaign + SEO AI). Brand kit injects into those prompts. See [green-chat-llm.md](green-chat-llm.md).
[UNCERTAIN] Other agents may remove env-based platform OpenAI in favor of dashboard-only configuration; prefer `/integrations/ai` for new setups.
## Billing / processing costs
- Debit per processed product: `processing_costs.product_processing` + `ceil(tokens/1000) * openai_token_k`
- Defaults seeded by worker/`EnsureDefaultCosts`
- Plans admin / assign / add-credits: `/api/admin/*` (platform admin only)
- Billing cycles: worker every 15m via `RunDueBillingCycles`
## EPREL energy labels
Optional enrichment during product processing. See [eprel.md](eprel.md).
**Preferred:** platform admin → **`/admin/settings`** (`values.eprel.enabled`, `eprel.base_url`, `eprel.timeout`, `eprel.fiche_language`, `eprel.api_key`). Process `EPREL_*` env is an optional fallback.
| Settings key / env | Purpose |
|---|---|
| `eprel.enabled` / `EPREL_ENABLED` | `true` to fetch label/fiche/class after AI steps |
| `eprel.timeout` / `EPREL_TIMEOUT` | HTTP timeout (default `10s`) |
| `eprel.fiche_language` / `EPREL_FICHE_LANGUAGE` | Fiche PDF language (default `EN`) |
| `eprel.api_key` / `EPREL_API_KEY` | Optional; never log |
## Feed private-URL allowlist
SSRF allowlist for private/LAN feed URLs: **`/admin/settings`** → `feeds.private_url_allowlist` (CSV). Optional env `FEED_URL_PRIVATE_ALLOWLIST` remains a fallback (settings merge/override — see `feeds.ApplyPrivateAllowlistCSV`).
## WooCommerce schedule
Worker already:
1. Claims `pending_sync` every 2s (`ClaimNextPending``SyncCompany`)
2. Every 15m enqueues due enabled configs (`EnqueueDueScheduled`, default 6h or `sync_options.schedule_interval_hours`)
Manual enqueue: `POST /api/woocommerce/sync` (dashboard) or operator cron hitting that endpoint / re-running enqueue SQL is unnecessary if the worker is up.
## Schema migrations
```bash
# Git Bash / WSL / macOS / Linux
make migrate
# PowerShell
.\scripts\migrate.ps1
```
Current head: **042_user_session_version.sql** (goose, not Drizzle; includes **039_worker_heartbeats** for `/readyz`, **040_job_hotpath_indexes** for claim/list, **041** for forgot-password tokens, **042** for `users.session_version`). Confirm goose status includes versions through **042** before relying on worker readiness / job claim indexes / self-serve reset / session revoke. MySQL→PG **data** cutover uses cmd/migrator separately — see [production-checklist.md](production-checklist.md) and [cutover.md](cutover.md).
Read-only gate: `npm run cutover:deploy-check` / `node scripts/cutover-deploy-check.mjs` (goose 039042 + `/readyz` worker + adapter-node host gates).
## Cutover blockers (honest)
- **No production cutover executed** from this repo automation.
- Live migrator dry-run needs operator-supplied `MIGRATE_MYSQL_DSN` — do not invent credentials. Until a real DSN is available, cutover stays blocked.
- SMTP must be verified against `set-password-hooks.json` before DNS switch.