Hands-On Labs · Guided Drills

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.

☺ Explain it like I'm 10

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.

🐢Your host for this drill: Timmy the Turtle — the guardrail. Timmy refuses to let a manifest through until a policy has actually checked it, and this drill is the exact moment that refusal stops being theoretical and starts being a real error message in your own terminal.
⚠ Before you start

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 kyverno
deployment "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          58s

Four 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 character
kubectl apply -f mission-control-baseline.yaml
kubectl get cpol mission-control-baseline
NAME                       BACKGROUND   VALIDATE ACTION   READY   AGE
mission-control-baseline   true         Audit             true    4s

Now 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-control
deployment.apps/ground-control created

NAME             READY   UP-TO-DATE   AVAILABLE   AGE
ground-control   1/1     1            1           22s

It's running. Nothing rejected it. Now go find the part of Kyverno that actually noticed:

kubectl get polr -A -o wide
NAMESPACE   NAME                                     PASS   FAIL   WARN   ERROR   SKIP   AGE
analytics   cpol-mission-control-baseline-4f8c2b1a    0      1      0      0       0      31s
◆ Key idea

That 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-baseline
NAME                       BACKGROUND   VALIDATE ACTION   READY   AGE
mission-control-baseline   true         Enforce           true    1m12s

Try to create a second unlabeled Deployment:

kubectl create deployment ground-control-2 --image=nginx:1.27 -n analytics
Error 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.27
kubectl apply -f ground-control-3.yaml
kubectl get polr -n analytics -o wide
deployment.apps/ground-control-3 created

NAMESPACE   NAME                                     PASS   FAIL   WARN   ERROR   SKIP   AGE
analytics   cpol-mission-control-baseline-4f8c2b1a    1      1      0      0       0      3m40s

PASS 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.

Lane 1 — Audit mode Deployment, no crew label CREATE request Admission controller require-crew-label: Audit Object created — nothing stopped it PolicyReport: FAIL row written Lane 2 — Enforce, no exception Deployment, no crew label CREATE request Admission controller require-crew-label: Enforce Rejected — 403, policy's own message no object ever created Lane 3 — Enforce + a matching PolicyException Deployment "batch-runner" no crew label, exception names it Admission controller matches PolicyException, skips rule Admitted — this one Deployment only any other unlabeled one: still rejected

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=120s

Confirm 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 analytics
Error 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 namespace
kubectl apply -f batch-runner-exempt.yaml
kubectl get polex -n platform-exceptions

kubectl create deployment batch-runner --image=apache/airflow:2.9.3 -n analytics
policyexception.kyverno.io/batch-runner-exempt created

NAME                    AGE
batch-runner-exempt     3s

deployment.apps/batch-runner created

It 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 analytics
Error 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/'
⚠ The exception's match block is the whole safety story

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.

🦆 Dot's-eye view

"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."

🐢 Timmy's challenge · going further

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.

🎬 At Mission Control
🦫

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.

🐢 Timmy's checkpoint

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
  1. Because the policy's failureAction was still Audit at that point — Audit mode records a violation in the PolicyReport but never blocks the request that caused it. Admission only refuses a request once failureAction is Enforce.
  2. spec.rules[].validate.failureAction, from Audit to Enforce. That single field is the entire difference between a violation being written down and a violation being refused outright.
  3. 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 PASS count moves while FAIL stays put.
  4. --enablePolicyException=true and --exceptionNamespaces=platform-exceptions. The second flag restricts where a PolicyException resource may even be created, so a tenant cannot self-grant an exemption to their own rule from inside their own namespace, no matter how its match block is written.
  5. 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.
  6. Dropping the names: [batch-runner] line and leaving only namespaces: [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.
0 / 8 steps complete
1Install Kyverno on a throwaway cluster and confirm all four controllers are healthy
Done when: the admission controller rollout succeeds and kubectl get pods -n kyverno shows four pods 1/1 Running.
2Apply mission-control-baseline in Audit mode and create an unlabeled Deployment
Done when: ground-control shows 1/1 Running in analytics despite carrying no crew label.
3Find that violation sitting unblocked in its own PolicyReport
Done when: kubectl get polr -A -o wide shows a FAIL: 1 row for it that never stopped anything.
4Flip failureAction to Enforce and watch a second unlabeled Deployment get refused
Done when: the create attempt is rejected outright, naming require-crew-label and your own message text.
5Prove the rule isn't blocking everything by applying a correctly labeled Deployment
Done when: it's created cleanly, and the PolicyReport's PASS count rises without FAIL moving.
6Turn on PolicyExceptions deliberately, scoped to one exception namespace
Done when: --enablePolicyException=true and --exceptionNamespaces=platform-exceptions both show up in the running container's own args.
7Watch batch-runner get rejected once, before any exception exists for it
Done when: it fails with the identical require-crew-label message, proving no favoritism happened.
8Write the scoped PolicyException, then prove it stayed scoped
Done when: batch-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.