Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff1f1358a7 | ||
|
|
f9a4b0a4ea | ||
|
|
69db467edd | ||
|
|
63de0b4ed3 | ||
|
|
9418fb4d1a | ||
|
|
c492269740 | ||
|
|
1f467f2b8f | ||
|
|
e3ab3f7fc0 | ||
|
|
1df1c3a3c1 | ||
|
|
6773a8085d | ||
|
|
ea7d5e7f47 | ||
|
|
98096ab69f | ||
|
|
2438a6ba0f | ||
|
|
96be5b4b03 | ||
|
|
7dc50b83e0 | ||
|
|
c1b5f72a68 | ||
|
|
b6038e06ca | ||
|
|
4aed9f6c5c | ||
|
|
df13233a29 | ||
|
|
422e9d257d | ||
|
|
66c44a7dad | ||
|
|
ef0093a406 | ||
|
|
0234c20463 | ||
|
|
9ec40370bf | ||
|
|
22534d65f3 | ||
|
|
45924f9df2 | ||
|
|
bb1249fd58 | ||
|
|
10556758b2 | ||
|
|
e8712327a2 | ||
|
|
d5c80c98e7 | ||
|
|
fb5cf8bc98 | ||
|
|
11737ba109 | ||
|
|
b42bcad45e | ||
|
|
130177a9f2 | ||
|
|
7644b53c49 | ||
|
|
8c52229e71 | ||
|
|
2d09867f70 | ||
|
|
6a0e9965a9 | ||
|
|
095912d02d | ||
|
|
a1d2f9db67 |
+280
-4
@@ -1,6 +1,10 @@
|
||||
name: Build and Push Frontend
|
||||
|
||||
on:
|
||||
# NOTA: sin anchors/aliases de YAML (&x / *x) a propósito -- el parser
|
||||
# de workflows de Gitea Actions no los resuelve en el bloque "on:" y
|
||||
# descarta el archivo completo con "unknown on type" (visto en vivo el
|
||||
# 2026-08-15). Las dos listas de paths quedan duplicadas literalmente.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -29,14 +33,90 @@ on:
|
||||
- 'workloads/ecommerce/public/**'
|
||||
- 'workloads/ecommerce/styles/**'
|
||||
- '.gitea/workflows/build.yaml'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'workloads/ecommerce/Dockerfile'
|
||||
- 'workloads/ecommerce/.dockerignore'
|
||||
- 'workloads/ecommerce/.npmrc'
|
||||
- 'workloads/ecommerce/package.json'
|
||||
- 'workloads/ecommerce/package-lock.json'
|
||||
- 'workloads/ecommerce/next.config.*'
|
||||
- 'workloads/ecommerce/tsconfig.json'
|
||||
- 'workloads/ecommerce/tailwind.config.*'
|
||||
- 'workloads/ecommerce/postcss.config.*'
|
||||
- 'workloads/ecommerce/eslint.config.*'
|
||||
- 'workloads/ecommerce/*.js'
|
||||
- 'workloads/ecommerce/*.mjs'
|
||||
- 'workloads/ecommerce/*.ts'
|
||||
- 'workloads/ecommerce/*.tsx'
|
||||
- 'workloads/ecommerce/app/**'
|
||||
- 'workloads/ecommerce/src/**'
|
||||
- 'workloads/ecommerce/components/**'
|
||||
- 'workloads/ecommerce/lib/**'
|
||||
- 'workloads/ecommerce/public/**'
|
||||
- 'workloads/ecommerce/styles/**'
|
||||
- '.gitea/workflows/build.yaml'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
# Corre en push y en pull_request, siempre antes que build. Si encuentra
|
||||
# un secreto commiteado, el step termina con exit code distinto de 0 y,
|
||||
# por el "needs" del job build, la imagen nunca se construye ni se sube.
|
||||
gitleaks:
|
||||
name: Escaneo de Secretos (Gitleaks)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout del código
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Instalar Gitleaks
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
GITLEAKS_VERSION="8.21.2"
|
||||
curl -sSfL \
|
||||
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
|
||||
-o /tmp/gitleaks.tar.gz
|
||||
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
|
||||
chmod +x /tmp/gitleaks
|
||||
/tmp/gitleaks version
|
||||
|
||||
# --no-git: escanea el árbol de archivos del checkout (fetch-depth: 1,
|
||||
# sin historial), no el log de commits. El scan histórico completo del
|
||||
# repo se corre aparte, manualmente, no en cada push/PR.
|
||||
- name: Escanear secretos en el árbol de archivos
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
/tmp/gitleaks detect \
|
||||
--source=workloads/ecommerce \
|
||||
--no-git \
|
||||
--redact \
|
||||
--report-format=json \
|
||||
--report-path=gitleaks-report.json \
|
||||
--exit-code=1
|
||||
|
||||
- name: Publicar reporte de Gitleaks
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: gitleaks-report
|
||||
path: gitleaks-report.json
|
||||
if-no-files-found: ignore
|
||||
|
||||
build:
|
||||
name: Construir y Subir Imagen
|
||||
needs: gitleaks
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
@@ -180,6 +260,87 @@ jobs:
|
||||
|
||||
echo "OK: navegación real del catálogo validada."
|
||||
|
||||
- name: Instalar Trivy
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TRIVY_VERSION="0.74.0"
|
||||
curl -sSfL \
|
||||
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \
|
||||
-o /tmp/trivy.tar.gz
|
||||
tar -xzf /tmp/trivy.tar.gz -C /tmp trivy
|
||||
chmod +x /tmp/trivy
|
||||
/tmp/trivy version
|
||||
|
||||
# Escanea el manifiesto Kubernetes real del frontend (no la imagen):
|
||||
# contenedores como root, falta de resource limits, falta de
|
||||
# readiness/liveness probes, etc. Informativo por ahora — no bloquea
|
||||
# el pipeline mientras revisamos juntos qué hallazgos son reales.
|
||||
- name: Escanear manifiestos Kubernetes (Trivy IaC)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
/tmp/trivy config \
|
||||
--severity CRITICAL,HIGH,MEDIUM \
|
||||
--exit-code 0 \
|
||||
"${MANIFEST_FILE}"
|
||||
|
||||
# Instalado directo con pip (no como container aparte): el runner de
|
||||
# Gitea ejecuta cada job ya dentro de un contenedor propio que habla
|
||||
# con el daemon Docker del host (sibling containers) -- un
|
||||
# `docker run -v "${{ github.workspace }}:/src"` desde acá intenta
|
||||
# montar una ruta que solo existe DENTRO del contenedor del job, no
|
||||
# en el host real, y falla con "read-only file system". Evitamos por
|
||||
# completo el problema corriendo Semgrep nativo, igual que
|
||||
# gitleaks/trivy/syft/cosign.
|
||||
# venv en vez de "pip3 install --break-system-packages": la imagen del
|
||||
# runner ya trae paquetes de Python instalados por apt (ej. PyJWT)
|
||||
# sin metadata compatible con pip, y pip aborta al intentar
|
||||
# reemplazarlos ("RECORD file not found"). Un venv aislado evita
|
||||
# tocar los paquetes del sistema por completo.
|
||||
- name: Instalar Semgrep
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m venv /tmp/semgrep-venv
|
||||
/tmp/semgrep-venv/bin/pip install --quiet "semgrep==1.173.0"
|
||||
/tmp/semgrep-venv/bin/semgrep --version
|
||||
|
||||
# Modo auditoría: sin --error a propósito, Semgrep siempre termina
|
||||
# con exit 0 aunque reporte hallazgos. Es la primera vuelta — se
|
||||
# revisan los resultados en conjunto antes de decidir qué reglas
|
||||
# deberían pasar a bloquear el pipeline más adelante.
|
||||
- name: Escaneo SAST (Semgrep) — modo auditoría, no bloquea
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
/tmp/semgrep-venv/bin/semgrep scan \
|
||||
--config=p/typescript \
|
||||
--config=p/react \
|
||||
--config=p/nextjs \
|
||||
--config=p/security-audit \
|
||||
--json \
|
||||
--output=semgrep-report.json \
|
||||
"${APP_DIR}"
|
||||
|
||||
echo "=== Resumen Semgrep ==="
|
||||
/tmp/semgrep-venv/bin/python -c "
|
||||
import json
|
||||
data = json.load(open('semgrep-report.json'))
|
||||
results = data.get('results', [])
|
||||
print(f'Hallazgos: {len(results)}')
|
||||
for r in results:
|
||||
print(f\" [{r['extra']['severity']}] {r['check_id']} - {r['path']}:{r['start']['line']}\")
|
||||
"
|
||||
|
||||
- name: Publicar reporte de Semgrep
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: semgrep-report
|
||||
path: semgrep-report.json
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Login en Gitea Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -188,14 +349,129 @@ jobs:
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
logout: true
|
||||
|
||||
- name: Construir y Subir Imagen
|
||||
# push: false — la imagen se queda cargada en el daemon local (load:
|
||||
# true) para poder escanearla con Trivy antes de subirla al registry.
|
||||
- name: Construir Imagen
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: workloads/ecommerce/
|
||||
push: true
|
||||
push: false
|
||||
load: true
|
||||
tags: |
|
||||
gitea.cruzcloud.net/devops/ecommerce-frontend:${{ steps.vars.outputs.VERSION }}
|
||||
gitea.cruzcloud.net/devops/ecommerce-frontend:latest
|
||||
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }}
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
# CRITICAL bloquea el pipeline: no se sube una imagen con una CVE
|
||||
# crítica conocida y con fix disponible.
|
||||
- name: Escanear imagen (Trivy) — CRITICAL bloquea
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
/tmp/trivy image \
|
||||
--severity CRITICAL \
|
||||
--exit-code 1 \
|
||||
--ignore-unfixed \
|
||||
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||
|
||||
# HIGH solo informa por ahora — no bloquea mientras aprendemos a leer
|
||||
# los reportes y decidimos, con calma, qué reglas deben bloquear.
|
||||
- name: Escanear imagen (Trivy) — HIGH informativo
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
/tmp/trivy image \
|
||||
--severity HIGH \
|
||||
--exit-code 0 \
|
||||
--ignore-unfixed \
|
||||
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||
|
||||
# SBOM de la imagen que ya pasó el gate de CRITICAL — describe
|
||||
# exactamente qué paquetes (y en qué versión) quedaron adentro.
|
||||
# CycloneDX: formato más usado por herramientas de consulta/alertas
|
||||
# de CVEs (ej. cruzar el SBOM contra un aviso nuevo tipo log4shell).
|
||||
- name: Instalar Syft
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SYFT_VERSION="1.51.0"
|
||||
curl -sSfL \
|
||||
"https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" \
|
||||
-o /tmp/syft.tar.gz
|
||||
tar -xzf /tmp/syft.tar.gz -C /tmp syft
|
||||
chmod +x /tmp/syft
|
||||
/tmp/syft version
|
||||
|
||||
- name: Generar SBOM (Syft)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
/tmp/syft "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" \
|
||||
-o cyclonedx-json=sbom.cdx.json \
|
||||
-o spdx-json=sbom.spdx.json
|
||||
|
||||
- name: Publicar SBOM
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: sbom-${{ steps.vars.outputs.VERSION }}
|
||||
path: |
|
||||
sbom.cdx.json
|
||||
sbom.spdx.json
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Login ya se hizo arriba; recién acá se sube, después de que la
|
||||
# imagen pasó el gate de CRITICAL.
|
||||
- name: Subir Imagen al Registry
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||
docker push "${IMAGE_NAME}:latest"
|
||||
|
||||
- name: Instalar Cosign
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
COSIGN_VERSION="3.1.3"
|
||||
curl -sSfL \
|
||||
"https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-amd64" \
|
||||
-o /tmp/cosign
|
||||
chmod +x /tmp/cosign
|
||||
/tmp/cosign version
|
||||
|
||||
# Firma solo la versión (no :latest, que es un tag mutable y firmarlo
|
||||
# pierde sentido en cuanto se vuelve a mover). --use-signing-config=false
|
||||
# --tlog-upload=false: firma solo con el par de llaves propio, sin
|
||||
# publicar metadata en el transparency log público de Sigstore — este
|
||||
# registry es privado, no tiene sentido anunciar públicamente qué se
|
||||
# firmó y cuándo.
|
||||
- name: Firmar Imagen (Cosign)
|
||||
shell: bash
|
||||
env:
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
/tmp/cosign sign \
|
||||
--key env://COSIGN_PRIVATE_KEY \
|
||||
--use-signing-config=false \
|
||||
--tlog-upload=false \
|
||||
--yes \
|
||||
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||
|
||||
# Smoke test: confirma en el propio pipeline que la firma que se
|
||||
# acaba de crear valida contra la llave pública commiteada en el
|
||||
# repo (workloads/ecommerce/cosign.pub). Es la misma verificación
|
||||
# que, más adelante, podría correr un admission controller en el
|
||||
# cluster antes de dejar desplegar la imagen (ver docs/devsecops/cosign.md).
|
||||
- name: Verificar Firma (smoke test)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
/tmp/cosign verify \
|
||||
--key "${APP_DIR}/cosign.pub" \
|
||||
--insecure-ignore-tlog=true \
|
||||
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||
|
||||
# Evita que una ejecución antigua actualice frontend.yaml después de que
|
||||
# ya exista un commit más reciente en main.
|
||||
|
||||
@@ -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
|
||||
blackhole de raíz y poder quitar el `podAffinity` (o dejarlo como
|
||||
optimización, no como requisito de disponibilidad).
|
||||
|
||||
## Topología de red: Cloudflare Tunnel → Nginx Proxy Manager → k3d
|
||||
|
||||
**Estado:** referencia de arquitectura — no es un issue abierto, pero el
|
||||
patrón de falla que describe ya se repitió una vez (ver
|
||||
`docs/playbooks/incidente-medusa-imagenes-proxy-npm-2026-08.md`) y puede
|
||||
volver a aparecer con otro subdominio.
|
||||
|
||||
**Ruta real de una petición pública a `*.cruzcloud.net`:**
|
||||
|
||||
```
|
||||
Internet → Cloudflare (DNS proxied, túnel) → contenedor `cloudflared`
|
||||
(app nativa de ZimaOS, red Host) → Nginx Proxy Manager
|
||||
(contenedor `nginxproxymanager`, config en
|
||||
/DATA/AppData/nginxproxymanager/data/) → 192.168.68.61:90
|
||||
→ `k3d-lab-cluster-serverlb` (k3d-proxy, publica 90→80 y 9443→443)
|
||||
→ Traefik (ingress del cluster) → Service correspondiente,
|
||||
enrutado por el header `Host`.
|
||||
```
|
||||
|
||||
Los proxy hosts de NPM viven en
|
||||
`/DATA/AppData/nginxproxymanager/data/database.sqlite` (tabla
|
||||
`proxy_host`) y NPM regenera solo el `.conf` correspondiente en
|
||||
`data/nginx/proxy_host/<id>.conf` cuando cambia la fila en la base —
|
||||
no hace falta editar el `.conf` a mano, y si se edita a mano puede
|
||||
sobreescribirse en cualquier cambio posterior desde la UI/DB.
|
||||
|
||||
**Patrón de diagnóstico:** si un subdominio `*.cruzcloud.net` responde
|
||||
`500 Internal Server Error` con header `Server: openresty` cuando se
|
||||
accede públicamente, pero el mismo path funciona bien contra el
|
||||
ingress interno del cluster (`curl -H "Host: <subdominio>"
|
||||
http://<IP-del-nodo>/...`), el problema **no está en el cluster ni en
|
||||
la app** — está en el proxy host de NPM para ese subdominio. Revisar
|
||||
`forward_host`/`forward_port` en la tabla `proxy_host` y compararlos
|
||||
contra un host que sí funcione (ej. `shop.cruzcloud.net`,
|
||||
`commerce.cruzcloud.net`, ambos con `forward_host='192.168.68.61'`,
|
||||
`forward_port=90`).
|
||||
|
||||
## Nota de arquitectura: Medusa v2 Store API requiere `region_id` explícito
|
||||
|
||||
**Estado:** no es un issue, es un requisito de la API que causó un
|
||||
incidente real por no ser conocido — ver
|
||||
`docs/playbooks/incidente-medusa-precios-region-id-2026-08.md`.
|
||||
|
||||
El endpoint `/store/products` de Medusa v2 (validado por
|
||||
`StoreGetProductsParams`, `.strict()`) **no acepta `currency_code` ni
|
||||
`country_code`** como parámetros para resolver el contexto de precio
|
||||
(`calculated_price`). El único parámetro válido para ese fin es
|
||||
`region_id`, apuntando a una región existente y configurada vía Admin
|
||||
API. Cualquier integración nueva contra el Store API de Medusa v2 que
|
||||
necesite precios calculados debe pasar `region_id` explícito — no
|
||||
asumir que `currency_code` o `country_code` son suficientes, aunque lo
|
||||
sean en Medusa v1 o en otras APIs de e-commerce.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Incidente: 504 Gateway Timeout intermitente en gitea.cruzcloud.net (túnel QUIC inestable) (2026-08)
|
||||
|
||||
**Ventana del incidente:** intermitente durante varias horas, 2026-08
|
||||
**Servicio afectado:** `cloudflared` (túnel de Cloudflare, app nativa de ZimaOS, modo red Host) — afecta a todos los subdominios servidos por el mismo túnel (`gitea.cruzcloud.net`, `commerce.cruzcloud.net`, `shop.cruzcloud.net`, `media.cruzcloud.net`, Immich, Argo CD, Memos, etc.)
|
||||
**Impacto:** 504 Gateway Timeout intermitente en peticiones HTTP a cualquier host detrás del túnel, sin patrón evidente de horario ni de carga.
|
||||
|
||||
## Síntoma
|
||||
|
||||
`gitea.cruzcloud.net` devolvía `504 Gateway Timeout` de forma intermitente. El mismo síntoma se observaba en otros hosts servidos por el mismo túnel (Immich, Argo CD, Memos), lo que apuntaba a una causa compartida a nivel de túnel, no de una app individual.
|
||||
|
||||
## Diagnóstico
|
||||
|
||||
```
|
||||
docker logs -f cloudflared 2>&1 | grep -i -E "connIndex|protocol|retry"
|
||||
```
|
||||
|
||||
Reveló un patrón sostenido durante horas: la conexión `connIndex=2` (de las 4 conexiones que `cloudflared` mantiene hacia el edge de Cloudflare) sufría fallos recurrentes de "control stream" cada 2-6 minutos, reconectándose en loop:
|
||||
|
||||
```
|
||||
control stream encountered a failure while serving
|
||||
Retrying connection in up to 1m4s
|
||||
```
|
||||
|
||||
Cuando una petición HTTP coincidía con el instante exacto de una de esas caídas de `connIndex=2`, el cliente recibía el 504.
|
||||
|
||||
## Causa raíz
|
||||
|
||||
El contenedor `cloudflared` (imagen oficial `cloudflare/cloudflared:latest`) usaba **QUIC (UDP)** como protocolo de transporte por defecto para sus 4 conexiones hacia el edge de Cloudflare. Una de esas conexiones presentaba fallos de control stream recurrentes durante horas.
|
||||
|
||||
Causa de fondo probable: inestabilidad de UDP/QUIC en la red local (NAT/firewall doméstico). QUIC es más sensible que TCP/HTTP2 a NATs agresivos o middleboxes que no manejan bien tráfico UDP de larga vida — un patrón común en redes domésticas, a diferencia de datacenters con NAT dedicado para este tipo de tráfico.
|
||||
|
||||
## Fix aplicado
|
||||
|
||||
Se agregó la variable de entorno `TUNNEL_TRANSPORT_PROTOCOL=http2` en la configuración del contenedor `cloudflared` desde la app de ZimaOS (sección Ambiente → variables), forzando HTTP/2 sobre TCP en vez de QUIC/UDP.
|
||||
|
||||
Tras reiniciar el contenedor, el precheck de `cloudflared` confirmó:
|
||||
|
||||
```
|
||||
Environment is healthy. cloudflared will use 'http2' as primary protocol.
|
||||
```
|
||||
|
||||
y las 4 conexiones pasaron a `protocol=http2`.
|
||||
|
||||
## Validación
|
||||
|
||||
Curl en loop contra `gitea.cruzcloud.net` tras el cambio: latencia estable ~0.37–0.4s sostenida por 24+ minutos sin un solo 504.
|
||||
|
||||
## Nota — degradación puntual no relacionada, pendiente de vigilar
|
||||
|
||||
Durante la ventana de validación se observó una ventana breve de latencia alta (hasta 39s) y algunos `502`/`530`, coincidiendo con una ejecución pesada de Gitea Actions (build/deploy de Medusa) en el mismo host. Esto es contención de recursos local (CPU/IO del host compitiendo entre el runner de Actions y el resto de los servicios), **no relacionado al fix de protocolo del túnel** — no se reabrió como parte de este incidente.
|
||||
|
||||
**Pendiente:** vigilar si estas ventanas de degradación coinciden sistemáticamente con builds/deploys de Gitea Actions. Si es recurrente, considerar mover el runner (`gitea-runner`) a otro host o limitarle recursos (CPU/memoria) para evitar que compita con el resto de las apps del NAS.
|
||||
@@ -0,0 +1,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`.
|
||||
@@ -36,7 +36,9 @@ export default async function revalidateStorefrontHandler({
|
||||
|
||||
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.98
|
||||
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,133 @@
|
||||
# Cosign — firma de imágenes
|
||||
|
||||
!!! info "Qué problema resuelve"
|
||||
Todo lo anterior en esta sección (Gitleaks, Trivy, Semgrep, SBOM)
|
||||
responde a la pregunta *"¿esta imagen es segura de construir?"*.
|
||||
Cosign responde una pregunta distinta, y que pasa **después**: *"la
|
||||
imagen que está corriendo ahora mismo en el cluster, ¿es
|
||||
exactamente la que armó el pipeline — o pudo haber sido reemplazada,
|
||||
modificada, o subida por otra vía?"*.
|
||||
|
||||
## Analogía simple
|
||||
|
||||
Pensalo como el sello de cera en un sobre antiguo. Cualquiera puede leer
|
||||
la carta (la imagen es pública, cualquiera puede bajarla del registry) —
|
||||
eso Cosign no lo esconde. Lo que el sello garantiza es otra cosa: que la
|
||||
carta salió exactamente de donde dice que salió, y que nadie la abrió y
|
||||
volvió a cerrar en el camino.
|
||||
|
||||
- **La llave privada** (guardada como secret de Gitea, nunca en el repo)
|
||||
es el sello físico — solo el pipeline de CI puede estampar una firma
|
||||
válida, porque solo él tiene el sello.
|
||||
- **La llave pública** (`workloads/ecommerce/cosign.pub`, commiteada sin
|
||||
problema — es pública a propósito) es la forma de reconocer el sello:
|
||||
cualquiera puede mirar la carta, ver el sello, y confirmar "sí, esto lo
|
||||
selló quien tiene la llave privada" — sin necesitar la llave privada
|
||||
para verificarlo.
|
||||
|
||||
Si alguien sube una imagen distinta con el mismo tag, o modifica un solo
|
||||
byte de la imagen original, la firma deja de coincidir. No es que Cosign
|
||||
"detecte" la alteración activamente — es que la verificación
|
||||
simplemente falla, porque la firma fue calculada sobre el digest exacto
|
||||
de la imagen original.
|
||||
|
||||
## Cómo funciona en este pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["docker push"] --> B["cosign sign<br/>(llave privada, secret)"]
|
||||
B --> C["cosign verify<br/>(llave pública, repo)"]
|
||||
C -- "firma válida" --> D["✅ pipeline termina OK"]
|
||||
C -- "firma inválida/ausente" --> X["❌ pipeline falla"]
|
||||
```
|
||||
|
||||
1. **Par de llaves**: generado una vez con `cosign generate-key-pair`,
|
||||
protegido por password. La privada (`cosign.key`) se subió como
|
||||
secret de Gitea Actions (`COSIGN_PRIVATE_KEY` + `COSIGN_PASSWORD`) —
|
||||
nunca se commiteó al repo, ni existe en el disco de este equipo
|
||||
después de subirla. La pública (`cosign.pub`) sí vive commiteada en
|
||||
`workloads/ecommerce/cosign.pub`, porque su función es poder
|
||||
compartirse.
|
||||
2. **Firma**: después de subir la imagen al registry, el pipeline la
|
||||
firma con la llave privada (leída desde el secret vía
|
||||
`--key env://COSIGN_PRIVATE_KEY`, sin escribirla nunca a disco).
|
||||
3. **Verificación (smoke test)**: en el mismo pipeline, inmediatamente
|
||||
después, se verifica la firma recién creada contra la llave pública
|
||||
del repo. Si algo salió mal (llave incorrecta, imagen corrupta), el
|
||||
pipeline falla ahí mismo — antes de que nadie más intente confiar en
|
||||
esa imagen.
|
||||
|
||||
## Por qué `--tlog-upload=false`
|
||||
|
||||
Cosign, por defecto, publica cada firma en el *transparency log* público
|
||||
de Sigstore (Rekor) — un registro público, auditable, de "quién firmó
|
||||
qué y cuándo", pensado para proyectos open source donde esa
|
||||
transparencia es el punto. Este registry (`gitea.cruzcloud.net`) es
|
||||
privado; no tiene sentido — y sería una fuga de metadata innecesaria —
|
||||
anunciar públicamente que este lab construyó una imagen `v1.0.97` en tal
|
||||
fecha. Por eso el pipeline firma solo con el par de llaves propio,
|
||||
localmente, sin tocar el transparency log público
|
||||
(`--use-signing-config=false --tlog-upload=false` al firmar,
|
||||
`--insecure-ignore-tlog=true` al verificar).
|
||||
|
||||
!!! warning "Trade-off consciente, no gratis"
|
||||
Sin transparency log, la garantía es "esta firma la generó quien
|
||||
tiene la llave privada" — pero no hay un registro público e
|
||||
inmutable de *cuándo* se generó cada firma. Para un registry privado
|
||||
de un lab personal, ese trade-off tiene sentido. Para un proyecto
|
||||
open source con más de una persona firmando, seguramente no.
|
||||
|
||||
## Qué NO se firma
|
||||
|
||||
Solo se firma el tag versionado (`ecommerce-frontend:v1.0.X`), no
|
||||
`:latest`. `:latest` es un tag mutable — se re-apunta a una imagen
|
||||
distinta en cada build — así que firmarlo no significa nada útil: la
|
||||
firma quedaría asociada al digest de turno, y la siguiente build la
|
||||
volvería a mover. Cualquier verificación real de firma debería apuntar
|
||||
siempre a un tag de versión específico (o, mejor todavía, al digest
|
||||
exacto).
|
||||
|
||||
## Verificar manualmente
|
||||
|
||||
Con la llave pública del repo, cualquiera puede confirmar la firma de
|
||||
una imagen sin necesitar acceso a nada privado:
|
||||
|
||||
```bash
|
||||
cosign verify \
|
||||
--key workloads/ecommerce/cosign.pub \
|
||||
--insecure-ignore-tlog=true \
|
||||
gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.97
|
||||
```
|
||||
|
||||
Si la imagen fue firmada por este pipeline, el comando termina con
|
||||
`exit 0` y muestra el detalle de la firma. Si no — sea porque nunca se
|
||||
firmó, porque la firmó otra llave, o porque la imagen fue modificada
|
||||
después — termina con `exit 1` y un error explícito.
|
||||
|
||||
## Qué falta (a propósito, todavía)
|
||||
|
||||
Hoy la verificación de firma corre como smoke test **dentro del mismo
|
||||
pipeline que la creó** — útil para confirmar que el mecanismo funciona,
|
||||
pero no impide que alguien despliegue manualmente una imagen sin firmar
|
||||
en el cluster. El siguiente paso natural, que **no** se implementó en
|
||||
esta primera vuelta, sería un *admission controller* en el cluster
|
||||
(ej. [Sigstore's policy-controller](https://docs.sigstore.dev/policy-controller/overview/)
|
||||
o [Kyverno](https://kyverno.io/policies/other/verify-images/verify-images/)
|
||||
con una política de verificación de imágenes) que rechace cualquier Pod
|
||||
cuya imagen no tenga una firma válida de `cosign.pub` — momento en el
|
||||
que Argo CD dejaría de poder desplegar una imagen sin firmar, no solo el
|
||||
pipeline de CI.
|
||||
|
||||
## Si la llave privada se compromete
|
||||
|
||||
1. Generar un par nuevo (`cosign generate-key-pair`).
|
||||
2. Reemplazar `COSIGN_PRIVATE_KEY` y `COSIGN_PASSWORD` en los secrets de
|
||||
Gitea Actions del repo.
|
||||
3. Reemplazar `workloads/ecommerce/cosign.pub` con la nueva llave
|
||||
pública, en un commit normal (no es secreto, no hace falta
|
||||
reescribir historial).
|
||||
4. Las imágenes ya firmadas con la llave vieja **siguen verificando
|
||||
contra la llave vieja** — no se "invalidan" solas. Si se sospecha
|
||||
compromiso real, hay que decidir explícitamente qué imágenes ya
|
||||
desplegadas se consideran no confiables, no asumir que rotar la
|
||||
llave alcanza.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Gitleaks — detección de secretos
|
||||
|
||||
!!! info "Qué problema resuelve"
|
||||
Gitleaks busca patrones de credenciales (API keys, tokens, contraseñas,
|
||||
llaves privadas) dentro del código fuente. No sabe si una credencial es
|
||||
"real" — detecta **formas** que parecen credenciales (una API key de
|
||||
AWS siempre empieza con `AKIA`, una llave privada siempre tiene el
|
||||
encabezado `-----BEGIN PRIVATE KEY-----`, etc.) y también cadenas con
|
||||
entropía alta (aleatoriedad), que suelen ser tokens generados.
|
||||
|
||||
## Por qué esto importa
|
||||
|
||||
Un secreto commiteado a git **nunca deja de estar ahí**, aunque lo borres
|
||||
en el siguiente commit. Sigue existiendo en el historial, en cualquier
|
||||
fork, en cualquier clon local que alguien ya haya hecho. La única manera
|
||||
real de "revocar" un secreto filtrado es rotarlo (generar uno nuevo e
|
||||
invalidar el viejo) — borrar el commit no alcanza.
|
||||
|
||||
Por eso el objetivo de gitleaks no es "arreglar" el secreto después de que
|
||||
se filtró, sino **evitar que el commit con el secreto llegue a existir en
|
||||
el repo remoto**.
|
||||
|
||||
## Por qué corre antes del build
|
||||
|
||||
En `.gitea/workflows/build.yaml`, el job `gitleaks` corre **antes** que el
|
||||
job `build` (que compila la imagen Docker y la sube al registry). El job
|
||||
`build` tiene `needs: gitleaks` — si el scan falla, `build` ni siquiera
|
||||
arranca.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[push / pull_request] --> B[gitleaks]
|
||||
B -- "sin hallazgos" --> C[build]
|
||||
B -- "secreto detectado" --> X["❌ pipeline detenido<br/>build no corre"]
|
||||
```
|
||||
|
||||
La lógica es simple: no tiene sentido gastar tiempo de build y minutos de
|
||||
runner compilando una imagen a partir de un commit que de todas formas hay
|
||||
que rechazar. Fallar rápido, fallar barato.
|
||||
|
||||
También corre en **pull request**, no solo en push a `main` — así un
|
||||
secreto se detecta antes de que el PR se mergee, que es el punto donde
|
||||
todavía es más fácil corregirlo (basta con un `git commit --amend` o un
|
||||
nuevo commit en la misma rama, sin tocar `main`).
|
||||
|
||||
## Alcance de este scan
|
||||
|
||||
El step de CI escanea el árbol de archivos ya *checked out* del commit
|
||||
(`gitleaks detect --no-git`), no el historial completo — el checkout del
|
||||
pipeline es superficial (`fetch-depth: 1`, solo el último commit), así que
|
||||
no hay historial que recorrer en ese punto.
|
||||
|
||||
Antes de integrar esto al pipeline corrimos un **scan histórico completo**
|
||||
del repo `apps-registry` (los 249 commits, con `gitleaks detect` en modo
|
||||
git normal, sin `--no-git`) para confirmar que no había secretos ya
|
||||
commiteados en el pasado. Resultado: **sin hallazgos**. Ese scan histórico
|
||||
es una tarea puntual, no algo que corra en cada push — si alguna vez se
|
||||
sospecha una filtración vieja, se repite manualmente.
|
||||
|
||||
## Cómo leer un hallazgo
|
||||
|
||||
Un hallazgo de gitleaks (en el `gitleaks-report.json` que el pipeline
|
||||
publica como artifact) se ve así:
|
||||
|
||||
```json
|
||||
{
|
||||
"Description": "AWS Access Key",
|
||||
"StartLine": 14,
|
||||
"File": "workloads/ecommerce/lib/config.ts",
|
||||
"Match": "REDACTED",
|
||||
"Secret": "REDACTED",
|
||||
"RuleID": "aws-access-token",
|
||||
"Commit": "a1b2c3d"
|
||||
}
|
||||
```
|
||||
|
||||
Campos clave:
|
||||
|
||||
| Campo | Qué significa |
|
||||
|---|---|
|
||||
| `RuleID` | Qué tipo de secreto detectó (la regla que hizo match) |
|
||||
| `File` / `StartLine` | Dónde está, exactamente |
|
||||
| `Match` / `Secret` | El valor detectado — el pipeline usa `--redact`, así que en el reporte real aparece censurado, no en texto plano |
|
||||
| `Commit` | En qué commit se introdujo (solo aplica al scan histórico, no al scan `--no-git` del pipeline) |
|
||||
|
||||
!!! danger "Si el hallazgo es real"
|
||||
1. **No lo borres del código y listo** — el secreto sigue "filtrado"
|
||||
aunque ya no esté en el archivo actual.
|
||||
2. **Rota la credencial primero** en el sistema que la emitió (AWS,
|
||||
Gitea, Medusa, lo que sea). Un secreto que ya se vio en un log de
|
||||
CI o en un diff de PR se trata como comprometido.
|
||||
3. Después de rotarla, sí, saca el valor viejo del código y usa una
|
||||
variable de entorno / secret de Gitea Actions en su lugar.
|
||||
4. Si el secreto llegó a estar en `main` (no solo en una rama de PR),
|
||||
avisa antes de reescribir historial — reescribir historial en un
|
||||
repo compartido tiene sus propios riesgos y hay que decidirlo con
|
||||
calma, no como reacción automática del pipeline.
|
||||
|
||||
## Falsos positivos: cómo hacer allowlist
|
||||
|
||||
Gitleaks detecta *formas*, no intención. Cosas que típicamente generan
|
||||
falsos positivos en este proyecto:
|
||||
|
||||
- Placeholders como `CAMBIAR` en `commerce/secrets.template.yaml` — **no**
|
||||
deberían disparar nada porque no tienen la forma de un secreto real
|
||||
(baja entropía, texto plano legible), pero si algún día se usa un
|
||||
placeholder con más pinta de secreto real (ej. un UUID de ejemplo), sí
|
||||
puede hacer match.
|
||||
- Hashes largos o IDs opacos que no son secretos (ej. `MEDUSA_REGION_ID`
|
||||
en `frontend.yaml`), si tienen entropía suficientemente alta.
|
||||
|
||||
Cuando gitleaks marca algo que **no es** un secreto real, se agrega una
|
||||
regla de allowlist en un archivo `.gitleaks.toml` en la raíz del repo
|
||||
(todavía no existe — se crea la primera vez que haga falta):
|
||||
|
||||
```toml
|
||||
[allowlist]
|
||||
description = "Falsos positivos conocidos del lab"
|
||||
regexes = [
|
||||
'''MEDUSA_REGION_ID''',
|
||||
]
|
||||
paths = [
|
||||
'''workloads/ecommerce/commerce/secrets\.template\.yaml''',
|
||||
]
|
||||
```
|
||||
|
||||
!!! warning "No es una vía rápida para ignorar hallazgos reales"
|
||||
Cada entrada de allowlist debe quedar documentada (por qué es un falso
|
||||
positivo, no solo "molestaba") y revisada antes de mergear, porque una
|
||||
allowlist mal escrita (una regex demasiado amplia) puede silenciar un
|
||||
secreto real futuro sin que nadie se dé cuenta.
|
||||
|
||||
## Dónde ver el resultado
|
||||
|
||||
El job `gitleaks` publica el reporte JSON como artifact del pipeline
|
||||
(`gitleaks-report`) en cada ejecución, tenga o no hallazgos — así queda
|
||||
disponible para inspección incluso cuando el scan pasa limpio.
|
||||
@@ -0,0 +1,91 @@
|
||||
# DevSecOps
|
||||
|
||||
Fase 2 del lab: integrar tooling de seguridad al pipeline de Gitea
|
||||
Actions, empezando por un solo repo de referencia — el frontend de ARI
|
||||
Shopping (`workloads/ecommerce`, `.gitea/workflows/build.yaml`) — antes
|
||||
de replicarlo a `commerce-backend` y `docs-portal`.
|
||||
|
||||
Cinco herramientas, cada una respondiendo una pregunta distinta:
|
||||
|
||||
| Herramienta | Pregunta que responde | Modo |
|
||||
|---|---|---|
|
||||
| [Gitleaks](gitleaks.md) | ¿Hay un secreto commiteado? | **Bloquea** |
|
||||
| [Trivy — imagen](trivy.md) | ¿La imagen tiene una CVE conocida? | CRITICAL **bloquea**, HIGH informa |
|
||||
| [Trivy — IaC](trivy.md) | ¿El manifiesto de Kubernetes es inseguro? | Informa |
|
||||
| [Semgrep (SAST)](sast.md) | ¿El código tiene un patrón inseguro conocido? | Informa (auditoría) |
|
||||
| [Syft (SBOM)](sbom.md) | ¿Qué paquetes exactos quedaron en la imagen? | Informa (inventario) |
|
||||
| [Cosign](cosign.md) | ¿Esta imagen es exactamente la que armó el pipeline? | **Bloquea** (smoke test) |
|
||||
|
||||
## El pipeline completo
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph J1["job: gitleaks (push + pull_request)"]
|
||||
A1[checkout] --> A2["gitleaks detect --no-git"]
|
||||
end
|
||||
|
||||
A2 -- "secreto encontrado" --> XA["❌ pipeline detenido<br/>build nunca arranca"]
|
||||
A2 -- "limpio" --> B0
|
||||
|
||||
subgraph J2["job: build (solo push a main, needs: gitleaks)"]
|
||||
B0[checkout + validaciones de código] --> B1["Trivy IaC<br/>(frontend.yaml)"]
|
||||
B1 -.informa.-> B2["Semgrep SAST<br/>(audit mode)"]
|
||||
B2 -.informa.-> B3[login registry]
|
||||
B3 --> B4["docker build<br/>(push: false, load: true)"]
|
||||
B4 --> B5["Trivy imagen<br/>CRITICAL"]
|
||||
B5 -- "CRITICAL con fix" --> XB["❌ detenido<br/>no se sube la imagen"]
|
||||
B5 -- "sin CRITICAL" --> B6["Trivy imagen<br/>HIGH (informa)"]
|
||||
B6 --> B7["Syft → SBOM<br/>(CycloneDX + SPDX)"]
|
||||
B7 --> B8["docker push"]
|
||||
B8 --> B9["cosign sign"]
|
||||
B9 --> B10["cosign verify<br/>(smoke test)"]
|
||||
B10 -- "firma inválida" --> XC["❌ detenido"]
|
||||
B10 -- "firma válida" --> B11["actualizar frontend.yaml<br/>(GitOps, Argo CD sincroniza)"]
|
||||
end
|
||||
```
|
||||
|
||||
## Por qué este orden
|
||||
|
||||
- **Gitleaks corre en un job aparte, antes que todo lo demás** — incluso
|
||||
antes que el checkout completo del job de build. Si hay un secreto,
|
||||
no tiene sentido gastar minutos de build en un commit que hay que
|
||||
rechazar igual. Es el único check que corre también en `pull_request`,
|
||||
no solo en push a `main`.
|
||||
- **Trivy IaC y Semgrep corren antes del build de Docker.** Ninguno de
|
||||
los dos necesita la imagen construida — analizan manifiestos y código
|
||||
fuente respectivamente — así que si algo llamativo apareciera ahí, se
|
||||
sabe temprano, sin esperar el build (que es el paso más lento del
|
||||
pipeline).
|
||||
- **Trivy imagen corre después del build pero antes del push.** Por eso
|
||||
el build usa `push: false, load: true`: la imagen queda en el daemon
|
||||
local del runner, escaneable, pero no sale hacia el registry hasta
|
||||
pasar el gate de CRITICAL.
|
||||
- **Syft (SBOM) corre sobre la imagen ya validada**, antes del push —
|
||||
documenta exactamente lo que se está por publicar.
|
||||
- **Cosign firma después del push exitoso** (no tiene sentido firmar
|
||||
algo que no llegó al registry) y se verifica en el mismo pipeline como
|
||||
smoke test.
|
||||
|
||||
## Qué pasa si cada uno falla
|
||||
|
||||
| Si falla... | El pipeline... |
|
||||
|---|---|
|
||||
| Gitleaks | Se detiene ahí mismo. El job `build` nunca arranca (`needs: gitleaks`). Nada se construye. |
|
||||
| Trivy IaC | No se detiene — el hallazgo queda en el log, informativo. |
|
||||
| Semgrep | No se detiene — mismo criterio: primera vuelta en modo auditoría. |
|
||||
| Trivy imagen (CRITICAL) | Se detiene después del build, antes del push. La imagen con la CVE nunca llega al registry. |
|
||||
| Trivy imagen (HIGH) | No se detiene — se reporta para revisar en conjunto. |
|
||||
| Syft | Si el propio comando falla (no si "encuentra algo" — un SBOM no tiene hallazgos que bloqueen), el pipeline se detiene por error real de la herramienta. |
|
||||
| Cosign sign/verify | Se detiene. Si la imagen se firmó pero no verifica, algo está mal con las llaves o con la imagen — no se continúa con la promoción GitOps. |
|
||||
|
||||
## Qué falta después de esta primera vuelta
|
||||
|
||||
- Revisar en conjunto los hallazgos de Trivy IaC y Semgrep (hoy
|
||||
informativos) y decidir cuáles pasan a bloquear.
|
||||
- Decidir si Trivy imagen sube el umbral de bloqueo a HIGH una vez que
|
||||
el backlog de CVEs conocidas esté bajo control.
|
||||
- Admission controller en el cluster que verifique la firma de Cosign
|
||||
antes de dejar correr un Pod (ver [cosign.md](cosign.md)) — hoy la
|
||||
verificación es solo un smoke test dentro del propio pipeline.
|
||||
- Replicar este mismo patrón a `commerce-backend` (Medusa) y
|
||||
`docs-portal`, adaptando lo que corresponda a cada stack.
|
||||
@@ -0,0 +1,115 @@
|
||||
# SAST (Semgrep) — análisis estático de código
|
||||
|
||||
!!! info "Qué problema resuelve"
|
||||
SAST significa *Static Application Security Testing*: analizar el
|
||||
**código fuente** en busca de patrones de programación inseguros, sin
|
||||
ejecutar la aplicación. Semgrep lee cada archivo `.ts`/`.tsx` y lo
|
||||
compara contra un catálogo de reglas — cada regla describe una forma
|
||||
de escribir código que suele terminar en una vulnerabilidad conocida
|
||||
(inyección, XSS, uso inseguro de una API, etc.).
|
||||
|
||||
## En qué se diferencia de Gitleaks y Trivy
|
||||
|
||||
Las tres herramientas ya integradas a este pipeline analizan cosas
|
||||
completamente distintas — vale la pena tenerlo claro porque a primera
|
||||
vista "escaneo de seguridad" suena como una sola categoría:
|
||||
|
||||
| Herramienta | Qué mira | Pregunta que responde |
|
||||
|---|---|---|
|
||||
| [Gitleaks](gitleaks.md) | El texto de los archivos y el historial de git | "¿Hay una credencial commiteada?" |
|
||||
| [Trivy](trivy.md) | Paquetes instalados (imagen) y configuración (YAML) | "¿Alguna dependencia tiene una CVE conocida? ¿El manifiesto de Kubernetes es inseguro?" |
|
||||
| **Semgrep (SAST)** | La **lógica** del código que escribimos nosotros | "¿Esta función, tal como está escrita, abre una vulnerabilidad?" |
|
||||
|
||||
Ninguna de las tres reemplaza a las otras. Una dependencia puede estar
|
||||
100% al día (sin CVEs, Trivy contento) y aun así el código propio puede
|
||||
construir una URL con un string sin sanitizar y quedar abierto a SSRF —
|
||||
eso solo lo detecta un análisis de la lógica del código, que es
|
||||
exactamente lo que hace Semgrep.
|
||||
|
||||
## Qué tipo de bugs detecta (ejemplos genéricos)
|
||||
|
||||
Los rulesets usados (`p/typescript`, `p/react`, `p/nextjs`,
|
||||
`p/security-audit`) cubren, entre otras cosas:
|
||||
|
||||
- **Inyección**: construir queries, comandos de shell o URLs concatenando
|
||||
strings con datos que vienen del usuario, en vez de usar una API
|
||||
parametrizada.
|
||||
- **XSS en React/Next.js**: usar `dangerouslySetInnerHTML` con contenido
|
||||
que no pasó por un sanitizador.
|
||||
- **SSRF**: hacer un `fetch()`/request server-side hacia una URL que
|
||||
construye el propio usuario, sin validar el host de destino.
|
||||
- **Criptografía insegura**: algoritmos de hash débiles (`md5`, `sha1`)
|
||||
usados para contraseñas o tokens, en vez de un KDF diseñado para eso.
|
||||
- **`eval` / `new Function()`** sobre datos no confiables.
|
||||
- **ReDoS**: expresiones regulares con backtracking exponencial que un
|
||||
input malicioso puede usar para colgar el proceso.
|
||||
- **Prototype pollution**: merges/asignaciones dinámicas de objetos que
|
||||
permiten sobrescribir `__proto__`.
|
||||
|
||||
## Resultado real de esta primera corrida
|
||||
|
||||
```text
|
||||
Scanning 72 files tracked by git with 292 Code rules:
|
||||
ts 87 rules 39 files
|
||||
js 81 rules 1 file
|
||||
json 1 rule 6 files
|
||||
|
||||
Ran 91 rules on 72 files: 0 findings.
|
||||
```
|
||||
|
||||
`workloads/ecommerce` salió limpio con estos rulesets: **0 hallazgos**.
|
||||
|
||||
!!! warning "0 hallazgos no significa 'código perfecto'"
|
||||
Significa que ningún patrón conocido de estos 91 rules hizo match —
|
||||
no es una garantía de ausencia de bugs, solo de ausencia de *estos*
|
||||
patrones específicos. SAST tiene falsos negativos por naturaleza (un
|
||||
bug de lógica de negocio nuevo, específico de esta app, no está en
|
||||
ningún ruleset genérico). El valor de correr esto en cada build no es
|
||||
"una vez limpio, siempre limpio" — es que si alguien introduce a
|
||||
futuro uno de estos patrones conocidos (por ejemplo, un
|
||||
`dangerouslySetInnerHTML` sin sanitizar en un componente nuevo), el
|
||||
pipeline lo va a marcar en ese mismo push.
|
||||
|
||||
## Por qué modo auditoría (no bloquea) en esta primera integración
|
||||
|
||||
El step corre **sin** la flag `--error` a propósito — Semgrep siempre
|
||||
termina con `exit 0`, reporta lo que encuentra pero nunca frena el
|
||||
pipeline. Es la primera vez que esta herramienta corre sobre el repo: la
|
||||
idea es revisar juntos qué reglas de los 91 activos generan ruido (falsos
|
||||
positivos específicos de este código) antes de decidir cuáles deberían
|
||||
pasar a bloquear.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Semgrep scan] --> B{hallazgos?}
|
||||
B -- "sí" --> C["se reportan en el log<br/>+ artifact JSON"]
|
||||
B -- "no" --> C
|
||||
C --> D["✅ pipeline sigue<br/>(exit 0 siempre)"]
|
||||
```
|
||||
|
||||
Una vez que se decida qué reglas son suficientemente confiables para
|
||||
esta app, el paso natural es agregar `--error` **con un subconjunto**
|
||||
de reglas (no las 91 completas) usando `--config` más específico o
|
||||
`.semgrepignore` / reglas individuales marcadas como bloqueantes —
|
||||
todavía no se hizo ese recorte.
|
||||
|
||||
## Los rulesets elegidos
|
||||
|
||||
- `p/typescript` — patrones generales de TypeScript/JavaScript.
|
||||
- `p/react` — específico de componentes React (hooks mal usados, XSS vía
|
||||
props/render, etc.).
|
||||
- `p/nextjs` — patrones propios del framework (App Router, API routes,
|
||||
middlewares).
|
||||
- `p/security-audit` — catálogo transversal de seguridad (inyección,
|
||||
criptografía débil, deserialización insegura) sin atarse a un
|
||||
framework específico.
|
||||
|
||||
Son rulesets **públicos y gratuitos** del registro de Semgrep — no
|
||||
requieren cuenta ni login (`semgrep login` solo hace falta para acceder a
|
||||
reglas adicionales de pago, que no se usan acá).
|
||||
|
||||
## Dónde ver el resultado
|
||||
|
||||
El JSON completo (`semgrep-report.json`) se publica como artifact del
|
||||
pipeline (`semgrep-report`) en cada corrida, tenga o no hallazgos —
|
||||
mismo patrón que Gitleaks y Trivy.
|
||||
@@ -0,0 +1,124 @@
|
||||
# SBOM (Syft) — inventario de software
|
||||
|
||||
!!! info "Qué es un SBOM"
|
||||
SBOM = *Software Bill of Materials* — literalmente, una "lista de
|
||||
materiales" del software, igual que la lista de ingredientes de un
|
||||
producto. Es un documento (JSON, en este caso) que enumera **cada
|
||||
paquete que terminó dentro de la imagen final**: nombre, versión
|
||||
exacta, de dónde viene (`npm`, `deb`, etc.) y, cuando aplica, su
|
||||
licencia.
|
||||
|
||||
[Syft](https://github.com/anchore/syft) genera ese inventario
|
||||
inspeccionando la imagen Docker ya construida — no necesita acceso al
|
||||
código fuente ni a `package.json`, lee directamente lo que quedó
|
||||
instalado en los layers de la imagen.
|
||||
|
||||
## Por qué importa: el escenario "log4shell"
|
||||
|
||||
En diciembre de 2021 apareció una vulnerabilidad crítica en Log4j (una
|
||||
librería de logging de Java) usada, directa o indirectamente, en una
|
||||
cantidad enorme de software. La pregunta que todo equipo tuvo que
|
||||
responder en horas, no en días, fue: **"¿nosotros usamos esto, en algún
|
||||
lugar, aunque sea una dependencia de una dependencia?"**
|
||||
|
||||
Sin un SBOM, esa respuesta implica revisar manualmente cada
|
||||
`package.json`, cada imagen Docker, cada servicio — y confiar en que no
|
||||
se te escapó una dependencia transitiva de tres niveles de profundidad.
|
||||
Con un SBOM generado en cada build y guardado como artifact, la respuesta
|
||||
es una búsqueda de texto sobre un archivo:
|
||||
|
||||
```bash
|
||||
grep -i "nombre-del-paquete-afectado" sbom.cdx.json
|
||||
```
|
||||
|
||||
Si aparece, sabés exactamente en qué versión, y podés cruzarlo contra el
|
||||
aviso de seguridad para saber si tu versión específica está afectada —
|
||||
en minutos, no en una auditoría manual del repo completo.
|
||||
|
||||
## Qué genera este pipeline
|
||||
|
||||
Un step de Syft corre sobre la imagen ya construida (la misma que pasó
|
||||
el gate de CRITICAL de Trivy) y produce **dos formatos** del mismo
|
||||
inventario, publicados como artifact del pipeline:
|
||||
|
||||
- `sbom.cdx.json` — [CycloneDX](https://cyclonedx.org/), el formato con
|
||||
mejor soporte en herramientas de consulta/alertas automáticas de CVEs.
|
||||
- `sbom.spdx.json` — [SPDX](https://spdx.dev/), el estándar ISO, más
|
||||
orientado a cumplimiento de licencias y trazabilidad legal.
|
||||
|
||||
No hay una razón fuerte para elegir solo uno en esta etapa — generar
|
||||
ambos cuesta segundos y cada formato es mejor para un caso de uso
|
||||
distinto, así que se publican los dos.
|
||||
|
||||
## Resultado real de este build
|
||||
|
||||
Corriendo Syft contra la imagen real de `workloads/ecommerce`:
|
||||
|
||||
| Formato | Paquetes listados |
|
||||
|---|---|
|
||||
| CycloneDX | 3528 componentes |
|
||||
| SPDX | 228 paquetes |
|
||||
|
||||
!!! tip "¿Por qué el número es tan distinto entre formatos?"
|
||||
No es un error — cada formato tiene un nivel de detalle distinto.
|
||||
CycloneDX de Syft incluye entradas más granulares (variantes,
|
||||
sub-paquetes, entradas sin versión resuelta marcadas `UNKNOWN`)
|
||||
mientras que SPDX agrupa a un nivel más alto. Para "¿tengo este
|
||||
paquete, sí o no?" cualquiera de los dos sirve; para conteos exactos,
|
||||
hay que saber cuál se está mirando.
|
||||
|
||||
Ejemplo de una entrada real (`sbom.cdx.json`, recortado):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "next",
|
||||
"version": "15.5.10",
|
||||
"licenses": [{ "license": { "id": "MIT" } }],
|
||||
"purl": "pkg:npm/[email protected]",
|
||||
"properties": [
|
||||
{ "name": "syft:package:language", "value": "javascript" },
|
||||
{ "name": "syft:package:type", "value": "npm" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
El campo `purl` (*Package URL*) es el identificador estándar que usan
|
||||
Trivy, Syft, GitHub Advisories y la mayoría de las bases de datos de
|
||||
CVEs para referirse a "este paquete, en este ecosistema, en esta
|
||||
versión" — es lo que hace posible cruzar un SBOM contra un aviso de
|
||||
seguridad de forma automática, sin depender de que el nombre coincida
|
||||
exactamente en texto libre.
|
||||
|
||||
!!! example "El SBOM también sirve para encontrar cosas que sobran"
|
||||
Revisando el inventario real apareció `[email protected]` — viene
|
||||
empaquetado en la imagen base de Node igual que el `npm` que ya se
|
||||
sacó del stage final en el fix de [Trivy](trivy.md). No es una
|
||||
vulnerabilidad activa hoy, pero es exactamente el tipo de hallazgo
|
||||
que un SBOM hace visible para revisar después: herramientas que
|
||||
viajan en la imagen de producción sin que el runtime las necesite.
|
||||
|
||||
## Cómo se consulta
|
||||
|
||||
El SBOM se publica como artifact del pipeline (`sbom-v1.0.X`, con ambos
|
||||
archivos) en cada build. Para consultarlo:
|
||||
|
||||
1. Descargar el artifact de la corrida del pipeline que te interesa
|
||||
(o del último build de `main`, para saber qué corre en producción
|
||||
ahora mismo).
|
||||
2. Buscar el paquete en cuestión:
|
||||
```bash
|
||||
python3 -c "
|
||||
import json
|
||||
d = json.load(open('sbom.cdx.json'))
|
||||
for c in d['components']:
|
||||
if c['name'] == 'next':
|
||||
print(c['name'], c['version'])
|
||||
"
|
||||
```
|
||||
(o `grep -A3 '"name": "next"' sbom.cdx.json` si no hay Python a mano).
|
||||
3. Si el paquete aparece, confirmar la versión contra el aviso de
|
||||
seguridad para saber si aplica.
|
||||
|
||||
No hace falta memorizar el formato — el punto de tener el SBOM ya
|
||||
generado es no depender de reconstruir esta información bajo presión
|
||||
el día que aparezca la próxima CVE grande.
|
||||
@@ -0,0 +1,187 @@
|
||||
# Trivy — vulnerabilidades de imagen e IaC
|
||||
|
||||
!!! info "Qué problema resuelve"
|
||||
Trivy es un escáner de seguridad multipropósito. En este pipeline se usa
|
||||
para dos cosas **distintas**, con dos comandos distintos:
|
||||
|
||||
- `trivy image`: busca CVEs conocidas en los paquetes que terminan
|
||||
dentro de la imagen Docker final (el sistema operativo base, y las
|
||||
librerías de Node.js instaladas).
|
||||
- `trivy config`: busca **misconfiguraciones** en los manifiestos de
|
||||
Kubernetes (YAML) — no vulnerabilidades de código, sino configuración
|
||||
insegura (contenedor como root, sin límites de recursos, etc.).
|
||||
|
||||
Son preguntas distintas: "¿esta imagen tiene código con bugs de
|
||||
seguridad conocidos?" contra "¿esta manera de desplegar el contenedor
|
||||
es insegura, aunque el código adentro esté perfecto?".
|
||||
|
||||
## Dónde corre cada uno en el pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[checkout] --> B["trivy config<br/>(frontend.yaml)"]
|
||||
B --> C[docker login]
|
||||
C --> D["docker build<br/>(push: false, load: true)"]
|
||||
D --> E["trivy image --severity CRITICAL<br/>--exit-code 1"]
|
||||
E -- "CRITICAL con fix" --> X["❌ pipeline detenido<br/>no se sube la imagen"]
|
||||
E -- "sin CRITICAL" --> F["trivy image --severity HIGH<br/>--exit-code 0 (informativo)"]
|
||||
F --> G["docker push"]
|
||||
```
|
||||
|
||||
El escaneo de imagen corre **después de construir la imagen, antes de
|
||||
subirla al registry** — por eso el build ahora usa
|
||||
`push: false, load: true` (la imagen queda en el daemon Docker del runner,
|
||||
pero no sale de ahí hasta pasar el gate de CRITICAL). El escaneo de
|
||||
manifiestos (`trivy config`) no depende de la imagen, así que corre antes,
|
||||
junto a las otras validaciones del código.
|
||||
|
||||
## Umbral de severidad (y por qué)
|
||||
|
||||
| Severidad | Comportamiento | Por qué |
|
||||
|---|---|---|
|
||||
| `CRITICAL` | Bloquea (`--exit-code 1`) | Si existe un fix disponible para una CVE crítica, no tiene sentido publicar la imagen igual |
|
||||
| `HIGH` | Informa, no bloquea (`--exit-code 0`) | Mientras aprendemos a leer los reportes, HIGH se revisa pero no frena el flujo — bloquear de entrada en HIGH hubiera parado el pipeline en el primer intento real (ver ejemplo abajo) |
|
||||
|
||||
Ambos steps usan `--ignore-unfixed`: si Trivy no tiene un `FixedVersion`
|
||||
para reportar, bloquear o hasta advertir no ayuda en nada — no hay acción
|
||||
posible más que esperar a que el mantenedor del paquete publique un
|
||||
parche.
|
||||
|
||||
!!! tip "Por qué no escanear todo junto con un solo umbral"
|
||||
Trivy permite pedir `--severity CRITICAL,HIGH` en una sola corrida,
|
||||
pero el `--exit-code` se aplica igual a toda la corrida — no se puede
|
||||
decir "bloqueá en CRITICAL, pero en HIGH solo avisá" en un solo
|
||||
comando. Por eso son dos steps separados, cada uno con su propio
|
||||
umbral y su propio `--exit-code`.
|
||||
|
||||
## Ejemplo real: un CRITICAL que sí bloqueaba
|
||||
|
||||
Antes de integrar este step, construimos la imagen real de
|
||||
`workloads/ecommerce` y corrimos Trivy contra ella para validar el
|
||||
pipeline. Encontró esto:
|
||||
|
||||
```text
|
||||
Node.js (node-pkg)
|
||||
Total: 1 (CRITICAL: 1)
|
||||
|
||||
tar (7.5.15 → 7.5.19) CVE-2026-59873 CRITICAL
|
||||
tar: node-tar: Denial of Service via crafted gzip bomb
|
||||
```
|
||||
|
||||
**El detalle importante no era el CVE en sí, sino dónde vivía:**
|
||||
`/usr/local/lib/node_modules/npm/node_modules/tar/` — ese `tar` no es una
|
||||
dependencia de ARI Shopping, es el que trae **empaquetado el propio
|
||||
`npm`** dentro de la imagen base `node:24.18.0-bookworm-slim`. El
|
||||
`Dockerfile` original hacía `FROM ${NODE_IMAGE} AS runner` para el stage
|
||||
final, heredando el Node.js completo — con `npm`, `npx` y `corepack`
|
||||
incluidos — aunque en producción el contenedor solo ejecuta
|
||||
`node server.js` y **nunca** invoca `npm`.
|
||||
|
||||
!!! danger "No se arregla solo subiendo la versión de Node"
|
||||
Antes de tocar el Dockerfile probamos si un patch más nuevo de la
|
||||
imagen base ya traía el `tar` corregido: `node:24.19.0-bookworm-slim`
|
||||
trae `npm` con `[email protected]` — sigue por debajo del `7.5.19` con el
|
||||
fix. El problema no es "Node desactualizado", es que el runtime de
|
||||
producción no necesita `npm` para nada.
|
||||
|
||||
**Fix aplicado** (`workloads/ecommerce/Dockerfile`, stage `runner`):
|
||||
|
||||
```diff
|
||||
+ RUN rm -rf \
|
||||
+ /usr/local/lib/node_modules/npm \
|
||||
+ /usr/local/lib/node_modules/corepack \
|
||||
+ /usr/local/bin/npm \
|
||||
+ /usr/local/bin/npx \
|
||||
+ /usr/local/bin/corepack
|
||||
```
|
||||
|
||||
Después del fix: **0 CRITICAL**, y de paso las HIGH fixable bajaron de 21
|
||||
a 15 (varias venían de dependencias de ese mismo `npm` empaquetado, no de
|
||||
la app). Se validó que la imagen sigue arrancando y respondiendo
|
||||
`GET /api/health` con `200` después de sacar `npm`.
|
||||
|
||||
**Lección:** sacar herramientas que la imagen de producción no necesita
|
||||
en runtime no es solo "buena práctica" en abstracto — reduce
|
||||
directamente la superficie que Trivy (y un atacante) tienen para
|
||||
encontrar algo.
|
||||
|
||||
## Las HIGH que quedan (ejemplo real, sin arreglar todavía)
|
||||
|
||||
Después del fix, `trivy image --severity HIGH` sigue reportando (de
|
||||
forma informativa, no bloqueante) CVEs reales en dependencias que sí son
|
||||
de la app — la mayoría en `next` (15.5.10, con fixes disponibles en
|
||||
15.5.16+ y 15.5.21+ según el CVE), además de `nanoid`, `postcss` y
|
||||
`sharp`. Esto queda pendiente de revisar como una actualización de
|
||||
dependencias normal, no como una emergencia de seguridad — es exactamente
|
||||
para eso que HIGH no bloquea en esta primera vuelta: da visibilidad sin
|
||||
frenar el flujo mientras se decide cuándo priorizar el bump.
|
||||
|
||||
## Cómo priorizar qué arreglar primero
|
||||
|
||||
1. **CRITICAL con fix disponible** — ya bloquea el pipeline, así que en
|
||||
la práctica no se acumulan.
|
||||
2. **HIGH en una dependencia que el runtime realmente carga** (como
|
||||
`next`, que corre en cada request) — más prioridad que una HIGH en
|
||||
una herramienta de build que ni siquiera llega a la imagen final.
|
||||
3. **HIGH sin ruta de explotación realista** (ej. una librería que solo
|
||||
se usa en un script de generación, no en el server) — se puede
|
||||
posponer con criterio, documentando por qué.
|
||||
4. **MEDIUM/LOW** — se revisan en lote, no una por una.
|
||||
|
||||
La pregunta que más ayuda a priorizar no es "¿qué tan grave dice la
|
||||
CVSS que es?", sino "¿este paquete corre en el proceso que atiende
|
||||
tráfico real, o es una herramienta de build que ni siquiera debería estar
|
||||
en la imagen final?" — el propio ejemplo de arriba (`npm` dentro de la
|
||||
imagen de producción) es el caso de manual del segundo.
|
||||
|
||||
## Escaneo de manifiestos Kubernetes (`trivy config`)
|
||||
|
||||
Corre contra `workloads/ecommerce/frontend.yaml` (el `Deployment` +
|
||||
`Service` real del frontend) con `--severity CRITICAL,HIGH,MEDIUM`, en
|
||||
modo informativo (`--exit-code 0`) por ahora.
|
||||
|
||||
Hallazgos reales de este manifiesto, hoy:
|
||||
|
||||
| Severidad | Regla | Qué significa |
|
||||
|---|---|---|
|
||||
| HIGH | [KSV-0014](https://avd.aquasec.com/misconfig/ksv-0014) | El filesystem raíz del contenedor no es de solo lectura |
|
||||
| HIGH | [KSV-0118](https://avd.aquasec.com/misconfig/ksv-0118) | No se define `securityContext` — Kubernetes usa el default, que permite privilegios de root |
|
||||
| MEDIUM | [KSV-0012](https://avd.aquasec.com/misconfig/ksv-0012) | El contenedor puede correr como root (aunque la imagen ya defina `USER nextjs` en el Dockerfile, Kubernetes no lo está *forzando* vía `runAsNonRoot`) |
|
||||
| MEDIUM | [KSV-0001](https://avd.aquasec.com/misconfig/ksv-0001) | El contenedor puede escalar sus propios privilegios (falta `allowPrivilegeEscalation: false`) |
|
||||
| MEDIUM | [KSV-0104](https://avd.aquasec.com/misconfig/ksv-0104) | No hay perfil de Seccomp configurado |
|
||||
| MEDIUM | [KSV-0117](https://avd.aquasec.com/misconfig/ksv-0117) | El `containerPort: 80` es un puerto privilegiado (<1024) |
|
||||
| MEDIUM | [KSV-0125](https://avd.aquasec.com/misconfig/ksv-0125) | La imagen viene de un registry que Trivy no reconoce como "de confianza" por defecto (es autoalojado: `gitea.cruzcloud.net`) |
|
||||
|
||||
!!! warning "Lo que este scan NO detecta todavía"
|
||||
El pedido original incluía "falta de resource limits" y "falta de
|
||||
readiness/liveness probes" como ejemplos de misconfiguración a
|
||||
buscar. En la práctica, `trivy config` sí tiene reglas para límites
|
||||
de recursos (`KSV-0011` CPU, `KSV-0018` memoria) pero las clasifica
|
||||
como **LOW**, por debajo del piso `MEDIUM` que usa este step — y no
|
||||
tiene ninguna regla propia para probes de liveness/readiness (eso lo
|
||||
cubren otras herramientas, como `kube-score` o `kube-linter`, que no
|
||||
forman parte de esta primera integración). Si más adelante se quiere
|
||||
cubrir ese hueco específico, es una herramienta aparte, no una opción
|
||||
de configuración de Trivy.
|
||||
|
||||
Ninguno de estos hallazgos bloquea el pipeline todavía — son reales, pero
|
||||
corregirlos (agregar `securityContext`, `resources.limits`, etc. a
|
||||
`frontend.yaml`) es un cambio de GitOps que conviene revisar con calma,
|
||||
no como reacción automática a un scan.
|
||||
|
||||
## Falsos positivos y excepciones
|
||||
|
||||
Cuando un hallazgo de Trivy no aplica (por ejemplo, KSV-0125 marcando el
|
||||
registry propio como "no confiable" — que es exactamente lo esperado en
|
||||
un lab self-hosted), se documenta con un archivo `.trivyignore` en la
|
||||
raíz del repo:
|
||||
|
||||
```text
|
||||
# KSV-0125: gitea.cruzcloud.net es nuestro registry self-hosted,
|
||||
# no un registry público de terceros. Excepción intencional.
|
||||
KSV-0125
|
||||
```
|
||||
|
||||
Igual que con Gitleaks, cada línea de `.trivyignore` debe poder
|
||||
justificarse — no es un lugar para silenciar hallazgos incómodos sin
|
||||
revisarlos primero.
|
||||
@@ -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,65 @@
|
||||
# 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. La idea es que cada
|
||||
página muestre su última fecha de modificación real vía
|
||||
`git-revision-date-localized` — por ahora ese plugin está deshabilitado
|
||||
(ver TODO en `mkdocs.yml`): el build corre con `mkdocs build --strict`,
|
||||
que aborta ante cualquier `WARNING`, y el plugin no tiene historial git
|
||||
real que leer desde el contexto de build actual, así que se queda fuera
|
||||
hasta que ese contexto incluya `.git` de verdad. 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.
|
||||
|
||||
Desde este commit, el sitio se construye y publica solo: cada push a
|
||||
`main` que toca `docs/` o `mkdocs.yml` dispara
|
||||
`.gitea/workflows/deploy-docs.yaml`, que hace `mkdocs build --strict`,
|
||||
publica la imagen en el Registry de Gitea y Argo CD sincroniza el
|
||||
Deployment en el cluster (namespace `docs-portal`). Este párrafo es la
|
||||
prueba: si lo estás leyendo servido desde el pod real, el pipeline
|
||||
funcionó de punta a punta.
|
||||
@@ -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-tecnica-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,112 @@
|
||||
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
|
||||
# TODO (fase 2): git-revision-date-localized deshabilitado a propósito.
|
||||
# El Dockerfile construye con context: workloads/docs-portal/, que no
|
||||
# incluye .git (vive en la raíz del repo) — el plugin no tiene historial
|
||||
# real que leer y cae siempre en fallback_to_build_date, emitiendo un
|
||||
# WARNING por página. mkdocs build --strict aborta ante CUALQUIER
|
||||
# WARNING, así que mientras no se le dé contexto de build con .git real
|
||||
# (mover el build a la raíz del repo + fetch-depth:0 en el workflow),
|
||||
# este plugin debe quedar fuera para no romper el pipeline.
|
||||
# - 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
|
||||
- DevSecOps:
|
||||
- Resumen: devsecops/index.md
|
||||
- Gitleaks (secretos): devsecops/gitleaks.md
|
||||
- Trivy (imagen + IaC): devsecops/trivy.md
|
||||
- SAST (Semgrep): devsecops/sast.md
|
||||
- SBOM (Syft): devsecops/sbom.md
|
||||
- Cosign (firma de imágenes): devsecops/cosign.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.* # deshabilitado, ver TODO en mkdocs.yml
|
||||
@@ -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
|
||||
@@ -305,6 +305,18 @@ RUN apt-get update \
|
||||
'cap_net_bind_service=+ep' \
|
||||
/usr/local/bin/node
|
||||
|
||||
# El runtime solo ejecuta "node server.js" (standalone output de Next.js);
|
||||
# nunca invoca npm/npx/corepack. Sacarlos del stage final reduce el árbol
|
||||
# de dependencias escaneado por Trivy a lo que realmente corre en
|
||||
# producción, en vez de arrastrar el npm completo de la imagen base
|
||||
# (con sus propias deps y CVEs, ej. CVE-2026-59873 en node-tar).
|
||||
RUN rm -rf \
|
||||
/usr/local/lib/node_modules/npm \
|
||||
/usr/local/lib/node_modules/corepack \
|
||||
/usr/local/bin/npm \
|
||||
/usr/local/bin/npx \
|
||||
/usr/local/bin/corepack
|
||||
|
||||
COPY --from=builder \
|
||||
--chown=nextjs:nodejs \
|
||||
/app/public \
|
||||
|
||||
@@ -52,7 +52,7 @@ spec:
|
||||
memory: 64Mi
|
||||
|
||||
- name: migrations
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.88
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.92
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- npx
|
||||
@@ -73,7 +73,7 @@ spec:
|
||||
|
||||
containers:
|
||||
- name: medusa
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.88
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.92
|
||||
imagePullPolicy: IfNotPresent
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhjg9/nC0u+iEANiHkVJY8iN+LZo+
|
||||
VFMF7XG/oC64W3/SfwrPgt+ZIqF6t+ceyrNuEgugajvUdpigz1PHEqQKLw==
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -16,7 +16,7 @@ spec:
|
||||
- name: gitea-registry-secret
|
||||
containers:
|
||||
- name: web
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.89
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.91
|
||||
ports:
|
||||
- containerPort: 80
|
||||
env:
|
||||
|
||||
Reference in New Issue
Block a user