Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be3e5c2a4c | ||
|
|
826f199ebc | ||
|
|
5c47a39579 | ||
|
|
3ee7171178 | ||
|
|
81c0f99e00 | ||
|
|
38722b97a8 | ||
|
|
5fc86e6ef4 | ||
|
|
93f071c175 | ||
|
|
0c1076eda0 | ||
|
|
80d99f9f3f | ||
|
|
81f01c7ade | ||
|
|
27a1e75a4c | ||
|
|
45312fc189 | ||
|
|
1bcae76102 | ||
|
|
8b49ac2bc5 | ||
|
|
c25877c700 | ||
|
|
941c009c9c | ||
|
|
5d3d578d36 | ||
|
|
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 |
@@ -1,5 +1,15 @@
|
|||||||
name: Build and Push Medusa
|
name: Build and Push Medusa
|
||||||
|
|
||||||
|
# Retrigger: el run 107 (con la remediacion de CVEs ya mergeada) fue
|
||||||
|
# cancelado a mitad de camino porque el stack de Gitea (gitea +
|
||||||
|
# gitea-runner) se reinicio solo durante el build -- no por el
|
||||||
|
# contenido del pipeline. Ver hallazgo de contencion de recursos en
|
||||||
|
# docs/playbooks.
|
||||||
|
|
||||||
|
# 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". Las dos listas de
|
||||||
|
# paths quedan duplicadas literalmente (mismo criterio que build.yaml).
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -7,17 +17,76 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'workloads/commerce-backend/**'
|
- 'workloads/commerce-backend/**'
|
||||||
- '.gitea/workflows/build-medusa.yaml'
|
- '.gitea/workflows/build-medusa.yaml'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'workloads/commerce-backend/**'
|
||||||
|
- '.gitea/workflows/build-medusa.yaml'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Corre en push y en pull_request, siempre antes que build. Mismo
|
||||||
|
# criterio que el frontend: si hay un secreto commiteado, el job build
|
||||||
|
# nunca arranca (needs: gitleaks).
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Escanear secretos en el árbol de archivos
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
/tmp/gitleaks detect \
|
||||||
|
--source=workloads/commerce-backend \
|
||||||
|
--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:
|
build:
|
||||||
name: Construir y publicar Medusa
|
name: Construir y publicar Medusa
|
||||||
|
needs: gitleaks
|
||||||
|
if: github.event_name == 'push'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
|
|
||||||
|
env:
|
||||||
|
APP_DIR: workloads/commerce-backend
|
||||||
|
MANIFEST_FILE: workloads/ecommerce/commerce/medusa.yaml
|
||||||
|
IMAGE_NAME: gitea.cruzcloud.net/devops/ecommerce-medusa
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout del código
|
- name: Checkout del código
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -48,6 +117,82 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
- 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 de Medusa (no la imagen).
|
||||||
|
# Informativo por ahora -- mismo criterio que el frontend, se
|
||||||
|
# revisan los hallazgos en conjunto antes de decidir qué bloquea.
|
||||||
|
- name: Escanear manifiestos Kubernetes (Trivy IaC)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
/tmp/trivy config \
|
||||||
|
--severity CRITICAL,HIGH,MEDIUM \
|
||||||
|
--exit-code 0 \
|
||||||
|
"${MANIFEST_FILE}"
|
||||||
|
|
||||||
|
# Mismo motivo que en build.yaml: el runner ejecuta el job ya
|
||||||
|
# dentro de un contenedor propio que habla con el daemon Docker
|
||||||
|
# del host (sibling containers, no Docker-in-Docker) -- correr
|
||||||
|
# Semgrep como container aparte falla montando rutas que no
|
||||||
|
# existen en el host real. Se instala nativo en un venv.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
# Ruleset adaptado al stack real de este repo: commerce-backend es
|
||||||
|
# una API de Medusa v2 (Node.js/TypeScript sobre Express), no una
|
||||||
|
# app Next.js con SSR -- confirmado en package.json (sin
|
||||||
|
# dependencia de "next", react/react-dom solo llegan como
|
||||||
|
# dependencia transitiva del admin-sdk de Medusa, no hay código de
|
||||||
|
# UI propio en este directorio). Por eso se usa p/typescript en
|
||||||
|
# vez de p/typescript + p/react + p/nextjs del frontend.
|
||||||
|
# Modo auditoría, igual que el frontend: sin --error, primera
|
||||||
|
# vuelta para revisar hallazgos antes de decidir qué bloquea.
|
||||||
|
- 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/security-audit \
|
||||||
|
--config=p/owasp-top-ten \
|
||||||
|
--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
|
- name: Login en Gitea Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
@@ -56,15 +201,138 @@ jobs:
|
|||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
logout: true
|
logout: true
|
||||||
|
|
||||||
- name: Construir y subir imagen
|
# push: false — igual que el frontend, la imagen se queda cargada
|
||||||
|
# en el daemon local para poder escanearla con Trivy antes de
|
||||||
|
# subirla al registry.
|
||||||
|
- name: Construir Imagen
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: workloads/commerce-backend/
|
context: ${{ env.APP_DIR }}/
|
||||||
file: workloads/commerce-backend/Dockerfile
|
file: ${{ env.APP_DIR }}/Dockerfile
|
||||||
push: true
|
push: false
|
||||||
|
load: true
|
||||||
tags: |
|
tags: |
|
||||||
gitea.cruzcloud.net/devops/ecommerce-medusa:${{ steps.vars.outputs.VERSION }}
|
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }}
|
||||||
gitea.cruzcloud.net/devops/ecommerce-medusa:latest
|
${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
|
# CRITICAL bloquea el pipeline: no se sube una imagen con una CVE
|
||||||
|
# crítica conocida y con fix disponible.
|
||||||
|
# --timeout 15m0s: el default de Trivy (5m) no alcanza para esta
|
||||||
|
# imagen -- a diferencia del frontend, commerce-backend arrastra
|
||||||
|
# el node_modules completo de Medusa v2 (framework + admin-sdk +
|
||||||
|
# cli), muchos más archivos que escanear tanto para vulnerabilidades
|
||||||
|
# como para el escaneo de secretos que Trivy corre por default
|
||||||
|
# dentro de la imagen. Visto en vivo: "context deadline exceeded"
|
||||||
|
# a los 4m52s con el timeout default, en un run donde el host
|
||||||
|
# venía de terminar el docker build (ver hallazgo de rendimiento
|
||||||
|
# de Gitea/runner en docs/playbooks).
|
||||||
|
# --ignorefile: excepciones puntuales y documentadas (ver
|
||||||
|
# workloads/commerce-backend/.trivyignore) para CVEs sin fix de
|
||||||
|
# bajo riesgo disponible hoy. No debilita el gate en general --
|
||||||
|
# cualquier otra CRITICAL sigue bloqueando igual.
|
||||||
|
- name: Escanear imagen (Trivy) — CRITICAL bloquea
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
/tmp/trivy image \
|
||||||
|
--severity CRITICAL \
|
||||||
|
--exit-code 1 \
|
||||||
|
--ignore-unfixed \
|
||||||
|
--timeout 15m0s \
|
||||||
|
--ignorefile "${APP_DIR}/.trivyignore" \
|
||||||
|
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||||
|
|
||||||
|
# HIGH solo informa por ahora — mismo criterio que el frontend.
|
||||||
|
- name: Escanear imagen (Trivy) — HIGH informativo
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
/tmp/trivy image \
|
||||||
|
--severity HIGH \
|
||||||
|
--exit-code 0 \
|
||||||
|
--ignore-unfixed \
|
||||||
|
--timeout 15m0s \
|
||||||
|
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
# Mismo par de llaves que el frontend (COSIGN_PRIVATE_KEY /
|
||||||
|
# COSIGN_PASSWORD ya existen como secrets a nivel de repo, no hace
|
||||||
|
# falta un secret nuevo por app): una sola identidad de firma para
|
||||||
|
# todo el registry de este lab. La llave pública se commitea en
|
||||||
|
# cada directorio de app (workloads/commerce-backend/cosign.pub)
|
||||||
|
# para que la verificación quede local a cada workflow, igual que
|
||||||
|
# el frontend.
|
||||||
|
- 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 }}"
|
||||||
|
|
||||||
|
- 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 }}"
|
||||||
|
|
||||||
- name: Verificar promoción segura
|
- name: Verificar promoción segura
|
||||||
id: promotion
|
id: promotion
|
||||||
@@ -91,8 +359,8 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${{ steps.vars.outputs.VERSION }}"
|
VERSION="${{ steps.vars.outputs.VERSION }}"
|
||||||
MANIFEST="workloads/ecommerce/commerce/medusa.yaml"
|
MANIFEST="${{ env.MANIFEST_FILE }}"
|
||||||
IMAGE="gitea.cruzcloud.net/devops/ecommerce-medusa"
|
IMAGE="${{ env.IMAGE_NAME }}"
|
||||||
|
|
||||||
git config user.name "gitea-actions"
|
git config user.name "gitea-actions"
|
||||||
git config user.email "[email protected]"
|
git config user.email "[email protected]"
|
||||||
@@ -115,3 +383,14 @@ jobs:
|
|||||||
-m "chore(gitops): deploy Medusa ${VERSION} [skip ci]"
|
-m "chore(gitops): deploy Medusa ${VERSION} [skip ci]"
|
||||||
|
|
||||||
git push origin HEAD:main
|
git push origin HEAD:main
|
||||||
|
|
||||||
|
- name: Resumen del pipeline
|
||||||
|
if: always()
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "========================================"
|
||||||
|
echo "ARI Shopping Commerce Backend (Medusa)"
|
||||||
|
echo "Versión: ${{ steps.vars.outputs.VERSION }}"
|
||||||
|
echo "Commit: ${{ github.sha }}"
|
||||||
|
echo "Promoción GitOps: ${{ steps.promotion.outputs.promote }}"
|
||||||
|
echo "========================================"
|
||||||
|
|||||||
+284
-4
@@ -1,6 +1,14 @@
|
|||||||
name: Build and Push Frontend
|
name: Build and Push Frontend
|
||||||
|
|
||||||
|
# Retrigger real (take 4): los 3 intentos anteriores fueron commits vacíos
|
||||||
|
# (--allow-empty) que no tocaban ningún path filtrado -- por diseño, nunca
|
||||||
|
# iban a disparar build.yaml. Este comentario sí cuenta como cambio real
|
||||||
|
# en .gitea/workflows/build.yaml, que está en la lista de paths.
|
||||||
on:
|
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:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@@ -29,14 +37,90 @@ on:
|
|||||||
- 'workloads/ecommerce/public/**'
|
- 'workloads/ecommerce/public/**'
|
||||||
- 'workloads/ecommerce/styles/**'
|
- 'workloads/ecommerce/styles/**'
|
||||||
- '.gitea/workflows/build.yaml'
|
- '.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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
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:
|
build:
|
||||||
name: Construir y Subir Imagen
|
name: Construir y Subir Imagen
|
||||||
|
needs: gitleaks
|
||||||
|
if: github.event_name == 'push'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|
||||||
@@ -180,6 +264,87 @@ jobs:
|
|||||||
|
|
||||||
echo "OK: navegación real del catálogo validada."
|
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
|
- name: Login en Gitea Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
@@ -188,14 +353,129 @@ jobs:
|
|||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
logout: true
|
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
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: workloads/ecommerce/
|
context: workloads/ecommerce/
|
||||||
push: true
|
push: false
|
||||||
|
load: true
|
||||||
tags: |
|
tags: |
|
||||||
gitea.cruzcloud.net/devops/ecommerce-frontend:${{ steps.vars.outputs.VERSION }}
|
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }}
|
||||||
gitea.cruzcloud.net/devops/ecommerce-frontend:latest
|
${{ 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
|
# Evita que una ejecución antigua actualice frontend.yaml después de que
|
||||||
# ya exista un commit más reciente en main.
|
# ya exista un commit más reciente en main.
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
name: Build and Push Docs Portal
|
||||||
|
|
||||||
|
# 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". Las dos listas de
|
||||||
|
# paths quedan duplicadas literalmente (mismo criterio que build.yaml).
|
||||||
|
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'
|
||||||
|
pull_request:
|
||||||
|
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:
|
||||||
|
# Corre en push y en pull_request, siempre antes que build. Mismo
|
||||||
|
# criterio que el frontend: si hay un secreto commiteado, el job build
|
||||||
|
# nunca arranca (needs: gitleaks).
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Escanear secretos en el árbol de archivos
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
/tmp/gitleaks detect \
|
||||||
|
--source=workloads/docs-portal \
|
||||||
|
--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 publicar Docs Portal
|
||||||
|
needs: gitleaks
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
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: 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 todo el directorio de la app (Deployment, Service,
|
||||||
|
# Ingress y Dockerfile), no un único MANIFEST_FILE como el
|
||||||
|
# frontend -- docs-portal tiene varios manifiestos K8s separados
|
||||||
|
# (deployment.yaml, service.yaml, ingress.yaml) en vez de uno
|
||||||
|
# solo, así que un único archivo dejaría fuera la mayoría del
|
||||||
|
# directorio. Informativo por ahora, mismo criterio que el resto.
|
||||||
|
- name: Escanear manifiestos Kubernetes (Trivy IaC)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
/tmp/trivy config \
|
||||||
|
--severity CRITICAL,HIGH,MEDIUM \
|
||||||
|
--exit-code 0 \
|
||||||
|
"${APP_DIR}"
|
||||||
|
|
||||||
|
# Mismo motivo que en build.yaml: sibling containers, no
|
||||||
|
# Docker-in-Docker -- Semgrep corre nativo en un venv.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
# Ruleset adaptado: docs-portal no tiene código de aplicación
|
||||||
|
# propio (es contenido Markdown + configuración de MkDocs), así
|
||||||
|
# que ni p/typescript ni p/react/p/nextjs del frontend aplican
|
||||||
|
# acá. Se usa p/python -- el único código ejecutable real en este
|
||||||
|
# directorio sería un hook/plugin de Python de MkDocs, si algún
|
||||||
|
# día se agrega uno. Hoy no hay ningún archivo .py en
|
||||||
|
# workloads/docs-portal, así que 0 hallazgos es el resultado
|
||||||
|
# esperado, no un falso negativo -- se deja el stage para que
|
||||||
|
# detecte código nuevo el día que se agregue, sin tener que
|
||||||
|
# recordar volver a tocar el pipeline. Modo auditoría, igual que
|
||||||
|
# el resto: no bloquea.
|
||||||
|
- name: Escaneo SAST (Semgrep) — modo auditoría, no bloquea
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
/tmp/semgrep-venv/bin/semgrep scan \
|
||||||
|
--config=p/python \
|
||||||
|
--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:
|
||||||
|
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. push: false / load: true -- igual que el frontend, la
|
||||||
|
# imagen queda cargada localmente para escanearla con Trivy antes
|
||||||
|
# de subirla.
|
||||||
|
- name: Construir Imagen
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: ${{ env.APP_DIR }}/
|
||||||
|
file: ${{ env.APP_DIR }}/Dockerfile
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: |
|
||||||
|
${{ 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.
|
||||||
|
# --timeout 15m0s: agregado preventivamente. Se vio en vivo, al
|
||||||
|
# correr este mismo comando sin el flag en commerce-backend, que
|
||||||
|
# el default de Trivy (5m) no alcanza en este host bajo carga
|
||||||
|
# ("context deadline exceeded" a los 4m52s) -- la imagen de
|
||||||
|
# docs-portal es chica, pero no cuesta nada blindar el mismo
|
||||||
|
# comando en los tres pipelines.
|
||||||
|
- name: Escanear imagen (Trivy) — CRITICAL bloquea
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
/tmp/trivy image \
|
||||||
|
--severity CRITICAL \
|
||||||
|
--exit-code 1 \
|
||||||
|
--ignore-unfixed \
|
||||||
|
--timeout 15m0s \
|
||||||
|
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||||
|
|
||||||
|
# HIGH solo informa por ahora — mismo criterio que el resto.
|
||||||
|
- name: Escanear imagen (Trivy) — HIGH informativo
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
/tmp/trivy image \
|
||||||
|
--severity HIGH \
|
||||||
|
--exit-code 0 \
|
||||||
|
--ignore-unfixed \
|
||||||
|
--timeout 15m0s \
|
||||||
|
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
# Mismo par de llaves que el frontend y commerce-backend (secrets
|
||||||
|
# ya existentes a nivel de repo). Llave pública commiteada en
|
||||||
|
# workloads/docs-portal/cosign.pub.
|
||||||
|
- 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 }}"
|
||||||
|
|
||||||
|
- 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 }}"
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- name: Resumen del pipeline
|
||||||
|
if: always()
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "========================================"
|
||||||
|
echo "CruzCloud Lab Docs Portal"
|
||||||
|
echo "Versión: ${{ steps.vars.outputs.VERSION }}"
|
||||||
|
echo "Commit: ${{ github.sha }}"
|
||||||
|
echo "Promoción GitOps: ${{ steps.promotion.outputs.promote }}"
|
||||||
|
echo "========================================"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Application
|
||||||
|
metadata:
|
||||||
|
name: docs-portal-app
|
||||||
|
namespace: argocd
|
||||||
|
spec:
|
||||||
|
project: default
|
||||||
|
source:
|
||||||
|
repoURL: https://gitea.cruzcloud.net/devops/apps-registry.git
|
||||||
|
path: workloads/docs-portal
|
||||||
|
targetRevision: main
|
||||||
|
destination:
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
namespace: docs-portal
|
||||||
|
syncPolicy:
|
||||||
|
automated:
|
||||||
|
prune: true
|
||||||
|
selfHeal: true
|
||||||
|
syncOptions:
|
||||||
|
- CreateNamespace=true
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Application
|
||||||
|
metadata:
|
||||||
|
name: docs-portal-governance
|
||||||
|
namespace: argocd
|
||||||
|
spec:
|
||||||
|
project: default
|
||||||
|
source:
|
||||||
|
repoURL: https://gitea.cruzcloud.net/devops/platform-infra.git
|
||||||
|
path: docs-portal-governance
|
||||||
|
targetRevision: main
|
||||||
|
destination:
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
namespace: docs-portal
|
||||||
|
syncPolicy:
|
||||||
|
automated:
|
||||||
|
prune: true
|
||||||
|
selfHeal: true
|
||||||
|
syncOptions:
|
||||||
|
- CreateNamespace=true
|
||||||
@@ -42,3 +42,56 @@ Ajustar el MTU del cluster k3d a un valor seguro (`1400`) de forma
|
|||||||
consistente en la red Docker del cluster y en flannel, para eliminar el
|
consistente en la red Docker del cluster y en flannel, para eliminar el
|
||||||
blackhole de raíz y poder quitar el `podAffinity` (o dejarlo como
|
blackhole de raíz y poder quitar el `podAffinity` (o dejarlo como
|
||||||
optimización, no como requisito de disponibilidad).
|
optimización, no como requisito de disponibilidad).
|
||||||
|
|
||||||
|
## Topología de red: Cloudflare Tunnel → Nginx Proxy Manager → k3d
|
||||||
|
|
||||||
|
**Estado:** referencia de arquitectura — no es un issue abierto, pero el
|
||||||
|
patrón de falla que describe ya se repitió una vez (ver
|
||||||
|
`docs/playbooks/incidente-medusa-imagenes-proxy-npm-2026-08.md`) y puede
|
||||||
|
volver a aparecer con otro subdominio.
|
||||||
|
|
||||||
|
**Ruta real de una petición pública a `*.cruzcloud.net`:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet → Cloudflare (DNS proxied, túnel) → contenedor `cloudflared`
|
||||||
|
(app nativa de ZimaOS, red Host) → Nginx Proxy Manager
|
||||||
|
(contenedor `nginxproxymanager`, config en
|
||||||
|
/DATA/AppData/nginxproxymanager/data/) → 192.168.68.61:90
|
||||||
|
→ `k3d-lab-cluster-serverlb` (k3d-proxy, publica 90→80 y 9443→443)
|
||||||
|
→ Traefik (ingress del cluster) → Service correspondiente,
|
||||||
|
enrutado por el header `Host`.
|
||||||
|
```
|
||||||
|
|
||||||
|
Los proxy hosts de NPM viven en
|
||||||
|
`/DATA/AppData/nginxproxymanager/data/database.sqlite` (tabla
|
||||||
|
`proxy_host`) y NPM regenera solo el `.conf` correspondiente en
|
||||||
|
`data/nginx/proxy_host/<id>.conf` cuando cambia la fila en la base —
|
||||||
|
no hace falta editar el `.conf` a mano, y si se edita a mano puede
|
||||||
|
sobreescribirse en cualquier cambio posterior desde la UI/DB.
|
||||||
|
|
||||||
|
**Patrón de diagnóstico:** si un subdominio `*.cruzcloud.net` responde
|
||||||
|
`500 Internal Server Error` con header `Server: openresty` cuando se
|
||||||
|
accede públicamente, pero el mismo path funciona bien contra el
|
||||||
|
ingress interno del cluster (`curl -H "Host: <subdominio>"
|
||||||
|
http://<IP-del-nodo>/...`), el problema **no está en el cluster ni en
|
||||||
|
la app** — está en el proxy host de NPM para ese subdominio. Revisar
|
||||||
|
`forward_host`/`forward_port` en la tabla `proxy_host` y compararlos
|
||||||
|
contra un host que sí funcione (ej. `shop.cruzcloud.net`,
|
||||||
|
`commerce.cruzcloud.net`, ambos con `forward_host='192.168.68.61'`,
|
||||||
|
`forward_port=90`).
|
||||||
|
|
||||||
|
## Nota de arquitectura: Medusa v2 Store API requiere `region_id` explícito
|
||||||
|
|
||||||
|
**Estado:** no es un issue, es un requisito de la API que causó un
|
||||||
|
incidente real por no ser conocido — ver
|
||||||
|
`docs/playbooks/incidente-medusa-precios-region-id-2026-08.md`.
|
||||||
|
|
||||||
|
El endpoint `/store/products` de Medusa v2 (validado por
|
||||||
|
`StoreGetProductsParams`, `.strict()`) **no acepta `currency_code` ni
|
||||||
|
`country_code`** como parámetros para resolver el contexto de precio
|
||||||
|
(`calculated_price`). El único parámetro válido para ese fin es
|
||||||
|
`region_id`, apuntando a una región existente y configurada vía Admin
|
||||||
|
API. Cualquier integración nueva contra el Store API de Medusa v2 que
|
||||||
|
necesite precios calculados debe pasar `region_id` explícito — no
|
||||||
|
asumir que `currency_code` o `country_code` son suficientes, aunque lo
|
||||||
|
sean en Medusa v1 o en otras APIs de e-commerce.
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Incidente: 504 Gateway Timeout intermitente en gitea.cruzcloud.net (túnel QUIC inestable) (2026-08)
|
||||||
|
|
||||||
|
**Ventana del incidente:** intermitente durante varias horas, 2026-08
|
||||||
|
**Servicio afectado:** `cloudflared` (túnel de Cloudflare, app nativa de ZimaOS, modo red Host) — afecta a todos los subdominios servidos por el mismo túnel (`gitea.cruzcloud.net`, `commerce.cruzcloud.net`, `shop.cruzcloud.net`, `media.cruzcloud.net`, Immich, Argo CD, Memos, etc.)
|
||||||
|
**Impacto:** 504 Gateway Timeout intermitente en peticiones HTTP a cualquier host detrás del túnel, sin patrón evidente de horario ni de carga.
|
||||||
|
|
||||||
|
## Síntoma
|
||||||
|
|
||||||
|
`gitea.cruzcloud.net` devolvía `504 Gateway Timeout` de forma intermitente. El mismo síntoma se observaba en otros hosts servidos por el mismo túnel (Immich, Argo CD, Memos), lo que apuntaba a una causa compartida a nivel de túnel, no de una app individual.
|
||||||
|
|
||||||
|
## Diagnóstico
|
||||||
|
|
||||||
|
```
|
||||||
|
docker logs -f cloudflared 2>&1 | grep -i -E "connIndex|protocol|retry"
|
||||||
|
```
|
||||||
|
|
||||||
|
Reveló un patrón sostenido durante horas: la conexión `connIndex=2` (de las 4 conexiones que `cloudflared` mantiene hacia el edge de Cloudflare) sufría fallos recurrentes de "control stream" cada 2-6 minutos, reconectándose en loop:
|
||||||
|
|
||||||
|
```
|
||||||
|
control stream encountered a failure while serving
|
||||||
|
Retrying connection in up to 1m4s
|
||||||
|
```
|
||||||
|
|
||||||
|
Cuando una petición HTTP coincidía con el instante exacto de una de esas caídas de `connIndex=2`, el cliente recibía el 504.
|
||||||
|
|
||||||
|
## Causa raíz
|
||||||
|
|
||||||
|
El contenedor `cloudflared` (imagen oficial `cloudflare/cloudflared:latest`) usaba **QUIC (UDP)** como protocolo de transporte por defecto para sus 4 conexiones hacia el edge de Cloudflare. Una de esas conexiones presentaba fallos de control stream recurrentes durante horas.
|
||||||
|
|
||||||
|
Causa de fondo probable: inestabilidad de UDP/QUIC en la red local (NAT/firewall doméstico). QUIC es más sensible que TCP/HTTP2 a NATs agresivos o middleboxes que no manejan bien tráfico UDP de larga vida — un patrón común en redes domésticas, a diferencia de datacenters con NAT dedicado para este tipo de tráfico.
|
||||||
|
|
||||||
|
## Fix aplicado
|
||||||
|
|
||||||
|
Se agregó la variable de entorno `TUNNEL_TRANSPORT_PROTOCOL=http2` en la configuración del contenedor `cloudflared` desde la app de ZimaOS (sección Ambiente → variables), forzando HTTP/2 sobre TCP en vez de QUIC/UDP.
|
||||||
|
|
||||||
|
Tras reiniciar el contenedor, el precheck de `cloudflared` confirmó:
|
||||||
|
|
||||||
|
```
|
||||||
|
Environment is healthy. cloudflared will use 'http2' as primary protocol.
|
||||||
|
```
|
||||||
|
|
||||||
|
y las 4 conexiones pasaron a `protocol=http2`.
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
Curl en loop contra `gitea.cruzcloud.net` tras el cambio: latencia estable ~0.37–0.4s sostenida por 24+ minutos sin un solo 504.
|
||||||
|
|
||||||
|
## Nota — degradación puntual no relacionada, pendiente de vigilar
|
||||||
|
|
||||||
|
Durante la ventana de validación se observó una ventana breve de latencia alta (hasta 39s) y algunos `502`/`530`, coincidiendo con una ejecución pesada de Gitea Actions (build/deploy de Medusa) en el mismo host. Esto es contención de recursos local (CPU/IO del host compitiendo entre el runner de Actions y el resto de los servicios), **no relacionado al fix de protocolo del túnel** — no se reabrió como parte de este incidente.
|
||||||
|
|
||||||
|
**Pendiente:** vigilar si estas ventanas de degradación coinciden sistemáticamente con builds/deploys de Gitea Actions. Si es recurrente, considerar mover el runner (`gitea-runner`) a otro host o limitarle recursos (CPU/memoria) para evitar que compita con el resto de las apps del NAS.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Incidente: imágenes de producto en blanco en el storefront (proxy host de media.cruzcloud.net mal configurado en NPM) (2026-08)
|
||||||
|
|
||||||
|
**Ventana del incidente:** 2026-08-13, resuelto el mismo día
|
||||||
|
**Servicio afectado:** `media.cruzcloud.net` (Nginx Proxy Manager → MinIO, `minio-svc` en namespace `ecommerce`)
|
||||||
|
**Impacto:** las imágenes subidas a productos desde el Admin de Medusa no se visualizaban en el detalle de producto del storefront (`shop.cruzcloud.net`) — el resto del producto (título, precio, subtítulo, botón añadir al carrito) se renderizaba bien.
|
||||||
|
|
||||||
|
## Síntoma
|
||||||
|
|
||||||
|
Al agregar imágenes a un producto desde el Admin de Medusa (sección Media), estas se veían correctamente cargadas en el Admin, pero en la página de detalle de producto del frontend los thumbnails aparecían vacíos/en blanco.
|
||||||
|
|
||||||
|
## Diagnóstico
|
||||||
|
|
||||||
|
1. **Storage:** confirmado que Medusa usa el provider `@medusajs/medusa/file-s3` apuntando a MinIO interno (`S3_ENDPOINT=http://minio-svc:9000`, bucket `ari-products`), con URLs públicas construidas sobre `S3_FILE_URL=https://media.cruzcloud.net/ari-products` (env en configmap `commerce-config`, namespace `ecommerce`).
|
||||||
|
2. **API:** el producto traía URLs absolutas válidas, ej. `https://media.cruzcloud.net/ari-products/<archivo>.jpg`.
|
||||||
|
3. **Prueba directa de la URL:**
|
||||||
|
- Contra el ingress interno del cluster (bypaseando el proxy externo, `Host: media.cruzcloud.net` directo a la IP del `k3d-proxy`) → `200 OK`, MinIO sirve el JPEG correctamente.
|
||||||
|
- Contra `https://media.cruzcloud.net/...` (la ruta real que usa el navegador del cliente, vía Cloudflare) → `500 Internal Server Error`, `Server: openresty`.
|
||||||
|
|
||||||
|
Ese contraste (funciona interno, falla público, con `Server: openresty` — el motor de Nginx Proxy Manager) aisló el problema al proxy externo, no a Medusa/MinIO/frontend.
|
||||||
|
|
||||||
|
## Causa raíz
|
||||||
|
|
||||||
|
En Nginx Proxy Manager (`/DATA/AppData/nginxproxymanager/`, contenedor `nginxproxymanager`), el proxy host de `media.cruzcloud.net` (`id=13` en la tabla `proxy_host` de `database.sqlite`) estaba mal configurado:
|
||||||
|
|
||||||
|
```
|
||||||
|
forward_host = "http://192.168.68.61/" -- esquema y barra final metidos dentro del campo (formato inválido)
|
||||||
|
forward_port = 5005 -- puerto donde no escucha ningún proceso en el host
|
||||||
|
```
|
||||||
|
|
||||||
|
Los proxy hosts que sí funcionan (`shop.cruzcloud.net` id 23, `commerce.cruzcloud.net` id 28) apuntan correctamente a:
|
||||||
|
|
||||||
|
```
|
||||||
|
forward_host = "192.168.68.61"
|
||||||
|
forward_port = 90 -- puerto publicado por k3d-lab-cluster-serverlb hacia Traefik
|
||||||
|
```
|
||||||
|
|
||||||
|
Se confirmó con `ss`/`docker ps` en el host que nada escuchaba en el puerto `5005` — este proxy host nunca estuvo apuntando correctamente al cluster, muy probablemente un typo desde que se creó.
|
||||||
|
|
||||||
|
## Fix aplicado
|
||||||
|
|
||||||
|
1. Backups previos: `database.sqlite.bak-pre-media-fix` y `13.conf.bak-pre-media-fix`.
|
||||||
|
2. Corrección directa en la base SQLite de NPM:
|
||||||
|
```sql
|
||||||
|
UPDATE proxy_host SET forward_host='192.168.68.61', forward_port=90 WHERE id=13;
|
||||||
|
```
|
||||||
|
3. NPM regeneró automáticamente `nginx/proxy_host/13.conf` con los valores correctos (la app Node de NPM detecta el cambio en la base y reescribe el `.conf`; no hace falta editarlo a mano).
|
||||||
|
4. Recarga de nginx dentro del contenedor:
|
||||||
|
```
|
||||||
|
docker exec nginxproxymanager nginx -t
|
||||||
|
docker exec nginxproxymanager nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
Adicionalmente, se seteó el campo `thumbnail` del producto de prueba desde el Admin (estaba en `null`) — Medusa no lo autocompleta a partir del array `images`, y el frontend lo usa como imagen hero en listados/tarjetas de catálogo.
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
Confirmado visualmente en `shop.cruzcloud.net/product/cybertron-sentinel-figura-de-coleccion-test-revalidate` que tanto la galería del detalle como el thumbnail en listados/tarjetas se renderizan correctamente. También se validó que ediciones de descripción/título/precio desde el Admin se reflejan automáticamente en el storefront sin necesidad de restart ni intervención manual, vía el subscriber `revalidate-storefront.ts` (`workloads/commerce-backend/src/subscribers/`) que procesa el evento `product.updated` de Medusa.
|
||||||
|
|
||||||
|
## Lecciones aprendidas
|
||||||
|
|
||||||
|
**Aislar interno vs. público antes de sospechar de la app.** Medusa, MinIO y el frontend estaban sanos todo el tiempo — la falla estaba en una capa de infraestructura fuera de los repos de GitOps (Nginx Proxy Manager, gestionado por fuera de `apps-registry`/`platform-infra`, sin versionar). Comparar la misma petición por la ruta interna del cluster contra la ruta pública real fue lo que aisló la causa en minutos, sin necesidad de tocar Medusa ni el frontend.
|
||||||
|
|
||||||
|
Ver `docs/known-issues.md` para la topología completa Cloudflare Tunnel → Nginx Proxy Manager → k3d y el patrón a seguir si otro subdominio (`*.cruzcloud.net`) presenta el mismo síntoma (`500`/`Server: openresty` público pero sano internamente).
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Incidente: precios `null` en detalle de producto (Medusa Store API sin region_id) (2026-08)
|
||||||
|
|
||||||
|
**Ventana del incidente:** 2026-08-12, resuelto el mismo día (commit `60072cf`)
|
||||||
|
**Servicio afectado:** `medusa-svc` (Store API), consumido por el frontend Next.js de ARI Shopping
|
||||||
|
**Impacto:** en el listado de productos (`/catalog`) los precios se mostraban correctamente, pero en la página de detalle de producto individual varios artículos mostraban "Consultar precio" en vez del monto real.
|
||||||
|
|
||||||
|
## Hipótesis inicial descartada: regiones COP duplicadas
|
||||||
|
|
||||||
|
La primera hipótesis fue que existían regiones "Colombia (COP)" duplicadas en Medusa, causando ambigüedad al resolver el precio. Se descartó con evidencia directa en Postgres, consultando la tabla `region` **incluyendo soft-deletes**:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT id, name, currency_code, created_at, updated_at, deleted_at FROM region ORDER BY created_at;
|
||||||
|
|
||||||
|
id | name | currency_code | created_at | updated_at | deleted_at
|
||||||
|
----------------------------------+----------+---------------+----------------------------+----------------------------+------------
|
||||||
|
reg_01KZT86WPAH7A7ZZRDSX3350V2 | Colombia | cop | 2026-08-12 05:48:03.151+00 | 2026-08-12 05:48:03.151+00 |
|
||||||
|
(1 row)
|
||||||
|
```
|
||||||
|
|
||||||
|
Una sola fila, creada una sola vez, nunca actualizada, nunca borrada (`deleted_at` vacío). **No había ni hubo nunca una región duplicada** — la hipótesis de deduplicación quedó descartada con esta consulta.
|
||||||
|
|
||||||
|
## Causa raíz real
|
||||||
|
|
||||||
|
El backend de Medusa **no tenía ninguna región configurada**. El frontend pedía precios al Store API (`/store/products`) usando el parámetro `currency_code=cop`, pero el validador `StoreGetProductsParams` de Medusa v2 (`.strict()`) **no acepta ese campo** — devolvía `400 Unrecognized fields: 'currency_code'` en cada carga de `/catalog`.
|
||||||
|
|
||||||
|
Se probó también `country_code`, que tampoco funcionó — Medusa respondía:
|
||||||
|
|
||||||
|
```
|
||||||
|
Missing required pricing context to calculate prices - region_id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Medusa v2 requiere explícitamente un `region_id` válido** para poder calcular precios (`calculated_price`) vía Store API; ni `currency_code` ni `country_code` son parámetros válidos para ese fin.
|
||||||
|
|
||||||
|
## Fix aplicado
|
||||||
|
|
||||||
|
Commit `60072cf` — *"fix: usar region_id en vez de currency_code para precios de Medusa"* (PR #5, mergeado en `04dc6bd`):
|
||||||
|
|
||||||
|
1. Se creó la región "Colombia" (moneda COP) vía Admin API — no existía ninguna región configurada previamente.
|
||||||
|
2. Se modificó el frontend para pedir precios usando `region_id` explícito en vez de `currency_code`, vía una nueva variable de entorno `MEDUSA_REGION_ID`:
|
||||||
|
|
||||||
|
```diff
|
||||||
|
# workloads/ecommerce/frontend.yaml
|
||||||
|
- name: MEDUSA_BACKEND_URL
|
||||||
|
value: http://medusa-svc:9000
|
||||||
|
+ - name: MEDUSA_REGION_ID
|
||||||
|
+ value: reg_01KZT86WPAH7A7ZZRDSX3350V2
|
||||||
|
```
|
||||||
|
|
||||||
|
```diff
|
||||||
|
# workloads/ecommerce/lib/headless/providers/medusa.ts
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: String(limit),
|
||||||
|
- currency_code: "cop",
|
||||||
|
+ // El validador de /store/products no acepta currency_code (400
|
||||||
|
+ // "Unrecognized fields"); el precio calculado solo se resuelve con
|
||||||
|
+ // region_id.
|
||||||
|
+ region_id: regionId(),
|
||||||
|
```
|
||||||
|
|
||||||
|
No hubo migración de precios entre regiones — no existían datos previos que migrar; la región se creó de cero como parte de este mismo fix. `MEDUSA_REGION_ID` no ha cambiado desde el commit original (verificado con `git log -p --follow` sobre `frontend.yaml`), y sigue apuntando a la única región que existe en la base.
|
||||||
|
|
||||||
|
## Lecciones aprendidas
|
||||||
|
|
||||||
|
**Verificar la causa raíz contra el estado real de la base de datos antes de asumir la primera hipótesis**, aun cuando esa hipótesis coincida con la intuición inicial de quien reporta el bug ("puede que haya algo duplicado"). La consulta a `region` incluyendo `deleted_at` tomó segundos y evitó documentar una causa raíz incorrecta en este mismo playbook.
|
||||||
|
|
||||||
|
**Nota de arquitectura:** Medusa v2 Store API (`StoreGetProductsParams`) requiere `region_id` explícito para resolver precios calculados. `currency_code` y `country_code` no son parámetros válidos para ese fin — ver también `docs/known-issues.md`.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Excepciones documentadas al gate CRITICAL de Trivy (imagen) para
|
||||||
|
# commerce-backend. Cada entrada requiere justificación y fecha -- no
|
||||||
|
# es un mecanismo para silenciar hallazgos sin revisar.
|
||||||
|
#
|
||||||
|
# CVE-2024-24790 / CVE-2025-68121 (golang stdlib, gobinary):
|
||||||
|
# Van embebidas en el binario precompilado de esbuild
|
||||||
|
# (app/node_modules/@esbuild/linux-x64/bin/esbuild), traído
|
||||||
|
# transitivamente por [email protected], que a su vez lo trae
|
||||||
|
# @medusajs/admin-sdk para bundlear el panel de admin en build time.
|
||||||
|
# No es un binario que se ejecute en runtime del contenedor (el CMD
|
||||||
|
# corre "node .../medusa/cli start", nunca esbuild).
|
||||||
|
#
|
||||||
|
# Se intentó el fix real (bump de esbuild a una versión compilada con
|
||||||
|
# un Go toolchain más nuevo, override en package.json) y rompió el
|
||||||
|
# build del admin: [email protected] declara "esbuild: ^0.21.3" como
|
||||||
|
# dependencia directa (no rango amplio), y esbuild >=0.24 cambió el
|
||||||
|
# manejo de la lista de "target" de transpilación que vite 5 pasa
|
||||||
|
# internamente -- build.yaml falló con
|
||||||
|
# "Transforming destructuring... not supported yet" /
|
||||||
|
# PLUGIN_ERROR en vite:esbuild-transpile. No existe un patch dentro
|
||||||
|
# de la propia serie 0.21.x (0.21.5 ya es la última) que incluya un
|
||||||
|
# Go toolchain con estas CVEs corregidas.
|
||||||
|
#
|
||||||
|
# Domesticar esto de verdad requiere subir @medusajs/admin-sdk (y por
|
||||||
|
# lo tanto vite) a una versión que dependa de un esbuild más nuevo --
|
||||||
|
# fuera de alcance de este pipeline de seguridad, queda como TODO de
|
||||||
|
# dependencias en un cambio aparte, no bloqueado por CI mientras
|
||||||
|
# tanto.
|
||||||
|
#
|
||||||
|
# Documentado: 2026-08-15. Revisar en cada bump de @medusajs/* por si
|
||||||
|
# ya arrastra una versión de vite/esbuild más nueva y esta excepción
|
||||||
|
# deja de ser necesaria.
|
||||||
|
CVE-2024-24790
|
||||||
|
CVE-2025-68121
|
||||||
@@ -59,6 +59,26 @@ ENV NODE_ENV=production \
|
|||||||
PORT=9000 \
|
PORT=9000 \
|
||||||
NPM_CONFIG_UPDATE_NOTIFIER=false
|
NPM_CONFIG_UPDATE_NOTIFIER=false
|
||||||
|
|
||||||
|
#
|
||||||
|
# libgnutls30 en la base node:22.18.0-bookworm-slim trae dos CVE
|
||||||
|
# CRITICAL con fix ya publicado por Debian (CVE-2026-33845,
|
||||||
|
# CVE-2026-42010) -- detectado por el gate de Trivy imagen del
|
||||||
|
# pipeline (run 138). Solo se actualiza este paquete puntual, no toda
|
||||||
|
# la imagen, para minimizar el diff de superficie de la base.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get upgrade -y libgnutls30 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
#
|
||||||
|
# El CLI global de npm que trae la imagen base node:*-slim no se usa en
|
||||||
|
# runtime (el CMD invoca a Medusa directo con `node`, nunca `npm`) y
|
||||||
|
# arrastra su propia copia vendorizada de `tar` con un CVE CRITICAL
|
||||||
|
# (CVE-2026-59873, detectado por Trivy en
|
||||||
|
# usr/local/lib/node_modules/npm/node_modules/tar). Se elimina en vez
|
||||||
|
# de forzar una versión: no es una dependencia real de este proyecto,
|
||||||
|
# así que no hay nada que "actualizar" -- solo superficie sin uso.
|
||||||
|
RUN rm -rf /usr/local/lib/node_modules/npm
|
||||||
|
|
||||||
#
|
#
|
||||||
# Solo lo necesario para ejecutar Medusa
|
# Solo lo necesario para ejecutar Medusa
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhjg9/nC0u+iEANiHkVJY8iN+LZo+
|
||||||
|
VFMF7XG/oC64W3/SfwrPgt+ZIqF6t+ceyrNuEgugajvUdpigz1PHEqQKLw==
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -36,7 +36,9 @@ export default async function revalidateStorefrontHandler({
|
|||||||
|
|
||||||
export const config: SubscriberConfig = {
|
export const config: SubscriberConfig = {
|
||||||
event: [
|
event: [
|
||||||
|
"product.created",
|
||||||
"product.updated",
|
"product.updated",
|
||||||
|
"product.deleted",
|
||||||
"product-variant.updated",
|
"product-variant.updated",
|
||||||
"product-variant.created",
|
"product-variant.created",
|
||||||
"product-variant.deleted",
|
"product-variant.deleted",
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
site/
|
||||||
|
.git/
|
||||||
|
**/__pycache__/
|
||||||
|
*.pyc
|
||||||
|
deployment.yaml
|
||||||
|
service.yaml
|
||||||
|
ingress.yaml
|
||||||
|
kustomization.yaml
|
||||||
|
README.md
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# --- 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
|
||||||
|
|
||||||
|
# libssl3/libcrypto3 de la base nginx:1.27-alpine traen un CVE CRITICAL
|
||||||
|
# con fix ya publicado por Alpine (CVE-2026-31789, heap buffer overflow
|
||||||
|
# en OpenSSL) -- detectado por el gate de Trivy imagen del pipeline
|
||||||
|
# (run 144). Solo se actualizan estos dos paquetes puntuales.
|
||||||
|
RUN apk update \
|
||||||
|
&& apk upgrade --no-cache libssl3 libcrypto3 \
|
||||||
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
|
COPY --from=build /site/site /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhjg9/nC0u+iEANiHkVJY8iN+LZo+
|
||||||
|
VFMF7XG/oC64W3/SfwrPgt+ZIqF6t+ceyrNuEgugajvUdpigz1PHEqQKLw==
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -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.110
|
||||||
|
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,201 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Limitaciones conocidas y mejoras futuras
|
||||||
|
|
||||||
|
Dos limitaciones identificadas al validar el pipeline end-to-end,
|
||||||
|
documentadas a propósito como TODO — ninguna de las dos se implementó
|
||||||
|
todavía.
|
||||||
|
|
||||||
|
### 1. Se firma por tag, no por digest
|
||||||
|
|
||||||
|
Hoy el pipeline firma `ecommerce-frontend:v1.0.104` — un **tag**, no un
|
||||||
|
**digest** (`sha256:...`). Un tag es una etiqueta mutable: nada impide
|
||||||
|
que, después de firmada la imagen, alguien (o un bug en el propio
|
||||||
|
pipeline) vuelva a subir contenido distinto bajo el mismo tag
|
||||||
|
`v1.0.104`. La firma original seguiría "verificando" — porque Cosign,
|
||||||
|
al verificar por tag, resuelve el tag al digest que tenga *en ese
|
||||||
|
momento*, no al que tenía cuando se firmó. Si el tag fue reasignado,
|
||||||
|
se está verificando una imagen distinta de la que realmente se firmó,
|
||||||
|
sin que nada avise.
|
||||||
|
|
||||||
|
Esto no es hipotético: el propio Cosign lo advierte en cada firma de
|
||||||
|
este pipeline (visto en los logs reales de cada run):
|
||||||
|
|
||||||
|
```text
|
||||||
|
WARNING: Image reference gitea.cruzcloud.net/.../ecommerce-frontend:v1.0.102
|
||||||
|
uses a tag, not a digest, to identify the image to sign.
|
||||||
|
This can lead you to sign a different image than the intended one.
|
||||||
|
```
|
||||||
|
|
||||||
|
**TODO:** capturar el digest exacto que devuelve `docker push` (o
|
||||||
|
`docker buildx build --metadata-file`) y firmar/verificar contra ese
|
||||||
|
digest en vez del tag —
|
||||||
|
`gitea.cruzcloud.net/devops/ecommerce-frontend@sha256:...` en vez de
|
||||||
|
`:v1.0.104`. No implementado todavía; requiere ajustar el step de
|
||||||
|
build para exponer el digest como output y pasarlo a los steps de
|
||||||
|
firma y verificación.
|
||||||
|
|
||||||
|
### 2. El transparency log (Rekor) está deshabilitado
|
||||||
|
|
||||||
|
Ya se explicó arriba, en la sección "Por qué `--tlog-upload=false`" de
|
||||||
|
esta misma página, la decisión consciente de no publicar en el
|
||||||
|
transparency log para este registry privado. Vale la pena nombrar en
|
||||||
|
simple qué es lo que se está dejando afuera:
|
||||||
|
|
||||||
|
Un **transparency log** (Rekor, el de Sigstore) es un registro
|
||||||
|
público, append-only, criptográficamente verificable, de "quién firmó
|
||||||
|
qué imagen y cuándo" — pensalo como un libro contable público que
|
||||||
|
nadie puede editar ni borrar después de escrito, solo agregar filas
|
||||||
|
nuevas. Cualquiera puede consultar ese libro para confirmar de forma
|
||||||
|
independiente (sin confiar en el propio proyecto, ni en la llave
|
||||||
|
privada, ni en el registry) que una firma específica existió en un
|
||||||
|
momento específico.
|
||||||
|
|
||||||
|
Sin transparency log, la garantía que queda es más débil: *"esta firma
|
||||||
|
la generó quien tuviera la llave privada en el momento en que se
|
||||||
|
verificó"* — pero no hay ningún registro externo e inmutable que
|
||||||
|
demuestre *cuándo* se generó, ni una forma de detectar si alguien con
|
||||||
|
acceso a la llave privada firmó algo por fuera del pipeline sin que
|
||||||
|
quede rastro. Para un lab personal con un registry privado, ese
|
||||||
|
trade-off es razonable (ver la advertencia más abajo en esta misma
|
||||||
|
página). Pero es una limitación real, no solo un detalle de
|
||||||
|
configuración — importa especialmente el día que este mismo patrón se
|
||||||
|
use en un contexto con más de una persona firmando, o con un registry
|
||||||
|
que deje de ser privado.
|
||||||
|
|
||||||
|
**TODO:** si en algún momento el registry deja de ser exclusivamente
|
||||||
|
privado, o se suma más de una persona con acceso a la llave de firma,
|
||||||
|
reevaluar habilitar el transparency log público de Sigstore (o correr
|
||||||
|
uno privado propio) en vez de mantenerlo deshabilitado.
|
||||||
|
|
||||||
|
## 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,119 @@
|
|||||||
|
# DevSecOps
|
||||||
|
|
||||||
|
Fase 2 del lab: integrar tooling de seguridad al pipeline de Gitea
|
||||||
|
Actions. Se empezó por un solo repo de referencia — el frontend de ARI
|
||||||
|
Shopping (`workloads/ecommerce`, `.gitea/workflows/build.yaml`) — y ya
|
||||||
|
está replicado, con evidencia real de corridas en verde, a los tres
|
||||||
|
componentes del monorepo:
|
||||||
|
|
||||||
|
| App | Workflow | Imagen | Adaptación de Semgrep |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Frontend (Next.js) | `build.yaml` | `ecommerce-frontend` | `p/typescript` + `p/react` + `p/nextjs` + `p/security-audit` |
|
||||||
|
| Backend (Medusa v2, API TypeScript) | `build-medusa.yaml` | `ecommerce-medusa` | `p/typescript` + `p/security-audit` + `p/owasp-top-ten` (sin React/Next.js — es una API, no SSR) |
|
||||||
|
| Docs Portal (MkDocs, Python) | `deploy-docs.yaml` | `docs-portal` | `p/python` + `p/security-audit` (sin código de aplicación propio hoy) |
|
||||||
|
|
||||||
|
Cada app tiene su propia llave pública de Cosign commiteada
|
||||||
|
(`cosign.pub` en su propio directorio), pero las tres comparten el
|
||||||
|
mismo par de llaves de firma (mismos secrets `COSIGN_PRIVATE_KEY` /
|
||||||
|
`COSIGN_PASSWORD` a nivel de repo) — una sola identidad de firma para
|
||||||
|
todo el registry de este lab.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
El mismo patrón corre, con el mismo orden de etapas, en los tres
|
||||||
|
workflows (`build.yaml`, `build-medusa.yaml`, `deploy-docs.yaml`) — lo
|
||||||
|
que cambia entre ellos es el ruleset de Semgrep y qué manifiesto(s)
|
||||||
|
escanea Trivy IaC (ver tabla arriba), no la estructura del pipeline.
|
||||||
|
|
||||||
|
```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/>(manifiesto K8s de la app)"]
|
||||||
|
B1 -.informa.-> B2["Semgrep SAST<br/>(ruleset por stack, 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 manifiesto de la app<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.
|
||||||
|
- Firmar por digest en vez de por tag, y reevaluar el transparency log
|
||||||
|
de Sigstore — ver
|
||||||
|
[limitaciones conocidas de Cosign](cosign.md#limitaciones-conocidas-y-mejoras-futuras).
|
||||||
|
- `commerce-backend` tiene dos CVE CRITICAL documentados como excepción
|
||||||
|
puntual en `workloads/commerce-backend/.trivyignore` (Go stdlib
|
||||||
|
embebido en el binario de esbuild, sin fix compatible con la versión
|
||||||
|
de Vite que usa el admin-sdk de Medusa hoy) — revisar en cada bump de
|
||||||
|
`@medusajs/*` si ya deja de ser necesaria.
|
||||||
|
- Contención de recursos entre Gitea y su runner bajo carga real —
|
||||||
|
causa raíz confirmada, fix propuesto y documentado, aplicación
|
||||||
|
manual pendiente (ver
|
||||||
|
[playbook de contención de recursos](../playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md)).
|
||||||
@@ -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,87 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
**Actualización 2026-08-15:** sí fue recurrente — ver
|
||||||
|
[contención de recursos entre Gitea y su runner](incidente-contencion-recursos-gitea-runner-2026-08.md)
|
||||||
|
para la causa raíz confirmada (ninguno de los dos contenedores
|
||||||
|
tiene límites de recursos reales) y la propuesta de fix.
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
# Incidente: contención de recursos entre Gitea y su runner (2026-08)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Ventana del incidente** | 2026-08-15, durante el primer intento real de correr el pipeline de seguridad completo en `commerce-backend` |
|
||||||
|
| **Servicio afectado** | `gitea` y `gitea-runner` (mismo host ZimaOS) |
|
||||||
|
| **Impacto** | Un job de CI en curso (build pesado de Medusa + Trivy) fue cancelado a mitad de camino porque ambos contenedores se reiniciaron solos |
|
||||||
|
|
||||||
|
## Contexto
|
||||||
|
|
||||||
|
Este hallazgo estaba **anotado como pendiente** en el playbook del
|
||||||
|
[504 del túnel](incidente-504-gitea-tunnel.md): una ventana de latencia
|
||||||
|
alta y algunos 502/530 habían coincidido, en su momento, con una
|
||||||
|
ejecución pesada de Gitea Actions — anotado como "contención de
|
||||||
|
recursos local, no relacionado al fix del túnel, vigilar si es
|
||||||
|
recurrente". Al triplicar la carga agregando el mismo pipeline de
|
||||||
|
seguridad a `commerce-backend` y `docs-portal`, se volvió a ver en
|
||||||
|
vivo, esta vez con evidencia suficiente para confirmar la causa.
|
||||||
|
|
||||||
|
## Síntoma
|
||||||
|
|
||||||
|
Un run de `build-medusa.yaml` (con Gitleaks ya en verde, build de
|
||||||
|
Docker en curso) quedó cancelado a mitad de camino. El log del runner
|
||||||
|
mostró, en la misma ventana de un par de minutos:
|
||||||
|
|
||||||
|
```text
|
||||||
|
level=error msg="failed to fetch task" error="unavailable: 502 Bad Gateway"
|
||||||
|
level=info msg="runner: ... shutdown initiated, waiting 0s for running jobs to complete before shutting down"
|
||||||
|
level=warning msg="runner: ... cancelled in progress jobs during shutdown"
|
||||||
|
level=info msg="Starting runner daemon"
|
||||||
|
```
|
||||||
|
|
||||||
|
El mismo patrón se repitió una segunda vez pocos minutos después. En
|
||||||
|
paralelo, `docker ps` mostró que el contenedor `gitea` también se
|
||||||
|
había reiniciado casi al mismo tiempo.
|
||||||
|
|
||||||
|
## Diagnóstico
|
||||||
|
|
||||||
|
`docker inspect` sobre ambos contenedores en el momento del incidente
|
||||||
|
mostró:
|
||||||
|
|
||||||
|
- `ExitCode=0` y `RestartCount=0` en los dos — **no fue un crash ni un
|
||||||
|
OOM-kill** (eso dejaría un `ExitCode` distinto de 0 o
|
||||||
|
`OOMKilled=true`, y el conteo de reinicios de la política de Docker
|
||||||
|
subiría). El `StartedAt` cambió igual, lo que indica un reinicio
|
||||||
|
limpio disparado por algo externo al propio proceso — muy
|
||||||
|
probablemente el supervisor de apps de CasaOS (`zimaos-app-management`),
|
||||||
|
aunque no se pudo confirmar por logs (`/var/log/casaos/log.log` es
|
||||||
|
de acceso root-only, sin sudo sin password disponible en esta
|
||||||
|
sesión).
|
||||||
|
- Memoria del host en el momento del incidente: **~1.3 GB libres de
|
||||||
|
15 GB totales**, con ~2 GB de swap en uso — el host estaba bajo
|
||||||
|
presión real de memoria, no solo de CPU.
|
||||||
|
- Ningún límite de recursos real en ninguno de los dos contenedores
|
||||||
|
(ver detalle abajo).
|
||||||
|
|
||||||
|
## Causa raíz
|
||||||
|
|
||||||
|
Ni `gitea` ni `gitea-runner` tienen aislamiento de recursos real
|
||||||
|
frente al resto del host:
|
||||||
|
|
||||||
|
| | `gitea` | `gitea-runner` |
|
||||||
|
|---|---|---|
|
||||||
|
| Límite de memoria | ~15.5 GB (≈ toda la RAM del host, no es un límite real) | **ninguno** |
|
||||||
|
| Límite de CPU (cuota dura) | ninguno | ninguno |
|
||||||
|
| Peso relativo de CPU (`cpu-shares`) | **90** (muy por debajo del default de Docker, 1024) | 0 → default Docker (**1024**) |
|
||||||
|
|
||||||
|
Con `gitea` en 90 y `gitea-runner` en el default de 1024, bajo
|
||||||
|
contención de CPU **el runner tiene ~11x más prioridad que el propio
|
||||||
|
servidor Gitea** — lo opuesto a lo deseable, ya que Gitea es el
|
||||||
|
servicio que necesita seguir respondiendo peticiones HTTP (incluidas
|
||||||
|
las del propio runner haciendo polling) mientras un build pesado corre.
|
||||||
|
|
||||||
|
Sumado a que `gitea-runner` no tiene techo de memoria: un job pesado
|
||||||
|
(build de Docker de una imagen con `node_modules` grande + escaneo de
|
||||||
|
Trivy con vuln + secret scanning) puede consumir memoria sin límite,
|
||||||
|
empujando al host entero a swap y dejando lento/no-responsivo a todo
|
||||||
|
lo demás — incluida la propia base de datos SQLite de Gitea (ver
|
||||||
|
[incidente de SQLite en modo DELETE](incidente-sqlite-modo-delete-2026-08.md),
|
||||||
|
que probablemente reaparezca con más frecuencia bajo este mismo tipo
|
||||||
|
de presión, aunque WAL reduce el impacto).
|
||||||
|
|
||||||
|
`gitea-runner` tampoco corre gestionado por el mismo `docker-compose.yml`
|
||||||
|
de Gitea — se crea con un `docker run` suelto desde
|
||||||
|
`scripts/deploy-lab.sh` (`create_gitea_runner_container()`), montando
|
||||||
|
`/var/run/docker.sock` directo (sibling containers, ver
|
||||||
|
[incidente de Docker-in-Docker](incidente-docker-in-docker-semgrep-2026-08.md)).
|
||||||
|
Esto no es la causa del incidente de recursos, pero significa que
|
||||||
|
cualquier ajuste de límites tiene que aplicarse en dos lugares
|
||||||
|
distintos: el `docker-compose.yml` de `gitea` y el script que crea
|
||||||
|
`gitea-runner`.
|
||||||
|
|
||||||
|
!!! info "La concurrencia del runner ya estaba bien"
|
||||||
|
`capacity: 1` en el `config.yaml` del runner ya limita la
|
||||||
|
ejecución a **un solo job a la vez** — no es un problema de
|
||||||
|
"demasiados jobs en paralelo". El problema es que incluso un solo
|
||||||
|
job pesado, sin ningún límite de recursos, puede acaparar
|
||||||
|
suficiente CPU/memoria como para dejar sin aire al resto del host.
|
||||||
|
|
||||||
|
## Fix propuesto (documentado, aplicación manual pendiente)
|
||||||
|
|
||||||
|
1. **`scripts/deploy-lab.sh`** (rama `fix/gitea-runner-resource-limits`,
|
||||||
|
pendiente de mergear): agrega `--cpu-shares 512 --memory 8g
|
||||||
|
--memory-swap 10g` a la creación de `gitea-runner`. Solo afecta a la
|
||||||
|
próxima vez que se cree el contenedor (la función es idempotente),
|
||||||
|
no al que ya está corriendo.
|
||||||
|
2. **Fix inmediato en caliente** (sin recrear contenedores), a aplicar
|
||||||
|
manualmente por fuera de este repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker update --cpu-shares 1024 gitea
|
||||||
|
docker update --cpu-shares 512 --memory 8g --memory-swap 10g gitea-runner
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **`docker-compose.yml` de Gitea** (fuera de este repo, acceso
|
||||||
|
root-only): subir `cpu_shares` de `gitea` de 90 a 1024 — pendiente
|
||||||
|
de aplicar manualmente, no se implementó todavía.
|
||||||
|
4. **Mover `gitea-runner` a k3d** como job/pod separado: evaluado como
|
||||||
|
posible mejora de fondo (aislamiento real vía Kubernetes en vez de
|
||||||
|
límites de Docker sueltos), pero queda **solo como recomendación
|
||||||
|
documentada** — no implementado en esta vuelta.
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
Con `gitea` y `gitea-runner` estables (sin reinicios) después del
|
||||||
|
incidente, se reintentó el mismo pipeline y corrió de punta a punta
|
||||||
|
sin interrupciones — pero eso confirma que el host se estabilizó
|
||||||
|
después del pico de carga, **no** que el fix de límites de recursos ya
|
||||||
|
esté aplicado (sigue pendiente, ver arriba). La correlación completa
|
||||||
|
carga-pesada → reinicio ya se había visto antes, en dos fechas
|
||||||
|
distintas (2026-07-30 y 2026-08-11) además de esta — no es un evento
|
||||||
|
aislado, es un patrón recurrente que ahora tiene una causa raíz
|
||||||
|
concreta y una propuesta de fix.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Incidente: el ingress del túnel rompió específicamente a Cosign, no al resto del pipeline (2026-08)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Ventana del incidente** | 2026-08-15, primer intento de firmar una imagen con Cosign en `build.yaml` |
|
||||||
|
| **Servicio afectado** | Cadena Cloudflare Tunnel → NPM (Nginx Proxy Manager) → Gitea, específicamente el endpoint del registry de contenedores |
|
||||||
|
| **Impacto** | `cosign sign` fallaba siempre, en cada intento — el resto del pipeline (Gitleaks, Trivy, Semgrep, build, push de la imagen) funcionaba normal contra el mismo registry |
|
||||||
|
|
||||||
|
## Síntoma
|
||||||
|
|
||||||
|
Todos los pasos anteriores del pipeline pasaban, incluido `docker push`
|
||||||
|
de la imagen al registry de Gitea. El paso de Cosign, inmediatamente
|
||||||
|
después, fallaba siempre con:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Error: signing [gitea.cruzcloud.net/***/ecommerce-frontend:v1.0.102]:
|
||||||
|
accessing entity: invalid realm in www-authenticate: realm scheme
|
||||||
|
"http" not allowed for a secure registry; use https
|
||||||
|
```
|
||||||
|
|
||||||
|
Lo llamativo: **`docker push` a ese mismo registry, un paso antes,
|
||||||
|
funcionaba sin problema.** Si el registry estuviera mal configurado en
|
||||||
|
general, se esperaría que fallara también el push, no solo la firma.
|
||||||
|
|
||||||
|
## Diagnóstico
|
||||||
|
|
||||||
|
El mensaje da la pista exacta: al autenticar contra el registry,
|
||||||
|
Cosign recibe un header `WWW-Authenticate` cuyo campo `realm` apunta a
|
||||||
|
una URL con esquema `http://` en vez de `https://`. Cosign rechaza
|
||||||
|
explícitamente seguir un realm `http` contra lo que él considera "un
|
||||||
|
registry seguro" (un dominio público, no `localhost`) — es una
|
||||||
|
protección intencional del lado de Cosign, no un bug.
|
||||||
|
|
||||||
|
`docker push`/`docker login` son más permisivos con esto (o cachean la
|
||||||
|
sesión de otra forma) y no chocan con el mismo problema, por eso solo
|
||||||
|
Cosign lo mostraba.
|
||||||
|
|
||||||
|
## Causa raíz
|
||||||
|
|
||||||
|
El realm que arma Gitea en su respuesta `WWW-Authenticate` depende de
|
||||||
|
que Gitea sepa correctamente que la petición le llegó por HTTPS
|
||||||
|
(típicamente vía el header `X-Forwarded-Proto` o el Server Name que ve
|
||||||
|
en la conexión TLS que termina antes de llegar a él). La cadena real
|
||||||
|
acá es **Cloudflare Tunnel → NPM → Gitea** — si el proxy host de NPM
|
||||||
|
para `gitea.cruzcloud.net` no tiene el *Origin Server Name* (SNI hacia
|
||||||
|
el origen) configurado correctamente, Gitea puede terminar creyendo
|
||||||
|
que la conexión entrante fue por HTTP plano, y arma el realm con ese
|
||||||
|
esquema equivocado.
|
||||||
|
|
||||||
|
## Fix aplicado
|
||||||
|
|
||||||
|
Corrección de la regla de ingress de `cloudflared`/NPM para
|
||||||
|
`gitea.cruzcloud.net`: HTTPS hacia el origen con el *Origin Server
|
||||||
|
Name* correcto. El fix fue enteramente de infraestructura — no hubo
|
||||||
|
ningún cambio en este repo más allá de un commit vacío para volver a
|
||||||
|
disparar el pipeline y confirmar el fix contra credenciales reales.
|
||||||
|
|
||||||
|
## La misma causa raíz de fondo, dos síntomas distintos
|
||||||
|
|
||||||
|
Este incidente **no es el mismo bug** que el
|
||||||
|
[504 Gateway Timeout intermitente del túnel](incidente-504-gitea-tunnel.md)
|
||||||
|
ya documentado — ese fue inestabilidad de QUIC/UDP en `cloudflared`,
|
||||||
|
resuelto forzando `TUNNEL_TRANSPORT_PROTOCOL=http2`. Pero **ambos
|
||||||
|
viven en la misma capa**: la cadena de ingress
|
||||||
|
Cloudflare Tunnel → NPM → Gitea, mal configurada de dos formas
|
||||||
|
distintas, descubiertas en momentos distintos:
|
||||||
|
|
||||||
|
- Una regla de **transporte** (QUIC vs HTTP/2) causando 504s
|
||||||
|
intermitentes en *cualquier* petición HTTP a los dominios detrás del
|
||||||
|
túnel.
|
||||||
|
- Una regla de **SNI/origin server name** causando que Gitea generara
|
||||||
|
un realm HTTP incorrecto, rompiendo específicamente a un cliente
|
||||||
|
(Cosign) que valida ese detalle de forma estricta.
|
||||||
|
|
||||||
|
La lección compartida: en una cadena de proxies con TLS terminando en
|
||||||
|
varios saltos, un solo campo de configuración mal puesto en cualquiera
|
||||||
|
de los saltos puede manifestarse como fallas completamente distintas
|
||||||
|
según qué tan estricto sea el cliente al otro lado — la mayoría de las
|
||||||
|
herramientas HTTP normales ni lo notan, pero una herramienta de
|
||||||
|
seguridad como Cosign, que valida explícitamente el esquema del
|
||||||
|
realm, sí.
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
Confirmado en una corrida real posterior al fix: `cosign sign` y
|
||||||
|
`cosign verify` completaron sin error contra el registry real, con
|
||||||
|
credenciales reales, de punta a punta.
|
||||||
|
|
||||||
|
!!! note "Nota relacionada, no parte de este incidente"
|
||||||
|
El mismo log de este fallo mostró, además del error de realm, el
|
||||||
|
warning esperado de Cosign sobre firmar por tag en vez de por
|
||||||
|
digest — ver
|
||||||
|
[limitaciones conocidas de Cosign](../devsecops/cosign.md#limitaciones-conocidas-y-mejoras-futuras).
|
||||||
@@ -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,102 @@
|
|||||||
|
# Incidente: Docker-in-Docker roto en el runner — por qué Semgrep corre nativo (2026-08)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Ventana del incidente** | 2026-08-15, primer intento de correr Semgrep como container aparte en `build.yaml` |
|
||||||
|
| **Servicio afectado** | Gitea Actions runner (`gitea-runner`, imagen `act_runner`) |
|
||||||
|
| **Impacto** | Cualquier step que intentara `docker run -v "$(pwd):/algo"` desde dentro de un job fallaba con `read-only file system` o rutas inexistentes |
|
||||||
|
|
||||||
|
## Docker-in-Docker vs. sibling containers, para quien recién arranca en CI/CD
|
||||||
|
|
||||||
|
Cuando un job de CI necesita usar Docker (por ejemplo, para correr una
|
||||||
|
herramienta empaquetada como imagen), hay dos formas distintas de
|
||||||
|
dárselo, y confundirlas rompe cosas de maneras difíciles de
|
||||||
|
diagnosticar:
|
||||||
|
|
||||||
|
- **Docker-in-Docker (DinD):** el contenedor del job tiene su **propio**
|
||||||
|
daemon Docker corriendo adentro, completamente aislado del daemon
|
||||||
|
del host. Cuando el job hace `docker run`, ese contenedor nuevo nace
|
||||||
|
*dentro* del contenedor del job, como una muñeca rusa. El
|
||||||
|
filesystem que ve ese daemon interno es el del contenedor del job,
|
||||||
|
no el del host real.
|
||||||
|
- **Sibling containers (contenedores hermanos):** el contenedor del
|
||||||
|
job **no** tiene su propio daemon — en cambio, monta el socket del
|
||||||
|
daemon Docker del **host** (`/var/run/docker.sock`) y habla
|
||||||
|
directamente con él. Cuando el job hace `docker run`, ese contenedor
|
||||||
|
nuevo nace como **hermano** del contenedor del job (mismo nivel,
|
||||||
|
mismo host, mismo daemon), no adentro suyo.
|
||||||
|
|
||||||
|
La imagen `act_runner` que usa este runner de Gitea Actions usa el
|
||||||
|
segundo modelo: **sibling containers**, montando el socket del daemon
|
||||||
|
del host (ver `create_gitea_runner_container()` en
|
||||||
|
`scripts/deploy-lab.sh`, que monta
|
||||||
|
`-v /var/run/docker.sock:/var/run/docker.sock`).
|
||||||
|
|
||||||
|
## Por qué eso rompe un `docker run -v` ingenuo
|
||||||
|
|
||||||
|
Con sibling containers, cuando un step dentro de un job pide
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -v "${{ github.workspace }}:/src" alguna-imagen
|
||||||
|
```
|
||||||
|
|
||||||
|
ese comando lo ejecuta el **daemon del host**, no el contenedor del
|
||||||
|
job. `${{ github.workspace }}` es una ruta que existe *dentro* del
|
||||||
|
contenedor del job (por ejemplo `/workspace/devops/apps-registry`) —
|
||||||
|
pero el daemon del host, al crear el contenedor hermano, intenta
|
||||||
|
montar esa misma ruta **desde el filesystem del host real**, donde
|
||||||
|
probablemente no existe (o existe otra cosa completamente distinta ahí).
|
||||||
|
|
||||||
|
## Síntoma
|
||||||
|
|
||||||
|
Al intentar correr Semgrep como container aparte (`docker run` desde
|
||||||
|
dentro del step), el error observado en el run real fue:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mkdir /workspace: read-only file system
|
||||||
|
```
|
||||||
|
|
||||||
|
No un "no existe la ruta" limpio — un intento de crear el directorio
|
||||||
|
que falla porque, en el contexto real del daemon del host, esa ruta
|
||||||
|
cae en un punto de montaje de solo lectura (o simplemente no es un
|
||||||
|
lugar donde el daemon del host puede/debe escribir).
|
||||||
|
|
||||||
|
## Causa raíz
|
||||||
|
|
||||||
|
Sibling containers, no Docker-in-Docker: un `docker run -v
|
||||||
|
"${{ github.workspace }}:/src"` ejecutado desde dentro de un job
|
||||||
|
intenta montar, en el daemon del **host**, una ruta que solo tiene
|
||||||
|
sentido **dentro** del contenedor del job. El daemon del host no ve
|
||||||
|
esa ruta como el mismo directorio — la ve como una ruta arbitraria de
|
||||||
|
su propio filesystem.
|
||||||
|
|
||||||
|
## Fix aplicado
|
||||||
|
|
||||||
|
Se evita el problema de raíz: **Semgrep corre nativo**, instalado
|
||||||
|
directo en el contenedor del job (mismo criterio ya usado para
|
||||||
|
Gitleaks, Trivy, Syft y Cosign — todos se instalan como binario/paquete
|
||||||
|
dentro del job, ninguno corre como container aparte):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv /tmp/semgrep-venv
|
||||||
|
/tmp/semgrep-venv/bin/pip install --quiet "semgrep==1.173.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Por qué un venv y no `pip3 install` directo
|
||||||
|
|
||||||
|
La imagen del runner ya trae paquetes de Python instalados por `apt`
|
||||||
|
(por ejemplo `PyJWT`) sin metadata compatible con `pip`. Un
|
||||||
|
`pip3 install --break-system-packages` sobre esa base falla al
|
||||||
|
intentar reemplazarlos (`RECORD file not found`) porque `pip` no
|
||||||
|
encuentra el registro de archivos que necesita para saber qué está
|
||||||
|
reemplazando. Un venv aislado evita tocar los paquetes del sistema por
|
||||||
|
completo — Semgrep y sus dependencias quedan en su propio directorio,
|
||||||
|
sin interferir con nada que `apt` ya haya instalado.
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
Validado localmente contra `docker.gitea.com/runner-images:ubuntu-latest`
|
||||||
|
(la misma imagen que usa el runner real) que `python3`/`pip3` están
|
||||||
|
disponibles, y confirmado en una corrida real posterior al fix que
|
||||||
|
Semgrep corre y reporta hallazgos sin ningún error de Docker de por
|
||||||
|
medio.
|
||||||
@@ -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,103 @@
|
|||||||
|
# Incidente: SQLite en modo DELETE causando contención en Gitea (2026-08)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Ventana del incidente** | 2026-08-15, coincidiendo con el arranque del runner de Gitea Actions y el polling frecuente de tareas |
|
||||||
|
| **Servicio afectado** | `gitea` (base de datos SQLite propia, `gitea.db`) |
|
||||||
|
| **Impacto** | Errores intermitentes `database is locked (SQLITE_BUSY)` al escribir desde distintos componentes de Gitea (runner, web, cron) al mismo tiempo |
|
||||||
|
|
||||||
|
## Contexto: por qué Gitea usa SQLite acá
|
||||||
|
|
||||||
|
Este lab corre Gitea con `DB_TYPE = sqlite3` (no Postgres/MySQL) — una
|
||||||
|
sola base de datos, un solo archivo (`gitea.db`), sin servidor de base
|
||||||
|
de datos aparte. Es la opción correcta para un lab de un solo nodo,
|
||||||
|
pero SQLite tiene una particularidad importante: por defecto abre el
|
||||||
|
archivo en **modo DELETE** (el modo "clásico" de journaling).
|
||||||
|
|
||||||
|
## Qué es el modo DELETE y por qué molesta acá
|
||||||
|
|
||||||
|
En modo DELETE, cada transacción de escritura:
|
||||||
|
|
||||||
|
1. Crea un archivo de journal temporal (`gitea.db-journal`).
|
||||||
|
2. Toma un **lock exclusivo sobre el archivo completo** de la base de
|
||||||
|
datos mientras dura la escritura.
|
||||||
|
3. Borra el journal al terminar.
|
||||||
|
|
||||||
|
Ese lock exclusivo es el problema: **mientras una escritura está en
|
||||||
|
curso, nada más puede leer ni escribir** — ni siquiera otro proceso
|
||||||
|
que solo quiere leer una fila que no tiene nada que ver con la que se
|
||||||
|
está escribiendo.
|
||||||
|
|
||||||
|
!!! info "Por qué esto pega justo en Gitea Actions"
|
||||||
|
El runner de Gitea Actions hace *polling* constante contra la API
|
||||||
|
de Gitea (cada pocos segundos, según `fetch_interval` en su
|
||||||
|
`config.yaml`) para preguntar "¿hay una tarea nueva?", y además
|
||||||
|
escribe actualizaciones de estado de cada step casi en tiempo real
|
||||||
|
mientras un job corre. Sumale los cron jobs internos de Gitea y el
|
||||||
|
tráfico normal de la web UI. Con la base en modo DELETE, todo eso
|
||||||
|
compite por el mismo lock exclusivo — cuantas más cosas escriben
|
||||||
|
seguido, más chance de pisarse.
|
||||||
|
|
||||||
|
## Síntoma
|
||||||
|
|
||||||
|
`SQLITE_BUSY` intermitente en los logs de Gitea, típicamente en
|
||||||
|
operaciones de corta duración (actualizar el estado de un step,
|
||||||
|
registrar un heartbeat del runner) que no deberían tener motivo para
|
||||||
|
fallar por contención:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[E] can't update runner status: database is locked (5) (SQLITE_BUSY)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Causa raíz
|
||||||
|
|
||||||
|
Modo de journaling `DELETE` (el default de SQLite si no se configura
|
||||||
|
nada distinto) combinado con el patrón de acceso de Gitea Actions:
|
||||||
|
muchas escrituras cortas y frecuentes desde procesos distintos
|
||||||
|
(runner, servidor web, cron), cada una compitiendo por un lock
|
||||||
|
exclusivo de archivo completo.
|
||||||
|
|
||||||
|
## Fix aplicado
|
||||||
|
|
||||||
|
Se cambió el modo de journaling a **WAL** (Write-Ahead Logging) en
|
||||||
|
`app.ini`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[database]
|
||||||
|
; journal_mode por defecto (DELETE) toma un lock exclusivo del archivo
|
||||||
|
; completo en cada escritura. WAL permite lectores concurrentes mientras
|
||||||
|
; hay una escritura en curso -- fix para "database is locked" bajo el
|
||||||
|
; polling frecuente de Actions + cron jobs (diagnosticado 2026-08-15).
|
||||||
|
SQLITE_JOURNAL_MODE = WAL
|
||||||
|
```
|
||||||
|
|
||||||
|
### Por qué WAL resuelve esto (y qué NO resuelve)
|
||||||
|
|
||||||
|
En modo WAL, las escrituras no se aplican directo al archivo principal
|
||||||
|
de la base: se van agregando a un archivo aparte (`gitea.db-wal`), y
|
||||||
|
los lectores siguen leyendo del estado consistente más reciente sin
|
||||||
|
bloquearse. La regla cambia de *"una escritura bloquea todo"* a
|
||||||
|
*"una escritura bloquea solo a otra escritura"* — los lectores dejan
|
||||||
|
de competir por el lock.
|
||||||
|
|
||||||
|
!!! warning "WAL reduce la contención, no la elimina"
|
||||||
|
WAL sigue permitiendo **una sola escritura a la vez** — dos
|
||||||
|
escrituras concurrentes todavía pueden chocar y devolver
|
||||||
|
`SQLITE_BUSY` si el proceso no reintenta. En una corrida de este
|
||||||
|
mismo playbook se vio, después de aplicado el fix, una única
|
||||||
|
ocurrencia aislada de `database is locked` coincidiendo con un
|
||||||
|
reinicio del contenedor de `gitea` (ver
|
||||||
|
[contención de recursos de Gitea/runner](incidente-contencion-recursos-gitea-runner-2026-08.md)) —
|
||||||
|
consistente con una escritura en curso justo en el momento del
|
||||||
|
reinicio, no con que WAL no esté funcionando. Frecuencia bajó de
|
||||||
|
"molesta con cierta regularidad" a "un caso aislado en varias
|
||||||
|
horas de uso intensivo".
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
El cambio se aplicó directo en el `app.ini` montado como volumen del
|
||||||
|
contenedor de `gitea` (no en un `docker exec` puntual) — persiste
|
||||||
|
across reinicios del contenedor, confirmado leyendo el archivo montado
|
||||||
|
después de un reinicio real del contenedor (ver
|
||||||
|
[contención de recursos](incidente-contencion-recursos-gitea-runner-2026-08.md)),
|
||||||
|
no solo aplicado en caliente y perdido al reciclar el contenedor.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# Incidente: anchors YAML no soportados por Gitea Actions — y un diagnóstico equivocado en el camino (2026-08)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Ventana del incidente** | 2026-08-15, desde el primer commit que agregó Gitleaks a `build.yaml` |
|
||||||
|
| **Servicio afectado** | Gitea Actions — el workflow `build.yaml` (frontend) |
|
||||||
|
| **Impacto** | `build.yaml` no generaba **ningún** `action_run`, ni en `push` ni en `pull_request` — el pipeline entero era invisible para Gitea, sin ningún error visible en la UI |
|
||||||
|
|
||||||
|
## Qué es un anchor/alias YAML, para quien no lo conoce
|
||||||
|
|
||||||
|
YAML tiene una forma de evitar repetir el mismo bloque dos veces:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
paths: &frontend_paths
|
||||||
|
- 'workloads/ecommerce/**'
|
||||||
|
- '.gitea/workflows/build.yaml'
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths: *frontend_paths
|
||||||
|
pull_request:
|
||||||
|
paths: *frontend_paths
|
||||||
|
```
|
||||||
|
|
||||||
|
`&frontend_paths` es un **anchor** (marca ese nodo con un nombre).
|
||||||
|
`*frontend_paths` es un **alias** (dice "poné acá una copia de lo que
|
||||||
|
marcó ese anchor"). Es YAML 100% estándar — cualquier parser que siga
|
||||||
|
la especificación lo resuelve sin problema, y es una forma común de no
|
||||||
|
duplicar listas idénticas en el mismo archivo.
|
||||||
|
|
||||||
|
## Síntoma
|
||||||
|
|
||||||
|
`build.yaml` (con Gitleaks recién agregado, usando anchors para no
|
||||||
|
repetir la lista de `paths` entre `push:` y `pull_request:`) no
|
||||||
|
generaba ningún run en Gitea Actions. Ni en el push directo a la rama
|
||||||
|
del PR, ni al abrir el pull request. Sin mensaje de error visible en
|
||||||
|
la UI de Gitea — el workflow simplemente no aparecía en la lista de
|
||||||
|
Actions, como si no existiera.
|
||||||
|
|
||||||
|
## Primer diagnóstico (equivocado)
|
||||||
|
|
||||||
|
La primera sospecha fue una indentación rota específicamente en el
|
||||||
|
step de Semgrep: el bloque `python3 -c "..."` embebido dentro de un
|
||||||
|
`run: |` tenía código pegado a columna 0, por debajo de la
|
||||||
|
indentación esperada del block scalar de YAML. Eso *sí* era un
|
||||||
|
problema real — cortaba el block scalar ahí mismo y en teoría podía
|
||||||
|
hacer que Gitea rechazara el archivo con un error de parseo genérico
|
||||||
|
(`could not find expected ':'`).
|
||||||
|
|
||||||
|
Se corrigió la indentación, se validó con un parser YAML real y
|
||||||
|
`bash -n` sobre los 20 steps del archivo, se mergeó a `main` como fix
|
||||||
|
(`fix(ci): corregir indentación YAML rota en el step de Semgrep`, PR
|
||||||
|
#19)... y `build.yaml` **siguió sin generar runs**.
|
||||||
|
|
||||||
|
!!! warning "Por qué vale la pena contar el diagnóstico que no era"
|
||||||
|
El primer fix no estaba mal — la indentación efectivamente estaba
|
||||||
|
rota y corregirla era necesario. El error fue asumir que esa era
|
||||||
|
*la única* causa sin confirmarlo con una corrida real después del
|
||||||
|
fix. La lección de proceso: un fix que "tiene sentido" y que pasa
|
||||||
|
la validación local (parser YAML, `bash -n`) todavía necesita una
|
||||||
|
confirmación en vivo contra el sistema real antes de darlo por
|
||||||
|
cerrado — sobre todo cuando el síntoma es "no pasa nada" en vez de
|
||||||
|
un error explícito, que es exactamente el tipo de fallo más fácil
|
||||||
|
de dar por resuelto sin verificar.
|
||||||
|
|
||||||
|
## Causa raíz real
|
||||||
|
|
||||||
|
`deploy-docs.yaml` (sin anchors) corría normal. `build.yaml` (con
|
||||||
|
anchors, agregados junto con Gitleaks) no generaba ningún run — ni
|
||||||
|
en `push` ni en `pull_request`, desde el primer commit que los
|
||||||
|
introdujo. El log del propio Gitea lo confirmó:
|
||||||
|
|
||||||
|
```text
|
||||||
|
unknown on type: &yaml.Node{...Value:"frontend_paths"...}
|
||||||
|
```
|
||||||
|
|
||||||
|
El parser propio de Gitea Actions para el bloque `on:` de un workflow
|
||||||
|
**no resuelve `&anchor`/`*alias` antes de inspeccionar el tipo de
|
||||||
|
nodo** — encuentra un anchor donde esperaba un valor ya resuelto, no
|
||||||
|
sabe qué tipo de dato es, y descarta el archivo completo en
|
||||||
|
silencio. `pyyaml` y cualquier parser YAML estándar sí resuelven el
|
||||||
|
alias primero (por eso el archivo "parseaba bien" en cualquier
|
||||||
|
herramienta de validación genérica) — es una limitación específica del
|
||||||
|
parser de workflows de Gitea Actions, no un YAML inválido.
|
||||||
|
|
||||||
|
Esto explica por qué el síntoma no tenía ningún error visible: no es
|
||||||
|
que el job fallara, es que **Gitea nunca llegaba a registrar el
|
||||||
|
workflow como existente**.
|
||||||
|
|
||||||
|
## Fix aplicado
|
||||||
|
|
||||||
|
Se eliminaron los anchors/alias. Las dos listas de `paths` (una para
|
||||||
|
`push:`, otra para `pull_request:`) quedan **duplicadas
|
||||||
|
literalmente**, sin referencia compartida:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'workloads/ecommerce/**'
|
||||||
|
- '.gitea/workflows/build.yaml'
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'workloads/ecommerce/**'
|
||||||
|
- '.gitea/workflows/build.yaml'
|
||||||
|
```
|
||||||
|
|
||||||
|
Es más repetitivo, pero es el precio de que el parser de Gitea
|
||||||
|
Actions lo entienda. Este mismo criterio (sin anchors, paths
|
||||||
|
duplicados) se replicó a propósito en `build-medusa.yaml` y
|
||||||
|
`deploy-docs.yaml` al agregarles el resto del pipeline de seguridad,
|
||||||
|
para no repetir el mismo problema.
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
Confirmado en vivo: tras mergear el fix, `build.yaml` generó su
|
||||||
|
primer `action_run` real. Validado también con un parser YAML +
|
||||||
|
`bash -n` sobre los 20 steps del archivo, igual que en el intento
|
||||||
|
anterior — la diferencia esta vez fue confirmarlo contra una corrida
|
||||||
|
real antes de cerrar el incidente.
|
||||||
@@ -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,117 @@
|
|||||||
|
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
|
||||||
|
- SQLite modo DELETE: playbooks/incidente-sqlite-modo-delete-2026-08.md
|
||||||
|
- Anchors YAML en Gitea Actions: playbooks/incidente-yaml-anchors-gitea-actions-2026-08.md
|
||||||
|
- Docker-in-Docker y Semgrep: playbooks/incidente-docker-in-docker-semgrep-2026-08.md
|
||||||
|
- Realm HTTP y Cosign: playbooks/incidente-cosign-realm-http-tunnel-2026-08.md
|
||||||
|
- Contención de recursos Gitea/runner: playbooks/incidente-contencion-recursos-gitea-runner-2026-08.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' \
|
'cap_net_bind_service=+ep' \
|
||||||
/usr/local/bin/node
|
/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 \
|
COPY --from=builder \
|
||||||
--chown=nextjs:nodejs \
|
--chown=nextjs:nodejs \
|
||||||
/app/public \
|
/app/public \
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ spec:
|
|||||||
memory: 64Mi
|
memory: 64Mi
|
||||||
|
|
||||||
- name: migrations
|
- name: migrations
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.90
|
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.108
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- npx
|
- npx
|
||||||
@@ -73,7 +73,7 @@ spec:
|
|||||||
|
|
||||||
containers:
|
containers:
|
||||||
- name: medusa
|
- name: medusa
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.90
|
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.108
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
envFrom:
|
envFrom:
|
||||||
- configMapRef:
|
- 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
|
- name: gitea-registry-secret
|
||||||
containers:
|
containers:
|
||||||
- name: web
|
- name: web
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.91
|
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.104
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 80
|
- containerPort: 80
|
||||||
env:
|
env:
|
||||||
|
|||||||
Reference in New Issue
Block a user