Drill — Write an Enforcing Kyverno Policy
Kyverno's real test isn't installing a policy — it's the moment you flip one from Audit to Enforce and watch actual traffic get refused for the first time. This drill puts you through exactly that moment, start to finish, on a throwaway cluster: install Kyverno, apply the same require-crew-label rule this course's Kyverno lesson walks through, and prove Audit mode's very real gap — a violation that gets recorded and never stopped. Then flip failureAction to Enforce and watch admission actually say no, with the policy's own message sitting in the error. The harder half comes after: PolicyExceptions are off by default for a reason, so you turn that on deliberately and scoped, write one exception for one legitimate workload, and prove — not assume — that it never widens past that single Deployment.
Imagine a hall monitor who's allowed to write your name in a notebook if you're missing your hall pass, but isn't actually allowed to stop you — that's audit mode. You walk right past, your name gets written down, and nothing else happens; a notebook nobody acts on is not the same thing as a rule. Enforce mode is the same monitor suddenly allowed to stand in the doorway and physically block you until you go back for the pass. Today you build both versions of that monitor, watch the difference with your own eyes, and then write one very specific hall pass — good for exactly one kid, at exactly one door, checked and signed — instead of just telling the monitor to stop checking passes altogether.
You need a throwaway Kubernetes cluster (a local kind cluster is assumed below — minikube works too, translate the commands), kubectl, and Helm 3+. Nothing else — no cloud account, no cost, nothing to tear down but one cluster when you're done. This drill is fully self-contained: it does not depend on, and does not modify, the capstone. Kyverno's chart values and exact CLI output shift between releases, so treat the commands and output quoted below as "the shape of what you'll see," not a byte-exact transcript — and where a Helm values path is genuinely likely to have moved, this drill shows you how to check it yourself rather than trusting a fixed flag.
How this drill works
☺ Like you're 10: One policy, evolved in place through seven steps — work them in order, not as two separate puzzles.
Budget 25 to 40 minutes. Like the real lifecycle of a guardrail, this is one policy rewritten under you in place — install it soft, prove the soft version's gap, harden it, prove the hard version actually holds, then carve one narrow, reviewable exception into it rather than softening it back. Each step names exactly what to run and exactly what you should see; if your terminal disagrees in a way that looks structural rather than cosmetic, stop and re-read the previous step before continuing.
Stand up Kyverno on a throwaway cluster
☺ Like you're 10: Before the hall monitor can do anything, the hall monitor has to actually show up for work.
Create the cluster, two namespaces you'll use throughout the drill, and install Kyverno with Helm:
kind create cluster --name kyverno-drill
kubectl create namespace analytics
kubectl create namespace platform-exceptions
helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update
helm install kyverno kyverno/kyverno -n kyverno --create-namespace
kubectl -n kyverno rollout status deploy/kyverno-admission-controller --timeout=120s
kubectl get pods -n kyvernodeployment "kyverno-admission-controller" successfully rolled out
NAME READY STATUS RESTARTS AGE
kyverno-admission-controller-7c9d4f6b5d-x2q7f 1/1 Running 0 58s
kyverno-background-controller-6f8b9c7d4-m9k2p 1/1 Running 0 58s
kyverno-cleanup-controller-5d7c6b8f9-tz4jn 1/1 Running 0 58s
kyverno-reports-controller-8b6c5d7f4-vh3wr 1/1 Running 0 58sFour controllers, four rows. If you only see the admission controller, give it another minute — the other three come up slightly behind it. The Kyverno lesson covers what each one actually does if any of these names are unfamiliar.
Done when: the rollout reports "successfully rolled out" and all four Kyverno pods show 1/1 Running.
Install the policy in Audit mode — and watch it wave a violation through
☺ Like you're 10: The monitor writes your name down. You keep walking. Nothing stops you.
Apply mission-control-baseline, requiring a crew label on every workload — the exact policy this course's Kyverno lesson works through, started here in its softest setting:
# mission-control-baseline.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: mission-control-baseline
spec:
background: true
rules:
- name: require-crew-label
match:
any:
- resources:
kinds: [Deployment, StatefulSet, DaemonSet]
exclude:
any:
- resources: { namespaces: [kube-system, kyverno] }
validate:
failureAction: Audit # recorded, never blocked -- that's the whole point of this step
message: "Workloads need metadata.labels.crew -- which flight owns this?"
pattern:
metadata:
labels:
crew: "?*" # ?* = at least one characterkubectl apply -f mission-control-baseline.yaml
kubectl get cpol mission-control-baselineNAME BACKGROUND VALIDATE ACTION READY AGE
mission-control-baseline true Audit true 4sNow create a Deployment that carries no crew label at all — the honest, fast thing a rushed engineer actually does:
kubectl create deployment ground-control --image=nginx:1.27 -n analytics
kubectl get deploy -n analytics ground-controldeployment.apps/ground-control created
NAME READY UP-TO-DATE AVAILABLE AGE
ground-control 1/1 1 1 22sIt's running. Nothing rejected it. Now go find the part of Kyverno that actually noticed:
kubectl get polr -A -o wideNAMESPACE NAME PASS FAIL WARN ERROR SKIP AGE
analytics cpol-mission-control-baseline-4f8c2b1a 0 1 0 0 0 31sThat FAIL: 1 is Kyverno telling you, correctly, that it saw the violation. ground-control is running anyway. Audit is a measurement, not a control — it proves the rule's match and pattern are wired correctly before you ever let it block real traffic, and it gives you an honest count of how many existing resources are already in violation. What it does not do, no matter how long you leave it running, is stop a single one of them. That is the entire gap this drill exists to close.
Done when: ground-control shows 1/1 Running in analytics despite carrying no crew label, and kubectl get polr -A -o wide shows a FAIL: 1 row for it that never stopped anything.
Flip failureAction to Enforce — and watch admission actually say no
☺ Like you're 10: Same monitor, same notebook — except now they're standing in the doorway.
Change exactly one line — failureAction: Audit to failureAction: Enforce — and reapply:
kubectl apply -f mission-control-baseline.yaml # after editing failureAction to Enforce
kubectl get cpol mission-control-baselineNAME BACKGROUND VALIDATE ACTION READY AGE
mission-control-baseline true Enforce true 1m12sTry to create a second unlabeled Deployment:
kubectl create deployment ground-control-2 --image=nginx:1.27 -n analyticsError from server: admission webhook "validate.kyverno.svc-fail" denied the request:
resource Deployment/analytics/ground-control-2 was blocked due to the following policies
mission-control-baseline:
require-crew-label: 'validation error: Workloads need metadata.labels.crew --
which flight owns this? rule require-crew-label failed at path /metadata/labels/crew/'No object was ever created — check kubectl get deploy -n analytics and ground-control-2 simply isn't there. Notice the message: it's the exact string you wrote in the policy's message field, not a generic Kubernetes error. That's not an accident — a rejection nobody can act on without opening a runbook is barely better than no rejection at all.
Done when: the second create attempt is refused outright, the error names require-crew-label and repeats your own message text, and kubectl get deploy -n analytics confirms ground-control-2 was never created.
Prove the rule isn't just blocking everything
☺ Like you're 10: The doorway monitor lets through anyone who actually has a pass — check that this is really true, don't just assume it.
A policy that blocks every create looks identical, from a five-second glance at a dashboard, to a policy that correctly blocks only the bad ones. Prove the difference by giving it what it actually asked for:
# ground-control-3.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ground-control-3
namespace: analytics
labels:
crew: apollo
spec:
replicas: 1
selector: { matchLabels: { app: ground-control-3 } }
template:
metadata:
labels: { app: ground-control-3, crew: apollo }
spec:
containers:
- name: web
image: nginx:1.27kubectl apply -f ground-control-3.yaml
kubectl get polr -n analytics -o widedeployment.apps/ground-control-3 created
NAMESPACE NAME PASS FAIL WARN ERROR SKIP AGE
analytics cpol-mission-control-baseline-4f8c2b1a 1 1 0 0 0 3m40sPASS went from 0 to 1 while FAIL stayed at the single violation from before Enforce ever took effect — proof the rule is discriminating, not just refusing on principle.
Done when: ground-control-3 is created cleanly with no error, and the PolicyReport's PASS count increases without its FAIL count moving.
Turn on PolicyExceptions, deliberately and scoped
☺ Like you're 10: The hall pass system doesn't exist yet — the principal has to actually turn it on first, and decide which office is allowed to print one.
PolicyExceptions are disabled by default, on purpose: a platform that lets every tenant quietly loosen the rule they don't like is a platform with no rule at all. Turning the feature on is a deliberate, auditable admin action — two flags on the admission controller, not a default anyone stumbles into.
# chart values move between Kyverno releases -- confirm the exact key on your version
# before trusting any --set path, this one included
helm show values kyverno/kyverno | grep -A6 "extraArgs"
# the flag names themselves are stable across chart layouts: enablePolicyException
# and exceptionNamespaces -- restrict the second one tightly
helm upgrade kyverno kyverno/kyverno -n kyverno --reuse-values \
--set-string admissionController.extraArgs.enablePolicyException=true \
--set-string admissionController.extraArgs.exceptionNamespaces=platform-exceptions
kubectl -n kyverno rollout status deploy/kyverno-admission-controller --timeout=120sConfirm it actually landed by reading the running container's own arguments, which is true regardless of which values path your chart version happened to use:
kubectl -n kyverno get deploy kyverno-admission-controller \
-o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i exception--enablePolicyException=true
--exceptionNamespaces=platform-exceptions--exceptionNamespaces=platform-exceptions is doing real work here: it means an exception can only ever be created inside that one namespace, so a tenant cannot self-grant an exemption from inside their own namespace no matter what the exception's own match block says.
Done when: both flags show up in the admission controller's live container args, exactly as set.
Try the legitimate case anyway — and watch it get rejected first
☺ Like you're 10: Before you print anyone a hall pass, make them actually get stopped at the door once — proves you're writing the pass because it's needed, not because you assumed it.
Say a batch workload genuinely can't carry a crew label yet — it's a shared vendor image nobody on your team templates. Resist the urge to write its exception first. Create it under Enforce exactly as it stands today:
kubectl create deployment batch-runner --image=apache/airflow:2.9.3 -n analyticsError from server: admission webhook "validate.kyverno.svc-fail" denied the request:
resource Deployment/analytics/batch-runner was blocked due to the following policies
mission-control-baseline:
require-crew-label: 'validation error: Workloads need metadata.labels.crew --
which flight owns this? rule require-crew-label failed at path /metadata/labels/crew/'Good — same rejection, same message, no favoritism. This is the step most people skip on a real platform, and skipping it is how "exceptions" quietly turn into "things I assumed would be a problem and pre-exempted without ever checking." An exception you write against a rejection you actually watched happen is a different, more honest artifact than one you write speculatively.
Done when: batch-runner is rejected with the identical require-crew-label message, proving the rule treats it no differently than ground-control-2 earlier.
Write the scoped exception, then prove the scope
☺ Like you're 10: One pass, one kid's name on it, one door it's good for — check that it doesn't quietly work for the kid standing next to them too.
Write a PolicyException that names exactly one policy, one rule, and one resource — not a namespace, not a label selector, one literal name:
# batch-runner-exempt.yaml
apiVersion: kyverno.io/v2
kind: PolicyException
metadata:
name: batch-runner-exempt
namespace: platform-exceptions # the one namespace --exceptionNamespaces allows
spec:
exceptions:
- policyName: mission-control-baseline
ruleNames: [require-crew-label]
match:
any:
- resources:
kinds: [Deployment]
namespaces: [analytics]
names: [batch-runner] # exactly one workload, never a whole namespacekubectl apply -f batch-runner-exempt.yaml
kubectl get polex -n platform-exceptions
kubectl create deployment batch-runner --image=apache/airflow:2.9.3 -n analyticspolicyexception.kyverno.io/batch-runner-exempt created
NAME AGE
batch-runner-exempt 3s
deployment.apps/batch-runner createdIt went through this time — same manifest, same missing label, only the exception changed. Now prove the exception is exactly as narrow as its match block claims, by testing a workload it does not name:
kubectl create deployment ground-control-4 --image=nginx:1.27 -n analyticsError from server: admission webhook "validate.kyverno.svc-fail" denied the request:
resource Deployment/analytics/ground-control-4 was blocked due to the following policies
mission-control-baseline:
require-crew-label: 'validation error: Workloads need metadata.labels.crew --
which flight owns this? rule require-crew-label failed at path /metadata/labels/crew/'If names: [batch-runner] above had been written as a namespace-only match — no names filter at all — every unlabeled Deployment in analytics would have quietly sailed through, and the PolicyReport would look identical to a properly scoped one at a glance. There is no separate "strictness" setting to lean on; the only thing standing between one exempt workload and an exempt namespace is exactly how tight you wrote match. Always test the boundary — one thing that should still be rejected — the same way you just did, not just the one thing you meant to exempt.
Done when: batch-runner applies cleanly with the exception in place, and ground-control-4 — a different, unrelated unlabeled Deployment in the same namespace — is still rejected by the same rule and the same message.
"My first PR against this policy tried to fix the rejection by adding namespaces: [analytics] to the exception and calling it done — it worked, in the sense that my deploy went through. It also meant the next four unlabeled deploys from other people in that namespace went through too, silently, for two weeks, until someone doing a policy audit asked why analytics had the lowest label-compliance rate on the whole cluster. The fix wasn't a smarter rule. It was names: [batch-runner] instead of a namespace match — the exact one-line difference this drill makes you test for on purpose."
Three ways to push past the minimum here. First, write a kyverno-test.yaml for mission-control-baseline with three resources — ground-control (fails), ground-control-3 (passes), batch-runner (passes, but only because of the exception) — and run kyverno test . locally, no cluster required, so this exact regression never needs a live create to catch again. Second, give the exception an obvious expiry: add a # review-by: 2026-11-01 comment and an actual calendar reminder — a PolicyException with no review date tends to outlive the reason it was written. Third, add a second rule to the same policy — block any image tagged :latest — and work through this exact Audit-then-Enforce sequence again on your own, without the walkthrough; if step 3 (the "prove it isn't blocking everything" check) doesn't occur to you unprompted this time, that's the part of this drill worth repeating.
Benny the Beaver: Ground-control-2 just got bounced. Message and everything — pretty painless, actually.
Foxy: So now everything's enforced. Are we done?
Timmy the Turtle: Not quite — batch-runner still needs a real answer, not a workaround. Watch it get rejected first. Then we write it an exception, on purpose, for exactly that one workload.
Gizmo: Or — hear me out — just scope the exception to the whole analytics namespace. Saves you writing a new one every time someone else in there hits the same wall. Efficiency! 🤑
Timmy: That's not efficiency, Gizmo, that's deleting the rule for one namespace and calling it a feature. The whole point of a PolicyException is that it's reviewable because it's narrow — one name, in Git, in a pull request. Widen the match and you've quietly rebuilt the audit-mode gap we spent this whole drill closing.
Benny: And I just watched ground-control-4 get rejected in the exact same namespace with the exception already live. Scoped, proven, not just claimed.
Timmy: That's the whole drill in one sentence, Benny. Well put.
1. Why did ground-control keep running in Step 2 even though kubectl get polr -A -o wide clearly showed a FAIL row for it? 2. What field, changed from what value to what value, is the entire difference between Step 2 and Step 3? 3. Why does Step 4 matter — what would you have failed to notice if you'd stopped right after Step 3? 4. Name the two flags that turn PolicyExceptions on, and what does the second one specifically stop a tenant from doing? 5. Why does Step 6 insist you watch batch-runner get rejected before writing its exception, instead of writing the exception first? 6. What single change to batch-runner-exempt.yaml's match block would have silently exempted every unlabeled Deployment in analytics, not just batch-runner?
Check your answers
- Because the policy's
failureActionwas stillAuditat that point — Audit mode records a violation in the PolicyReport but never blocks the request that caused it. Admission only refuses a request oncefailureActionisEnforce. spec.rules[].validate.failureAction, fromAudittoEnforce. That single field is the entire difference between a violation being written down and a violation being refused outright.- Without Step 4 you'd only have proof that the rule blocks something — which looks identical, from a glance at a dashboard, to a rule that blocks everything. Step 4 proves the rule actually discriminates: a correctly labeled Deployment sails through cleanly and the PolicyReport's
PASScount moves whileFAILstays put. --enablePolicyException=trueand--exceptionNamespaces=platform-exceptions. The second flag restricts where aPolicyExceptionresource may even be created, so a tenant cannot self-grant an exemption to their own rule from inside their own namespace, no matter how itsmatchblock is written.- To prove the exception is solving a real, observed problem rather than a speculative one — an exception written against a rejection you actually watched happen is verifiable; one written "just in case" tends to accumulate scope nobody ever double-checked was needed.
- Dropping the
names: [batch-runner]line and leaving onlynamespaces: [analytics]— that turns a one-workload exemption into a namespace-wide one, and a PolicyReport would look identical to a properly scoped exception at a glance, which is exactly why Step 7 makes you test a workload the exception doesn't name.
kubectl get pods -n kyverno shows four pods 1/1 Running.mission-control-baseline in Audit mode and create an unlabeled Deploymentground-control shows 1/1 Running in analytics despite carrying no crew label.kubectl get polr -A -o wide shows a FAIL: 1 row for it that never stopped anything.failureAction to Enforce and watch a second unlabeled Deployment get refusedrequire-crew-label and your own message text.PASS count rises without FAIL moving.PolicyExceptions deliberately, scoped to one exception namespace--enablePolicyException=true and --exceptionNamespaces=platform-exceptions both show up in the running container's own args.batch-runner get rejected once, before any exception exists for itrequire-crew-label message, proving no favoritism happened.PolicyException, then prove it stayed scopedbatch-runner now applies cleanly, and a different unlabeled Deployment in the same namespace is still rejected.Audit told you the truth without stopping anything, Enforce actually stopped it, and one narrow PolicyException let a real exception through without loosening the rule for anyone else — that's the whole lifecycle a guardrail goes through on a real platform. For the full architecture underneath what you just did — the four controllers, all four rule types, autogen, and every gotcha this drill deliberately avoided — see the Kyverno lesson; for the vendor-neutral argument for policy-as-code at all, see Policy-as-Code Philosophy. This exact rule reappears, wired into a real mesh and a real cluster, in Capstone Part 3 — Mesh & Policy, and the full domain breakdown behind it is on the KCA blueprint. If Kubernetes admission control and RBAC themselves are still shaky underneath this, the sibling Kubernetes course's CKS material covers that ground in depth. Ready for a different single skill? Try Drill — Trace a Broken Telemetry Pipeline, or step back to the capstone hub for the connected, six-part version.