Compare commits

...
Author SHA1 Message Date
devops 1df1c3a3c1 Merge pull request 'fix(ci): quitar anchors/aliases YAML de build.yaml' (#20) from fix/build-yaml-anchor-not-supported into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 25s
Build and Push Frontend / Construir y Subir Imagen (push) Failing after 42s
2026-08-15 04:25:51 +00:00
devops 6773a8085d fix(ci): quitar anchors/aliases YAML de build.yaml, no soportados por Gitea Actions
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (pull_request) Successful in 39s
Build and Push Frontend / Construir y Subir Imagen (pull_request) Skipped
Gitea descartaba el workflow completo en TODOS los eventos (push y
pull_request), desde el primer commit de gitleaks: "unknown on type:
&yaml.Node{...Value:"frontend_paths"...}". El YAML era válido (pyyaml
lo parsea sin problema, resolviendo el alias como cualquier parser
estándar) pero el parser propio de Gitea Actions para el bloque "on:"
no resuelve &anchor/*alias antes de inspeccionar el tipo de nodo.

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

Fix: paths duplicados literalmente entre push: y pull_request:, sin
anchor. Válido con parser YAML + bash -n en los 20 steps.
2026-08-14 23:24:31 -05:00
gitea-actions ea7d5e7f47 chore(gitops): deploy Docs Portal v1.0.98 [skip ci] 2026-08-15 04:15:42 +00:00
devops 98096ab69f Merge pull request 'feat(devsecops): Gitleaks + Trivy + Semgrep + Syft + Cosign en el frontend' (#19) from fix/frontend-gitleaks-ci into main
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 1m36s
2026-08-15 04:14:08 +00:00
devops 2438a6ba0f fix(ci): corregir indentación YAML rota en el step de Semgrep
El python3 -c "..." embebido dentro del run: | tenía el código pegado
a columna 0, por debajo de la indentación del bloque YAML -- eso corta
el block scalar ahí mismo y Gitea terminaba ignorando el workflow
completo ("could not find expected ':'"), silenciosamente, desde el
commit de Semgrep.

Validado con un parser YAML real (no solo con builds de Docker) y
bash -n sobre los 20 steps del archivo.
2026-08-14 22:59:31 -05:00
devops 96be5b4b03 chore: trigger CI re-check after Gitea SQLite WAL fix 2026-08-14 22:28:22 -05:00
devops 7dc50b83e0 docs(devsecops): agregar página resumen con diagrama del pipeline
docs/devsecops/index.md documenta el pipeline completo (job gitleaks +
job build) con un diagrama Mermaid del orden real de ejecución, tabla
de qué bloquea vs qué solo informa, y qué pasa si cada herramienta
falla. Nav actualizado: "Resumen" como landing page de la sección
DevSecOps.

Con esto quedan las 5 herramientas de la Fase 2 (Gitleaks, Trivy,
Semgrep, Syft, Cosign) integradas y documentadas para
workloads/ecommerce, listas para replicar a commerce-backend y
docs-portal en una próxima vuelta.
2026-08-14 21:59:58 -05:00
devops c1b5f72a68 feat(devsecops): firmar la imagen del frontend con Cosign
Firma la imagen (solo el tag versionado, no :latest) después del push
exitoso, usando la llave privada desde secrets de Gitea Actions
(COSIGN_PRIVATE_KEY/COSIGN_PASSWORD, nunca en el repo ni en disco).
Verifica la firma como smoke test en el mismo pipeline contra
workloads/ecommerce/cosign.pub (pública, commiteada a propósito).

Firma solo con el par de llaves propio, sin publicar en el
transparency log público de Sigstore (--use-signing-config=false
--tlog-upload=false / --insecure-ignore-tlog=true) — registry privado,
no tiene sentido esa fuga de metadata.

Par de llaves generado y validado end-to-end (firma + verificación,
incluyendo que verificar con la llave incorrecta falla como se espera)
contra un registry local descartable antes de tocar nada real. Docs en
docs/devsecops/cosign.md, incluyendo qué falta para que un admission
controller verifique esto en el cluster (no implementado todavía).
2026-08-14 21:55:03 -05:00
devops b6038e06ca feat(devsecops): generar SBOM (Syft) de la imagen del frontend
Corre después del gate de CRITICAL de Trivy, sobre la imagen ya
construida. Genera CycloneDX y SPDX, publicados como artifact
sbom-v1.0.X en cada build.

Validado contra la imagen real: 3528 componentes CycloneDX / 228
paquetes SPDX. Documenta en docs/devsecops/sbom.md el propósito
(trazabilidad tipo "log4shell"), diferencia entre formatos, y un
hallazgo real (yarn empaquetado sin usarse, mismo patrón que el npm
sacado en el fix de Trivy).
2026-08-14 21:14:00 -05:00
devops 4aed9f6c5c feat(devsecops): agregar Semgrep SAST en modo auditoría al pipeline
Escanea workloads/ecommerce con rulesets públicos del registro de
Semgrep (p/typescript, p/react, p/nextjs, p/security-audit) vía la
imagen oficial semgrep/semgrep:1.173.0. Sin --error a propósito: siempre
termina en exit 0, solo reporta — primera vuelta para revisar juntos qué
reglas deberían pasar a bloquear más adelante.

Validado contra el código real: 0 hallazgos en 91 reglas / 72 archivos.
Documenta la herramienta en docs/devsecops/sast.md, incluyendo la
diferencia con gitleaks/trivy y qué significa (y qué no) un scan limpio.
2026-08-14 21:07:05 -05:00
devops df13233a29 feat(devsecops): agregar Trivy (imagen + IaC) al pipeline del frontend
trivy config sobre frontend.yaml (CRITICAL/HIGH/MEDIUM, informativo) y
trivy image sobre la imagen recién construida (CRITICAL bloquea con
--ignore-unfixed, HIGH solo informa), entre build (push:false, load:true)
y el push real al registry.

Fix real encontrado al validar contra la imagen real: el stage runner
heredaba npm/npx/corepack completos de la imagen base de Node sin
necesitarlos en runtime, trayendo CVE-2026-59873 (CRITICAL, node-tar
empaquetado en npm). Sacarlos del stage final baja CRITICAL de 1 a 0 y
HIGH fixable de 21 a 15 (las que quedan son de la propia app, ej. next
desactualizado, documentadas como pendiente sin bloquear).

Documenta ambos scans en docs/devsecops/trivy.md con los hallazgos
reales de este repo.
2026-08-14 20:43:09 -05:00
devops 422e9d257d feat(devsecops): agregar Gitleaks al pipeline del frontend
Corre en push y pull_request, antes del job build (needs: gitleaks).
Si detecta un secreto, exit-code=1 detiene el pipeline antes de
construir la imagen. Scan histórico completo del repo (249 commits)
confirmado limpio, corrido aparte de forma manual.

Documenta el step en docs/devsecops/gitleaks.md: qué es secret
scanning, por qué corre antes del build, cómo leer un hallazgo y
cómo manejar falsos positivos con allowlist.
2026-08-14 19:48:44 -05:00
gitea-actions 66c44a7dad chore(gitops): deploy Docs Portal v1.0.97 [skip ci] 2026-08-14 04:10:07 +00:00
devops ef0093a406 Merge pull request 'fix(docs-portal): deshabilitar git-revision-date-localized (rompe --strict)' (#18) from fix/docs-portal-deshabilitar-plugin-fechas into main
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 10m7s
2026-08-14 03:29:21 +00:00
devops 0234c20463 fix(docs-portal): deshabilitar git-revision-date-localized (rompe --strict)
mkdocs build --strict aborta ante CUALQUIER WARNING, no solo ante links
rotos. El plugin, al no tener .git real en el contexto de build
(workloads/docs-portal/, sin .git de la raíz del repo), cae siempre en
fallback_to_build_date y emite un WARNING por página — suficiente para
tumbar el build bajo --strict (confirmado en run 96, job 108: 33
warnings, todos de este plugin, cero links rotos).

Se deshabilita el plugin (comentado en mkdocs.yml y requirements.txt,
con TODO fase 2: mover el build a la raíz del repo + fetch-depth:0 para
darle historial real) y se revierte la instalación de git en el
Dockerfile, que ya no hace falta. index.md actualizado para no prometer
fechas reales que hoy no se muestran.
2026-08-13 22:27:56 -05:00
devops 9ec40370bf Merge pull request 'fix(docs-portal): corregir anchor roto en playbook de crashloop de Medusa' (#17) from fix/docs-portal-anchor-roto into main
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Failing after 2m11s
2026-08-14 03:20:51 +00:00
devops 22534d65f3 fix(docs-portal): corregir anchor roto en playbook de crashloop de Medusa
mkdocs build --strict (run 95, job 107) falló: el link a '#deuda-técnica-pendiente'
no coincide con el anchor real que genera mkdocs, porque el slugify por
defecto de Python-Markdown normaliza tildes (NFKD + strip de combining marks)
antes de generar el id del heading. El heading '## Deuda técnica pendiente'
genera 'deuda-tecnica-pendiente' (sin tilde), no 'deuda-técnica-pendiente'.
2026-08-13 22:10:36 -05:00
devops 45924f9df2 Merge pull request 'fix(docs-portal): instalar git en la imagen de build para mkdocs-git-revision-date-localized-plugin' (#16) from fix/docs-portal-dockerfile-git-binario into main
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Failing after 9m50s
2026-08-14 02:57:10 +00:00
devops bb1249fd58 fix(docs-portal): instalar git en la imagen de build para mkdocs-git-revision-date-localized-plugin
El plugin depende de GitPython, que necesita el binario git para
importarse (no solo para leer fechas) — sin él, mkdocs build --strict
falla en el import del plugin antes de llegar al fallback_to_build_date
configurado en mkdocs.yml.

Detectado en el primer intento real de build en Gitea Actions (run 94,
job 106).
2026-08-13 21:56:11 -05:00
devops 10556758b2 Merge pull request 'docs(docs-portal): documentar que el pipeline de build/deploy quedó operativo' (#15) from fix/docs-portal-estado-pipeline-vivo into main
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Failing after 7m46s
2026-08-14 02:42:04 +00:00
devops e8712327a2 docs(docs-portal): documentar que el pipeline de build/deploy quedó operativo
Dispara el primer build real del portal via .gitea/workflows/deploy-docs.yaml
ahora que el Deployment, Service, Ingress y el secret de registry ya existen
en el namespace docs-portal.
2026-08-13 21:41:50 -05:00
devops d5c80c98e7 Merge pull request 'feat(docs-portal): pipeline GitOps y manifiestos K8s para el portal MkDocs' (#14) from fix/docs-portal-pipeline-manifests into main
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Failing after 1m21s
2026-08-14 02:33:53 +00:00
devops fb5cf8bc98 feat(docs-portal): pipeline GitOps y manifiestos K8s para el portal MkDocs
Agrega lo que faltaba para desplegar workloads/docs-portal/ (ya existente
sin commitear): deployment/service/ingress + kustomization siguiendo el
patrón de workloads/nginx, Applications de workload y gobernanza
separadas, y el workflow de Gitea Actions (build+push a Gitea Registry,
bump de versión en el manifiesto) siguiendo el mismo patrón que
build.yaml/build-medusa.yaml.
2026-08-13 21:08:04 -05:00
devops 11737ba109 Merge pull request 'docs(playbooks): documentar incidentes túnel/precios/imágenes' (#13) from fix/docs-incidentes-tunel-precios-imagenes into main 2026-08-13 23:39:17 +00:00
devops b42bcad45e docs(playbooks): documentar incidentes túnel/precios/imágenes [skip ci]
Tres incidentes resueltos en la sesión del 2026-08-12/13: 504 intermitente
por QUIC inestable en cloudflared, precios null por falta de region_id
en el Store API de Medusa v2 (se descarta la hipótesis inicial de
regiones COP duplicadas con evidencia directa en Postgres), e imágenes
en blanco por un proxy host mal configurado en Nginx Proxy Manager para
media.cruzcloud.net. Se agregan también dos notas de referencia en
known-issues.md: la topología Cloudflare Tunnel -> NPM -> k3d, y el
requisito de region_id explícito en StoreGetProductsParams.
2026-08-13 18:34:10 -05:00
gitea-actions 130177a9f2 chore(gitops): deploy Medusa v1.0.92 [skip ci] 2026-08-13 22:08:17 +00:00
devops 7644b53c49 Merge pull request 'feat(commerce-backend): revalidar el storefront al crear/borrar productos' (#11) from fix/medusa-revalidate-on-product-created into main
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 12m33s
2026-08-13 21:55:54 +00:00
devops 8c52229e71 feat(commerce-backend): revalidar el storefront al crear/borrar productos
El subscriber revalidate-storefront.ts (PR #9) solo escuchaba
product.updated y los eventos de variante, asi que un producto nuevo
(product.created) o eliminado (product.deleted) no disparaba
revalidateTag: el storefront tardaba hasta el ciclo normal de cache
(~60s por pod) en reflejar altas/bajas de catalogo, a diferencia de
precio/descripcion/imagenes en productos existentes, que ya viajan por
product.updated.
2026-08-13 16:55:16 -05:00
devops 2d09867f70 Merge pull request 'chore(gitops): fijar Medusa a v1.0.90 tras carrera de promocion en CI' (#10) from fix/medusa-manifest-lagged-race into main 2026-08-13 21:41:45 +00:00
devops 6a0e9965a9 chore(gitops): fijar Medusa a v1.0.90 tras carrera de promocion en CI
El pipeline build-medusa.yaml (run #90) construyo y publico
ecommerce-medusa:v1.0.90 con exito, pero su paso "Actualizar
manifiesto Medusa" quedo skipped: el chequeo de promocion segura
comparo github.sha contra origin/main y encontro que el commit
"chore: release v1.0.91" del frontend ya habia cambiado el HEAD, asi
que se abstuvo de sobrescribirlo. medusa.yaml se quedo en v1.0.88, sin
el subscriber revalidate-storefront agregado en el PR #9.

Bump manual del tag en ambos containers (migrations y medusa) para
que Argo CD despliegue la imagen que ya esta en el registry.
2026-08-13 16:39:28 -05:00
devops 095912d02d chore: release v1.0.91 [skip ci] 2026-08-13 21:19:17 +00:00
devops a1d2f9db67 Merge pull request 'feat(ecommerce): revalidar el storefront al instante tras editar precios' (#9) from fix/medusa-price-instant-revalidate into main
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 7m39s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 15m24s
2026-08-13 21:06:13 +00:00
devops d1a4a84d24 feat(ecommerce): revalidar el storefront al instante tras editar precios
Antes el precio actualizado en Medusa tardaba hasta ~60s (o más, con
varias réplicas del frontend sin cache handler compartido) en verse en
el front, por el revalidate:60 del fetch a /store/products.

Ahora:
- Nueva ruta app/api/revalidate en el storefront que llama a
  revalidateTag("medusa-products") al recibir un POST autenticado con
  el header x-revalidate-secret.
- Nuevo subscriber en el backend Medusa (product.updated,
  product-variant.updated/created/deleted) que llama a esa ruta usando
  STOREFRONT_URL (ya existe en commerce-config) y un REVALIDATE_SECRET
  compartido entre ambos servicios.
- secrets.template.yaml documenta la nueva clave REVALIDATE_SECRET en
  commerce-secrets y commerce-storefront (mismo valor en ambos).

Requiere reaplicar commerce-secrets y commerce-storefront con el script
actualizado en el repo scripts (fix/revalidate-secret-scripts) para que
el REVALIDATE_SECRET exista en el cluster.
2026-08-13 16:02:49 -05:00
devops a9c57d4985 chore: release v1.0.89 [skip ci] 2026-08-13 07:07:55 +00:00
devops 584a42ea72 Merge pull request 'fix(ecommerce): detalle de producto en dynamic render, no SSG+ISR stale' (#8) from fix/product-detail-static-mock-price into main
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 5m55s
2026-08-13 07:02:11 +00:00
devopsandClaude Sonnet 5 a6a92f75ea fix(ecommerce): render product detail dynamically instead of stale SSG+ISR
Product detail pages were generateStaticParams'd at Docker build time,
where MEDUSA_* env vars aren't set, so the static snapshot baked in the
mock catalog (price: null for all products). With revalidate=60 and no
shared ISR cache handler across the 2 frontend replicas, each pod healed
its own cache independently on next visit past staleness — so a product
could show "Consultar precio" on one pod and the real price on the other,
while the fully-dynamic catalog listing always hit Medusa fresh. Verified
against the live Medusa API that pricing/region data itself was correct
for the reported failing products.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01DXqBoNfYQNwFg65mbx2FWM
2026-08-13 01:59:44 -05:00
devops 0eea6234d6 chore: release v1.0.87 [skip ci] 2026-08-13 00:24:20 -05:00
gitea-actions 77bf6089fb chore(gitops): deploy Medusa v1.0.88 [skip ci] 2026-08-13 05:01:13 +00:00
devops 0134a4dd0b Merge pull request 'fix: desactivar cookie Secure en sesion admin de Medusa' (#7) from fix/medusa-admin-session-cookie into main
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 18m10s
2026-08-13 04:08:03 +00:00
devops 3fe63591ec Merge pull request 'fix: forzar ISR en /product/[slug] para que no quede atascado en mock' (#6) from fix/product-page-static-mock-price into main
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 7m12s
2026-08-13 04:04:40 +00:00
devops c6cf394c26 fix: desactivar cookie Secure en sesion admin de Medusa
Todos los Ingress del lab terminan en Traefik por HTTP plano (entrypoint
web, sin TLS); Cloudflare es quien atiende HTTPS de cara al navegador.
Con secure:true (default en produccion), express-session nunca emite
Set-Cookie porque no detecta la conexion como HTTPS, dejando el
dashboard admin con 401 en /admin/users/me pese a loguear bien.
2026-08-12 23:01:35 -05:00
devops 7a09202c27 fix: forzar ISR en /product/[slug] para que no quede atascado en datos mock del build
generateStaticParams usa el catálogo mock en build time porque
HEADLESS_PROVIDER y MEDUSA_* no existen como build args del Dockerfile.
Sin un revalidate explícito, esa ruta queda estática para siempre con
price:null. El listado no sufre esto porque su ruta es dinámica por
searchParams.
2026-08-12 22:56:03 -05:00
devops a902dc0a26 chore: release v1.0.86 [skip ci] 2026-08-12 07:16:46 +00:00
devops 04dc6bd8bc Merge pull request 'fix: usar region_id en vez de currency_code para precios de Medusa' (#5) from fix/medusa-region-id-pricing into main
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 9m9s
2026-08-12 07:03:28 +00:00
devops 60072cfcbe fix: usar region_id en vez de currency_code para precios de Medusa
El validador de /store/products (StoreGetProductsParams, .strict()) solo
acepta region_id/country_code/province/cart_id para resolver contexto de
precio -- currency_code como campo plano no existe y rompía con 400
"Unrecognized fields: 'currency_code'" en cada carga de /catalog.

country_code tampoco alcanza: probado en vivo, devuelve "Missing required
pricing context to calculate prices - region_id". Se necesita region_id
explícito.

Se agrega MEDUSA_REGION_ID (env var) apuntando a la región "Colombia"
(COP) creada vía Admin API, ya que no existía ninguna región configurada
en el backend.
2026-08-12 02:01:49 -05:00
devops c9b376c4e6 Merge pull request 'fix: conectar el frontend al catálogo real de Medusa' (#4) from fix/frontend-wire-medusa-catalog into main 2026-08-12 05:16:18 +00:00
devops cea41ce428 fix: conectar el frontend al catálogo real de Medusa
El Deployment frontend-deploy no tenía ninguna variable de entorno, así
que HEADLESS_PROVIDER quedaba undefined y el provider caía siempre en
mockCatalogProvider (data/products.json estático), aunque
medusaCatalogProvider ya estaba implementado y listo en
lib/headless/providers/medusa.ts.

Se agrega HEADLESS_PROVIDER=medusa, MEDUSA_BACKEND_URL apuntando al
Service interno medusa-svc:9000 (evita depender de Cloudflare/NPM en
cada render SSR), y envFrom hacia el secret commerce-storefront
(MEDUSA_PUBLISHABLE_KEY) ya creado en el namespace ecommerce.

No se bumpea el tag de imagen: build.yaml excluye explícitamente los
cambios de frontend.yaml del trigger de build, y la versión la gestiona
la propia CI vía sed sobre este archivo.
2026-08-12 00:12:10 -05:00
devops 381e6ab6ab docs(playbooks): documentar incidente medusa-deploy atascado en Init (2026-08)
Postmortem del incidente resuelto en e9c93bf: sintoma reportado como
CrashLoopBackOff que en realidad era Init:0/2 indefinido (0 restarts).
Documenta las hipotesis descartadas en orden (secret/DATABASE_URL,
NetworkPolicy, salud de Postgres, ResourceQuota, DNS, MTU/PMTUD
cross-node), la causa raiz (pg_isready no parseaba ?sslmode=disable en
la URI, exit 3 "no attempt"), el fix con flags explicitos, y la deuda
tecnica pendiente en la branch fix/medusa-wait-for-postgres.
2026-08-10 00:47:23 -05:00
devops e9c93bfb82 fix(medusa): usar flags explicitos en pg_isready en vez de DATABASE_URL crudo
El init container wait-for-postgres pasaba $DATABASE_URL completo a
pg_isready. Al agregarle ?sslmode=disable (via create-commerce-secrets.sh),
pg_isready fallaba con "no attempt" (exit 3, conninfo invalida para su
parser de URI) y el pod quedaba atascado en Init indefinidamente, sin
llegar nunca al CrashLoopBackOff visible. Postgres, DNS, Endpoints y
NetworkPolicy estaban sanos; el fallo era puramente del parseo de la URI.

Se reemplaza por -h/-p/-U/-d explicitos (host y puerto fijos del Service,
usuario y db ya disponibles via envFrom), asi el init container queda
inmune a query params futuros en DATABASE_URL.
2026-08-10 00:27:10 -05:00
devops f25b10a2f9 Merge pull request 'fix(medusa): forzar podAffinity con commerce-postgres para evitar blackhole MTU cross-node' (#3) from fix/medusa-postgres-node-affinity into main 2026-08-09 07:36:33 +00:00
devops 7a83ed61b9 fix(medusa): forzar podAffinity con commerce-postgres para evitar blackhole MTU cross-node
Diagnostico: pg_isready y el cliente pg de Medusa fallan de forma
consistente (100%, 186/186 intentos) cuando el pod queda agendado en un
nodo distinto al de commerce-postgres-0. TCP handshake (nc/ping) funciona
cruzando nodos, pero el primer paquete de datos real de la sesion nunca
llega - blackhole de PMTU Discovery entre los nodos del cluster k3d
(flannel VXLAN / MTU del bridge Docker subyacente).

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