Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bcae76102 | ||
|
|
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 |
+284
-4
@@ -1,6 +1,14 @@
|
||||
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:
|
||||
# NOTA: sin anchors/aliases de YAML (&x / *x) a propósito -- el parser
|
||||
# de workflows de Gitea Actions no los resuelve en el bloque "on:" y
|
||||
# descarta el archivo completo con "unknown on type" (visto en vivo el
|
||||
# 2026-08-15). Las dos listas de paths quedan duplicadas literalmente.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -29,14 +37,90 @@ on:
|
||||
- 'workloads/ecommerce/public/**'
|
||||
- 'workloads/ecommerce/styles/**'
|
||||
- '.gitea/workflows/build.yaml'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'workloads/ecommerce/Dockerfile'
|
||||
- 'workloads/ecommerce/.dockerignore'
|
||||
- 'workloads/ecommerce/.npmrc'
|
||||
- 'workloads/ecommerce/package.json'
|
||||
- 'workloads/ecommerce/package-lock.json'
|
||||
- 'workloads/ecommerce/next.config.*'
|
||||
- 'workloads/ecommerce/tsconfig.json'
|
||||
- 'workloads/ecommerce/tailwind.config.*'
|
||||
- 'workloads/ecommerce/postcss.config.*'
|
||||
- 'workloads/ecommerce/eslint.config.*'
|
||||
- 'workloads/ecommerce/*.js'
|
||||
- 'workloads/ecommerce/*.mjs'
|
||||
- 'workloads/ecommerce/*.ts'
|
||||
- 'workloads/ecommerce/*.tsx'
|
||||
- 'workloads/ecommerce/app/**'
|
||||
- 'workloads/ecommerce/src/**'
|
||||
- 'workloads/ecommerce/components/**'
|
||||
- 'workloads/ecommerce/lib/**'
|
||||
- 'workloads/ecommerce/public/**'
|
||||
- 'workloads/ecommerce/styles/**'
|
||||
- '.gitea/workflows/build.yaml'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
# Corre en push y en pull_request, siempre antes que build. Si encuentra
|
||||
# un secreto commiteado, el step termina con exit code distinto de 0 y,
|
||||
# por el "needs" del job build, la imagen nunca se construye ni se sube.
|
||||
gitleaks:
|
||||
name: Escaneo de Secretos (Gitleaks)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout del código
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Instalar Gitleaks
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
GITLEAKS_VERSION="8.21.2"
|
||||
curl -sSfL \
|
||||
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
|
||||
-o /tmp/gitleaks.tar.gz
|
||||
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
|
||||
chmod +x /tmp/gitleaks
|
||||
/tmp/gitleaks version
|
||||
|
||||
# --no-git: escanea el árbol de archivos del checkout (fetch-depth: 1,
|
||||
# sin historial), no el log de commits. El scan histórico completo del
|
||||
# repo se corre aparte, manualmente, no en cada push/PR.
|
||||
- name: Escanear secretos en el árbol de archivos
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
/tmp/gitleaks detect \
|
||||
--source=workloads/ecommerce \
|
||||
--no-git \
|
||||
--redact \
|
||||
--report-format=json \
|
||||
--report-path=gitleaks-report.json \
|
||||
--exit-code=1
|
||||
|
||||
- name: Publicar reporte de Gitleaks
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: gitleaks-report
|
||||
path: gitleaks-report.json
|
||||
if-no-files-found: ignore
|
||||
|
||||
build:
|
||||
name: Construir y Subir Imagen
|
||||
needs: gitleaks
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
@@ -180,6 +264,87 @@ jobs:
|
||||
|
||||
echo "OK: navegación real del catálogo validada."
|
||||
|
||||
- name: Instalar Trivy
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TRIVY_VERSION="0.74.0"
|
||||
curl -sSfL \
|
||||
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \
|
||||
-o /tmp/trivy.tar.gz
|
||||
tar -xzf /tmp/trivy.tar.gz -C /tmp trivy
|
||||
chmod +x /tmp/trivy
|
||||
/tmp/trivy version
|
||||
|
||||
# Escanea el manifiesto Kubernetes real del frontend (no la imagen):
|
||||
# contenedores como root, falta de resource limits, falta de
|
||||
# readiness/liveness probes, etc. Informativo por ahora — no bloquea
|
||||
# el pipeline mientras revisamos juntos qué hallazgos son reales.
|
||||
- name: Escanear manifiestos Kubernetes (Trivy IaC)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
/tmp/trivy config \
|
||||
--severity CRITICAL,HIGH,MEDIUM \
|
||||
--exit-code 0 \
|
||||
"${MANIFEST_FILE}"
|
||||
|
||||
# Instalado directo con pip (no como container aparte): el runner de
|
||||
# Gitea ejecuta cada job ya dentro de un contenedor propio que habla
|
||||
# con el daemon Docker del host (sibling containers) -- un
|
||||
# `docker run -v "${{ github.workspace }}:/src"` desde acá intenta
|
||||
# montar una ruta que solo existe DENTRO del contenedor del job, no
|
||||
# en el host real, y falla con "read-only file system". Evitamos por
|
||||
# completo el problema corriendo Semgrep nativo, igual que
|
||||
# gitleaks/trivy/syft/cosign.
|
||||
# venv en vez de "pip3 install --break-system-packages": la imagen del
|
||||
# runner ya trae paquetes de Python instalados por apt (ej. PyJWT)
|
||||
# sin metadata compatible con pip, y pip aborta al intentar
|
||||
# reemplazarlos ("RECORD file not found"). Un venv aislado evita
|
||||
# tocar los paquetes del sistema por completo.
|
||||
- name: Instalar Semgrep
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m venv /tmp/semgrep-venv
|
||||
/tmp/semgrep-venv/bin/pip install --quiet "semgrep==1.173.0"
|
||||
/tmp/semgrep-venv/bin/semgrep --version
|
||||
|
||||
# Modo auditoría: sin --error a propósito, Semgrep siempre termina
|
||||
# con exit 0 aunque reporte hallazgos. Es la primera vuelta — se
|
||||
# revisan los resultados en conjunto antes de decidir qué reglas
|
||||
# deberían pasar a bloquear el pipeline más adelante.
|
||||
- name: Escaneo SAST (Semgrep) — modo auditoría, no bloquea
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
/tmp/semgrep-venv/bin/semgrep scan \
|
||||
--config=p/typescript \
|
||||
--config=p/react \
|
||||
--config=p/nextjs \
|
||||
--config=p/security-audit \
|
||||
--json \
|
||||
--output=semgrep-report.json \
|
||||
"${APP_DIR}"
|
||||
|
||||
echo "=== Resumen Semgrep ==="
|
||||
/tmp/semgrep-venv/bin/python -c "
|
||||
import json
|
||||
data = json.load(open('semgrep-report.json'))
|
||||
results = data.get('results', [])
|
||||
print(f'Hallazgos: {len(results)}')
|
||||
for r in results:
|
||||
print(f\" [{r['extra']['severity']}] {r['check_id']} - {r['path']}:{r['start']['line']}\")
|
||||
"
|
||||
|
||||
- name: Publicar reporte de Semgrep
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: semgrep-report
|
||||
path: semgrep-report.json
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Login en Gitea Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -188,14 +353,129 @@ jobs:
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
logout: true
|
||||
|
||||
- name: Construir y Subir Imagen
|
||||
# push: false — la imagen se queda cargada en el daemon local (load:
|
||||
# true) para poder escanearla con Trivy antes de subirla al registry.
|
||||
- name: Construir Imagen
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: workloads/ecommerce/
|
||||
push: true
|
||||
push: false
|
||||
load: true
|
||||
tags: |
|
||||
gitea.cruzcloud.net/devops/ecommerce-frontend:${{ steps.vars.outputs.VERSION }}
|
||||
gitea.cruzcloud.net/devops/ecommerce-frontend:latest
|
||||
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }}
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
# CRITICAL bloquea el pipeline: no se sube una imagen con una CVE
|
||||
# crítica conocida y con fix disponible.
|
||||
- name: Escanear imagen (Trivy) — CRITICAL bloquea
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
/tmp/trivy image \
|
||||
--severity CRITICAL \
|
||||
--exit-code 1 \
|
||||
--ignore-unfixed \
|
||||
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||
|
||||
# HIGH solo informa por ahora — no bloquea mientras aprendemos a leer
|
||||
# los reportes y decidimos, con calma, qué reglas deben bloquear.
|
||||
- name: Escanear imagen (Trivy) — HIGH informativo
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
/tmp/trivy image \
|
||||
--severity HIGH \
|
||||
--exit-code 0 \
|
||||
--ignore-unfixed \
|
||||
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||
|
||||
# SBOM de la imagen que ya pasó el gate de CRITICAL — describe
|
||||
# exactamente qué paquetes (y en qué versión) quedaron adentro.
|
||||
# CycloneDX: formato más usado por herramientas de consulta/alertas
|
||||
# de CVEs (ej. cruzar el SBOM contra un aviso nuevo tipo log4shell).
|
||||
- name: Instalar Syft
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SYFT_VERSION="1.51.0"
|
||||
curl -sSfL \
|
||||
"https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" \
|
||||
-o /tmp/syft.tar.gz
|
||||
tar -xzf /tmp/syft.tar.gz -C /tmp syft
|
||||
chmod +x /tmp/syft
|
||||
/tmp/syft version
|
||||
|
||||
- name: Generar SBOM (Syft)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
/tmp/syft "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" \
|
||||
-o cyclonedx-json=sbom.cdx.json \
|
||||
-o spdx-json=sbom.spdx.json
|
||||
|
||||
- name: Publicar SBOM
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: sbom-${{ steps.vars.outputs.VERSION }}
|
||||
path: |
|
||||
sbom.cdx.json
|
||||
sbom.spdx.json
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Login ya se hizo arriba; recién acá se sube, después de que la
|
||||
# imagen pasó el gate de CRITICAL.
|
||||
- name: Subir Imagen al Registry
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||
docker push "${IMAGE_NAME}:latest"
|
||||
|
||||
- name: Instalar Cosign
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
COSIGN_VERSION="3.1.3"
|
||||
curl -sSfL \
|
||||
"https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-amd64" \
|
||||
-o /tmp/cosign
|
||||
chmod +x /tmp/cosign
|
||||
/tmp/cosign version
|
||||
|
||||
# Firma solo la versión (no :latest, que es un tag mutable y firmarlo
|
||||
# pierde sentido en cuanto se vuelve a mover). --use-signing-config=false
|
||||
# --tlog-upload=false: firma solo con el par de llaves propio, sin
|
||||
# publicar metadata en el transparency log público de Sigstore — este
|
||||
# registry es privado, no tiene sentido anunciar públicamente qué se
|
||||
# firmó y cuándo.
|
||||
- name: Firmar Imagen (Cosign)
|
||||
shell: bash
|
||||
env:
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
/tmp/cosign sign \
|
||||
--key env://COSIGN_PRIVATE_KEY \
|
||||
--use-signing-config=false \
|
||||
--tlog-upload=false \
|
||||
--yes \
|
||||
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||
|
||||
# Smoke test: confirma en el propio pipeline que la firma que se
|
||||
# acaba de crear valida contra la llave pública commiteada en el
|
||||
# repo (workloads/ecommerce/cosign.pub). Es la misma verificación
|
||||
# que, más adelante, podría correr un admission controller en el
|
||||
# cluster antes de dejar desplegar la imagen (ver docs/devsecops/cosign.md).
|
||||
- name: Verificar Firma (smoke test)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
/tmp/cosign verify \
|
||||
--key "${APP_DIR}/cosign.pub" \
|
||||
--insecure-ignore-tlog=true \
|
||||
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
|
||||
|
||||
# Evita que una ejecución antigua actualice frontend.yaml después de que
|
||||
# ya exista un commit más reciente en main.
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
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.
|
||||
- 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 — 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 \
|
||||
"${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
|
||||
@@ -0,0 +1,9 @@
|
||||
site/
|
||||
.git/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
deployment.yaml
|
||||
service.yaml
|
||||
ingress.yaml
|
||||
kustomization.yaml
|
||||
README.md
|
||||
@@ -0,0 +1,21 @@
|
||||
# --- Stage 1: build del sitio estático con MkDocs Material ---
|
||||
FROM python:3.12-slim AS build
|
||||
|
||||
WORKDIR /site
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY mkdocs.yml .
|
||||
COPY docs/ docs/
|
||||
|
||||
# --strict: cualquier link roto o warning de nav rompe el build,
|
||||
# para no publicar nunca un sitio con referencias muertas.
|
||||
RUN mkdocs build --strict
|
||||
|
||||
# --- Stage 2: sirve el sitio estático generado con nginx ---
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY --from=build /site/site /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,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.98
|
||||
ports:
|
||||
- containerPort: 80
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 128Mi
|
||||
@@ -0,0 +1,33 @@
|
||||
# Notas sueltas / aprendizajes
|
||||
|
||||
> **TODO (fase 2+):** esto es un scratchpad estructurado, no un
|
||||
> playbook — va creciendo con notas cortas a medida que aparecen. Las
|
||||
> entradas de abajo son semillas reales extraídas de incidentes ya
|
||||
> documentados; expandir cada una con más contexto en fases siguientes.
|
||||
|
||||
## Qué haría distinto
|
||||
|
||||
- **Fijar `TUNNEL_TRANSPORT_PROTOCOL=http2` desde el día uno**, no
|
||||
después de un incidente — QUIC/UDP sobre NAT doméstico fue un problema
|
||||
predecible en retrospectiva. Ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md).
|
||||
- **Separar siempre "chequeo de disponibilidad" de "migraciones"** en
|
||||
init containers distintos, y que el chequeo use el mínimo de
|
||||
parámetros posible (host/puerto/usuario/db) en vez de reusar una URI
|
||||
de conexión pensada para la app. Confirmado como decisión correcta en
|
||||
el [incidente de crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md).
|
||||
|
||||
## Gotchas de Medusa v2
|
||||
|
||||
- El Store API (`/store/products`, validado por `StoreGetProductsParams`,
|
||||
`.strict()`) **no acepta** `currency_code` ni `country_code` para
|
||||
resolver precios calculados — solo `region_id` explícito. Ver
|
||||
[playbook de precios](../playbooks/incidente-precios-medusa.md).
|
||||
- El campo `thumbnail` de un producto **no se autocompleta** a partir
|
||||
del array `images` — hay que setearlo explícito desde el Admin si el
|
||||
frontend lo usa como imagen hero en listados.
|
||||
|
||||
## TODO
|
||||
|
||||
- Notas sobre MinIO / S3 provider.
|
||||
- Notas sobre el subscriber `revalidate-storefront.ts` y sus límites.
|
||||
- Qué automatizaría si volviera a montar este lab desde cero.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Del commit al despliegue: flujo GitOps
|
||||
|
||||
> **TODO (fase 2+):** narrar cada paso con ejemplos reales tomados de
|
||||
> `.gitea/workflows/build-medusa.yaml` y `build.yaml`. El diagrama de
|
||||
> secuencia de abajo refleja el pipeline real (build → push imagen →
|
||||
> promoción segura → commit del tag → sync de Argo CD).
|
||||
|
||||
## Secuencia: un commit hasta quedar corriendo en el cluster
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor Dev as Devops
|
||||
participant Gitea
|
||||
participant CI as Gitea Actions
|
||||
participant Reg as Gitea Registry
|
||||
participant Argo as Argo CD
|
||||
participant K8s as Cluster k3d
|
||||
|
||||
Dev->>Gitea: push a fix/<rama>
|
||||
Dev->>Gitea: abre PR → main
|
||||
Gitea->>Gitea: merge a main
|
||||
Gitea->>CI: dispara workflow (paths: workloads/**)
|
||||
CI->>CI: build de la imagen (Docker)
|
||||
CI->>Reg: push imagen vX.Y.Z
|
||||
CI->>CI: verifica promoción segura (HEAD == origin/main)
|
||||
CI->>Gitea: commit "chore(gitops): release vX.Y.Z" actualizando el manifiesto
|
||||
Argo->>Gitea: detecta el nuevo commit (poll/webhook)
|
||||
Argo->>K8s: sync (prune:true, selfHeal:true)
|
||||
K8s->>K8s: rollout del nuevo pod
|
||||
```
|
||||
|
||||
## Por qué esta separación (build vs. deploy)
|
||||
|
||||
- El **pipeline de CI** solo construye y publica la imagen — nunca
|
||||
aplica nada directo al cluster.
|
||||
- El **commit al manifiesto** (`sed` sobre el tag de imagen) es lo único
|
||||
que representa "intención de desplegar" — y vive en el mismo repo Git
|
||||
que Argo CD vigila.
|
||||
- **Argo CD** es la única pieza con permisos de escritura sobre el
|
||||
cluster real. Nada fuera de GitOps toca `kubectl apply` en producción.
|
||||
- El paso de "promoción segura" (comparar `HEAD` contra `origin/main`)
|
||||
evita que un pipeline viejo sobreescriba el tag con una versión
|
||||
anterior si hubo un push más reciente mientras corría.
|
||||
|
||||
## TODO
|
||||
|
||||
- Ejemplo real con hashes de commit (tomar del historial de
|
||||
`apps-registry`).
|
||||
- Explicar `syncPolicy.automated.selfHeal` con un caso concreto (qué
|
||||
pasa si alguien hace `kubectl edit` a mano).
|
||||
@@ -0,0 +1,73 @@
|
||||
# Glosario
|
||||
|
||||
> Pensado para alguien que ve estos términos por primera vez. Definiciones
|
||||
> cortas primero, detalle técnico después. Se irá expandiendo en fases
|
||||
> siguientes — por ahora cubre los términos que ya aparecen en el resto
|
||||
> del sitio.
|
||||
|
||||
## GitOps
|
||||
|
||||
**En una frase:** el estado deseado del sistema vive en Git, y un
|
||||
proceso automático (no una persona con `kubectl`) se encarga de que el
|
||||
cluster real coincida con lo que dice Git.
|
||||
|
||||
**Detalle:** en vez de ejecutar comandos contra el cluster a mano, se
|
||||
edita un archivo YAML, se hace commit, y una herramienta (aquí, Argo CD)
|
||||
detecta el cambio y lo aplica. Si alguien cambia algo directo en el
|
||||
cluster sin pasar por Git, GitOps lo revierte (`selfHeal`) — Git es la
|
||||
única fuente de verdad.
|
||||
|
||||
## Tunnel (Cloudflare Tunnel)
|
||||
|
||||
**En una frase:** una forma de exponer un servicio interno a internet
|
||||
sin abrir puertos en el router/firewall.
|
||||
|
||||
**Detalle:** un proceso (`cloudflared`) corre dentro de la red local y
|
||||
abre una conexión saliente hacia Cloudflare. El tráfico público llega a
|
||||
Cloudflare y viaja hacia adentro por esa misma conexión — no hace falta
|
||||
port-forwarding ni IP pública propia.
|
||||
|
||||
## Argo CD
|
||||
|
||||
**En una frase:** el "robot" que sincroniza el cluster Kubernetes con
|
||||
lo que está escrito en Git.
|
||||
|
||||
**Detalle:** vigila uno o más repos Git, compara el estado declarado
|
||||
(manifiestos YAML/Kustomize) contra el estado real del cluster, y
|
||||
aplica la diferencia. Ver [flujo GitOps](gitops-flujo.md).
|
||||
|
||||
## Manifest (manifiesto)
|
||||
|
||||
**En una frase:** un archivo YAML que describe qué quieres que exista
|
||||
en Kubernetes (un Deployment, un Service, un Ingress...).
|
||||
|
||||
**Detalle:** Kubernetes no se controla escribiendo código imperativo
|
||||
("crea un pod"), sino declarando el resultado deseado ("quiero 2
|
||||
réplicas de esta imagen corriendo"). El manifiesto es ese documento.
|
||||
|
||||
## App of Apps
|
||||
|
||||
**En una frase:** un patrón donde una Application de Argo CD no
|
||||
despliega una app directamente, sino que despliega *otras*
|
||||
Applications.
|
||||
|
||||
**Detalle:** en este lab, `application.yaml` (raíz) vigila la carpeta
|
||||
`apps/` de `apps-registry`; cada archivo ahí dentro es una Application
|
||||
hija que a su vez apunta a un workload real. Permite agregar una app
|
||||
nueva con un solo archivo YAML nuevo.
|
||||
|
||||
## Kustomize
|
||||
|
||||
**En una frase:** una forma de componer manifiestos de Kubernetes sin
|
||||
plantillas (a diferencia de Helm).
|
||||
|
||||
**Detalle:** un archivo `kustomization.yaml` lista qué recursos YAML
|
||||
incluir y qué parches aplicarles, sin necesidad de un lenguaje de
|
||||
templating separado.
|
||||
|
||||
## TODO
|
||||
|
||||
- Namespace, ResourceQuota, LimitRange, NetworkPolicy
|
||||
- Ingress vs. Service vs. Deployment
|
||||
- StatefulSet (por qué Postgres lo usa y el frontend no)
|
||||
- Init container
|
||||
@@ -0,0 +1,25 @@
|
||||
# Red y exposición
|
||||
|
||||
> **TODO (fase 2+):** explicar cada dominio, por qué está proxied vs
|
||||
> DNS-only en Cloudflare, y el manejo de TLS extremo a extremo. Tabla
|
||||
> de dominios abajo es real, pendiente de expandir con capturas/ejemplos.
|
||||
|
||||
## Dominios activos
|
||||
|
||||
| Dominio | Servicio | Namespace | TLS |
|
||||
|---|---|---|---|
|
||||
| `shop.cruzcloud.net` | Frontend Next.js (storefront) | `ecommerce` | Cloudflare (edge) |
|
||||
| `commerce.cruzcloud.net` | Medusa Store/Admin API | `ecommerce` | Cloudflare (edge) |
|
||||
| `media.cruzcloud.net` | MinIO (imágenes de producto) | `ecommerce` | Cloudflare (edge) |
|
||||
| `gitea.cruzcloud.net` | Gitea | — | Cloudflare (edge) |
|
||||
| `docs.cruzcloud.net` | Este portal | `docs` | Cloudflare (edge) |
|
||||
|
||||
## TODO
|
||||
|
||||
- Diagrama de TLS termination (dónde termina TLS realmente: en
|
||||
Cloudflare, no en Traefik — el tráfico interno NPM→k3d es HTTP plano).
|
||||
- Explicar por qué el transporte del túnel se fijó a HTTP/2 en vez de
|
||||
QUIC (ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)
|
||||
y el [playbook del 504](../playbooks/incidente-504-gitea-tunnel.md)).
|
||||
- Patrón de diagnóstico "interno sano, público roto" (ver
|
||||
[playbook de imágenes NPM](../playbooks/incidente-imagenes-npm.md)).
|
||||
@@ -0,0 +1,43 @@
|
||||
# Visión general
|
||||
|
||||
> **TODO (fase 2+):** narrativa completa explicando cada capa del
|
||||
> diagrama, con foco en el "por qué" de cada frontera de confianza. El
|
||||
> diagrama de abajo es real (validado contra `docs/known-issues.md` y
|
||||
> los playbooks de incidentes de `apps-registry`), pero el texto que lo
|
||||
> acompaña es solo el esqueleto.
|
||||
|
||||
## Flujo de una petición pública
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User(["Usuario"]) -->|HTTPS| CF["Cloudflare<br/>(DNS proxied + túnel)"]
|
||||
CF -->|HTTP/2, red host| CFD["cloudflared<br/>(app ZimaOS)"]
|
||||
CFD --> NPM["Nginx Proxy Manager<br/>192.168.68.61:90"]
|
||||
NPM --> LB["k3d-lab-cluster-serverlb<br/>(k3d-proxy, 90→80)"]
|
||||
LB --> TR["Traefik<br/>(ingress del cluster)"]
|
||||
TR -->|Host: shop.cruzcloud.net| FE["frontend-svc<br/>(Next.js)"]
|
||||
TR -->|Host: commerce.cruzcloud.net| MED["medusa-svc<br/>(Medusa API)"]
|
||||
TR -->|Host: media.cruzcloud.net| MIN["minio-svc<br/>(imágenes de producto)"]
|
||||
TR -->|Host: gitea.cruzcloud.net| GIT["Gitea"]
|
||||
TR -->|Host: docs.cruzcloud.net| DOCS["docs-portal-svc<br/>(este sitio)"]
|
||||
|
||||
subgraph GitOps["Control plane GitOps"]
|
||||
ARGO["Argo CD"] -->|sync| FE
|
||||
ARGO -->|sync| MED
|
||||
ARGO -->|sync| DOCS
|
||||
GIT -.->|watch repos| ARGO
|
||||
end
|
||||
```
|
||||
|
||||
## Componentes
|
||||
|
||||
| Capa | Qué es | Dónde vive |
|
||||
|---|---|---|
|
||||
| Cloudflare Tunnel | Expone `*.cruzcloud.net` a internet sin abrir puertos en el NAS | Fuera del cluster, app nativa ZimaOS (`cloudflared`) |
|
||||
| Nginx Proxy Manager | Enruta por dominio hacia el cluster | Fuera del cluster, contenedor `nginxproxymanager` |
|
||||
| k3d | Cluster Kubernetes local (multi-nodo, Docker) | Host ZimaOS |
|
||||
| Traefik | Ingress controller del cluster | Dentro de k3d |
|
||||
| Argo CD | Sincroniza el estado del cluster con Git (patrón App of Apps) | Namespace `argocd` |
|
||||
| Gitea | Origen de verdad de los manifiestos y del código de las apps | Contenedor propio, expuesto vía túnel |
|
||||
|
||||
Ver también: [Red y exposición](red-y-exposicion.md) · [Flujo GitOps](gitops-flujo.md) · [Glosario](glosario.md)
|
||||
@@ -0,0 +1,33 @@
|
||||
# ADR 0001 — Por qué k3d (y no k3s directo, minikube, o un cluster cloud)
|
||||
|
||||
> **TODO (fase 2+):** completar contexto/opciones/consecuencias con
|
||||
> criterio real (recursos del NAS ZimaOS, necesidad de multi-nodo para
|
||||
> reproducir el blackhole de MTU documentado en `known-issues.md`,
|
||||
> costo cero vs. cluster gestionado).
|
||||
|
||||
**Estado:** aceptada (placeholder — pendiente de redactar)
|
||||
**Fecha:** TODO
|
||||
|
||||
## Contexto
|
||||
|
||||
TODO
|
||||
|
||||
## Opciones consideradas
|
||||
|
||||
| Opción | A favor | En contra |
|
||||
|---|---|---|
|
||||
| k3d | | |
|
||||
| k3s directo (sin Docker) | | |
|
||||
| minikube | | |
|
||||
| Cluster gestionado (cloud) | | |
|
||||
|
||||
## Decisión
|
||||
|
||||
TODO
|
||||
|
||||
## Consecuencias
|
||||
|
||||
TODO — mencionar explícitamente el blackhole de red cross-node
|
||||
(MTU/PMTUD) como consecuencia real y documentada de correr multi-nodo
|
||||
sobre Docker-in-Docker sin ajuste de MTU (ver
|
||||
[playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md)).
|
||||
@@ -0,0 +1,30 @@
|
||||
# ADR 0002 — Por qué Argo CD (y no `kubectl apply` manual o un script de CI)
|
||||
|
||||
> **TODO (fase 2+):** completar con el criterio real detrás de elegir
|
||||
> reconciliación continua (`selfHeal`) sobre despliegue imperativo desde
|
||||
> CI.
|
||||
|
||||
**Estado:** aceptada (placeholder — pendiente de redactar)
|
||||
**Fecha:** TODO
|
||||
|
||||
## Contexto
|
||||
|
||||
TODO
|
||||
|
||||
## Opciones consideradas
|
||||
|
||||
| Opción | A favor | En contra |
|
||||
|---|---|---|
|
||||
| Argo CD (GitOps, pull-based) | | |
|
||||
| `kubectl apply` manual | | |
|
||||
| `kubectl apply` desde el pipeline de CI (push-based) | | |
|
||||
|
||||
## Decisión
|
||||
|
||||
TODO
|
||||
|
||||
## Consecuencias
|
||||
|
||||
TODO — mencionar el patrón App of Apps (`application.yaml` +
|
||||
`apps/*.yaml`) y cómo `selfHeal:true` cambia el modelo mental de
|
||||
"quién puede tocar el cluster".
|
||||
@@ -0,0 +1,44 @@
|
||||
# ADR 0003 — Por qué HTTP/2 sobre QUIC para el transporte del túnel de Cloudflare
|
||||
|
||||
**Estado:** aceptada
|
||||
**Fecha:** 2026-08 (ventana del incidente documentado abajo)
|
||||
|
||||
> **TODO (fase 2+):** expandir la narrativa; el contexto/decisión ya
|
||||
> están fundamentados en un incidente real, solo falta pulir la
|
||||
> redacción y agregar los datos de validación completos.
|
||||
|
||||
## Contexto
|
||||
|
||||
`cloudflared` (el proceso que abre el túnel hacia Cloudflare) usaba
|
||||
QUIC (UDP) como protocolo de transporte por defecto para sus 4
|
||||
conexiones hacia el edge de Cloudflare. Una de esas conexiones sufría
|
||||
fallos recurrentes de "control stream" cada 2-6 minutos, causando
|
||||
`504 Gateway Timeout` intermitentes en **todos** los subdominios detrás
|
||||
del túnel (Gitea, Argo CD, la tienda, etc.), no solo uno.
|
||||
|
||||
Diagnóstico completo en el
|
||||
[playbook del incidente](../playbooks/incidente-504-gitea-tunnel.md).
|
||||
|
||||
## Opciones consideradas
|
||||
|
||||
| Opción | A favor | En contra |
|
||||
|---|---|---|
|
||||
| QUIC (default) | Menor latencia teórica, multiplexing sin head-of-line blocking | Sensible a NAT/firewall doméstico; causaba el 504 intermitente real |
|
||||
| HTTP/2 sobre TCP | Estable en redes domésticas con NAT agresivo | Ligeramente más latencia teórica que QUIC |
|
||||
|
||||
## Decisión
|
||||
|
||||
Forzar `TUNNEL_TRANSPORT_PROTOCOL=http2` en la configuración del
|
||||
contenedor `cloudflared`, en vez de dejar el default (QUIC/UDP).
|
||||
|
||||
## Consecuencias
|
||||
|
||||
- Se eliminaron los `504` intermitentes — validado con curl en loop
|
||||
contra `gitea.cruzcloud.net`: ~0.37–0.4s de latencia estable por
|
||||
24+ minutos sin un solo error.
|
||||
- Se renuncia a la ventaja teórica de latencia de QUIC, aceptable en un
|
||||
lab doméstico donde la estabilidad importa más que microsegundos.
|
||||
- Si en el futuro cambia el hardware de red (router, NAT) podría valer
|
||||
la pena revisar si QUIC vuelve a ser viable — no hay una razón de
|
||||
fondo para excluirlo para siempre, solo evidencia de que falló en
|
||||
esta red concreta.
|
||||
@@ -0,0 +1,31 @@
|
||||
# ADR NNNN — Título corto de la decisión
|
||||
|
||||
> Copiar este archivo como `NNNN-titulo-corto.md` (siguiente número
|
||||
> disponible) para cada decisión arquitectónica nueva.
|
||||
|
||||
**Estado:** propuesta | aceptada | reemplazada por ADR-XXXX
|
||||
**Fecha:** AAAA-MM-DD
|
||||
|
||||
## Contexto
|
||||
|
||||
¿Qué problema forzó esta decisión? ¿Qué restricciones reales existían
|
||||
(hardware del NAS, presupuesto cero, tiempo disponible, conocimiento
|
||||
previo)? Sin justificar todavía la elección — solo la situación.
|
||||
|
||||
## Opciones consideradas
|
||||
|
||||
| Opción | A favor | En contra |
|
||||
|---|---|---|
|
||||
| Opción A | | |
|
||||
| Opción B | | |
|
||||
| Opción C | | |
|
||||
|
||||
## Decisión
|
||||
|
||||
Qué se eligió, en una o dos frases.
|
||||
|
||||
## Consecuencias
|
||||
|
||||
- Qué se gana
|
||||
- Qué se sacrifica o queda como deuda técnica
|
||||
- Qué tendría que pasar para revertir esta decisión
|
||||
@@ -0,0 +1,133 @@
|
||||
# Cosign — firma de imágenes
|
||||
|
||||
!!! info "Qué problema resuelve"
|
||||
Todo lo anterior en esta sección (Gitleaks, Trivy, Semgrep, SBOM)
|
||||
responde a la pregunta *"¿esta imagen es segura de construir?"*.
|
||||
Cosign responde una pregunta distinta, y que pasa **después**: *"la
|
||||
imagen que está corriendo ahora mismo en el cluster, ¿es
|
||||
exactamente la que armó el pipeline — o pudo haber sido reemplazada,
|
||||
modificada, o subida por otra vía?"*.
|
||||
|
||||
## Analogía simple
|
||||
|
||||
Pensalo como el sello de cera en un sobre antiguo. Cualquiera puede leer
|
||||
la carta (la imagen es pública, cualquiera puede bajarla del registry) —
|
||||
eso Cosign no lo esconde. Lo que el sello garantiza es otra cosa: que la
|
||||
carta salió exactamente de donde dice que salió, y que nadie la abrió y
|
||||
volvió a cerrar en el camino.
|
||||
|
||||
- **La llave privada** (guardada como secret de Gitea, nunca en el repo)
|
||||
es el sello físico — solo el pipeline de CI puede estampar una firma
|
||||
válida, porque solo él tiene el sello.
|
||||
- **La llave pública** (`workloads/ecommerce/cosign.pub`, commiteada sin
|
||||
problema — es pública a propósito) es la forma de reconocer el sello:
|
||||
cualquiera puede mirar la carta, ver el sello, y confirmar "sí, esto lo
|
||||
selló quien tiene la llave privada" — sin necesitar la llave privada
|
||||
para verificarlo.
|
||||
|
||||
Si alguien sube una imagen distinta con el mismo tag, o modifica un solo
|
||||
byte de la imagen original, la firma deja de coincidir. No es que Cosign
|
||||
"detecte" la alteración activamente — es que la verificación
|
||||
simplemente falla, porque la firma fue calculada sobre el digest exacto
|
||||
de la imagen original.
|
||||
|
||||
## Cómo funciona en este pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["docker push"] --> B["cosign sign<br/>(llave privada, secret)"]
|
||||
B --> C["cosign verify<br/>(llave pública, repo)"]
|
||||
C -- "firma válida" --> D["✅ pipeline termina OK"]
|
||||
C -- "firma inválida/ausente" --> X["❌ pipeline falla"]
|
||||
```
|
||||
|
||||
1. **Par de llaves**: generado una vez con `cosign generate-key-pair`,
|
||||
protegido por password. La privada (`cosign.key`) se subió como
|
||||
secret de Gitea Actions (`COSIGN_PRIVATE_KEY` + `COSIGN_PASSWORD`) —
|
||||
nunca se commiteó al repo, ni existe en el disco de este equipo
|
||||
después de subirla. La pública (`cosign.pub`) sí vive commiteada en
|
||||
`workloads/ecommerce/cosign.pub`, porque su función es poder
|
||||
compartirse.
|
||||
2. **Firma**: después de subir la imagen al registry, el pipeline la
|
||||
firma con la llave privada (leída desde el secret vía
|
||||
`--key env://COSIGN_PRIVATE_KEY`, sin escribirla nunca a disco).
|
||||
3. **Verificación (smoke test)**: en el mismo pipeline, inmediatamente
|
||||
después, se verifica la firma recién creada contra la llave pública
|
||||
del repo. Si algo salió mal (llave incorrecta, imagen corrupta), el
|
||||
pipeline falla ahí mismo — antes de que nadie más intente confiar en
|
||||
esa imagen.
|
||||
|
||||
## Por qué `--tlog-upload=false`
|
||||
|
||||
Cosign, por defecto, publica cada firma en el *transparency log* público
|
||||
de Sigstore (Rekor) — un registro público, auditable, de "quién firmó
|
||||
qué y cuándo", pensado para proyectos open source donde esa
|
||||
transparencia es el punto. Este registry (`gitea.cruzcloud.net`) es
|
||||
privado; no tiene sentido — y sería una fuga de metadata innecesaria —
|
||||
anunciar públicamente que este lab construyó una imagen `v1.0.97` en tal
|
||||
fecha. Por eso el pipeline firma solo con el par de llaves propio,
|
||||
localmente, sin tocar el transparency log público
|
||||
(`--use-signing-config=false --tlog-upload=false` al firmar,
|
||||
`--insecure-ignore-tlog=true` al verificar).
|
||||
|
||||
!!! warning "Trade-off consciente, no gratis"
|
||||
Sin transparency log, la garantía es "esta firma la generó quien
|
||||
tiene la llave privada" — pero no hay un registro público e
|
||||
inmutable de *cuándo* se generó cada firma. Para un registry privado
|
||||
de un lab personal, ese trade-off tiene sentido. Para un proyecto
|
||||
open source con más de una persona firmando, seguramente no.
|
||||
|
||||
## Qué NO se firma
|
||||
|
||||
Solo se firma el tag versionado (`ecommerce-frontend:v1.0.X`), no
|
||||
`:latest`. `:latest` es un tag mutable — se re-apunta a una imagen
|
||||
distinta en cada build — así que firmarlo no significa nada útil: la
|
||||
firma quedaría asociada al digest de turno, y la siguiente build la
|
||||
volvería a mover. Cualquier verificación real de firma debería apuntar
|
||||
siempre a un tag de versión específico (o, mejor todavía, al digest
|
||||
exacto).
|
||||
|
||||
## Verificar manualmente
|
||||
|
||||
Con la llave pública del repo, cualquiera puede confirmar la firma de
|
||||
una imagen sin necesitar acceso a nada privado:
|
||||
|
||||
```bash
|
||||
cosign verify \
|
||||
--key workloads/ecommerce/cosign.pub \
|
||||
--insecure-ignore-tlog=true \
|
||||
gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.97
|
||||
```
|
||||
|
||||
Si la imagen fue firmada por este pipeline, el comando termina con
|
||||
`exit 0` y muestra el detalle de la firma. Si no — sea porque nunca se
|
||||
firmó, porque la firmó otra llave, o porque la imagen fue modificada
|
||||
después — termina con `exit 1` y un error explícito.
|
||||
|
||||
## Qué falta (a propósito, todavía)
|
||||
|
||||
Hoy la verificación de firma corre como smoke test **dentro del mismo
|
||||
pipeline que la creó** — útil para confirmar que el mecanismo funciona,
|
||||
pero no impide que alguien despliegue manualmente una imagen sin firmar
|
||||
en el cluster. El siguiente paso natural, que **no** se implementó en
|
||||
esta primera vuelta, sería un *admission controller* en el cluster
|
||||
(ej. [Sigstore's policy-controller](https://docs.sigstore.dev/policy-controller/overview/)
|
||||
o [Kyverno](https://kyverno.io/policies/other/verify-images/verify-images/)
|
||||
con una política de verificación de imágenes) que rechace cualquier Pod
|
||||
cuya imagen no tenga una firma válida de `cosign.pub` — momento en el
|
||||
que Argo CD dejaría de poder desplegar una imagen sin firmar, no solo el
|
||||
pipeline de CI.
|
||||
|
||||
## Si la llave privada se compromete
|
||||
|
||||
1. Generar un par nuevo (`cosign generate-key-pair`).
|
||||
2. Reemplazar `COSIGN_PRIVATE_KEY` y `COSIGN_PASSWORD` en los secrets de
|
||||
Gitea Actions del repo.
|
||||
3. Reemplazar `workloads/ecommerce/cosign.pub` con la nueva llave
|
||||
pública, en un commit normal (no es secreto, no hace falta
|
||||
reescribir historial).
|
||||
4. Las imágenes ya firmadas con la llave vieja **siguen verificando
|
||||
contra la llave vieja** — no se "invalidan" solas. Si se sospecha
|
||||
compromiso real, hay que decidir explícitamente qué imágenes ya
|
||||
desplegadas se consideran no confiables, no asumir que rotar la
|
||||
llave alcanza.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Gitleaks — detección de secretos
|
||||
|
||||
!!! info "Qué problema resuelve"
|
||||
Gitleaks busca patrones de credenciales (API keys, tokens, contraseñas,
|
||||
llaves privadas) dentro del código fuente. No sabe si una credencial es
|
||||
"real" — detecta **formas** que parecen credenciales (una API key de
|
||||
AWS siempre empieza con `AKIA`, una llave privada siempre tiene el
|
||||
encabezado `-----BEGIN PRIVATE KEY-----`, etc.) y también cadenas con
|
||||
entropía alta (aleatoriedad), que suelen ser tokens generados.
|
||||
|
||||
## Por qué esto importa
|
||||
|
||||
Un secreto commiteado a git **nunca deja de estar ahí**, aunque lo borres
|
||||
en el siguiente commit. Sigue existiendo en el historial, en cualquier
|
||||
fork, en cualquier clon local que alguien ya haya hecho. La única manera
|
||||
real de "revocar" un secreto filtrado es rotarlo (generar uno nuevo e
|
||||
invalidar el viejo) — borrar el commit no alcanza.
|
||||
|
||||
Por eso el objetivo de gitleaks no es "arreglar" el secreto después de que
|
||||
se filtró, sino **evitar que el commit con el secreto llegue a existir en
|
||||
el repo remoto**.
|
||||
|
||||
## Por qué corre antes del build
|
||||
|
||||
En `.gitea/workflows/build.yaml`, el job `gitleaks` corre **antes** que el
|
||||
job `build` (que compila la imagen Docker y la sube al registry). El job
|
||||
`build` tiene `needs: gitleaks` — si el scan falla, `build` ni siquiera
|
||||
arranca.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[push / pull_request] --> B[gitleaks]
|
||||
B -- "sin hallazgos" --> C[build]
|
||||
B -- "secreto detectado" --> X["❌ pipeline detenido<br/>build no corre"]
|
||||
```
|
||||
|
||||
La lógica es simple: no tiene sentido gastar tiempo de build y minutos de
|
||||
runner compilando una imagen a partir de un commit que de todas formas hay
|
||||
que rechazar. Fallar rápido, fallar barato.
|
||||
|
||||
También corre en **pull request**, no solo en push a `main` — así un
|
||||
secreto se detecta antes de que el PR se mergee, que es el punto donde
|
||||
todavía es más fácil corregirlo (basta con un `git commit --amend` o un
|
||||
nuevo commit en la misma rama, sin tocar `main`).
|
||||
|
||||
## Alcance de este scan
|
||||
|
||||
El step de CI escanea el árbol de archivos ya *checked out* del commit
|
||||
(`gitleaks detect --no-git`), no el historial completo — el checkout del
|
||||
pipeline es superficial (`fetch-depth: 1`, solo el último commit), así que
|
||||
no hay historial que recorrer en ese punto.
|
||||
|
||||
Antes de integrar esto al pipeline corrimos un **scan histórico completo**
|
||||
del repo `apps-registry` (los 249 commits, con `gitleaks detect` en modo
|
||||
git normal, sin `--no-git`) para confirmar que no había secretos ya
|
||||
commiteados en el pasado. Resultado: **sin hallazgos**. Ese scan histórico
|
||||
es una tarea puntual, no algo que corra en cada push — si alguna vez se
|
||||
sospecha una filtración vieja, se repite manualmente.
|
||||
|
||||
## Cómo leer un hallazgo
|
||||
|
||||
Un hallazgo de gitleaks (en el `gitleaks-report.json` que el pipeline
|
||||
publica como artifact) se ve así:
|
||||
|
||||
```json
|
||||
{
|
||||
"Description": "AWS Access Key",
|
||||
"StartLine": 14,
|
||||
"File": "workloads/ecommerce/lib/config.ts",
|
||||
"Match": "REDACTED",
|
||||
"Secret": "REDACTED",
|
||||
"RuleID": "aws-access-token",
|
||||
"Commit": "a1b2c3d"
|
||||
}
|
||||
```
|
||||
|
||||
Campos clave:
|
||||
|
||||
| Campo | Qué significa |
|
||||
|---|---|
|
||||
| `RuleID` | Qué tipo de secreto detectó (la regla que hizo match) |
|
||||
| `File` / `StartLine` | Dónde está, exactamente |
|
||||
| `Match` / `Secret` | El valor detectado — el pipeline usa `--redact`, así que en el reporte real aparece censurado, no en texto plano |
|
||||
| `Commit` | En qué commit se introdujo (solo aplica al scan histórico, no al scan `--no-git` del pipeline) |
|
||||
|
||||
!!! danger "Si el hallazgo es real"
|
||||
1. **No lo borres del código y listo** — el secreto sigue "filtrado"
|
||||
aunque ya no esté en el archivo actual.
|
||||
2. **Rota la credencial primero** en el sistema que la emitió (AWS,
|
||||
Gitea, Medusa, lo que sea). Un secreto que ya se vio en un log de
|
||||
CI o en un diff de PR se trata como comprometido.
|
||||
3. Después de rotarla, sí, saca el valor viejo del código y usa una
|
||||
variable de entorno / secret de Gitea Actions en su lugar.
|
||||
4. Si el secreto llegó a estar en `main` (no solo en una rama de PR),
|
||||
avisa antes de reescribir historial — reescribir historial en un
|
||||
repo compartido tiene sus propios riesgos y hay que decidirlo con
|
||||
calma, no como reacción automática del pipeline.
|
||||
|
||||
## Falsos positivos: cómo hacer allowlist
|
||||
|
||||
Gitleaks detecta *formas*, no intención. Cosas que típicamente generan
|
||||
falsos positivos en este proyecto:
|
||||
|
||||
- Placeholders como `CAMBIAR` en `commerce/secrets.template.yaml` — **no**
|
||||
deberían disparar nada porque no tienen la forma de un secreto real
|
||||
(baja entropía, texto plano legible), pero si algún día se usa un
|
||||
placeholder con más pinta de secreto real (ej. un UUID de ejemplo), sí
|
||||
puede hacer match.
|
||||
- Hashes largos o IDs opacos que no son secretos (ej. `MEDUSA_REGION_ID`
|
||||
en `frontend.yaml`), si tienen entropía suficientemente alta.
|
||||
|
||||
Cuando gitleaks marca algo que **no es** un secreto real, se agrega una
|
||||
regla de allowlist en un archivo `.gitleaks.toml` en la raíz del repo
|
||||
(todavía no existe — se crea la primera vez que haga falta):
|
||||
|
||||
```toml
|
||||
[allowlist]
|
||||
description = "Falsos positivos conocidos del lab"
|
||||
regexes = [
|
||||
'''MEDUSA_REGION_ID''',
|
||||
]
|
||||
paths = [
|
||||
'''workloads/ecommerce/commerce/secrets\.template\.yaml''',
|
||||
]
|
||||
```
|
||||
|
||||
!!! warning "No es una vía rápida para ignorar hallazgos reales"
|
||||
Cada entrada de allowlist debe quedar documentada (por qué es un falso
|
||||
positivo, no solo "molestaba") y revisada antes de mergear, porque una
|
||||
allowlist mal escrita (una regex demasiado amplia) puede silenciar un
|
||||
secreto real futuro sin que nadie se dé cuenta.
|
||||
|
||||
## Dónde ver el resultado
|
||||
|
||||
El job `gitleaks` publica el reporte JSON como artifact del pipeline
|
||||
(`gitleaks-report`) en cada ejecución, tenga o no hallazgos — así queda
|
||||
disponible para inspección incluso cuando el scan pasa limpio.
|
||||
@@ -0,0 +1,91 @@
|
||||
# DevSecOps
|
||||
|
||||
Fase 2 del lab: integrar tooling de seguridad al pipeline de Gitea
|
||||
Actions, empezando por un solo repo de referencia — el frontend de ARI
|
||||
Shopping (`workloads/ecommerce`, `.gitea/workflows/build.yaml`) — antes
|
||||
de replicarlo a `commerce-backend` y `docs-portal`.
|
||||
|
||||
Cinco herramientas, cada una respondiendo una pregunta distinta:
|
||||
|
||||
| Herramienta | Pregunta que responde | Modo |
|
||||
|---|---|---|
|
||||
| [Gitleaks](gitleaks.md) | ¿Hay un secreto commiteado? | **Bloquea** |
|
||||
| [Trivy — imagen](trivy.md) | ¿La imagen tiene una CVE conocida? | CRITICAL **bloquea**, HIGH informa |
|
||||
| [Trivy — IaC](trivy.md) | ¿El manifiesto de Kubernetes es inseguro? | Informa |
|
||||
| [Semgrep (SAST)](sast.md) | ¿El código tiene un patrón inseguro conocido? | Informa (auditoría) |
|
||||
| [Syft (SBOM)](sbom.md) | ¿Qué paquetes exactos quedaron en la imagen? | Informa (inventario) |
|
||||
| [Cosign](cosign.md) | ¿Esta imagen es exactamente la que armó el pipeline? | **Bloquea** (smoke test) |
|
||||
|
||||
## El pipeline completo
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph J1["job: gitleaks (push + pull_request)"]
|
||||
A1[checkout] --> A2["gitleaks detect --no-git"]
|
||||
end
|
||||
|
||||
A2 -- "secreto encontrado" --> XA["❌ pipeline detenido<br/>build nunca arranca"]
|
||||
A2 -- "limpio" --> B0
|
||||
|
||||
subgraph J2["job: build (solo push a main, needs: gitleaks)"]
|
||||
B0[checkout + validaciones de código] --> B1["Trivy IaC<br/>(frontend.yaml)"]
|
||||
B1 -.informa.-> B2["Semgrep SAST<br/>(audit mode)"]
|
||||
B2 -.informa.-> B3[login registry]
|
||||
B3 --> B4["docker build<br/>(push: false, load: true)"]
|
||||
B4 --> B5["Trivy imagen<br/>CRITICAL"]
|
||||
B5 -- "CRITICAL con fix" --> XB["❌ detenido<br/>no se sube la imagen"]
|
||||
B5 -- "sin CRITICAL" --> B6["Trivy imagen<br/>HIGH (informa)"]
|
||||
B6 --> B7["Syft → SBOM<br/>(CycloneDX + SPDX)"]
|
||||
B7 --> B8["docker push"]
|
||||
B8 --> B9["cosign sign"]
|
||||
B9 --> B10["cosign verify<br/>(smoke test)"]
|
||||
B10 -- "firma inválida" --> XC["❌ detenido"]
|
||||
B10 -- "firma válida" --> B11["actualizar frontend.yaml<br/>(GitOps, Argo CD sincroniza)"]
|
||||
end
|
||||
```
|
||||
|
||||
## Por qué este orden
|
||||
|
||||
- **Gitleaks corre en un job aparte, antes que todo lo demás** — incluso
|
||||
antes que el checkout completo del job de build. Si hay un secreto,
|
||||
no tiene sentido gastar minutos de build en un commit que hay que
|
||||
rechazar igual. Es el único check que corre también en `pull_request`,
|
||||
no solo en push a `main`.
|
||||
- **Trivy IaC y Semgrep corren antes del build de Docker.** Ninguno de
|
||||
los dos necesita la imagen construida — analizan manifiestos y código
|
||||
fuente respectivamente — así que si algo llamativo apareciera ahí, se
|
||||
sabe temprano, sin esperar el build (que es el paso más lento del
|
||||
pipeline).
|
||||
- **Trivy imagen corre después del build pero antes del push.** Por eso
|
||||
el build usa `push: false, load: true`: la imagen queda en el daemon
|
||||
local del runner, escaneable, pero no sale hacia el registry hasta
|
||||
pasar el gate de CRITICAL.
|
||||
- **Syft (SBOM) corre sobre la imagen ya validada**, antes del push —
|
||||
documenta exactamente lo que se está por publicar.
|
||||
- **Cosign firma después del push exitoso** (no tiene sentido firmar
|
||||
algo que no llegó al registry) y se verifica en el mismo pipeline como
|
||||
smoke test.
|
||||
|
||||
## Qué pasa si cada uno falla
|
||||
|
||||
| Si falla... | El pipeline... |
|
||||
|---|---|
|
||||
| Gitleaks | Se detiene ahí mismo. El job `build` nunca arranca (`needs: gitleaks`). Nada se construye. |
|
||||
| Trivy IaC | No se detiene — el hallazgo queda en el log, informativo. |
|
||||
| Semgrep | No se detiene — mismo criterio: primera vuelta en modo auditoría. |
|
||||
| Trivy imagen (CRITICAL) | Se detiene después del build, antes del push. La imagen con la CVE nunca llega al registry. |
|
||||
| Trivy imagen (HIGH) | No se detiene — se reporta para revisar en conjunto. |
|
||||
| Syft | Si el propio comando falla (no si "encuentra algo" — un SBOM no tiene hallazgos que bloqueen), el pipeline se detiene por error real de la herramienta. |
|
||||
| Cosign sign/verify | Se detiene. Si la imagen se firmó pero no verifica, algo está mal con las llaves o con la imagen — no se continúa con la promoción GitOps. |
|
||||
|
||||
## Qué falta después de esta primera vuelta
|
||||
|
||||
- Revisar en conjunto los hallazgos de Trivy IaC y Semgrep (hoy
|
||||
informativos) y decidir cuáles pasan a bloquear.
|
||||
- Decidir si Trivy imagen sube el umbral de bloqueo a HIGH una vez que
|
||||
el backlog de CVEs conocidas esté bajo control.
|
||||
- Admission controller en el cluster que verifique la firma de Cosign
|
||||
antes de dejar correr un Pod (ver [cosign.md](cosign.md)) — hoy la
|
||||
verificación es solo un smoke test dentro del propio pipeline.
|
||||
- Replicar este mismo patrón a `commerce-backend` (Medusa) y
|
||||
`docs-portal`, adaptando lo que corresponda a cada stack.
|
||||
@@ -0,0 +1,115 @@
|
||||
# SAST (Semgrep) — análisis estático de código
|
||||
|
||||
!!! info "Qué problema resuelve"
|
||||
SAST significa *Static Application Security Testing*: analizar el
|
||||
**código fuente** en busca de patrones de programación inseguros, sin
|
||||
ejecutar la aplicación. Semgrep lee cada archivo `.ts`/`.tsx` y lo
|
||||
compara contra un catálogo de reglas — cada regla describe una forma
|
||||
de escribir código que suele terminar en una vulnerabilidad conocida
|
||||
(inyección, XSS, uso inseguro de una API, etc.).
|
||||
|
||||
## En qué se diferencia de Gitleaks y Trivy
|
||||
|
||||
Las tres herramientas ya integradas a este pipeline analizan cosas
|
||||
completamente distintas — vale la pena tenerlo claro porque a primera
|
||||
vista "escaneo de seguridad" suena como una sola categoría:
|
||||
|
||||
| Herramienta | Qué mira | Pregunta que responde |
|
||||
|---|---|---|
|
||||
| [Gitleaks](gitleaks.md) | El texto de los archivos y el historial de git | "¿Hay una credencial commiteada?" |
|
||||
| [Trivy](trivy.md) | Paquetes instalados (imagen) y configuración (YAML) | "¿Alguna dependencia tiene una CVE conocida? ¿El manifiesto de Kubernetes es inseguro?" |
|
||||
| **Semgrep (SAST)** | La **lógica** del código que escribimos nosotros | "¿Esta función, tal como está escrita, abre una vulnerabilidad?" |
|
||||
|
||||
Ninguna de las tres reemplaza a las otras. Una dependencia puede estar
|
||||
100% al día (sin CVEs, Trivy contento) y aun así el código propio puede
|
||||
construir una URL con un string sin sanitizar y quedar abierto a SSRF —
|
||||
eso solo lo detecta un análisis de la lógica del código, que es
|
||||
exactamente lo que hace Semgrep.
|
||||
|
||||
## Qué tipo de bugs detecta (ejemplos genéricos)
|
||||
|
||||
Los rulesets usados (`p/typescript`, `p/react`, `p/nextjs`,
|
||||
`p/security-audit`) cubren, entre otras cosas:
|
||||
|
||||
- **Inyección**: construir queries, comandos de shell o URLs concatenando
|
||||
strings con datos que vienen del usuario, en vez de usar una API
|
||||
parametrizada.
|
||||
- **XSS en React/Next.js**: usar `dangerouslySetInnerHTML` con contenido
|
||||
que no pasó por un sanitizador.
|
||||
- **SSRF**: hacer un `fetch()`/request server-side hacia una URL que
|
||||
construye el propio usuario, sin validar el host de destino.
|
||||
- **Criptografía insegura**: algoritmos de hash débiles (`md5`, `sha1`)
|
||||
usados para contraseñas o tokens, en vez de un KDF diseñado para eso.
|
||||
- **`eval` / `new Function()`** sobre datos no confiables.
|
||||
- **ReDoS**: expresiones regulares con backtracking exponencial que un
|
||||
input malicioso puede usar para colgar el proceso.
|
||||
- **Prototype pollution**: merges/asignaciones dinámicas de objetos que
|
||||
permiten sobrescribir `__proto__`.
|
||||
|
||||
## Resultado real de esta primera corrida
|
||||
|
||||
```text
|
||||
Scanning 72 files tracked by git with 292 Code rules:
|
||||
ts 87 rules 39 files
|
||||
js 81 rules 1 file
|
||||
json 1 rule 6 files
|
||||
|
||||
Ran 91 rules on 72 files: 0 findings.
|
||||
```
|
||||
|
||||
`workloads/ecommerce` salió limpio con estos rulesets: **0 hallazgos**.
|
||||
|
||||
!!! warning "0 hallazgos no significa 'código perfecto'"
|
||||
Significa que ningún patrón conocido de estos 91 rules hizo match —
|
||||
no es una garantía de ausencia de bugs, solo de ausencia de *estos*
|
||||
patrones específicos. SAST tiene falsos negativos por naturaleza (un
|
||||
bug de lógica de negocio nuevo, específico de esta app, no está en
|
||||
ningún ruleset genérico). El valor de correr esto en cada build no es
|
||||
"una vez limpio, siempre limpio" — es que si alguien introduce a
|
||||
futuro uno de estos patrones conocidos (por ejemplo, un
|
||||
`dangerouslySetInnerHTML` sin sanitizar en un componente nuevo), el
|
||||
pipeline lo va a marcar en ese mismo push.
|
||||
|
||||
## Por qué modo auditoría (no bloquea) en esta primera integración
|
||||
|
||||
El step corre **sin** la flag `--error` a propósito — Semgrep siempre
|
||||
termina con `exit 0`, reporta lo que encuentra pero nunca frena el
|
||||
pipeline. Es la primera vez que esta herramienta corre sobre el repo: la
|
||||
idea es revisar juntos qué reglas de los 91 activos generan ruido (falsos
|
||||
positivos específicos de este código) antes de decidir cuáles deberían
|
||||
pasar a bloquear.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Semgrep scan] --> B{hallazgos?}
|
||||
B -- "sí" --> C["se reportan en el log<br/>+ artifact JSON"]
|
||||
B -- "no" --> C
|
||||
C --> D["✅ pipeline sigue<br/>(exit 0 siempre)"]
|
||||
```
|
||||
|
||||
Una vez que se decida qué reglas son suficientemente confiables para
|
||||
esta app, el paso natural es agregar `--error` **con un subconjunto**
|
||||
de reglas (no las 91 completas) usando `--config` más específico o
|
||||
`.semgrepignore` / reglas individuales marcadas como bloqueantes —
|
||||
todavía no se hizo ese recorte.
|
||||
|
||||
## Los rulesets elegidos
|
||||
|
||||
- `p/typescript` — patrones generales de TypeScript/JavaScript.
|
||||
- `p/react` — específico de componentes React (hooks mal usados, XSS vía
|
||||
props/render, etc.).
|
||||
- `p/nextjs` — patrones propios del framework (App Router, API routes,
|
||||
middlewares).
|
||||
- `p/security-audit` — catálogo transversal de seguridad (inyección,
|
||||
criptografía débil, deserialización insegura) sin atarse a un
|
||||
framework específico.
|
||||
|
||||
Son rulesets **públicos y gratuitos** del registro de Semgrep — no
|
||||
requieren cuenta ni login (`semgrep login` solo hace falta para acceder a
|
||||
reglas adicionales de pago, que no se usan acá).
|
||||
|
||||
## Dónde ver el resultado
|
||||
|
||||
El JSON completo (`semgrep-report.json`) se publica como artifact del
|
||||
pipeline (`semgrep-report`) en cada corrida, tenga o no hallazgos —
|
||||
mismo patrón que Gitleaks y Trivy.
|
||||
@@ -0,0 +1,124 @@
|
||||
# SBOM (Syft) — inventario de software
|
||||
|
||||
!!! info "Qué es un SBOM"
|
||||
SBOM = *Software Bill of Materials* — literalmente, una "lista de
|
||||
materiales" del software, igual que la lista de ingredientes de un
|
||||
producto. Es un documento (JSON, en este caso) que enumera **cada
|
||||
paquete que terminó dentro de la imagen final**: nombre, versión
|
||||
exacta, de dónde viene (`npm`, `deb`, etc.) y, cuando aplica, su
|
||||
licencia.
|
||||
|
||||
[Syft](https://github.com/anchore/syft) genera ese inventario
|
||||
inspeccionando la imagen Docker ya construida — no necesita acceso al
|
||||
código fuente ni a `package.json`, lee directamente lo que quedó
|
||||
instalado en los layers de la imagen.
|
||||
|
||||
## Por qué importa: el escenario "log4shell"
|
||||
|
||||
En diciembre de 2021 apareció una vulnerabilidad crítica en Log4j (una
|
||||
librería de logging de Java) usada, directa o indirectamente, en una
|
||||
cantidad enorme de software. La pregunta que todo equipo tuvo que
|
||||
responder en horas, no en días, fue: **"¿nosotros usamos esto, en algún
|
||||
lugar, aunque sea una dependencia de una dependencia?"**
|
||||
|
||||
Sin un SBOM, esa respuesta implica revisar manualmente cada
|
||||
`package.json`, cada imagen Docker, cada servicio — y confiar en que no
|
||||
se te escapó una dependencia transitiva de tres niveles de profundidad.
|
||||
Con un SBOM generado en cada build y guardado como artifact, la respuesta
|
||||
es una búsqueda de texto sobre un archivo:
|
||||
|
||||
```bash
|
||||
grep -i "nombre-del-paquete-afectado" sbom.cdx.json
|
||||
```
|
||||
|
||||
Si aparece, sabés exactamente en qué versión, y podés cruzarlo contra el
|
||||
aviso de seguridad para saber si tu versión específica está afectada —
|
||||
en minutos, no en una auditoría manual del repo completo.
|
||||
|
||||
## Qué genera este pipeline
|
||||
|
||||
Un step de Syft corre sobre la imagen ya construida (la misma que pasó
|
||||
el gate de CRITICAL de Trivy) y produce **dos formatos** del mismo
|
||||
inventario, publicados como artifact del pipeline:
|
||||
|
||||
- `sbom.cdx.json` — [CycloneDX](https://cyclonedx.org/), el formato con
|
||||
mejor soporte en herramientas de consulta/alertas automáticas de CVEs.
|
||||
- `sbom.spdx.json` — [SPDX](https://spdx.dev/), el estándar ISO, más
|
||||
orientado a cumplimiento de licencias y trazabilidad legal.
|
||||
|
||||
No hay una razón fuerte para elegir solo uno en esta etapa — generar
|
||||
ambos cuesta segundos y cada formato es mejor para un caso de uso
|
||||
distinto, así que se publican los dos.
|
||||
|
||||
## Resultado real de este build
|
||||
|
||||
Corriendo Syft contra la imagen real de `workloads/ecommerce`:
|
||||
|
||||
| Formato | Paquetes listados |
|
||||
|---|---|
|
||||
| CycloneDX | 3528 componentes |
|
||||
| SPDX | 228 paquetes |
|
||||
|
||||
!!! tip "¿Por qué el número es tan distinto entre formatos?"
|
||||
No es un error — cada formato tiene un nivel de detalle distinto.
|
||||
CycloneDX de Syft incluye entradas más granulares (variantes,
|
||||
sub-paquetes, entradas sin versión resuelta marcadas `UNKNOWN`)
|
||||
mientras que SPDX agrupa a un nivel más alto. Para "¿tengo este
|
||||
paquete, sí o no?" cualquiera de los dos sirve; para conteos exactos,
|
||||
hay que saber cuál se está mirando.
|
||||
|
||||
Ejemplo de una entrada real (`sbom.cdx.json`, recortado):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "next",
|
||||
"version": "15.5.10",
|
||||
"licenses": [{ "license": { "id": "MIT" } }],
|
||||
"purl": "pkg:npm/[email protected]",
|
||||
"properties": [
|
||||
{ "name": "syft:package:language", "value": "javascript" },
|
||||
{ "name": "syft:package:type", "value": "npm" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
El campo `purl` (*Package URL*) es el identificador estándar que usan
|
||||
Trivy, Syft, GitHub Advisories y la mayoría de las bases de datos de
|
||||
CVEs para referirse a "este paquete, en este ecosistema, en esta
|
||||
versión" — es lo que hace posible cruzar un SBOM contra un aviso de
|
||||
seguridad de forma automática, sin depender de que el nombre coincida
|
||||
exactamente en texto libre.
|
||||
|
||||
!!! example "El SBOM también sirve para encontrar cosas que sobran"
|
||||
Revisando el inventario real apareció `[email protected]` — viene
|
||||
empaquetado en la imagen base de Node igual que el `npm` que ya se
|
||||
sacó del stage final en el fix de [Trivy](trivy.md). No es una
|
||||
vulnerabilidad activa hoy, pero es exactamente el tipo de hallazgo
|
||||
que un SBOM hace visible para revisar después: herramientas que
|
||||
viajan en la imagen de producción sin que el runtime las necesite.
|
||||
|
||||
## Cómo se consulta
|
||||
|
||||
El SBOM se publica como artifact del pipeline (`sbom-v1.0.X`, con ambos
|
||||
archivos) en cada build. Para consultarlo:
|
||||
|
||||
1. Descargar el artifact de la corrida del pipeline que te interesa
|
||||
(o del último build de `main`, para saber qué corre en producción
|
||||
ahora mismo).
|
||||
2. Buscar el paquete en cuestión:
|
||||
```bash
|
||||
python3 -c "
|
||||
import json
|
||||
d = json.load(open('sbom.cdx.json'))
|
||||
for c in d['components']:
|
||||
if c['name'] == 'next':
|
||||
print(c['name'], c['version'])
|
||||
"
|
||||
```
|
||||
(o `grep -A3 '"name": "next"' sbom.cdx.json` si no hay Python a mano).
|
||||
3. Si el paquete aparece, confirmar la versión contra el aviso de
|
||||
seguridad para saber si aplica.
|
||||
|
||||
No hace falta memorizar el formato — el punto de tener el SBOM ya
|
||||
generado es no depender de reconstruir esta información bajo presión
|
||||
el día que aparezca la próxima CVE grande.
|
||||
@@ -0,0 +1,187 @@
|
||||
# Trivy — vulnerabilidades de imagen e IaC
|
||||
|
||||
!!! info "Qué problema resuelve"
|
||||
Trivy es un escáner de seguridad multipropósito. En este pipeline se usa
|
||||
para dos cosas **distintas**, con dos comandos distintos:
|
||||
|
||||
- `trivy image`: busca CVEs conocidas en los paquetes que terminan
|
||||
dentro de la imagen Docker final (el sistema operativo base, y las
|
||||
librerías de Node.js instaladas).
|
||||
- `trivy config`: busca **misconfiguraciones** en los manifiestos de
|
||||
Kubernetes (YAML) — no vulnerabilidades de código, sino configuración
|
||||
insegura (contenedor como root, sin límites de recursos, etc.).
|
||||
|
||||
Son preguntas distintas: "¿esta imagen tiene código con bugs de
|
||||
seguridad conocidos?" contra "¿esta manera de desplegar el contenedor
|
||||
es insegura, aunque el código adentro esté perfecto?".
|
||||
|
||||
## Dónde corre cada uno en el pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[checkout] --> B["trivy config<br/>(frontend.yaml)"]
|
||||
B --> C[docker login]
|
||||
C --> D["docker build<br/>(push: false, load: true)"]
|
||||
D --> E["trivy image --severity CRITICAL<br/>--exit-code 1"]
|
||||
E -- "CRITICAL con fix" --> X["❌ pipeline detenido<br/>no se sube la imagen"]
|
||||
E -- "sin CRITICAL" --> F["trivy image --severity HIGH<br/>--exit-code 0 (informativo)"]
|
||||
F --> G["docker push"]
|
||||
```
|
||||
|
||||
El escaneo de imagen corre **después de construir la imagen, antes de
|
||||
subirla al registry** — por eso el build ahora usa
|
||||
`push: false, load: true` (la imagen queda en el daemon Docker del runner,
|
||||
pero no sale de ahí hasta pasar el gate de CRITICAL). El escaneo de
|
||||
manifiestos (`trivy config`) no depende de la imagen, así que corre antes,
|
||||
junto a las otras validaciones del código.
|
||||
|
||||
## Umbral de severidad (y por qué)
|
||||
|
||||
| Severidad | Comportamiento | Por qué |
|
||||
|---|---|---|
|
||||
| `CRITICAL` | Bloquea (`--exit-code 1`) | Si existe un fix disponible para una CVE crítica, no tiene sentido publicar la imagen igual |
|
||||
| `HIGH` | Informa, no bloquea (`--exit-code 0`) | Mientras aprendemos a leer los reportes, HIGH se revisa pero no frena el flujo — bloquear de entrada en HIGH hubiera parado el pipeline en el primer intento real (ver ejemplo abajo) |
|
||||
|
||||
Ambos steps usan `--ignore-unfixed`: si Trivy no tiene un `FixedVersion`
|
||||
para reportar, bloquear o hasta advertir no ayuda en nada — no hay acción
|
||||
posible más que esperar a que el mantenedor del paquete publique un
|
||||
parche.
|
||||
|
||||
!!! tip "Por qué no escanear todo junto con un solo umbral"
|
||||
Trivy permite pedir `--severity CRITICAL,HIGH` en una sola corrida,
|
||||
pero el `--exit-code` se aplica igual a toda la corrida — no se puede
|
||||
decir "bloqueá en CRITICAL, pero en HIGH solo avisá" en un solo
|
||||
comando. Por eso son dos steps separados, cada uno con su propio
|
||||
umbral y su propio `--exit-code`.
|
||||
|
||||
## Ejemplo real: un CRITICAL que sí bloqueaba
|
||||
|
||||
Antes de integrar este step, construimos la imagen real de
|
||||
`workloads/ecommerce` y corrimos Trivy contra ella para validar el
|
||||
pipeline. Encontró esto:
|
||||
|
||||
```text
|
||||
Node.js (node-pkg)
|
||||
Total: 1 (CRITICAL: 1)
|
||||
|
||||
tar (7.5.15 → 7.5.19) CVE-2026-59873 CRITICAL
|
||||
tar: node-tar: Denial of Service via crafted gzip bomb
|
||||
```
|
||||
|
||||
**El detalle importante no era el CVE en sí, sino dónde vivía:**
|
||||
`/usr/local/lib/node_modules/npm/node_modules/tar/` — ese `tar` no es una
|
||||
dependencia de ARI Shopping, es el que trae **empaquetado el propio
|
||||
`npm`** dentro de la imagen base `node:24.18.0-bookworm-slim`. El
|
||||
`Dockerfile` original hacía `FROM ${NODE_IMAGE} AS runner` para el stage
|
||||
final, heredando el Node.js completo — con `npm`, `npx` y `corepack`
|
||||
incluidos — aunque en producción el contenedor solo ejecuta
|
||||
`node server.js` y **nunca** invoca `npm`.
|
||||
|
||||
!!! danger "No se arregla solo subiendo la versión de Node"
|
||||
Antes de tocar el Dockerfile probamos si un patch más nuevo de la
|
||||
imagen base ya traía el `tar` corregido: `node:24.19.0-bookworm-slim`
|
||||
trae `npm` con `[email protected]` — sigue por debajo del `7.5.19` con el
|
||||
fix. El problema no es "Node desactualizado", es que el runtime de
|
||||
producción no necesita `npm` para nada.
|
||||
|
||||
**Fix aplicado** (`workloads/ecommerce/Dockerfile`, stage `runner`):
|
||||
|
||||
```diff
|
||||
+ RUN rm -rf \
|
||||
+ /usr/local/lib/node_modules/npm \
|
||||
+ /usr/local/lib/node_modules/corepack \
|
||||
+ /usr/local/bin/npm \
|
||||
+ /usr/local/bin/npx \
|
||||
+ /usr/local/bin/corepack
|
||||
```
|
||||
|
||||
Después del fix: **0 CRITICAL**, y de paso las HIGH fixable bajaron de 21
|
||||
a 15 (varias venían de dependencias de ese mismo `npm` empaquetado, no de
|
||||
la app). Se validó que la imagen sigue arrancando y respondiendo
|
||||
`GET /api/health` con `200` después de sacar `npm`.
|
||||
|
||||
**Lección:** sacar herramientas que la imagen de producción no necesita
|
||||
en runtime no es solo "buena práctica" en abstracto — reduce
|
||||
directamente la superficie que Trivy (y un atacante) tienen para
|
||||
encontrar algo.
|
||||
|
||||
## Las HIGH que quedan (ejemplo real, sin arreglar todavía)
|
||||
|
||||
Después del fix, `trivy image --severity HIGH` sigue reportando (de
|
||||
forma informativa, no bloqueante) CVEs reales en dependencias que sí son
|
||||
de la app — la mayoría en `next` (15.5.10, con fixes disponibles en
|
||||
15.5.16+ y 15.5.21+ según el CVE), además de `nanoid`, `postcss` y
|
||||
`sharp`. Esto queda pendiente de revisar como una actualización de
|
||||
dependencias normal, no como una emergencia de seguridad — es exactamente
|
||||
para eso que HIGH no bloquea en esta primera vuelta: da visibilidad sin
|
||||
frenar el flujo mientras se decide cuándo priorizar el bump.
|
||||
|
||||
## Cómo priorizar qué arreglar primero
|
||||
|
||||
1. **CRITICAL con fix disponible** — ya bloquea el pipeline, así que en
|
||||
la práctica no se acumulan.
|
||||
2. **HIGH en una dependencia que el runtime realmente carga** (como
|
||||
`next`, que corre en cada request) — más prioridad que una HIGH en
|
||||
una herramienta de build que ni siquiera llega a la imagen final.
|
||||
3. **HIGH sin ruta de explotación realista** (ej. una librería que solo
|
||||
se usa en un script de generación, no en el server) — se puede
|
||||
posponer con criterio, documentando por qué.
|
||||
4. **MEDIUM/LOW** — se revisan en lote, no una por una.
|
||||
|
||||
La pregunta que más ayuda a priorizar no es "¿qué tan grave dice la
|
||||
CVSS que es?", sino "¿este paquete corre en el proceso que atiende
|
||||
tráfico real, o es una herramienta de build que ni siquiera debería estar
|
||||
en la imagen final?" — el propio ejemplo de arriba (`npm` dentro de la
|
||||
imagen de producción) es el caso de manual del segundo.
|
||||
|
||||
## Escaneo de manifiestos Kubernetes (`trivy config`)
|
||||
|
||||
Corre contra `workloads/ecommerce/frontend.yaml` (el `Deployment` +
|
||||
`Service` real del frontend) con `--severity CRITICAL,HIGH,MEDIUM`, en
|
||||
modo informativo (`--exit-code 0`) por ahora.
|
||||
|
||||
Hallazgos reales de este manifiesto, hoy:
|
||||
|
||||
| Severidad | Regla | Qué significa |
|
||||
|---|---|---|
|
||||
| HIGH | [KSV-0014](https://avd.aquasec.com/misconfig/ksv-0014) | El filesystem raíz del contenedor no es de solo lectura |
|
||||
| HIGH | [KSV-0118](https://avd.aquasec.com/misconfig/ksv-0118) | No se define `securityContext` — Kubernetes usa el default, que permite privilegios de root |
|
||||
| MEDIUM | [KSV-0012](https://avd.aquasec.com/misconfig/ksv-0012) | El contenedor puede correr como root (aunque la imagen ya defina `USER nextjs` en el Dockerfile, Kubernetes no lo está *forzando* vía `runAsNonRoot`) |
|
||||
| MEDIUM | [KSV-0001](https://avd.aquasec.com/misconfig/ksv-0001) | El contenedor puede escalar sus propios privilegios (falta `allowPrivilegeEscalation: false`) |
|
||||
| MEDIUM | [KSV-0104](https://avd.aquasec.com/misconfig/ksv-0104) | No hay perfil de Seccomp configurado |
|
||||
| MEDIUM | [KSV-0117](https://avd.aquasec.com/misconfig/ksv-0117) | El `containerPort: 80` es un puerto privilegiado (<1024) |
|
||||
| MEDIUM | [KSV-0125](https://avd.aquasec.com/misconfig/ksv-0125) | La imagen viene de un registry que Trivy no reconoce como "de confianza" por defecto (es autoalojado: `gitea.cruzcloud.net`) |
|
||||
|
||||
!!! warning "Lo que este scan NO detecta todavía"
|
||||
El pedido original incluía "falta de resource limits" y "falta de
|
||||
readiness/liveness probes" como ejemplos de misconfiguración a
|
||||
buscar. En la práctica, `trivy config` sí tiene reglas para límites
|
||||
de recursos (`KSV-0011` CPU, `KSV-0018` memoria) pero las clasifica
|
||||
como **LOW**, por debajo del piso `MEDIUM` que usa este step — y no
|
||||
tiene ninguna regla propia para probes de liveness/readiness (eso lo
|
||||
cubren otras herramientas, como `kube-score` o `kube-linter`, que no
|
||||
forman parte de esta primera integración). Si más adelante se quiere
|
||||
cubrir ese hueco específico, es una herramienta aparte, no una opción
|
||||
de configuración de Trivy.
|
||||
|
||||
Ninguno de estos hallazgos bloquea el pipeline todavía — son reales, pero
|
||||
corregirlos (agregar `securityContext`, `resources.limits`, etc. a
|
||||
`frontend.yaml`) es un cambio de GitOps que conviene revisar con calma,
|
||||
no como reacción automática a un scan.
|
||||
|
||||
## Falsos positivos y excepciones
|
||||
|
||||
Cuando un hallazgo de Trivy no aplica (por ejemplo, KSV-0125 marcando el
|
||||
registry propio como "no confiable" — que es exactamente lo esperado en
|
||||
un lab self-hosted), se documenta con un archivo `.trivyignore` en la
|
||||
raíz del repo:
|
||||
|
||||
```text
|
||||
# KSV-0125: gitea.cruzcloud.net es nuestro registry self-hosted,
|
||||
# no un registry público de terceros. Excepción intencional.
|
||||
KSV-0125
|
||||
```
|
||||
|
||||
Igual que con Gitleaks, cada línea de `.trivyignore` debe poder
|
||||
justificarse — no es un lugar para silenciar hallazgos incómodos sin
|
||||
revisarlos primero.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Guía del estudiante — ruta de aprendizaje sugerida
|
||||
|
||||
> Escrita para alguien que **nunca ha visto GitOps ni Kubernetes**. Si
|
||||
> ya conoces estos conceptos, probablemente quieras saltar directo a
|
||||
> [Arquitectura](../arquitectura/vision-general.md) o
|
||||
> [Decisiones](../decisiones/0001-por-que-k3d.md).
|
||||
|
||||
> **TODO (fase 2+):** convertir esto en una ruta paso a paso completa,
|
||||
> con ejercicios o preguntas de verificación al final de cada parada.
|
||||
> Por ahora es el esqueleto de la ruta.
|
||||
|
||||
## Antes de empezar
|
||||
|
||||
No hace falta saber Kubernetes de antemano. Sí ayuda tener claro qué es
|
||||
un contenedor Docker — si eso todavía no es familiar, empieza por ahí
|
||||
antes de seguir.
|
||||
|
||||
## Ruta sugerida
|
||||
|
||||
1. **[Conceptos básicos](conceptos-basicos.md)** — qué es un contenedor,
|
||||
qué es Kubernetes, qué problema resuelve.
|
||||
2. **[Glosario](../arquitectura/glosario.md)** — términos clave
|
||||
(GitOps, tunnel, Argo CD, manifest) explicados en una frase antes del
|
||||
detalle técnico.
|
||||
3. **[Visión general de arquitectura](../arquitectura/vision-general.md)**
|
||||
— el diagrama completo: cómo llega una petición desde el navegador
|
||||
hasta la aplicación real.
|
||||
4. **[Flujo GitOps](../arquitectura/gitops-flujo.md)** — qué pasa
|
||||
exactamente entre que alguien hace `git push` y el cambio queda
|
||||
corriendo.
|
||||
5. **Un playbook real** — empieza por
|
||||
[el incidente del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md):
|
||||
es un buen ejemplo de cómo se descarta una hipótesis con evidencia en
|
||||
vez de adivinar.
|
||||
6. **Una decisión de arquitectura (ADR)** — lee
|
||||
[por qué k3d](../decisiones/0001-por-que-k3d.md) para ver cómo se
|
||||
documenta un trade-off real, no solo la elección final.
|
||||
|
||||
## Si esto se convierte en proyecto de grado
|
||||
|
||||
Este lab es candidato razonable de base para un proyecto de grado
|
||||
porque ya tiene: infraestructura real corriendo, incidentes reales
|
||||
documentados con causa raíz, y decisiones arquitectónicas explícitas
|
||||
en vez de implícitas. La sección de
|
||||
[aprendizajes](../aprendizajes/notas-sueltas.md) es un buen punto de
|
||||
partida para encontrar preguntas de investigación abiertas (deuda
|
||||
técnica, mejoras pendientes) en vez de inventar un tema desde cero.
|
||||
|
||||
**TODO:** definir aquí, en una sesión futura, el alcance concreto de
|
||||
ese posible proyecto de grado (¿extender el lab? ¿documentar y
|
||||
analizar el patrón? ¿construir sobre él?).
|
||||
@@ -0,0 +1,22 @@
|
||||
# Conceptos básicos
|
||||
|
||||
> **TODO (fase 2+):** este es el archivo con más responsabilidad
|
||||
> pedagógica del sitio — pensado para alguien en su primer contacto con
|
||||
> estos temas. Requiere más tiempo de redacción y ejemplos visuales que
|
||||
> el resto; se deja como esqueleto a propósito para trabajarlo con
|
||||
> cuidado en una sesión dedicada.
|
||||
|
||||
## Lo que este capítulo va a cubrir
|
||||
|
||||
- ¿Qué es un contenedor? (analogía antes que definición técnica)
|
||||
- ¿Qué problema resuelve Kubernetes que Docker solo no resuelve?
|
||||
- ¿Qué es un "cluster" en la práctica?
|
||||
- ¿Qué es un namespace, y por qué importa?
|
||||
- Diagrama Mermaid: "una app en un solo contenedor" vs. "una app en
|
||||
Kubernetes" — mismo resultado final, distinta forma de lograrlo.
|
||||
|
||||
## TODO
|
||||
|
||||
Contenido pendiente — ver [Glosario](../arquitectura/glosario.md)
|
||||
mientras tanto para definiciones cortas de los términos que van a
|
||||
aparecer aquí.
|
||||
@@ -0,0 +1,65 @@
|
||||
# CruzCloud Lab — Plataforma GitOps
|
||||
|
||||
> **TODO (fase 2+):** reescribir esta portada con la narrativa final. Por
|
||||
> ahora describe el propósito y la audiencia para que la navegación y el
|
||||
> `nav:` de `mkdocs.yml` tengan un punto de entrada real.
|
||||
|
||||
Este sitio documenta un laboratorio personal de **Platform Engineering**:
|
||||
un cluster Kubernetes (k3d) corriendo sobre un NAS (ZimaOS), gobernado
|
||||
100% por GitOps (Gitea + Argo CD), sirviendo una tienda de e-commerce real
|
||||
(Medusa + Next.js) como carga de trabajo de referencia.
|
||||
|
||||
No es un tutorial genérico ni una demo de juguete: cada decisión, cada
|
||||
incidente y cada diagrama de este sitio corresponde a un sistema que
|
||||
corre de verdad, con los commits, logs y causas raíz reales que lo
|
||||
probaron.
|
||||
|
||||
## Para quién es este sitio
|
||||
|
||||
Este portal sirve a tres audiencias distintas, y está organizado para
|
||||
que cada una pueda entrar por su propia puerta:
|
||||
|
||||
- **Reclutadores / pares de Platform Engineering** — ver
|
||||
[`decisiones/`](decisiones/0001-por-que-k3d.md) para el criterio
|
||||
arquitectónico detrás del stack, y [`playbooks/`](playbooks/incidente-crashloop-medusa.md)
|
||||
para diagnóstico real de incidentes (no solo "lo arreglé").
|
||||
- **Alguien evaluando este patrón para un caso de negocio real**
|
||||
(tienda desplegable desde ZimaOS) — ver
|
||||
[`arquitectura/`](arquitectura/vision-general.md) para el flujo
|
||||
completo y su costo/complejidad real.
|
||||
- **Estudiantes empezando con GitOps/Kubernetes** (incluye a mi hijo,
|
||||
7mo semestre de Ingeniería de Sistemas) — empezar por
|
||||
[`guia-estudiante/`](guia-estudiante/README.md), pensada para alguien
|
||||
que nunca ha visto estos conceptos.
|
||||
|
||||
## Mapa del sitio
|
||||
|
||||
| Sección | Qué encontrarás |
|
||||
|---|---|
|
||||
| [Arquitectura](arquitectura/vision-general.md) | Diagramas Mermaid del flujo completo, red/exposición, flujo GitOps, glosario |
|
||||
| [Decisiones (ADRs)](decisiones/0001-por-que-k3d.md) | Por qué k3d, por qué Argo CD, por qué HTTP/2 sobre QUIC, etc. |
|
||||
| [Playbooks](playbooks/incidente-crashloop-medusa.md) | Incidentes reales, diagnóstico paso a paso, causa raíz, fix |
|
||||
| [Aprendizajes](aprendizajes/notas-sueltas.md) | Qué haría distinto, gotchas de Medusa v2 |
|
||||
| [Guía del estudiante](guia-estudiante/README.md) | Ruta de aprendizaje desde cero |
|
||||
|
||||
## Estado de este sitio
|
||||
|
||||
Este portal se mantiene vivo junto con el lab. La idea es que cada
|
||||
página muestre su última fecha de modificación real vía
|
||||
`git-revision-date-localized` — por ahora ese plugin está deshabilitado
|
||||
(ver TODO en `mkdocs.yml`): el build corre con `mkdocs build --strict`,
|
||||
que aborta ante cualquier `WARNING`, y el plugin no tiene historial git
|
||||
real que leer desde el contexto de build actual, así que se queda fuera
|
||||
hasta que ese contexto incluya `.git` de verdad. Si una página dice
|
||||
"TODO", es contenido pendiente de una fase posterior, no una promesa
|
||||
incumplida: la estructura completa se construyó primero a propósito,
|
||||
para que el contenido se llene sección por sección con el mismo
|
||||
criterio que el resto del lab.
|
||||
|
||||
Desde este commit, el sitio se construye y publica solo: cada push a
|
||||
`main` que toca `docs/` o `mkdocs.yml` dispara
|
||||
`.gitea/workflows/deploy-docs.yaml`, que hace `mkdocs build --strict`,
|
||||
publica la imagen en el Registry de Gitea y Argo CD sincroniza el
|
||||
Deployment en el cluster (namespace `docs-portal`). Este párrafo es la
|
||||
prueba: si lo estás leyendo servido desde el pod real, el pipeline
|
||||
funcionó de punta a punta.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Incidente: 504 Gateway Timeout intermitente (túnel QUIC inestable) (2026-08)
|
||||
|
||||
!!! info "Origen"
|
||||
Migrado desde `apps-registry/docs/playbooks/incidente-cloudflared-504-quic-2026-08.md`.
|
||||
Contenido técnico preservado sin cambios de fondo — solo formato adaptado a MkDocs Material.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Ventana del incidente** | Intermitente durante varias horas, 2026-08 |
|
||||
| **Servicio afectado** | `cloudflared` (túnel de Cloudflare, app nativa de ZimaOS, red Host) — afecta a **todos** los subdominios detrás del mismo túnel (`gitea.cruzcloud.net`, `commerce.cruzcloud.net`, `shop.cruzcloud.net`, `media.cruzcloud.net`, Immich, Argo CD, Memos, etc.) |
|
||||
| **Impacto** | `504 Gateway Timeout` intermitente en peticiones HTTP a cualquier host detrás del túnel, sin patrón evidente de horario ni de carga |
|
||||
|
||||
## Síntoma
|
||||
|
||||
`gitea.cruzcloud.net` devolvía `504 Gateway Timeout` de forma
|
||||
intermitente. El mismo síntoma se observaba en otros hosts servidos por
|
||||
el mismo túnel, lo que apuntaba a una causa **compartida a nivel de
|
||||
túnel**, no de una app individual.
|
||||
|
||||
## Diagnóstico
|
||||
|
||||
```bash
|
||||
docker logs -f cloudflared 2>&1 | grep -i -E "connIndex|protocol|retry"
|
||||
```
|
||||
|
||||
Reveló un patrón sostenido durante horas: la conexión `connIndex=2`
|
||||
(de las 4 conexiones que `cloudflared` mantiene hacia el edge de
|
||||
Cloudflare) sufría fallos recurrentes de "control stream" cada 2-6
|
||||
minutos, reconectándose en loop:
|
||||
|
||||
```text
|
||||
control stream encountered a failure while serving
|
||||
Retrying connection in up to 1m4s
|
||||
```
|
||||
|
||||
Cuando una petición HTTP coincidía con el instante exacto de una de
|
||||
esas caídas de `connIndex=2`, el cliente recibía el 504.
|
||||
|
||||
## Causa raíz
|
||||
|
||||
El contenedor `cloudflared` (imagen oficial `cloudflare/cloudflared:latest`)
|
||||
usaba **QUIC (UDP)** como protocolo de transporte por defecto para sus
|
||||
4 conexiones hacia el edge de Cloudflare. Una de esas conexiones
|
||||
presentaba fallos de control stream recurrentes durante horas.
|
||||
|
||||
!!! warning "Causa de fondo probable"
|
||||
Inestabilidad de UDP/QUIC en la red local (NAT/firewall doméstico).
|
||||
QUIC es más sensible que TCP/HTTP2 a NATs agresivos o middleboxes
|
||||
que no manejan bien tráfico UDP de larga vida — un patrón común en
|
||||
redes domésticas, a diferencia de datacenters con NAT dedicado para
|
||||
este tipo de tráfico.
|
||||
|
||||
## Fix aplicado
|
||||
|
||||
Se agregó la variable de entorno `TUNNEL_TRANSPORT_PROTOCOL=http2` en
|
||||
la configuración del contenedor `cloudflared` (app ZimaOS → Ambiente →
|
||||
variables), forzando HTTP/2 sobre TCP en vez de QUIC/UDP.
|
||||
|
||||
Tras reiniciar el contenedor, el precheck de `cloudflared` confirmó:
|
||||
|
||||
```text
|
||||
Environment is healthy. cloudflared will use 'http2' as primary protocol.
|
||||
```
|
||||
|
||||
y las 4 conexiones pasaron a `protocol=http2`.
|
||||
|
||||
Ver también el ADR correspondiente:
|
||||
[0003 — Por qué HTTP/2 sobre QUIC](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md).
|
||||
|
||||
## Validación
|
||||
|
||||
Curl en loop contra `gitea.cruzcloud.net` tras el cambio: latencia
|
||||
estable ~0.37–0.4s sostenida por 24+ minutos sin un solo 504.
|
||||
|
||||
!!! note "Degradación puntual no relacionada, pendiente de vigilar"
|
||||
Durante la ventana de validación se observó una ventana breve de
|
||||
latencia alta (hasta 39s) y algunos `502`/`530`, coincidiendo con
|
||||
una ejecución pesada de Gitea Actions (build/deploy de Medusa) en
|
||||
el mismo host. Esto es contención de recursos local (CPU/IO del
|
||||
host compitiendo entre el runner de Actions y el resto de los
|
||||
servicios), **no relacionado al fix de protocolo del túnel** — no
|
||||
se reabrió como parte de este incidente.
|
||||
|
||||
**Pendiente:** vigilar si estas ventanas de degradación coinciden
|
||||
sistemáticamente con builds/deploys de Gitea Actions. Si es
|
||||
recurrente, considerar mover el runner (`gitea-runner`) a otro host
|
||||
o limitarle recursos para evitar que compita con el resto de las
|
||||
apps del NAS.
|
||||
@@ -0,0 +1,175 @@
|
||||
# Incidente: `medusa-deploy` atascado sin levantar (2026-08)
|
||||
|
||||
!!! info "Origen"
|
||||
Migrado desde `apps-registry/docs/playbooks/incidente-medusa-crashloop-2026-08.md`.
|
||||
Contenido técnico preservado sin cambios de fondo — solo formato adaptado a MkDocs Material.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Ventana del incidente** | ~2026-08-08 (onset) → 2026-08-10 05:31 UTC (resuelto) |
|
||||
| **Servicio afectado** | `medusa-deploy` en namespace `ecommerce` |
|
||||
| **Impacto** | El rollout nuevo nunca llegaba a estar listo. El pod anterior siguió sirviendo tráfico sin interrupción (`maxUnavailable: 0`) — no hubo downtime de cara al usuario. El impacto fue "no se puede desplegar", no "el servicio está caído". |
|
||||
|
||||
## Síntoma inicial reportado vs. estado real
|
||||
|
||||
El incidente se reportó como **CrashLoopBackOff** ("sigue igual desde
|
||||
ayer, más de 20-24 horas"). Al correr `kubectl get pods -n ecommerce -o wide`
|
||||
esa descripción resultó **inexacta**: no había ningún pod en
|
||||
CrashLoopBackOff. El estado real era:
|
||||
|
||||
```text
|
||||
commerce-postgres-0 1/1 Running 0 4d9h
|
||||
medusa-deploy-59878b956f-hf45b 1/1 Running 0 21h (revisión vieja, sana)
|
||||
medusa-deploy-9d8784d7b-g9jql 0/1 Init:0/2 0 3h56m (revisión nueva, atascada)
|
||||
```
|
||||
|
||||
0 *restarts* en el pod nuevo — no estaba crasheando y reiniciando,
|
||||
estaba **colgado indefinidamente en el primer init container**
|
||||
(`wait-for-postgres`), sin timeout ni backoff que lo sacara de ese
|
||||
estado.
|
||||
|
||||
!!! tip "Lección"
|
||||
Un pod en `Init:X/Y` con 0 restarts y un `CrashLoopBackOff` requieren
|
||||
líneas de investigación distintas — y el primero no dispara las
|
||||
alertas típicas de CrashLoop, lo que probablemente explica por qué
|
||||
pasó desapercibido tanto tiempo. **Verificar siempre el estado real
|
||||
con `kubectl get pods` antes de asumir el tipo de falla que describe
|
||||
quien reporta el incidente.**
|
||||
|
||||
## Hipótesis descartadas (en orden)
|
||||
|
||||
### 1. Credenciales / secret inválido o vacío
|
||||
|
||||
- `kubectl get secret -n ecommerce commerce-secrets` → la clave
|
||||
`DATABASE_URL` existe, longitud correcta (~126 caracteres).
|
||||
- `pg_isready` ejecutado **dentro del propio pod `commerce-postgres-0`**
|
||||
con ese `DATABASE_URL`: `accepting connections`, exit 0.
|
||||
- **Descartada:** el secret existe, tiene el formato correcto y las
|
||||
credenciales son válidas.
|
||||
|
||||
### 2. NetworkPolicy bloqueando tráfico
|
||||
|
||||
- `kubectl describe networkpolicy -n ecommerce ecommerce-network-security`
|
||||
→ `PodSelector: <none>`, ingress allow-all, egress sin restricciones.
|
||||
- **Descartada:** no podía estar bloqueando la conexión de `medusa`
|
||||
hacia `postgres-svc`.
|
||||
|
||||
### 3. Salud de Postgres
|
||||
|
||||
- `SELECT count(*) FROM pg_stat_activity;` → 8 conexiones activas,
|
||||
respuesta inmediata.
|
||||
- `kubectl get endpoints -n ecommerce postgres-svc` → apunta
|
||||
correctamente a `10.42.0.98:5432`.
|
||||
- **Descartada:** Postgres estaba sano.
|
||||
|
||||
### 4. ResourceQuota / LimitRange del namespace
|
||||
|
||||
- `pods: 7/12`, `requests.cpu: 625m/3`, `requests.memory: 1504Mi/4Gi` —
|
||||
todo muy por debajo de los topes. Sin eventos de `exceeded quota`.
|
||||
- **Descartada:** no había presión de cuota.
|
||||
|
||||
### 5. DNS
|
||||
|
||||
- `getent hosts postgres-svc` resolvía a `10.42.0.98` correctamente,
|
||||
tanto en el pod sano como **dentro del propio init container
|
||||
atascado**. CoreDNS sano.
|
||||
- **Descartada:** la resolución DNS funcionaba de forma idéntica en
|
||||
ambos pods.
|
||||
|
||||
### 6. MTU/PMTUD cross-node (blackhole conocido)
|
||||
|
||||
Existe un blackhole de red confirmado (186/186 fallos reproducidos)
|
||||
cuando un pod que necesita hablar con `commerce-postgres-0` queda
|
||||
agendado en un nodo distinto al de Postgres, mitigado con `podAffinity`
|
||||
en `medusa.yaml`.
|
||||
|
||||
- `kubectl get pods -o wide` mostró que los tres pods relevantes
|
||||
estaban en el **mismo nodo** (`k3d-lab-cluster-server-0`).
|
||||
- **Descartada para este incidente puntual** (sin tráfico cross-node
|
||||
involucrado) — pero sigue siendo una condición latente real del
|
||||
cluster. Ver [nota de deuda técnica](#deuda-tecnica-pendiente) abajo.
|
||||
|
||||
## Causa raíz real
|
||||
|
||||
El init container `wait-for-postgres` ejecutaba:
|
||||
|
||||
```sh
|
||||
until pg_isready -d "$DATABASE_URL" -t 5; do ...
|
||||
```
|
||||
|
||||
pasando la URI completa de `DATABASE_URL` a `pg_isready`. En algún
|
||||
momento se agregó el query param `?sslmode=disable` al `DATABASE_URL`
|
||||
generado por `create-commerce-secrets.sh` (repo `scripts`). Con ese
|
||||
query param, el parser de conninfo de `pg_isready` en la imagen
|
||||
`postgres:17-alpine` no lograba interpretar la URI y devolvía:
|
||||
|
||||
```text
|
||||
postgres-svc:5432 - no attempt
|
||||
```
|
||||
|
||||
!!! danger "`exit 3` — `no attempt`"
|
||||
Es un status propio de `pg_isready` que indica que el cliente **ni
|
||||
siquiera intentó conectar** por un problema en los parámetros de
|
||||
conexión (a diferencia de `no response`, que sí implica un intento
|
||||
de conexión fallido).
|
||||
|
||||
Aislamiento reproducido dentro del propio init container atascado:
|
||||
|
||||
- `pg_isready -h postgres-svc -p 5432 -U medusa -d medusa` (sin URI) →
|
||||
`accepting connections`, exit 0.
|
||||
- URI completa **sin** `?sslmode=disable` → también exitosa.
|
||||
- URI completa **con** `?sslmode=disable` → reproducía el fallo.
|
||||
|
||||
Como el `until` no tiene timeout ni backoff máximo, el init container
|
||||
quedaba reintentando indefinidamente sin nunca fallar de forma visible
|
||||
— de ahí que no apareciera como CrashLoopBackOff.
|
||||
|
||||
## Fix aplicado
|
||||
|
||||
`workloads/ecommerce/commerce/medusa.yaml`, init container
|
||||
`wait-for-postgres`:
|
||||
|
||||
```diff
|
||||
- until pg_isready -d "$DATABASE_URL" -t 5; do
|
||||
+ until pg_isready -h postgres-svc -p 5432 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -t 5; do
|
||||
```
|
||||
|
||||
`postgres-svc` y `5432` son fijos (el Service de Postgres);
|
||||
`POSTGRES_USER` y `POSTGRES_DB` ya llegan al contenedor vía
|
||||
`envFrom: secretRef: commerce-secrets`. El chequeo de disponibilidad
|
||||
deja de depender por completo del formato de `DATABASE_URL`.
|
||||
|
||||
Commit `e9c93bf` en `main` de `apps-registry`, sincronizado por Argo CD
|
||||
(`ecommerce-app`) a las `2026-08-10T05:29:32Z`. El pod atascado fue
|
||||
reemplazado automáticamente por uno sano, sin intervención manual
|
||||
adicional.
|
||||
|
||||
## Lecciones aprendidas
|
||||
|
||||
**Separar el chequeo de disponibilidad de las migraciones en init
|
||||
containers distintos fue lo correcto** — permitió aislar el fallo al
|
||||
primer init container sin ambigüedad. La lección no es cambiar esa
|
||||
separación, sino mantenerla: cualquier chequeo de disponibilidad debe
|
||||
usar el mínimo de parámetros necesarios en vez de reusar una URI de
|
||||
conexión pensada para la app.
|
||||
|
||||
**No fue necesario crear pods de diagnóstico efímeros.** Toda la
|
||||
reproducción se hizo con `kubectl exec` sobre pods que ya existían.
|
||||
Crear pods de prueba repetidos consume cuota y puede introducir su
|
||||
propio ruido de scheduling/red — la regla práctica es usar pods
|
||||
existentes cuando el fallo es reproducible ahí, y reservar pods
|
||||
efímeros para cuando la variable a probar (nodo, imagen, versión) no
|
||||
se puede cambiar en un pod ya desplegado.
|
||||
|
||||
## Deuda técnica pendiente
|
||||
|
||||
El blackhole de red cross-node por MTU/PMTUD sigue sin arreglo de
|
||||
fondo (ajustar MTU a `1400` en la red Docker/flannel del cluster k3d).
|
||||
El workaround actual es el `podAffinity` en `medusa.yaml` que fija
|
||||
`medusa-deploy` al mismo nodo que `commerce-postgres-0`.
|
||||
|
||||
Existe una branch abierta, `fix/medusa-wait-for-postgres`, que elimina
|
||||
ese `podAffinity` y borra `docs/known-issues.md` **sin incluir el fix
|
||||
de MTU real**. Mergearla tal como está reintroduciría el blackhole
|
||||
cross-node sin red de contención. Queda pendiente decidir si se cierra
|
||||
sin mergear, o si se retoma agregando primero el fix real de MTU.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Incidente: imágenes de producto en blanco (proxy host de `media.cruzcloud.net` mal configurado en NPM) (2026-08)
|
||||
|
||||
!!! info "Origen"
|
||||
Migrado desde `apps-registry/docs/playbooks/incidente-medusa-imagenes-proxy-npm-2026-08.md`.
|
||||
Contenido técnico preservado sin cambios de fondo — solo formato adaptado a MkDocs Material.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Ventana del incidente** | 2026-08-13, resuelto el mismo día |
|
||||
| **Servicio afectado** | `media.cruzcloud.net` (Nginx Proxy Manager → MinIO, `minio-svc` en namespace `ecommerce`) |
|
||||
| **Impacto** | Las imágenes subidas a productos desde el Admin de Medusa no se visualizaban en el detalle de producto del storefront (`shop.cruzcloud.net`) — el resto del producto (título, precio, subtítulo, botón añadir al carrito) se renderizaba bien |
|
||||
|
||||
## Síntoma
|
||||
|
||||
Al agregar imágenes a un producto desde el Admin de Medusa (sección
|
||||
Media), estas se veían correctamente cargadas en el Admin, pero en la
|
||||
página de detalle de producto del frontend los thumbnails aparecían
|
||||
vacíos/en blanco.
|
||||
|
||||
## Diagnóstico
|
||||
|
||||
1. **Storage:** confirmado que Medusa usa el provider
|
||||
`@medusajs/medusa/file-s3` apuntando a MinIO interno
|
||||
(`S3_ENDPOINT=http://minio-svc:9000`, bucket `ari-products`), con
|
||||
URLs públicas construidas sobre
|
||||
`S3_FILE_URL=https://media.cruzcloud.net/ari-products` (env en
|
||||
configmap `commerce-config`, namespace `ecommerce`).
|
||||
2. **API:** el producto traía URLs absolutas válidas, ej.
|
||||
`https://media.cruzcloud.net/ari-products/<archivo>.jpg`.
|
||||
3. **Prueba directa de la URL:**
|
||||
- Contra el ingress interno del cluster (bypaseando el proxy
|
||||
externo, `Host: media.cruzcloud.net` directo a la IP del
|
||||
`k3d-proxy`) → `200 OK`, MinIO sirve el JPEG correctamente.
|
||||
- Contra `https://media.cruzcloud.net/...` (la ruta real que usa el
|
||||
navegador del cliente, vía Cloudflare) → `500 Internal Server
|
||||
Error`, `Server: openresty`.
|
||||
|
||||
!!! tip "Patrón de diagnóstico clave"
|
||||
Ese contraste (funciona interno, falla público, con
|
||||
`Server: openresty` — el motor de Nginx Proxy Manager) aisló el
|
||||
problema al **proxy externo**, no a Medusa/MinIO/frontend.
|
||||
|
||||
## Causa raíz
|
||||
|
||||
En Nginx Proxy Manager (`/DATA/AppData/nginxproxymanager/`, contenedor
|
||||
`nginxproxymanager`), el proxy host de `media.cruzcloud.net`
|
||||
(`id=13` en la tabla `proxy_host` de `database.sqlite`) estaba mal
|
||||
configurado:
|
||||
|
||||
```text
|
||||
forward_host = "http://192.168.68.61/" -- esquema y barra final metidos dentro del campo (formato inválido)
|
||||
forward_port = 5005 -- puerto donde no escucha ningún proceso en el host
|
||||
```
|
||||
|
||||
Los proxy hosts que sí funcionan (`shop.cruzcloud.net` id 23,
|
||||
`commerce.cruzcloud.net` id 28) apuntan correctamente a:
|
||||
|
||||
```text
|
||||
forward_host = "192.168.68.61"
|
||||
forward_port = 90 -- puerto publicado por k3d-lab-cluster-serverlb hacia Traefik
|
||||
```
|
||||
|
||||
Se confirmó con `ss`/`docker ps` en el host que nada escuchaba en el
|
||||
puerto `5005` — este proxy host nunca estuvo apuntando correctamente
|
||||
al cluster, muy probablemente un typo desde que se creó.
|
||||
|
||||
## Fix aplicado
|
||||
|
||||
1. Backups previos: `database.sqlite.bak-pre-media-fix` y
|
||||
`13.conf.bak-pre-media-fix`.
|
||||
2. Corrección directa en la base SQLite de NPM:
|
||||
```sql
|
||||
UPDATE proxy_host SET forward_host='192.168.68.61', forward_port=90 WHERE id=13;
|
||||
```
|
||||
3. NPM regeneró automáticamente `nginx/proxy_host/13.conf` con los
|
||||
valores correctos (la app Node de NPM detecta el cambio en la base
|
||||
y reescribe el `.conf`; no hace falta editarlo a mano).
|
||||
4. Recarga de nginx dentro del contenedor:
|
||||
```bash
|
||||
docker exec nginxproxymanager nginx -t
|
||||
docker exec nginxproxymanager nginx -s reload
|
||||
```
|
||||
|
||||
Adicionalmente, se seteó el campo `thumbnail` del producto de prueba
|
||||
desde el Admin (estaba en `null`) — Medusa no lo autocompleta a partir
|
||||
del array `images`, y el frontend lo usa como imagen hero en listados/
|
||||
tarjetas de catálogo.
|
||||
|
||||
## Validación
|
||||
|
||||
Confirmado visualmente en
|
||||
`shop.cruzcloud.net/product/cybertron-sentinel-figura-de-coleccion-test-revalidate`
|
||||
que tanto la galería del detalle como el thumbnail en listados/tarjetas
|
||||
se renderizan correctamente. También se validó que ediciones de
|
||||
descripción/título/precio desde el Admin se reflejan automáticamente
|
||||
en el storefront sin necesidad de restart ni intervención manual, vía
|
||||
el subscriber `revalidate-storefront.ts`
|
||||
(`workloads/commerce-backend/src/subscribers/`) que procesa el evento
|
||||
`product.updated` de Medusa.
|
||||
|
||||
## Lecciones aprendidas
|
||||
|
||||
**Aislar interno vs. público antes de sospechar de la app.** Medusa,
|
||||
MinIO y el frontend estaban sanos todo el tiempo — la falla estaba en
|
||||
una capa de infraestructura fuera de los repos de GitOps (Nginx Proxy
|
||||
Manager, gestionado por fuera de `apps-registry`/`platform-infra`, sin
|
||||
versionar). Comparar la misma petición por la ruta interna del cluster
|
||||
contra la ruta pública real fue lo que aisló la causa en minutos, sin
|
||||
necesidad de tocar Medusa ni el frontend.
|
||||
|
||||
Ver [Red y exposición](../arquitectura/red-y-exposicion.md) para la
|
||||
topología completa Cloudflare Tunnel → Nginx Proxy Manager → k3d y el
|
||||
patrón a seguir si otro subdominio (`*.cruzcloud.net`) presenta el
|
||||
mismo síntoma (`500`/`Server: openresty` público pero sano
|
||||
internamente).
|
||||
@@ -0,0 +1,103 @@
|
||||
# Incidente: precios `null` en detalle de producto (Medusa Store API sin `region_id`) (2026-08)
|
||||
|
||||
!!! info "Origen"
|
||||
Migrado desde `apps-registry/docs/playbooks/incidente-medusa-precios-region-id-2026-08.md`.
|
||||
Contenido técnico preservado sin cambios de fondo — solo formato adaptado a MkDocs Material.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Ventana del incidente** | 2026-08-12, resuelto el mismo día (commit `60072cf`) |
|
||||
| **Servicio afectado** | `medusa-svc` (Store API), consumido por el frontend Next.js de ARI Shopping |
|
||||
| **Impacto** | En el listado de productos (`/catalog`) los precios se mostraban correctamente, pero en la página de detalle de producto individual varios artículos mostraban "Consultar precio" en vez del monto real |
|
||||
|
||||
## Hipótesis inicial descartada: regiones COP duplicadas
|
||||
|
||||
La primera hipótesis fue que existían regiones "Colombia (COP)"
|
||||
duplicadas en Medusa, causando ambigüedad al resolver el precio. Se
|
||||
descartó con evidencia directa en Postgres, consultando la tabla
|
||||
`region` **incluyendo soft-deletes**:
|
||||
|
||||
```sql
|
||||
SELECT id, name, currency_code, created_at, updated_at, deleted_at FROM region ORDER BY created_at;
|
||||
|
||||
id | name | currency_code | created_at | updated_at | deleted_at
|
||||
----------------------------------+----------+---------------+----------------------------+----------------------------+------------
|
||||
reg_01KZT86WPAH7A7ZZRDSX3350V2 | Colombia | cop | 2026-08-12 05:48:03.151+00 | 2026-08-12 05:48:03.151+00 |
|
||||
(1 row)
|
||||
```
|
||||
|
||||
Una sola fila, creada una sola vez, nunca actualizada, nunca borrada.
|
||||
**No había ni hubo nunca una región duplicada** — la hipótesis de
|
||||
deduplicación quedó descartada con esta consulta.
|
||||
|
||||
## Causa raíz real
|
||||
|
||||
El backend de Medusa **no tenía ninguna región configurada**. El
|
||||
frontend pedía precios al Store API (`/store/products`) usando el
|
||||
parámetro `currency_code=cop`, pero el validador
|
||||
`StoreGetProductsParams` de Medusa v2 (`.strict()`) **no acepta ese
|
||||
campo** — devolvía `400 Unrecognized fields: 'currency_code'` en cada
|
||||
carga de `/catalog`.
|
||||
|
||||
Se probó también `country_code`, que tampoco funcionó — Medusa
|
||||
respondía:
|
||||
|
||||
```text
|
||||
Missing required pricing context to calculate prices - region_id
|
||||
```
|
||||
|
||||
!!! danger "Nota de arquitectura"
|
||||
**Medusa v2 requiere explícitamente un `region_id` válido** para
|
||||
poder calcular precios (`calculated_price`) vía Store API; ni
|
||||
`currency_code` ni `country_code` son parámetros válidos para ese
|
||||
fin.
|
||||
|
||||
## Fix aplicado
|
||||
|
||||
Commit `60072cf` — *"fix: usar region_id en vez de currency_code para
|
||||
precios de Medusa"* (PR #5, mergeado en `04dc6bd`):
|
||||
|
||||
1. Se creó la región "Colombia" (moneda COP) vía Admin API — no
|
||||
existía ninguna región configurada previamente.
|
||||
2. Se modificó el frontend para pedir precios usando `region_id`
|
||||
explícito en vez de `currency_code`, vía una nueva variable de
|
||||
entorno `MEDUSA_REGION_ID`:
|
||||
|
||||
```diff
|
||||
# workloads/ecommerce/frontend.yaml
|
||||
- name: MEDUSA_BACKEND_URL
|
||||
value: http://medusa-svc:9000
|
||||
+ - name: MEDUSA_REGION_ID
|
||||
+ value: reg_01KZT86WPAH7A7ZZRDSX3350V2
|
||||
```
|
||||
|
||||
```diff
|
||||
# workloads/ecommerce/lib/headless/providers/medusa.ts
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
- currency_code: "cop",
|
||||
+ // El validador de /store/products no acepta currency_code (400
|
||||
+ // "Unrecognized fields"); el precio calculado solo se resuelve con
|
||||
+ // region_id.
|
||||
+ region_id: regionId(),
|
||||
```
|
||||
|
||||
No hubo migración de precios entre regiones — no existían datos
|
||||
previos que migrar; la región se creó de cero como parte de este mismo
|
||||
fix. `MEDUSA_REGION_ID` no ha cambiado desde el commit original
|
||||
(verificado con `git log -p --follow` sobre `frontend.yaml`), y sigue
|
||||
apuntando a la única región que existe en la base.
|
||||
|
||||
## Lecciones aprendidas
|
||||
|
||||
**Verificar la causa raíz contra el estado real de la base de datos
|
||||
antes de asumir la primera hipótesis**, aun cuando esa hipótesis
|
||||
coincida con la intuición inicial de quien reporta el bug ("puede que
|
||||
haya algo duplicado"). La consulta a `region` incluyendo `deleted_at`
|
||||
tomó segundos y evitó documentar una causa raíz incorrecta en este
|
||||
mismo playbook.
|
||||
|
||||
**Nota de arquitectura:** Medusa v2 Store API (`StoreGetProductsParams`)
|
||||
requiere `region_id` explícito para resolver precios calculados.
|
||||
`currency_code` y `country_code` no son parámetros válidos para ese
|
||||
fin.
|
||||
@@ -0,0 +1,19 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: docs-portal-ingress
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: web
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: docs.cruzcloud.net
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: docs-portal-svc
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,7 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: docs-portal
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
@@ -0,0 +1,112 @@
|
||||
site_name: CruzCloud Lab
|
||||
site_description: >-
|
||||
Portal de documentación del lab GitOps CruzCloud — arquitectura, decisiones
|
||||
y playbooks de incidentes reales (ZimaOS + k3d + Gitea + Argo CD).
|
||||
site_url: https://docs.cruzcloud.net/
|
||||
repo_url: https://gitea.cruzcloud.net/devops/apps-registry
|
||||
repo_name: devops/apps-registry
|
||||
edit_uri: _blank
|
||||
|
||||
docs_dir: docs
|
||||
|
||||
theme:
|
||||
name: material
|
||||
language: es
|
||||
palette:
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
primary: indigo
|
||||
accent: indigo
|
||||
toggle:
|
||||
icon: material/weather-night
|
||||
name: Cambiar a modo oscuro
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
scheme: slate
|
||||
primary: indigo
|
||||
accent: indigo
|
||||
toggle:
|
||||
icon: material/weather-sunny
|
||||
name: Cambiar a modo claro
|
||||
features:
|
||||
- navigation.tabs
|
||||
- navigation.tabs.sticky
|
||||
- navigation.sections
|
||||
- navigation.top
|
||||
- navigation.footer
|
||||
- navigation.indexes
|
||||
- search.suggest
|
||||
- search.highlight
|
||||
- content.code.copy
|
||||
- content.tabs.link
|
||||
- toc.follow
|
||||
|
||||
plugins:
|
||||
- search:
|
||||
lang: es
|
||||
# TODO (fase 2): git-revision-date-localized deshabilitado a propósito.
|
||||
# El Dockerfile construye con context: workloads/docs-portal/, que no
|
||||
# incluye .git (vive en la raíz del repo) — el plugin no tiene historial
|
||||
# real que leer y cae siempre en fallback_to_build_date, emitiendo un
|
||||
# WARNING por página. mkdocs build --strict aborta ante CUALQUIER
|
||||
# WARNING, así que mientras no se le dé contexto de build con .git real
|
||||
# (mover el build a la raíz del repo + fetch-depth:0 en el workflow),
|
||||
# este plugin debe quedar fuera para no romper el pipeline.
|
||||
# - git-revision-date-localized:
|
||||
# enable_creation_date: true
|
||||
# type: date
|
||||
# fallback_to_build_date: true
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- attr_list
|
||||
- md_in_html
|
||||
- tables
|
||||
- toc:
|
||||
permalink: true
|
||||
- pymdownx.details
|
||||
- pymdownx.superfences:
|
||||
custom_fences:
|
||||
- name: mermaid
|
||||
class: mermaid
|
||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||
- pymdownx.highlight:
|
||||
anchor_linenums: true
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.snippets
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
|
||||
nav:
|
||||
- Inicio: index.md
|
||||
- Arquitectura:
|
||||
- Visión general: arquitectura/vision-general.md
|
||||
- Red y exposición: arquitectura/red-y-exposicion.md
|
||||
- Flujo GitOps: arquitectura/gitops-flujo.md
|
||||
- Glosario: arquitectura/glosario.md
|
||||
- Decisiones (ADRs):
|
||||
- "0001 · Por qué k3d": decisiones/0001-por-que-k3d.md
|
||||
- "0002 · Por qué Argo CD": decisiones/0002-por-que-argocd-vs-manual.md
|
||||
- "0003 · HTTP/2 sobre QUIC": decisiones/0003-por-que-http2-sobre-quic-tunnel.md
|
||||
- Plantilla ADR: decisiones/plantilla-adr.md
|
||||
- Playbooks:
|
||||
- Crashloop Medusa: playbooks/incidente-crashloop-medusa.md
|
||||
- 504 túnel Gitea: playbooks/incidente-504-gitea-tunnel.md
|
||||
- Precios Medusa: playbooks/incidente-precios-medusa.md
|
||||
- Imágenes NPM: playbooks/incidente-imagenes-npm.md
|
||||
- Aprendizajes:
|
||||
- Notas sueltas: aprendizajes/notas-sueltas.md
|
||||
- Guía del estudiante:
|
||||
- Inicio: guia-estudiante/README.md
|
||||
- Conceptos básicos: guia-estudiante/conceptos-basicos.md
|
||||
- DevSecOps:
|
||||
- Resumen: devsecops/index.md
|
||||
- Gitleaks (secretos): devsecops/gitleaks.md
|
||||
- Trivy (imagen + IaC): devsecops/trivy.md
|
||||
- SAST (Semgrep): devsecops/sast.md
|
||||
- SBOM (Syft): devsecops/sbom.md
|
||||
- Cosign (firma de imágenes): devsecops/cosign.md
|
||||
|
||||
extra:
|
||||
social:
|
||||
- icon: fontawesome/brands/git-alt
|
||||
link: https://gitea.cruzcloud.net/devops
|
||||
@@ -0,0 +1,3 @@
|
||||
mkdocs==1.6.*
|
||||
mkdocs-material==9.5.*
|
||||
# mkdocs-git-revision-date-localized-plugin==1.2.* # deshabilitado, ver TODO en mkdocs.yml
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: docs-portal-svc
|
||||
spec:
|
||||
selector:
|
||||
app: docs-portal
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
@@ -305,6 +305,18 @@ RUN apt-get update \
|
||||
'cap_net_bind_service=+ep' \
|
||||
/usr/local/bin/node
|
||||
|
||||
# El runtime solo ejecuta "node server.js" (standalone output de Next.js);
|
||||
# nunca invoca npm/npx/corepack. Sacarlos del stage final reduce el árbol
|
||||
# de dependencias escaneado por Trivy a lo que realmente corre en
|
||||
# producción, en vez de arrastrar el npm completo de la imagen base
|
||||
# (con sus propias deps y CVEs, ej. CVE-2026-59873 en node-tar).
|
||||
RUN rm -rf \
|
||||
/usr/local/lib/node_modules/npm \
|
||||
/usr/local/lib/node_modules/corepack \
|
||||
/usr/local/bin/npm \
|
||||
/usr/local/bin/npx \
|
||||
/usr/local/bin/corepack
|
||||
|
||||
COPY --from=builder \
|
||||
--chown=nextjs:nodejs \
|
||||
/app/public \
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhjg9/nC0u+iEANiHkVJY8iN+LZo+
|
||||
VFMF7XG/oC64W3/SfwrPgt+ZIqF6t+ceyrNuEgugajvUdpigz1PHEqQKLw==
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -16,7 +16,7 @@ spec:
|
||||
- name: gitea-registry-secret
|
||||
containers:
|
||||
- name: web
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.91
|
||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.104
|
||||
ports:
|
||||
- containerPort: 80
|
||||
env:
|
||||
|
||||
Reference in New Issue
Block a user