Files
task-stream/docs/07-despliegue.md
Josep Piguillem 5ae1d05d30 fix(api): accept Pireon webhooks from the password client too
Pireon signs each webhook with the secret of the client that registered
it, and the API has two clients (the web app's and the password
grant's). Only PIREON_WEBHOOK_SECRET was accepted, so a token revoked
for someone signed in with a password went unheard. A delivery now
verifies with PIREON_WEBHOOK_SECRET or PIREON_PASSWORD_WEBHOOK_SECRET;
the same event arriving from both clients changes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:29:27 +02:00

142 lines
14 KiB
Markdown

# 7. Despliegue
## 7.1 Docker
```sh
docker compose up -d --build # todo: web en http://localhost:8080
docker compose up -d postgres mongo redis # solo infraestructura
docker compose logs -f api
docker compose down # parar (-v borra los datos)
```
| Servicio | Imagen | Puerto en local | Función |
| ---------- | --------------------------- | --------------- | ----------------------------------------------------------------------- |
| `web` | `task-stream-web` (nginx) | 8080 | SPA y proxy de `/api` y `/graphql` (incluido el WebSocket) hacia la API |
| `api` | `task-stream-api` (Node 22) | 3000 | API NestJS |
| `postgres` | postgres:17-alpine | 5432 | Event store y lookups |
| `mongo` | mongo:8 | 27017 | Read model |
| `redis` | redis:7-alpine (AOF) | 6379 | Stream de eventos, pub/sub y caché de sesiones |
### Producción y desarrollo
- **`docker-compose.yml`** es la definición de producción (la que despliega Dokploy) y **no publica puertos**: los
servicios solo se ven entre sí por la red de Compose. Hacia fuera solo sale la web, por el proxy de la plataforma
(en Dokploy: dominio → servicio `web`, puerto `80`). `web` y `api` declaran `expose` para documentarlo.
- **`docker-compose.override.yml`** publica los puertos de la tabla. Compose lo carga solo cuando se ejecuta sin `-f`,
es decir, en local; Dokploy usa `-f docker-compose.yml` y no lo lee.
Para probar en local exactamente lo de producción: `docker compose -f docker-compose.yml up -d --build`.
### Nombres únicos en la red
Los servicios se llaman entre sí por **alias únicos** (`task-stream-api`, `task-stream-postgres`, `task-stream-mongo`,
`task-stream-redis`), no por el nombre del servicio. Al dar dominio a un servicio, Dokploy lo conecta a la red
compartida `dokploy-network`, donde otros proyectos también tienen un `api`, un `postgres` o un `redis`: con nombres
genéricos, el DNS de Docker devuelve también los suyos y nginx reparte las peticiones entre ambos (síntoma: la mitad
de las llamadas a `/api/*` responden `404 Cannot GET …` y el login dice «no configurado»). Si añades un servicio,
dale su alias `task-stream-*` y úsalo en las URLs.
Todos tienen healthcheck; `web` espera a que `api` esté sana, y `api` a las tres bases de datos.
### Imágenes
El `Dockerfile` tiene tres destinos que comparten la instalación de dependencias:
- **Instalación**: `npm ci` del workspace. `@pi/ui` viene del Nexus privado; el token llega como **build secret**
desde la variable `NEXUS_TOKEN` (`secrets.nexus_token` en `docker-compose.yml`), se escribe en un `npmrc` temporal y
se borra en el mismo paso, así que nunca queda en una capa. Sin `NEXUS_TOKEN` la build falla en ese paso.
- **`api`**: `nx run api:prune` genera el bundle con un `package.json` y un lockfile podados a partir de
`apps/api/package.json`. La imagen final solo instala esas dependencias (sin Angular ni paquetes privados).
Si la API empieza a usar un paquete nuevo **en tiempo de ejecución**, hay que añadirlo a `apps/api/package.json`.
- **`mcp`**: el [servidor MCP](09-mcp.md), podado igual que la API (`nx run mcp:prune`), sobre Node 24. Su servicio
en `docker-compose.yml` va en el perfil `mcp`: solo arranca con `--profile mcp` (en Dokploy, `COMPOSE_PROFILES=mcp`).
- **`web`**: build de producción de Angular servido por nginx (`apps/web/nginx.conf`): fallback de rutas a
`index.html`, caché larga para los bundles con hash y sin caché para `index.html`.
### Construir
`NEXUS_TOKEN` tiene que estar en el entorno de quien construye, o en el `.env` junto al `docker-compose.yml`:
```sh
# Local: reutiliza el token de tu ~/.npmrc
export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
docker compose up -d --build
```
En **Dokploy** (despliegue con Docker Compose): pestaña _Environment_ del servicio → `NEXUS_TOKEN=<token>`, junto a
`PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET`, `PIREON_PASSWORD_CLIENT_ID`, `PIREON_PASSWORD_CLIENT_SECRET` y
`PIREON_WEBHOOK_SECRET`. Dokploy los escribe en el `.env` del proyecto, de
donde los lee Compose.
## 7.2 Variables de entorno de la API
| Variable | Por defecto | Descripción |
| ------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PORT` | `3000` | Puerto HTTP |
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
| `POSTGRES_SYNCHRONIZE` | `false` | Crea las tablas desde las entidades. Cómodo en desarrollo; en producción, migraciones |
| `MONGO_URL` | — (obligatoria) | Read model |
| `REDIS_URL` | — (obligatoria) | Stream, pub/sub y caché |
| `EVENT_STREAM_KEY` | `task-stream:events` | Clave del Redis Stream |
| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream |
| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) |
| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad |
| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth `authorization_code`: «Entrar con Pireon» y Swagger UI |
| `PIREON_PASSWORD_CLIENT_ID` / `_SECRET` | el anterior | Cliente OAuth `password`: login con email y contraseña |
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
| `PIREON_PASSWORD_WEBHOOK_SECRET` | — | El del webhook del cliente de contraseña, si es otro cliente |
| `PIREON_TIMEOUT_MS` | `5000` | Timeout de las llamadas a Pireon |
| `AUTH_SESSION_TTL_SECONDS` | `60` | Cuánto se confía en un token validado antes de volver a preguntar a Pireon |
| `GRAPHQL_PLAYGROUND` | `true` | GraphiQL e introspección |
| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) |
| `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` |
| `OPENAPI_FILE` | `apps/api/openapi.json` | Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker) |
| `ATTACHMENTS_DIR` | `data/attachments` | Carpeta de los adjuntos de los tickets; en Docker, el volumen `attachments-data` |
| `SMTP_URL` | — | Servidor de correo para las copias de las notificaciones (`smtps://user:pass@host`); sin él no se envía nada |
| `MAIL_FROM` | `TaskStream <no-reply@pireon.dev>` | Remitente de esos correos |
| `WEB_URL` | — | Dirección pública de la web, para los enlaces de los correos |
| `ATTACHMENTS_MAX_MB` | `25` | Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición) |
| `WEBHOOKS_ALLOW_PRIVATE` | `false` | Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos) |
| `REMINDERS_ENABLED` | `true` | Avisa cada hora de los tickets que vencen mañana, hoy o ya han vencido |
| `REMINDERS_TIMEZONE` | `Europe/Madrid` | Zona horaria en la que cambia el día para los recordatorios |
| `TRASH_DAYS` | `30` | Días que un ticket pasa en la papelera antes de borrarse del todo |
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).
## 7.3 Escalado
- **API**: es _stateless_ (sesiones en Redis, subscriptions por Redis pub/sub), así que admite varias réplicas detrás
del balanceador. El WebSocket necesita que el balanceador permita `Upgrade`. La excepción son los **adjuntos**: se
guardan en disco (`ATTACHMENTS_DIR`), así que varias réplicas tienen que compartir ese volumen.
- **Relay**: cada instancia publica eventos al stream; `FOR UPDATE SKIP LOCKED` evita duplicados.
- **Proyecciones**: el `EventsBridge` procesa los eventos en orden. Lo más sencillo es dejarlo activo en **una sola**
instancia (`EVENTS_BRIDGE_ENABLED=true`) y desactivarlo en las demás. Varias instancias en el mismo consumer group se
reparten los eventos y pierden el orden entre ellos.
## 7.4 Antes de producción
- Sustituir `POSTGRES_SYNCHRONIZE` por migraciones de TypeORM.
- Registrar el cliente OAuth y las redirect URIs de producción en Pireon.
- Servir por HTTPS (la web usa `wss://` automáticamente cuando la página va por HTTPS).
- Revisar `GRAPHQL_PLAYGROUND` (introspección) según el entorno.
- Copias de seguridad de PostgreSQL: es la fuente de verdad. Y del volumen de adjuntos (`attachments-data`): los
eventos dicen qué archivos hay, pero los bytes solo están ahí.
## 7.5 Reconstruir el read model
MongoDB solo contiene proyecciones, así que se puede regenerar desde el event store:
1. Para la API (o desactiva el bridge en todas las instancias).
2. Borra la base de lectura (`taskstream_read`) salvo la colección `users`, que no sale de los eventos sino de Pireon.
3. Marca los eventos como no publicados: `UPDATE events SET published_at = NULL;`
4. Arranca la API: el relay vuelve a enviar todos los eventos, en orden, y las proyecciones reconstruyen las vistas.
Las notificaciones en tiempo real se reenvían también, sin efecto si no hay clientes conectados.
Hazlo también una vez tras desplegar la configuración por proyecto y las estadísticas si quieres la historia completa
de los tickets anteriores: sin reconstruir, la línea temporal (`issue_timeline`) solo empieza con los cambios nuevos y
las estadísticas usan, para lo anterior, la creación y el estado actual de cada ticket. La categoría de los estados no
lo necesita: la API la rellena al arrancar en los tickets que no la tienen.