Pipeline de imagen automática (scheduled delivery + webhook de MinIO) — cierre técnico

Fecha: 2026-08-09 Resultado: http://192.168.117.7:9100/latest.png sirve una captura del dashboard Compras, actualizada sola cada hora, sin login. Verificado end-to-end dos veces (webhook simulado a mano, y el pipeline real completo disparado por POST /schedulers/:uuid/send) — en ambos casos el archivo se actualizó sin intervención manual.

Headless browser: falta genuina, no config faltante

ghcr.io/browserless/chromium:v2.49.0 (el mismo Dockerfile de un renglón que usa el compose oficial de Lightdash: FROM ghcr.io/browserless/chromium:v2.49.0, sin build propio) no estaba en el docker-compose.yml de esta instancia — confirmado con hasHeadlessBrowser: false en /api/v1/health antes de agregarlo. El pull (3.84GB) falló dos veces por espacio en disco del host antes de completar a la tercera — ver despliegue-cierre.md para la saga completa de RAM/disco de este host, es el mismo tipo de problema que ya había tumbado el primer intento de pull de la imagen principal de Lightdash.

Env vars agregadas al servicio lightdash una vez que el contenedor headless-browser existe en el compose (nombre de servicio = hostname interno, resuelto por Docker DNS):

environment:
  - HEADLESS_BROWSER_HOST=headless-browser
  - HEADLESS_BROWSER_PORT=3000

3000 es el PORT default de la imagen browserless/chromium (confirmado con docker inspect ... --format '{{json .Config.Env}}'), no hace falta configurarlo explícitamente en el lado del headless browser.

API: crear un scheduled delivery en formato imagen

POST /api/v1/dashboards/{dashboardUuid}/schedulers

El endpoint tsoa no valida el body contra un schema tipado (el método del controller usa req.body crudo, sin @Body() — a diferencia de casi todo lo demás en esta API, ver api-referencia.md), así que el shape se sacó del tipo TypeScript (CreateSchedulerAndTargetsWithoutIds en @lightdash/common), no de routes.js:

{
  "name": "Compras - export horario a Minio",
  "cron": "0 * * * *",
  "format": "image",
  "options": {},
  "targets": [{"recipient": "dashboards@ctunlinux.local"}],
  "includeLinks": true,
  "enabled": true
}

format acepta csv | xlsx | image | gsheets | pdf (SchedulerFormat enum). options es una unión discriminada por format — para image, SchedulerImageOptions = { withPdf?: boolean; pagePerTab?: boolean }, ambos opcionales, {} es válido.

No existe un target “S3 directo” — por qué se usó un email desechable

CreateSchedulerTarget es una unión cerrada de 4 shapes, ninguno es webhook genérico ni S3:

type CreateSchedulerTarget =
  | Pick<SchedulerSlackTarget, 'channel'>
  | Pick<SchedulerMsTeamsTarget, 'webhook'>
  | Pick<SchedulerGoogleChatTarget, 'googleChatWebhook'>
  | Pick<SchedulerEmailTarget, 'recipient'>

Y la validación en SchedulerService.sendScheduler rechaza targets: [] salvo para format: gsheets:

if (scheduler.targets.length === 0 && scheduler.format !== SchedulerFormat.GSHEETS) {
  throw new ParameterError('You must specify at least 1 destination before sending a scheduled delivery');
}

Se usó {"recipient": "dashboards@ctunlinux.local"} — esta instancia no tiene EMAIL_SMTP_HOST configurado (hasEmailClient: false), así que el envío de email en sí probablemente falla o queda en cola sin salir nunca. No importa para este pipeline: el render + upload a S3/MinIO pasa por UnfurlService.unfurlImage → S3Client.uploadImage, un paso compartido antes de la rama específica de envío por target (confirmado empíricamente: el archivo llegó al bucket igual, sin que el email se enviara ni se confirmara). Un target Slack/Teams tendría el mismo efecto práctico si algún día se prefiere — no se probó, pero la arquitectura (upload primero, delivery después) es la misma para los 4 tipos de target.

Nombre del objeto subido a MinIO

S3Client.uploadImage(image, imageId) sube a Key: ${imageId}.png, en la raíz del bucket (sin prefijo/carpeta). Para dashboards, UnfurlService arma imageId como slack-image-notification-${nanoid()} — el prefijo slack-image- queda igual sin importar qué tipo de target se use (es solo el nombre interno de la función, no indica que se use Slack). Ejemplos reales observados: slack-image-notification-I6PuAB69s8w3wP0nqzoSC.png, slack-image-notification-sIu4McPSwq4JQSSyS0ILr.png. El pipeline no depende de este prefijo exacto — filtra solo por sufijo .png vía la notificación de MinIO (ver abajo), así que sobrevive si el prefijo cambia en una versión futura de Lightdash.

Enviar el scheduler ya mismo, sin esperar el cron

POST /api/v1/schedulers/{schedulerUuid}/send

Sin body. Devuelve {"jobId": "..."} de inmediato (asíncrono) — el render real tarda ~15-20s en esta instancia (5 charts, warehouse Postgres local). Usado para las dos pruebas end-to-end de este cierre.

Onboarding modal en las capturas — fix

Primera captura (slack-image-notification-I6PuAB69s8w3wP0nqzoSC.png) salió con un modal semi-transparente tapando media pantalla: “Nearly there… Tell us a bit more about yourself” — el admin de esta instancia se creó vía POST /api/v1/user (ver api-referencia.md) sin pasar nunca por el onboarding de la UI, entonces isSetupComplete quedaba en false indefinidamente, y el headless browser renderiza la sesión real del usuario tal cual, modal incluido.

Fix — el mismo endpoint que había fallado con 403 "User is not part of an organization" cuando se probó por primera vez (antes de que existiera la organización, ver api-referencia.md) ahora sí funciona porque la organización ya existe:

PATCH /api/v1/user/me/complete
{"jobTitle": "...", "howDidYouHearAboutUs": "...", "isMarketingOptedIn": false, "isTrackingAnonymized": true}

Respuesta confirma "isSetupComplete": true. Captura siguiente (slack-image-notification-sIu4McPSwq4JQSSyS0ILr.png, 127KiB vs 143KiB de la primera — la diferencia de tamaño es justo el modal ya no presente) salió limpia.

Notificación de bucket en MinIO

MinIO trae mc empacado en su propia imagen (minio/minio:latest) — confirmado con docker exec lightdash-minio mc --version, no hace falta un contenedor minio/mc aparte para administrarlo en runtime.

# 1. Configurar el target del webhook
docker exec lightdash-minio mc admin config set local \
  notify_webhook:dashboard endpoint="http://172.21.0.1:9100/minio-event"
 
# 2. Aplicar (mc admin service restart falla sin TTY dentro de docker exec:
#    "Unable to initialize service restart UI: could not open a new TTY" —
#    usar docker restart del contenedor en su lugar)
docker restart lightdash-minio
 
# 3. Enlazar el bucket al target, solo eventos ObjectCreated con sufijo .png
docker exec lightdash-minio mc event add local/lightdash \
  arn:minio:sqs::dashboard:webhook --event put --suffix .png

172.21.0.1 es el gateway de la red Docker lightdash_default (docker network inspect lightdash_default --format '{{json .IPAM.Config}}') — la forma en que un contenedor de esa red bridge llega a un proceso escuchando en el host (0.0.0.0, no 127.0.0.1, para que el bridge pueda alcanzarlo). Cada red Docker custom tiene su propio gateway; si el receiver se mueve a otra red, este valor cambia.

Formato del evento que llega al webhook

MinIO hace POST al endpoint configurado con un body JSON, formato evento S3 estándar (compatible AWS), potencialmente varios eventos por línea (NDJSON) en un solo POST:

{
  "EventName": "s3:ObjectCreated:Put",
  "Key": "lightdash/slack-image-notification-C5XQXrSY-L_FckbW-SBes.png",
  "Records": [
    {
      "eventVersion": "2.0",
      "eventSource": "minio:s3",
      "eventName": "s3:ObjectCreated:Put",
      "s3": {
        "bucket": {"name": "lightdash"},
        "object": {"key": "slack-image-notification-C5XQXrSY-L_FckbW-SBes.png"}
      }
    }
  ]
}

El receiver (scripts/lightdash/receiver.py en este repo, deploy idéntico en trivasa-bi-dev/lightdash/dashboard-pipeline/receiver.py en ctunlinux) lee Records[].s3.bucket.name / Records[].s3.object.key, filtra por eventName empezando con s3:ObjectCreated: y bucket == lightdash, y trae el objeto con:

docker exec lightdash-minio mc cat local/lightdash/<key>

capturando stdout a un archivo temporal y haciendo rename() atómico sobre /var/www/dashboard/latest.png al terminar — para que un cliente nunca reciba un archivo a medio escribir si el GET llega justo mientras se está actualizando.

systemd

[Unit]
Description=Receptor de webhook de Minio + servidor HTTP para el ultimo dashboard de Lightdash (imagen)
After=docker.service
Requires=docker.service
 
[Service]
Type=simple
User=ealcocer
WorkingDirectory=/home/ealcocer/trivasa-bi-dev/lightdash/dashboard-pipeline
ExecStart=/usr/bin/python3 receiver.py --port 9100 --bucket lightdash --out /var/www/dashboard/latest.png
Restart=unless-stopped
RestartSec=5
 
[Install]
WantedBy=multi-user.target

Restart=unless-stopped + enable — aplicado desde el principio, a diferencia de Metabase/Superset (ver ctunlinux.md en content/, causa raíz del reboot del 2026-08-08), para no repetir el mismo tipo de incidente con un servicio nuevo.

Verificación end-to-end realizada

  1. Webhook simulado a mano — curl -X POST localhost:9100/minio-event con un payload de evento construido a mano apuntando a un objeto ya existente en el bucket → confirmó que el receiver copia y sirve correctamente, sin depender todavía de que MinIO mismo dispare el evento.
  2. Pipeline real completo — rm /var/www/dashboard/latest.png, luego POST /schedulers/{uuid}/send, luego esperar. El log del receiver mostró la IP del contenedor de MinIO (172.21.0.3, no 127.0.0.1) llamando al webhook por su cuenta ~15s después del trigger, sin ningún comando manual de por medio. Esta es la prueba real de que el pipeline funciona en producción, no solo que las piezas sueltas funcionan por separado.

Véase también

  • despliegue-cierre.md — saga de RAM/disco de este mismo host, mismo tipo de problema que causó los dos primeros intentos fallidos del pull de browserless/chromium.
  • api-referencia.md — el resto de la API de Lightdash reversada (org, proyecto, dashboards+charts) que hizo posible construir todo esto por curl en vez de la UI.
  • Dashboard como imagen en la red local — página de wiki con el resumen en prosa y la URL final para el equipo.