feat(docs-portal): pipeline GitOps y manifiestos K8s para el portal MkDocs

Agrega lo que faltaba para desplegar workloads/docs-portal/ (ya existente
sin commitear): deployment/service/ingress + kustomization siguiendo el
patrón de workloads/nginx, Applications de workload y gobernanza
separadas, y el workflow de Gitea Actions (build+push a Gitea Registry,
bump de versión en el manifiesto) siguiendo el mismo patrón que
build.yaml/build-medusa.yaml.
This commit is contained in:
2026-08-13 21:08:04 -05:00
parent 11737ba109
commit fb5cf8bc98
27 changed files with 1328 additions and 0 deletions
@@ -0,0 +1,88 @@
# Incidente: 504 Gateway Timeout intermitente (túnel QUIC inestable) (2026-08)
!!! info "Origen"
Migrado desde `apps-registry/docs/playbooks/incidente-cloudflared-504-quic-2026-08.md`.
Contenido técnico preservado sin cambios de fondo — solo formato adaptado a MkDocs Material.
| | |
|---|---|
| **Ventana del incidente** | Intermitente durante varias horas, 2026-08 |
| **Servicio afectado** | `cloudflared` (túnel de Cloudflare, app nativa de ZimaOS, red Host) — afecta a **todos** los subdominios detrás del mismo túnel (`gitea.cruzcloud.net`, `commerce.cruzcloud.net`, `shop.cruzcloud.net`, `media.cruzcloud.net`, Immich, Argo CD, Memos, etc.) |
| **Impacto** | `504 Gateway Timeout` intermitente en peticiones HTTP a cualquier host detrás del túnel, sin patrón evidente de horario ni de carga |
## Síntoma
`gitea.cruzcloud.net` devolvía `504 Gateway Timeout` de forma
intermitente. El mismo síntoma se observaba en otros hosts servidos por
el mismo túnel, lo que apuntaba a una causa **compartida a nivel de
túnel**, no de una app individual.
## Diagnóstico
```bash
docker logs -f cloudflared 2>&1 | grep -i -E "connIndex|protocol|retry"
```
Reveló un patrón sostenido durante horas: la conexión `connIndex=2`
(de las 4 conexiones que `cloudflared` mantiene hacia el edge de
Cloudflare) sufría fallos recurrentes de "control stream" cada 2-6
minutos, reconectándose en loop:
```text
control stream encountered a failure while serving
Retrying connection in up to 1m4s
```
Cuando una petición HTTP coincidía con el instante exacto de una de
esas caídas de `connIndex=2`, el cliente recibía el 504.
## Causa raíz
El contenedor `cloudflared` (imagen oficial `cloudflare/cloudflared:latest`)
usaba **QUIC (UDP)** como protocolo de transporte por defecto para sus
4 conexiones hacia el edge de Cloudflare. Una de esas conexiones
presentaba fallos de control stream recurrentes durante horas.
!!! warning "Causa de fondo probable"
Inestabilidad de UDP/QUIC en la red local (NAT/firewall doméstico).
QUIC es más sensible que TCP/HTTP2 a NATs agresivos o middleboxes
que no manejan bien tráfico UDP de larga vida — un patrón común en
redes domésticas, a diferencia de datacenters con NAT dedicado para
este tipo de tráfico.
## Fix aplicado
Se agregó la variable de entorno `TUNNEL_TRANSPORT_PROTOCOL=http2` en
la configuración del contenedor `cloudflared` (app ZimaOS → Ambiente →
variables), forzando HTTP/2 sobre TCP en vez de QUIC/UDP.
Tras reiniciar el contenedor, el precheck de `cloudflared` confirmó:
```text
Environment is healthy. cloudflared will use 'http2' as primary protocol.
```
y las 4 conexiones pasaron a `protocol=http2`.
Ver también el ADR correspondiente:
[0003 — Por qué HTTP/2 sobre QUIC](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md).
## Validación
Curl en loop contra `gitea.cruzcloud.net` tras el cambio: latencia
estable ~0.370.4s sostenida por 24+ minutos sin un solo 504.
!!! note "Degradación puntual no relacionada, pendiente de vigilar"
Durante la ventana de validación se observó una ventana breve de
latencia alta (hasta 39s) y algunos `502`/`530`, coincidiendo con
una ejecución pesada de Gitea Actions (build/deploy de Medusa) en
el mismo host. Esto es contención de recursos local (CPU/IO del
host compitiendo entre el runner de Actions y el resto de los
servicios), **no relacionado al fix de protocolo del túnel** — no
se reabrió como parte de este incidente.
**Pendiente:** vigilar si estas ventanas de degradación coinciden
sistemáticamente con builds/deploys de Gitea Actions. Si es
recurrente, considerar mover el runner (`gitea-runner`) a otro host
o limitarle recursos para evitar que compita con el resto de las
apps del NAS.
@@ -0,0 +1,175 @@
# Incidente: `medusa-deploy` atascado sin levantar (2026-08)
!!! info "Origen"
Migrado desde `apps-registry/docs/playbooks/incidente-medusa-crashloop-2026-08.md`.
Contenido técnico preservado sin cambios de fondo — solo formato adaptado a MkDocs Material.
| | |
|---|---|
| **Ventana del incidente** | ~2026-08-08 (onset) → 2026-08-10 05:31 UTC (resuelto) |
| **Servicio afectado** | `medusa-deploy` en namespace `ecommerce` |
| **Impacto** | El rollout nuevo nunca llegaba a estar listo. El pod anterior siguió sirviendo tráfico sin interrupción (`maxUnavailable: 0`) — no hubo downtime de cara al usuario. El impacto fue "no se puede desplegar", no "el servicio está caído". |
## Síntoma inicial reportado vs. estado real
El incidente se reportó como **CrashLoopBackOff** ("sigue igual desde
ayer, más de 20-24 horas"). Al correr `kubectl get pods -n ecommerce -o wide`
esa descripción resultó **inexacta**: no había ningún pod en
CrashLoopBackOff. El estado real era:
```text
commerce-postgres-0 1/1 Running 0 4d9h
medusa-deploy-59878b956f-hf45b 1/1 Running 0 21h (revisión vieja, sana)
medusa-deploy-9d8784d7b-g9jql 0/1 Init:0/2 0 3h56m (revisión nueva, atascada)
```
0 *restarts* en el pod nuevo — no estaba crasheando y reiniciando,
estaba **colgado indefinidamente en el primer init container**
(`wait-for-postgres`), sin timeout ni backoff que lo sacara de ese
estado.
!!! tip "Lección"
Un pod en `Init:X/Y` con 0 restarts y un `CrashLoopBackOff` requieren
líneas de investigación distintas — y el primero no dispara las
alertas típicas de CrashLoop, lo que probablemente explica por qué
pasó desapercibido tanto tiempo. **Verificar siempre el estado real
con `kubectl get pods` antes de asumir el tipo de falla que describe
quien reporta el incidente.**
## Hipótesis descartadas (en orden)
### 1. Credenciales / secret inválido o vacío
- `kubectl get secret -n ecommerce commerce-secrets` → la clave
`DATABASE_URL` existe, longitud correcta (~126 caracteres).
- `pg_isready` ejecutado **dentro del propio pod `commerce-postgres-0`**
con ese `DATABASE_URL`: `accepting connections`, exit 0.
- **Descartada:** el secret existe, tiene el formato correcto y las
credenciales son válidas.
### 2. NetworkPolicy bloqueando tráfico
- `kubectl describe networkpolicy -n ecommerce ecommerce-network-security`
`PodSelector: <none>`, ingress allow-all, egress sin restricciones.
- **Descartada:** no podía estar bloqueando la conexión de `medusa`
hacia `postgres-svc`.
### 3. Salud de Postgres
- `SELECT count(*) FROM pg_stat_activity;` → 8 conexiones activas,
respuesta inmediata.
- `kubectl get endpoints -n ecommerce postgres-svc` → apunta
correctamente a `10.42.0.98:5432`.
- **Descartada:** Postgres estaba sano.
### 4. ResourceQuota / LimitRange del namespace
- `pods: 7/12`, `requests.cpu: 625m/3`, `requests.memory: 1504Mi/4Gi`
todo muy por debajo de los topes. Sin eventos de `exceeded quota`.
- **Descartada:** no había presión de cuota.
### 5. DNS
- `getent hosts postgres-svc` resolvía a `10.42.0.98` correctamente,
tanto en el pod sano como **dentro del propio init container
atascado**. CoreDNS sano.
- **Descartada:** la resolución DNS funcionaba de forma idéntica en
ambos pods.
### 6. MTU/PMTUD cross-node (blackhole conocido)
Existe un blackhole de red confirmado (186/186 fallos reproducidos)
cuando un pod que necesita hablar con `commerce-postgres-0` queda
agendado en un nodo distinto al de Postgres, mitigado con `podAffinity`
en `medusa.yaml`.
- `kubectl get pods -o wide` mostró que los tres pods relevantes
estaban en el **mismo nodo** (`k3d-lab-cluster-server-0`).
- **Descartada para este incidente puntual** (sin tráfico cross-node
involucrado) — pero sigue siendo una condición latente real del
cluster. Ver [nota de deuda técnica](#deuda-técnica-pendiente) abajo.
## Causa raíz real
El init container `wait-for-postgres` ejecutaba:
```sh
until pg_isready -d "$DATABASE_URL" -t 5; do ...
```
pasando la URI completa de `DATABASE_URL` a `pg_isready`. En algún
momento se agregó el query param `?sslmode=disable` al `DATABASE_URL`
generado por `create-commerce-secrets.sh` (repo `scripts`). Con ese
query param, el parser de conninfo de `pg_isready` en la imagen
`postgres:17-alpine` no lograba interpretar la URI y devolvía:
```text
postgres-svc:5432 - no attempt
```
!!! danger "`exit 3``no attempt`"
Es un status propio de `pg_isready` que indica que el cliente **ni
siquiera intentó conectar** por un problema en los parámetros de
conexión (a diferencia de `no response`, que sí implica un intento
de conexión fallido).
Aislamiento reproducido dentro del propio init container atascado:
- `pg_isready -h postgres-svc -p 5432 -U medusa -d medusa` (sin URI) →
`accepting connections`, exit 0.
- URI completa **sin** `?sslmode=disable` → también exitosa.
- URI completa **con** `?sslmode=disable` → reproducía el fallo.
Como el `until` no tiene timeout ni backoff máximo, el init container
quedaba reintentando indefinidamente sin nunca fallar de forma visible
— de ahí que no apareciera como CrashLoopBackOff.
## Fix aplicado
`workloads/ecommerce/commerce/medusa.yaml`, init container
`wait-for-postgres`:
```diff
- until pg_isready -d "$DATABASE_URL" -t 5; do
+ until pg_isready -h postgres-svc -p 5432 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -t 5; do
```
`postgres-svc` y `5432` son fijos (el Service de Postgres);
`POSTGRES_USER` y `POSTGRES_DB` ya llegan al contenedor vía
`envFrom: secretRef: commerce-secrets`. El chequeo de disponibilidad
deja de depender por completo del formato de `DATABASE_URL`.
Commit `e9c93bf` en `main` de `apps-registry`, sincronizado por Argo CD
(`ecommerce-app`) a las `2026-08-10T05:29:32Z`. El pod atascado fue
reemplazado automáticamente por uno sano, sin intervención manual
adicional.
## Lecciones aprendidas
**Separar el chequeo de disponibilidad de las migraciones en init
containers distintos fue lo correcto** — permitió aislar el fallo al
primer init container sin ambigüedad. La lección no es cambiar esa
separación, sino mantenerla: cualquier chequeo de disponibilidad debe
usar el mínimo de parámetros necesarios en vez de reusar una URI de
conexión pensada para la app.
**No fue necesario crear pods de diagnóstico efímeros.** Toda la
reproducción se hizo con `kubectl exec` sobre pods que ya existían.
Crear pods de prueba repetidos consume cuota y puede introducir su
propio ruido de scheduling/red — la regla práctica es usar pods
existentes cuando el fallo es reproducible ahí, y reservar pods
efímeros para cuando la variable a probar (nodo, imagen, versión) no
se puede cambiar en un pod ya desplegado.
## Deuda técnica pendiente
El blackhole de red cross-node por MTU/PMTUD sigue sin arreglo de
fondo (ajustar MTU a `1400` en la red Docker/flannel del cluster k3d).
El workaround actual es el `podAffinity` en `medusa.yaml` que fija
`medusa-deploy` al mismo nodo que `commerce-postgres-0`.
Existe una branch abierta, `fix/medusa-wait-for-postgres`, que elimina
ese `podAffinity` y borra `docs/known-issues.md` **sin incluir el fix
de MTU real**. Mergearla tal como está reintroduciría el blackhole
cross-node sin red de contención. Queda pendiente decidir si se cierra
sin mergear, o si se retoma agregando primero el fix real de MTU.
@@ -0,0 +1,115 @@
# Incidente: imágenes de producto en blanco (proxy host de `media.cruzcloud.net` mal configurado en NPM) (2026-08)
!!! info "Origen"
Migrado desde `apps-registry/docs/playbooks/incidente-medusa-imagenes-proxy-npm-2026-08.md`.
Contenido técnico preservado sin cambios de fondo — solo formato adaptado a MkDocs Material.
| | |
|---|---|
| **Ventana del incidente** | 2026-08-13, resuelto el mismo día |
| **Servicio afectado** | `media.cruzcloud.net` (Nginx Proxy Manager → MinIO, `minio-svc` en namespace `ecommerce`) |
| **Impacto** | Las imágenes subidas a productos desde el Admin de Medusa no se visualizaban en el detalle de producto del storefront (`shop.cruzcloud.net`) — el resto del producto (título, precio, subtítulo, botón añadir al carrito) se renderizaba bien |
## Síntoma
Al agregar imágenes a un producto desde el Admin de Medusa (sección
Media), estas se veían correctamente cargadas en el Admin, pero en la
página de detalle de producto del frontend los thumbnails aparecían
vacíos/en blanco.
## Diagnóstico
1. **Storage:** confirmado que Medusa usa el provider
`@medusajs/medusa/file-s3` apuntando a MinIO interno
(`S3_ENDPOINT=http://minio-svc:9000`, bucket `ari-products`), con
URLs públicas construidas sobre
`S3_FILE_URL=https://media.cruzcloud.net/ari-products` (env en
configmap `commerce-config`, namespace `ecommerce`).
2. **API:** el producto traía URLs absolutas válidas, ej.
`https://media.cruzcloud.net/ari-products/<archivo>.jpg`.
3. **Prueba directa de la URL:**
- Contra el ingress interno del cluster (bypaseando el proxy
externo, `Host: media.cruzcloud.net` directo a la IP del
`k3d-proxy`) → `200 OK`, MinIO sirve el JPEG correctamente.
- Contra `https://media.cruzcloud.net/...` (la ruta real que usa el
navegador del cliente, vía Cloudflare) → `500 Internal Server
Error`, `Server: openresty`.
!!! tip "Patrón de diagnóstico clave"
Ese contraste (funciona interno, falla público, con
`Server: openresty` — el motor de Nginx Proxy Manager) aisló el
problema al **proxy externo**, no a Medusa/MinIO/frontend.
## Causa raíz
En Nginx Proxy Manager (`/DATA/AppData/nginxproxymanager/`, contenedor
`nginxproxymanager`), el proxy host de `media.cruzcloud.net`
(`id=13` en la tabla `proxy_host` de `database.sqlite`) estaba mal
configurado:
```text
forward_host = "http://192.168.68.61/" -- esquema y barra final metidos dentro del campo (formato inválido)
forward_port = 5005 -- puerto donde no escucha ningún proceso en el host
```
Los proxy hosts que sí funcionan (`shop.cruzcloud.net` id 23,
`commerce.cruzcloud.net` id 28) apuntan correctamente a:
```text
forward_host = "192.168.68.61"
forward_port = 90 -- puerto publicado por k3d-lab-cluster-serverlb hacia Traefik
```
Se confirmó con `ss`/`docker ps` en el host que nada escuchaba en el
puerto `5005` — este proxy host nunca estuvo apuntando correctamente
al cluster, muy probablemente un typo desde que se creó.
## Fix aplicado
1. Backups previos: `database.sqlite.bak-pre-media-fix` y
`13.conf.bak-pre-media-fix`.
2. Corrección directa en la base SQLite de NPM:
```sql
UPDATE proxy_host SET forward_host='192.168.68.61', forward_port=90 WHERE id=13;
```
3. NPM regeneró automáticamente `nginx/proxy_host/13.conf` con los
valores correctos (la app Node de NPM detecta el cambio en la base
y reescribe el `.conf`; no hace falta editarlo a mano).
4. Recarga de nginx dentro del contenedor:
```bash
docker exec nginxproxymanager nginx -t
docker exec nginxproxymanager nginx -s reload
```
Adicionalmente, se seteó el campo `thumbnail` del producto de prueba
desde el Admin (estaba en `null`) — Medusa no lo autocompleta a partir
del array `images`, y el frontend lo usa como imagen hero en listados/
tarjetas de catálogo.
## Validación
Confirmado visualmente en
`shop.cruzcloud.net/product/cybertron-sentinel-figura-de-coleccion-test-revalidate`
que tanto la galería del detalle como el thumbnail en listados/tarjetas
se renderizan correctamente. También se validó que ediciones de
descripción/título/precio desde el Admin se reflejan automáticamente
en el storefront sin necesidad de restart ni intervención manual, vía
el subscriber `revalidate-storefront.ts`
(`workloads/commerce-backend/src/subscribers/`) que procesa el evento
`product.updated` de Medusa.
## Lecciones aprendidas
**Aislar interno vs. público antes de sospechar de la app.** Medusa,
MinIO y el frontend estaban sanos todo el tiempo — la falla estaba en
una capa de infraestructura fuera de los repos de GitOps (Nginx Proxy
Manager, gestionado por fuera de `apps-registry`/`platform-infra`, sin
versionar). Comparar la misma petición por la ruta interna del cluster
contra la ruta pública real fue lo que aisló la causa en minutos, sin
necesidad de tocar Medusa ni el frontend.
Ver [Red y exposición](../arquitectura/red-y-exposicion.md) para la
topología completa Cloudflare Tunnel → Nginx Proxy Manager → k3d y el
patrón a seguir si otro subdominio (`*.cruzcloud.net`) presenta el
mismo síntoma (`500`/`Server: openresty` público pero sano
internamente).
@@ -0,0 +1,103 @@
# Incidente: precios `null` en detalle de producto (Medusa Store API sin `region_id`) (2026-08)
!!! info "Origen"
Migrado desde `apps-registry/docs/playbooks/incidente-medusa-precios-region-id-2026-08.md`.
Contenido técnico preservado sin cambios de fondo — solo formato adaptado a MkDocs Material.
| | |
|---|---|
| **Ventana del incidente** | 2026-08-12, resuelto el mismo día (commit `60072cf`) |
| **Servicio afectado** | `medusa-svc` (Store API), consumido por el frontend Next.js de ARI Shopping |
| **Impacto** | En el listado de productos (`/catalog`) los precios se mostraban correctamente, pero en la página de detalle de producto individual varios artículos mostraban "Consultar precio" en vez del monto real |
## Hipótesis inicial descartada: regiones COP duplicadas
La primera hipótesis fue que existían regiones "Colombia (COP)"
duplicadas en Medusa, causando ambigüedad al resolver el precio. Se
descartó con evidencia directa en Postgres, consultando la tabla
`region` **incluyendo soft-deletes**:
```sql
SELECT id, name, currency_code, created_at, updated_at, deleted_at FROM region ORDER BY created_at;
id | name | currency_code | created_at | updated_at | deleted_at
----------------------------------+----------+---------------+----------------------------+----------------------------+------------
reg_01KZT86WPAH7A7ZZRDSX3350V2 | Colombia | cop | 2026-08-12 05:48:03.151+00 | 2026-08-12 05:48:03.151+00 |
(1 row)
```
Una sola fila, creada una sola vez, nunca actualizada, nunca borrada.
**No había ni hubo nunca una región duplicada** — la hipótesis de
deduplicación quedó descartada con esta consulta.
## Causa raíz real
El backend de Medusa **no tenía ninguna región configurada**. El
frontend pedía precios al Store API (`/store/products`) usando el
parámetro `currency_code=cop`, pero el validador
`StoreGetProductsParams` de Medusa v2 (`.strict()`) **no acepta ese
campo** — devolvía `400 Unrecognized fields: 'currency_code'` en cada
carga de `/catalog`.
Se probó también `country_code`, que tampoco funcionó — Medusa
respondía:
```text
Missing required pricing context to calculate prices - region_id
```
!!! danger "Nota de arquitectura"
**Medusa v2 requiere explícitamente un `region_id` válido** para
poder calcular precios (`calculated_price`) vía Store API; ni
`currency_code` ni `country_code` son parámetros válidos para ese
fin.
## Fix aplicado
Commit `60072cf` — *"fix: usar region_id en vez de currency_code para
precios de Medusa"* (PR #5, mergeado en `04dc6bd`):
1. Se creó la región "Colombia" (moneda COP) vía Admin API — no
existía ninguna región configurada previamente.
2. Se modificó el frontend para pedir precios usando `region_id`
explícito en vez de `currency_code`, vía una nueva variable de
entorno `MEDUSA_REGION_ID`:
```diff
# workloads/ecommerce/frontend.yaml
- name: MEDUSA_BACKEND_URL
value: http://medusa-svc:9000
+ - name: MEDUSA_REGION_ID
+ value: reg_01KZT86WPAH7A7ZZRDSX3350V2
```
```diff
# workloads/ecommerce/lib/headless/providers/medusa.ts
const params = new URLSearchParams({
limit: String(limit),
- currency_code: "cop",
+ // El validador de /store/products no acepta currency_code (400
+ // "Unrecognized fields"); el precio calculado solo se resuelve con
+ // region_id.
+ region_id: regionId(),
```
No hubo migración de precios entre regiones — no existían datos
previos que migrar; la región se creó de cero como parte de este mismo
fix. `MEDUSA_REGION_ID` no ha cambiado desde el commit original
(verificado con `git log -p --follow` sobre `frontend.yaml`), y sigue
apuntando a la única región que existe en la base.
## Lecciones aprendidas
**Verificar la causa raíz contra el estado real de la base de datos
antes de asumir la primera hipótesis**, aun cuando esa hipótesis
coincida con la intuición inicial de quien reporta el bug ("puede que
haya algo duplicado"). La consulta a `region` incluyendo `deleted_at`
tomó segundos y evitó documentar una causa raíz incorrecta en este
mismo playbook.
**Nota de arquitectura:** Medusa v2 Store API (`StoreGetProductsParams`)
requiere `region_id` explícito para resolver precios calculados.
`currency_code` y `country_code` no son parámetros válidos para ese
fin.