Módulo 8: Capstone Andes Cargo On Kubernetes
4. Recorrido end-to-end: un cambio que el gate detiene
Descripción
El Módulo 6 probó Gatekeeper y Kyverno contra Pods de prueba aislados, aplicados con kubectl apply directo. Esta lección sube la apuesta: un intento real de quitarle los límites de recursos al propio Deployment de producción, andes-cargo-status-api, subido por el mismo camino de GitOps que la lección 3 usó para el cambio que sí pasó. El resultado, capturado en vivo contra este mismo clúster, confirma la mitad de la promesa de esta guía —el gate detiene el cambio, antes de que exista ningún Pod nuevo— y revela algo que ningún módulo anterior mostró: los dos motores no cubren exactamente el mismo territorio, y esta lección es el primer lugar donde esa diferencia se vuelve visible con evidencia real.
Conexión con el módulo
Esta lección prueba la rama else del diagrama de secuencia de la lección 2 — el objeto viola una política. La lección 3 probó la rama alt. Las dos comparten exactamente el mismo mecanismo (Git → ArgoCD → admission control); lo único que cambia es el contenido del cambio.
Paso 0 — El escenario: una razón real, un cambio peligroso
Un ingeniero de Andes Cargo, investigando un OOMKilled intermitente en andes-cargo-status-api, decide "aflojar" temporalmente los límites de memoria mientras diagnostica — un atajo real, del tipo que cualquier equipo con presión de incidente ha considerado alguna vez. En vez de ajustar el valor, por apuro, quita el bloque resources completo:
grep -n "resources:" -A6 deployment.yaml
Qué esperar (el estado antes del cambio de esta lección — el mismo bloque que la lección 3 confirmó intacto):
34: resources:
35: requests:
36: cpu: "100m"
37: memory: "64Mi"
38: limits:
39: cpu: "250m"
40: memory: "128Mi"
Paso 1 — El cambio: quita el bloque resources completo
# deployment.yaml (fragmento, ANTES → DESPUÉS de este cambio)
- name: andes-cargo-status-api
image: andes-cargo-status-api:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
envFrom:
- configMapRef:
name: andes-cargo-status-api-config
- secretRef:
name: andes-cargo-status-api-secrets
- resources:
- requests:
- cpu: "100m"
- memory: "64Mi"
- limits:
- cpu: "250m"
- memory: "128Mi"
startupProbe:
httpGet:
path: /health
git diff
Qué esperar (literal, ejecutado):
diff --git a/deployment.yaml b/deployment.yaml
index 0dce5da..4d874d8 100644
--- a/deployment.yaml
+++ b/deployment.yaml
@@ -33,13 +33,6 @@ spec:
name: andes-cargo-status-api-config
- secretRef:
name: andes-cargo-status-api-secrets
- resources:
- requests:
- cpu: "100m"
- memory: "64Mi"
- limits:
- cpu: "250m"
- memory: "128Mi"
startupProbe:
httpGet:
path: /health
Siete líneas removidas, exactamente el bloque que el Módulo 6, lección 4, exige en todo Pod del namespace andes-cargo — esta es, deliberadamente, la misma violación que bad-pod-no-limits.yaml probó ahí, esta vez contra el Deployment real, no un Pod de prueba.
Este git push sigue corriendo desde tu máquina contra localhost:3000, igual que la lección 3 — si el port-forward de Gitea se cerró entre lecciones, ábrelo de nuevo antes de seguir: kubectl port-forward -n gitea svc/gitea-http 3000:3000.
git add deployment.yaml
git commit -m "Temporarily remove resource limits from andes-cargo-status-api to debug OOM"
git push origin main
Qué esperar (literal, ejecutado — el hash es tu valor variable):
[main 229cd7b] Temporarily remove resource limits from andes-cargo-status-api to debug OOM
1 file changed, 7 deletions(-)
To http://localhost:3000/andes-cargo/andes-cargo-k8s.git
d0b98c6..229cd7b main -> main
Paso 2 — ArgoCD lo detecta, y se queda atascado: OutOfSync, no Synced
for i in $(seq 1 24); do
ts=$(date +%H:%M:%S)
rev=$(kubectl get application andes-cargo-status-api -n argocd -o jsonpath='{.status.sync.revision}' | cut -c1-7)
sync=$(kubectl get application andes-cargo-status-api -n argocd -o jsonpath='{.status.sync.status}')
echo "$ts rev=$rev sync=$sync"
sleep 10
done
Qué esperar (literal, ejecutado — a diferencia de la lección 3, donde sync pasaba directo a Synced, aquí se queda en OutOfSync de forma indefinida):
17:23:10 rev=d0b98c6 sync=Synced
17:23:20 rev=d0b98c6 sync=Synced
...
17:24:31 rev=d0b98c6 sync=Synced
17:24:41 rev=229cd7b sync=OutOfSync
17:24:48 rev=229cd7b sync=OutOfSync
17:24:56 rev=229cd7b sync=OutOfSync
...
17:26:43 rev=229cd7b sync=OutOfSync
Esta es la primera señal de que algo distinto pasó frente a la lección 3: ahí, revision avanzaba y sync volvía a Synced en el mismo ciclo. Aquí, revision avanza (ArgoCD sí detectó el commit 229cd7b), pero sync se queda en OutOfSync — selfHeal: true está intentando aplicar el cambio, y algo se lo impide, de forma repetida.
Paso 3 — El mensaje literal: Kyverno bloqueó el Deployment, antes de que se guardara
kubectl logs -n argocd argocd-application-controller-0 --since=5m | grep -A3 "denied the request"
Qué esperar (literal, ejecutado — este es el hallazgo central de esta lección, capturado directo del intento real de ArgoCD):
"message":"Updating operation state. phase: Running -> Failed, message: 'waiting for healthy state of
/Namespace/andes-cargo and 8 more resources' -> 'one or more synchronization tasks completed
unsuccessfully, reason: error when patching \"/dev/shm/793282161\": admission webhook
\"validate.kyverno.svc-fail\" denied the request:
resource Deployment/andes-cargo/andes-cargo-status-api was blocked due to the following policies
andes-cargo-require-resource-limits:
autogen-require-resource-requests-and-limits: 'validation error: every container
must set resources.requests and resources.limits for cpu and memory. rule
autogen-require-resource-requests-and-limits failed at path
/spec/template/spec/containers/0/resources/limits/''"
Lee este mensaje con la misma disciplina que el Módulo 6, lección 4, enseñó: admission webhook "validate.kyverno.svc-fail" denied the request confirma que Kyverno, no Gatekeeper, fue quien detuvo este intento — y Deployment/andes-cargo/andes-cargo-status-api confirma algo nuevo frente a todo lo que viste en el Módulo 6: la política rechazó el objeto Deployment directamente, no un Pod. El nombre de la regla que disparó, autogen-require-resource-requests-and-limits, tiene el prefijo autogen- por una razón concreta que el Paso 5 de esta lección explica a fondo.
ArgoCD lo intentó tres veces (syncId: 00023, 00024, 00025 en el registro completo del controlador), y las tres veces recibió el mismo rechazo — no es un error transitorio, es el mismo motivo, repetido, porque el objeto que Git declara sigue violando la política mientras el archivo no cambie.
Paso 4 — Confirma: el Deployment real nunca cambió
kubectl get deployment andes-cargo-status-api -n andes-cargo -o jsonpath='{.spec.template.spec.containers[0].resources}{"\n"}'
kubectl get pods -n andes-cargo -l app=andes-cargo-status-api
Qué esperar (literal, ejecutado — el bloque resources sigue completo, y los Pods siguen siendo los mismos de la lección 3, sin ningún ReplicaSet nuevo):
{"limits":{"cpu":"250m","memory":"128Mi"},"requests":{"cpu":"100m","memory":"64Mi"}}
NAME READY STATUS RESTARTS AGE
andes-cargo-status-api-669755d655-np5mt 1/1 Running 0 6m46s
andes-cargo-status-api-669755d655-qhgwx 1/1 Running 0 6m40s
Este es el punto exacto que la instrucción de esta lección pedía verificar: el objeto rechazado nunca llegó a etcd. El Deployment en vivo sigue declarando el resources completo del commit anterior (d0b98c6), porque el intento de aplicar 229cd7b fue rechazado por el admission webhook de Kyverno antes de que kube-apiserver pudiera guardar el cambio — Git dice una cosa (229cd7b, sin resources), el clúster real dice otra (d0b98c6, con resources), y esa diferencia es exactamente lo que Sync Status: OutOfSync está reportando.
Paso 5 — El hallazgo real: Gatekeeper nunca llegó a evaluar este intento
Antes de repetir la prueba con Gatekeeper, vale la pena confirmar algo que el mensaje del Paso 3 no dijo en ningún momento: ¿participó Gatekeeper en este rechazo, en absoluto?
kubectl logs -n gatekeeper-system deploy/gatekeeper-controller-manager --since=6m | grep "andes-cargo-status-api"
Qué esperar (literal — sin ninguna línea de salida):
Cero líneas. A diferencia del Módulo 6, lección 8 (donde ambos motores evaluaron el mismo Pod, y solo el mensaje de Gatekeeper apareció en kubectl mientras los logs confirmaban que Kyverno también actuó), aquí Gatekeeper nunca fue invocado en absoluto para este intento — no es que perdiera la carrera contra Kyverno, es que su Constraint no aplica a este tipo de objeto. Confírmalo revisando la definición exacta del Constraint (Módulo 6, lección 4):
kubectl get k8srequiredresources andes-cargo-must-have-resource-limits -o jsonpath='{.spec.match.kinds}{"\n"}'
Qué esperar (literal, ejecutado):
[{"apiGroups":[""],"kinds":["Pod"]}]
Ahí está la causa exacta: spec.match.kinds de este Constraint declara, sin ambigüedad, ["Pod"] — nada más. Un Deployment no es un Pod; es un objeto distinto, con su propio apiGroup (apps), que este Constraint nunca declaró en su alcance. Cuando ArgoCD intentó aplicar el Deployment modificado, kube-apiserver invocó el ValidatingWebhookConfiguration de Gatekeeper igual que siempre —pero Gatekeeper, al revisar sus propias reglas, encontró que ninguna aplica a un objeto de tipo Deployment, y respondió allowed: true sin evaluar nada más.
Kyverno, en cambio, sí lo capturó — y la razón está en el nombre de la regla que viste en el Paso 3: autogen-require-resource-requests-and-limits. La ClusterPolicy del Módulo 6, lección 6, declaró spec.background: true y una regla escrita para kind: Pod — pero Kyverno, al ver ese background: true, genera automáticamente reglas equivalentes para los controladores más comunes que producen Pods (Deployment, ReplicaSet, DaemonSet, StatefulSet, Job, CronJob), aplicando el mismo pattern al campo correspondiente dentro de spec.template.spec.containers[].resources de cada uno — sin que nadie en el Módulo 6 haya escrito esa regla adicional a mano. Ese es, literalmente, el mecanismo de autogen de Kyverno, y es la razón exacta por la que Kyverno detuvo este intento en el punto más temprano posible —el propio Deployment— mientras Gatekeeper, con el Constraint tal como quedó configurado en el Módulo 6, habría esperado a que existiera un Pod real para actuar.
POR QUÉ SOLO UN MOTOR DETUVO ESTE INTENTO
ArgoCD intenta aplicar: Deployment/andes-cargo-status-api (sin resources)
│
┌────────────┴────────────┐
▼ ▼
Gatekeeper Constraint Kyverno ClusterPolicy
match.kinds: ["Pod"] background: true (genera reglas
Deployment ≠ Pod autogen- para Deployment/RS/etc.)
│ │
"no me corresponde "sí me corresponde, la regla
evaluar esto" autogen- SÍ cubre Deployment"
│ │
allowed: true allowed: false — DENIED
│ │
└────────────┬────────────┘
▼
kube-apiserver: DENIED (basta con que UNO deniegue)
El Deployment nunca se guarda en etcd
Paso 6 — Reproduce la comparación "lado a lado" que el Módulo 6 sí mostró: un Pod bare, sin el Deployment de por medio
Para confirmar que Gatekeeper sí protege este mismo tipo de violación —solo que en un alcance distinto (Pod, no Deployment)—, repite el experimento del Módulo 6 contra un Pod de prueba, exactamente igual que ahí:
cat > bad-pod-no-limits-m8.yaml << 'EOF'
apiVersion: v1
kind: Pod
metadata:
name: bad-pod-no-limits-m8
namespace: andes-cargo
spec:
containers:
- name: bad-pod-no-limits-m8
image: nginx:1.27-alpine
EOF
kubectl apply -f bad-pod-no-limits-m8.yaml
Qué esperar (literal, ejecutado — el mensaje de Gatekeeper, esta vez sí presente, porque el objeto ahora es exactamente lo que su Constraint declara cubrir):
Error from server (Forbidden): error when creating "bad-pod-no-limits-m8.yaml": admission webhook "validation.gatekeeper.sh" denied the request: [andes-cargo-must-have-resource-limits] container <bad-pod-no-limits-m8> is missing required resource limits: {"cpu", "memory"}
[andes-cargo-must-have-resource-limits] container <bad-pod-no-limits-m8> is missing required resource requests: {"cpu", "memory"}
Y confirma, con los logs de Kyverno, que también evaluó y también bloqueó este mismo Pod —de forma independiente, con su regla original (no la autogen-, porque esta vez el objeto es, literalmente, un Pod):
kubectl -n kyverno logs deploy/kyverno-admission-controller --since=3m | grep "bad-pod-no-limits-m8"
Qué esperar (literal, ejecutado — recortado a los campos que importan):
... validation failed ... failed rules=["require-resource-requests-and-limits"] ... kind=Pod ... name=bad-pod-no-limits-m8 namespace=andes-cargo operation=CREATE ... policy=andes-cargo-require-resource-limits ...
... blocking admission request ... action=validate ... kind=Pod ... name=bad-pod-no-limits-m8 namespace=andes-cargo operation=CREATE ... policy=andes-cargo-require-resource-limits ...
kubectl get pod bad-pod-no-limits-m8 -n andes-cargo
Qué esperar:
Error from server (NotFound): pods "bad-pod-no-limits-m8" not found
Ahí está la comparación completa: contra un Pod bare, los dos motores rechazan (Gatekeeper con el mensaje visible en kubectl, Kyverno confirmado en logs — el mismo patrón exacto del Módulo 6, lección 8). Contra el Deployment real, con el mismo tipo de violación, solo Kyverno rechaza, porque solo su regla tiene alcance de autogen sobre controladores de Pods.
| Objeto evaluado | Gatekeeper (k8srequiredresources) | Kyverno (andes-cargo-require-resource-limits) |
|---|---|---|
Pod directo (bad-pod-no-limits-m8) | Rechaza — match.kinds: ["Pod"] cubre este objeto | Rechaza — regla require-resource-requests-and-limits original |
Deployment real (andes-cargo-status-api, vía ArgoCD) | No evalúa — match.kinds no incluye Deployment | Rechaza — regla generada autogen-require-resource-requests-and-limits |
Paso 7 — Revierte, y confirma la recuperación completa
Como cualquier cambio real que un gate detiene, la corrección es un nuevo commit — nunca un kubectl apply de emergencia:
git revert --no-edit 229cd7b
git push origin main
Qué esperar (literal, ejecutado — el hash del revert es tu valor variable):
[main dcdd4b3] Revert "Temporarily remove resource limits from andes-cargo-status-api to debug OOM"
1 file changed, 7 insertions(+)
To http://localhost:3000/andes-cargo/andes-cargo-k8s.git
229cd7b..dcdd4b3 main -> main
for i in $(seq 1 20); do
ts=$(date +%H:%M:%S)
rev=$(kubectl get application andes-cargo-status-api -n argocd -o jsonpath='{.status.sync.revision}' | cut -c1-7)
sync=$(kubectl get application andes-cargo-status-api -n argocd -o jsonpath='{.status.sync.status}')
echo "$ts rev=$rev sync=$sync"
if [ "$rev" = "dcdd4b3" ] && [ "$sync" = "Synced" ]; then break; fi
sleep 10
done
Qué esperar (literal, ejecutado):
17:28:28 rev=229cd7b sync=OutOfSync
...
17:29:19 rev=229cd7b sync=OutOfSync
17:29:29 rev=dcdd4b3 sync=Synced
kubectl delete pod bad-pod-no-limits-m8 -n andes-cargo --ignore-not-found
kubectl get k8srequiredresources
kubectl get clusterpolicy
curl -i -s --max-time 8 --resolve andes-cargo.local:80:127.0.0.1 http://andes-cargo.local/health
Qué esperar (literal, ejecutado — clúster completamente recuperado):
NAME ENFORCEMENT-ACTION TOTAL-VIOLATIONS
andes-cargo-must-have-resource-limits deny 0
NAME ADMISSION BACKGROUND READY AGE MESSAGE
andes-cargo-require-resource-limits true true True 60m Ready
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 51
{"service":"andes-cargo-status-api","status":"ok"}
Analogía: dos inspectores, dos listas distintas
Retomando la fábrica: esta lección mandó, por la puerta correcta, una orden que quitaba una especificación obligatoria de una pieza. En la primera estación de control de calidad —Kyverno— el inspector tiene una lista que cubre tanto la pieza individual como el plano completo que la produce: rechazó la orden antes de que el plano siquiera se archivara. En la segunda estación —Gatekeeper— el inspector solo tiene instrucciones de revisar piezas terminadas, nunca planos: dejó pasar el plano sin comentario, porque nunca se le pidió revisar planos, solo piezas. El resultado final —la pieza defectuosa nunca se fabricó— fue el mismo, pero por un camino distinto: un inspector la detuvo en el papel, el otro la habría detenido en la línea si el primero no la hubiera parado antes.
Errores comunes
Concluir que "Gatekeeper falló" o "es más débil que Kyverno" (de generalización injusta, el error más importante de esta lección). Qué pasa: alguien, al ver que Gatekeeper no reaccionó al Deployment mientras Kyverno sí, concluye que Gatekeeper es un motor inferior. Cómo detectarlo: si tu resumen de esta lección es "Kyverno protege mejor que Gatekeeper". Cómo corregirlo: la diferencia no es de capacidad — es de configuración. El Paso 6 de esta lección demostró que Gatekeeper protege exactamente igual de bien contra un Pod directo. La Constraint de esta guía se escribió, a propósito, con match.kinds: ["Pod"] (Módulo 6, lección 4) — Gatekeeper sí soporta declarar Deployment (y otros controladores) en match.kinds explícitamente; simplemente esta guía nunca lo agregó, mientras que el background: true de la ClusterPolicy de Kyverno activó su cobertura ampliada de forma automática, sin que nadie lo pidiera campo por campo.
Asumir que "un cambio que el gate detiene" significa que ArgoCD deja de funcionar (de expectativa sobre el resto del sistema). Qué pasa: alguien, al ver Sync Status: OutOfSync durante varios minutos, asume que todo el Application dejó de sincronizar, incluidos los otros nueve recursos. Cómo detectarlo: si no revisaste la tabla de recursos individuales de kubectl get application ... -o yaml (status.resources) durante el Paso 2 o 3. Cómo corregirlo: como confirmó el Paso 2, solo el recurso Deployment quedó OutOfSync — el Namespace, el Service, el ConfigMap, el Secret, el HorizontalPodAutoscaler, el Ingress y las dos NetworkPolicy siguieron Synced sin problema, porque ninguno de ellos cambió en este commit.
Intentar "arreglar" el problema con kubectl apply --force o editando el objeto directamente en el clúster (de urgencia mal dirigida). Qué pasa: alguien, frustrado por ver OutOfSync persistente, intenta forzar el cambio directo contra el clúster, saltándose Git por completo. Cómo detectarlo: si tu primer instinto frente a un rechazo de admission control es buscar una bandera que lo evite, en vez de corregir el manifiesto. Cómo corregirlo: un rechazo de admission control nunca es un problema de permisos que una bandera resuelva — es la política funcionando exactamente como se diseñó. La única corrección legítima, como demostró el Paso 7, es un nuevo commit que corrija el objeto (en este caso, un git revert), nunca un intento de evadir el guardrail desde la línea de comandos.
Ejercicios
Ejercicio 1 — Predice el resultado si agregaras Deployment al match.kinds de Gatekeeper. Sin ejecutarlo, predice: si editaras el Constraint andes-cargo-must-have-resource-limits para que spec.match.kinds incluyera también {"apiGroups": ["apps"], "kinds": ["Deployment"]}, y repitieras el Paso 1-2 de esta lección, ¿qué cambiaría en el resultado del Paso 3?
Ver solución
El mensaje de rechazo capturado por ArgoCD probablemente mostraría ambos motores respondiendo, con el mismo patrón de visibilidad parcial que el Módulo 6, lección 8, ya documentó: kubectl/ArgoCD mostrarían solo el primero en orden alfabético de ValidatingWebhookConfiguration (gatekeeper-validating-webhook-configuration antes que kyverno-resource-validating-webhook-cfg), y los logs de Kyverno confirmarían, por separado, que también evaluó y también bloqueó — igual que en el Paso 6 de esta lección, pero ahora contra el objeto Deployment en vez de un Pod bare. El resultado neto (el Deployment nunca se guarda) no cambiaría; lo que cambiaría es que ahora dos motores, no uno, participarían en el rechazo.
Ejercicio 2 — Explica autogen sin usar la palabra "automático". En una frase, sin usar la palabra "automático" ni "automáticamente", explica qué hace la característica de autogen de Kyverno que esta lección descubrió.
Ver solución
Una explicación razonable: "Cuando una ClusterPolicy de Kyverno tiene background: true y una regla escrita para Pod, Kyverno genera, por su cuenta, reglas equivalentes para los controladores más comunes que producen Pods —Deployment, ReplicaSet, DaemonSet, StatefulSet, Job, CronJob—, aplicando el mismo criterio al campo correspondiente dentro de la plantilla de cada uno, sin que nadie tenga que escribir esas reglas adicionales a mano."
Ejercicio 3 — Diseña una prueba que confirme, sin usar logs, que Gatekeeper de verdad no evaluó el Deployment. El Paso 5 de esta lección usó kubectl logs para confirmar que Gatekeeper nunca fue invocado. Sin usar logs, ¿qué otro comando, ya conocido del Módulo 6, confirmaría lo mismo?
Ver solución
kubectl get k8srequiredresources andes-cargo-must-have-resource-limits — la columna TOTAL-VIOLATIONS refleja lo que gatekeeper-audit encuentra al re-evaluar el estado actual del clúster en segundo plano (Módulo 6, lección 4). Si Gatekeeper considerara al Deployment dentro de su alcance, y el Deployment en algún momento hubiera quedado sin resources (cosa que nunca pasó, porque Kyverno lo bloqueó primero), TOTAL-VIOLATIONS lo reportaría. Como el Constraint nunca incluyó Deployment en su match.kinds, ese número permanece en 0 sin importar qué le pase al Deployment — una confirmación indirecta, pero válida, de que ese tipo de objeto está fuera de su alcance.
Resumen y siguiente paso
Esta lección subió, por el mismo camino de GitOps que la lección 3, un cambio real que viola la política de recursos del Módulo 6 — y confirmó, con evidencia literal, que el gate lo detiene: el Deployment en vivo nunca cambió, ningún Pod nuevo se creó, y Sync Status quedó en OutOfSync hasta que un nuevo commit (un git revert) corrigió el problema. El hallazgo no planeado de esta lección —Kyverno, vía su mecanismo de autogen, capturó el Deployment directamente, mientras que Gatekeeper, con el Constraint tal como el Módulo 6 lo configuró, solo cubre objetos Pod— es una lección real sobre configuración de admission control en producción: dos motores que resuelven el mismo problema pueden, sin que nadie lo planee, terminar cubriendo alcances ligeramente distintos, y la única forma de saberlo con certeza es probar contra el objeto real, no solo contra el caso de prueba más simple.
Antes de avanzar deberías poder: reproducir este recorrido completo en tu propio laboratorio, incluida la reversión; explicar por qué Kyverno capturó el Deployment y Gatekeeper no, sin usar la palabra "mejor" o "peor"; y diseñar una prueba, con o sin logs, que confirme el alcance real de cualquier Constraint/ClusterPolicy antes de confiar en ella en producción.
Siguiente lección: lo que esta guía dejó representativo. Ahí se reúne, en un solo lugar, la honestidad completa sobre EKS —lo único de toda esta guía que nunca corrió contra un clúster real— antes de cerrar el capstone con el mapa del resto del ecosistema.
Recursos
- Kyverno — Auto-Gen Rules for Pod Controllers — documentación oficial del mecanismo
autogen-que esta lección descubrió en producción. - Gatekeeper — Constraints,
match— referencia oficial despec.match.kinds, el campo exacto que explica por qué Gatekeeper no evaluó elDeployment. - Argo CD — Sync Status y Argo CD — Diffing — documentación oficial de
OutOfSyncy de cómo ArgoCD reporta un fallo de aplicación real. kubernetes-and-eks-in-production-guide(NIEVA), Módulo 6, lecciones 4, 6 y 8 — el origen delConstraint/ClusterPolicyy la prueba original de "lado a lado" que esta lección extiende.