Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11737ba109 | ||
|
|
b42bcad45e | ||
|
|
130177a9f2 | ||
|
|
7644b53c49 | ||
|
|
8c52229e71 | ||
|
|
2d09867f70 | ||
|
|
6a0e9965a9 | ||
|
|
095912d02d | ||
|
|
a1d2f9db67 | ||
|
|
d1a4a84d24 | ||
|
|
a9c57d4985 | ||
|
|
584a42ea72 | ||
|
|
a6a92f75ea | ||
|
|
0eea6234d6 | ||
|
|
77bf6089fb | ||
|
|
0134a4dd0b | ||
|
|
3fe63591ec | ||
|
|
c6cf394c26 | ||
|
|
7a09202c27 | ||
|
|
a902dc0a26 | ||
|
|
04dc6bd8bc | ||
|
|
60072cfcbe | ||
|
|
c9b376c4e6 | ||
|
|
cea41ce428 | ||
|
|
381e6ab6ab | ||
|
|
e9c93bfb82 | ||
|
|
f25b10a2f9 | ||
|
|
7a83ed61b9 | ||
|
|
987ef04b0d |
@@ -0,0 +1,97 @@
|
||||
# Known issues
|
||||
|
||||
## Blackhole de red cross-node en el cluster k3d (MTU/PMTUD, flannel VXLAN)
|
||||
|
||||
**Estado:** workaround aplicado, fix definitivo pendiente.
|
||||
|
||||
**Síntoma:** cualquier pod que necesite hablar con `commerce-postgres-0`
|
||||
(StatefulSet, sin réplicas, fijo a un nodo) falla de forma consistente y
|
||||
reproducible al 100% cuando queda agendado en un nodo **distinto** al de
|
||||
Postgres. El handshake TCP (SYN/ACK, `nc`, `ping`) funciona perfecto entre
|
||||
nodos, pero el primer paquete de datos real de la sesión (protocolo Postgres,
|
||||
`pg_isready` incluido) nunca llega — se cuelga hasta hacer timeout.
|
||||
|
||||
**Diagnóstico:**
|
||||
|
||||
- Confirmado aislando el nodo con `nodeName` en pods de prueba: 100% de
|
||||
fallos (186/186 intentos) agendando cross-node (`agent-0` → Postgres en
|
||||
`server-0`); 100% de éxito agendando en el mismo nodo.
|
||||
- MTU dentro de los pods (interfaz `flannel.1`/VXLAN) es 1450 en ambos nodos
|
||||
— consistente, no es un mismatch obvio a nivel flannel.
|
||||
- `ping` sin `-M do` (permite fragmentación) no muestra pérdida de paquetes
|
||||
hasta payloads de 2000 bytes entre los contenedores Docker de los nodos.
|
||||
- Encaja con un blackhole de PMTU Discovery: el bridge Docker externo que
|
||||
conecta los contenedores de los nodos k3d probablemente tiene un MTU real
|
||||
menor a 1500, y los paquetes TCP con el bit DF puesto (como los de la
|
||||
sesión de Postgres) se pierden en vez de fragmentarse o generar el ICMP
|
||||
"fragmentation needed" que permitiría el ajuste automático.
|
||||
|
||||
**Workaround temporal (aplicado en `workloads/ecommerce/commerce/medusa.yaml`):**
|
||||
|
||||
`podAffinity` con `requiredDuringSchedulingIgnoredDuringExecution` sobre
|
||||
`medusa-deploy`, forzando que sus pods se agenden siempre en el mismo nodo
|
||||
que `commerce-postgres-0` (`matchLabels: app: commerce-postgres`,
|
||||
`topologyKey: kubernetes.io/hostname`). Esto evita el tráfico cross-node
|
||||
entre Medusa y Postgres, pero no resuelve el problema de fondo — cualquier
|
||||
otro workload que necesite cruzar nodos para hablar con Postgres (u otro
|
||||
servicio) puede pisar el mismo blackhole.
|
||||
|
||||
**TODO — fix definitivo pendiente:**
|
||||
|
||||
Ajustar el MTU del cluster k3d a un valor seguro (`1400`) de forma
|
||||
consistente en la red Docker del cluster y en flannel, para eliminar el
|
||||
blackhole de raíz y poder quitar el `podAffinity` (o dejarlo como
|
||||
optimización, no como requisito de disponibilidad).
|
||||
|
||||
## Topología de red: Cloudflare Tunnel → Nginx Proxy Manager → k3d
|
||||
|
||||
**Estado:** referencia de arquitectura — no es un issue abierto, pero el
|
||||
patrón de falla que describe ya se repitió una vez (ver
|
||||
`docs/playbooks/incidente-medusa-imagenes-proxy-npm-2026-08.md`) y puede
|
||||
volver a aparecer con otro subdominio.
|
||||
|
||||
**Ruta real de una petición pública a `*.cruzcloud.net`:**
|
||||
|
||||
```
|
||||
Internet → Cloudflare (DNS proxied, túnel) → contenedor `cloudflared`
|
||||
(app nativa de ZimaOS, red Host) → Nginx Proxy Manager
|
||||
(contenedor `nginxproxymanager`, config en
|
||||
/DATA/AppData/nginxproxymanager/data/) → 192.168.68.61:90
|
||||
→ `k3d-lab-cluster-serverlb` (k3d-proxy, publica 90→80 y 9443→443)
|
||||
→ Traefik (ingress del cluster) → Service correspondiente,
|
||||
enrutado por el header `Host`.
|
||||
```
|
||||
|
||||
Los proxy hosts de NPM viven en
|
||||
`/DATA/AppData/nginxproxymanager/data/database.sqlite` (tabla
|
||||
`proxy_host`) y NPM regenera solo el `.conf` correspondiente en
|
||||
`data/nginx/proxy_host/<id>.conf` cuando cambia la fila en la base —
|
||||
no hace falta editar el `.conf` a mano, y si se edita a mano puede
|
||||
sobreescribirse en cualquier cambio posterior desde la UI/DB.
|
||||
|
||||
**Patrón de diagnóstico:** si un subdominio `*.cruzcloud.net` responde
|
||||
`500 Internal Server Error` con header `Server: openresty` cuando se
|
||||
accede públicamente, pero el mismo path funciona bien contra el
|
||||
ingress interno del cluster (`curl -H "Host: <subdominio>"
|
||||
http://<IP-del-nodo>/...`), el problema **no está en el cluster ni en
|
||||
la app** — está en el proxy host de NPM para ese subdominio. Revisar
|
||||
`forward_host`/`forward_port` en la tabla `proxy_host` y compararlos
|
||||
contra un host que sí funcione (ej. `shop.cruzcloud.net`,
|
||||
`commerce.cruzcloud.net`, ambos con `forward_host='192.168.68.61'`,
|
||||
`forward_port=90`).
|
||||
|
||||
## Nota de arquitectura: Medusa v2 Store API requiere `region_id` explícito
|
||||
|
||||
**Estado:** no es un issue, es un requisito de la API que causó un
|
||||
incidente real por no ser conocido — ver
|
||||
`docs/playbooks/incidente-medusa-precios-region-id-2026-08.md`.
|
||||
|
||||
El endpoint `/store/products` de Medusa v2 (validado por
|
||||
`StoreGetProductsParams`, `.strict()`) **no acepta `currency_code` ni
|
||||
`country_code`** como parámetros para resolver el contexto de precio
|
||||
(`calculated_price`). El único parámetro válido para ese fin es
|
||||
`region_id`, apuntando a una región existente y configurada vía Admin
|
||||
API. Cualquier integración nueva contra el Store API de Medusa v2 que
|
||||
necesite precios calculados debe pasar `region_id` explícito — no
|
||||
asumir que `currency_code` o `country_code` son suficientes, aunque lo
|
||||
sean en Medusa v1 o en otras APIs de e-commerce.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Incidente: 504 Gateway Timeout intermitente en gitea.cruzcloud.net (túnel QUIC inestable) (2026-08)
|
||||
|
||||
**Ventana del incidente:** intermitente durante varias horas, 2026-08
|
||||
**Servicio afectado:** `cloudflared` (túnel de Cloudflare, app nativa de ZimaOS, modo red Host) — afecta a todos los subdominios servidos por el 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 (Immich, Argo CD, Memos), lo que apuntaba a una causa compartida a nivel de túnel, no de una app individual.
|
||||
|
||||
## Diagnóstico
|
||||
|
||||
```
|
||||
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:
|
||||
|
||||
```
|
||||
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.
|
||||
|
||||
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` desde la app de ZimaOS (sección Ambiente → variables), forzando HTTP/2 sobre TCP en vez de QUIC/UDP.
|
||||
|
||||
Tras reiniciar el contenedor, el precheck de `cloudflared` confirmó:
|
||||
|
||||
```
|
||||
Environment is healthy. cloudflared will use 'http2' as primary protocol.
|
||||
```
|
||||
|
||||
y las 4 conexiones pasaron a `protocol=http2`.
|
||||
|
||||
## Validación
|
||||
|
||||
Curl en loop contra `gitea.cruzcloud.net` tras el cambio: latencia estable ~0.37–0.4s sostenida por 24+ minutos sin un solo 504.
|
||||
|
||||
## Nota — 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 (CPU/memoria) para evitar que compita con el resto de las apps del NAS.
|
||||
@@ -0,0 +1,212 @@
|
||||
# Incidente: medusa-deploy atascado sin levantar (2026-08)
|
||||
|
||||
**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
|
||||
(`medusa-deploy-59878b956f-hf45b`) siguió sirviendo tráfico sin interrupción
|
||||
durante todo el incidente (`maxUnavailable: 0`), por lo que 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:
|
||||
|
||||
```
|
||||
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. Esta distinción importa:
|
||||
un pod en `Init:0/2` con 0 restarts no aparece en las alertas típicas de
|
||||
CrashLoopBackOff, lo que probablemente explica por qué pasó desapercibido
|
||||
tanto tiempo.
|
||||
|
||||
**Lección:** verificar siempre el estado real con `kubectl get pods` antes de
|
||||
asumir el tipo de falla que describe quien reporta el incidente. Un
|
||||
`Init:X/Y` con 0 restarts y un `CrashLoopBackOff` requieren líneas de
|
||||
investigación distintas.
|
||||
|
||||
## 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.
|
||||
- Se verificó longitud del valor (`~126` caracteres, no vacío ni truncado)
|
||||
sin volcar el contenido en texto plano.
|
||||
- Se probó el `DATABASE_URL` real contra Postgres con `pg_isready` ejecutado
|
||||
**dentro del propio pod `commerce-postgres-0`**: `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>` (aplica a todos los pods), `Allowing ingress
|
||||
traffic: To Port: <any>, From: NamespaceSelector: <none>` (permite todo el
|
||||
ingress desde cualquier namespace), `Not affecting egress traffic`.
|
||||
- **Descartada:** la policy es efectivamente allow-all para ingress y no
|
||||
restringe egress. No podía estar bloqueando la conexión de `medusa` hacia
|
||||
`postgres-svc`.
|
||||
|
||||
### 3. Salud de Postgres
|
||||
|
||||
- `kubectl exec -n ecommerce commerce-postgres-0 -- psql -U medusa -c
|
||||
"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`, la IP real del pod.
|
||||
- **Descartada:** Postgres estaba sano, aceptando conexiones y con el
|
||||
Endpoint del Service correctamente resuelto.
|
||||
|
||||
### 4. ResourceQuota / LimitRange del namespace
|
||||
|
||||
- `kubectl describe resourcequota -n ecommerce` → `pods: 7/12`,
|
||||
`requests.cpu: 625m/3`, `requests.memory: 1504Mi/4Gi`, `limits.cpu:
|
||||
4250m/8`, `limits.memory: 4480Mi/8Gi` — todo muy por debajo de los topes.
|
||||
- `kubectl describe limitrange -n ecommerce` → rangos por-contenedor
|
||||
(16Mi–1Gi mem, 10m–2 cpu) compatibles con los `resources` definidos en
|
||||
`medusa.yaml`.
|
||||
- Sin eventos de `exceeded quota` en el namespace.
|
||||
- **Descartada:** no había presión de cuota ni un contenedor rechazado por
|
||||
LimitRange.
|
||||
|
||||
### 5. DNS
|
||||
|
||||
- `kubectl exec` en el pod viejo (sano) → `getent hosts postgres-svc`
|
||||
resolvía a `10.42.0.98` correctamente.
|
||||
- CoreDNS (`kube-system`) → `Running`, sano.
|
||||
- Repetido el mismo chequeo **dentro del propio init container atascado**
|
||||
(`wait-for-postgres` del pod nuevo) → misma resolución correcta, y
|
||||
`/etc/resolv.conf` con `nameserver 10.43.0.10` normal.
|
||||
- **Descartada:** la resolución DNS funcionaba de forma idéntica en el pod
|
||||
sano y en el pod atascado.
|
||||
|
||||
### 6. MTU/PMTUD cross-node (blackhole conocido, ver `docs/known-issues.md`)
|
||||
|
||||
- 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 hoy con `podAffinity` en
|
||||
`medusa.yaml`.
|
||||
- `kubectl get pods -o wide` mostró que **los tres pods relevantes
|
||||
(`commerce-postgres-0`, el pod viejo y el pod nuevo) estaban en el mismo
|
||||
nodo** (`k3d-lab-cluster-server-0`).
|
||||
- **Descartada para este incidente puntual:** al no haber tráfico
|
||||
cross-node involucrado, el blackhole de MTU no podía ser la causa. Sigue
|
||||
siendo una condición latente real del cluster (ver sección de deuda
|
||||
técnica).
|
||||
|
||||
## 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`, branch
|
||||
`fix/medusa-database-url-sslmode`, ya ejecutada contra el secret vivo del
|
||||
cluster). 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:
|
||||
|
||||
```
|
||||
postgres-svc:5432 - no attempt
|
||||
```
|
||||
|
||||
`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). Esto se reprodujo de forma determinística
|
||||
ejecutando el mismo comando **dentro del propio init container atascado**
|
||||
(sin crear pods efímeros nuevos), y se aisló la causa probando la misma URI:
|
||||
|
||||
- con `pg_isready -h postgres-svc -p 5432 -U medusa -d medusa` (sin URI) →
|
||||
`accepting connections`, exit 0.
|
||||
- con la URI completa pero sin el `?sslmode=disable` → también exitosa.
|
||||
- con la URI completa incluyendo `?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 el mismo `envFrom:
|
||||
secretRef: commerce-secrets`. El chequeo de disponibilidad deja de depender
|
||||
por completo del formato de `DATABASE_URL`, incluyendo cualquier query
|
||||
param futuro.
|
||||
|
||||
Commit `e9c93bf` en `main` de `apps-registry`, pusheado y sincronizado por
|
||||
Argo CD (`ecommerce-app`, revisión `e9c93bfb82ec09b8ea2cc3468040ef9d72416542`,
|
||||
sync a las `2026-08-10T05:29:32Z`). El pod atascado
|
||||
`medusa-deploy-9d8784d7b-g9jql` fue reemplazado automáticamente por
|
||||
`medusa-deploy-56c7c878b6-xgnkb`, que completó `wait-for-postgres` en
|
||||
segundos y quedó `1/1 Running` sin intervención manual adicional.
|
||||
|
||||
## Lecciones aprendidas
|
||||
|
||||
**Separar el chequeo de disponibilidad (`wait-for-postgres`) de las
|
||||
migraciones (`migrations`) en init containers distintos fue lo correcto** y
|
||||
ya estaba así desde que se introdujo este init container
|
||||
(commit `f4ad140`). Eso permitió aislar el fallo al primer init container
|
||||
sin ambigüedad — la falla nunca llegó a tocar `migrations`. La lección no es
|
||||
cambiar esa separación, sino mantenerla: cualquier chequeo de
|
||||
disponibilidad debe usar el mínimo de parámetros necesarios (host/puerto/
|
||||
usuario/db) en vez de reusar una URI de conexión pensada para la app,
|
||||
que puede cambiar de formato por razones ajenas al chequeo.
|
||||
|
||||
**No fue necesario crear pods de diagnóstico efímeros, y no crearlos evitó
|
||||
ruido adicional en el cluster.** Toda la reproducción y aislamiento de la
|
||||
causa raíz (DNS, `pg_isready` con distintos argumentos, lectura de
|
||||
`/etc/resolv.conf`) se hizo con `kubectl exec` sobre pods que ya existían
|
||||
(el pod viejo sano, el pod nuevo atascado, y `commerce-postgres-0`). Crear
|
||||
pods de prueba repetidos consume cuota (`ecommerce-quota` está en `pods:
|
||||
7/12`, con margen pero no ilimitado) y puede introducir su propio ruido de
|
||||
scheduling/red — como muestra `docs/known-issues.md`, el diagnóstico previo
|
||||
del blackhole de MTU sí necesitó pods de prueba dedicados (186 intentos)
|
||||
porque el fenómeno dependía del nodo de scheduling, algo que no se puede
|
||||
observar desde un pod ya corriendo. La regla práctica: usar pods
|
||||
existentes cuando el fallo es reproducible ahí, y reservar pods efímeros
|
||||
para cuando la variable a probar (ej. nodo, imagen, versión) no se puede
|
||||
cambiar en un pod ya desplegado — midiendo el impacto (cuota, ruido) de
|
||||
cada corrida.
|
||||
|
||||
## Deuda técnica pendiente (no resuelta por este incidente)
|
||||
|
||||
El blackhole de red cross-node por MTU/PMTUD documentado en
|
||||
`docs/known-issues.md` 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`.
|
||||
- Borra `docs/known-issues.md` por completo.
|
||||
- **No incluye el fix de MTU real** (no toca configuración de flannel ni de
|
||||
la red Docker del cluster).
|
||||
|
||||
Mergear esa branch tal como está day-1 reintroduciría el blackhole cross-node
|
||||
sin red de contención y borraría la única documentación del problema. No se
|
||||
tocó como parte de este incidente. Queda pendiente decidir si se cierra sin
|
||||
mergear, o si se retoma agregando primero el fix real de MTU antes de poder
|
||||
quitar el `podAffinity` con seguridad.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Incidente: imágenes de producto en blanco en el storefront (proxy host de media.cruzcloud.net mal configurado en NPM) (2026-08)
|
||||
|
||||
**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`.
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
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:
|
||||
|
||||
```
|
||||
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:
|
||||
```
|
||||
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 `docs/known-issues.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,66 @@
|
||||
# Incidente: precios `null` en detalle de producto (Medusa Store API sin region_id) (2026-08)
|
||||
|
||||
**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 (`deleted_at` vacío). **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:
|
||||
|
||||
```
|
||||
Missing required pricing context to calculate prices - region_id
|
||||
```
|
||||
|
||||
**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 — ver también `docs/known-issues.md`.
|
||||
@@ -25,6 +25,16 @@ module.exports = defineConfig({
|
||||
jwtSecret: required("JWT_SECRET"),
|
||||
cookieSecret: required("COOKIE_SECRET"),
|
||||
},
|
||||
// Todos los Ingress del lab terminan en Traefik por HTTP plano
|
||||
// (entrypoint "web", sin TLS); Cloudflare es quien atiende HTTPS de
|
||||
// cara al navegador. Con `secure: true` (el default en producción),
|
||||
// express-session nunca emite Set-Cookie porque no detecta la
|
||||
// conexión como HTTPS, y el dashboard admin queda con 401 en
|
||||
// /admin/users/me pese a loguear bien. La API con Bearer token no se
|
||||
// ve afectada por esto.
|
||||
cookieOptions: {
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
|
||||
admin: {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework";
|
||||
import { ContainerRegistrationKeys } from "@medusajs/framework/utils";
|
||||
|
||||
export default async function revalidateStorefrontHandler({
|
||||
container,
|
||||
}: SubscriberArgs) {
|
||||
const logger = container.resolve(ContainerRegistrationKeys.LOGGER);
|
||||
|
||||
const storefrontUrl = process.env.STOREFRONT_URL;
|
||||
const secret = process.env.REVALIDATE_SECRET;
|
||||
|
||||
if (!storefrontUrl || !secret) {
|
||||
logger.warn(
|
||||
"STOREFRONT_URL o REVALIDATE_SECRET no configurados; se omite la revalidación del storefront.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${storefrontUrl}/api/revalidate`, {
|
||||
method: "POST",
|
||||
headers: { "x-revalidate-secret": secret },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
`Revalidación del storefront respondió HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`No se pudo revalidar el storefront: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const config: SubscriberConfig = {
|
||||
event: [
|
||||
"product.created",
|
||||
"product.updated",
|
||||
"product.deleted",
|
||||
"product-variant.updated",
|
||||
"product-variant.created",
|
||||
"product-variant.deleted",
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const secret = request.headers.get("x-revalidate-secret");
|
||||
|
||||
if (!secret || secret !== process.env.REVALIDATE_SECRET) {
|
||||
return Response.json({ message: "Invalid secret" }, { status: 401 });
|
||||
}
|
||||
|
||||
revalidateTag("medusa-products");
|
||||
|
||||
return Response.json({ revalidated: true, tag: "medusa-products" }, { status: 200 });
|
||||
}
|
||||
@@ -9,10 +9,17 @@ import { getProductBySlug, getProducts } from "@/lib/headless/client";
|
||||
|
||||
interface ProductPageProps { params: Promise<{ slug: string }> }
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const products = await getProducts();
|
||||
return products.map((product) => ({ slug: product.slug }));
|
||||
}
|
||||
// generateStaticParams + revalidate quedaban aquí para pre-renderizar en
|
||||
// build time, pero HEADLESS_PROVIDER/MEDUSA_* no existen en el contexto del
|
||||
// Docker build: el snapshot estático quedaba congelado con el catálogo mock
|
||||
// (todos los productos con price: null) hasta que ISR lo revalidara. Con
|
||||
// frontend-deploy corriendo 2 réplicas y sin cacheHandler compartido, cada
|
||||
// pod revalida su propia caché de forma independiente, así que un producto
|
||||
// podía mostrar "Consultar precio" en un pod y el precio real en el otro
|
||||
// según cuál hubiera sido "calentado". force-dynamic elimina esa caché por
|
||||
// pod y deja esta ruta en vivo contra Medusa, igual que el listado
|
||||
// (dinámico por searchParams).
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
|
||||
@@ -20,6 +20,14 @@ spec:
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry-secret
|
||||
|
||||
affinity:
|
||||
podAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchLabels:
|
||||
app: commerce-postgres
|
||||
topologyKey: kubernetes.io/hostname
|
||||
|
||||
initContainers:
|
||||
- name: wait-for-postgres
|
||||
image: postgres:17-alpine
|
||||
@@ -28,7 +36,7 @@ spec:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
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
|
||||
echo "postgres no disponible aun, reintentando..."
|
||||
sleep 2
|
||||
done
|
||||
@@ -44,7 +52,7 @@ spec:
|
||||
memory: 64Mi
|
||||
|
||||
- name: migrations
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.85
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.92
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- npx
|
||||
@@ -65,7 +73,7 @@ spec:
|
||||
|
||||
containers:
|
||||
- name: medusa
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.85
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.92
|
||||
imagePullPolicy: IfNotPresent
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
|
||||
@@ -22,6 +22,11 @@ stringData:
|
||||
|
||||
JWT_SECRET: CAMBIAR
|
||||
COOKIE_SECRET: CAMBIAR
|
||||
|
||||
# Debe ser el MISMO valor que REVALIDATE_SECRET en el Secret
|
||||
# commerce-storefront: el subscriber de Medusa lo envía como header
|
||||
# x-revalidate-secret al invocar POST /api/revalidate en el storefront.
|
||||
REVALIDATE_SECRET: CAMBIAR
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
@@ -30,3 +35,6 @@ metadata:
|
||||
type: Opaque
|
||||
stringData:
|
||||
MEDUSA_PUBLISHABLE_KEY: pk_CAMBIAR_DESPUES_DE_CREARLA_EN_MEDUSA
|
||||
|
||||
# Mismo valor que REVALIDATE_SECRET en commerce-secrets.
|
||||
REVALIDATE_SECRET: CAMBIAR
|
||||
|
||||
@@ -16,9 +16,19 @@ spec:
|
||||
- name: gitea-registry-secret
|
||||
containers:
|
||||
- name: web
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.77
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.91
|
||||
ports:
|
||||
- containerPort: 80
|
||||
env:
|
||||
- name: HEADLESS_PROVIDER
|
||||
value: medusa
|
||||
- name: MEDUSA_BACKEND_URL
|
||||
value: http://medusa-svc:9000
|
||||
- name: MEDUSA_REGION_ID
|
||||
value: reg_01KZT86WPAH7A7ZZRDSX3350V2
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: commerce-storefront
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
|
||||
@@ -258,6 +258,14 @@ function backendUrl(): string {
|
||||
return value.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function regionId(): string {
|
||||
const value = process.env.MEDUSA_REGION_ID;
|
||||
if (!value) {
|
||||
throw new Error("MEDUSA_REGION_ID no está configurado.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function medusaFetch<T>(
|
||||
path: string,
|
||||
searchParams?: URLSearchParams,
|
||||
@@ -515,7 +523,10 @@ async function fetchProducts(
|
||||
): Promise<Product[]> {
|
||||
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(),
|
||||
fields:
|
||||
"+metadata,+images,+tags,+categories,+collection,+variants.inventory_quantity,+variants.calculated_price,+variants.options",
|
||||
});
|
||||
@@ -543,7 +554,10 @@ export const medusaCatalogProvider: CatalogProvider = {
|
||||
const params = new URLSearchParams({
|
||||
handle: slug,
|
||||
limit: "1",
|
||||
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(),
|
||||
fields:
|
||||
"+metadata,+images,+tags,+categories,+collection,+variants.inventory_quantity,+variants.calculated_price,+variants.options",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user