diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5c6b42f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Contexto de build de la imagen web. El builder hace su propio +# `meteor npm install`; nada del host debe colarse. +.git +**/.git +.meteor/local +node_modules +smoke/node_modules +smoke/snapshots +plan-modernizacion +secrets +docker-compose.yml +RUNBOOK.md +*.log +.env +.env.* diff --git a/.gitignore b/.gitignore index 603fb12..b48695c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ output.json settings-production-*.json settings-tests.json *.json~ + +# fase-3: secretos del stack docker-compose local (solo se versionan los .example) +secrets/* +!secrets/README.md +!secrets/*.example diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..d3d0113 --- /dev/null +++ b/.npmrc @@ -0,0 +1,5 @@ +# Las libs react-* antiguas (react-bootstrap 0.31, react-confirm 0.1.16, ...) +# declaran peers de react 15/16 pero funcionan con react 18 (ver UPGRADE.md). +# npm >= 7 fallaría con ERESOLVE sin esto — aplica igual en dev y en el build +# de la imagen Docker. +legacy-peer-deps=true diff --git a/Dockerfile b/Dockerfile index ffb419f..e8176ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,7 +39,9 @@ COPY --chown=node:node docker/web-entrypoint.sh /web-entrypoint.sh RUN chmod +x /web-entrypoint.sh USER node EXPOSE 3000 +# 127.0.0.1 (not localhost): busybox wget resolves localhost to ::1 first, but +# the Meteor server listens on IPv4 only -> the check would false-negative. HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=5 \ - CMD wget -qO /dev/null "http://localhost:${PORT}/" || exit 1 + CMD wget -qO /dev/null "http://127.0.0.1:${PORT}/" || exit 1 ENTRYPOINT ["/web-entrypoint.sh"] CMD ["node", "main.js"] diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..afa81e5 --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,117 @@ +# RUNBOOK — stack local dockerizado (fase 3, validación) + +Stack Docker Compose del sistema modernizado de "Todos contra el fuego": +web Meteor 3.1 (Node 22), MongoDB 7 (replica set de un nodo), Redis (AOF), +`tcef-notifications` y node-red. **Es el entorno de validación local previo a +producción** — el despliegue real irá al ansible de Comunes (fase 3 del plan); +este compose no toca el proxy ni la infra compartida. + +## Requisitos + +- Docker + Docker Compose v2. +- El repo hermano `../tcef-notifications` (el compose construye su imagen desde ahí). + +## Primer arranque + +```bash +# 1. Secretos (gitignorados; ver secrets/README.md) +cp settings-development.json secrets/meteor-settings.json +cp secrets/notifications.env.example secrets/notifications.env # MODE=dry-run + +# 2. Construir y levantar (el primer build de la web tarda: descarga meteor-tool +# y compila el bundle; ~10-20 min en frío) +docker compose build +docker compose up -d + +# 3. Comprobar salud +docker compose ps # todo "healthy" / mongo-init "exited (0)" +``` + +Servicios y puertos en el host: + +| Servicio | Contenedor | URL / puerto | +|---|---|---| +| web Meteor | `tcef-stack-web` | http://localhost:3200 | +| node-red | `tcef-stack-node-red` | http://localhost:1880 | +| MongoDB 7 | `tcef-stack-mongo` | solo red interna (usar `docker exec`) | +| Redis | `tcef-stack-redis` | solo red interna | +| notificaciones | `tcef-stack-notifications` | sin puerto (worker) | + +`mongo-init` es un one-shot: inicia el replica set `rs0` y fija +`db.migrations` a la versión 18 (los `up()` históricos ya son async, pero se +pinta v18 porque los datos reales de producción llegan pre-migrados). + +> Nota healthcheck: los checks usan `127.0.0.1`, no `localhost`. El `wget` de +> busybox (alpine) resuelve `localhost` a `::1` (IPv6) primero, pero los +> servidores Meteor/node-red escuchan solo en IPv4 → un `localhost` daría +> unhealthy aunque la app responda perfectamente por el puerto mapeado. + +## Smoke test REST (red de seguridad del contrato Flutter) + +Debe ser **byte-idéntico** a los snapshots committeados: + +```bash +MONGO_CONTAINER=tcef-stack-mongo MONGO_SHELL=mongosh MONGO_PORT=27017 \ + BASE_URL=http://localhost:3200 ./smoke/smoke.sh +``` + +## Operación diaria + +```bash +docker compose logs -f web # logs de un servicio (rotados: 3×10MB) +docker compose ps # estado + healthchecks +docker compose restart notifications # reiniciar un servicio +docker compose down # parar todo (los volúmenes persisten) +docker compose down -v # ⚠️ borra también mongo/redis/node-red +``` + +## Desplegar una nueva versión + +```bash +# web (tras cambios en el repo) +docker compose build web && docker compose up -d web + +# notificaciones (tras cambios en ../tcef-notifications) +docker compose build notifications && docker compose up -d notifications +``` + +Verificar tras cada despliegue de la web: `docker compose ps` healthy + smoke +REST byte-idéntico (arriba). + +## Backups y restauración (volúmenes locales) + +```bash +# Mongo: dump / restore de la db fuegos +docker exec tcef-stack-mongo mongodump --db fuegos --archive > fuegos.dump +docker exec -i tcef-stack-mongo mongorestore --archive --drop < fuegos.dump + +# node-red: tar del volumen de datos +docker run --rm -v tcef-stack_nodered-data:/data -v "$PWD:/backup" alpine \ + tar czf /backup/nodered-data.tgz -C /data . +``` + +## Rollback + +- **web**: `git checkout ` + `docker compose build web && docker compose up -d web`. + (En producción se conservará la imagen anterior etiquetada; aquí basta git.) +- **notificaciones**: igual, desde `../tcef-notifications`. `KILL_SWITCH=1` en + `secrets/notifications.env` + `docker compose up -d notifications` detiene + todo envío al instante sin parar el servicio. +- **datos**: restaurar el último dump (arriba). + +## Salvaguardas de notificaciones + +`secrets/notifications.env` arranca en `MODE=dry-run` (no envía nada). La +progresión dry-run → shadow → canary → full está documentada en +`../tcef-notifications/docs/rollout.md` y **nunca** se salta pasos. En este +stack local no hay credenciales reales salvo que las pongas tú. + +## Deuda conocida de este compose + +- `tcef-notifications` no expone endpoint HTTP de salud → healthcheck por + proceso (`pgrep`). Añadir `/healthz` al servicio cuando toque. +- node-red corre la imagen oficial vacía (los flows de los bots viven en + shiva; su migración de datos es parte del cutover real de fase 3). +- El driver mongodb 3.7 de `tcef-notifications` (elegido por el Mongo 3.2 de + producción) conecta con Mongo 7 en pruebas locales, pero al hacer el cutover + real a Mongo 7 debe subirse a driver 6.x (y habilitar change streams). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f83c85a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,125 @@ +# Stack local modernizado de "Todos contra el fuego" (fase 3 — validación local). +# NO es el despliegue de producción: eso irá al ansible de Comunes (proxies, +# firewall, monitorización). Aquí se valida que todo el stack corre en Docker. +# +# Uso: ver RUNBOOK.md. Secretos: ./secrets/ (montados como ficheros, gitignored). + +name: tcef-stack + +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + mongo: + image: mongo:7 + container_name: tcef-stack-mongo + command: ["--replSet", "rs0", "--bind_ip_all"] + volumes: + - mongo-data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + restart: unless-stopped + logging: *default-logging + + # One-shot: inicia el replica set (un nodo) y marca las migraciones de Meteor + # como aplicadas (v18) para que los up() históricos (aún síncronos) no corran. + mongo-init: + image: mongo:7 + depends_on: + mongo: + condition: service_healthy + restart: "no" + entrypoint: + - bash + - -ec + - | + mongosh --host mongo --quiet --eval 'try { rs.status().ok } catch (e) { rs.initiate({_id: "rs0", members: [{_id: 0, host: "mongo:27017"}]}) }' + # espera a que el nodo sea PRIMARY antes de escribir + for i in $$(seq 1 30); do + state=$$(mongosh --host mongo --quiet --eval 'try { rs.isMaster().ismaster } catch (e) { false }') + [ "$$state" = "true" ] && break + sleep 1 + done + mongosh --host mongo --quiet fuegos --eval 'db.migrations.replaceOne({_id: "control"}, {_id: "control", version: 18, locked: false}, {upsert: true})' + echo "mongo-init: replica set + migrations OK" + logging: *default-logging + + web: + build: + context: . + dockerfile: Dockerfile + image: tcef-web:meteor3 + container_name: tcef-stack-web + depends_on: + mongo-init: + condition: service_completed_successfully + environment: + PORT: "3000" + ROOT_URL: http://localhost:3200 + MONGO_URL: mongodb://mongo:27017/fuegos?replicaSet=rs0 + METEOR_SETTINGS_FILE: /run/secrets/meteor-settings.json + volumes: + - ./secrets/meteor-settings.json:/run/secrets/meteor-settings.json:ro + ports: + - "3200:3000" + restart: unless-stopped + logging: *default-logging + + redis: + image: redis:7-alpine + container_name: tcef-stack-redis + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 6 + restart: unless-stopped + logging: *default-logging + + notifications: + build: + context: ../tcef-notifications + image: tcef-notifications:local + container_name: tcef-stack-notifications + depends_on: + mongo-init: + condition: service_completed_successfully + redis: + condition: service_healthy + # Copia secrets/notifications.env.example -> secrets/notifications.env. + # Por defecto MODE=dry-run: no envía nada aunque haya credenciales. + env_file: + - ./secrets/notifications.env + restart: unless-stopped + logging: *default-logging + + node-red: + image: nodered/node-red:4.0 + container_name: tcef-stack-node-red + volumes: + - nodered-data:/data + ports: + - "1880:1880" + healthcheck: + test: ["CMD", "wget", "-qO", "/dev/null", "http://127.0.0.1:1880/"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + logging: *default-logging + +volumes: + mongo-data: + redis-data: + nodered-data: diff --git a/docker/web-entrypoint.sh b/docker/web-entrypoint.sh new file mode 100644 index 0000000..8d3ff5a --- /dev/null +++ b/docker/web-entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Carga METEOR_SETTINGS desde un fichero montado (secretos fuera de la imagen). +set -e + +if [ -n "${METEOR_SETTINGS_FILE:-}" ]; then + if [ ! -r "$METEOR_SETTINGS_FILE" ]; then + echo "ERROR: METEOR_SETTINGS_FILE=$METEOR_SETTINGS_FILE no existe o no es legible" >&2 + exit 1 + fi + METEOR_SETTINGS="$(cat "$METEOR_SETTINGS_FILE")" + export METEOR_SETTINGS +fi + +exec "$@" diff --git a/secrets/README.md b/secrets/README.md new file mode 100644 index 0000000..91382a6 --- /dev/null +++ b/secrets/README.md @@ -0,0 +1,15 @@ +# secrets/ — secretos del stack local (docker-compose) + +Los ficheros reales de este directorio están **gitignorados** — solo se +versionan los `*.example`. Los valores de producción viven en el repo privado +`tcef-private-config` / en shiva, nunca aquí. + +Para la validación local: + +```bash +# settings de la web Meteor (el compose lo monta como fichero en el contenedor) +cp ../settings-development.json meteor-settings.json + +# entorno del microservicio de notificaciones (dry-run por defecto: no envía nada) +cp notifications.env.example notifications.env +``` diff --git a/secrets/meteor-settings.json.example b/secrets/meteor-settings.json.example new file mode 100644 index 0000000..dfba714 --- /dev/null +++ b/secrets/meteor-settings.json.example @@ -0,0 +1,14 @@ +{ + "gmaps": { "key": "GMAPS_BROWSER_KEY", "serverKey": "GMAPS_SERVER_KEY" }, + "public": {}, + "private": { + "internalApiToken": "CAMBIA-ESTE-TOKEN (la API REST solo se registra si existe)", + "testMailer": false, + "debugMailer": false, + "isMailServer": true, + "fireIconUrl": "https://fuegos.comunes.org/fire-marker.png", + "ironPassword": "IRON_PASSWORD", + "cron": { "debug": false }, + "proxies_count": 0 + } +} diff --git a/secrets/notifications.env.example b/secrets/notifications.env.example new file mode 100644 index 0000000..5fa6143 --- /dev/null +++ b/secrets/notifications.env.example @@ -0,0 +1,28 @@ +# tcef-notifications en el stack local (docker-compose). Copia a +# notifications.env. Referencia completa: ../../tcef-notifications/.env.example +# +# SEGURO POR DEFECTO: MODE=dry-run no envía nada por ningún canal. + +MODE=dry-run +KILL_SWITCH=0 +OWNED_CHANNELS=fcm,email +MATCHER_MODE=off + +# Infra del compose (nombres de servicio de docker-compose.yml) +MONGO_URL=mongodb://mongo:27017/fuegos?replicaSet=rs0 +MONGO_DB=fuegos +REDIS_HOST=redis +REDIS_PORT=6379 + +POLL_INTERVAL_MS=10000 +POLL_BATCH_SIZE=500 + +# Solo necesarios en canary/full (el dry-run no construye los clientes): +# FCM_SERVICE_ACCOUNT=/run/secrets/firebase-service-account.json +# FCM_PROJECT_ID=angular-cosmos-108908 +# MAIL_URL=smtps://USER:PASS@smtp.host:465 +# IRON_PASSWORD= + +ROOT_URL=http://localhost:3200/ +LOG_LEVEL=info +SERVICE_NAME=tcef-notifications