Skip to content

Stack de BI en ctunlinux

Qué corre en la máquina de BI, en qué puerto, y quién lo mantiene. Verificado 2026-08-12.

La máquina

ctunlinux — Ubuntu 26.04 LTS. Es donde vive todo lo de BI: extracción, warehouse, transformación, dashboards y observabilidad.

Red — IP fija (2026-09-03)

192.168.117.14 (interfaz ens18, MAC bc:24:11:fb:1f:ee) pasó de DHCP a estática — misma IP que el DHCP del router ya le venía asignando, así que no cambió ninguna referencia existente (ej. la conexión directa a postgres-dw documentada arriba). Se hizo estática para que no varíe en el futuro: varios lugares ya hardcodean esta IP (este mismo doc, psycopg2://...@192.168.117.14:5433/...) y dependían de que el arrendamiento DHCP nunca cambiara la asignación.

Config en /etc/netplan/00-installer-config.yaml (gestión de red vía netplan, no NetworkManager — nmcli no está disponible en esta máquina):

network:
  ethernets:
    ens18:
      dhcp4: false
      addresses:
        - 192.168.117.14/24
      routes:
        - to: default
          via: 192.168.117.1
      nameservers:
        addresses: [8.8.8.8, 8.8.4.4]
      match:
        macaddress: bc:24:11:fb:1f:ee
      set-name: ens18
  version: 2

Aplicado con netplan apply (verificado con ip route mostrando proto static en la ruta default, más ping sano a gateway e internet). Config vieja (dhcp4: true) respaldada junto al archivo real, 00-installer-config.yaml.bak.<timestamp>, mismo directorio.

Pendiente, no hecho desde aquí: sacar .14 del rango DHCP del router (o poner una reserva por la MAC de arriba), para que nunca la vuelva a ofrecer a otro cliente — esta config solo asegura el lado de ctunlinux, no lo que el router pueda repartir.

Flujo de datos

SQL Server .207/.204  ──dlt──►  PostgreSQL :5433/trivasa_dw  ──dbt Core──►  analytics_marts  ──►  Lightdash / Metabase
//.211/SincronizarXml ──py──►   raw_sat

Servicios (docker)

Los docker-compose.yml viven en ~/stack/, fuera de git — un yaml por servicio, nunca uno consolidado. Es config operativa de esta máquina, no código.

Servicio Contenedor Puerto Notas
PostgreSQL warehouse postgres-dw (postgres:16) 0.0.0.0:5433 La base trivasa_dw. Único expuesto fuera de loopback junto con Metabase.
Lightdash lightdash 127.0.0.1:8090 + lightdash-db (pgvector/pg15), lightdash-minio, lightdash-headless-browser. Público vía tunnel: dash.frento.com.mx
Metabase metabase-metabase-1 0.0.0.0:3000 + su propia postgres:16
Loki loki (grafana/loki:3.7.6) 127.0.0.1:3100 Destino de los checks de calidad
Perses perses (persesdev/perses:v0.54.0) 127.0.0.1:8080 Dashboards de observabilidad

Lightdash usa volúmenes con nombre (lightdash_lightdash-db-data, lightdash_lightdash-minio-data), persistentes entre down/up. Tras cambiar .env, siempre down + up — nunca solo restart, porque las variables de interpolación solo se leen al crear el contenedor.

Acceso de solo lectura a postgres-dw sin pasar por ctunlinux

Agregado 2026-08-28. Antes, para checar cualquier tabla de trivasa_dw había que entrar por SSH a ctunlinux y hacer docker exec postgres-dw psql -U trivasa ... — el usuario trivasa es el owner (dlt/dbt escriben con él) y no estaba pensado para consultas ad-hoc externas.

Se creó un rol dedicado, solo SELECT, para no tener que pasar por el contenedor:

  • Usuario: ealcocer_ro — SELECT en los 8 schemas de la base (raw, raw_staging, raw_sat, analytics_staging, analytics_intermediate, analytics_marts, monitoring, public), incluyendo tablas futuras vía ALTER DEFAULT PRIVILEGES. Sin permiso de escritura — verificado (CREATE TABLE da permission denied for schema raw).
  • Como postgres-dw ya expone 0.0.0.0:5433 (ver tabla de servicios arriba), este usuario conecta directo a 192.168.117.14:5433/trivasa_dw desde cualquier host con red hacia ctunlinux, sin SSH.
  • Credenciales en Infisical, proyecto Trivasa, entorno dev, raíz /: POSTGRES_DW_HOST, POSTGRES_DW_PORT, POSTGRES_DW_DBNAME, POSTGRES_DW_RO_USERNAME, POSTGRES_DW_RO_PASSWORD (mismo patrón que las SMB_200_* ya documentadas en servidores-y-bases.md).

Ejemplo de uso:

from sqlalchemy import create_engine
engine = create_engine('postgresql+psycopg2://ealcocer_ro:<POSTGRES_DW_RO_PASSWORD>@192.168.117.14:5433/trivasa_dw')

No se creó un connections/connection_postgres_dw_ro.py en trivasa-bi-core para esto (decisión explícita, a diferencia de las conexiones a SQL Server) — queda pendiente si hace falta más adelante.

Repos y qué vive en cada uno

Repo Contenido
trivasa-bi-core ELT como código: dlt/ (ingesta desde SQL Server), raw_sat_xml/ (ingesta desde archivos XML, sin dlt — ver consulta-xmls), dbt/ (transformación), lightdash/, observability/, orchestration/, connections/
trivasa-context Este sitio: docs, decisiones, proyectos curados
~/por_ordenar/ Trabajo antiguo sin clasificar, fuera de git — de consulta/referencia, no de trabajo activo. Nada en producción vive aquí (sin cron, sin systemd, sin mount persistente apuntando adentro) — si algo ahí lo tiene, ya se promovió mal y hay que sacarlo (ver regla de promoción en CLAUDE.md). Nada llega a trivasa-context sin curar.
~/stack/ docker-compose de cada servicio, sin git

Extracción — dlt, no Meltano

La ingesta corre con dlt desde scripts Python en trivasa-bi-core/dlt/. No hay Meltano instalado ni ningún meltano.yml en la máquina.

Credenciales: destino Postgres y orígenes SQL Server viven en dlt/.dlt/secrets.toml ([destination.postgres.credentials], [sources.mssql_sa], [sources.mssql_ealcocer]) — el archivo completo está en .gitignore.

Corregido 2026-08-27: hasta esa fecha la nota de arriba decía la verdad al revés — las mismas 2 combinaciones usuario/password (sa/... contra .200/.204/.205, EALCOCER/... contra .207 producción) estaban hardcodeadas y duplicadas en 12 archivos trackeados en git (los 6 dlt/load_*.py, los 5 connections/*.py, observability/checks/check_raw_freshness.py), confirmado con git grep. trivasa-bi-core/shared/db_credentials.py es ahora el único lugar que las arma — cada script las importa de ahí en vez de repetirlas. Pendiente (no lo resuelve ese cambio, solo detiene que se sigan duplicando en código nuevo): rotar esas 2 passwords en SQL Server, ya que quedaron expuestas en el historial de git antes de la corrección — ver "Pendiente" más abajo.

⚠️ Gotcha (2026-08-27): dlt no crea índice sobre el primary_key declarado para merge, solo sobre _dlt_id. Confirmado en raw.movimiento (4.58M filas, 2.97GB, write_disposition="merge", primary_key=["Mv_Folio", "Mv_ID"] en load_movimiento.py) — tras el reload completo de raw.* del 2026-08-26 (ver nota en Observabilidad), la corrida diaria de load_movimiento.py pasó de 4-25 segundos habituales a 38 min (26-ago) y 64 min (27-ago), detectado por el chart de duración nuevo del dashboard elt-health. Causa raíz confirmada con EXPLAIN ANALYZE: raw.movimiento solo tenía índice en _dlt_id (el surrogate de dlt) — un lookup por (mv_folio, mv_id), la llave real que el merge usa para saber qué actualizar, forzaba un parallel seq scan de las 4.5M filas (8.2 segundos por lookup, casi todo I/O de disco). El mismo patrón — 0 índices propios, solo el de _dlt_id — está en todas las tablas de raw.*, pero solo importa en la práctica en movimiento: es 16x más grande que la siguiente tabla (transferencia, 181MB), así que en el resto el seq scan es trivial. Fix: CREATE INDEX CONCURRENTLY movimiento_mv_folio_mv_id_idx ON raw.movimiento (mv_folio, mv_id) (no bloquea escrituras, ~30s de build) — el mismo lookup bajó de 8,175ms a 0.53ms después. (mv_folio, mv_id) sí es único (0 duplicados, verificado) — el problema era puramente falta de índice, no una llave mal declarada. Si se vuelve a hacer un reload completo de raw.* en el futuro, recrear a mano los índices de merge key de las tablas grandes (dlt no los repone solo) — no hay todavía una lista canónica de qué tablas necesitan uno además de movimiento, evaluar por tamaño si vuelve a pasar.

Transformación — dbt Core

Proyecto trivasa_dbt, perfil en ~/.dbt/profiles.yml (fuera del repo, se arma a mano por máquina).

Capa Materialización Schema
staging view analytics_staging (marcado hidden)
marts table analytics_marts

Además de models/{staging,intermediate,marts}/, el proyecto debe tener seeds/, snapshots/, analyses/ y packages.yml — aunque estén vacíos, son el lugar oficial para no reinventar convención después.

Estado 2026-08-25: intermediate//marts/ ya tenían contenido real (14 marts, 6 modelos intermedios) antes de esta pasada — no eran carpetas vacías. Lo que sí faltaba y se completó: 4 sources de raw sin declarar (ztrv_solicitud_material_ceco, ztrv_solicitu_material_producto — typo heredado de la tabla física, no corregido —, ztrv_solicitud_materia_documento, ztrv_solicitud_agenda_logistica) más raw.compra (declarado como source pero sin modelo staging); los 5 modelos stg_* nuevos; tests declarativos (unique/not_null/relationships, nativos de dbt, sin agregar dbt_utils) en _sources.yml (todo source, vía _dlt_id) y en _staging.yml (cobertura parcial, ver comentario en el archivo); y los directorios seeds/, snapshots/, analyses/ + packages.yml que no existían. dbt build completo: PASS=137 WARN=2 ERROR=0 (los 2 warn son gaps de datos reales y pequeños, documentados inline en _staging.yml, no bugs).

Orquestación — cron

Los pipelines de trivasa-bi-core corren por crontab del usuario ealcocer, no por systemd timers. Se evaluó migrar a systemd timers (~/.config/systemd/user/) por consistencia con los servicios persistentes que ya lo usan en esta máquina (cloudflared, etc.) — decisión 2026-08-27: quedarse en cron, es lo que corre de verdad y funciona sin problemas; migrar no aportaba nada más que un .service+.timer por tarea sin necesidad real. Ver la tabla de horarios en Warehouse.

Nota histórica: hasta 2026-08-12 esta página documentaba systemd timers como la convención "oficial" con la migración pendiente — quedó al revés de la realidad durante más de dos semanas. CLAUDE.md de trivasa-bi-core decía lo mismo hasta 2026-08-27, corregido en ese mismo commit.

Observabilidad

Actualizado 2026-08-27, layout revisado 2026-08-28: Perses (data.frento.com.mx) tiene un único dashboard, elt-health (proyecto elt — hasta 2026-08-27 se llamaba soda, heredado de cuando el check real todavía citaba a Soda Core como referencia; ya no queda ninguna mención a Soda en el repo). Antes eran 3 dashboards sueltos (uno por capa de sanidad ELT), consolidados en uno solo para no tener que revisarlos por separado. Todos leen de Loki, mismo patrón: un script Python postea 1 línea JSON por tabla/stage a /loki/api/v1/push con un job= distinto, y el dashboard agrega con LogQL (count_over_time, count by (...)).

El dashboard son 4 secciones plegables (Grid layout con display.collapse), no pestañas — ver el gotcha de 2026-08-28 más abajo sobre por qué no es Tabs.

Sección (elt-health) Capa Script / fuente job en Loki
Resumen Vista combinada de las 3 capas — 1 gauge + 1 stat con sparkline por capa — —
Freshness y Reconciliación Freshness + reconciliación fuente→destino check_raw_freshness.py — compara filas modificadas en 30d entre .207 y raw.* vía Fecha_Ult_Modif. Solo cubre las 17 tablas con esa columna utilizable. raw_freshness (hasta 2026-08-27, soda_real)
Volumen y Completitud Volumen/completitud check_raw_volume.py — COUNT(*) total por tabla contra la corrida anterior de este mismo check (lee su propio histórico de Loki, no hace falta un store aparte). Cubre las 33 tablas de raw/raw_sat completas (auto-descubiertas por information_schema, no una lista fija — incluye cfdi_emitidos/cfdi_retencion desde 2026-09-02 sin tocar el check), incluidas las que se cargan con replace sin columna de fecha. raw_volume
Corridas del Pipeline Éxito/fallo/duración de las corridas Dos fuentes conviven en el mismo job: (a) observability/checks/run_tracked.sh <stage> <comando> — envoltura genérica en cron para dbt build y dbt/scripts/deploy_docs.sh, mide duración/exit code sin tocar el comando; (b) check_pipeline_run_health.py — puente que lee monitoring.pipeline_runs (tracking nativo de dlt, poblado por dlt/log_run_metrics.py en cada load_*.py) y lo espeja a Loki. pipeline_run

Cada sección de detalle (Freshness, Volumen, Pipeline) trae además una tabla de estado por tabla/stage (verde/amarillo/rojo, ordenable) — antes solo se sabía "cuántas tablas fallaron" desde el dashboard; para saber cuáles había que entrar a last_status*.txt en el servidor. La sección de Pipeline suma un chart de duración de corrida por stage (mediana diaria vía quantile_over_time + unwrap sobre duration_seconds) — dato que los checks ya posteaban a Loki pero ningún dashboard usaba.

Tolerancia de check_raw_freshness.py antes de marcar fail: max(5, 0.1 % del conteo origen) — producción está viva y el check corre después de las cargas, así que unas pocas filas de diferencia son lag normal. check_raw_volume.py usa max(20, 5% del conteo anterior), más laxo porque compara contra sí mismo, no contra una fuente de verdad.

Cada check escribe también su propio last_status*.txt junto al script, para consulta rápida sin pasar por Loki/Perses.

Sustituyó a dvt-checks/, que hacía column+schema checks con un contenedor Docker propio y resultó más pesado de lo necesario. observability/scripts/generate_dummy_data.py (simulaba scans de "Soda Core" con datasets ficticios) se borró 2026-08-27 — no aplicaba a nada real. No hay canal de notificación externo todavía (decisión 2026-08-10: no valía la pena un bot dedicado solo para esto) — pendiente de alertas a Telegram vía wa-scheduler (vm-playground, bot @Waschedule54_bot), bloqueado por falta de acceso SSH a esa máquina desde ctunlinux (ver "Pendiente" abajo).

Gotcha (2026-08-25): el volumen nombrado perses_data quedó root-owned (0755 root:root) desde que se creó (2026-08-11), pero el contenedor de Perses corre como uid 65532 (distroless nonroot) — cualquier escritura (crear un proyecto, un dashboard) fallaba con mkdir /perses/data/projects: permission denied (500 en la API). No se manifestó antes porque nadie había creado un proyecto/dashboard real todavía. Fix: chown -R 65532:65532 /var/lib/docker/volumes/observability_perses_data/_data. Si se recrea el volumen desde cero, aplicar el mismo chown antes de usar percli apply.

Gotcha (2026-08-28): la imagen oficial persesdev/perses:v0.54.0 viene rota de fábrica — ningún panel con datos reales renderiza, en ningún dashboard. Tras consolidar el dashboard (ver arriba), quedó en blanco al abrirlo — sin error visible, cero requests a /proxy/... en los logs del contenedor pese a que el dashboard, el datasource y Loki estaban perfectamente sanos (verificado pegándole al proxy a mano con curl, con datos reales de vuelta). La causa apareció en la consola del navegador: Module Federation rechaza compartir los módulos singleton (react, @perses-dev/plugin-system, @perses-dev/dashboards, etc.) entre el host y todos los plugins de panel (Loki, TimeSeriesChart, Table, y el resto) porque el host se reporta a sí mismo como 0.54.0-rc.1 — un release candidate — mientras los plugins piden ^0.54.0, rango que por semver excluye pre-releases. Además el host trae React 18.3.1 empaquetado contra plugins compilados para 18.2.0. Confirmado que no es un pull viejo: el digest de la imagen corriendo en ctunlinux (sha256:6def46a66...) es idéntico al que Docker Hub sirve hoy para el tag v0.54.0 (y para latest, mismo digest) — el bug está en el build oficial que Perses publicó, no en nuestro pull. No hay tag más nuevo en Docker Hub (v0.54.0/latest es lo último) ni un v0.54.1; tampoco hay issue abierto en perses/perses que lo describa a la fecha.

Fix: downgrade a persesdev/perses:v0.53.1 (último release estable antes de v0.54.0, sin este bug — confirmado con /api/v1/health reportando "version":"0.53.1" limpio, sin -rc). Costo real: v0.53.1 no soporta el layout Tabs — se agregó recién en v0.54.0 vía la dependencia perses/spec v0.2.0-rc.0 (en v0.53.1 esa dependencia ni existe en go.mod). El dashboard elt-health.yaml se reescribió de 1 layout Tabs con 4 pestañas a 4 layouts Grid independientes con display.collapse: {open: true} — mismo contenido, ahora como secciones plegables apiladas en una sola página en vez de pestañas. Antes de bajar la versión del contenedor, el dashboard/proyecto viejo se borró por API (percli delete dashboard elt-health -p elt) para que el binario v0.53.1 no intentara leer al arrancar un recurso persistido con kind: "Tabs", que no reconoce. Si en el futuro sale un v0.54.1 (o más adelante) que corrija el version bump, re-evaluar volver a Tabs — mientras tanto, ~/stack/observability/docker-compose.yml queda fijo en v0.53.1, no v0.54.0, aunque parezca un downgrade "por error" al leerlo suelto.

Gotcha (2026-08-24/25): Loki y Perses no volvieron solos tras un reboot del host (uptime -s mostró 2026-08-24 08:48, contenedores con Exited (127) ~33h). Ambos tienen restart: unless-stopped en ~/stack/observability/docker-compose.yml, que debería alcanzar, pero algo en el orden de arranque (Docker/red/NetBird no listos a tiempo) los dejó caídos silenciosamente — check_raw_freshness.py seguía corriendo por cron y fallando con ConnectionError a 127.0.0.1:3100 sin que nadie lo notara hasta revisar el log a mano. docker compose up -d los revive. Sin timer/healthcheck que lo detecte solo todavía — el dashboard "ELT Pipeline Run Health" ayuda a notar el patrón (stage con fail sostenido), pero no sustituye una alerta real (ver "Pendiente").

torep — capturar output de scripts a HTML navegable

Helper personal en ctunlinux (no es parte de trivasa-bi-core, vive suelto en el home de ealcocer) para no perder el output de scripts Python de exploración/diagnóstico — prints, tracebacks, tablas de rich — que de otro modo solo quedan en la terminal.

  • Ejecutable: ~/torep/torep (bash). ~/torep está en el PATH vía ~/.bashrc (export PATH="$HOME/torep:$PATH").
  • Uso: torep archivo — detecta la extensión: .py corre con python3, .sql corre directo con duckdb -f (CLI puro, sin envoltura de Python — ver sección de DuckDB abajo). Cualquiera de los dos casos captura la sesión completa de terminal con script, la convierte a HTML con aha --black, y la agrega a un reporte acumulado por carpeta: ~/torep-www/<nombre-carpeta-del-script>.html. Archivos de un mismo proyecto se van apilando en el mismo HTML, cada corrida con su propio bloque con encabezado nombre.ext exit N.
  • Reportes: ~/torep-www/, servido en http://<ip-de-ctunlinux>:8000/ con python3 -m http.server 8000 --directory ~/torep-www, levantado a mano (nohup + disown, no hay unit de systemd todavía). Un index.html autogenerado en cada corrida lista los proyectos, ordenable por nombre o última corrida.

⚠️ Gotcha (2026-08-14): script no propaga el exit code sin -e. La primera versión usaba script -qc "... python3 script.py" archivo y leía $? después — pero script sin la flag -e/--return siempre devuelve el exit status de sí mismo (típicamente 0), no el del proceso hijo. Resultado: todos los reportes decían exit 0 aunque el script hubiera fallado con traceback y todo. Fix: script -qec "..." archivo (flag -e agregada). Verificado corriendo un script con sys.exit(1) a propósito — antes de -e reportaba exit 0, después exit 1. Si se reescribe torep, no perder esta flag.

⚠️ Gotcha (2026-08-14): duckdb se cuelga bajo la pty de script sin -light-mode. Sin flag explícito de tema, duckdb auto-detecta si la terminal es clara u oscura mandando una consulta OSC de color de fondo y esperando la respuesta del emulador. Bajo la pty que crea script para torep no hay emulador real del otro lado que conteste — la consulta se queda colgada para siempre (visto primero corriendo un script que probaba los 18 modos de salida del CLI: se colgó justo al entrar al modo column, con result sets grandes). Fix: torep invoca duckdb -light-mode -cmd '.pager off' -f archivo.sql para .sql — la flag evita la auto-detección, y -cmd '.pager off' evita que un result set grande dispare el paginador (mismo problema, mismo síntoma: esperar input de una terminal que no está ahí).

DuckDB CLI + conector MS SQL Server

Instalado 2026-08-14 para exploración rápida vía SQL directo contra SQL Server, sin pasar por Python/pandas/pymssql.

  • Instalación: curl -fsSL https://install.duckdb.org | sh — deja el binario en ~/.duckdb/cli/<version>/duckdb con symlink latest, y crea otro symlink en ~/.local/bin/duckdb (ya en PATH).
  • Conector MS SQL Server: no es built-in — es la extensión community mssql (INSTALL mssql FROM community; LOAD mssql;). Ojo: no se llama sqlserver ni mssql_scanner (esos nombres no existen en el repo community, dan 404).
  • Conexión: ATTACH 'Server=<host>,<puerto>;Database=<db>;User Id=<user>;Password=<pass>;TrustServerCertificate=yes' AS alias (TYPE mssql); — el formato de connection string es distinto al de pymssql/sqlalchemy (Server=host,puerto con coma, no host:puerto). Probado contra .205/TRIVASADB3 con las credenciales de trivasa-bi-core/connections/connection_205_trivasadb3.py. Tras el ATTACH, las tablas se referencian como alias.dbo.NombreTabla.
  • Ver los dos gotchas de -light-mode/.pager off arriba — aplican a cualquier uso interactivo o via torep, no solo a .sql corridos por el helper.

Acceso remoto

  • Cloudflare Tunnel (cloudflared) — credenciales en ~/.cloudflared/ y /etc/cloudflared/. Ingress (/etc/cloudflared/config.yml), un hostname por servicio:
Hostname Servicio local
dash.frento.com.mx Lightdash (127.0.0.1:8090)
metabase.frento.com.mx Metabase (localhost:3000)
explore.frento.com.mx (localhost:8501)
data.frento.com.mx (127.0.0.1:8080, Perses)
monitor.frento.com.mx (127.0.0.1:61208)
bot.frento.com.mx varela-bot (127.0.0.1:8091) — no es BI, ver proyectos/varela-bot
reportes.frento.com.mx Streamlit reportes, ruteo por path: /recursos-materiales*→8501, /contabilidad*→8502, resto→8503
reportesweb.frento.com.mx query-api (127.0.0.1:8765), servicio systemd — puente HTTP de solo lectura para Cowork (ver gotcha de quick tunnel abajo); reemplazó un CNAME huérfano que apuntaba a un túnel ya inactivo (error 1033)

📌 Pendiente: reportesweb.frento.com.mx sigue siendo el nombre genérico que se le puso cuando el hostname solo estaba "reservado para pruebas de API ad-hoc" — no describe el servicio real que corre ahí ahora (query-api). Cambiarlo a algo más específico (ej. query-api.frento.com.mx) queda pendiente, ver query-api § infraestructura.

⚠️ Gotcha (2026-09-07): un quick tunnel (cloudflared tunnel --url ...) en esta máquina siempre devuelve 404, aunque el servicio local sí responda. Causa: cloudflared busca automáticamente un config por default en /etc/cloudflared/config.yml — que es justo el de este túnel nombrado (ctunlinux) — y lo carga aunque se le pase --url. Como el hostname aleatorio de trycloudflare.com no matchea ninguna regla de ingress, cae en el catch-all service: http_status:404 de este mismo archivo. El síntoma engaña: parece un 404 real del borde de Cloudflare (headers server: cloudflare, cf-ray válido), pero con --loglevel debug se ve ingressRule=9 originService=http_status:404 — la petición sí llega al proceso, solo la enruta mal. No es firewall/DPI de red (se descartó con iptables/nft limpios y handshake TLS/QUIC exitoso). Fix para probar un quick tunnel real en esta máquina: forzar que ignore el config global, p. ej. cloudflared tunnel --config /dev/null --url http://localhost:PUERTO (con /dev/null como config vacío falla la carga y cloudflared cae al comportamiento sin ingress rules). Para exponer algo de verdad, mejor sumar una entrada de ingress en /etc/cloudflared/config.yml + cloudflared tunnel route dns -f ctunlinux <hostname> (como se hizo para reportesweb.frento.com.mx arriba) en vez de pelear con el quick tunnel.

  • ZeroTier y RustDesk instalados en el servidor Windows .200.

Deploy de Lightdash (dbt → explores)

El CLI (@lightdash/cli) no viene preinstalado con el stack — se instala on-demand vía npm install -g @lightdash/cli en la máquina desde la que se despliega (hoy, directo en ctunlinux; compila un binding nativo de ssh2, tarda ~2 min).

Login: headless, con Personal Access Token — no con OAuth de navegador. El flujo OAuth (lightdash login <url>) abre un callback en localhost:<puerto random> que corre en la misma máquina que el CLI; si el CLI corre en ctunlinux y el navegador está en otra máquina, el callback nunca llega. Generar el PAT desde https://dash.frento.com.mx → Settings → Personal Access Tokens, luego:

lightdash login https://dash.frento.com.mx --token <PAT>
lightdash config set-project --uuid <project-uuid>   # fija el proyecto default

Antes de desplegar, los modelos deben estar materializados de verdad (dbt run --profiles-dir ~/.dbt) — Lightdash lee el catálogo físico del warehouse (columnas reales vía information_schema), no solo el .yml. Un modelo nunca corrido no tiene columnas que ofrecer como dimensiones.

⚠️ Nota de decisión / gotcha (2026-08-12, corregida 2026-08-21): +meta: hidden: true puesto a nivel dbt_project.yml (usado hoy para todo models/staging/) no oculta el explore completo — oculta las dimensiones individuales. Con 0 dimensiones visibles, Lightdash rechaza el modelo como explore inválido (No dimensions available).

--exclude staging YA NO BASTA. Confirmado 2026-08-21: modelos de intermediate que no tienen su propio meta.dimension fallan con el mismo error No dimensions available — mismo mecanismo que staging, aunque no tengan hidden: true explícito. El comando real de deploy es:

lightdash deploy --exclude staging,intermediate --profiles-dir ~/.dbt -y

Corrección sobre una creencia equivocada de esta misma nota: meta.dimension en _marts.yml no es lo que habilita una columna como dimensión — dbt/Lightdash expone toda columna no oculta como dimensión por default; meta.dimension solo personaliza label/type de una dimensión que ya existiría de todas formas. No hacía falta agregarlo a una columna (ej. folio) solo para poder usarla en una tabla/explore.

Con esto se despliegan los explores de marts (incluido fct_transferencia, agregado 2026-08-21 — deploy exitoso, 12/12 explores), que es lo que se consume en Lightdash de todas formas.

Proyecto activo en Lightdash: trivasa_dw (uuid df98464b-9806-49f2-b5cb-2f99d47905ad).

⚠️ Riesgo abierto, sin resolver: el password de postgres-dw en ~/stack/postgres-warehouse/.env sigue siendo el placeholder por defecto — nunca se rotó a uno real. Pendiente de cambiar (recordar down + up, no restart, tras el cambio — ver nota de volúmenes arriba).

Gotcha: host del warehouse — localhost no sirve para queries (2026-08-12)

lightdash deploy --create guarda en el proyecto la conexión al warehouse tal cual está en ~/.dbt/profiles.yml (host: localhost) — correcto para dbt run, que corre en el host de ctunlinux. Pero las queries que corren desde la UI de Lightdash las ejecuta el contenedor lightdash, y ahí localhost/127.0.0.1 apunta al propio contenedor, no al host. Resultado: al abrir cualquier explore, Error loading results — connect ECONNREFUSED 127.0.0.1:5433, aunque dbt debug y el deploy hayan salido limpios.

Fix aplicado:

  1. ~/stack/lightdash/docker-compose.yml — se agregó extra_hosts: ["host.docker.internal:host-gateway"] al servicio lightdash, para que ese hostname resuelva al host real (Docker 20.10+, confirmado con Docker 29.6.1). Requiere recrear el contenedor (down + up, no restart) para que tome efecto.
  2. En profiles.yml se agregó un target extra lightdash (mismo warehouse, solo cambia host a host.docker.internal) — el target prod para dbt run en el host queda intacto.
  3. lightdash set-warehouse --target lightdash -y — actualiza la conexión guardada del proyecto sin tocar el resto de su config. Este comando existe justo para esto; no hace falta editarlo a mano por la UI ni pegarle a la API directo.

Cualquier proyecto nuevo creado con --create va a nacer con el mismo problema — correr set-warehouse --target lightdash es parte del flujo, no un parche de una sola vez.

Imagen Docker de Lightdash — slim, solo dbt v1.12/Postgres (2026-08-26)

La imagen oficial lightdash/lightdash:latest (versión 1.129.0 corriendo) pesa 13.9GB — medido con docker system df -v/docker history sobre la imagen realmente pulled en ctunlinux, no una cifra de otra versión o de Docker Hub. El grueso, 7.5GB (más de la mitad), son 9 versiones de dbt embebidas en paralelo (/usr/local/dbt1.4 a dbt1.12), cada una con 7-9 adaptadores instalados (Postgres, Redshift, Snowflake, BigQuery, Databricks, Trino, Clickhouse, Athena, DuckDB) — necesario para soportar cualquier warehouse en cualquier versión de dbt vía integración GitHub/GitLab, pero peso muerto para este deployment: el único proyecto (trivasa_dw, uuid df98464b-9806-49f2-b5cb-2f99d47905ad, confirmado en lightdash-db.projects) usa solo Postgres, tiene dbt_version = v1.12 y dbt_connection_type = 'none' — el manifest se sincroniza por lightdash deploy desde el CLI (ver arriba), el contenedor nunca ejecuta el dbt embebido.

~/stack/lightdash/dockerfile.slim-dbt arma una imagen propia (lightdash-slim:1.129.0) que reusa el build oficial (/usr/app y el entrypoint, copiados de lightdash/lightdash:1.129.0 vía multi-stage FROM ... AS upstream) pero instala solo dbt-core 1.12 + dbt-postgres, de cero, en vez de copiar y recortar el venv oficial. Importante, probado en un contenedor descartable antes de aplicarlo: copiar el venv oficial (1.1GB) y hacerle pip uninstall a los adaptadores no-Postgres solo baja a 835MB, porque pip uninstall no borra en cascada las dependencias transitivas (boto3, google-cloud-bigquery, grpc, snowflake-connector, pyarrow...). Instalar de cero solo lo necesario: 265MB. ~/stack/lightdash/docker-compose.yml construye desde ese Dockerfile (build: + args: LIGHTDASH_VERSION) en vez de usar image: lightdash/lightdash:latest directo.

Resultado medido: 13.9GB → 4.77GB (-66%), contenedor desplegado y sano (/api/v1/health → healthy: true), sin tocar lightdash-db — ningún dato de proyecto/usuario se movió ni migró.

Queda atado a dbt_version = v1.12 de trivasa_dw. Si ese valor cambia, o se agrega un segundo proyecto con otro warehouse/versión, hay que volver a SELECT dbt_version, dbt_connection_type FROM projects; en lightdash-db y ajustar qué versión/adaptadores instala el Dockerfile antes de rebuildear.

⚠️ Gotcha (2026-08-26): el disco de este host estaba al 99% (839MB libres de 48GB) antes incluso de arrancar el build, y llegó a 0 bytes libres dos veces durante los intentos — el build necesita varios GB temporales más que el tamaño final de la imagen (export + unpack de la imagen oficial de origen, más el build cache de BuildKit). Fix temporal: parar, quitar y luego recrear lightdash-headless-browser (ghcr.io/browserless/chromium, 3.84GB) para ganar margen mientras corría el build. El fix permanente es borrar lightdash/lightdash:latest (13.9GB) una vez que la imagen slim ya está validada en producción — dejó el disco en 32GB usados / 14GB libres (70%), muy por debajo del 99% de partida. Ese 99% de partida no lo causó este trabajo — ya estaba así al empezar la sesión.

Gotcha de red: varios intentos de build fallaron a media descarga por cortes intermitentes de DNS (deb.debian.org y registry-1.docker.io, ~70-120s cada corte) — mismo patrón de red inestable de este host. dockerfile.slim-dbt envuelve los apt-get update && apt-get install en un retry-loop de shell (for i in 1..6; do ... || sleep 20; done), no solo las opciones nativas de apt (-o Acquire::Retries), porque un corte total de resolución DNS de varios segundos no lo cubre un retry rápido a nivel de paquete individual.

docker compose up -d lightdash sin --no-deps intenta recrear/pull todas sus depends_on (lightdash-db, lightdash-minio, headless-browser) — si alguna de esas imágenes se quitó a propósito para liberar espacio temporal (como pasó aquí), el pull puede volver a llenar el disco antes de que lightdash mismo llegue a levantar. Usar docker compose up -d --no-deps lightdash cuando las dependencias ya están corriendo y no hace falta tocarlas.

dbt docs

dbt/scripts/deploy_docs.sh corre dbt docs generate (necesita conexión viva al warehouse, por eso corre en ctunlinux después de dbt build, no en un build remoto sin acceso a Postgres) y despliega dbt/target/ a Cloudflare Pages vía npx wrangler pages deploy, mismo patrón manual que trivasa-context-wiki (ver Hosting de la wiki). Proyecto: dbtdocs-trivasa, dominio dbtdocs.ehas.uk. Cron: 07:02, después de dbt build.

Resuelto 2026-08-25. Historial de lo que hizo falta, por si se repite en otro proyecto:

  1. trivasa-bi-core/.infisical.json enlazado al proyecto de Infisical secret-management (2aefdbd1-389c-4fd0-bdb8-a5621af8aac1, self-hosted en secrets.ehas.uk) — es el mismo proyecto compartido de todo el homelab, no uno dedicado a este repo.
  2. El secreto de Cloudflare ya existía, con un nombre que no lo delataba: CLAUDE_CODE_APPS_AND_POLICIES_DNS_ZONES. Renombrado a CLOUDFLARE_CLAUDE_CODE_APPS_AND_POLICIES_DNS_ZONES (mismo valor, preservado sin imprimirlo). El secreto viejo quedó huérfano en Infisical — está marcado "personal secret" y una identidad de máquina no lo puede borrar (Must be user to delete personal secret); pendiente de borrado manual por la UI si se quiere limpiar.
  3. Ese token nació con scope solo de DNS de zona. Se le agregó el permission group Account → Cloudflare Pages → Edit desde el dashboard, y aparte (esto es lo que no era obvio) Account Resources → Include → la cuenta correspondiente — sin este segundo paso el permission group no aplica a ninguna cuenta y todo sigue fallando con Authentication error [code: 10000] aunque el permiso "se vea" agregado.
  4. El token no tiene permiso Account:Read, así que wrangler/GET /accounts no puede autodescubrir la cuenta (devuelve lista vacía siempre, sin importar el scope de Pages). Hubo que fijar CLOUDFLARE_ACCOUNT_ID a mano — ojo con de dónde se saca ese id: la primera vez se tomó el AccountTag de /etc/cloudflared/*.json (túnel de frento.com.mx) y era la cuenta equivocada (9109 Unauthorized to access requested resource al pedir Pages ahí). El id correcto se obtuvo de account.id en GET /zones?name=ehas.uk — ese campo sí es visible con solo permiso de zona, sin necesitar Account:Read.
  5. El proyecto de Pages no existía — wrangler pages project create dbtdocs-trivasa --production-branch=main primero, deploy después.
  6. El dominio custom no auto-crea su CNAME aun con el proyecto y la zona en la misma cuenta (mismo gotcha ya documentado para trivasa.ehas.uk en Hosting de la wiki) — aquí sí se pudo crear por API (POST .../dns_records, dbtdocs → dbtdocs-trivasa.pages.dev, proxied) porque este token trae dns_records:edit de origen.

deploy_docs.sh deja documentado en sus propios comentarios el nombre exacto del secreto y el account ID, con la advertencia de no reusar el AccountTag del túnel.

Cloudflare Access (2026-08-26): dbtdocs.ehas.uk quedó protegido con la misma política que ya usaba wiki.estebanalcocer.cloud — la app de Access dbtdocs reusa la policy existente google project-vm-personal (070a3c92-5aa8-4bb3-8b1f-f250ffe1526e, solo ehalsou@gmail.com vía IdP de Google) en vez de duplicarla, mismo allowed_idps/auto_redirect_to_identity/ http_only_cookie_attribute que esa app. Confirmado con curl -I: la raíz ahora responde 302 a estebanalcocer.cloudflareaccess.com/cdn-cgi/access/login/... en vez de servir el sitio directo.

Pendiente — deuda técnica del ETL (revisión 2026-08-27)

Salió de una revisión completa de dlt/, dbt/ y observability/ en trivasa-bi-core — no son bugs activos (salvo el marcado como tal), es deuda que conviene pagar antes de que crezca más. Orden: de más a menos urgente.

  1. ~~Rotar las 2 passwords SQL Server (sa, EALCOCER) expuestas en el historial de git~~ — decisión explícita del usuario (2026-08-27): no se van a rotar. Quedan centralizadas en dlt/.dlt/secrets.toml (ver arriba) pero las mismas 2 passwords siguen siendo válidas en SQL Server. Si en algún momento cambia esta decisión, rotarlas es cambiar 2 valores en ese archivo, no editar 12 scripts.
  2. ~~dlt/ no separa producción de exploración~~ — resuelto 2026-08-27. pipelines/ y sources/ estaban vacías y sin uso real: se borraron (junto con .claude/, otro directorio vacío). Los 3 smoke tests manuales (test_secrets.py, test_secrets_postgres.py, test_conexion_sqlserver.py) se movieron a dlt/scratch/ con un README.md que explica que deben correrse con cwd=dlt/ (dlt resuelve secrets.toml por directorio de trabajo, no por ubicación del script — verificado corriendo los 3 tras el movimiento). check_duplicados.py (script muerto, password placeholder TU_PASSWORD_AQUI) se borró.
  3. ~~Funciones de diagnóstico/reparación de un solo uso acumulándose dentro de los scripts de carga de producción~~ — resuelto 2026-08-27. dlt/maintenance.py centraliza diff_keys_vs_raw, delete_orphan_keys y backfill_rango (genéricas por tabla/llaves/columna de cursor); load_comprobante_digital.py, load_solicitudes.py, load_movimiento.py, load_compras_inventario.py y load_reorden.py ahora son wrappers delgados sobre ese módulo — se conservaron todos los nombres de función pública (diff_keys_207_vs_raw, delete_orphan_keys, movimiento_rango, etc.) para no romper cómo ya se invocan a mano. Verificado con py_compile + import real de los 5 módulos + construcción (sin correr) de cada resource de backfill, confirmando mismo resource.name que antes en los 4 casos (comprobante_digital, movimiento, compra, producto).
  4. ~~El CLAUDE.md de trivasa-bi-core sigue diciendo "systemd timers, no cron" para la orquestación~~ — resuelto 2026-08-27. Se decidió quedarse en cron (ver sección "Orquestación" arriba) y se corrigió el CLAUDE.md del repo para que diga eso, no lo contrario.
  5. ~~Cobertura de tests dbt cae a cero en intermediate/ y marts/~~ — resuelto parcialmente 2026-08-27, empezando por los marts más consultados en Lightdash (conteo real vía tableName: en lightdash/charts/*.yml): fct_requisiciones_compra (19 charts), fct_requisiciones_compra_worklist (7), fct_solicitud_material_pipeline y fct_documento_trazabilidad (5 c/u), fct_solicitud_material_autorizacion (3, bonus barato por ser grano de una sola columna). Grano de una columna con unique/not_null nativo en _marts.yml; grano compuesto con test singular .sql, mismo patrón que assert_almacen_unique.sql.

Verificado con dbt build real, no solo revisión de SQL — y salieron 2 hallazgos reales que una revisión de código nunca hubiera atrapado: - fct_documento_trazabilidad necesitó agregar compra_folio a la llave de grano: sin ella daba 8,712 "duplicados" falsos, 100% explicados por el fan-out intencional ya documentado en el modelo (una OC puede recibirse en varias compras parciales). - fct_requisiciones_compra sí tiene una brecha real y pequeña: 118 líneas (0.261% de 45,291) tienen más de una Orden_Compra vinculada, concentrado en estado RCT (requisiciones rechazadas con varios intentos de OC, también rechazados). Test en severity:warn, documentado en el propio modelo — no bloquea el build, no se investigó un criterio de negocio para elegir "la" OC vigente ahí.

Quedan sin test de grano el resto de intermediate/ (9 modelos) y de marts/ (9 marts restantes, incluido fct_documento_ficha — grano de pivote, más complejo de expresar). 6. ~~check_pipeline_run_health.py es el único check de cron que no pasa por run_tracked.sh~~ — resuelto 2026-08-27. El cron de las 06:56 ahora lo envuelve igual que a los demás. Causa real del 500 Internal Server Error visto el 2026-08-26: push_to_loki() no atrapaba errores, así que un problema de Loki tumbaba el script entero con una excepción sin capturar antes de llegar al exit(1) explícito por fallo real de dlt — cron podía marcar "fail" por Loki aunque los pipelines hubieran corrido bien, o al revés. push_to_loki_safe() ahora solo advierte a stderr si Loki falla, mismo criterio que run_tracked.sh/log_run_metrics.run_tracked ("postear a Loki es best-effort, nunca debe tumbar el check"). 7. ~~fct_documento_trazabilidad.sql (385 líneas) mete lógica reusable inline en vez de subirla a intermediate/~~ — resuelto 2026-08-27. existencia_agg/reorden_agg (existencia/reorden por almacén-producto) se promovieron a int_existencia_almacen_producto.sql / int_reorden_almacen_producto.sql. sm_detalle/sm_detalle_estado/ sm_detalle_info (las tres agregando la misma tabla al mismo grano (solicitud_folio, producto_id)) se combinaron en un solo modelo, int_solicitud_material_detalle_producto.sql, en vez de promover 3 modelos para el mismo concepto de negocio. Verificado reconstruyendo los 3 modelos + el mart contra el warehouse real: mismo conteo de filas (389,523) y mismo hash md5 agregado del contenido completo de la tabla — cero drift de datos. dbt build completo después: PASS=141 WARN=1 ERROR=0 (el warning es el ya documentado en _staging.yml, no relacionado con este cambio). 8. ~~Menor: connections/connection.py (sin sufijo) vs connection_207.py vs connection_207_empresas2.py no dejan claro cuál es el default sin abrir el archivo~~ — resuelto 2026-08-27, en dos pasos. Primero se agregó un docstring con host/base/default a los 5 archivos, lo que reveló el hallazgo real: connection.py (el nombre más genérico, el que uno asumiría "el" default) apuntaba a .200/TRIVASADB, la copia congelada que Servidores y bases marca "no usar para nada nuevo". Con eso confirmado, se borró directamente en vez de solo documentarlo — sin referencias en el código del repo (git grep). Quedan 4 archivos, todos con host/base en el nombre; el default real sigue siendo connection_205_trivasadb3.py. 9. load_reorden.py tiene nombre engañoso, y su bundling ya causó una falla en cascada real — identificado 2026-09-03, no resuelto, decisión explícita del usuario: dejarlo como pendiente por ahora. El archivo/pipeline trivasa_reorden carga 8 tablas (Reorden + Producto + 6 catálogos: Familia, SubFamilia, Categoria, Departamento, Almacen, Sucursal) — el nombre solo refleja la primera tabla que tuvo, no lo que realmente es (un loader de catálogo de producto, con Reorden viajando ahí por referenciar Producto). Mismo patrón de "un archivo por dominio" que load_compras_inventario.py/ load_solicitudes.py (ver runbook-tabla-nueva.md), pero esos sí tienen nombre que describe el dominio — este no.

Costo real, ya pagado: el incidente de sucursal.z_rango_escaneo (2026-08-25, ver Calidad de datos) tumbó las 8 tablas del pipeline el mismo día por un problema que solo era de sucursal — producto/almacen salieron en fail/warn sin tener nada roto en sí mismos. Causa raíz de ese incidente (columna NOT NULL nueva en origen chocando con NULLs ya en destino) sigue sin arreglarse tampoco.

Si se retoma: el rename de archivo es seguro y barato (actualizar load_reorden.py → nombre que describa el dominio, más crontab y referencias en docs). Cuidado con el pipeline_name="trivasa_reorden" interno de dlt.pipeline() — dlt guarda el cursor incremental de Producto en ~/.dlt/pipelines/trivasa_reorden/state.json; renombrar ese string también (no solo el archivo) resetea ese estado y fuerza un re-pull completo de Producto (27,603 filas, confirmado 2026-09-03 — barato pero no gratis, y no hace falta si solo se renombra el archivo). La cascada de fallo (separar _catalogos() de _reorden()+_producto() en pipelines distintos, o arreglar z_rango_escaneo) es una decisión aparte, no implícita en el rename.

Pendiente — bloqueado por acceso, no por diseño (2026-08-25)

  • Alertas a Telegram (wa-scheduler, vm-playground, bot @Waschedule54_bot): esta sesión no tiene llave SSH autorizada en vm-playground (confirmado, Permission denied (publickey) — la conectividad de red sí existe, vía NetBird). Falta: agregar la llave pública de ealcocer@ctunlinux a vm-playground, o pasar directo el endpoint/token de wa-scheduler para postear la alerta sin SSH. Decisión explícita del usuario (2026-08-25): queda pendiente por ahora.

Higiene antes de comitear

Revisar git add -A -n (dry-run) antes del commit real — ya pasó que .env/secrets.toml casi se cuelan. Checklist: sin .env, sin secrets.toml, sin __pycache__/venv, sin logs generados.