Compare commits

..
Author SHA1 Message Date
devops be3e5c2a4c docs(devsecops): documentar 4+1 hallazgos de infraestructura y TODOs de Cosign
5 playbooks nuevos en docs/playbooks/ del portal:
- SQLite en modo DELETE causando SQLITE_BUSY, por que WAL lo resuelve.
- Anchors YAML no soportados por el parser de Gitea Actions, incluido
  el diagnostico equivocado inicial (indentacion) que se corrigio
  despues -- documentado con honestidad como leccion de proceso.
- Docker-in-Docker vs sibling containers, y por que Semgrep corre
  nativo en un venv en vez de como container aparte.
- El SNI/realm HTTP del tunel rompiendo especificamente a Cosign
  (docker push funcionaba, cosign sign no), conectado con el
  incidente ya documentado del 504 -- misma capa de ingress, dos
  fallas distintas.
- Contencion de recursos entre Gitea y su runner: causa raiz real
  confirmada en vivo durante esta misma sesion (ambos contenedores
  reiniciados a mitad de un build, sin limites de CPU/memoria reales),
  con fix propuesto (ver rama fix/gitea-runner-resource-limits en
  scripts/) pendiente de aplicacion manual.

Se actualiza el playbook del 504 para linkear hacia el nuevo hallazgo
de contencion, cerrando el loop que habia quedado abierto ahi.

cosign.md: nueva seccion "Limitaciones conocidas y mejoras futuras"
con los dos TODO reales -- firma por tag en vez de digest (con el
warning real de Cosign visto en los logs) y transparency log de Rekor
deshabilitado, explicado en simple.

index.md: tabla resumen de los 3 pipelines (imagen + adaptacion de
Semgrep por stack), diagrama generalizado para reflejar que es el
mismo patron en los 3 workflows, y "que falta" actualizado.

Validado con mkdocs build --strict (0 errores, 0 warnings) antes de
commitear.
2026-08-15 15:17:43 -05:00
gitea-actions 826f199ebc chore(gitops): deploy Docs Portal v1.0.110 [skip ci] 2026-08-15 19:48:50 +00:00
devops 5c47a39579 fix(docs-portal): remediar CVE CRITICAL en libssl3/libcrypto3 (Alpine)
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 21s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 4m25s
Run 144 de deploy-docs.yaml encontro un CVE CRITICAL real
(CVE-2026-31789, heap buffer overflow en OpenSSL) en libssl3/libcrypto3
de la base nginx:1.27-alpine, con fix ya publicado por Alpine
(3.3.3-r0 -> 3.3.7-r0). Se agrega apk upgrade puntual de esos dos
paquetes en el Dockerfile. Verificado localmente: docker build OK +
Trivy real en exit 0 (0 vulnerabilidades).
2026-08-15 14:43:52 -05:00
devops 3ee7171178 Merge: incorporar promocion GitOps automatica de Medusa v1.0.108
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 1m27s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Failing after 4m56s
2026-08-15 14:30:52 -05:00
devops 81c0f99e00 Merge fix/devsecops-docs-portal: pipeline de seguridad para docs-portal 2026-08-15 14:30:37 -05:00
gitea-actions 38722b97a8 chore(gitops): deploy Medusa v1.0.108 [skip ci] 2026-08-15 19:28:44 +00:00
devops 5fc86e6ef4 chore: reintentar pipeline de commerce-backend (run 107 murio por reinicio de Gitea, no por el pipeline)
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 10m59s
2026-08-15 14:17:18 -05:00
devops 93f071c175 Merge fix/devsecops-medusa-cve-remediation: remediacion CVE + excepcion documentada
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 21s
Build and Push Medusa / Construir y publicar Medusa (push) Failing after 22m14s
2026-08-15 13:50:09 -05:00
devops 0c1076eda0 fix(commerce-backend): remediar 3/5 CVE CRITICAL, documentar excepcion para 2
Run 138 de build-medusa.yaml encontro 5 CVE CRITICAL reales (no falsos
positivos) al escanear la imagen con Trivy:

Remediados en el Dockerfile (verificado localmente con Trivy real,
exit 0):
- CVE-2026-33845 / CVE-2026-42010 (libgnutls30, base Debian): apt
  upgrade puntual del paquete.
- CVE-2026-59873 (tar vendorizado dentro del npm CLI global de la
  imagen base node:*-slim): se elimina /usr/local/lib/node_modules/npm
  completo -- no es una dependencia real del proyecto (el CMD nunca
  invoca npm en runtime), asi que no hay nada que "actualizar".

Documentados como excepcion en workloads/commerce-backend/.trivyignore
(con --ignorefile solo en este workflow, no afecta a build.yaml ni
deploy-docs.yaml):
- CVE-2024-24790 / CVE-2025-68121 (Go stdlib embebido en el binario de
  esbuild que trae [email protected], dependencia interna de
  @medusajs/admin-sdk). Se probo el fix real (override de esbuild a
  una version mas nueva) y rompio el build del admin panel --
  [email protected] fija "esbuild: ^0.21.3" como dependencia directa, y
  esbuild >=0.24 cambio el manejo de targets de transpilacion que vite
  5 espera. No existe un patch dentro de la serie 0.21.x con un Go
  toolchain mas nuevo. Requiere subir @medusajs/admin-sdk/vite en un
  cambio aparte, fuera de alcance de este pipeline de seguridad.
2026-08-15 13:49:51 -05:00
devops 80d99f9f3f Merge fix/devsecops-trivy-timeout: fix real tras primer intento fallido (run 136)
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 20s
Build and Push Medusa / Construir y publicar Medusa (push) Failing after 15m1s
2026-08-15 13:10:55 -05:00
devops 81f01c7ade fix(devsecops): timeout de 15m en Trivy imagen para commerce-backend
El run 136 (primer intento de este pipeline, recien mergeado a main)
fallo en el paso "Escanear imagen (Trivy) - CRITICAL bloquea" con
"context deadline exceeded" a los 4m52s -- no una CVE, un timeout de
herramienta. Causa: el default de Trivy (5m) no alcanza para escanear
(vuln + secret scan de la imagen completa) el node_modules de Medusa
v2, mas pesado que el del frontend. Se agrega --timeout 15m0s a ambos
escaneos de imagen (CRITICAL y HIGH).
2026-08-15 13:09:47 -05:00
devops 27a1e75a4c fix(devsecops): timeout de 15m en Trivy imagen para docs-portal
Preventivo: mismo comando que en commerce-backend, donde se vio en
vivo que el timeout default de Trivy (5m) no alcanza en este host bajo
carga ("context deadline exceeded" a los 4m52s, run 136). Ver
fix/devsecops-trivy-timeout para el fix real en commerce-backend.
2026-08-15 13:08:24 -05:00
devops 45312fc189 Merge fix/devsecops-commerce-backend: pipeline de seguridad para commerce-backend
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 42s
Build and Push Medusa / Construir y publicar Medusa (push) Failing after 9m31s
2026-08-15 12:51:41 -05:00
devops 1bcae76102 feat(devsecops): replicar pipeline de seguridad a docs-portal
Agrega a deploy-docs.yaml las mismas 6 etapas ya validadas en
build.yaml (run 134, frontend): Gitleaks, Trivy IaC, Semgrep (SAST),
Trivy imagen, Syft (SBOM) y Cosign (firma + verificación).

Adaptaciones de stack:
- Trivy IaC escanea todo workloads/docs-portal/ (varios manifiestos
  K8s separados: deployment/service/ingress), no un MANIFEST_FILE
  único como el frontend.
- Semgrep usa p/python + p/security-audit (no hay TypeScript/React
  acá) -- docs-portal no tiene código de aplicación propio hoy (solo
  Markdown + config de MkDocs), así que 0 hallazgos es el resultado
  esperado; el stage queda listo para el día que se agregue un
  hook/plugin en Python.

Mismos umbrales que el resto: Trivy bloquea en CRITICAL, Semgrep en
modo auditoría. Reutiliza el mismo par de llaves Cosign (secrets ya
existentes a nivel de repo); se agrega workloads/docs-portal/cosign.pub.
2026-08-15 12:47:43 -05:00
devops 8b49ac2bc5 feat(devsecops): replicar pipeline de seguridad a commerce-backend
Agrega a build-medusa.yaml las mismas 6 etapas ya validadas en
build.yaml (run 134, frontend): Gitleaks, Trivy IaC, Semgrep (SAST),
Trivy imagen, Syft (SBOM) y Cosign (firma + verificación).

Ruleset de Semgrep adaptado: p/typescript + p/security-audit +
p/owasp-top-ten, sin p/react/p/nextjs -- commerce-backend es la API de
Medusa v2 (Node/TypeScript), no una app con SSR. Mismos umbrales que
el frontend: Trivy bloquea en CRITICAL, Semgrep en modo auditoría
(no bloquea todavía).

Reutiliza el mismo par de llaves Cosign del frontend (secrets ya
existentes a nivel de repo); se agrega workloads/commerce-backend/cosign.pub
con la misma llave pública para que la verificación quede local a
este workflow.
2026-08-15 12:46:45 -05:00
devops c25877c700 chore: release v1.0.104 [skip ci] 2026-08-15 06:23:20 +00:00
devops 941c009c9c Merge pull request 'chore: retrigger real del pipeline (take 4)' (#25) from chore/retrigger-pipeline-take4 into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 21s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 7m26s
2026-08-15 06:15:09 +00:00
devops 5d3d578d36 chore: retrigger real del pipeline (comentario en build.yaml, path filtrado)
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 29s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
2026-08-15 01:14:59 -05:00
devops ff1f1358a7 Merge pull request 'chore: reintentar disparo del pipeline (take 3)' (#24) from chore/retrigger-pipeline-take3 into main 2026-08-15 06:10:55 +00:00
devops f9a4b0a4ea chore: reintentar disparo del pipeline (take 3, runner reiniciado) 2026-08-15 01:10:42 -05:00
devops 69db467edd Merge pull request 'chore: reintentar disparo del pipeline (take 2)' (#23) from chore/retrigger-pipeline-take2 into main 2026-08-15 06:09:07 +00:00
devops 63de0b4ed3 chore: reintentar disparo del pipeline (la corrida anterior coincidió con inestabilidad transitoria del túnel) 2026-08-15 01:08:56 -05:00
devops 9418fb4d1a Merge pull request 'chore: re-disparar pipeline tras fix del tunel Cloudflare' (#22) from chore/retrigger-pipeline-cosign-fix into main 2026-08-15 06:03:47 +00:00
devops c492269740 chore: re-disparar pipeline tras arreglar el realm https del túnel Cloudflare→NPM
El fix fue en infraestructura (regla de ingress de cloudflared para
gitea.cruzcloud.net -> HTTPS con Origin Server Name correcto), no en
este repo -- este commit vacío es solo para generar un push nuevo y
validar el pipeline completo (incluida la firma de Cosign) de punta
a punta con las credenciales reales.
2026-08-15 01:03:33 -05:00
devops 1f467f2b8f Merge pull request 'fix(ci): correr Semgrep nativo (venv) en vez de docker run anidado' (#21) from fix/build-yaml-anchor-not-supported into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 17s
Build and Push Frontend / Construir y Subir Imagen (push) Failing after 11m59s
2026-08-15 04:50:41 +00:00
devops e3ab3f7fc0 fix(ci): correr Semgrep nativo (venv) en vez de docker run anidado
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 22s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
El runner de Gitea ejecuta cada job dentro de un contenedor propio que
habla con el daemon Docker del host (sibling containers) -- un
`docker run -v "\${{ github.workspace }}:/src"` desde ahí intenta
montar una ruta que solo existe DENTRO del contenedor del job, no en
el host real: "mkdir /workspace: read-only file system". Confirmado en
el run real (job 128, task_id 128) tras el fix del anchor.

Se valida localmente contra docker.gitea.com/runner-images:ubuntu-latest
(la misma imagen del runner real) que python3/pip3 están disponibles.
pip3 install directo falla por paquetes de apt sin metadata compatible
(PyJWT, "RECORD file not found") -- se usa un venv aislado en su lugar,
validado end-to-end con los dos steps extraídos del YAML real.
2026-08-14 23:50:23 -05:00
devops 1df1c3a3c1 Merge pull request 'fix(ci): quitar anchors/aliases YAML de build.yaml' (#20) from fix/build-yaml-anchor-not-supported into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 25s
Build and Push Frontend / Construir y Subir Imagen (push) Failing after 42s
2026-08-15 04:25:51 +00:00
devops 6773a8085d fix(ci): quitar anchors/aliases YAML de build.yaml, no soportados por Gitea Actions
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 39s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Gitea descartaba el workflow completo en TODOS los eventos (push y
pull_request), desde el primer commit de gitleaks: "unknown on type:
&yaml.Node{...Value:"frontend_paths"...}". El YAML era válido (pyyaml
lo parsea sin problema, resolviendo el alias como cualquier parser
estándar) pero el parser propio de Gitea Actions para el bloque "on:"
no resuelve &anchor/*alias antes de inspeccionar el tipo de nodo.

Confirmado en vivo: tras mergear #19 a main, deploy-docs.yaml corrió
normal (sin anchors) pero build.yaml no generó ningún action_run.

Fix: paths duplicados literalmente entre push: y pull_request:, sin
anchor. Válido con parser YAML + bash -n en los 20 steps.
2026-08-14 23:24:31 -05:00
gitea-actions ea7d5e7f47 chore(gitops): deploy Docs Portal v1.0.98 [skip ci] 2026-08-15 04:15:42 +00:00
devops 98096ab69f Merge pull request 'feat(devsecops): Gitleaks + Trivy + Semgrep + Syft + Cosign en el frontend' (#19) from fix/frontend-gitleaks-ci into main
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 1m36s
2026-08-15 04:14:08 +00:00
20 changed files with 1354 additions and 52 deletions
+287 -8
View File
@@ -1,5 +1,15 @@
name: Build and Push Medusa
# Retrigger: el run 107 (con la remediacion de CVEs ya mergeada) fue
# cancelado a mitad de camino porque el stack de Gitea (gitea +
# gitea-runner) se reinicio solo durante el build -- no por el
# contenido del pipeline. Ver hallazgo de contencion de recursos en
# docs/playbooks.
# Sin anchors/aliases de YAML (&x / *x) a propósito -- el parser de
# workflows de Gitea Actions no los resuelve en el bloque "on:" y
# descarta el archivo completo con "unknown on type". Las dos listas de
# paths quedan duplicadas literalmente (mismo criterio que build.yaml).
on:
push:
branches:
@@ -7,17 +17,76 @@ on:
paths:
- 'workloads/commerce-backend/**'
- '.gitea/workflows/build-medusa.yaml'
pull_request:
branches:
- main
paths:
- 'workloads/commerce-backend/**'
- '.gitea/workflows/build-medusa.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/commerce-backend \
--no-git \
--redact \
--report-format=json \
--report-path=gitleaks-report.json \
--exit-code=1
- name: Publicar reporte de Gitleaks
if: always()
uses: actions/upload-artifact@v3
with:
name: gitleaks-report
path: gitleaks-report.json
if-no-files-found: ignore
build:
name: Construir y publicar Medusa
needs: gitleaks
if: github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 45
env:
APP_DIR: workloads/commerce-backend
MANIFEST_FILE: workloads/ecommerce/commerce/medusa.yaml
IMAGE_NAME: gitea.cruzcloud.net/devops/ecommerce-medusa
steps:
- name: Checkout del código
uses: actions/checkout@v3
@@ -48,6 +117,82 @@ jobs:
exit 1
}
- name: Instalar Trivy
shell: bash
run: |
set -euo pipefail
TRIVY_VERSION="0.74.0"
curl -sSfL \
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" \
-o /tmp/trivy.tar.gz
tar -xzf /tmp/trivy.tar.gz -C /tmp trivy
chmod +x /tmp/trivy
/tmp/trivy version
# Escanea el manifiesto Kubernetes real de Medusa (no la imagen).
# Informativo por ahora -- mismo criterio que el frontend, se
# revisan los hallazgos en conjunto antes de decidir qué bloquea.
- name: Escanear manifiestos Kubernetes (Trivy IaC)
shell: bash
run: |
set -euo pipefail
/tmp/trivy config \
--severity CRITICAL,HIGH,MEDIUM \
--exit-code 0 \
"${MANIFEST_FILE}"
# Mismo motivo que en build.yaml: el runner ejecuta el job ya
# dentro de un contenedor propio que habla con el daemon Docker
# del host (sibling containers, no Docker-in-Docker) -- correr
# Semgrep como container aparte falla montando rutas que no
# existen en el host real. Se instala nativo en un venv.
- name: Instalar Semgrep
shell: bash
run: |
set -euo pipefail
python3 -m venv /tmp/semgrep-venv
/tmp/semgrep-venv/bin/pip install --quiet "semgrep==1.173.0"
/tmp/semgrep-venv/bin/semgrep --version
# Ruleset adaptado al stack real de este repo: commerce-backend es
# una API de Medusa v2 (Node.js/TypeScript sobre Express), no una
# app Next.js con SSR -- confirmado en package.json (sin
# dependencia de "next", react/react-dom solo llegan como
# dependencia transitiva del admin-sdk de Medusa, no hay código de
# UI propio en este directorio). Por eso se usa p/typescript en
# vez de p/typescript + p/react + p/nextjs del frontend.
# Modo auditoría, igual que el frontend: sin --error, primera
# vuelta para revisar hallazgos antes de decidir qué bloquea.
- name: Escaneo SAST (Semgrep) — modo auditoría, no bloquea
shell: bash
run: |
set -euo pipefail
/tmp/semgrep-venv/bin/semgrep scan \
--config=p/typescript \
--config=p/security-audit \
--config=p/owasp-top-ten \
--json \
--output=semgrep-report.json \
"${APP_DIR}"
echo "=== Resumen Semgrep ==="
/tmp/semgrep-venv/bin/python -c "
import json
data = json.load(open('semgrep-report.json'))
results = data.get('results', [])
print(f'Hallazgos: {len(results)}')
for r in results:
print(f\" [{r['extra']['severity']}] {r['check_id']} - {r['path']}:{r['start']['line']}\")
"
- name: Publicar reporte de Semgrep
if: always()
uses: actions/upload-artifact@v3
with:
name: semgrep-report
path: semgrep-report.json
if-no-files-found: ignore
- name: Login en Gitea Registry
uses: docker/login-action@v2
with:
@@ -56,15 +201,138 @@ jobs:
password: ${{ secrets.REGISTRY_PASSWORD }}
logout: true
- name: Construir y subir imagen
# push: false — igual que el frontend, la imagen se queda cargada
# en el daemon local para poder escanearla con Trivy antes de
# subirla al registry.
- name: Construir Imagen
uses: docker/build-push-action@v4
with:
context: workloads/commerce-backend/
file: workloads/commerce-backend/Dockerfile
push: true
context: ${{ env.APP_DIR }}/
file: ${{ env.APP_DIR }}/Dockerfile
push: false
load: true
tags: |
gitea.cruzcloud.net/devops/ecommerce-medusa:${{ steps.vars.outputs.VERSION }}
gitea.cruzcloud.net/devops/ecommerce-medusa: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.
# --timeout 15m0s: el default de Trivy (5m) no alcanza para esta
# imagen -- a diferencia del frontend, commerce-backend arrastra
# el node_modules completo de Medusa v2 (framework + admin-sdk +
# cli), muchos más archivos que escanear tanto para vulnerabilidades
# como para el escaneo de secretos que Trivy corre por default
# dentro de la imagen. Visto en vivo: "context deadline exceeded"
# a los 4m52s con el timeout default, en un run donde el host
# venía de terminar el docker build (ver hallazgo de rendimiento
# de Gitea/runner en docs/playbooks).
# --ignorefile: excepciones puntuales y documentadas (ver
# workloads/commerce-backend/.trivyignore) para CVEs sin fix de
# bajo riesgo disponible hoy. No debilita el gate en general --
# cualquier otra CRITICAL sigue bloqueando igual.
- name: Escanear imagen (Trivy) — CRITICAL bloquea
shell: bash
run: |
set -euo pipefail
/tmp/trivy image \
--severity CRITICAL \
--exit-code 1 \
--ignore-unfixed \
--timeout 15m0s \
--ignorefile "${APP_DIR}/.trivyignore" \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
# HIGH solo informa por ahora — mismo criterio que el frontend.
- name: Escanear imagen (Trivy) — HIGH informativo
shell: bash
run: |
set -euo pipefail
/tmp/trivy image \
--severity HIGH \
--exit-code 0 \
--ignore-unfixed \
--timeout 15m0s \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
- name: Instalar Syft
shell: bash
run: |
set -euo pipefail
SYFT_VERSION="1.51.0"
curl -sSfL \
"https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" \
-o /tmp/syft.tar.gz
tar -xzf /tmp/syft.tar.gz -C /tmp syft
chmod +x /tmp/syft
/tmp/syft version
- name: Generar SBOM (Syft)
shell: bash
run: |
set -euo pipefail
/tmp/syft "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" \
-o cyclonedx-json=sbom.cdx.json \
-o spdx-json=sbom.spdx.json
- name: Publicar SBOM
if: always()
uses: actions/upload-artifact@v3
with:
name: sbom-${{ steps.vars.outputs.VERSION }}
path: |
sbom.cdx.json
sbom.spdx.json
if-no-files-found: ignore
# Login ya se hizo arriba; recién acá se sube, después de que la
# imagen pasó el gate de CRITICAL.
- name: Subir Imagen al Registry
shell: bash
run: |
set -euo pipefail
docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
docker push "${IMAGE_NAME}:latest"
- name: Instalar Cosign
shell: bash
run: |
set -euo pipefail
COSIGN_VERSION="3.1.3"
curl -sSfL \
"https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-amd64" \
-o /tmp/cosign
chmod +x /tmp/cosign
/tmp/cosign version
# Mismo par de llaves que el frontend (COSIGN_PRIVATE_KEY /
# COSIGN_PASSWORD ya existen como secrets a nivel de repo, no hace
# falta un secret nuevo por app): una sola identidad de firma para
# todo el registry de este lab. La llave pública se commitea en
# cada directorio de app (workloads/commerce-backend/cosign.pub)
# para que la verificación quede local a cada workflow, igual que
# el frontend.
- name: Firmar Imagen (Cosign)
shell: bash
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
set -euo pipefail
/tmp/cosign sign \
--key env://COSIGN_PRIVATE_KEY \
--use-signing-config=false \
--tlog-upload=false \
--yes \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
- name: Verificar Firma (smoke test)
shell: bash
run: |
set -euo pipefail
/tmp/cosign verify \
--key "${APP_DIR}/cosign.pub" \
--insecure-ignore-tlog=true \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
- name: Verificar promoción segura
id: promotion
@@ -91,8 +359,8 @@ jobs:
set -euo pipefail
VERSION="${{ steps.vars.outputs.VERSION }}"
MANIFEST="workloads/ecommerce/commerce/medusa.yaml"
IMAGE="gitea.cruzcloud.net/devops/ecommerce-medusa"
MANIFEST="${{ env.MANIFEST_FILE }}"
IMAGE="${{ env.IMAGE_NAME }}"
git config user.name "gitea-actions"
git config user.email "[email protected]"
@@ -115,3 +383,14 @@ jobs:
-m "chore(gitops): deploy Medusa ${VERSION} [skip ci]"
git push origin HEAD:main
- name: Resumen del pipeline
if: always()
shell: bash
run: |
echo "========================================"
echo "ARI Shopping Commerce Backend (Medusa)"
echo "Versión: ${{ steps.vars.outputs.VERSION }}"
echo "Commit: ${{ github.sha }}"
echo "Promoción GitOps: ${{ steps.promotion.outputs.promote }}"
echo "========================================"
+61 -21
View File
@@ -1,10 +1,18 @@
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
paths: &frontend_paths
paths:
# Código y configuración real del frontend.
# Los cambios exclusivos de GitOps (frontend.yaml, ingress, patches, etc.)
# no vuelven a construir la imagen.
@@ -32,7 +40,28 @@ on:
pull_request:
branches:
- main
paths: *frontend_paths
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
@@ -260,6 +289,27 @@ jobs:
--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
@@ -268,27 +318,17 @@ jobs:
shell: bash
run: |
set -euo pipefail
SEMGREP_IMAGE="semgrep/semgrep:1.173.0"
docker run --rm \
-v "${{ github.workspace }}:/src" \
-w /src \
"${SEMGREP_IMAGE}" \
semgrep scan \
--config=p/typescript \
--config=p/react \
--config=p/nextjs \
--config=p/security-audit \
--json \
--output=semgrep-report.json \
"${APP_DIR}"
/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 ==="
docker run --rm \
-v "${{ github.workspace }}:/src" \
-w /src \
"${SEMGREP_IMAGE}" \
python3 -c "
/tmp/semgrep-venv/bin/python -c "
import json
data = json.load(open('semgrep-report.json'))
results = data.get('results', [])
+266 -6
View File
@@ -1,5 +1,9 @@
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:
@@ -10,14 +14,71 @@ on:
- '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
@@ -56,6 +117,83 @@ jobs:
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:
@@ -64,18 +202,129 @@ jobs:
password: ${{ secrets.REGISTRY_PASSWORD }}
logout: true
# mkdocs build --strict corre dentro del propio Dockerfile (stage de
# build), así que un nav/link roto rompe este paso antes de publicar.
- name: Construir y subir imagen
# 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: workloads/docs-portal/
file: workloads/docs-portal/Dockerfile
push: true
context: ${{ env.APP_DIR }}/
file: ${{ env.APP_DIR }}/Dockerfile
push: false
load: true
tags: |
${{ env.IMAGE_NAME }}:${{ steps.vars.outputs.VERSION }}
${{ env.IMAGE_NAME }}:latest
# CRITICAL bloquea el pipeline: no se sube una imagen con una CVE
# crítica conocida y con fix disponible.
# --timeout 15m0s: agregado preventivamente. Se vio en vivo, al
# correr este mismo comando sin el flag en commerce-backend, que
# el default de Trivy (5m) no alcanza en este host bajo carga
# ("context deadline exceeded" a los 4m52s) -- la imagen de
# docs-portal es chica, pero no cuesta nada blindar el mismo
# comando en los tres pipelines.
- name: Escanear imagen (Trivy) — CRITICAL bloquea
shell: bash
run: |
set -euo pipefail
/tmp/trivy image \
--severity CRITICAL \
--exit-code 1 \
--ignore-unfixed \
--timeout 15m0s \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
# HIGH solo informa por ahora — mismo criterio que el resto.
- name: Escanear imagen (Trivy) — HIGH informativo
shell: bash
run: |
set -euo pipefail
/tmp/trivy image \
--severity HIGH \
--exit-code 0 \
--ignore-unfixed \
--timeout 15m0s \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
- name: Instalar Syft
shell: bash
run: |
set -euo pipefail
SYFT_VERSION="1.51.0"
curl -sSfL \
"https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" \
-o /tmp/syft.tar.gz
tar -xzf /tmp/syft.tar.gz -C /tmp syft
chmod +x /tmp/syft
/tmp/syft version
- name: Generar SBOM (Syft)
shell: bash
run: |
set -euo pipefail
/tmp/syft "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}" \
-o cyclonedx-json=sbom.cdx.json \
-o spdx-json=sbom.spdx.json
- name: Publicar SBOM
if: always()
uses: actions/upload-artifact@v3
with:
name: sbom-${{ steps.vars.outputs.VERSION }}
path: |
sbom.cdx.json
sbom.spdx.json
if-no-files-found: ignore
# Login ya se hizo arriba; recién acá se sube, después de que la
# imagen pasó el gate de CRITICAL.
- name: Subir Imagen al Registry
shell: bash
run: |
set -euo pipefail
docker push "${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
docker push "${IMAGE_NAME}:latest"
- name: Instalar Cosign
shell: bash
run: |
set -euo pipefail
COSIGN_VERSION="3.1.3"
curl -sSfL \
"https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-amd64" \
-o /tmp/cosign
chmod +x /tmp/cosign
/tmp/cosign version
# Mismo par de llaves que el frontend y commerce-backend (secrets
# ya existentes a nivel de repo). Llave pública commiteada en
# workloads/docs-portal/cosign.pub.
- name: Firmar Imagen (Cosign)
shell: bash
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
set -euo pipefail
/tmp/cosign sign \
--key env://COSIGN_PRIVATE_KEY \
--use-signing-config=false \
--tlog-upload=false \
--yes \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
- name: Verificar Firma (smoke test)
shell: bash
run: |
set -euo pipefail
/tmp/cosign verify \
--key "${APP_DIR}/cosign.pub" \
--insecure-ignore-tlog=true \
"${IMAGE_NAME}:${{ steps.vars.outputs.VERSION }}"
- name: Verificar promoción segura
id: promotion
shell: bash
@@ -123,3 +372,14 @@ jobs:
-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 "========================================"
+34
View File
@@ -0,0 +1,34 @@
# Excepciones documentadas al gate CRITICAL de Trivy (imagen) para
# commerce-backend. Cada entrada requiere justificación y fecha -- no
# es un mecanismo para silenciar hallazgos sin revisar.
#
# CVE-2024-24790 / CVE-2025-68121 (golang stdlib, gobinary):
# Van embebidas en el binario precompilado de esbuild
# (app/node_modules/@esbuild/linux-x64/bin/esbuild), traído
# transitivamente por [email protected], que a su vez lo trae
# @medusajs/admin-sdk para bundlear el panel de admin en build time.
# No es un binario que se ejecute en runtime del contenedor (el CMD
# corre "node .../medusa/cli start", nunca esbuild).
#
# Se intentó el fix real (bump de esbuild a una versión compilada con
# un Go toolchain más nuevo, override en package.json) y rompió el
# build del admin: [email protected] declara "esbuild: ^0.21.3" como
# dependencia directa (no rango amplio), y esbuild >=0.24 cambió el
# manejo de la lista de "target" de transpilación que vite 5 pasa
# internamente -- build.yaml falló con
# "Transforming destructuring... not supported yet" /
# PLUGIN_ERROR en vite:esbuild-transpile. No existe un patch dentro
# de la propia serie 0.21.x (0.21.5 ya es la última) que incluya un
# Go toolchain con estas CVEs corregidas.
#
# Domesticar esto de verdad requiere subir @medusajs/admin-sdk (y por
# lo tanto vite) a una versión que dependa de un esbuild más nuevo --
# fuera de alcance de este pipeline de seguridad, queda como TODO de
# dependencias en un cambio aparte, no bloqueado por CI mientras
# tanto.
#
# Documentado: 2026-08-15. Revisar en cada bump de @medusajs/* por si
# ya arrastra una versión de vite/esbuild más nueva y esta excepción
# deja de ser necesaria.
CVE-2024-24790
CVE-2025-68121
+20
View File
@@ -59,6 +59,26 @@ ENV NODE_ENV=production \
PORT=9000 \
NPM_CONFIG_UPDATE_NOTIFIER=false
#
# libgnutls30 en la base node:22.18.0-bookworm-slim trae dos CVE
# CRITICAL con fix ya publicado por Debian (CVE-2026-33845,
# CVE-2026-42010) -- detectado por el gate de Trivy imagen del
# pipeline (run 138). Solo se actualiza este paquete puntual, no toda
# la imagen, para minimizar el diff de superficie de la base.
RUN apt-get update \
&& apt-get upgrade -y libgnutls30 \
&& rm -rf /var/lib/apt/lists/*
#
# El CLI global de npm que trae la imagen base node:*-slim no se usa en
# runtime (el CMD invoca a Medusa directo con `node`, nunca `npm`) y
# arrastra su propia copia vendorizada de `tar` con un CVE CRITICAL
# (CVE-2026-59873, detectado por Trivy en
# usr/local/lib/node_modules/npm/node_modules/tar). Se elimina en vez
# de forzar una versión: no es una dependencia real de este proyecto,
# así que no hay nada que "actualizar" -- solo superficie sin uso.
RUN rm -rf /usr/local/lib/node_modules/npm
#
# Solo lo necesario para ejecutar Medusa
#
+4
View File
@@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhjg9/nC0u+iEANiHkVJY8iN+LZo+
VFMF7XG/oC64W3/SfwrPgt+ZIqF6t+ceyrNuEgugajvUdpigz1PHEqQKLw==
-----END PUBLIC KEY-----
+8
View File
@@ -16,6 +16,14 @@ RUN mkdocs build --strict
# --- Stage 2: sirve el sitio estático generado con nginx ---
FROM nginx:1.27-alpine
# libssl3/libcrypto3 de la base nginx:1.27-alpine traen un CVE CRITICAL
# con fix ya publicado por Alpine (CVE-2026-31789, heap buffer overflow
# en OpenSSL) -- detectado por el gate de Trivy imagen del pipeline
# (run 144). Solo se actualizan estos dos paquetes puntuales.
RUN apk update \
&& apk upgrade --no-cache libssl3 libcrypto3 \
&& rm -rf /var/cache/apk/*
COPY --from=build /site/site /usr/share/nginx/html
EXPOSE 80
+4
View File
@@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhjg9/nC0u+iEANiHkVJY8iN+LZo+
VFMF7XG/oC64W3/SfwrPgt+ZIqF6t+ceyrNuEgugajvUdpigz1PHEqQKLw==
-----END PUBLIC KEY-----
+1 -1
View File
@@ -16,7 +16,7 @@ spec:
- name: gitea-registry-secret
containers:
- name: docs-portal
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.97
image: gitea.cruzcloud.net/devops/docs-portal:v1.0.110
ports:
- containerPort: 80
resources:
@@ -104,6 +104,74 @@ Si la imagen fue firmada por este pipeline, el comando termina con
firmó, porque la firmó otra llave, o porque la imagen fue modificada
después — termina con `exit 1` y un error explícito.
## Limitaciones conocidas y mejoras futuras
Dos limitaciones identificadas al validar el pipeline end-to-end,
documentadas a propósito como TODO — ninguna de las dos se implementó
todavía.
### 1. Se firma por tag, no por digest
Hoy el pipeline firma `ecommerce-frontend:v1.0.104` — un **tag**, no un
**digest** (`sha256:...`). Un tag es una etiqueta mutable: nada impide
que, después de firmada la imagen, alguien (o un bug en el propio
pipeline) vuelva a subir contenido distinto bajo el mismo tag
`v1.0.104`. La firma original seguiría "verificando" — porque Cosign,
al verificar por tag, resuelve el tag al digest que tenga *en ese
momento*, no al que tenía cuando se firmó. Si el tag fue reasignado,
se está verificando una imagen distinta de la que realmente se firmó,
sin que nada avise.
Esto no es hipotético: el propio Cosign lo advierte en cada firma de
este pipeline (visto en los logs reales de cada run):
```text
WARNING: Image reference gitea.cruzcloud.net/.../ecommerce-frontend:v1.0.102
uses a tag, not a digest, to identify the image to sign.
This can lead you to sign a different image than the intended one.
```
**TODO:** capturar el digest exacto que devuelve `docker push` (o
`docker buildx build --metadata-file`) y firmar/verificar contra ese
digest en vez del tag —
`gitea.cruzcloud.net/devops/ecommerce-frontend@sha256:...` en vez de
`:v1.0.104`. No implementado todavía; requiere ajustar el step de
build para exponer el digest como output y pasarlo a los steps de
firma y verificación.
### 2. El transparency log (Rekor) está deshabilitado
Ya se explicó arriba, en la sección "Por qué `--tlog-upload=false`" de
esta misma página, la decisión consciente de no publicar en el
transparency log para este registry privado. Vale la pena nombrar en
simple qué es lo que se está dejando afuera:
Un **transparency log** (Rekor, el de Sigstore) es un registro
público, append-only, criptográficamente verificable, de "quién firmó
qué imagen y cuándo" — pensalo como un libro contable público que
nadie puede editar ni borrar después de escrito, solo agregar filas
nuevas. Cualquiera puede consultar ese libro para confirmar de forma
independiente (sin confiar en el propio proyecto, ni en la llave
privada, ni en el registry) que una firma específica existió en un
momento específico.
Sin transparency log, la garantía que queda es más débil: *"esta firma
la generó quien tuviera la llave privada en el momento en que se
verificó"* — pero no hay ningún registro externo e inmutable que
demuestre *cuándo* se generó, ni una forma de detectar si alguien con
acceso a la llave privada firmó algo por fuera del pipeline sin que
quede rastro. Para un lab personal con un registry privado, ese
trade-off es razonable (ver la advertencia más abajo en esta misma
página). Pero es una limitación real, no solo un detalle de
configuración — importa especialmente el día que este mismo patrón se
use en un contexto con más de una persona firmando, o con un registry
que deje de ser privado.
**TODO:** si en algún momento el registry deja de ser exclusivamente
privado, o se suma más de una persona con acceso a la llave de firma,
reevaluar habilitar el transparency log público de Sigstore (o correr
uno privado propio) en vez de mantenerlo deshabilitado.
## Qué falta (a propósito, todavía)
Hoy la verificación de firma corre como smoke test **dentro del mismo
+36 -8
View File
@@ -1,9 +1,22 @@
# 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`.
Actions. Se empezó por un solo repo de referencia — el frontend de ARI
Shopping (`workloads/ecommerce`, `.gitea/workflows/build.yaml`) — y ya
está replicado, con evidencia real de corridas en verde, a los tres
componentes del monorepo:
| App | Workflow | Imagen | Adaptación de Semgrep |
|---|---|---|---|
| Frontend (Next.js) | `build.yaml` | `ecommerce-frontend` | `p/typescript` + `p/react` + `p/nextjs` + `p/security-audit` |
| Backend (Medusa v2, API TypeScript) | `build-medusa.yaml` | `ecommerce-medusa` | `p/typescript` + `p/security-audit` + `p/owasp-top-ten` (sin React/Next.js — es una API, no SSR) |
| Docs Portal (MkDocs, Python) | `deploy-docs.yaml` | `docs-portal` | `p/python` + `p/security-audit` (sin código de aplicación propio hoy) |
Cada app tiene su propia llave pública de Cosign commiteada
(`cosign.pub` en su propio directorio), pero las tres comparten el
mismo par de llaves de firma (mismos secrets `COSIGN_PRIVATE_KEY` /
`COSIGN_PASSWORD` a nivel de repo) — una sola identidad de firma para
todo el registry de este lab.
Cinco herramientas, cada una respondiendo una pregunta distinta:
@@ -18,6 +31,11 @@ Cinco herramientas, cada una respondiendo una pregunta distinta:
## El pipeline completo
El mismo patrón corre, con el mismo orden de etapas, en los tres
workflows (`build.yaml`, `build-medusa.yaml`, `deploy-docs.yaml`) — lo
que cambia entre ellos es el ruleset de Semgrep y qué manifiesto(s)
escanea Trivy IaC (ver tabla arriba), no la estructura del pipeline.
```mermaid
flowchart TD
subgraph J1["job: gitleaks (push + pull_request)"]
@@ -28,8 +46,8 @@ flowchart TD
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)"]
B0[checkout + validaciones de código] --> B1["Trivy IaC<br/>(manifiesto K8s de la app)"]
B1 -.informa.-> B2["Semgrep SAST<br/>(ruleset por stack, audit mode)"]
B2 -.informa.-> B3[login registry]
B3 --> B4["docker build<br/>(push: false, load: true)"]
B4 --> B5["Trivy imagen<br/>CRITICAL"]
@@ -40,7 +58,7 @@ flowchart TD
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)"]
B10 -- "firma válida" --> B11["actualizar manifiesto de la app<br/>(GitOps, Argo CD sincroniza)"]
end
```
@@ -87,5 +105,15 @@ flowchart TD
- 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.
- Firmar por digest en vez de por tag, y reevaluar el transparency log
de Sigstore — ver
[limitaciones conocidas de Cosign](cosign.md#limitaciones-conocidas-y-mejoras-futuras).
- `commerce-backend` tiene dos CVE CRITICAL documentados como excepción
puntual en `workloads/commerce-backend/.trivyignore` (Go stdlib
embebido en el binario de esbuild, sin fix compatible con la versión
de Vite que usa el admin-sdk de Medusa hoy) — revisar en cada bump de
`@medusajs/*` si ya deja de ser necesaria.
- Contención de recursos entre Gitea y su runner bajo carga real —
causa raíz confirmada, fix propuesto y documentado, aplicación
manual pendiente (ver
[playbook de contención de recursos](../playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md)).
@@ -81,8 +81,7 @@ estable ~0.370.4s sostenida por 24+ minutos sin un solo 504.
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.
**Actualización 2026-08-15:** sí fue recurrente — ver
[contención de recursos entre Gitea y su runner](incidente-contencion-recursos-gitea-runner-2026-08.md)
para la causa raíz confirmada (ninguno de los dos contenedores
tiene límites de recursos reales) y la propuesta de fix.
@@ -0,0 +1,133 @@
# Incidente: contención de recursos entre Gitea y su runner (2026-08)
| | |
|---|---|
| **Ventana del incidente** | 2026-08-15, durante el primer intento real de correr el pipeline de seguridad completo en `commerce-backend` |
| **Servicio afectado** | `gitea` y `gitea-runner` (mismo host ZimaOS) |
| **Impacto** | Un job de CI en curso (build pesado de Medusa + Trivy) fue cancelado a mitad de camino porque ambos contenedores se reiniciaron solos |
## Contexto
Este hallazgo estaba **anotado como pendiente** en el playbook del
[504 del túnel](incidente-504-gitea-tunnel.md): una ventana de latencia
alta y algunos 502/530 habían coincidido, en su momento, con una
ejecución pesada de Gitea Actions — anotado como "contención de
recursos local, no relacionado al fix del túnel, vigilar si es
recurrente". Al triplicar la carga agregando el mismo pipeline de
seguridad a `commerce-backend` y `docs-portal`, se volvió a ver en
vivo, esta vez con evidencia suficiente para confirmar la causa.
## Síntoma
Un run de `build-medusa.yaml` (con Gitleaks ya en verde, build de
Docker en curso) quedó cancelado a mitad de camino. El log del runner
mostró, en la misma ventana de un par de minutos:
```text
level=error msg="failed to fetch task" error="unavailable: 502 Bad Gateway"
level=info msg="runner: ... shutdown initiated, waiting 0s for running jobs to complete before shutting down"
level=warning msg="runner: ... cancelled in progress jobs during shutdown"
level=info msg="Starting runner daemon"
```
El mismo patrón se repitió una segunda vez pocos minutos después. En
paralelo, `docker ps` mostró que el contenedor `gitea` también se
había reiniciado casi al mismo tiempo.
## Diagnóstico
`docker inspect` sobre ambos contenedores en el momento del incidente
mostró:
- `ExitCode=0` y `RestartCount=0` en los dos — **no fue un crash ni un
OOM-kill** (eso dejaría un `ExitCode` distinto de 0 o
`OOMKilled=true`, y el conteo de reinicios de la política de Docker
subiría). El `StartedAt` cambió igual, lo que indica un reinicio
limpio disparado por algo externo al propio proceso — muy
probablemente el supervisor de apps de CasaOS (`zimaos-app-management`),
aunque no se pudo confirmar por logs (`/var/log/casaos/log.log` es
de acceso root-only, sin sudo sin password disponible en esta
sesión).
- Memoria del host en el momento del incidente: **~1.3 GB libres de
15 GB totales**, con ~2 GB de swap en uso — el host estaba bajo
presión real de memoria, no solo de CPU.
- Ningún límite de recursos real en ninguno de los dos contenedores
(ver detalle abajo).
## Causa raíz
Ni `gitea` ni `gitea-runner` tienen aislamiento de recursos real
frente al resto del host:
| | `gitea` | `gitea-runner` |
|---|---|---|
| Límite de memoria | ~15.5 GB (≈ toda la RAM del host, no es un límite real) | **ninguno** |
| Límite de CPU (cuota dura) | ninguno | ninguno |
| Peso relativo de CPU (`cpu-shares`) | **90** (muy por debajo del default de Docker, 1024) | 0 → default Docker (**1024**) |
Con `gitea` en 90 y `gitea-runner` en el default de 1024, bajo
contención de CPU **el runner tiene ~11x más prioridad que el propio
servidor Gitea** — lo opuesto a lo deseable, ya que Gitea es el
servicio que necesita seguir respondiendo peticiones HTTP (incluidas
las del propio runner haciendo polling) mientras un build pesado corre.
Sumado a que `gitea-runner` no tiene techo de memoria: un job pesado
(build de Docker de una imagen con `node_modules` grande + escaneo de
Trivy con vuln + secret scanning) puede consumir memoria sin límite,
empujando al host entero a swap y dejando lento/no-responsivo a todo
lo demás — incluida la propia base de datos SQLite de Gitea (ver
[incidente de SQLite en modo DELETE](incidente-sqlite-modo-delete-2026-08.md),
que probablemente reaparezca con más frecuencia bajo este mismo tipo
de presión, aunque WAL reduce el impacto).
`gitea-runner` tampoco corre gestionado por el mismo `docker-compose.yml`
de Gitea — se crea con un `docker run` suelto desde
`scripts/deploy-lab.sh` (`create_gitea_runner_container()`), montando
`/var/run/docker.sock` directo (sibling containers, ver
[incidente de Docker-in-Docker](incidente-docker-in-docker-semgrep-2026-08.md)).
Esto no es la causa del incidente de recursos, pero significa que
cualquier ajuste de límites tiene que aplicarse en dos lugares
distintos: el `docker-compose.yml` de `gitea` y el script que crea
`gitea-runner`.
!!! info "La concurrencia del runner ya estaba bien"
`capacity: 1` en el `config.yaml` del runner ya limita la
ejecución a **un solo job a la vez** — no es un problema de
"demasiados jobs en paralelo". El problema es que incluso un solo
job pesado, sin ningún límite de recursos, puede acaparar
suficiente CPU/memoria como para dejar sin aire al resto del host.
## Fix propuesto (documentado, aplicación manual pendiente)
1. **`scripts/deploy-lab.sh`** (rama `fix/gitea-runner-resource-limits`,
pendiente de mergear): agrega `--cpu-shares 512 --memory 8g
--memory-swap 10g` a la creación de `gitea-runner`. Solo afecta a la
próxima vez que se cree el contenedor (la función es idempotente),
no al que ya está corriendo.
2. **Fix inmediato en caliente** (sin recrear contenedores), a aplicar
manualmente por fuera de este repo:
```bash
docker update --cpu-shares 1024 gitea
docker update --cpu-shares 512 --memory 8g --memory-swap 10g gitea-runner
```
3. **`docker-compose.yml` de Gitea** (fuera de este repo, acceso
root-only): subir `cpu_shares` de `gitea` de 90 a 1024 — pendiente
de aplicar manualmente, no se implementó todavía.
4. **Mover `gitea-runner` a k3d** como job/pod separado: evaluado como
posible mejora de fondo (aislamiento real vía Kubernetes en vez de
límites de Docker sueltos), pero queda **solo como recomendación
documentada** — no implementado en esta vuelta.
## Validación
Con `gitea` y `gitea-runner` estables (sin reinicios) después del
incidente, se reintentó el mismo pipeline y corrió de punta a punta
sin interrupciones — pero eso confirma que el host se estabilizó
después del pico de carga, **no** que el fix de límites de recursos ya
esté aplicado (sigue pendiente, ver arriba). La correlación completa
carga-pesada → reinicio ya se había visto antes, en dos fechas
distintas (2026-07-30 y 2026-08-11) además de esta — no es un evento
aislado, es un patrón recurrente que ahora tiene una causa raíz
concreta y una propuesta de fix.
@@ -0,0 +1,93 @@
# Incidente: el ingress del túnel rompió específicamente a Cosign, no al resto del pipeline (2026-08)
| | |
|---|---|
| **Ventana del incidente** | 2026-08-15, primer intento de firmar una imagen con Cosign en `build.yaml` |
| **Servicio afectado** | Cadena Cloudflare Tunnel → NPM (Nginx Proxy Manager) → Gitea, específicamente el endpoint del registry de contenedores |
| **Impacto** | `cosign sign` fallaba siempre, en cada intento — el resto del pipeline (Gitleaks, Trivy, Semgrep, build, push de la imagen) funcionaba normal contra el mismo registry |
## Síntoma
Todos los pasos anteriores del pipeline pasaban, incluido `docker push`
de la imagen al registry de Gitea. El paso de Cosign, inmediatamente
después, fallaba siempre con:
```text
Error: signing [gitea.cruzcloud.net/***/ecommerce-frontend:v1.0.102]:
accessing entity: invalid realm in www-authenticate: realm scheme
"http" not allowed for a secure registry; use https
```
Lo llamativo: **`docker push` a ese mismo registry, un paso antes,
funcionaba sin problema.** Si el registry estuviera mal configurado en
general, se esperaría que fallara también el push, no solo la firma.
## Diagnóstico
El mensaje da la pista exacta: al autenticar contra el registry,
Cosign recibe un header `WWW-Authenticate` cuyo campo `realm` apunta a
una URL con esquema `http://` en vez de `https://`. Cosign rechaza
explícitamente seguir un realm `http` contra lo que él considera "un
registry seguro" (un dominio público, no `localhost`) — es una
protección intencional del lado de Cosign, no un bug.
`docker push`/`docker login` son más permisivos con esto (o cachean la
sesión de otra forma) y no chocan con el mismo problema, por eso solo
Cosign lo mostraba.
## Causa raíz
El realm que arma Gitea en su respuesta `WWW-Authenticate` depende de
que Gitea sepa correctamente que la petición le llegó por HTTPS
(típicamente vía el header `X-Forwarded-Proto` o el Server Name que ve
en la conexión TLS que termina antes de llegar a él). La cadena real
acá es **Cloudflare Tunnel → NPM → Gitea** — si el proxy host de NPM
para `gitea.cruzcloud.net` no tiene el *Origin Server Name* (SNI hacia
el origen) configurado correctamente, Gitea puede terminar creyendo
que la conexión entrante fue por HTTP plano, y arma el realm con ese
esquema equivocado.
## Fix aplicado
Corrección de la regla de ingress de `cloudflared`/NPM para
`gitea.cruzcloud.net`: HTTPS hacia el origen con el *Origin Server
Name* correcto. El fix fue enteramente de infraestructura — no hubo
ningún cambio en este repo más allá de un commit vacío para volver a
disparar el pipeline y confirmar el fix contra credenciales reales.
## La misma causa raíz de fondo, dos síntomas distintos
Este incidente **no es el mismo bug** que el
[504 Gateway Timeout intermitente del túnel](incidente-504-gitea-tunnel.md)
ya documentado — ese fue inestabilidad de QUIC/UDP en `cloudflared`,
resuelto forzando `TUNNEL_TRANSPORT_PROTOCOL=http2`. Pero **ambos
viven en la misma capa**: la cadena de ingress
Cloudflare Tunnel → NPM → Gitea, mal configurada de dos formas
distintas, descubiertas en momentos distintos:
- Una regla de **transporte** (QUIC vs HTTP/2) causando 504s
intermitentes en *cualquier* petición HTTP a los dominios detrás del
túnel.
- Una regla de **SNI/origin server name** causando que Gitea generara
un realm HTTP incorrecto, rompiendo específicamente a un cliente
(Cosign) que valida ese detalle de forma estricta.
La lección compartida: en una cadena de proxies con TLS terminando en
varios saltos, un solo campo de configuración mal puesto en cualquiera
de los saltos puede manifestarse como fallas completamente distintas
según qué tan estricto sea el cliente al otro lado — la mayoría de las
herramientas HTTP normales ni lo notan, pero una herramienta de
seguridad como Cosign, que valida explícitamente el esquema del
realm, sí.
## Validación
Confirmado en una corrida real posterior al fix: `cosign sign` y
`cosign verify` completaron sin error contra el registry real, con
credenciales reales, de punta a punta.
!!! note "Nota relacionada, no parte de este incidente"
El mismo log de este fallo mostró, además del error de realm, el
warning esperado de Cosign sobre firmar por tag en vez de por
digest — ver
[limitaciones conocidas de Cosign](../devsecops/cosign.md#limitaciones-conocidas-y-mejoras-futuras).
@@ -0,0 +1,102 @@
# Incidente: Docker-in-Docker roto en el runner — por qué Semgrep corre nativo (2026-08)
| | |
|---|---|
| **Ventana del incidente** | 2026-08-15, primer intento de correr Semgrep como container aparte en `build.yaml` |
| **Servicio afectado** | Gitea Actions runner (`gitea-runner`, imagen `act_runner`) |
| **Impacto** | Cualquier step que intentara `docker run -v "$(pwd):/algo"` desde dentro de un job fallaba con `read-only file system` o rutas inexistentes |
## Docker-in-Docker vs. sibling containers, para quien recién arranca en CI/CD
Cuando un job de CI necesita usar Docker (por ejemplo, para correr una
herramienta empaquetada como imagen), hay dos formas distintas de
dárselo, y confundirlas rompe cosas de maneras difíciles de
diagnosticar:
- **Docker-in-Docker (DinD):** el contenedor del job tiene su **propio**
daemon Docker corriendo adentro, completamente aislado del daemon
del host. Cuando el job hace `docker run`, ese contenedor nuevo nace
*dentro* del contenedor del job, como una muñeca rusa. El
filesystem que ve ese daemon interno es el del contenedor del job,
no el del host real.
- **Sibling containers (contenedores hermanos):** el contenedor del
job **no** tiene su propio daemon — en cambio, monta el socket del
daemon Docker del **host** (`/var/run/docker.sock`) y habla
directamente con él. Cuando el job hace `docker run`, ese contenedor
nuevo nace como **hermano** del contenedor del job (mismo nivel,
mismo host, mismo daemon), no adentro suyo.
La imagen `act_runner` que usa este runner de Gitea Actions usa el
segundo modelo: **sibling containers**, montando el socket del daemon
del host (ver `create_gitea_runner_container()` en
`scripts/deploy-lab.sh`, que monta
`-v /var/run/docker.sock:/var/run/docker.sock`).
## Por qué eso rompe un `docker run -v` ingenuo
Con sibling containers, cuando un step dentro de un job pide
```bash
docker run -v "${{ github.workspace }}:/src" alguna-imagen
```
ese comando lo ejecuta el **daemon del host**, no el contenedor del
job. `${{ github.workspace }}` es una ruta que existe *dentro* del
contenedor del job (por ejemplo `/workspace/devops/apps-registry`) —
pero el daemon del host, al crear el contenedor hermano, intenta
montar esa misma ruta **desde el filesystem del host real**, donde
probablemente no existe (o existe otra cosa completamente distinta ahí).
## Síntoma
Al intentar correr Semgrep como container aparte (`docker run` desde
dentro del step), el error observado en el run real fue:
```text
mkdir /workspace: read-only file system
```
No un "no existe la ruta" limpio — un intento de crear el directorio
que falla porque, en el contexto real del daemon del host, esa ruta
cae en un punto de montaje de solo lectura (o simplemente no es un
lugar donde el daemon del host puede/debe escribir).
## Causa raíz
Sibling containers, no Docker-in-Docker: un `docker run -v
"${{ github.workspace }}:/src"` ejecutado desde dentro de un job
intenta montar, en el daemon del **host**, una ruta que solo tiene
sentido **dentro** del contenedor del job. El daemon del host no ve
esa ruta como el mismo directorio — la ve como una ruta arbitraria de
su propio filesystem.
## Fix aplicado
Se evita el problema de raíz: **Semgrep corre nativo**, instalado
directo en el contenedor del job (mismo criterio ya usado para
Gitleaks, Trivy, Syft y Cosign — todos se instalan como binario/paquete
dentro del job, ninguno corre como container aparte):
```bash
python3 -m venv /tmp/semgrep-venv
/tmp/semgrep-venv/bin/pip install --quiet "semgrep==1.173.0"
```
### Por qué un venv y no `pip3 install` directo
La imagen del runner ya trae paquetes de Python instalados por `apt`
(por ejemplo `PyJWT`) sin metadata compatible con `pip`. Un
`pip3 install --break-system-packages` sobre esa base falla al
intentar reemplazarlos (`RECORD file not found`) porque `pip` no
encuentra el registro de archivos que necesita para saber qué está
reemplazando. Un venv aislado evita tocar los paquetes del sistema por
completo — Semgrep y sus dependencias quedan en su propio directorio,
sin interferir con nada que `apt` ya haya instalado.
## Validación
Validado localmente contra `docker.gitea.com/runner-images:ubuntu-latest`
(la misma imagen que usa el runner real) que `python3`/`pip3` están
disponibles, y confirmado en una corrida real posterior al fix que
Semgrep corre y reporta hallazgos sin ningún error de Docker de por
medio.
@@ -0,0 +1,103 @@
# Incidente: SQLite en modo DELETE causando contención en Gitea (2026-08)
| | |
|---|---|
| **Ventana del incidente** | 2026-08-15, coincidiendo con el arranque del runner de Gitea Actions y el polling frecuente de tareas |
| **Servicio afectado** | `gitea` (base de datos SQLite propia, `gitea.db`) |
| **Impacto** | Errores intermitentes `database is locked (SQLITE_BUSY)` al escribir desde distintos componentes de Gitea (runner, web, cron) al mismo tiempo |
## Contexto: por qué Gitea usa SQLite acá
Este lab corre Gitea con `DB_TYPE = sqlite3` (no Postgres/MySQL) — una
sola base de datos, un solo archivo (`gitea.db`), sin servidor de base
de datos aparte. Es la opción correcta para un lab de un solo nodo,
pero SQLite tiene una particularidad importante: por defecto abre el
archivo en **modo DELETE** (el modo "clásico" de journaling).
## Qué es el modo DELETE y por qué molesta acá
En modo DELETE, cada transacción de escritura:
1. Crea un archivo de journal temporal (`gitea.db-journal`).
2. Toma un **lock exclusivo sobre el archivo completo** de la base de
datos mientras dura la escritura.
3. Borra el journal al terminar.
Ese lock exclusivo es el problema: **mientras una escritura está en
curso, nada más puede leer ni escribir** — ni siquiera otro proceso
que solo quiere leer una fila que no tiene nada que ver con la que se
está escribiendo.
!!! info "Por qué esto pega justo en Gitea Actions"
El runner de Gitea Actions hace *polling* constante contra la API
de Gitea (cada pocos segundos, según `fetch_interval` en su
`config.yaml`) para preguntar "¿hay una tarea nueva?", y además
escribe actualizaciones de estado de cada step casi en tiempo real
mientras un job corre. Sumale los cron jobs internos de Gitea y el
tráfico normal de la web UI. Con la base en modo DELETE, todo eso
compite por el mismo lock exclusivo — cuantas más cosas escriben
seguido, más chance de pisarse.
## Síntoma
`SQLITE_BUSY` intermitente en los logs de Gitea, típicamente en
operaciones de corta duración (actualizar el estado de un step,
registrar un heartbeat del runner) que no deberían tener motivo para
fallar por contención:
```text
[E] can't update runner status: database is locked (5) (SQLITE_BUSY)
```
## Causa raíz
Modo de journaling `DELETE` (el default de SQLite si no se configura
nada distinto) combinado con el patrón de acceso de Gitea Actions:
muchas escrituras cortas y frecuentes desde procesos distintos
(runner, servidor web, cron), cada una compitiendo por un lock
exclusivo de archivo completo.
## Fix aplicado
Se cambió el modo de journaling a **WAL** (Write-Ahead Logging) en
`app.ini`:
```ini
[database]
; journal_mode por defecto (DELETE) toma un lock exclusivo del archivo
; completo en cada escritura. WAL permite lectores concurrentes mientras
; hay una escritura en curso -- fix para "database is locked" bajo el
; polling frecuente de Actions + cron jobs (diagnosticado 2026-08-15).
SQLITE_JOURNAL_MODE = WAL
```
### Por qué WAL resuelve esto (y qué NO resuelve)
En modo WAL, las escrituras no se aplican directo al archivo principal
de la base: se van agregando a un archivo aparte (`gitea.db-wal`), y
los lectores siguen leyendo del estado consistente más reciente sin
bloquearse. La regla cambia de *"una escritura bloquea todo"* a
*"una escritura bloquea solo a otra escritura"* — los lectores dejan
de competir por el lock.
!!! warning "WAL reduce la contención, no la elimina"
WAL sigue permitiendo **una sola escritura a la vez** — dos
escrituras concurrentes todavía pueden chocar y devolver
`SQLITE_BUSY` si el proceso no reintenta. En una corrida de este
mismo playbook se vio, después de aplicado el fix, una única
ocurrencia aislada de `database is locked` coincidiendo con un
reinicio del contenedor de `gitea` (ver
[contención de recursos de Gitea/runner](incidente-contencion-recursos-gitea-runner-2026-08.md)) —
consistente con una escritura en curso justo en el momento del
reinicio, no con que WAL no esté funcionando. Frecuencia bajó de
"molesta con cierta regularidad" a "un caso aislado en varias
horas de uso intensivo".
## Validación
El cambio se aplicó directo en el `app.ini` montado como volumen del
contenedor de `gitea` (no en un `docker exec` puntual) — persiste
across reinicios del contenedor, confirmado leyendo el archivo montado
después de un reinicio real del contenedor (ver
[contención de recursos](incidente-contencion-recursos-gitea-runner-2026-08.md)),
no solo aplicado en caliente y perdido al reciclar el contenedor.
@@ -0,0 +1,122 @@
# Incidente: anchors YAML no soportados por Gitea Actions — y un diagnóstico equivocado en el camino (2026-08)
| | |
|---|---|
| **Ventana del incidente** | 2026-08-15, desde el primer commit que agregó Gitleaks a `build.yaml` |
| **Servicio afectado** | Gitea Actions — el workflow `build.yaml` (frontend) |
| **Impacto** | `build.yaml` no generaba **ningún** `action_run`, ni en `push` ni en `pull_request` — el pipeline entero era invisible para Gitea, sin ningún error visible en la UI |
## Qué es un anchor/alias YAML, para quien no lo conoce
YAML tiene una forma de evitar repetir el mismo bloque dos veces:
```yaml
paths: &frontend_paths
- 'workloads/ecommerce/**'
- '.gitea/workflows/build.yaml'
on:
push:
paths: *frontend_paths
pull_request:
paths: *frontend_paths
```
`&frontend_paths` es un **anchor** (marca ese nodo con un nombre).
`*frontend_paths` es un **alias** (dice "poné acá una copia de lo que
marcó ese anchor"). Es YAML 100% estándar — cualquier parser que siga
la especificación lo resuelve sin problema, y es una forma común de no
duplicar listas idénticas en el mismo archivo.
## Síntoma
`build.yaml` (con Gitleaks recién agregado, usando anchors para no
repetir la lista de `paths` entre `push:` y `pull_request:`) no
generaba ningún run en Gitea Actions. Ni en el push directo a la rama
del PR, ni al abrir el pull request. Sin mensaje de error visible en
la UI de Gitea — el workflow simplemente no aparecía en la lista de
Actions, como si no existiera.
## Primer diagnóstico (equivocado)
La primera sospecha fue una indentación rota específicamente en el
step de Semgrep: el bloque `python3 -c "..."` embebido dentro de un
`run: |` tenía código pegado a columna 0, por debajo de la
indentación esperada del block scalar de YAML. Eso ** era un
problema real — cortaba el block scalar ahí mismo y en teoría podía
hacer que Gitea rechazara el archivo con un error de parseo genérico
(`could not find expected ':'`).
Se corrigió la indentación, se validó con un parser YAML real y
`bash -n` sobre los 20 steps del archivo, se mergeó a `main` como fix
(`fix(ci): corregir indentación YAML rota en el step de Semgrep`, PR
#19)... y `build.yaml` **siguió sin generar runs**.
!!! warning "Por qué vale la pena contar el diagnóstico que no era"
El primer fix no estaba mal — la indentación efectivamente estaba
rota y corregirla era necesario. El error fue asumir que esa era
*la única* causa sin confirmarlo con una corrida real después del
fix. La lección de proceso: un fix que "tiene sentido" y que pasa
la validación local (parser YAML, `bash -n`) todavía necesita una
confirmación en vivo contra el sistema real antes de darlo por
cerrado — sobre todo cuando el síntoma es "no pasa nada" en vez de
un error explícito, que es exactamente el tipo de fallo más fácil
de dar por resuelto sin verificar.
## Causa raíz real
`deploy-docs.yaml` (sin anchors) corría normal. `build.yaml` (con
anchors, agregados junto con Gitleaks) no generaba ningún run — ni
en `push` ni en `pull_request`, desde el primer commit que los
introdujo. El log del propio Gitea lo confirmó:
```text
unknown on type: &yaml.Node{...Value:"frontend_paths"...}
```
El parser propio de Gitea Actions para el bloque `on:` de un workflow
**no resuelve `&anchor`/`*alias` antes de inspeccionar el tipo de
nodo** — encuentra un anchor donde esperaba un valor ya resuelto, no
sabe qué tipo de dato es, y descarta el archivo completo en
silencio. `pyyaml` y cualquier parser YAML estándar sí resuelven el
alias primero (por eso el archivo "parseaba bien" en cualquier
herramienta de validación genérica) — es una limitación específica del
parser de workflows de Gitea Actions, no un YAML inválido.
Esto explica por qué el síntoma no tenía ningún error visible: no es
que el job fallara, es que **Gitea nunca llegaba a registrar el
workflow como existente**.
## Fix aplicado
Se eliminaron los anchors/alias. Las dos listas de `paths` (una para
`push:`, otra para `pull_request:`) quedan **duplicadas
literalmente**, sin referencia compartida:
```yaml
on:
push:
branches: [main]
paths:
- 'workloads/ecommerce/**'
- '.gitea/workflows/build.yaml'
pull_request:
branches: [main]
paths:
- 'workloads/ecommerce/**'
- '.gitea/workflows/build.yaml'
```
Es más repetitivo, pero es el precio de que el parser de Gitea
Actions lo entienda. Este mismo criterio (sin anchors, paths
duplicados) se replicó a propósito en `build-medusa.yaml` y
`deploy-docs.yaml` al agregarles el resto del pipeline de seguridad,
para no repetir el mismo problema.
## Validación
Confirmado en vivo: tras mergear el fix, `build.yaml` generó su
primer `action_run` real. Validado también con un parser YAML +
`bash -n` sobre los 20 steps del archivo, igual que en el intento
anterior — la diferencia esta vez fue confirmarlo contra una corrida
real antes de cerrar el incidente.
+5
View File
@@ -93,6 +93,11 @@ nav:
- 504 túnel Gitea: playbooks/incidente-504-gitea-tunnel.md
- Precios Medusa: playbooks/incidente-precios-medusa.md
- Imágenes NPM: playbooks/incidente-imagenes-npm.md
- SQLite modo DELETE: playbooks/incidente-sqlite-modo-delete-2026-08.md
- Anchors YAML en Gitea Actions: playbooks/incidente-yaml-anchors-gitea-actions-2026-08.md
- Docker-in-Docker y Semgrep: playbooks/incidente-docker-in-docker-semgrep-2026-08.md
- Realm HTTP y Cosign: playbooks/incidente-cosign-realm-http-tunnel-2026-08.md
- Contención de recursos Gitea/runner: playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md
- Aprendizajes:
- Notas sueltas: aprendizajes/notas-sueltas.md
- Guía del estudiante:
+2 -2
View File
@@ -52,7 +52,7 @@ spec:
memory: 64Mi
- name: migrations
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.92
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.108
imagePullPolicy: IfNotPresent
command:
- npx
@@ -73,7 +73,7 @@ spec:
containers:
- name: medusa
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.92
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.108
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef:
+1 -1
View File
@@ -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: