Module 8: Capstone Andes Cargo On Kubernetes
3. End-to-end walkthrough: a change that crosses the whole gate
Description
This is the capstone's central test, actually executed against andes-cargo-cluster to write this lesson: a harmless change — a new label on andes-cargo-status-api's Deployment — gets pushed to Gitea with git push, ArgoCD detects it and applies it on its own, the new Pods that change produces cross Gatekeeper and Kyverno with no friction, and keep running. At no point in this lesson does a kubectl apply run against deployment.yaml. Every "What to expect" block in this lesson is literal output, captured in the same lab that carries the rest of this guide.
Connection to the module
This lesson puts lesson 2's sequence diagram's alt branch to the test — the path where the object satisfies the policies. Lesson 4 tests the else branch, with the same mechanism and a different change.
Step 0 — Confirm the starting point
This lesson runs git commands from your machine, not from a Pod — so, as Module 5, lesson 3, established, Gitea's URL is localhost:3000, never the cluster's internal DNS name (gitea-http.gitea.svc.cluster.local, which only resolves inside a Pod). If you closed a previous lesson's port-forward, open it again before continuing:
kubectl port-forward -n gitea svc/gitea-http 3000:3000
In another terminal:
git clone http://andes-cargo:AndesCargo2026!@localhost:3000/andes-cargo/andes-cargo-k8s.git
cd andes-cargo-k8s
git log --oneline
What to expect (literal, executed — the hashes are the repository's real state at the time this lesson was written; yours match if you followed the guide without skipping any module):
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)
Four commits, the exact same state the Module 5 project closed with: deployment.yaml declares replicas: 5, but the real cluster runs with 2 — the HorizontalPodAutoscaler, not Git, owns that specific field since ignoreDifferences (Module 5, lesson 8). Confirm it:
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
What to expect (literal, executed):
2 ready
NAME SYNC STATUS HEALTH STATUS
andes-cargo-status-api Synced Healthy
Synced, Healthy, two real replicas — this lesson's exact starting point.
Step 1 — The change: a new label on the Pod template
The change this lesson pushes is, deliberately, the most harmless thing a Deployment can receive: a new descriptive label, with no functional effect on the service whatsoever.
grep -n "labels:" -A2 deployment.yaml
15: labels:
16: app: andes-cargo-status-api
...
22: labels:
23: app: andes-cargo-status-api
The second occurrence (line 22-23) is the one that matters for this lesson: it's spec.template.metadata.labels, the labels Kubernetes copies to every new Pod the Deployment creates — unlike the metadata.labels labels on line 15-16, which belong solely to the Deployment object itself and never reach a Pod.
# deployment.yaml (fragment, spec.template.metadata)
template:
metadata:
labels:
app: andes-cargo-status-api
tier: backend
tier: backend is the only new line in the entire file — no image, no port, no resources, no probe changes. Confirm the diff before pushing it:
git diff
What to expect (literal, executed):
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:
One line added, zero lines removed. And yet — this is worth flagging ahead of time, because Step 3 confirms it with evidence — this change does trigger a RollingUpdate: any modification to spec.template, no matter how small, changes the hash Kubernetes uses to identify the Pod version, and the Deployment reacts by replacing every existing Pod with a new one carrying the added label.
Step 2 — git commit, git push — and nothing else
git add deployment.yaml
git commit -m "Add tier=backend label to the andes-cargo-status-api pod template"
git push origin main
What to expect (literal, executed — the commit hash is your variable value):
[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
From here, and until the end of this lesson, no command changes anything against the cluster. Everything that follows observes.
Step 3 — Watch the convergence, with real timestamps
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
What to expect (literal, executed — the timestamps are your variable value; the pattern — several cycles with no change, then convergence — is the same mechanism confirmed in Module 5, lesson 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
About four and a half minutes between Step 2's git push and detected convergence — within ArgoCD's default polling range (Module 5, lesson 2). Not a single kubectl apply ran at any point during this wait.
Step 4 — Confirm the RollingUpdate, live
While ArgoCD applies the change, the same RollingUpdate mechanism from Module 5, lesson 7, kicks in — new Pods, with the added label, replace the old ones one at a time:
kubectl get events -n andes-cargo --sort-by=.lastTimestamp | tail -10
What to expect (literal, executed — captured at the exact instant of the 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
A new ReplicaSet (669755d655) grew from 0 to 2, while the old one (548966dd97) went down from 2 to 0 — the exact pattern maxSurge: 1/maxUnavailable: 0 (Module 3, lesson 6) guarantees: never fewer than two replicas available, one new Pod at a time. Every SuccessfulCreate line in this log is, with no exception, a Pod that already crossed admission control successfully — a FailedCreate event is what would show up if Gatekeeper or Kyverno had rejected one, and none shows up.
Step 5 — Confirm the final result: Pods, label, and both engines
kubectl get pods -n andes-cargo -l app=andes-cargo-status-api --show-labels
What to expect (literal, executed — Pod names with a hash suffix are your variable value; tier=backend on both rows is 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
Confirm, with Module 6's two engines' tools, that neither one recorded any objection to these new Pods:
kubectl get k8srequiredresources andes-cargo-must-have-resource-limits
kubectl get policyreport -n andes-cargo
What to expect (literal, executed):
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 from Gatekeeper, and PASS: 1, FAIL: 0 from Kyverno on every new Pod — both Pods carry, untouched by this change, the same resources.requests/limits Module 3 declared (cpu: 100m/250m, memory: 64Mi/128Mi); a new label doesn't affect either policy's evaluation at all, because neither one evaluates labels.
And, finally, confirm the service itself is still responding — the change never interrupted real traffic:
curl -i -s --max-time 8 --resolve andes-cargo.local:80:127.0.0.1 http://andes-cargo.local/health
What to expect (literal, executed — Date is your variable value):
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"}
The visual summary: four confirmations, in a table
| Confirmation | Command | Result |
|---|---|---|
| Gitea received the commit | git log --oneline (fresh clone) | d0b98c6 on main |
| ArgoCD applied it, with no manual intervention | kubectl get application -o jsonpath=... | Synced to d0b98c6 |
| The new Pods crossed admission control | kubectl get events | Zero FailedCreate events |
| The service still serves real traffic | curl .../health | 200 OK |
Not a single kubectl apply -f deployment.yaml ran at any point in this lesson — the only write against the cluster, across this walkthrough's five phases, was executed by argocd-application-controller, not by you.
Analogy: the harmless order, approved at every station without stopping
Picking up the factory from lesson 1: this lesson sent a production order — "add a label to every new unit" — through the single entry door (Git). The line reconfigured itself as soon as the order arrived (ArgoCD), with no one touching any lever. And at every quality-check station (Gatekeeper, Kyverno), the piece passed with the inspector not even looking up from their list — because the order never touched any field that list checks. A label is, to this chapter's quality control, invisible: it exists, it got applied, it got recorded, and no inspector had anything to object to.
Common mistakes
Running kubectl apply "just to confirm" before ArgoCD converges, and misreading the result (impatience). What happens: someone, anxious to see the change reflected, runs kubectl apply -f deployment.yaml by hand while waiting for ArgoCD's polling cycle. How to spot it: if your command includes apply and the file deployment.yaml, at any point between Step 2 and Step 5 of this lesson. How to fix it: this completely breaks the test this lesson demonstrates — the central point is that no one needs to run that command. If you ran it by accident, the final result is going to be the same (the change ends up applied), but you no longer have evidence that ArgoCD, on its own, would have done the same. Wait for Step 3 without intervening.
Expecting the new label to appear with no RollingUpdate (expectation about what changes a Pod). What happens: someone assumes that, since the change is "just a label," the existing Pods get updated in place, with no new one created. How to spot it: if you expected to see the same Pod names (...-548966dd97-...) with the label added, instead of new names. How to fix it: review this lesson's Step 1 — any change to spec.template, no matter the field, changes the template's hash and triggers a complete RollingUpdate. A Pod is never "edited in place"; it always gets replaced by a new one with the complete updated specification.
Confusing "zero recorded violations" with "the policies weren't evaluated" (the same reading error Module 6, lesson 4, already warned about). What happens: someone sees TOTAL-VIOLATIONS: 0 and FAIL: 0, and concludes Gatekeeper/Kyverno "did nothing" in this change. How to spot it: if your summary of this lesson is "the guardrails didn't participate." How to fix it: both engines evaluated each of the two new Pods, at the exact instant of their creation — that evaluation's result was allow, not skip. Zero violations is the correct result of a guardrail working against an object that satisfies the policy, not evidence the guardrail was absent.
Exercises
Exercise 1 — Rebuild the complete flow from memory. Without going back to this lesson, list the five steps, in order, from "I edit deployment.yaml" to "I confirm the service is still responding," naming which component acts at each step.
See solution
(1) You edit deployment.yaml locally, adding tier: backend to spec.template.metadata.labels — you act, without touching the cluster. (2) git commit + git push pushes the change to Gitea — you act, the cluster is still unchanged. (3) argocd-application-controller, on its next polling cycle, detects the difference and applies it against kube-apiserver — ArgoCD acts, with no command from you. (4) kube-apiserver invokes Gatekeeper and Kyverno for every new Pod the RollingUpdate creates; both allow it — the two admission-control engines act, in parallel. (5) You confirm, with kubectl get/curl, that the new Pods are Running and the service responds — you act, but only observing.
Exercise 2 — Explain why a label triggers a RollingUpdate but not a policy violation. In two or three sentences, explain to a colleague why the same change (tier: backend) replaces the two existing Pods, and at the same time has no effect on Gatekeeper/Kyverno's result.
See solution
A reasonable explanation: "Kubernetes decides whether to replace a Pod by comparing the template's complete specification against the one that generated the current Pod — any difference, no matter which field, produces a different hash and triggers the replacement. Gatekeeper and Kyverno, on the other hand, evaluate a specific, bounded set of fields — in this case, resources.requests/limits — regardless of whatever else changed in the rest of the object. A new label alters the hash (triggers the replacement), but doesn't alter resources (triggers no violation) — they're two completely different mechanisms looking at the same object."
Exercise 3 — Design a test that the change never interrupted availability. Without repeating this lesson's Step 5, describe an experiment — with concrete commands — that confirms that, throughout Step 4's entire RollingUpdate, there was always at least one Running Pod responding to traffic.
See solution
A reasonable answer: run, in a separate terminal, a continuous loop of curl -s -o /dev/null -w "%{http_code}\n" --resolve andes-cargo.local:80:127.0.0.1 http://andes-cargo.local/health every half second, starting right before Step 2 (git push) and ending after Step 4 confirms the complete RollingUpdate. If the complete sequence of results is an uninterrupted list of 200s, with no error code or timeout interspersed, that confirms maxSurge: 1/maxUnavailable: 0 kept its promise: at no instant of the replacement were there zero Pods available to serve that traffic.
Summary and next step
This lesson pushed a real change — a new label on the Deployment's template — with git commit/git push, and confirmed, with literal evidence at every step, this guide's central thesis's four pieces: Gitea received the commit, ArgoCD applied it with no kubectl apply, the new Pods that change produced crossed Gatekeeper and Kyverno with no friction, and the service never stopped responding. The complete path — Git → ArgoCD → admission control → etcd — worked end to end, with no human ever touching the cluster directly at any point.
Before moving on you should be able to: reproduce this complete flow in your own lab; explain why a label triggers a RollingUpdate without triggering any policy violation; and design a continuous availability test during a change of this kind.
Next lesson: a change the gate stops. There you're going to push a real change that does violate a policy — removing andes-cargo-status-api's resource limits — and you're going to see, with literal evidence, exactly where and how the same flow stops before any new Pod exists.
Resources
- Argo CD — Automated Sync Policy — the continuous polling mechanism this lesson measured with real timestamps.
- Kubernetes — Deployments, Updating a Deployment — official reference for why any change to
spec.templatetriggers a completeRollingUpdate. - Gatekeeper — Audit and Kyverno — Policy Reports — the two evidence sources this lesson used to confirm both engines evaluated the new Pods with no objection.
kubernetes-and-eks-in-production-guide(NIEVA), Module 5, lesson 8 — the project that left theApplicationin the exact state (Synced,Healthy,ignoreDifferencesactive) this lesson starts from.