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—SELECTen los 8 schemas de la base (raw,raw_staging,raw_sat,analytics_staging,analytics_intermediate,analytics_marts,monitoring,public), incluyendo tablas futuras víaALTER DEFAULT PRIVILEGES. Sin permiso de escritura — verificado (CREATE TABLEdapermission denied for schema raw). - Como
postgres-dwya expone0.0.0.0:5433(ver tabla de servicios arriba), este usuario conecta directo a192.168.117.14:5433/trivasa_dwdesde cualquier host con red haciactunlinux, sin SSH. - Credenciales en Infisical, proyecto
Trivasa, entornodev, raíz/:POSTGRES_DW_HOST,POSTGRES_DW_PORT,POSTGRES_DW_DBNAME,POSTGRES_DW_RO_USERNAME,POSTGRES_DW_RO_PASSWORD(mismo patrón que lasSMB_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):
dltno crea índice sobre elprimary_keydeclarado paramerge, solo sobre_dlt_id. Confirmado enraw.movimiento(4.58M filas, 2.97GB,write_disposition="merge",primary_key=["Mv_Folio", "Mv_ID"]enload_movimiento.py) — tras el reload completo deraw.*del 2026-08-26 (ver nota en Observabilidad), la corrida diaria deload_movimiento.pypasó de 4-25 segundos habituales a 38 min (26-ago) y 64 min (27-ago), detectado por el chart de duración nuevo del dashboardelt-health. Causa raíz confirmada conEXPLAIN ANALYZE:raw.movimientosolo tenía índice en_dlt_id(el surrogate de dlt) — un lookup por(mv_folio, mv_id), la llave real que elmergeusa 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 deraw.*, pero solo importa en la práctica enmovimiento: 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 deraw.*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 demovimiento, 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.mddetrivasa-bi-coredecí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).~/torepestá en elPATHvía~/.bashrc(export PATH="$HOME/torep:$PATH"). - Uso:
torep archivo— detecta la extensión:.pycorre conpython3,.sqlcorre directo conduckdb -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 conscript, la convierte a HTML conaha --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 encabezadonombre.ext exit N. - Reportes:
~/torep-www/, servido enhttp://<ip-de-ctunlinux>:8000/conpython3 -m http.server 8000 --directory ~/torep-www, levantado a mano (nohup+disown, no hay unit de systemd todavía). Unindex.htmlautogenerado en cada corrida lista los proyectos, ordenable por nombre o última corrida.
⚠️ Gotcha (2026-08-14):
scriptno propaga el exit code sin-e. La primera versión usabascript -qc "... python3 script.py" archivoy leía$?después — peroscriptsin la flag-e/--returnsiempre devuelve el exit status de sí mismo (típicamente 0), no el del proceso hijo. Resultado: todos los reportes decíanexit 0aunque el script hubiera fallado con traceback y todo. Fix:script -qec "..." archivo(flag-eagregada). Verificado corriendo un script consys.exit(1)a propósito — antes de-ereportabaexit 0, despuésexit 1. Si se reescribetorep, no perder esta flag.⚠️ Gotcha (2026-08-14):
duckdbse cuelga bajo la pty descriptsin-light-mode. Sin flag explícito de tema,duckdbauto-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 creascriptparatorepno 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 modocolumn, con result sets grandes). Fix:torepinvocaduckdb -light-mode -cmd '.pager off' -f archivo.sqlpara.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>/duckdbcon symlinklatest, y crea otro symlink en~/.local/bin/duckdb(ya enPATH). - Conector MS SQL Server: no es built-in — es la extensión community
mssql(INSTALL mssql FROM community; LOAD mssql;). Ojo: no se llamasqlservernimssql_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,puertocon coma, nohost:puerto). Probado contra.205/TRIVASADB3con las credenciales detrivasa-bi-core/connections/connection_205_trivasadb3.py. Tras elATTACH, las tablas se referencian comoalias.dbo.NombreTabla. - Ver los dos gotchas de
-light-mode/.pager offarriba — aplican a cualquier uso interactivo o viatorep, no solo a.sqlcorridos 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.mxsigue 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:cloudflaredbusca 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 detrycloudflare.comno matchea ninguna regla deingress, cae en el catch-allservice: http_status:404de este mismo archivo. El síntoma engaña: parece un 404 real del borde de Cloudflare (headersserver: cloudflare,cf-rayválido), pero con--loglevel debugse veingressRule=9 originService=http_status:404— la petición sí llega al proceso, solo la enruta mal. No es firewall/DPI de red (se descartó coniptables/nftlimpios 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/nullcomo config vacío falla la carga y cloudflared cae al comportamiento sin ingress rules). Para exponer algo de verdad, mejor sumar una entrada deingressen/etc/cloudflared/config.yml+cloudflared tunnel route dns -f ctunlinux <hostname>(como se hizo parareportesweb.frento.com.mxarriba) 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: truepuesto a niveldbt_project.yml(usado hoy para todomodels/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 stagingYA NO BASTA. Confirmado 2026-08-21: modelos deintermediateque no tienen su propiometa.dimensionfallan con el mismo errorNo dimensions available— mismo mecanismo que staging, aunque no tenganhidden: trueexplícito. El comando real de deploy es:lightdash deploy --exclude staging,intermediate --profiles-dir ~/.dbt -yCorrección sobre una creencia equivocada de esta misma nota:
meta.dimensionen_marts.ymlno es lo que habilita una columna como dimensión — dbt/Lightdash expone toda columna no oculta como dimensión por default;meta.dimensionsolo personalizalabel/typede 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(incluidofct_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-dwen~/stack/postgres-warehouse/.envsigue siendo el placeholder por defecto — nunca se rotó a uno real. Pendiente de cambiar (recordardown+up, norestart, 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:
~/stack/lightdash/docker-compose.yml— se agregóextra_hosts: ["host.docker.internal:host-gateway"]al serviciolightdash, para que ese hostname resuelva al host real (Docker 20.10+, confirmado con Docker 29.6.1). Requiere recrear el contenedor (down+up, norestart) para que tome efecto.- En
profiles.ymlse agregó un target extralightdash(mismo warehouse, solo cambiahostahost.docker.internal) — el targetprodparadbt runen el host queda intacto. 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 borrarlightdash/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.orgyregistry-1.docker.io, ~70-120s cada corte) — mismo patrón de red inestable de este host.dockerfile.slim-dbtenvuelve losapt-get update && apt-get installen 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 lightdashsin--no-depsintenta recrear/pull todas susdepends_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 quelightdashmismo llegue a levantar. Usardocker compose up -d --no-deps lightdashcuando 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:
trivasa-bi-core/.infisical.jsonenlazado al proyecto de Infisicalsecret-management(2aefdbd1-389c-4fd0-bdb8-a5621af8aac1, self-hosted ensecrets.ehas.uk) — es el mismo proyecto compartido de todo el homelab, no uno dedicado a este repo.- El secreto de Cloudflare ya existía, con un nombre que no lo delataba:
CLAUDE_CODE_APPS_AND_POLICIES_DNS_ZONES. Renombrado aCLOUDFLARE_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. - 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. - El token no tiene permiso
Account:Read, así quewrangler/GET /accountsno puede autodescubrir la cuenta (devuelve lista vacía siempre, sin importar el scope de Pages). Hubo que fijarCLOUDFLARE_ACCOUNT_IDa mano — ojo con de dónde se saca ese id: la primera vez se tomó elAccountTagde/etc/cloudflared/*.json(túnel defrento.com.mx) y era la cuenta equivocada (9109 Unauthorized to access requested resourceal pedir Pages ahí). El id correcto se obtuvo deaccount.idenGET /zones?name=ehas.uk— ese campo sí es visible con solo permiso de zona, sin necesitarAccount:Read. - El proyecto de Pages no existía —
wrangler pages project create dbtdocs-trivasa --production-branch=mainprimero, deploy después. - 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.uken Hosting de la wiki) — aquí sí se pudo crear por API (POST .../dns_records,dbtdocs→dbtdocs-trivasa.pages.dev, proxied) porque este token traedns_records:editde 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.
- ~~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 endlt/.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. - ~~
dlt/no separa producción de exploración~~ — resuelto 2026-08-27.pipelines/ysources/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 adlt/scratch/con unREADME.mdque explica que deben correrse concwd=dlt/(dlt resuelvesecrets.tomlpor directorio de trabajo, no por ubicación del script — verificado corriendo los 3 tras el movimiento).check_duplicados.py(script muerto, password placeholderTU_PASSWORD_AQUI) se borró. - ~~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.pycentralizadiff_keys_vs_raw,delete_orphan_keysybackfill_rango(genéricas por tabla/llaves/columna de cursor);load_comprobante_digital.py,load_solicitudes.py,load_movimiento.py,load_compras_inventario.pyyload_reorden.pyahora 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 conpy_compile+ import real de los 5 módulos + construcción (sin correr) de cada resource de backfill, confirmando mismoresource.nameque antes en los 4 casos (comprobante_digital,movimiento,compra,producto). - ~~El
CLAUDE.mddetrivasa-bi-coresigue 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ó elCLAUDE.mddel repo para que diga eso, no lo contrario. - ~~Cobertura de tests dbt cae a cero en
intermediate/ymarts/~~ — resuelto parcialmente 2026-08-27, empezando por los marts más consultados en Lightdash (conteo real víatableName:enlightdash/charts/*.yml):fct_requisiciones_compra(19 charts),fct_requisiciones_compra_worklist(7),fct_solicitud_material_pipelineyfct_documento_trazabilidad(5 c/u),fct_solicitud_material_autorizacion(3, bonus barato por ser grano de una sola columna). Grano de una columna conunique/not_nullnativo en_marts.yml; grano compuesto con test singular.sql, mismo patrón queassert_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 envm-playground(confirmado,Permission denied (publickey)— la conectividad de red sí existe, vía NetBird). Falta: agregar la llave pública deealcocer@ctunlinuxavm-playground, o pasar directo el endpoint/token dewa-schedulerpara 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.