diff --git a/.gitea/workflows/deploy-docs.yaml b/.gitea/workflows/deploy-docs.yaml new file mode 100644 index 0000000..03e8b72 --- /dev/null +++ b/.gitea/workflows/deploy-docs.yaml @@ -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 "gitea-actions@cruzcloud.net" + + 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 diff --git a/apps/docs-portal-app.yaml b/apps/docs-portal-app.yaml new file mode 100644 index 0000000..3fbbeb9 --- /dev/null +++ b/apps/docs-portal-app.yaml @@ -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 diff --git a/apps/docs-portal-governance-app.yaml b/apps/docs-portal-governance-app.yaml new file mode 100644 index 0000000..aa9abc2 --- /dev/null +++ b/apps/docs-portal-governance-app.yaml @@ -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 diff --git a/workloads/docs-portal/.dockerignore b/workloads/docs-portal/.dockerignore new file mode 100644 index 0000000..c83af6d --- /dev/null +++ b/workloads/docs-portal/.dockerignore @@ -0,0 +1,9 @@ +site/ +.git/ +**/__pycache__/ +*.pyc +deployment.yaml +service.yaml +ingress.yaml +kustomization.yaml +README.md diff --git a/workloads/docs-portal/Dockerfile b/workloads/docs-portal/Dockerfile new file mode 100644 index 0000000..22bdd77 --- /dev/null +++ b/workloads/docs-portal/Dockerfile @@ -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 diff --git a/workloads/docs-portal/deployment.yaml b/workloads/docs-portal/deployment.yaml new file mode 100644 index 0000000..f9c8493 --- /dev/null +++ b/workloads/docs-portal/deployment.yaml @@ -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 diff --git a/workloads/docs-portal/docs/aprendizajes/notas-sueltas.md b/workloads/docs-portal/docs/aprendizajes/notas-sueltas.md new file mode 100644 index 0000000..18c40c1 --- /dev/null +++ b/workloads/docs-portal/docs/aprendizajes/notas-sueltas.md @@ -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. diff --git a/workloads/docs-portal/docs/arquitectura/gitops-flujo.md b/workloads/docs-portal/docs/arquitectura/gitops-flujo.md new file mode 100644 index 0000000..db24916 --- /dev/null +++ b/workloads/docs-portal/docs/arquitectura/gitops-flujo.md @@ -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/ + 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). diff --git a/workloads/docs-portal/docs/arquitectura/glosario.md b/workloads/docs-portal/docs/arquitectura/glosario.md new file mode 100644 index 0000000..e24f51a --- /dev/null +++ b/workloads/docs-portal/docs/arquitectura/glosario.md @@ -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 diff --git a/workloads/docs-portal/docs/arquitectura/red-y-exposicion.md b/workloads/docs-portal/docs/arquitectura/red-y-exposicion.md new file mode 100644 index 0000000..686e970 --- /dev/null +++ b/workloads/docs-portal/docs/arquitectura/red-y-exposicion.md @@ -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)). diff --git a/workloads/docs-portal/docs/arquitectura/vision-general.md b/workloads/docs-portal/docs/arquitectura/vision-general.md new file mode 100644 index 0000000..3160061 --- /dev/null +++ b/workloads/docs-portal/docs/arquitectura/vision-general.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
(DNS proxied + túnel)"] + CF -->|HTTP/2, red host| CFD["cloudflared
(app ZimaOS)"] + CFD --> NPM["Nginx Proxy Manager
192.168.68.61:90"] + NPM --> LB["k3d-lab-cluster-serverlb
(k3d-proxy, 90→80)"] + LB --> TR["Traefik
(ingress del cluster)"] + TR -->|Host: shop.cruzcloud.net| FE["frontend-svc
(Next.js)"] + TR -->|Host: commerce.cruzcloud.net| MED["medusa-svc
(Medusa API)"] + TR -->|Host: media.cruzcloud.net| MIN["minio-svc
(imágenes de producto)"] + TR -->|Host: gitea.cruzcloud.net| GIT["Gitea"] + TR -->|Host: docs.cruzcloud.net| DOCS["docs-portal-svc
(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) diff --git a/workloads/docs-portal/docs/decisiones/0001-por-que-k3d.md b/workloads/docs-portal/docs/decisiones/0001-por-que-k3d.md new file mode 100644 index 0000000..96c2ed8 --- /dev/null +++ b/workloads/docs-portal/docs/decisiones/0001-por-que-k3d.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)). diff --git a/workloads/docs-portal/docs/decisiones/0002-por-que-argocd-vs-manual.md b/workloads/docs-portal/docs/decisiones/0002-por-que-argocd-vs-manual.md new file mode 100644 index 0000000..28bac03 --- /dev/null +++ b/workloads/docs-portal/docs/decisiones/0002-por-que-argocd-vs-manual.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". diff --git a/workloads/docs-portal/docs/decisiones/0003-por-que-http2-sobre-quic-tunnel.md b/workloads/docs-portal/docs/decisiones/0003-por-que-http2-sobre-quic-tunnel.md new file mode 100644 index 0000000..89bfdad --- /dev/null +++ b/workloads/docs-portal/docs/decisiones/0003-por-que-http2-sobre-quic-tunnel.md @@ -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. diff --git a/workloads/docs-portal/docs/decisiones/plantilla-adr.md b/workloads/docs-portal/docs/decisiones/plantilla-adr.md new file mode 100644 index 0000000..d61688d --- /dev/null +++ b/workloads/docs-portal/docs/decisiones/plantilla-adr.md @@ -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 diff --git a/workloads/docs-portal/docs/guia-estudiante/README.md b/workloads/docs-portal/docs/guia-estudiante/README.md new file mode 100644 index 0000000..4f35eae --- /dev/null +++ b/workloads/docs-portal/docs/guia-estudiante/README.md @@ -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?). diff --git a/workloads/docs-portal/docs/guia-estudiante/conceptos-basicos.md b/workloads/docs-portal/docs/guia-estudiante/conceptos-basicos.md new file mode 100644 index 0000000..1cc79f3 --- /dev/null +++ b/workloads/docs-portal/docs/guia-estudiante/conceptos-basicos.md @@ -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í. diff --git a/workloads/docs-portal/docs/index.md b/workloads/docs-portal/docs/index.md new file mode 100644 index 0000000..5a6c90b --- /dev/null +++ b/workloads/docs-portal/docs/index.md @@ -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. diff --git a/workloads/docs-portal/docs/playbooks/incidente-504-gitea-tunnel.md b/workloads/docs-portal/docs/playbooks/incidente-504-gitea-tunnel.md new file mode 100644 index 0000000..c9cd7d4 --- /dev/null +++ b/workloads/docs-portal/docs/playbooks/incidente-504-gitea-tunnel.md @@ -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. diff --git a/workloads/docs-portal/docs/playbooks/incidente-crashloop-medusa.md b/workloads/docs-portal/docs/playbooks/incidente-crashloop-medusa.md new file mode 100644 index 0000000..558f68d --- /dev/null +++ b/workloads/docs-portal/docs/playbooks/incidente-crashloop-medusa.md @@ -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: `, 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. diff --git a/workloads/docs-portal/docs/playbooks/incidente-imagenes-npm.md b/workloads/docs-portal/docs/playbooks/incidente-imagenes-npm.md new file mode 100644 index 0000000..efccd13 --- /dev/null +++ b/workloads/docs-portal/docs/playbooks/incidente-imagenes-npm.md @@ -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/.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). diff --git a/workloads/docs-portal/docs/playbooks/incidente-precios-medusa.md b/workloads/docs-portal/docs/playbooks/incidente-precios-medusa.md new file mode 100644 index 0000000..b902c4f --- /dev/null +++ b/workloads/docs-portal/docs/playbooks/incidente-precios-medusa.md @@ -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. diff --git a/workloads/docs-portal/ingress.yaml b/workloads/docs-portal/ingress.yaml new file mode 100644 index 0000000..eef7892 --- /dev/null +++ b/workloads/docs-portal/ingress.yaml @@ -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 diff --git a/workloads/docs-portal/kustomization.yaml b/workloads/docs-portal/kustomization.yaml new file mode 100644 index 0000000..b2a633d --- /dev/null +++ b/workloads/docs-portal/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: docs-portal +resources: + - deployment.yaml + - service.yaml + - ingress.yaml diff --git a/workloads/docs-portal/mkdocs.yml b/workloads/docs-portal/mkdocs.yml new file mode 100644 index 0000000..15e64e5 --- /dev/null +++ b/workloads/docs-portal/mkdocs.yml @@ -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 diff --git a/workloads/docs-portal/requirements.txt b/workloads/docs-portal/requirements.txt new file mode 100644 index 0000000..0f4e44e --- /dev/null +++ b/workloads/docs-portal/requirements.txt @@ -0,0 +1,3 @@ +mkdocs==1.6.* +mkdocs-material==9.5.* +mkdocs-git-revision-date-localized-plugin==1.2.* diff --git a/workloads/docs-portal/service.yaml b/workloads/docs-portal/service.yaml new file mode 100644 index 0000000..884eff1 --- /dev/null +++ b/workloads/docs-portal/service.yaml @@ -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