Files
apps-registry/workloads/docs-portal/docs/playbooks/migracion-platform-templates-2026-08.md
devopsandClaude Sonnet 5 29021bff70
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 22s
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 26s
Build and Push Docs Portal / Escaneo de Secretos (Gitleaks) (push) Successful in 28s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 17m19s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 8m23s
Build and Push Docs Portal / Construir y publicar Docs Portal (push) Successful in 6m18s
fix(ci): condicion de carrera en el chequeo de promocion GitOps
Con el runner corriendo 1 job a la vez (capacity: 1), cuando 2+
pipelines comparten el mismo commit disparador, el primero en terminar
deja su propio commit de GitOps en main -- los siguientes veian 'main
ya no es igual a mi commit' y se saltaban su propia promocion, aunque
era seguro promocionar. Visto en vivo: Frontend construyo, escaneo y
firmo v1.0.119 pero el manifiesto seguia en v1.0.104 (Promocion GitOps:
false). Fix en los 3 workflows: git merge-base --is-ancestor en vez de
comparacion exacta de SHA. Bump manual de frontend a v1.0.119 (la
version que ya estaba lista, sin desplegar). Documentado el limite
conocido de este fix en el playbook de la migracion.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011FqYP3Wf1W63Qmh7qgXAAT
2026-08-17 01:47:04 -05:00

8.4 KiB

Migración a platform-templates: docs-portal, frontend, medusa (2026-08)

Ventana 2026-08-17
Servicios migrados docs-portal, ecommerce-frontend (frontend-deploy), commerce-backend (medusa-deploy)
Impacto en producción Cero downtime en las 3 — cada rollout mantuvo el pod viejo sirviendo tráfico hasta que el nuevo estuvo sano

Qué cambió

Las 3 apps pasaron de manifiestos Kustomize sueltos y propios de cada una a un overlay sobre el base común de platform-templates (k8s-manifests/base/, referenciado por URL de git remota, no una copia local). El base agrega por default lo que ninguna de las 3 tenía completo: readinessProbe/livenessProbe (docs-portal y frontend no tenían ninguna) y securityContext a nivel pod y contenedor.

Ningún nombre de recurso cambió (docs-portal-deployment, frontend-deploy, medusa-deploy, sus Services, todo se mantuvo idéntico) — importante para no romper el tracking de Argo CD ni la resolución DNS interna de otros servicios que dependen de esos nombres (ej. MEDUSA_BACKEND_URL: http://medusa-svc:9000 en el frontend).

Por qué: 3 bugs reales encontrados aplicando esto en producción

Los 3 se encontraron con el pod viejo todavía sirviendo tráfico (maxUnavailable: 0 en la estrategia de rollout, y Kubernetes excluye automáticamente del Service cualquier pod que no pase su readinessProbe) — cero impacto real en ningún momento, pero sí bugs de verdad, no hipotéticos.

1. runAsNonRoot: true contra una imagen que corre como root (docs-portal)

docs-portal usa nginx:1.27-alpine sin ningún USER en su Dockerfile — corre como root. Aplicar el default del base (runAsNonRoot: true) rompió el pod al instante: CreateContainerConfigError. Fix: override explícito runAsNonRoot: false para este container puntual, documentado como temporal hasta que docs-portal adopte dockerfiles/static-nginx.Dockerfile (nginxinc/nginx-unprivileged, no-root de verdad) — cambio de imagen aparte, no hecho todavía.

2. capabilities: drop: [ALL] rompe el propio arranque de nginx (docs-portal)

Con runAsNonRoot: false ya resuelto, el pod siguió fallando:

2026/08/17 05:30:33 [emerg] 1#1: chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)

El entrypoint oficial de la imagen de nginx hace un chown() sobre su propio directorio de cache en cada arranque — necesita CAP_CHOWN. El base saca todas las capabilities por default; incluso corriendo como root, sin CAP_CHOWN ese chown() falla igual. Fix: sacar la restricción de capabilities entera para este container (capabilities: null en el patch — es la única forma real de anular un drop: [ALL] heredado, ver nota técnica más abajo). No es peor que el comportamiento actual en producción, solo no está endurecido todavía — se resuelve del todo con el mismo cambio de imagen del punto 1.

3. runAsNonRoot: true sin runAsUser numérico (frontend)

ecommerce-frontend sí corre no-root (USER nextjs en su Dockerfile) — pero el pod nuevo falló igual:

Error: container has runAsNonRoot and image has non-numeric user (nextjs),
cannot verify user is non-root

El Dockerfile define el usuario por nombre (useradd ... nextjs), no por UID numérico. Kubernetes no puede confirmar que ese nombre resuelve a un UID distinto de 0 sin arrancar el contenedor, así que por seguridad se niega a intentarlo. Fix: runAsUser: 1001 explícito (el UID real del useradd --uid 1001 del Dockerfile).

Aplicado ya con este aprendizaje: para medusa-deploy, se confirmó el UID real del usuario node de la imagen con docker run --rm <imagen> id node (→ 1000) antes de escribir el overlay, en vez de asumirlo — la migración de Medusa no tuvo ningún bug, arrancó sana en el primer intento, initContainers (wait-for-postgres, migrations) incluidos.

Nota técnica: 2 gotchas de Kustomize (no de Kubernetes) encontrados armando esto

  • Renombrar un recurso vía patch de merge normal no funciona — aunque uses target: para seleccionar el recurso del base por su nombre original, cambiar metadata.name dentro del contenido del patch no lo renombra en el resultado final. Hace falta un patch JSON6902 aparte (op: replace, path: /metadata/name).
  • Anular un campo heredado necesita null explícito — para sacar el capabilities: {drop: [ALL]} del base (bug #2 de arriba), ni omitir el campo ni un objeto vacío {} alcanzan; solo capabilities: null en el patch realmente lo borra.

Ambos confirmados corriendo kubectl kustomize antes de aplicar nada al cluster — no son suposiciones.

Validación

Cada una de las 3 migraciones se confirmó con un curl real al dominio público correspondiente después del rollout:

  • docs.cruzcloud.net → 200
  • shop.cruzcloud.net → 200
  • commerce.cruzcloud.net/app → 200

Y con el estado del pod nuevo (kubectl get pods, sin reinicios, 1/1 Ready) antes de dar cada migración por cerrada.

Actualización (mismo día): 2 bugs más, esta vez en CI, no en K8s

El primer push real después de migrar las 3 apps (justo el commit que agregó las secciones de arriba a este playbook) expuso dos problemas más — ninguno de los dos en Kubernetes esta vez, los dos en los propios pipelines de Gitea Actions.

4. MANIFEST_FILE apuntando a una ruta que ya no existía

Mover cada deployment.yaml suelto a .../patches/deployment.yaml (necesario para el patrón base+overlay) rompió en silencio el paso de auto-actualización GitOps de los 3 pipelines (build.yaml, build-medusa.yaml, deploy-docs.yaml) — cada uno tiene su propio MANIFEST_FILE hardcodeado como variable de entorno, y los 3 seguían apuntando a las rutas viejas. No se notó hasta el primer build real post-migración: la imagen se construyó, escaneó y firmó bien, pero el último paso falló con sed: can't read ...: No such file or directory, dejando una imagen firmada y verificada sin desplegar. Fix: actualizar las 3 rutas de MANIFEST_FILE.

Para la próxima vez que se mueva un manifiesto: grep -rn MANIFEST_FILE .gitea/workflows/ antes de asumir que un git mv dentro de workloads/ es solo un problema del propio manifiesto — los pipelines de CI referencian esas rutas exactas en un archivo aparte.

5. Condición de carrera en el chequeo de "promoción segura"

El runner de Gitea de este lab corre un solo job a la vez (capacity: 1 en su config). Cuando el fix del punto 4 disparó los 3 pipelines a la vez para el mismo commit, se ejecutaron en serie. El chequeo de "promoción segura" de cada uno compara el commit que lo disparó contra el HEAD actual de main (if [ "$CURRENT_SHA" = "$REMOTE_SHA" ]) para evitar que una corrida vieja pise un manifiesto más nuevo. Problema real: el primer pipeline en terminar (Medusa) dejó su propio commit automático en main — para cuando les tocó el turno a Frontend y Docs Portal, main ya no coincidía con el commit que los había disparado, así que ambos se saltaron la actualización del manifiesto (Promoción GitOps: false) aunque era perfectamente seguro promocionar. Frontend quedó con una imagen nueva construida, escaneada y firmada (v1.0.119) pero sin desplegar — el manifiesto seguía en v1.0.104.

Fix aplicado en los 3 workflows: cambiar la comparación exacta de SHA por git merge-base --is-ancestor "$CURRENT_SHA" origin/main — permite promocionar mientras el commit disparador siga siendo ancestro de main (tolera que otro pipeline haya commiteado en el medio), y solo bloquea si de verdad hubo un rebase/force-push que lo descartó.

!!! warning "Límite conocido de este fix, aceptado a propósito" is-ancestor es más permisivo que la comparación exacta en un caso borde distinto: si 2 commits al mismo app quedaran en cola y el más viejo terminara después del más nuevo, ahora sí promocionaría igual — pisando la versión más nueva con la más vieja. Con un solo mantenedor en este lab, 2 commits casi simultáneos al mismo app es muy poco probable; se aceptó el trade-off en vez de una solución más compleja (ignorar commits [skip ci] del propio bot al calcular "el último commit real"). Revisar si esto deja de ser cierto (más de una persona commiteando, o automatización que dispare builds más seguido).