From be3e5c2a4cd04c38a3a4b3c540ff75bc7c2e813f Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Sat, 15 Aug 2026 15:17:43 -0500 Subject: [PATCH] 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. --- .../docs-portal/docs/devsecops/cosign.md | 68 +++++++++ workloads/docs-portal/docs/devsecops/index.md | 44 ++++-- .../playbooks/incidente-504-gitea-tunnel.md | 9 +- ...ontencion-recursos-gitea-runner-2026-08.md | 133 ++++++++++++++++++ ...idente-cosign-realm-http-tunnel-2026-08.md | 93 ++++++++++++ ...idente-docker-in-docker-semgrep-2026-08.md | 102 ++++++++++++++ .../incidente-sqlite-modo-delete-2026-08.md | 103 ++++++++++++++ ...ente-yaml-anchors-gitea-actions-2026-08.md | 122 ++++++++++++++++ workloads/docs-portal/mkdocs.yml | 5 + 9 files changed, 666 insertions(+), 13 deletions(-) create mode 100644 workloads/docs-portal/docs/playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md create mode 100644 workloads/docs-portal/docs/playbooks/incidente-cosign-realm-http-tunnel-2026-08.md create mode 100644 workloads/docs-portal/docs/playbooks/incidente-docker-in-docker-semgrep-2026-08.md create mode 100644 workloads/docs-portal/docs/playbooks/incidente-sqlite-modo-delete-2026-08.md create mode 100644 workloads/docs-portal/docs/playbooks/incidente-yaml-anchors-gitea-actions-2026-08.md diff --git a/workloads/docs-portal/docs/devsecops/cosign.md b/workloads/docs-portal/docs/devsecops/cosign.md index fa97d2d..15ae440 100644 --- a/workloads/docs-portal/docs/devsecops/cosign.md +++ b/workloads/docs-portal/docs/devsecops/cosign.md @@ -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 diff --git a/workloads/docs-portal/docs/devsecops/index.md b/workloads/docs-portal/docs/devsecops/index.md index 23d5761..514aceb 100644 --- a/workloads/docs-portal/docs/devsecops/index.md +++ b/workloads/docs-portal/docs/devsecops/index.md @@ -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
(frontend.yaml)"] - B1 -.informa.-> B2["Semgrep SAST
(audit mode)"] + B0[checkout + validaciones de código] --> B1["Trivy IaC
(manifiesto K8s de la app)"] + B1 -.informa.-> B2["Semgrep SAST
(ruleset por stack, audit mode)"] B2 -.informa.-> B3[login registry] B3 --> B4["docker build
(push: false, load: true)"] B4 --> B5["Trivy imagen
CRITICAL"] @@ -40,7 +58,7 @@ flowchart TD B8 --> B9["cosign sign"] B9 --> B10["cosign verify
(smoke test)"] B10 -- "firma inválida" --> XC["❌ detenido"] - B10 -- "firma válida" --> B11["actualizar frontend.yaml
(GitOps, Argo CD sincroniza)"] + B10 -- "firma válida" --> B11["actualizar manifiesto de la app
(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)). diff --git a/workloads/docs-portal/docs/playbooks/incidente-504-gitea-tunnel.md b/workloads/docs-portal/docs/playbooks/incidente-504-gitea-tunnel.md index c9cd7d4..c0c7885 100644 --- a/workloads/docs-portal/docs/playbooks/incidente-504-gitea-tunnel.md +++ b/workloads/docs-portal/docs/playbooks/incidente-504-gitea-tunnel.md @@ -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. diff --git a/workloads/docs-portal/docs/playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md b/workloads/docs-portal/docs/playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md new file mode 100644 index 0000000..447c467 --- /dev/null +++ b/workloads/docs-portal/docs/playbooks/incidente-contencion-recursos-gitea-runner-2026-08.md @@ -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. diff --git a/workloads/docs-portal/docs/playbooks/incidente-cosign-realm-http-tunnel-2026-08.md b/workloads/docs-portal/docs/playbooks/incidente-cosign-realm-http-tunnel-2026-08.md new file mode 100644 index 0000000..a2aa6f8 --- /dev/null +++ b/workloads/docs-portal/docs/playbooks/incidente-cosign-realm-http-tunnel-2026-08.md @@ -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). diff --git a/workloads/docs-portal/docs/playbooks/incidente-docker-in-docker-semgrep-2026-08.md b/workloads/docs-portal/docs/playbooks/incidente-docker-in-docker-semgrep-2026-08.md new file mode 100644 index 0000000..f67933b --- /dev/null +++ b/workloads/docs-portal/docs/playbooks/incidente-docker-in-docker-semgrep-2026-08.md @@ -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. diff --git a/workloads/docs-portal/docs/playbooks/incidente-sqlite-modo-delete-2026-08.md b/workloads/docs-portal/docs/playbooks/incidente-sqlite-modo-delete-2026-08.md new file mode 100644 index 0000000..4c67047 --- /dev/null +++ b/workloads/docs-portal/docs/playbooks/incidente-sqlite-modo-delete-2026-08.md @@ -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. diff --git a/workloads/docs-portal/docs/playbooks/incidente-yaml-anchors-gitea-actions-2026-08.md b/workloads/docs-portal/docs/playbooks/incidente-yaml-anchors-gitea-actions-2026-08.md new file mode 100644 index 0000000..0209845 --- /dev/null +++ b/workloads/docs-portal/docs/playbooks/incidente-yaml-anchors-gitea-actions-2026-08.md @@ -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. diff --git a/workloads/docs-portal/mkdocs.yml b/workloads/docs-portal/mkdocs.yml index dbcc013..640f5b0 100644 --- a/workloads/docs-portal/mkdocs.yml +++ b/workloads/docs-portal/mkdocs.yml @@ -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: