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.
This commit is contained in:
1 parent
b7443a034f
commit
056a7fb76a
7 files changed
+490
-80
No files matched your search
@@ -1,20 +1,9 @@
|
|||||||
# Notas sueltas / aprendizajes
|
# Notas sueltas / aprendizajes
|
||||||
|
|
||||||
> **TODO (fase 2+):** esto es un scratchpad estructurado, no un
|
> Esto es un scratchpad estructurado, no un playbook — sigue creciendo
|
||||||
> playbook — va creciendo con notas cortas a medida que aparecen. Las
|
> con notas cortas a medida que aparecen. Cada entrada está extraída de
|
||||||
> entradas de abajo son semillas reales extraídas de incidentes ya
|
> un incidente o hallazgo real ya documentado en otra parte del sitio,
|
||||||
> documentados; expandir cada una con más contexto en fases siguientes.
|
> no es especulación.
|
||||||
|
|
||||||
## 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
|
## Gotchas de Medusa v2
|
||||||
|
|
||||||
@@ -26,8 +15,70 @@
|
|||||||
del array `images` — hay que setearlo explícito desde el Admin si el
|
del array `images` — hay que setearlo explícito desde el Admin si el
|
||||||
frontend lo usa como imagen hero en listados.
|
frontend lo usa como imagen hero en listados.
|
||||||
|
|
||||||
## TODO
|
## MinIO / S3 provider
|
||||||
|
|
||||||
- Notas sobre MinIO / S3 provider.
|
- Medusa sube imágenes de producto a MinIO (`minio-svc:9000`, bucket
|
||||||
- Notas sobre el subscriber `revalidate-storefront.ts` y sus límites.
|
`ari-products`) usando el provider S3-compatible estándar — MinIO no
|
||||||
- Qué automatizaría si volviera a montar este lab desde cero.
|
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.
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Del commit al despliegue: flujo GitOps
|
# Del commit al despliegue: flujo GitOps
|
||||||
|
|
||||||
> **TODO (fase 2+):** narrar cada paso con ejemplos reales tomados de
|
El diagrama de abajo refleja el pipeline real (build → push imagen →
|
||||||
> `.gitea/workflows/build-medusa.yaml` y `build.yaml`. El diagrama de
|
promoción segura → commit del tag → sync de Argo CD). Después del
|
||||||
> secuencia de abajo refleja el pipeline real (build → push imagen →
|
diagrama hay un ejemplo con hashes de commit reales, tomado de un
|
||||||
> promoción segura → commit del tag → sync de Argo CD).
|
cambio real hecho sobre este mismo repo.
|
||||||
|
|
||||||
## Secuencia: un commit hasta quedar corriendo en el cluster
|
## 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
|
evita que un pipeline viejo sobreescriba el tag con una versión
|
||||||
anterior si hubo un push más reciente mientras corría.
|
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
|
Fase 3b del ADR 0006 (facetas del sidebar del catálogo, ver
|
||||||
`apps-registry`).
|
`decisiones/0006-motor-busqueda-facetas.md`) recorrió el flujo
|
||||||
- Explicar `syncPolicy.automated.selfHeal` con un caso concreto (qué
|
completo dos veces seguidas el mismo día:
|
||||||
pasa si alguien hace `kubectl edit` a mano).
|
|
||||||
|
| 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).
|
||||||
@@ -65,9 +65,142 @@ plantillas (a diferencia de Helm).
|
|||||||
incluir y qué parches aplicarles, sin necesidad de un lenguaje de
|
incluir y qué parches aplicarles, sin necesidad de un lenguaje de
|
||||||
templating separado.
|
templating separado.
|
||||||
|
|
||||||
## TODO
|
## Namespace
|
||||||
|
|
||||||
- Namespace, ResourceQuota, LimitRange, NetworkPolicy
|
**En una frase:** una partición lógica dentro del mismo cluster físico
|
||||||
- Ingress vs. Service vs. Deployment
|
— agrupa recursos relacionados y les pone un límite alrededor.
|
||||||
- StatefulSet (por qué Postgres lo usa y el frontend no)
|
|
||||||
- Init container
|
**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).
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
# Red y exposición
|
# Red y exposición
|
||||||
|
|
||||||
> **TODO (fase 2+):** explicar cada dominio, por qué está proxied vs
|
Los 5 dominios de abajo son reales y comparten exactamente el mismo
|
||||||
> DNS-only en Cloudflare, y el manejo de TLS extremo a extremo. Tabla
|
camino de red — ninguno tiene un caso especial.
|
||||||
> de dominios abajo es real, pendiente de expandir con capturas/ejemplos.
|
|
||||||
|
|
||||||
## Dominios activos
|
## Dominios activos
|
||||||
|
|
||||||
@@ -14,12 +13,63 @@
|
|||||||
| `gitea.cruzcloud.net` | Gitea | — | Cloudflare (edge) |
|
| `gitea.cruzcloud.net` | Gitea | — | Cloudflare (edge) |
|
||||||
| `docs.cruzcloud.net` | Este portal | `docs` | 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
|
Un registro DNS-only en Cloudflare expondría directamente la IP real
|
||||||
Cloudflare, no en Traefik — el tráfico interno NPM→k3d es HTTP plano).
|
del túnel — pero acá no hay IP pública fija que exponer: el NAS está
|
||||||
- Explicar por qué el transporte del túnel se fijó a HTTP/2 en vez de
|
detrás de NAT residencial. El modo proxied es lo que hace que
|
||||||
QUIC (ver [ADR 0003](../decisiones/0003-por-que-http2-sobre-quic-tunnel.md)
|
Cloudflare Tunnel tenga sentido en primer lugar (el tráfico entra por
|
||||||
y el [playbook del 504](../playbooks/incidente-504-gitea-tunnel.md)).
|
el edge de Cloudflare, nunca directo al origen), así que no hay ningún
|
||||||
- Patrón de diagnóstico "interno sano, público roto" (ver
|
dominio de este lab para el que DNS-only sea una opción real.
|
||||||
[playbook de imágenes NPM](../playbooks/incidente-imagenes-npm.md)).
|
|
||||||
|
## Dónde termina TLS de verdad
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
User(["Usuario"]) -->|HTTPS| CF["Cloudflare edge<br/>🔒 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: <dominio>" 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.
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# Visión general
|
# Visión general
|
||||||
|
|
||||||
> **TODO (fase 2+):** narrativa completa explicando cada capa del
|
Este lab corre sobre un NAS ZimaOS doméstico, sin IP pública ni
|
||||||
> diagrama, con foco en el "por qué" de cada frontera de confianza. El
|
presupuesto de nube — cada capa del diagrama de abajo existe por una
|
||||||
> diagrama de abajo es real (validado contra `docs/known-issues.md` y
|
razón concreta de ese contexto, no por moda arquitectónica. Esta
|
||||||
> los playbooks de incidentes de `apps-registry`), pero el texto que lo
|
página explica el "por qué" de cada frontera; el "cómo" vive en las
|
||||||
> acompaña es solo el esqueleto.
|
páginas enlazadas al final de cada sección.
|
||||||
|
|
||||||
## Flujo de una petición pública
|
## 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` |
|
| 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 |
|
| 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)
|
Ver también: [Red y exposición](red-y-exposicion.md) · [Flujo GitOps](gitops-flujo.md) · [Glosario](glosario.md)
|
||||||
@@ -1,33 +1,67 @@
|
|||||||
# ADR 0001 — Por qué k3d (y no k3s directo, minikube, o un cluster cloud)
|
# ADR 0001 — Por qué k3d (y no k3s directo, minikube, o un cluster cloud)
|
||||||
|
|
||||||
> **TODO (fase 2+):** completar contexto/opciones/consecuencias con
|
**Estado:** aceptada
|
||||||
> criterio real (recursos del NAS ZimaOS, necesidad de multi-nodo para
|
**Fecha:** 2026-08 (retroactiva — decisión tomada al arrancar el lab, documentada en Fase 2 del portal)
|
||||||
> reproducir el blackhole de MTU documentado en `known-issues.md`,
|
|
||||||
> costo cero vs. cluster gestionado).
|
|
||||||
|
|
||||||
**Estado:** aceptada (placeholder — pendiente de redactar)
|
|
||||||
**Fecha:** TODO
|
|
||||||
|
|
||||||
## Contexto
|
## 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
|
## Opciones consideradas
|
||||||
|
|
||||||
| Opción | A favor | En contra |
|
| Opción | A favor | En contra |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| k3d | | |
|
| **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) | | |
|
| 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 | | |
|
| 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) | | |
|
| 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
|
## 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
|
## Consecuencias
|
||||||
|
|
||||||
TODO — mencionar explícitamente el blackhole de red cross-node
|
- **Se ganó** un cluster real y gratuito, reproducible con un solo
|
||||||
(MTU/PMTUD) como consecuencia real y documentada de correr multi-nodo
|
script (`scripts/deploy-lab.sh`), y suficientemente parecido a un
|
||||||
sobre Docker-in-Docker sin ajuste de MTU (ver
|
Kubernetes de producción como para que los incidentes documentados
|
||||||
[playbook del crashloop de Medusa](../playbooks/incidente-crashloop-medusa.md)).
|
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.
|
||||||
@@ -1,30 +1,80 @@
|
|||||||
# ADR 0002 — Por qué Argo CD (y no `kubectl apply` manual o un script de CI)
|
# 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
|
**Estado:** aceptada
|
||||||
> reconciliación continua (`selfHeal`) sobre despliegue imperativo desde
|
**Fecha:** 2026-08 (retroactiva — decisión tomada al arrancar el lab, documentada en Fase 2 del portal)
|
||||||
> CI.
|
|
||||||
|
|
||||||
**Estado:** aceptada (placeholder — pendiente de redactar)
|
|
||||||
**Fecha:** TODO
|
|
||||||
|
|
||||||
## Contexto
|
## 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
|
## Opciones consideradas
|
||||||
|
|
||||||
| Opción | A favor | En contra |
|
| Opción | A favor | En contra |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Argo CD (GitOps, pull-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 | | |
|
| `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) | | |
|
| `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
|
## 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
|
## Consecuencias
|
||||||
|
|
||||||
TODO — mencionar el patrón App of Apps (`application.yaml` +
|
- **`selfHeal: true` cambia el modelo mental de quién puede tocar el
|
||||||
`apps/*.yaml`) y cómo `selfHeal:true` cambia el modelo mental de
|
cluster de verdad.** No es solo que Argo CD detecte diferencias y
|
||||||
"quién puede tocar el cluster".
|
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.
|
||||||
Reference in new issue
Block a user