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.
This commit is contained in:
@@ -81,8 +81,7 @@ estable ~0.37–0.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.
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# Incidente: contención de recursos entre Gitea y su runner (2026-08)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Ventana del incidente** | 2026-08-15, durante el primer intento real de correr el pipeline de seguridad completo en `commerce-backend` |
|
||||
| **Servicio afectado** | `gitea` y `gitea-runner` (mismo host ZimaOS) |
|
||||
| **Impacto** | Un job de CI en curso (build pesado de Medusa + Trivy) fue cancelado a mitad de camino porque ambos contenedores se reiniciaron solos |
|
||||
|
||||
## Contexto
|
||||
|
||||
Este hallazgo estaba **anotado como pendiente** en el playbook del
|
||||
[504 del túnel](incidente-504-gitea-tunnel.md): una ventana de latencia
|
||||
alta y algunos 502/530 habían coincidido, en su momento, con una
|
||||
ejecución pesada de Gitea Actions — anotado como "contención de
|
||||
recursos local, no relacionado al fix del túnel, vigilar si es
|
||||
recurrente". Al triplicar la carga agregando el mismo pipeline de
|
||||
seguridad a `commerce-backend` y `docs-portal`, se volvió a ver en
|
||||
vivo, esta vez con evidencia suficiente para confirmar la causa.
|
||||
|
||||
## Síntoma
|
||||
|
||||
Un run de `build-medusa.yaml` (con Gitleaks ya en verde, build de
|
||||
Docker en curso) quedó cancelado a mitad de camino. El log del runner
|
||||
mostró, en la misma ventana de un par de minutos:
|
||||
|
||||
```text
|
||||
level=error msg="failed to fetch task" error="unavailable: 502 Bad Gateway"
|
||||
level=info msg="runner: ... shutdown initiated, waiting 0s for running jobs to complete before shutting down"
|
||||
level=warning msg="runner: ... cancelled in progress jobs during shutdown"
|
||||
level=info msg="Starting runner daemon"
|
||||
```
|
||||
|
||||
El mismo patrón se repitió una segunda vez pocos minutos después. En
|
||||
paralelo, `docker ps` mostró que el contenedor `gitea` también se
|
||||
había reiniciado casi al mismo tiempo.
|
||||
|
||||
## Diagnóstico
|
||||
|
||||
`docker inspect` sobre ambos contenedores en el momento del incidente
|
||||
mostró:
|
||||
|
||||
- `ExitCode=0` y `RestartCount=0` en los dos — **no fue un crash ni un
|
||||
OOM-kill** (eso dejaría un `ExitCode` distinto de 0 o
|
||||
`OOMKilled=true`, y el conteo de reinicios de la política de Docker
|
||||
subiría). El `StartedAt` cambió igual, lo que indica un reinicio
|
||||
limpio disparado por algo externo al propio proceso — muy
|
||||
probablemente el supervisor de apps de CasaOS (`zimaos-app-management`),
|
||||
aunque no se pudo confirmar por logs (`/var/log/casaos/log.log` es
|
||||
de acceso root-only, sin sudo sin password disponible en esta
|
||||
sesión).
|
||||
- Memoria del host en el momento del incidente: **~1.3 GB libres de
|
||||
15 GB totales**, con ~2 GB de swap en uso — el host estaba bajo
|
||||
presión real de memoria, no solo de CPU.
|
||||
- Ningún límite de recursos real en ninguno de los dos contenedores
|
||||
(ver detalle abajo).
|
||||
|
||||
## Causa raíz
|
||||
|
||||
Ni `gitea` ni `gitea-runner` tienen aislamiento de recursos real
|
||||
frente al resto del host:
|
||||
|
||||
| | `gitea` | `gitea-runner` |
|
||||
|---|---|---|
|
||||
| Límite de memoria | ~15.5 GB (≈ toda la RAM del host, no es un límite real) | **ninguno** |
|
||||
| Límite de CPU (cuota dura) | ninguno | ninguno |
|
||||
| Peso relativo de CPU (`cpu-shares`) | **90** (muy por debajo del default de Docker, 1024) | 0 → default Docker (**1024**) |
|
||||
|
||||
Con `gitea` en 90 y `gitea-runner` en el default de 1024, bajo
|
||||
contención de CPU **el runner tiene ~11x más prioridad que el propio
|
||||
servidor Gitea** — lo opuesto a lo deseable, ya que Gitea es el
|
||||
servicio que necesita seguir respondiendo peticiones HTTP (incluidas
|
||||
las del propio runner haciendo polling) mientras un build pesado corre.
|
||||
|
||||
Sumado a que `gitea-runner` no tiene techo de memoria: un job pesado
|
||||
(build de Docker de una imagen con `node_modules` grande + escaneo de
|
||||
Trivy con vuln + secret scanning) puede consumir memoria sin límite,
|
||||
empujando al host entero a swap y dejando lento/no-responsivo a todo
|
||||
lo demás — incluida la propia base de datos SQLite de Gitea (ver
|
||||
[incidente de SQLite en modo DELETE](incidente-sqlite-modo-delete-2026-08.md),
|
||||
que probablemente reaparezca con más frecuencia bajo este mismo tipo
|
||||
de presión, aunque WAL reduce el impacto).
|
||||
|
||||
`gitea-runner` tampoco corre gestionado por el mismo `docker-compose.yml`
|
||||
de Gitea — se crea con un `docker run` suelto desde
|
||||
`scripts/deploy-lab.sh` (`create_gitea_runner_container()`), montando
|
||||
`/var/run/docker.sock` directo (sibling containers, ver
|
||||
[incidente de Docker-in-Docker](incidente-docker-in-docker-semgrep-2026-08.md)).
|
||||
Esto no es la causa del incidente de recursos, pero significa que
|
||||
cualquier ajuste de límites tiene que aplicarse en dos lugares
|
||||
distintos: el `docker-compose.yml` de `gitea` y el script que crea
|
||||
`gitea-runner`.
|
||||
|
||||
!!! info "La concurrencia del runner ya estaba bien"
|
||||
`capacity: 1` en el `config.yaml` del runner ya limita la
|
||||
ejecución a **un solo job a la vez** — no es un problema de
|
||||
"demasiados jobs en paralelo". El problema es que incluso un solo
|
||||
job pesado, sin ningún límite de recursos, puede acaparar
|
||||
suficiente CPU/memoria como para dejar sin aire al resto del host.
|
||||
|
||||
## Fix propuesto (documentado, aplicación manual pendiente)
|
||||
|
||||
1. **`scripts/deploy-lab.sh`** (rama `fix/gitea-runner-resource-limits`,
|
||||
pendiente de mergear): agrega `--cpu-shares 512 --memory 8g
|
||||
--memory-swap 10g` a la creación de `gitea-runner`. Solo afecta a la
|
||||
próxima vez que se cree el contenedor (la función es idempotente),
|
||||
no al que ya está corriendo.
|
||||
2. **Fix inmediato en caliente** (sin recrear contenedores), a aplicar
|
||||
manualmente por fuera de este repo:
|
||||
|
||||
```bash
|
||||
docker update --cpu-shares 1024 gitea
|
||||
docker update --cpu-shares 512 --memory 8g --memory-swap 10g gitea-runner
|
||||
```
|
||||
|
||||
3. **`docker-compose.yml` de Gitea** (fuera de este repo, acceso
|
||||
root-only): subir `cpu_shares` de `gitea` de 90 a 1024 — pendiente
|
||||
de aplicar manualmente, no se implementó todavía.
|
||||
4. **Mover `gitea-runner` a k3d** como job/pod separado: evaluado como
|
||||
posible mejora de fondo (aislamiento real vía Kubernetes en vez de
|
||||
límites de Docker sueltos), pero queda **solo como recomendación
|
||||
documentada** — no implementado en esta vuelta.
|
||||
|
||||
## Validación
|
||||
|
||||
Con `gitea` y `gitea-runner` estables (sin reinicios) después del
|
||||
incidente, se reintentó el mismo pipeline y corrió de punta a punta
|
||||
sin interrupciones — pero eso confirma que el host se estabilizó
|
||||
después del pico de carga, **no** que el fix de límites de recursos ya
|
||||
esté aplicado (sigue pendiente, ver arriba). La correlación completa
|
||||
carga-pesada → reinicio ya se había visto antes, en dos fechas
|
||||
distintas (2026-07-30 y 2026-08-11) además de esta — no es un evento
|
||||
aislado, es un patrón recurrente que ahora tiene una causa raíz
|
||||
concreta y una propuesta de fix.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Incidente: el ingress del túnel rompió específicamente a Cosign, no al resto del pipeline (2026-08)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Ventana del incidente** | 2026-08-15, primer intento de firmar una imagen con Cosign en `build.yaml` |
|
||||
| **Servicio afectado** | Cadena Cloudflare Tunnel → NPM (Nginx Proxy Manager) → Gitea, específicamente el endpoint del registry de contenedores |
|
||||
| **Impacto** | `cosign sign` fallaba siempre, en cada intento — el resto del pipeline (Gitleaks, Trivy, Semgrep, build, push de la imagen) funcionaba normal contra el mismo registry |
|
||||
|
||||
## Síntoma
|
||||
|
||||
Todos los pasos anteriores del pipeline pasaban, incluido `docker push`
|
||||
de la imagen al registry de Gitea. El paso de Cosign, inmediatamente
|
||||
después, fallaba siempre con:
|
||||
|
||||
```text
|
||||
Error: signing [gitea.cruzcloud.net/***/ecommerce-frontend:v1.0.102]:
|
||||
accessing entity: invalid realm in www-authenticate: realm scheme
|
||||
"http" not allowed for a secure registry; use https
|
||||
```
|
||||
|
||||
Lo llamativo: **`docker push` a ese mismo registry, un paso antes,
|
||||
funcionaba sin problema.** Si el registry estuviera mal configurado en
|
||||
general, se esperaría que fallara también el push, no solo la firma.
|
||||
|
||||
## Diagnóstico
|
||||
|
||||
El mensaje da la pista exacta: al autenticar contra el registry,
|
||||
Cosign recibe un header `WWW-Authenticate` cuyo campo `realm` apunta a
|
||||
una URL con esquema `http://` en vez de `https://`. Cosign rechaza
|
||||
explícitamente seguir un realm `http` contra lo que él considera "un
|
||||
registry seguro" (un dominio público, no `localhost`) — es una
|
||||
protección intencional del lado de Cosign, no un bug.
|
||||
|
||||
`docker push`/`docker login` son más permisivos con esto (o cachean la
|
||||
sesión de otra forma) y no chocan con el mismo problema, por eso solo
|
||||
Cosign lo mostraba.
|
||||
|
||||
## Causa raíz
|
||||
|
||||
El realm que arma Gitea en su respuesta `WWW-Authenticate` depende de
|
||||
que Gitea sepa correctamente que la petición le llegó por HTTPS
|
||||
(típicamente vía el header `X-Forwarded-Proto` o el Server Name que ve
|
||||
en la conexión TLS que termina antes de llegar a él). La cadena real
|
||||
acá es **Cloudflare Tunnel → NPM → Gitea** — si el proxy host de NPM
|
||||
para `gitea.cruzcloud.net` no tiene el *Origin Server Name* (SNI hacia
|
||||
el origen) configurado correctamente, Gitea puede terminar creyendo
|
||||
que la conexión entrante fue por HTTP plano, y arma el realm con ese
|
||||
esquema equivocado.
|
||||
|
||||
## Fix aplicado
|
||||
|
||||
Corrección de la regla de ingress de `cloudflared`/NPM para
|
||||
`gitea.cruzcloud.net`: HTTPS hacia el origen con el *Origin Server
|
||||
Name* correcto. El fix fue enteramente de infraestructura — no hubo
|
||||
ningún cambio en este repo más allá de un commit vacío para volver a
|
||||
disparar el pipeline y confirmar el fix contra credenciales reales.
|
||||
|
||||
## La misma causa raíz de fondo, dos síntomas distintos
|
||||
|
||||
Este incidente **no es el mismo bug** que el
|
||||
[504 Gateway Timeout intermitente del túnel](incidente-504-gitea-tunnel.md)
|
||||
ya documentado — ese fue inestabilidad de QUIC/UDP en `cloudflared`,
|
||||
resuelto forzando `TUNNEL_TRANSPORT_PROTOCOL=http2`. Pero **ambos
|
||||
viven en la misma capa**: la cadena de ingress
|
||||
Cloudflare Tunnel → NPM → Gitea, mal configurada de dos formas
|
||||
distintas, descubiertas en momentos distintos:
|
||||
|
||||
- Una regla de **transporte** (QUIC vs HTTP/2) causando 504s
|
||||
intermitentes en *cualquier* petición HTTP a los dominios detrás del
|
||||
túnel.
|
||||
- Una regla de **SNI/origin server name** causando que Gitea generara
|
||||
un realm HTTP incorrecto, rompiendo específicamente a un cliente
|
||||
(Cosign) que valida ese detalle de forma estricta.
|
||||
|
||||
La lección compartida: en una cadena de proxies con TLS terminando en
|
||||
varios saltos, un solo campo de configuración mal puesto en cualquiera
|
||||
de los saltos puede manifestarse como fallas completamente distintas
|
||||
según qué tan estricto sea el cliente al otro lado — la mayoría de las
|
||||
herramientas HTTP normales ni lo notan, pero una herramienta de
|
||||
seguridad como Cosign, que valida explícitamente el esquema del
|
||||
realm, sí.
|
||||
|
||||
## Validación
|
||||
|
||||
Confirmado en una corrida real posterior al fix: `cosign sign` y
|
||||
`cosign verify` completaron sin error contra el registry real, con
|
||||
credenciales reales, de punta a punta.
|
||||
|
||||
!!! note "Nota relacionada, no parte de este incidente"
|
||||
El mismo log de este fallo mostró, además del error de realm, el
|
||||
warning esperado de Cosign sobre firmar por tag en vez de por
|
||||
digest — ver
|
||||
[limitaciones conocidas de Cosign](../devsecops/cosign.md#limitaciones-conocidas-y-mejoras-futuras).
|
||||
@@ -0,0 +1,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 *sí* era un
|
||||
problema real — cortaba el block scalar ahí mismo y en teoría podía
|
||||
hacer que Gitea rechazara el archivo con un error de parseo genérico
|
||||
(`could not find expected ':'`).
|
||||
|
||||
Se corrigió la indentación, se validó con un parser YAML real y
|
||||
`bash -n` sobre los 20 steps del archivo, se mergeó a `main` como fix
|
||||
(`fix(ci): corregir indentación YAML rota en el step de Semgrep`, PR
|
||||
#19)... y `build.yaml` **siguió sin generar runs**.
|
||||
|
||||
!!! warning "Por qué vale la pena contar el diagnóstico que no era"
|
||||
El primer fix no estaba mal — la indentación efectivamente estaba
|
||||
rota y corregirla era necesario. El error fue asumir que esa era
|
||||
*la única* causa sin confirmarlo con una corrida real después del
|
||||
fix. La lección de proceso: un fix que "tiene sentido" y que pasa
|
||||
la validación local (parser YAML, `bash -n`) todavía necesita una
|
||||
confirmación en vivo contra el sistema real antes de darlo por
|
||||
cerrado — sobre todo cuando el síntoma es "no pasa nada" en vez de
|
||||
un error explícito, que es exactamente el tipo de fallo más fácil
|
||||
de dar por resuelto sin verificar.
|
||||
|
||||
## Causa raíz real
|
||||
|
||||
`deploy-docs.yaml` (sin anchors) corría normal. `build.yaml` (con
|
||||
anchors, agregados junto con Gitleaks) no generaba ningún run — ni
|
||||
en `push` ni en `pull_request`, desde el primer commit que los
|
||||
introdujo. El log del propio Gitea lo confirmó:
|
||||
|
||||
```text
|
||||
unknown on type: &yaml.Node{...Value:"frontend_paths"...}
|
||||
```
|
||||
|
||||
El parser propio de Gitea Actions para el bloque `on:` de un workflow
|
||||
**no resuelve `&anchor`/`*alias` antes de inspeccionar el tipo de
|
||||
nodo** — encuentra un anchor donde esperaba un valor ya resuelto, no
|
||||
sabe qué tipo de dato es, y descarta el archivo completo en
|
||||
silencio. `pyyaml` y cualquier parser YAML estándar sí resuelven el
|
||||
alias primero (por eso el archivo "parseaba bien" en cualquier
|
||||
herramienta de validación genérica) — es una limitación específica del
|
||||
parser de workflows de Gitea Actions, no un YAML inválido.
|
||||
|
||||
Esto explica por qué el síntoma no tenía ningún error visible: no es
|
||||
que el job fallara, es que **Gitea nunca llegaba a registrar el
|
||||
workflow como existente**.
|
||||
|
||||
## Fix aplicado
|
||||
|
||||
Se eliminaron los anchors/alias. Las dos listas de `paths` (una para
|
||||
`push:`, otra para `pull_request:`) quedan **duplicadas
|
||||
literalmente**, sin referencia compartida:
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'workloads/ecommerce/**'
|
||||
- '.gitea/workflows/build.yaml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'workloads/ecommerce/**'
|
||||
- '.gitea/workflows/build.yaml'
|
||||
```
|
||||
|
||||
Es más repetitivo, pero es el precio de que el parser de Gitea
|
||||
Actions lo entienda. Este mismo criterio (sin anchors, paths
|
||||
duplicados) se replicó a propósito en `build-medusa.yaml` y
|
||||
`deploy-docs.yaml` al agregarles el resto del pipeline de seguridad,
|
||||
para no repetir el mismo problema.
|
||||
|
||||
## Validación
|
||||
|
||||
Confirmado en vivo: tras mergear el fix, `build.yaml` generó su
|
||||
primer `action_run` real. Validado también con un parser YAML +
|
||||
`bash -n` sobre los 20 steps del archivo, igual que en el intento
|
||||
anterior — la diferencia esta vez fue confirmarlo contra una corrida
|
||||
real antes de cerrar el incidente.
|
||||
Reference in New Issue
Block a user