Módulo 8: Capstone Andes Cargo On Kubernetes

3. Recorrido end-to-end: un cambio que cruza el gate completo

Descripción

Esta es la prueba central de todo el capstone, ejecutada de verdad contra andes-cargo-cluster para escribir esta lección: un cambio inocuo —una etiqueta nueva en el Deployment de andes-cargo-status-api— sube a Gitea con git push, ArgoCD lo detecta y lo aplica solo, los nuevos Pods que ese cambio produce atraviesan Gatekeeper y Kyverno sin fricción, y quedan corriendo. En ningún momento de esta lección corre un kubectl apply contra deployment.yaml. Cada bloque "Qué esperar" de esta lección es salida literal, capturada en el mismo laboratorio que sostiene el resto de esta guía.

Conexión con el módulo

Esta lección pone a prueba la rama alt del diagrama de secuencia de la lección 2 — el camino donde el objeto cumple las políticas. La lección 4 prueba la rama else, con el mismo mecanismo y un cambio distinto.


Paso 0 — Confirma el punto de partida

Esta lección corre comandos git desde tu máquina, no desde un Pod — así que, como estableció el Módulo 5, lección 3, la URL de Gitea es localhost:3000, nunca el nombre DNS interno del clúster (gitea-http.gitea.svc.cluster.local, que solo resuelve dentro de un Pod). Si cerraste el port-forward de una lección anterior, ábrelo de nuevo antes de seguir:

kubectl port-forward -n gitea svc/gitea-http 3000:3000

En otra terminal:

git clone http://andes-cargo:AndesCargo2026!@localhost:3000/andes-cargo/andes-cargo-k8s.git
cd andes-cargo-k8s
git log --oneline

Qué esperar (literal, ejecutado — los hashes son el estado real del repositorio al momento de escribir esta lección; los tuyos coinciden si seguiste la guía sin saltar ningún módulo):

db09bbe Ignore spec.replicas on the Deployment: the HorizontalPodAutoscaler owns it, not Git
cd0b5ca Scale andes-cargo-status-api from 3 to 5 replicas
820515f Add ArgoCD Application pointing at this same repository
45b14f6 Initial GitOps source: namespace, deployment, service, config, hpa, ingress, networkpolicy (inherited from M1-M4)

Cuatro commits, el mismo estado exacto en que cerró el proyecto del Módulo 5: deployment.yaml declara replicas: 5, pero el clúster real corre con 2 — el HorizontalPodAutoscaler, no Git, es dueño de ese campo específico desde ignoreDifferences (Módulo 5, lección 8). Confírmalo:

kubectl get deployment andes-cargo-status-api -n andes-cargo -o jsonpath='{.status.replicas} ready{"\n"}'
kubectl get application andes-cargo-status-api -n argocd

Qué esperar (literal, ejecutado):

2 ready

NAME                     SYNC STATUS   HEALTH STATUS
andes-cargo-status-api   Synced        Healthy

Synced, Healthy, dos réplicas reales — el punto de partida exacto de esta lección.


Paso 1 — El cambio: una etiqueta nueva en el template del Pod

El cambio que esta lección sube es, deliberadamente, lo más inocuo que un Deployment puede recibir: una etiqueta descriptiva nueva, sin ningún efecto funcional sobre el servicio.

grep -n "labels:" -A2 deployment.yaml
15:  labels:
16:    app: andes-cargo-status-api
...
22:      labels:
23:        app: andes-cargo-status-api

La segunda ocurrencia (línea 22-23) es la que importa para esta lección: son las etiquetas de spec.template.metadata.labels, las que Kubernetes copia a cada Pod nuevo que el Deployment cree — a diferencia de las etiquetas de metadata.labels en la línea 15-16, que pertenecen únicamente al objeto Deployment en sí y nunca llegan a un Pod.

# deployment.yaml (fragmento, spec.template.metadata)
  template:
    metadata:
      labels:
        app: andes-cargo-status-api
        tier: backend

tier: backend es la única línea nueva de todo el archivo — ninguna imagen, ningún puerto, ningún resources, ninguna probe cambia. Confirma el diff antes de subirlo:

git diff

Qué esperar (literal, ejecutado):

diff --git a/deployment.yaml b/deployment.yaml
index 9086693..0dce5da 100644
--- a/deployment.yaml
+++ b/deployment.yaml
@@ -20,6 +20,7 @@ spec:
     metadata:
       labels:
         app: andes-cargo-status-api
+        tier: backend
     spec:
       containers:

Una línea agregada, cero líneas removidas. Y sin embargo —esto vale la pena adelantarlo, porque el Paso 3 lo confirma con evidencia— este cambio dispara un RollingUpdate: cualquier modificación a spec.template, sin importar cuán pequeña, cambia el hash que Kubernetes usa para identificar la versión del Pod, y el Deployment reacciona reemplazando cada Pod existente por uno nuevo con la etiqueta agregada.


Paso 2 — git commit, git push — y nada más

git add deployment.yaml
git commit -m "Add tier=backend label to the andes-cargo-status-api pod template"
git push origin main

Qué esperar (literal, ejecutado — el hash del commit es tu valor variable):

[main d0b98c6] Add tier=backend label to the andes-cargo-status-api pod template
 1 file changed, 1 insertion(+)
To http://localhost:3000/andes-cargo/andes-cargo-k8s.git
   db09bbe..d0b98c6  main -> main

A partir de aquí, y hasta el final de esta lección, ningún comando cambia nada contra el clúster. Todo lo que sigue observa.


Paso 3 — Observa la convergencia, con timestamps reales

for i in $(seq 1 24); do
  ts=$(date +%H:%M:%S)
  sync=$(kubectl get application andes-cargo-status-api -n argocd -o jsonpath='{.status.sync.status}')
  revision=$(kubectl get application andes-cargo-status-api -n argocd -o jsonpath='{.status.sync.revision}' | cut -c1-7)
  echo "$ts sync=$sync revision=$revision"
  if [ "$revision" = "d0b98c6" ]; then break; fi
  sleep 10
done

Qué esperar (literal, ejecutado — los timestamps son tu valor variable; el patrón —varios ciclos sin cambio, después convergencia— es el mismo mecanismo confirmado en el Módulo 5, lección 8):

17:16:29 sync=Synced revision=db09bbe
17:16:39 sync=Synced revision=db09bbe
...
17:20:48 sync=Synced revision=db09bbe
17:20:58 sync=Synced revision=d0b98c6

Aproximadamente cuatro minutos y medio entre el git push del Paso 2 y la convergencia detectada — dentro del rango de sondeo por defecto de ArgoCD (Módulo 5, lección 2). Ni un solo kubectl apply corrió en ningún momento de esta espera.


Paso 4 — Confirma el RollingUpdate, en vivo

Mientras ArgoCD aplica el cambio, el mismo mecanismo de RollingUpdate del Módulo 5, lección 7, entra en acción — nuevos Pods, con la etiqueta agregada, reemplazan a los viejos uno a la vez:

kubectl get events -n andes-cargo --sort-by=.lastTimestamp | tail -10

Qué esperar (literal, ejecutado — capturado en el instante exacto del rollout):

12s   Normal   ScalingReplicaSet   deployment/andes-cargo-status-api              Scaled up replica set andes-cargo-status-api-669755d655 from 0 to 1
12s   Normal   SuccessfulCreate    replicaset/andes-cargo-status-api-669755d655   Created pod: andes-cargo-status-api-669755d655-np5mt
6s    Normal   SuccessfulCreate    replicaset/andes-cargo-status-api-669755d655   Created pod: andes-cargo-status-api-669755d655-qhgwx
6s    Normal   Killing             pod/andes-cargo-status-api-548966dd97-gsgx8    Stopping container andes-cargo-status-api
6s    Normal   ScalingReplicaSet   deployment/andes-cargo-status-api              Scaled up replica set andes-cargo-status-api-669755d655 from 1 to 2
6s    Normal   ScalingReplicaSet   deployment/andes-cargo-status-api              Scaled down replica set andes-cargo-status-api-548966dd97 from 2 to 1
6s    Normal   SuccessfulDelete    replicaset/andes-cargo-status-api-548966dd97   Deleted pod: andes-cargo-status-api-548966dd97-gsgx8

Un ReplicaSet nuevo (669755d655) creció de 0 a 2, mientras el viejo (548966dd97) bajó de 2 a 0 — el patrón exacto que maxSurge: 1/maxUnavailable: 0 (Módulo 3, lección 6) garantiza: nunca menos de dos réplicas disponibles, un Pod nuevo a la vez. Cada línea SuccessfulCreate de este registro es, sin excepción, un Pod que ya atravesó admission control con éxito — un evento FailedCreate es lo que aparecería si Gatekeeper o Kyverno hubieran rechazado alguno, y no aparece ninguno.


Paso 5 — Confirma el resultado final: Pods, etiqueta, y ambos motores

kubectl get pods -n andes-cargo -l app=andes-cargo-status-api --show-labels

Qué esperar (literal, ejecutado — nombres de Pod con sufijo hash son tu valor variable; tier=backend en las dos filas es literal):

NAME                                      READY   STATUS    RESTARTS   AGE   LABELS
andes-cargo-status-api-669755d655-np5mt   1/1     Running   0          37s   app=andes-cargo-status-api,pod-template-hash=669755d655,tier=backend
andes-cargo-status-api-669755d655-qhgwx   1/1     Running   0          31s   app=andes-cargo-status-api,pod-template-hash=669755d655,tier=backend

Confirma, con las herramientas de los dos motores del Módulo 6, que ninguno de los dos registró ninguna objeción a estos Pods nuevos:

kubectl get k8srequiredresources andes-cargo-must-have-resource-limits
kubectl get policyreport -n andes-cargo

Qué esperar (literal, ejecutado):

NAME                                    ENFORCEMENT-ACTION   TOTAL-VIOLATIONS
andes-cargo-must-have-resource-limits   deny                 0

NAME                                   KIND         NAME                                      PASS   FAIL   WARN   ERROR   SKIP   AGE
b655474b-8b61-4af7-8fc1-5754351952d7   Pod          andes-cargo-status-api-669755d655-np5mt   1      0      0      0       0      45s
f4333127-9058-4d06-89c4-e053a1edbd67   Pod          andes-cargo-status-api-669755d655-qhgwx   1      0      0      0       0      39s

TOTAL-VIOLATIONS: 0 de Gatekeeper, y PASS: 1, FAIL: 0 de Kyverno en cada Pod nuevo — los dos Pods traen, sin que este cambio los haya tocado, el mismo resources.requests/limits que el Módulo 3 declaró (cpu: 100m/250m, memory: 64Mi/128Mi); una etiqueta nueva no afecta en absoluto la evaluación de ninguna de las dos políticas, porque ninguna de las dos evalúa etiquetas.

Y, por último, confirma que el servicio en sí sigue respondiendo — el cambio nunca interrumpió tráfico real:

curl -i -s --max-time 8 --resolve andes-cargo.local:80:127.0.0.1 http://andes-cargo.local/health

Qué esperar (literal, ejecutado — Date es tu valor variable):

HTTP/1.1 200 OK
Date: Fri, 14 Aug 2026 23:21:59 GMT
Content-Type: application/json
Content-Length: 51
Connection: keep-alive

{"service":"andes-cargo-status-api","status":"ok"}

El resumen visual: las cuatro confirmaciones, en una tabla

ConfirmaciónComandoResultado
Gitea recibió el commitgit log --oneline (clon fresco)d0b98c6 en main
ArgoCD lo aplicó, sin intervención manualkubectl get application -o jsonpath=...Synced a d0b98c6
Los Pods nuevos atravesaron admission controlkubectl get eventsCero eventos FailedCreate
El servicio sigue sirviendo tráfico realcurl .../health200 OK

Ningún kubectl apply -f deployment.yaml corrió en ningún punto de esta lección — la única escritura contra el clúster, en las cinco fases de este recorrido, la ejecutó argocd-application-controller, no tú.


Analogía: la orden inocua, aprobada en cada estación sin detenerse

Retomando la fábrica de la lección 1: esta lección mandó una orden de producción —"agrega una etiqueta a cada unidad nueva"— por la única puerta de entrada (Git). La línea se reconfiguró sola en cuanto la orden llegó (ArgoCD), sin que nadie tocara ninguna palanca. Y en cada estación de control de calidad (Gatekeeper, Kyverno), la pieza pasó sin que el inspector siquiera levantara la vista de su lista — porque la orden nunca tocó ningún campo que esa lista revisa. Una etiqueta es, para el control de calidad de este capítulo, invisible: existe, se aplicó, quedó registrada, y ningún inspector tuvo nada que objetar.


Errores comunes

Correr kubectl apply "solo para confirmar" antes de que ArgoCD converja, y confundir el resultado (de impaciencia). Qué pasa: alguien, ansioso por ver el cambio reflejado, corre kubectl apply -f deployment.yaml a mano mientras espera el ciclo de sondeo de ArgoCD. Cómo detectarlo: si tu comando incluye apply y el archivo deployment.yaml, en cualquier punto entre el Paso 2 y el Paso 5 de esta lección. Cómo corregirlo: esto rompe por completo la prueba que esta lección demuestra — el punto central es que nadie necesita ejecutar ese comando. Si lo corriste por accidente, el resultado final va a ser el mismo (el cambio queda aplicado), pero ya no tienes evidencia de que ArgoCD, por sí solo, lo hubiera hecho igual. Espera al Paso 3 sin intervenir.

Esperar que la etiqueta nueva aparezca sin ningún RollingUpdate (de expectativa sobre qué cambia un Pod). Qué pasa: alguien asume que, como el cambio es "solo una etiqueta", los Pods existentes se actualizan en su lugar, sin crear ninguno nuevo. Cómo detectarlo: si esperabas ver los mismos nombres de Pod (...-548966dd97-...) con la etiqueta agregada, en vez de nombres nuevos. Cómo corregirlo: repasa el Paso 1 de esta lección — cualquier cambio a spec.template, sin importar el campo, cambia el hash de la plantilla y dispara un RollingUpdate completo. Un Pod nunca se "edita en su lugar"; siempre se reemplaza por uno nuevo con la especificación completa actualizada.

Confundir "cero violaciones registradas" con "las políticas no se evaluaron" (el mismo error de lectura que el Módulo 6, lección 4, ya advirtió). Qué pasa: alguien ve TOTAL-VIOLATIONS: 0 y FAIL: 0, y concluye que Gatekeeper/Kyverno "no hicieron nada" en este cambio. Cómo detectarlo: si tu resumen de esta lección es "los guardrails no participaron". Cómo corregirlo: los dos motores evaluaron cada uno de los dos Pods nuevos, en el instante exacto de su creación — el resultado de esa evaluación fue allow, no skip. Cero violaciones es el resultado correcto de un guardrail funcionando frente a un objeto que cumple la política, no evidencia de que el guardrail estuvo ausente.


Ejercicios

Ejercicio 1 — Reconstruye el flujo completo de memoria. Sin volver a esta lección, enumera los cinco pasos, en orden, desde "edito deployment.yaml" hasta "confirmo que el servicio sigue respondiendo", nombrando en cada paso qué componente actúa.

Ver solución

(1) Editas deployment.yaml localmente, agregando tier: backend a spec.template.metadata.labels — actúas tú, sin tocar el clúster. (2) git commit + git push sube el cambio a Gitea — actúas tú, el clúster sigue sin cambios. (3) argocd-application-controller, en su próximo ciclo de sondeo, detecta la diferencia y la aplica contra kube-apiserver — actúa ArgoCD, sin ningún comando tuyo. (4) kube-apiserver invoca a Gatekeeper y Kyverno para cada Pod nuevo que el RollingUpdate crea; ambos lo permiten — actúan los dos motores de admission control, en paralelo. (5) Confirmas, con kubectl get/curl, que los Pods nuevos están Running y el servicio responde — actúas tú, pero solo observando.

Ejercicio 2 — Explica por qué una etiqueta dispara un RollingUpdate pero no una violación de política. En dos o tres frases, explica a un colega por qué el mismo cambio (tier: backend) reemplaza los dos Pods existentes, y al mismo tiempo no tiene ningún efecto sobre el resultado de Gatekeeper/Kyverno.

Ver solución

Una explicación razonable: "Kubernetes decide si reemplaza un Pod comparando la especificación completa del template contra la que generó el Pod actual — cualquier diferencia, sin importar cuál campo, produce un hash distinto y dispara el reemplazo. Gatekeeper y Kyverno, en cambio, evalúan un conjunto específico y acotado de campos —en este caso, resources.requests/limits— sin que les importe qué más cambió en el resto del objeto. Una etiqueta nueva altera el hash (dispara el reemplazo), pero no altera resources (no dispara ninguna violación) — son dos mecanismos completamente distintos mirando el mismo objeto."

Ejercicio 3 — Diseña una prueba de que el cambio nunca interrumpió disponibilidad. Sin repetir el Paso 5 de esta lección, describe un experimento —con comandos concretos— que confirme que, durante todo el RollingUpdate del Paso 4, siempre hubo al menos un Pod Running respondiendo tráfico.

Ver solución

Una respuesta razonable: correr, en una terminal separada, un bucle continuo de curl -s -o /dev/null -w "%{http_code}\n" --resolve andes-cargo.local:80:127.0.0.1 http://andes-cargo.local/health cada medio segundo, empezando justo antes del Paso 2 (git push) y terminando después de que el Paso 4 confirme el RollingUpdate completo. Si la secuencia completa de resultados es una lista ininterrumpida de 200, sin ningún código de error o timeout intercalado, eso confirma que maxSurge: 1/maxUnavailable: 0 cumplió su promesa: en ningún instante del reemplazo hubo cero Pods disponibles para atender ese tráfico.


Resumen y siguiente paso

Esta lección subió un cambio real —una etiqueta nueva en el template del Deployment— con git commit/git push, y confirmó, con evidencia literal en cada paso, las cuatro piezas de la tesis central de esta guía: Gitea recibió el commit, ArgoCD lo aplicó sin ningún kubectl apply, los Pods nuevos que ese cambio produjo atravesaron Gatekeeper y Kyverno sin fricción, y el servicio nunca dejó de responder. El camino completo —Git → ArgoCD → admission controletcd— funcionó de punta a punta, sin que ningún humano tocara el clúster directamente en ningún momento.

Antes de avanzar deberías poder: reproducir este flujo completo en tu propio laboratorio; explicar por qué una etiqueta dispara un RollingUpdate sin disparar ninguna violación de política; y diseñar una prueba de disponibilidad continua durante un cambio de este tipo.

Siguiente lección: un cambio que el gate detiene. Ahí vas a subir un cambio real que sí viola una política —remover los límites de recursos de andes-cargo-status-api— y vas a ver, con evidencia literal, exactamente dónde y cómo el mismo flujo se detiene antes de que exista ningún Pod nuevo.

Recursos

  1. Argo CD — Automated Sync Policy — el mecanismo de sondeo continuo que esta lección midió con timestamps reales.
  2. Kubernetes — Deployments, Updating a Deployment — referencia oficial de por qué cualquier cambio a spec.template dispara un RollingUpdate completo.
  3. Gatekeeper — Audit y Kyverno — Policy Reports — las dos fuentes de evidencia que esta lección usó para confirmar que ambos motores evaluaron los Pods nuevos sin objeción.
  4. kubernetes-and-eks-in-production-guide (NIEVA), Módulo 5, lección 8 — el proyecto que dejó el Application en el estado exacto (Synced, Healthy, ignoreDifferences activo) del que parte esta lección.