From 056a7fb76a8350c49eab99a50354799ae6dd8b64 Mon Sep 17 00:00:00 2001 From: Cristian Felipe Cruz Buitron Date: Wed, 26 Aug 2026 23:51:18 -0500 Subject: [PATCH] docs(docs-portal): Fase 2 - contenido narrativo real de arquitectura/decisiones/aprendizajes Reemplaza los placeholders "TODO (fase 2+)" por narrativa completa, grounded en el codigo/manifiestos/incidentes reales del lab (no generico): - ADR 0001 (por que k3d) y ADR 0002 (por que Argo CD): pasan de template vacio a Contexto/Opciones/Decision/Consecuencias reales, apoyados en known-issues.md (blackhole MTU) y en la config real de ecommerce-app.yaml. - vision-general.md: el "por que" de cada frontera de confianza del diagrama. - gitops-flujo.md: ejemplo real con hashes de commit (Fase 3b de ADR 0006, v1.0.171 -> hotfix -> v1.0.172), selfHeal explicado con el syncPolicy real del repo. - red-y-exposicion.md: diagrama de terminacion TLS real, por que todo va "proxied", patron de diagnostico "interno sano, publico roto". - glosario.md: 9 terminos nuevos, cada uno con ejemplo real y verificado del repo (Namespace, ResourceQuota, LimitRange, NetworkPolicy, Deployment, Service, Ingress, StatefulSet, Init container). - notas-sueltas.md: MinIO/S3, limites reales del subscriber revalidate-storefront.ts, que se automatizaria desde cero. Validado con `docker build --target build` (mkdocs build --strict, mismo paso que el pipeline real) -- cero links rotos, cero warnings. --- .../docs/aprendizajes/notas-sueltas.md | 89 ++++++++--- .../docs/arquitectura/gitops-flujo.md | 62 ++++++-- .../docs-portal/docs/arquitectura/glosario.md | 143 +++++++++++++++++- .../docs/arquitectura/red-y-exposicion.md | 72 +++++++-- .../docs/arquitectura/vision-general.md | 58 ++++++- .../docs/decisiones/0001-por-que-k3d.md | 68 ++++++--- .../0002-por-que-argocd-vs-manual.md | 78 ++++++++-- 7 files changed, 490 insertions(+), 80 deletions(-) diff --git a/workloads/docs-portal/docs/aprendizajes/notas-sueltas.md b/workloads/docs-portal/docs/aprendizajes/notas-sueltas.md index 18c40c1..0e00191 100644 --- a/workloads/docs-portal/docs/aprendizajes/notas-sueltas.md +++ b/workloads/docs-portal/docs/aprendizajes/notas-sueltas.md @@ -1,20 +1,9 @@ # 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). +> Esto es un scratchpad estructurado, no un playbook — sigue creciendo +> con notas cortas a medida que aparecen. Cada entrada está extraída de +> un incidente o hallazgo real ya documentado en otra parte del sitio, +> no es especulación. ## Gotchas de Medusa v2 @@ -26,8 +15,70 @@ del array `images` — hay que setearlo explícito desde el Admin si el frontend lo usa como imagen hero en listados. -## TODO +## MinIO / S3 provider -- 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. +- Medusa sube imágenes de producto a MinIO (`minio-svc:9000`, bucket + `ari-products`) usando el provider S3-compatible estándar — MinIO no + sabe que no es AWS real, y Medusa tampoco necesita saberlo. +- La URL pública que termina viendo el navegador **no** es la interna + (`minio-svc:9000`): es `S3_FILE_URL=https://media.cruzcloud.net/ari-products`, + un dominio propio proxied por NPM hacia el mismo MinIO. Esto tiene + una consecuencia directa que ya rompió producción una vez: si + `media.cruzcloud.net` deja de rutear bien (mismo patrón de + `forward_host` roto que el resto del sitio), **todas** las imágenes + de producto del storefront se caen aunque MinIO y Medusa estén + perfectamente sanos — porque el navegador nunca habla con MinIO + directo, solo con esa URL pública. Ver [Red y exposición](../arquitectura/red-y-exposicion.md). + +## Subscriber `revalidate-storefront.ts` y sus límites + +- Escucha 6 eventos de Medusa (`product.created/updated/deleted` + + los 3 equivalentes de `product-variant`) y por cada uno hace un + único `POST` a `${STOREFRONT_URL}/api/revalidate` con un header + secreto (`x-revalidate-secret`) — esa ruta del frontend llama + `revalidateTag("medusa-products")` de Next.js. +- **Se degrada solo, no tira el evento entero**: si + `STOREFRONT_URL`/`REVALIDATE_SECRET` no están configurados, o el + `fetch` falla, solo loguea un `warn` y sigue — un fallo de red hacia + el frontend nunca revierte ni bloquea la operación real en Medusa + (crear/editar un producto). Mismo criterio de "degradar, no romper" + que se repite en el resto del storefront (`searchWithFacets` + opcional, `/api/search` cayendo a `{}` si Meilisearch no responde). +- **Límite real, todavía no un problema**: no hay debounce ni + deduplicación — si una operación dispara varios eventos juntos (ej. + editar un producto con 3 variantes a la vez), son varios `POST` + independientes al mismo endpoint. `revalidateTag` de Next.js es + barato e idempotente, así que hoy no se nota, pero si el catálogo + creciera mucho y las ediciones masivas se volvieran frecuentes, sería + el primer lugar donde agregar algún tipo de agrupación. + +## Qué haría distinto si volviera a montar este lab desde cero + +- **Fijar `TUNNEL_TRANSPORT_PROTOCOL=http2` desde el día uno** (ver + [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)) y + **ajustar el MTU de `flannel.1` desde el primer `deploy-lab.sh`** — + ambos terminaron siendo incidentes reales + (504 intermitente, blackhole cross-node) que en retrospectiva eran + predecibles: red doméstica con NAT agresivo + Docker-in-Docker + multi-nodo son una combinación conocida para tener problemas de MTU + y de transporte UDP. Hoy los dos ya están automatizados + (`fix_flannel_mtu` en `deploy-lab.sh`, la variable de entorno del + túnel), pero se aprendieron reactivamente, no se anticiparon. +- **Usar `git merge-base --is-ancestor` para la promoción segura del + CI desde el primer pipeline**, no después de ver la condición de + carrera en vivo (dos pipelines corriendo en serie sobre el mismo + commit, el segundo pisando o saltándose la promoción del primero). + El patrón correcto no es complicado una vez que se conoce — el costo + fue no anticiparlo. +- **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). +- **No escribir un script que regenere credenciales completas en cada + corrida** (`create-commerce-secrets.sh` rota JWT/COOKIE/passwords de + Postgres/Redis/MinIO enteros cada vez que se ejecuta, incluso si solo + hacía falta agregar una clave nueva) — un script de setup inicial y + uno de "agregar una clave a un secret ya vivo" deberían haber sido + dos herramientas distintas desde el principio, no una sola que hay + que saber cuándo NO correr completa. \ No newline at end of file diff --git a/workloads/docs-portal/docs/arquitectura/gitops-flujo.md b/workloads/docs-portal/docs/arquitectura/gitops-flujo.md index db24916..65491fc 100644 --- a/workloads/docs-portal/docs/arquitectura/gitops-flujo.md +++ b/workloads/docs-portal/docs/arquitectura/gitops-flujo.md @@ -1,9 +1,9 @@ # 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). +El diagrama de abajo refleja el pipeline real (build → push imagen → +promoción segura → commit del tag → sync de Argo CD). Después del +diagrama hay un ejemplo con hashes de commit reales, tomado de un +cambio real hecho sobre este mismo repo. ## Secuencia: un commit hasta quedar corriendo en el cluster @@ -42,9 +42,53 @@ sequenceDiagram 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: un commit de principio a fin -- 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). +Fase 3b del ADR 0006 (facetas del sidebar del catálogo, ver +`decisiones/0006-motor-busqueda-facetas.md`) recorrió el flujo +completo dos veces seguidas el mismo día: + +| Paso | Commit / evento real | +|---|---| +| Feature | `83d448b` — `feat(ecommerce): facetas reales de Meilisearch en el sidebar del catalogo`, en `fix/adr0006-etapa2-fase3b-facetas-sidebar` | +| Merge a `main` | `6b5450f` | +| CI build+push | Gitea Actions task 258, imagen `gitea.cruzcloud.net/devops/ecommerce-frontend` | +| Release commit (CD) | `951682a` — `chore: release v1.0.171 [skip ci]`, bump automático del tag en `frontend/patches/deployment.yaml` | +| Sync de Argo CD | dos ciclos distintos: primero sincronizó contra `6b5450f` (el merge, todavía con el tag viejo), y en el ciclo siguiente detectó `951682a` y aplicó el tag nuevo | +| Rollout | `frontend-deploy` con 2/2 réplicas en `v1.0.171` | + +El gate en vivo contra ese mismo cambio encontró un bug real +(`estimatedTotalHits` en 0 con `limit=0` en Meilisearch) — el fix +repitió el mismo ciclo completo: `884dbd1` → merge `ba3e6c7` → release +`v1.0.172` → sync → rollout. Dos vueltas completas del diagrama de +arriba en la misma sesión, cada una verificada contra el endpoint real +antes de darla por cerrada. + +**Detalle no obvio:** Argo CD sincroniza el *merge commit* primero +(todavía con la imagen vieja, porque el bump de tag es un commit +*posterior* que hace el propio pipeline) y recién en un segundo ciclo +de poll detecta el commit de release. Son dos sincronizaciones +distintas, no una sola — hay que esperar ambas antes de asumir que el +pod nuevo está corriendo. + +## `selfHeal`, con la configuración real + +Todas las Applications de este repo comparten el mismo `syncPolicy` +(`apps/ecommerce-app.yaml`): + +```yaml +syncPolicy: + automated: + prune: true + selfHeal: true +``` + +Con `selfHeal: true`, si alguien corre `kubectl scale deployment +frontend-deploy --replicas=1` directo contra el cluster (saltándose +Git por completo), Argo CD no se queda en mostrar `OutOfSync` — en el +siguiente ciclo de reconciliación vuelve a aplicar el manifiesto tal +como está en `main` y el Deployment queda otra vez en las réplicas +declaradas ahí. El cambio manual no se pierde silenciosamente: queda +un instante como drift visible en la UI de Argo CD, pero nunca se +vuelve el estado final. La única forma de que ese cambio persista es +que exista en Git — lo mismo que describe [ADR 0002](../decisiones/0002-por-que-argocd-vs-manual.md). diff --git a/workloads/docs-portal/docs/arquitectura/glosario.md b/workloads/docs-portal/docs/arquitectura/glosario.md index e24f51a..9ee0353 100644 --- a/workloads/docs-portal/docs/arquitectura/glosario.md +++ b/workloads/docs-portal/docs/arquitectura/glosario.md @@ -65,9 +65,142 @@ plantillas (a diferencia de Helm). incluir y qué parches aplicarles, sin necesidad de un lenguaje de templating separado. -## TODO +## Namespace -- Namespace, ResourceQuota, LimitRange, NetworkPolicy -- Ingress vs. Service vs. Deployment -- StatefulSet (por qué Postgres lo usa y el frontend no) -- Init container +**En una frase:** una partición lógica dentro del mismo cluster físico +— agrupa recursos relacionados y les pone un límite alrededor. + +**Detalle:** este lab tiene un namespace por dominio de negocio +(`ecommerce`, `docs-portal`) más `argocd` para el propio Argo CD. Todo +lo que gobierna a un namespace (cuotas, políticas de red) vive en +`platform-infra`, separado de los manifiestos de la app — así una app +nueva no puede "olvidarse" de traer su propia gobernanza. + +## ResourceQuota + +**En una frase:** el techo total de recursos que un namespace entero +puede consumir, sumando todos sus pods. + +**Detalle:** la cuota real de `ecommerce` +(`platform-infra/ecommerce-governance/resourcequota.yaml`): + +```yaml +hard: + requests.cpu: "3" + requests.memory: 4Gi + limits.cpu: "8" + limits.memory: 8Gi + pods: "12" + persistentvolumeclaims: "6" + requests.storage: 80Gi +``` + +Si un Deployment nuevo pidiera más `requests.memory` del que queda +disponible bajo ese techo, Kubernetes rechaza el pod directamente — no +lo deja arrancar y luego fallar. + +## LimitRange + +**En una frase:** los valores por defecto (y los mínimos/máximos) que +aplican a un contenedor *individual* dentro de un namespace, para +cuando el manifiesto no los declara explícitos. + +**Detalle:** complementa a `ResourceQuota` (que mira el namespace +entero) a nivel de un solo contenedor. La de `ecommerce` +(`limitrange.yaml`) pone `defaultRequest: 100m/128Mi`, +`default: 500m/512Mi` (límites si el contenedor no los declara), y un +rango permitido de `10m/16Mi` a `2 CPU/1Gi` por contenedor — evita +tanto el contenedor que no pide nada y termina compitiendo mal por +recursos, como el que pide de más y se come la cuota del namespace +entero él solo. + +## NetworkPolicy + +**En una frase:** un firewall a nivel de Kubernetes — qué tráfico de +red se permite entrar (o salir) de un pod. + +**Detalle:** sin ninguna `NetworkPolicy`, todos los pods de un cluster +pueden hablarse entre sí libremente. La de `ecommerce` +(`networkpolicy.yaml`) usa `podSelector: {}` (aplica a todos los pods +del namespace) y permite ingress solo desde `namespaceSelector: {}` +(cualquier namespace del cluster, típicamente Traefik llegando desde +`kube-system` o el namespace de ingress) — bloquea tráfico externo al +cluster que intentara saltarse Traefik y pegarle directo a un Service +interno. + +## Deployment + +**En una frase:** el controlador para cargas de trabajo **sin +estado** — réplicas intercambiables entre sí, ninguna con identidad +propia. + +**Detalle:** `frontend-deploy` es un Deployment de 2 réplicas: no +importa cuál de las dos atienda una petición, ambas sirven exactamente +lo mismo y ninguna guarda datos propios en disco. Si un pod muere, +Kubernetes crea otro cualquiera para reemplazarlo — no hay "el pod +original" que recuperar. Contrastar con **StatefulSet** abajo. + +## Service + +**En una frase:** un nombre de red estable que apunta a un grupo de +pods, aunque esos pods cambien. + +**Detalle:** los pods son efímeros (mueren y se recrean con IP nueva +cada vez); un Service (ej. `frontend-svc`, `postgres-svc`) les da un +nombre DNS interno fijo (`frontend-svc.ecommerce.svc.cluster.local`, +o simplemente `frontend-svc` desde dentro del mismo namespace) que no +cambia aunque los pods de atrás sí. `postgres-svc` en este lab es +además un Service `clusterIP: None` ("headless") — necesario porque +`commerce-postgres` es un StatefulSet y cada pod necesita su propia +identidad de red estable, no una IP compartida balanceada entre +réplicas. + +## Ingress + +**En una frase:** la regla que dice "las peticiones a este dominio y +esta ruta van a este Service", para tráfico HTTP que entra al cluster. + +**Detalle:** `ecommerce-ingress` enruta por *path*, no solo por +dominio: `shop.cruzcloud.net/api/catalog` va a `catalog-api-svc`, y +todo lo demás (`/`) va a `frontend-svc` — un solo objeto Ingress con +dos backends distintos según el prefijo de la URL. Es Traefik (el +Ingress Controller del cluster) quien lee estos objetos y hace el +enrutamiento real; el Ingress en sí es solo la declaración de la +regla, no el componente que la ejecuta. + +## StatefulSet + +**En una frase:** el controlador para cargas de trabajo **con +estado** — cada réplica tiene identidad propia y almacenamiento propio +que sobrevive a que el pod se reinicie. + +**Detalle:** `commerce-postgres` es un StatefulSet, no un Deployment, +porque el frontend puede tener 2 réplicas idénticas sin datos propios, +pero Postgres no — sus datos tienen que vivir en un disco específico +que sobreviva un reinicio del pod y sepa exactamente cuál pod es +"el suyo". Cada pod de un StatefulSet obtiene su propio +`PersistentVolumeClaim` vía `volumeClaimTemplates` (acá, +`postgres-data`, 10Gi en `local-path`) y un nombre de red estable y +numerado (`commerce-postgres-0`), a diferencia de los nombres +aleatorios de un Deployment. Meilisearch, Redis y MinIO en este lab +siguen el mismo patrón por la misma razón — todos guardan datos en +disco que tienen que sobrevivir al pod. + +## Init container + +**En una frase:** un contenedor que corre **antes** que los +contenedores principales de un pod, y tiene que terminar bien (exit +0) para que el resto arranque. + +**Detalle:** `medusa-deploy` usa dos, y a propósito están separados en +vez de ser uno solo: `wait-for-postgres` (`pg_isready` en loop, solo +espera a que Postgres acepte conexiones) y `migrations` +(`db:migrate`, corre las migraciones de verdad). Un incidente real +mostró por qué separarlos importa: cuando el chequeo de disponibilidad +reusaba la misma `DATABASE_URL` completa de la app (con +`?sslmode=disable` incluido), el parser de `pg_isready` no la entendía +y el pod quedaba atascado en `Init` — reportado como si fuera +`CrashLoopBackOff`, sin serlo. El fix fue que el chequeo de +disponibilidad use el mínimo de parámetros posible +(host/puerto/usuario/db), nunca la URI completa pensada para la app. +Ver el [playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md). diff --git a/workloads/docs-portal/docs/arquitectura/red-y-exposicion.md b/workloads/docs-portal/docs/arquitectura/red-y-exposicion.md index 686e970..3b12e42 100644 --- a/workloads/docs-portal/docs/arquitectura/red-y-exposicion.md +++ b/workloads/docs-portal/docs/arquitectura/red-y-exposicion.md @@ -1,8 +1,7 @@ # 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. +Los 5 dominios de abajo son reales y comparten exactamente el mismo +camino de red — ninguno tiene un caso especial. ## Dominios activos @@ -14,12 +13,63 @@ | `gitea.cruzcloud.net` | Gitea | — | Cloudflare (edge) | | `docs.cruzcloud.net` | Este portal | `docs` | Cloudflare (edge) | -## TODO +## Por qué todos van "proxied" (naranja), ninguno "DNS-only" -- 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)). +Un registro DNS-only en Cloudflare expondría directamente la IP real +del túnel — pero acá no hay IP pública fija que exponer: el NAS está +detrás de NAT residencial. El modo proxied es lo que hace que +Cloudflare Tunnel tenga sentido en primer lugar (el tráfico entra por +el edge de Cloudflare, nunca directo al origen), así que no hay ningún +dominio de este lab para el que DNS-only sea una opción real. + +## Dónde termina TLS de verdad + +```mermaid +flowchart LR + User(["Usuario"]) -->|HTTPS| CF["Cloudflare edge
🔒 TLS termina acá"] + CF -->|túnel cifrado, HTTP/2| CFD["cloudflared"] + CFD -->|HTTP plano| NPM["Nginx Proxy Manager"] + NPM -->|HTTP plano| LB["k3d-proxy :90"] + LB -->|HTTP plano| TR["Traefik"] + TR -->|HTTP plano| SVC["Service interno"] +``` + +TLS termina en el **edge de Cloudflare**, no en Traefik ni en NPM. De +ahí hacia adentro (`cloudflared` → NPM → `k3d-proxy` → Traefik → +Service) todo el tráfico va en HTTP plano — seguro en este contexto +porque ese tramo entero vive dentro de la red local del NAS, nunca +sale a internet. Ningún componente interno necesita certificado propio +ni gestionar renovación de TLS. + +## Por qué HTTP/2 y no QUIC en el túnel + +`cloudflared` puede usar QUIC (UDP) o HTTP/2 (TCP) para sus conexiones +salientes hacia el edge. Este lab fuerza HTTP/2 explícitamente — no es +el default. Un incidente real de `504 Gateway Timeout` intermitente en +**todos** los subdominios (no solo uno) se rastreó hasta fallos +recurrentes del "control stream" de QUIC sobre la red doméstica del +NAS. Ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md) +para la decisión completa y el [playbook del 504](../playbooks/incidente-504-gitea-tunnel.md) +para el diagnóstico paso a paso. + +## Patrón de diagnóstico: "interno sano, público roto" + +Cuando un dominio público falla pero todo dentro del cluster responde +bien (pods `Running`, probes verdes, `kubectl exec` con `curl` al +Service funciona), la causa casi nunca está en Kubernetes — está en la +capa NPM. La forma rápida de confirmarlo es saltarse NPM a propósito: +`curl -H "Host: " http://192.168.68.61:90/` habla directo con +`k3d-proxy`/Traefik sin pasar por NPM. Si eso funciona pero el dominio +público no, el problema está específicamente en el `forward_host`/ +`forward_port` del proxy host en NPM (ej.: un scheme o un path colado +en `forward_host`, que rompe todo el enrutamiento). + +Este patrón se repitió dos veces en este lab con la misma causa raíz +exacta: el [incidente de imágenes en `media.cruzcloud.net`](../playbooks/incidente-imagenes-npm.md) +(playbook completo) y un incidente posterior de `commerce.cruzcloud.net` +devolviendo 400 por el mismo tipo de `forward_host` roto (sin playbook +propio todavía). Una lección adicional de ese segundo incidente: +editar el `proxy_host` a mano en el sqlite de NPM **no** regenera el +`.conf` de nginx automáticamente — el cambio tiene que pasar por la +propia UI/API de NPM, o el `.conf` hay que parchearlo a mano y +recargar nginx. diff --git a/workloads/docs-portal/docs/arquitectura/vision-general.md b/workloads/docs-portal/docs/arquitectura/vision-general.md index 3160061..d564346 100644 --- a/workloads/docs-portal/docs/arquitectura/vision-general.md +++ b/workloads/docs-portal/docs/arquitectura/vision-general.md @@ -1,10 +1,10 @@ # 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. +Este lab corre sobre un NAS ZimaOS doméstico, sin IP pública ni +presupuesto de nube — cada capa del diagrama de abajo existe por una +razón concreta de ese contexto, no por moda arquitectónica. Esta +página explica el "por qué" de cada frontera; el "cómo" vive en las +páginas enlazadas al final de cada sección. ## Flujo de una petición pública @@ -40,4 +40,52 @@ flowchart LR | 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 | +## Por qué cada frontera está donde está + +**Cloudflare Tunnel, no port-forwarding.** El NAS vive detrás de NAT +residencial, sin IP pública fija. En vez de abrir puertos en el router +(superficie de ataque directa contra el hogar), `cloudflared` abre una +conexión **saliente** hacia Cloudflare y todo el tráfico público viaja +hacia adentro por esa misma conexión — el origen nunca expone un +puerto escuchando en internet. El transporte de esa conexión se fijó a +HTTP/2 en vez del QUIC por defecto tras un incidente real de +inestabilidad; ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md). + +**Nginx Proxy Manager, fuera del cluster.** No todo lo que sirve +`*.cruzcloud.net` vive dentro de k3d — Gitea, por ejemplo, corre como +contenedor Docker aparte. NPM es el único punto de enrutamiento por +dominio que necesita conocer *ambos* mundos (cluster y no-cluster), así +que vive fuera de Kubernetes a propósito, no por descuido. + +**`k3d-lab-cluster-serverlb`, la frontera de entrada al cluster.** Es +el load balancer que el propio k3d monta automáticamente para publicar +puertos del host hacia los nodos internos — no es un componente que +este lab configuró a mano, viene con la topología multi-nodo elegida +en [ADR 0001](../decisiones/0001-por-que-k3d.md). + +**Traefik, enrutamiento por `Host` dentro del cluster.** Una vez el +tráfico entra al cluster por un único puerto (90/9443), Traefik es +quien decide a qué Service mandarlo según el header `Host` de cada +petición — así NPM solo necesita saber "todo `*.cruzcloud.net` va al +puerto 90 del cluster", sin tener que aprender un forward_host nuevo +cada vez que se agrega un servicio interno. Cuando esta capa falla en +silencio (`forward_host`/`forward_port` mal apuntado en NPM), el +síntoma es siempre el mismo: "internamente sano, públicamente roto" — +ver [Red y exposición](red-y-exposicion.md). + +**Argo CD, el único escritor del cluster.** Ningún pipeline de CI ni +ninguna persona corre `kubectl apply` contra producción — Argo CD es +la única pieza con permisos de escritura, y reconstruye +automáticamente cualquier diferencia entre Git y el cluster real +(`selfHeal: true`). El porqué completo de esa elección está en +[ADR 0002](../decisiones/0002-por-que-argocd-vs-manual.md); el paso a +paso de un commit real hasta quedar corriendo, en +[Flujo GitOps](gitops-flujo.md). + +**Gitea, autoalojado.** Además de alojar el código, Gitea corre el CI +(Gitea Actions) y el registry de imágenes en el mismo lugar — un solo +componente autoalojado en vez de depender de la disponibilidad y los +límites de un servicio externo gratuito para un lab que ya de por sí +no tiene presupuesto de nube. + Ver también: [Red y exposición](red-y-exposicion.md) · [Flujo GitOps](gitops-flujo.md) · [Glosario](glosario.md) diff --git a/workloads/docs-portal/docs/decisiones/0001-por-que-k3d.md b/workloads/docs-portal/docs/decisiones/0001-por-que-k3d.md index 96c2ed8..a84d324 100644 --- a/workloads/docs-portal/docs/decisiones/0001-por-que-k3d.md +++ b/workloads/docs-portal/docs/decisiones/0001-por-que-k3d.md @@ -1,33 +1,67 @@ # 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 +**Estado:** aceptada +**Fecha:** 2026-08 (retroactiva — decisión tomada al arrancar el lab, documentada en Fase 2 del portal) ## Contexto -TODO +El lab corre sobre un NAS ZimaOS doméstico, sin presupuesto para un +cluster gestionado y con recursos modestos y compartidos con otras +apps del NAS (Gitea, Nginx Proxy Manager, este mismo portal). El +propósito del lab es doble: servir de portafolio técnico real y de +material de estudio para el hijo del usuario (7mo semestre de +Ingeniería de Sistemas) — ver [guía del estudiante](../guia-estudiante/README.md). +Para que ambos objetivos tengan sentido, el cluster necesitaba +comportarse como un Kubernetes real y multi-nodo, no como una caja de +arena de un solo proceso: patrones como afinidad de pods, StatefulSets +con almacenamiento persistente por nodo, y tráfico cross-node debían +poder reproducirse y depurarse de verdad, no simularse. + +También pesó la restricción del propio sistema operativo: `/` en +ZimaOS es un `squashfs` de solo lectura y `/usr` es un overlay +igualmente inmutable — instalar un binario nativo como `k3s` que +espera escribir en `/usr/local/bin`, `/etc/rancher`, etc. de forma +persistente no encaja bien con ese filesystem sin trabajo extra de +por medio. ## Opciones consideradas | Opción | A favor | En contra | |---|---|---| -| k3d | | | -| k3s directo (sin Docker) | | | -| minikube | | | -| Cluster gestionado (cloud) | | | +| **k3d** | Multi-nodo real corriendo como contenedores Docker (server + agent), se crea/destruye en segundos, no toca el filesystem inmutable de ZimaOS más allá de `/DATA` | Al ser Docker-in-Docker, hereda las capas de red de Docker — un blackhole de MTU/PMTUD real apareció por esto (ver Consecuencias) | +| k3s directo (sin Docker) | Sin la capa extra de red de Docker-in-Docker, más cercano a un nodo "real" | Necesita instalar servicios systemd y escribir en rutas de sistema (`/usr/local/bin`, `/etc/rancher`) que en ZimaOS son de solo lectura o requieren workarounds — solo `/DATA` y `/etc` quedan escribibles | +| minikube | Instalación simple, muy documentado, ideal para aprender lo básico | Single-node por defecto — no permite reproducir tráfico cross-node, afinidad de pods, ni el tipo de incidentes de infraestructura real que este lab busca documentar como aprendizaje | +| Cluster gestionado (cloud) | Cero mantenimiento de infraestructura, SLA real | Costo recurrente que este lab no tiene presupuestado; además reduce el valor de aprendizaje — gran parte de lo documentado en `playbooks/` (MTU, DNS, límites de recursos) es justamente lo que un proveedor gestionado abstrae | ## Decisión -TODO +k3d, multi-nodo (hoy: 1 server + 1 agent, `k3d-lab-cluster-server-0` / +`k3d-lab-cluster-agent-0`), corriendo como contenedores Docker sobre +el propio NAS. ## 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)). +- **Se ganó** un cluster real y gratuito, reproducible con un solo + script (`scripts/deploy-lab.sh`), y suficientemente parecido a un + Kubernetes de producción como para que los incidentes documentados + en `playbooks/` sean transferibles a un entorno cloud real. +- **Se pagó** con un blackhole de red cross-node real: cualquier pod + agendado en un nodo distinto al de `commerce-postgres-0` + (StatefulSet, fijo a un nodo) fallaba de forma consistente al hablar + con Postgres — diagnosticado hasta el bit exacto (payload interno + 1420 bytes OK, 1450 bytes blackhole total, sin ICMP "frag needed") + y resuelto ajustando el MTU real de la interfaz `flannel.1` a 1400 + en cada nodo. Ver [known-issues.md](https://gitea.cruzcloud.net/devops/apps-registry/src/branch/main/docs/known-issues.md) + y el [playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md) + para el diagnóstico completo. El fix no persiste solo — está + automatizado en `scripts/deploy-lab.sh` (función `fix_flannel_mtu`), + se reaplica cada vez que el cluster arranca o se recrea. +- **Deuda aceptada a propósito**: el `podAffinity` que fuerza a + `medusa-deploy` a compartir nodo con Postgres se dejó como red de + contención extra después del fix de fondo — no es estrictamente + necesario, pero es barato mantenerlo. +- **Para revertir esta decisión** haría falta migrar a un cluster + gestionado o a `k3s` nativo, lo que implicaría asumir el costo + recurrente (cloud) o resolver las restricciones de filesystem + inmutable de ZimaOS (k3s nativo) — ninguna de las dos se justifica + hoy con el tamaño real del lab. diff --git a/workloads/docs-portal/docs/decisiones/0002-por-que-argocd-vs-manual.md b/workloads/docs-portal/docs/decisiones/0002-por-que-argocd-vs-manual.md index 28bac03..653c29a 100644 --- a/workloads/docs-portal/docs/decisiones/0002-por-que-argocd-vs-manual.md +++ b/workloads/docs-portal/docs/decisiones/0002-por-que-argocd-vs-manual.md @@ -1,30 +1,80 @@ # 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 +**Estado:** aceptada +**Fecha:** 2026-08 (retroactiva — decisión tomada al arrancar el lab, documentada en Fase 2 del portal) ## Contexto -TODO +Desde el principio el lab iba a tener más de un repo Git (hoy: +`apps-registry` para las Applications y los manifiestos reales de los +workloads, `platform-infra` para la gobernanza de cada namespace, +`scripts` para bootstrap/operación) y más de una app corriendo en el +cluster (ecommerce, docs-portal, nginx, cada una con su propia +gobernanza). Se necesitaba una forma de mantener el estado del +cluster sincronizado con lo declarado en Git que escalara a "agregar +una app nueva" sin volverse un proceso manual que dependiera de que +una sola persona recordara qué `kubectl apply` correr y en qué orden. + +También importaba la trazabilidad: en un lab que sirve de portafolio, +poder señalar "este commit es exactamente lo que está corriendo en +producción" (y por qué) tiene valor por sí mismo — no solo operativo. ## 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) | | | +| **Argo CD (GitOps, pull-based)** | Reconciliación continua: un proceso dentro del cluster compara el estado real contra Git y corrige la diferencia solo, sin que nadie tenga que acordarse de aplicar nada. Patrón App of Apps nativo — agregar una app nueva es un archivo YAML más en `apps/`, no un paso manual nuevo | Componente extra que mantener sano (su propio namespace, su propio consumo de recursos) | +| `kubectl apply` manual | Cero infraestructura extra, control total en cada comando | Sin detección de drift — si alguien edita algo a mano y lo olvida, nadie se entera hasta que rompe; no escala más allá de un solo operador que recuerde el estado completo de memoria | +| `kubectl apply` desde el pipeline de CI (push-based) | No agrega un componente nuevo al cluster, reutiliza el CI que ya existe para construir imágenes | El pipeline de CI necesitaría credenciales de escritura amplias sobre el cluster — más superficie de riesgo si esas credenciales se filtran. Push-based además significa que nada vuelve a comparar el estado después de aplicado: un drift posterior (alguien con `kubectl` directo) queda invisible hasta el siguiente deploy | ## Decisión -TODO +Argo CD, patrón App of Apps: una Application raíz +(`apps-registry/application.yaml`) vigila la carpeta `apps/`, y cada +archivo ahí dentro es una Application hija que apunta a un workload +real. Toda Application se declara con el mismo `syncPolicy`: + +```yaml +syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true +``` + +(configuración real de `apps/ecommerce-app.yaml`, repetida en cada +Application del repo) ## 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". +- **`selfHeal: true` cambia el modelo mental de quién puede tocar el + cluster de verdad.** No es solo que Argo CD detecte diferencias y + las muestre como `OutOfSync` — las revierte activamente. Si alguien + corre `kubectl scale` o `kubectl edit` directo contra un recurso + gestionado, en el siguiente ciclo de reconciliación (poll periódico, + o inmediato si el commit llegó por webhook) Argo CD lo vuelve a + dejar exactamente como dice Git. Git deja de ser "la intención" y + pasa a ser literalmente la única fuente de la que el cluster puede + aprender su propio estado — ver [flujo GitOps](../arquitectura/gitops-flujo.md). +- **`prune: true`** significa que borrar un recurso del repo (por + ejemplo, quitar `meilisearch.yaml` de una `kustomization.yaml`) lo + borra también del cluster en el siguiente sync, sin un paso manual + de limpieza — confirmado en la práctica en varias fases de este lab + (ver los ADRs de rediseño del storefront y de Meilisearch). +- **El rol de CI se redujo a "construir, publicar imagen, y dejar un + commit en Git"** — nunca aplica nada directo al cluster. Esto evitó + tener que darle a CI credenciales de cluster, y dejó un problema real + documentado (la condición de carrera de promoción cuando dos + pipelines corren en serie sobre el mismo commit) resoluble + comparando contra Git (`git merge-base --is-ancestor`) en vez de + contra el estado del cluster. +- **Se paga con un componente más que mantener sano**: Argo CD corre + en su propio namespace, con su propio consumo de recursos y su + propia superficie de fallo — un Argo CD caído no rompe lo que ya + está corriendo (el cluster sigue con el último estado aplicado), + pero sí detiene la sincronización de cambios nuevos. +- **Para revertir esta decisión** haría falta volver a un modelo + push-based (CI aplicando directo) o puramente manual, perdiendo la + reconciliación continua y aceptando que un drift manual pueda pasar + desapercibido.