The Redis consumer used to leave a failed event pending and go on to the next ones, retrying it only at the next start. With Mongo down for a while, a later change of the same ticket was then projected first, and the version check dropped the earlier one for good (a status change could vanish from the read model), besides the read side lagging until a restart. Now a failed event is retried in place, waiting longer each time (up to 30 s), and the next one waits for it. Failures of the infrastructure (Mongo, Redis, the network: isTransientError) wait as long as it takes, so the read side catches up by itself; an event that keeps failing on its own goes, after 5 attempts, to <stream>:dead with its error, so it does not hold up the rest. GET /api/health reports the way from write to read (events waiting in the outbox, pending to be projected, set aside) and says `degraded`, still with 200, when something is stuck. Tried for real: with Mongo stopped, two status changes went in; the first failed to project with ECONNREFUSED, and once Mongo was back the ticket's history had both steps, in order, without restarting the API. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
16 KiB
Markdown
147 lines
16 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. `GET /api/health` responde 503 si cae una base de datos; con
|
|
las tres en pie, `status: degraded` (200) avisa de eventos que no llegan a la lectura (`events.problems`). Los eventos
|
|
apartados por fallar una y otra vez se ven con `redis-cli XRANGE task-stream:events:dead - +`; arreglado el fallo, se
|
|
reconstruye la lectura (abajo) o se vuelven a publicar.
|
|
|
|
### 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 |
|
|
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | — | Notificaciones push (Web Push). Sin las dos no se envía nada. Se crean una vez con `npx web-push generate-vapid-keys`; cambiarlas anula las suscripciones |
|
|
| `VAPID_SUBJECT` | `mailto:no-reply@pireon.dev` | Contacto (`mailto:` o `https:`) que los servicios push pueden usar si algo va mal |
|
|
| `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.
|