# 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](../plantillas/platform-templates.md) (`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`](https://gitea.cruzcloud.net/devops/platform-templates/src/branch/main/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 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).