Merge docs/devsecops-playbooks-y-hallazgos: 5 playbooks + cosign.md + index.md
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 18s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 5m10s

This commit is contained in:
2026-08-15 15:17:59 -05:00
9 changed files with 666 additions and 13 deletions
@@ -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: