Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb5cf8bc98 | ||
|
|
11737ba109 | ||
|
|
b42bcad45e | ||
|
|
130177a9f2 | ||
|
|
7644b53c49 | ||
|
|
8c52229e71 | ||
|
|
2d09867f70 | ||
|
|
6a0e9965a9 | ||
|
|
095912d02d | ||
|
|
a1d2f9db67 | ||
|
|
d1a4a84d24 | ||
|
|
a9c57d4985 | ||
|
|
584a42ea72 | ||
|
|
a6a92f75ea | ||
|
|
0eea6234d6 | ||
|
|
77bf6089fb | ||
|
|
0134a4dd0b | ||
|
|
3fe63591ec | ||
|
|
c6cf394c26 | ||
|
|
7a09202c27 | ||
|
|
a902dc0a26 | ||
|
|
04dc6bd8bc |
@@ -0,0 +1,125 @@
|
|||||||
|
name: Build and Push Docs Portal
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'workloads/docs-portal/docs/**'
|
||||||
|
- 'workloads/docs-portal/mkdocs.yml'
|
||||||
|
- 'workloads/docs-portal/requirements.txt'
|
||||||
|
- 'workloads/docs-portal/Dockerfile'
|
||||||
|
- '.gitea/workflows/deploy-docs.yaml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Construir y publicar Docs Portal
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
env:
|
||||||
|
APP_DIR: workloads/docs-portal
|
||||||
|
MANIFEST_FILE: workloads/docs-portal/deployment.yaml
|
||||||
|
IMAGE_NAME: gitea.cruzcloud.net/devops/docs-portal
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout del código
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
persist-credentials: true
|
||||||
|
|
||||||
|
- name: Definir versión
|
||||||
|
id: vars
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "VERSION=v1.0.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Validar secretos del Registry
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "${REGISTRY_USER}" || {
|
||||||
|
echo "ERROR: REGISTRY_USER no está configurado."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
test -n "${REGISTRY_PASSWORD}" || {
|
||||||
|
echo "ERROR: REGISTRY_PASSWORD no está configurado."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Login en Gitea Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: gitea.cruzcloud.net
|
||||||
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
logout: true
|
||||||
|
|
||||||
|
# mkdocs build --strict corre dentro del propio Dockerfile (stage de
|
||||||
|
# build), así que un nav/link roto rompe este paso antes de publicar.
|
||||||
|
- name: Construir y subir imagen
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: workloads/docs-portal/
|
||||||
|
file: workloads/docs-portal/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }}
|
||||||
|
${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
|
- name: Verificar promoción segura
|
||||||
|
id: promotion
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
git fetch origin main
|
||||||
|
|
||||||
|
CURRENT_SHA="${{ github.sha }}"
|
||||||
|
REMOTE_SHA="$(git rev-parse origin/main)"
|
||||||
|
|
||||||
|
if [ "${CURRENT_SHA}" = "${REMOTE_SHA}" ]; then
|
||||||
|
echo "promote=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "promote=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Hay un commit más reciente; no se actualizará el manifiesto."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Actualizar manifiesto GitOps
|
||||||
|
if: steps.promotion.outputs.promote == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${{ steps.vars.outputs.VERSION }}"
|
||||||
|
|
||||||
|
git config user.name "gitea-actions"
|
||||||
|
git config user.email "[email protected]"
|
||||||
|
|
||||||
|
git fetch origin main
|
||||||
|
git checkout -B main origin/main
|
||||||
|
|
||||||
|
sed -i -E \
|
||||||
|
"s|(image: ${IMAGE_NAME}:).*|\1${VERSION}|g" \
|
||||||
|
"${MANIFEST_FILE}"
|
||||||
|
|
||||||
|
git add "${MANIFEST_FILE}"
|
||||||
|
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "El manifiesto ya apunta a ${VERSION}."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git commit \
|
||||||
|
-m "chore(gitops): deploy Docs Portal ${VERSION} [skip ci]"
|
||||||
|
|
||||||
|
git push origin HEAD:main
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Application
|
||||||
|
metadata:
|
||||||
|
name: docs-portal-app
|
||||||
|
namespace: argocd
|
||||||
|
spec:
|
||||||
|
project: default
|
||||||
|
source:
|
||||||
|
repoURL: https://gitea.cruzcloud.net/devops/apps-registry.git
|
||||||
|
path: workloads/docs-portal
|
||||||
|
targetRevision: main
|
||||||
|
destination:
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
namespace: docs-portal
|
||||||
|
syncPolicy:
|
||||||
|
automated:
|
||||||
|
prune: true
|
||||||
|
selfHeal: true
|
||||||
|
syncOptions:
|
||||||
|
- CreateNamespace=true
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Application
|
||||||
|
metadata:
|
||||||
|
name: docs-portal-governance
|
||||||
|
namespace: argocd
|
||||||
|
spec:
|
||||||
|
project: default
|
||||||
|
source:
|
||||||
|
repoURL: https://gitea.cruzcloud.net/devops/platform-infra.git
|
||||||
|
path: docs-portal-governance
|
||||||
|
targetRevision: main
|
||||||
|
destination:
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
namespace: docs-portal
|
||||||
|
syncPolicy:
|
||||||
|
automated:
|
||||||
|
prune: true
|
||||||
|
selfHeal: true
|
||||||
|
syncOptions:
|
||||||
|
- CreateNamespace=true
|
||||||
@@ -42,3 +42,56 @@ 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
|
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
|
blackhole de raíz y poder quitar el `podAffinity` (o dejarlo como
|
||||||
optimización, no como requisito de disponibilidad).
|
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,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"),
|
jwtSecret: required("JWT_SECRET"),
|
||||||
cookieSecret: required("COOKIE_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: {
|
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,9 @@
|
|||||||
|
site/
|
||||||
|
.git/
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
deployment.yaml
|
||||||
|
service.yaml
|
||||||
|
ingress.yaml
|
||||||
|
kustomization.yaml
|
||||||
|
README.md
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# --- Stage 1: build del sitio estático con MkDocs Material ---
|
||||||
|
FROM python:3.12-slim AS build
|
||||||
|
|
||||||
|
WORKDIR /site
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY mkdocs.yml .
|
||||||
|
COPY docs/ docs/
|
||||||
|
|
||||||
|
# --strict: cualquier link roto o warning de nav rompe el build,
|
||||||
|
# para no publicar nunca un sitio con referencias muertas.
|
||||||
|
RUN mkdocs build --strict
|
||||||
|
|
||||||
|
# --- Stage 2: sirve el sitio estático generado con nginx ---
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY --from=build /site/site /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: docs-portal-deployment
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: docs-portal
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: docs-portal
|
||||||
|
spec:
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: gitea-registry-secret
|
||||||
|
containers:
|
||||||
|
- name: docs-portal
|
||||||
|
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.0
|
||||||
|
ports:
|
||||||
|
- containerPort: 80
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 200m
|
||||||
|
memory: 128Mi
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Notas sueltas / aprendizajes
|
||||||
|
|
||||||
|
> **TODO (fase 2+):** esto es un scratchpad estructurado, no un
|
||||||
|
> playbook — va creciendo con notas cortas a medida que aparecen. Las
|
||||||
|
> entradas de abajo son semillas reales extraídas de incidentes ya
|
||||||
|
> documentados; expandir cada una con más contexto en fases siguientes.
|
||||||
|
|
||||||
|
## Qué haría distinto
|
||||||
|
|
||||||
|
- **Fijar `TUNNEL_TRANSPORT_PROTOCOL=http2` desde el día uno**, no
|
||||||
|
después de un incidente — QUIC/UDP sobre NAT doméstico fue un problema
|
||||||
|
predecible en retrospectiva. Ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md).
|
||||||
|
- **Separar siempre "chequeo de disponibilidad" de "migraciones"** en
|
||||||
|
init containers distintos, y que el chequeo use el mínimo de
|
||||||
|
parámetros posible (host/puerto/usuario/db) en vez de reusar una URI
|
||||||
|
de conexión pensada para la app. Confirmado como decisión correcta en
|
||||||
|
el [incidente de crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md).
|
||||||
|
|
||||||
|
## Gotchas de Medusa v2
|
||||||
|
|
||||||
|
- El Store API (`/store/products`, validado por `StoreGetProductsParams`,
|
||||||
|
`.strict()`) **no acepta** `currency_code` ni `country_code` para
|
||||||
|
resolver precios calculados — solo `region_id` explícito. Ver
|
||||||
|
[playbook de precios](../playbooks/incidente-precios-medusa.md).
|
||||||
|
- El campo `thumbnail` de un producto **no se autocompleta** a partir
|
||||||
|
del array `images` — hay que setearlo explícito desde el Admin si el
|
||||||
|
frontend lo usa como imagen hero en listados.
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- Notas sobre MinIO / S3 provider.
|
||||||
|
- Notas sobre el subscriber `revalidate-storefront.ts` y sus límites.
|
||||||
|
- Qué automatizaría si volviera a montar este lab desde cero.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Del commit al despliegue: flujo GitOps
|
||||||
|
|
||||||
|
> **TODO (fase 2+):** narrar cada paso con ejemplos reales tomados de
|
||||||
|
> `.gitea/workflows/build-medusa.yaml` y `build.yaml`. El diagrama de
|
||||||
|
> secuencia de abajo refleja el pipeline real (build → push imagen →
|
||||||
|
> promoción segura → commit del tag → sync de Argo CD).
|
||||||
|
|
||||||
|
## Secuencia: un commit hasta quedar corriendo en el cluster
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor Dev as Devops
|
||||||
|
participant Gitea
|
||||||
|
participant CI as Gitea Actions
|
||||||
|
participant Reg as Gitea Registry
|
||||||
|
participant Argo as Argo CD
|
||||||
|
participant K8s as Cluster k3d
|
||||||
|
|
||||||
|
Dev->>Gitea: push a fix/<rama>
|
||||||
|
Dev->>Gitea: abre PR → main
|
||||||
|
Gitea->>Gitea: merge a main
|
||||||
|
Gitea->>CI: dispara workflow (paths: workloads/**)
|
||||||
|
CI->>CI: build de la imagen (Docker)
|
||||||
|
CI->>Reg: push imagen vX.Y.Z
|
||||||
|
CI->>CI: verifica promoción segura (HEAD == origin/main)
|
||||||
|
CI->>Gitea: commit "chore(gitops): release vX.Y.Z" actualizando el manifiesto
|
||||||
|
Argo->>Gitea: detecta el nuevo commit (poll/webhook)
|
||||||
|
Argo->>K8s: sync (prune:true, selfHeal:true)
|
||||||
|
K8s->>K8s: rollout del nuevo pod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Por qué esta separación (build vs. deploy)
|
||||||
|
|
||||||
|
- El **pipeline de CI** solo construye y publica la imagen — nunca
|
||||||
|
aplica nada directo al cluster.
|
||||||
|
- El **commit al manifiesto** (`sed` sobre el tag de imagen) es lo único
|
||||||
|
que representa "intención de desplegar" — y vive en el mismo repo Git
|
||||||
|
que Argo CD vigila.
|
||||||
|
- **Argo CD** es la única pieza con permisos de escritura sobre el
|
||||||
|
cluster real. Nada fuera de GitOps toca `kubectl apply` en producción.
|
||||||
|
- El paso de "promoción segura" (comparar `HEAD` contra `origin/main`)
|
||||||
|
evita que un pipeline viejo sobreescriba el tag con una versión
|
||||||
|
anterior si hubo un push más reciente mientras corría.
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- Ejemplo real con hashes de commit (tomar del historial de
|
||||||
|
`apps-registry`).
|
||||||
|
- Explicar `syncPolicy.automated.selfHeal` con un caso concreto (qué
|
||||||
|
pasa si alguien hace `kubectl edit` a mano).
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Glosario
|
||||||
|
|
||||||
|
> Pensado para alguien que ve estos términos por primera vez. Definiciones
|
||||||
|
> cortas primero, detalle técnico después. Se irá expandiendo en fases
|
||||||
|
> siguientes — por ahora cubre los términos que ya aparecen en el resto
|
||||||
|
> del sitio.
|
||||||
|
|
||||||
|
## GitOps
|
||||||
|
|
||||||
|
**En una frase:** el estado deseado del sistema vive en Git, y un
|
||||||
|
proceso automático (no una persona con `kubectl`) se encarga de que el
|
||||||
|
cluster real coincida con lo que dice Git.
|
||||||
|
|
||||||
|
**Detalle:** en vez de ejecutar comandos contra el cluster a mano, se
|
||||||
|
edita un archivo YAML, se hace commit, y una herramienta (aquí, Argo CD)
|
||||||
|
detecta el cambio y lo aplica. Si alguien cambia algo directo en el
|
||||||
|
cluster sin pasar por Git, GitOps lo revierte (`selfHeal`) — Git es la
|
||||||
|
única fuente de verdad.
|
||||||
|
|
||||||
|
## Tunnel (Cloudflare Tunnel)
|
||||||
|
|
||||||
|
**En una frase:** una forma de exponer un servicio interno a internet
|
||||||
|
sin abrir puertos en el router/firewall.
|
||||||
|
|
||||||
|
**Detalle:** un proceso (`cloudflared`) corre dentro de la red local y
|
||||||
|
abre una conexión saliente hacia Cloudflare. El tráfico público llega a
|
||||||
|
Cloudflare y viaja hacia adentro por esa misma conexión — no hace falta
|
||||||
|
port-forwarding ni IP pública propia.
|
||||||
|
|
||||||
|
## Argo CD
|
||||||
|
|
||||||
|
**En una frase:** el "robot" que sincroniza el cluster Kubernetes con
|
||||||
|
lo que está escrito en Git.
|
||||||
|
|
||||||
|
**Detalle:** vigila uno o más repos Git, compara el estado declarado
|
||||||
|
(manifiestos YAML/Kustomize) contra el estado real del cluster, y
|
||||||
|
aplica la diferencia. Ver [flujo GitOps](gitops-flujo.md).
|
||||||
|
|
||||||
|
## Manifest (manifiesto)
|
||||||
|
|
||||||
|
**En una frase:** un archivo YAML que describe qué quieres que exista
|
||||||
|
en Kubernetes (un Deployment, un Service, un Ingress...).
|
||||||
|
|
||||||
|
**Detalle:** Kubernetes no se controla escribiendo código imperativo
|
||||||
|
("crea un pod"), sino declarando el resultado deseado ("quiero 2
|
||||||
|
réplicas de esta imagen corriendo"). El manifiesto es ese documento.
|
||||||
|
|
||||||
|
## App of Apps
|
||||||
|
|
||||||
|
**En una frase:** un patrón donde una Application de Argo CD no
|
||||||
|
despliega una app directamente, sino que despliega *otras*
|
||||||
|
Applications.
|
||||||
|
|
||||||
|
**Detalle:** en este lab, `application.yaml` (raíz) vigila la carpeta
|
||||||
|
`apps/` de `apps-registry`; cada archivo ahí dentro es una Application
|
||||||
|
hija que a su vez apunta a un workload real. Permite agregar una app
|
||||||
|
nueva con un solo archivo YAML nuevo.
|
||||||
|
|
||||||
|
## Kustomize
|
||||||
|
|
||||||
|
**En una frase:** una forma de componer manifiestos de Kubernetes sin
|
||||||
|
plantillas (a diferencia de Helm).
|
||||||
|
|
||||||
|
**Detalle:** un archivo `kustomization.yaml` lista qué recursos YAML
|
||||||
|
incluir y qué parches aplicarles, sin necesidad de un lenguaje de
|
||||||
|
templating separado.
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- Namespace, ResourceQuota, LimitRange, NetworkPolicy
|
||||||
|
- Ingress vs. Service vs. Deployment
|
||||||
|
- StatefulSet (por qué Postgres lo usa y el frontend no)
|
||||||
|
- Init container
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Red y exposición
|
||||||
|
|
||||||
|
> **TODO (fase 2+):** explicar cada dominio, por qué está proxied vs
|
||||||
|
> DNS-only en Cloudflare, y el manejo de TLS extremo a extremo. Tabla
|
||||||
|
> de dominios abajo es real, pendiente de expandir con capturas/ejemplos.
|
||||||
|
|
||||||
|
## Dominios activos
|
||||||
|
|
||||||
|
| Dominio | Servicio | Namespace | TLS |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `shop.cruzcloud.net` | Frontend Next.js (storefront) | `ecommerce` | Cloudflare (edge) |
|
||||||
|
| `commerce.cruzcloud.net` | Medusa Store/Admin API | `ecommerce` | Cloudflare (edge) |
|
||||||
|
| `media.cruzcloud.net` | MinIO (imágenes de producto) | `ecommerce` | Cloudflare (edge) |
|
||||||
|
| `gitea.cruzcloud.net` | Gitea | — | Cloudflare (edge) |
|
||||||
|
| `docs.cruzcloud.net` | Este portal | `docs` | Cloudflare (edge) |
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- Diagrama de TLS termination (dónde termina TLS realmente: en
|
||||||
|
Cloudflare, no en Traefik — el tráfico interno NPM→k3d es HTTP plano).
|
||||||
|
- Explicar por qué el transporte del túnel se fijó a HTTP/2 en vez de
|
||||||
|
QUIC (ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)
|
||||||
|
y el [playbook del 504](../playbooks/incidente-504-gitea-tunnel.md)).
|
||||||
|
- Patrón de diagnóstico "interno sano, público roto" (ver
|
||||||
|
[playbook de imágenes NPM](../playbooks/incidente-imagenes-npm.md)).
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Visión general
|
||||||
|
|
||||||
|
> **TODO (fase 2+):** narrativa completa explicando cada capa del
|
||||||
|
> diagrama, con foco en el "por qué" de cada frontera de confianza. El
|
||||||
|
> diagrama de abajo es real (validado contra `docs/known-issues.md` y
|
||||||
|
> los playbooks de incidentes de `apps-registry`), pero el texto que lo
|
||||||
|
> acompaña es solo el esqueleto.
|
||||||
|
|
||||||
|
## Flujo de una petición pública
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
User(["Usuario"]) -->|HTTPS| CF["Cloudflare<br/>(DNS proxied + túnel)"]
|
||||||
|
CF -->|HTTP/2, red host| CFD["cloudflared<br/>(app ZimaOS)"]
|
||||||
|
CFD --> NPM["Nginx Proxy Manager<br/>192.168.68.61:90"]
|
||||||
|
NPM --> LB["k3d-lab-cluster-serverlb<br/>(k3d-proxy, 90→80)"]
|
||||||
|
LB --> TR["Traefik<br/>(ingress del cluster)"]
|
||||||
|
TR -->|Host: shop.cruzcloud.net| FE["frontend-svc<br/>(Next.js)"]
|
||||||
|
TR -->|Host: commerce.cruzcloud.net| MED["medusa-svc<br/>(Medusa API)"]
|
||||||
|
TR -->|Host: media.cruzcloud.net| MIN["minio-svc<br/>(imágenes de producto)"]
|
||||||
|
TR -->|Host: gitea.cruzcloud.net| GIT["Gitea"]
|
||||||
|
TR -->|Host: docs.cruzcloud.net| DOCS["docs-portal-svc<br/>(este sitio)"]
|
||||||
|
|
||||||
|
subgraph GitOps["Control plane GitOps"]
|
||||||
|
ARGO["Argo CD"] -->|sync| FE
|
||||||
|
ARGO -->|sync| MED
|
||||||
|
ARGO -->|sync| DOCS
|
||||||
|
GIT -.->|watch repos| ARGO
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Componentes
|
||||||
|
|
||||||
|
| Capa | Qué es | Dónde vive |
|
||||||
|
|---|---|---|
|
||||||
|
| Cloudflare Tunnel | Expone `*.cruzcloud.net` a internet sin abrir puertos en el NAS | Fuera del cluster, app nativa ZimaOS (`cloudflared`) |
|
||||||
|
| Nginx Proxy Manager | Enruta por dominio hacia el cluster | Fuera del cluster, contenedor `nginxproxymanager` |
|
||||||
|
| k3d | Cluster Kubernetes local (multi-nodo, Docker) | Host ZimaOS |
|
||||||
|
| Traefik | Ingress controller del cluster | Dentro de k3d |
|
||||||
|
| Argo CD | Sincroniza el estado del cluster con Git (patrón App of Apps) | Namespace `argocd` |
|
||||||
|
| Gitea | Origen de verdad de los manifiestos y del código de las apps | Contenedor propio, expuesto vía túnel |
|
||||||
|
|
||||||
|
Ver también: [Red y exposición](red-y-exposicion.md) · [Flujo GitOps](gitops-flujo.md) · [Glosario](glosario.md)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# ADR 0001 — Por qué k3d (y no k3s directo, minikube, o un cluster cloud)
|
||||||
|
|
||||||
|
> **TODO (fase 2+):** completar contexto/opciones/consecuencias con
|
||||||
|
> criterio real (recursos del NAS ZimaOS, necesidad de multi-nodo para
|
||||||
|
> reproducir el blackhole de MTU documentado en `known-issues.md`,
|
||||||
|
> costo cero vs. cluster gestionado).
|
||||||
|
|
||||||
|
**Estado:** aceptada (placeholder — pendiente de redactar)
|
||||||
|
**Fecha:** TODO
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
## Opciones consideradas
|
||||||
|
|
||||||
|
| Opción | A favor | En contra |
|
||||||
|
|---|---|---|
|
||||||
|
| k3d | | |
|
||||||
|
| k3s directo (sin Docker) | | |
|
||||||
|
| minikube | | |
|
||||||
|
| Cluster gestionado (cloud) | | |
|
||||||
|
|
||||||
|
## Decisión
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
TODO — mencionar explícitamente el blackhole de red cross-node
|
||||||
|
(MTU/PMTUD) como consecuencia real y documentada de correr multi-nodo
|
||||||
|
sobre Docker-in-Docker sin ajuste de MTU (ver
|
||||||
|
[playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md)).
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# ADR 0002 — Por qué Argo CD (y no `kubectl apply` manual o un script de CI)
|
||||||
|
|
||||||
|
> **TODO (fase 2+):** completar con el criterio real detrás de elegir
|
||||||
|
> reconciliación continua (`selfHeal`) sobre despliegue imperativo desde
|
||||||
|
> CI.
|
||||||
|
|
||||||
|
**Estado:** aceptada (placeholder — pendiente de redactar)
|
||||||
|
**Fecha:** TODO
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
## Opciones consideradas
|
||||||
|
|
||||||
|
| Opción | A favor | En contra |
|
||||||
|
|---|---|---|
|
||||||
|
| Argo CD (GitOps, pull-based) | | |
|
||||||
|
| `kubectl apply` manual | | |
|
||||||
|
| `kubectl apply` desde el pipeline de CI (push-based) | | |
|
||||||
|
|
||||||
|
## Decisión
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
TODO — mencionar el patrón App of Apps (`application.yaml` +
|
||||||
|
`apps/*.yaml`) y cómo `selfHeal:true` cambia el modelo mental de
|
||||||
|
"quién puede tocar el cluster".
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# ADR 0003 — Por qué HTTP/2 sobre QUIC para el transporte del túnel de Cloudflare
|
||||||
|
|
||||||
|
**Estado:** aceptada
|
||||||
|
**Fecha:** 2026-08 (ventana del incidente documentado abajo)
|
||||||
|
|
||||||
|
> **TODO (fase 2+):** expandir la narrativa; el contexto/decisión ya
|
||||||
|
> están fundamentados en un incidente real, solo falta pulir la
|
||||||
|
> redacción y agregar los datos de validación completos.
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
`cloudflared` (el proceso que abre el túnel hacia Cloudflare) usaba
|
||||||
|
QUIC (UDP) como protocolo de transporte por defecto para sus 4
|
||||||
|
conexiones hacia el edge de Cloudflare. Una de esas conexiones sufría
|
||||||
|
fallos recurrentes de "control stream" cada 2-6 minutos, causando
|
||||||
|
`504 Gateway Timeout` intermitentes en **todos** los subdominios detrás
|
||||||
|
del túnel (Gitea, Argo CD, la tienda, etc.), no solo uno.
|
||||||
|
|
||||||
|
Diagnóstico completo en el
|
||||||
|
[playbook del incidente](../playbooks/incidente-504-gitea-tunnel.md).
|
||||||
|
|
||||||
|
## Opciones consideradas
|
||||||
|
|
||||||
|
| Opción | A favor | En contra |
|
||||||
|
|---|---|---|
|
||||||
|
| QUIC (default) | Menor latencia teórica, multiplexing sin head-of-line blocking | Sensible a NAT/firewall doméstico; causaba el 504 intermitente real |
|
||||||
|
| HTTP/2 sobre TCP | Estable en redes domésticas con NAT agresivo | Ligeramente más latencia teórica que QUIC |
|
||||||
|
|
||||||
|
## Decisión
|
||||||
|
|
||||||
|
Forzar `TUNNEL_TRANSPORT_PROTOCOL=http2` en la configuración del
|
||||||
|
contenedor `cloudflared`, en vez de dejar el default (QUIC/UDP).
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
- Se eliminaron los `504` intermitentes — validado con curl en loop
|
||||||
|
contra `gitea.cruzcloud.net`: ~0.37–0.4s de latencia estable por
|
||||||
|
24+ minutos sin un solo error.
|
||||||
|
- Se renuncia a la ventaja teórica de latencia de QUIC, aceptable en un
|
||||||
|
lab doméstico donde la estabilidad importa más que microsegundos.
|
||||||
|
- Si en el futuro cambia el hardware de red (router, NAT) podría valer
|
||||||
|
la pena revisar si QUIC vuelve a ser viable — no hay una razón de
|
||||||
|
fondo para excluirlo para siempre, solo evidencia de que falló en
|
||||||
|
esta red concreta.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# ADR NNNN — Título corto de la decisión
|
||||||
|
|
||||||
|
> Copiar este archivo como `NNNN-titulo-corto.md` (siguiente número
|
||||||
|
> disponible) para cada decisión arquitectónica nueva.
|
||||||
|
|
||||||
|
**Estado:** propuesta | aceptada | reemplazada por ADR-XXXX
|
||||||
|
**Fecha:** AAAA-MM-DD
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
¿Qué problema forzó esta decisión? ¿Qué restricciones reales existían
|
||||||
|
(hardware del NAS, presupuesto cero, tiempo disponible, conocimiento
|
||||||
|
previo)? Sin justificar todavía la elección — solo la situación.
|
||||||
|
|
||||||
|
## Opciones consideradas
|
||||||
|
|
||||||
|
| Opción | A favor | En contra |
|
||||||
|
|---|---|---|
|
||||||
|
| Opción A | | |
|
||||||
|
| Opción B | | |
|
||||||
|
| Opción C | | |
|
||||||
|
|
||||||
|
## Decisión
|
||||||
|
|
||||||
|
Qué se eligió, en una o dos frases.
|
||||||
|
|
||||||
|
## Consecuencias
|
||||||
|
|
||||||
|
- Qué se gana
|
||||||
|
- Qué se sacrifica o queda como deuda técnica
|
||||||
|
- Qué tendría que pasar para revertir esta decisión
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Guía del estudiante — ruta de aprendizaje sugerida
|
||||||
|
|
||||||
|
> Escrita para alguien que **nunca ha visto GitOps ni Kubernetes**. Si
|
||||||
|
> ya conoces estos conceptos, probablemente quieras saltar directo a
|
||||||
|
> [Arquitectura](../arquitectura/vision-general.md) o
|
||||||
|
> [Decisiones](../decisiones/0001-por-que-k3d.md).
|
||||||
|
|
||||||
|
> **TODO (fase 2+):** convertir esto en una ruta paso a paso completa,
|
||||||
|
> con ejercicios o preguntas de verificación al final de cada parada.
|
||||||
|
> Por ahora es el esqueleto de la ruta.
|
||||||
|
|
||||||
|
## Antes de empezar
|
||||||
|
|
||||||
|
No hace falta saber Kubernetes de antemano. Sí ayuda tener claro qué es
|
||||||
|
un contenedor Docker — si eso todavía no es familiar, empieza por ahí
|
||||||
|
antes de seguir.
|
||||||
|
|
||||||
|
## Ruta sugerida
|
||||||
|
|
||||||
|
1. **[Conceptos básicos](conceptos-basicos.md)** — qué es un contenedor,
|
||||||
|
qué es Kubernetes, qué problema resuelve.
|
||||||
|
2. **[Glosario](../arquitectura/glosario.md)** — términos clave
|
||||||
|
(GitOps, tunnel, Argo CD, manifest) explicados en una frase antes del
|
||||||
|
detalle técnico.
|
||||||
|
3. **[Visión general de arquitectura](../arquitectura/vision-general.md)**
|
||||||
|
— el diagrama completo: cómo llega una petición desde el navegador
|
||||||
|
hasta la aplicación real.
|
||||||
|
4. **[Flujo GitOps](../arquitectura/gitops-flujo.md)** — qué pasa
|
||||||
|
exactamente entre que alguien hace `git push` y el cambio queda
|
||||||
|
corriendo.
|
||||||
|
5. **Un playbook real** — empieza por
|
||||||
|
[el incidente del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md):
|
||||||
|
es un buen ejemplo de cómo se descarta una hipótesis con evidencia en
|
||||||
|
vez de adivinar.
|
||||||
|
6. **Una decisión de arquitectura (ADR)** — lee
|
||||||
|
[por qué k3d](../decisiones/0001-por-que-k3d.md) para ver cómo se
|
||||||
|
documenta un trade-off real, no solo la elección final.
|
||||||
|
|
||||||
|
## Si esto se convierte en proyecto de grado
|
||||||
|
|
||||||
|
Este lab es candidato razonable de base para un proyecto de grado
|
||||||
|
porque ya tiene: infraestructura real corriendo, incidentes reales
|
||||||
|
documentados con causa raíz, y decisiones arquitectónicas explícitas
|
||||||
|
en vez de implícitas. La sección de
|
||||||
|
[aprendizajes](../aprendizajes/notas-sueltas.md) es un buen punto de
|
||||||
|
partida para encontrar preguntas de investigación abiertas (deuda
|
||||||
|
técnica, mejoras pendientes) en vez de inventar un tema desde cero.
|
||||||
|
|
||||||
|
**TODO:** definir aquí, en una sesión futura, el alcance concreto de
|
||||||
|
ese posible proyecto de grado (¿extender el lab? ¿documentar y
|
||||||
|
analizar el patrón? ¿construir sobre él?).
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Conceptos básicos
|
||||||
|
|
||||||
|
> **TODO (fase 2+):** este es el archivo con más responsabilidad
|
||||||
|
> pedagógica del sitio — pensado para alguien en su primer contacto con
|
||||||
|
> estos temas. Requiere más tiempo de redacción y ejemplos visuales que
|
||||||
|
> el resto; se deja como esqueleto a propósito para trabajarlo con
|
||||||
|
> cuidado en una sesión dedicada.
|
||||||
|
|
||||||
|
## Lo que este capítulo va a cubrir
|
||||||
|
|
||||||
|
- ¿Qué es un contenedor? (analogía antes que definición técnica)
|
||||||
|
- ¿Qué problema resuelve Kubernetes que Docker solo no resuelve?
|
||||||
|
- ¿Qué es un "cluster" en la práctica?
|
||||||
|
- ¿Qué es un namespace, y por qué importa?
|
||||||
|
- Diagrama Mermaid: "una app en un solo contenedor" vs. "una app en
|
||||||
|
Kubernetes" — mismo resultado final, distinta forma de lograrlo.
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
Contenido pendiente — ver [Glosario](../arquitectura/glosario.md)
|
||||||
|
mientras tanto para definiciones cortas de los términos que van a
|
||||||
|
aparecer aquí.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# CruzCloud Lab — Plataforma GitOps
|
||||||
|
|
||||||
|
> **TODO (fase 2+):** reescribir esta portada con la narrativa final. Por
|
||||||
|
> ahora describe el propósito y la audiencia para que la navegación y el
|
||||||
|
> `nav:` de `mkdocs.yml` tengan un punto de entrada real.
|
||||||
|
|
||||||
|
Este sitio documenta un laboratorio personal de **Platform Engineering**:
|
||||||
|
un cluster Kubernetes (k3d) corriendo sobre un NAS (ZimaOS), gobernado
|
||||||
|
100% por GitOps (Gitea + Argo CD), sirviendo una tienda de e-commerce real
|
||||||
|
(Medusa + Next.js) como carga de trabajo de referencia.
|
||||||
|
|
||||||
|
No es un tutorial genérico ni una demo de juguete: cada decisión, cada
|
||||||
|
incidente y cada diagrama de este sitio corresponde a un sistema que
|
||||||
|
corre de verdad, con los commits, logs y causas raíz reales que lo
|
||||||
|
probaron.
|
||||||
|
|
||||||
|
## Para quién es este sitio
|
||||||
|
|
||||||
|
Este portal sirve a tres audiencias distintas, y está organizado para
|
||||||
|
que cada una pueda entrar por su propia puerta:
|
||||||
|
|
||||||
|
- **Reclutadores / pares de Platform Engineering** — ver
|
||||||
|
[`decisiones/`](decisiones/0001-por-que-k3d.md) para el criterio
|
||||||
|
arquitectónico detrás del stack, y [`playbooks/`](playbooks/incidente-crashloop-medusa.md)
|
||||||
|
para diagnóstico real de incidentes (no solo "lo arreglé").
|
||||||
|
- **Alguien evaluando este patrón para un caso de negocio real**
|
||||||
|
(tienda desplegable desde ZimaOS) — ver
|
||||||
|
[`arquitectura/`](arquitectura/vision-general.md) para el flujo
|
||||||
|
completo y su costo/complejidad real.
|
||||||
|
- **Estudiantes empezando con GitOps/Kubernetes** (incluye a mi hijo,
|
||||||
|
7mo semestre de Ingeniería de Sistemas) — empezar por
|
||||||
|
[`guia-estudiante/`](guia-estudiante/README.md), pensada para alguien
|
||||||
|
que nunca ha visto estos conceptos.
|
||||||
|
|
||||||
|
## Mapa del sitio
|
||||||
|
|
||||||
|
| Sección | Qué encontrarás |
|
||||||
|
|---|---|
|
||||||
|
| [Arquitectura](arquitectura/vision-general.md) | Diagramas Mermaid del flujo completo, red/exposición, flujo GitOps, glosario |
|
||||||
|
| [Decisiones (ADRs)](decisiones/0001-por-que-k3d.md) | Por qué k3d, por qué Argo CD, por qué HTTP/2 sobre QUIC, etc. |
|
||||||
|
| [Playbooks](playbooks/incidente-crashloop-medusa.md) | Incidentes reales, diagnóstico paso a paso, causa raíz, fix |
|
||||||
|
| [Aprendizajes](aprendizajes/notas-sueltas.md) | Qué haría distinto, gotchas de Medusa v2 |
|
||||||
|
| [Guía del estudiante](guia-estudiante/README.md) | Ruta de aprendizaje desde cero |
|
||||||
|
|
||||||
|
## Estado de este sitio
|
||||||
|
|
||||||
|
Este portal se mantiene vivo junto con el lab — cada página muestra su
|
||||||
|
última fecha de modificación real (`git-revision-date-localized`). Si
|
||||||
|
una página dice "TODO", es contenido pendiente de una fase posterior,
|
||||||
|
no una promesa incumplida: la estructura completa se construyó primero
|
||||||
|
a propósito, para que el contenido se llene sección por sección con el
|
||||||
|
mismo criterio que el resto del lab.
|
||||||
@@ -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.37–0.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.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: docs-portal-ingress
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.entrypoints: web
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: docs.cruzcloud.net
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: docs-portal-svc
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
namespace: docs-portal
|
||||||
|
resources:
|
||||||
|
- deployment.yaml
|
||||||
|
- service.yaml
|
||||||
|
- ingress.yaml
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
site_name: CruzCloud Lab
|
||||||
|
site_description: >-
|
||||||
|
Portal de documentación del lab GitOps CruzCloud — arquitectura, decisiones
|
||||||
|
y playbooks de incidentes reales (ZimaOS + k3d + Gitea + Argo CD).
|
||||||
|
site_url: https://docs.cruzcloud.net/
|
||||||
|
repo_url: https://gitea.cruzcloud.net/devops/apps-registry
|
||||||
|
repo_name: devops/apps-registry
|
||||||
|
edit_uri: _blank
|
||||||
|
|
||||||
|
docs_dir: docs
|
||||||
|
|
||||||
|
theme:
|
||||||
|
name: material
|
||||||
|
language: es
|
||||||
|
palette:
|
||||||
|
- media: "(prefers-color-scheme: light)"
|
||||||
|
scheme: default
|
||||||
|
primary: indigo
|
||||||
|
accent: indigo
|
||||||
|
toggle:
|
||||||
|
icon: material/weather-night
|
||||||
|
name: Cambiar a modo oscuro
|
||||||
|
- media: "(prefers-color-scheme: dark)"
|
||||||
|
scheme: slate
|
||||||
|
primary: indigo
|
||||||
|
accent: indigo
|
||||||
|
toggle:
|
||||||
|
icon: material/weather-sunny
|
||||||
|
name: Cambiar a modo claro
|
||||||
|
features:
|
||||||
|
- navigation.tabs
|
||||||
|
- navigation.tabs.sticky
|
||||||
|
- navigation.sections
|
||||||
|
- navigation.top
|
||||||
|
- navigation.footer
|
||||||
|
- navigation.indexes
|
||||||
|
- search.suggest
|
||||||
|
- search.highlight
|
||||||
|
- content.code.copy
|
||||||
|
- content.tabs.link
|
||||||
|
- toc.follow
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- search:
|
||||||
|
lang: es
|
||||||
|
- git-revision-date-localized:
|
||||||
|
enable_creation_date: true
|
||||||
|
type: date
|
||||||
|
fallback_to_build_date: true
|
||||||
|
|
||||||
|
markdown_extensions:
|
||||||
|
- admonition
|
||||||
|
- attr_list
|
||||||
|
- md_in_html
|
||||||
|
- tables
|
||||||
|
- toc:
|
||||||
|
permalink: true
|
||||||
|
- pymdownx.details
|
||||||
|
- pymdownx.superfences:
|
||||||
|
custom_fences:
|
||||||
|
- name: mermaid
|
||||||
|
class: mermaid
|
||||||
|
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||||
|
- pymdownx.highlight:
|
||||||
|
anchor_linenums: true
|
||||||
|
- pymdownx.inlinehilite
|
||||||
|
- pymdownx.snippets
|
||||||
|
- pymdownx.tabbed:
|
||||||
|
alternate_style: true
|
||||||
|
|
||||||
|
nav:
|
||||||
|
- Inicio: index.md
|
||||||
|
- Arquitectura:
|
||||||
|
- Visión general: arquitectura/vision-general.md
|
||||||
|
- Red y exposición: arquitectura/red-y-exposicion.md
|
||||||
|
- Flujo GitOps: arquitectura/gitops-flujo.md
|
||||||
|
- Glosario: arquitectura/glosario.md
|
||||||
|
- Decisiones (ADRs):
|
||||||
|
- "0001 · Por qué k3d": decisiones/0001-por-que-k3d.md
|
||||||
|
- "0002 · Por qué Argo CD": decisiones/0002-por-que-argocd-vs-manual.md
|
||||||
|
- "0003 · HTTP/2 sobre QUIC": decisiones/0003-por-que-http2-sobre-quic-tunnel.md
|
||||||
|
- Plantilla ADR: decisiones/plantilla-adr.md
|
||||||
|
- Playbooks:
|
||||||
|
- Crashloop Medusa: playbooks/incidente-crashloop-medusa.md
|
||||||
|
- 504 túnel Gitea: playbooks/incidente-504-gitea-tunnel.md
|
||||||
|
- Precios Medusa: playbooks/incidente-precios-medusa.md
|
||||||
|
- Imágenes NPM: playbooks/incidente-imagenes-npm.md
|
||||||
|
- Aprendizajes:
|
||||||
|
- Notas sueltas: aprendizajes/notas-sueltas.md
|
||||||
|
- Guía del estudiante:
|
||||||
|
- Inicio: guia-estudiante/README.md
|
||||||
|
- Conceptos básicos: guia-estudiante/conceptos-basicos.md
|
||||||
|
|
||||||
|
extra:
|
||||||
|
social:
|
||||||
|
- icon: fontawesome/brands/git-alt
|
||||||
|
link: https://gitea.cruzcloud.net/devops
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
mkdocs==1.6.*
|
||||||
|
mkdocs-material==9.5.*
|
||||||
|
mkdocs-git-revision-date-localized-plugin==1.2.*
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: docs-portal-svc
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: docs-portal
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 80
|
||||||
|
targetPort: 80
|
||||||
@@ -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 }> }
|
interface ProductPageProps { params: Promise<{ slug: string }> }
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
// generateStaticParams + revalidate quedaban aquí para pre-renderizar en
|
||||||
const products = await getProducts();
|
// build time, pero HEADLESS_PROVIDER/MEDUSA_* no existen en el contexto del
|
||||||
return products.map((product) => ({ slug: product.slug }));
|
// 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> {
|
export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ spec:
|
|||||||
memory: 64Mi
|
memory: 64Mi
|
||||||
|
|
||||||
- name: migrations
|
- name: migrations
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.85
|
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.92
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- npx
|
- npx
|
||||||
@@ -73,7 +73,7 @@ spec:
|
|||||||
|
|
||||||
containers:
|
containers:
|
||||||
- name: medusa
|
- name: medusa
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.85
|
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.92
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- configMapRef:
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ stringData:
|
|||||||
|
|
||||||
JWT_SECRET: CAMBIAR
|
JWT_SECRET: CAMBIAR
|
||||||
COOKIE_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
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
@@ -30,3 +35,6 @@ metadata:
|
|||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
MEDUSA_PUBLISHABLE_KEY: pk_CAMBIAR_DESPUES_DE_CREARLA_EN_MEDUSA
|
MEDUSA_PUBLISHABLE_KEY: pk_CAMBIAR_DESPUES_DE_CREARLA_EN_MEDUSA
|
||||||
|
|
||||||
|
# Mismo valor que REVALIDATE_SECRET en commerce-secrets.
|
||||||
|
REVALIDATE_SECRET: CAMBIAR
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ spec:
|
|||||||
- name: gitea-registry-secret
|
- name: gitea-registry-secret
|
||||||
containers:
|
containers:
|
||||||
- name: web
|
- name: web
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.77
|
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.91
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 80
|
- containerPort: 80
|
||||||
env:
|
env:
|
||||||
|
|||||||
Reference in New Issue
Block a user