Admission control is the API server's last word. Policy engines are well-organized admission webhooks plus reporting. The lab runs three engines deliberately: the same rule in three dialects is the fastest way to learn what is engine-specific and what is admission-generic.

needsmake up sec

Orientation

competency 5.4 · policy engines and admission controllers

Get the request pipeline exact and most of this section is corollaries.

request ──▶ authn ──▶ authz (RBAC) ──▶ mutating admission ──▶ schema validation ──▶ validating admission ──▶ etcd
                                               │                                             │
                                    LimitRanger, sidecar and                  PSS, ValidatingAdmissionPolicy,
                                    label injection, Kyverno                  Kyverno, Gatekeeper webhooks
                                    MutatingPolicy                            (all see the mutated object)

Run a pod through it. The interesting cases are the ones where a mutation quietly satisfies a validation:

Three consequences to carry
  1. Validating policies see the object after mutation, so a mutation can satisfy a validation the user never wrote (that is the LimitRange trap below).
  2. Admission applies at write time only. Tightening a policy never touches existing objects; you need an audit/report mechanism for those.
  3. Every webhook is an availability dependency of the API server, and failurePolicy chooses which way you fail.

The three dialects

Kyverno · Gatekeeper · ValidatingAdmissionPolicy
AspectKyvernoGatekeeper (OPA)ValidatingAdmissionPolicy
LanguageYAML + CELRegoCEL
ObjectsClusterPolicy/Policy (classic), and ValidatingPolicy/MutatingPolicy/ImageValidatingPolicy (newer, CEL)ConstraintTemplate → generated CRD → ConstraintValidatingAdmissionPolicy + …Binding
Can mutateyesassign mutations (separate CRDs)a Mutating counterpart exists in newer versions
Can generateyes (create resources on events)nono
ReportingPolicyReport CRsviolation counts on the Constraint, via audit sweepsnone built in
Runs asa webhook (a Deployment you must keep alive)a webhookin-process in the API server
Enforcement dialclassic: spec.validationFailureAction: Audit|Enforce · newer: spec.validationActions: [Audit|Deny|Warn]enforcementAction: deny|warn|dryrunvalidationActions on the binding

Kyverno speaks two dialects, and you need both. The classic one is ClusterPolicy/Policy: a list of typed rules (validate, mutate, generate, verifyImages) with match/exclude blocks and a JMESPath-flavored pattern language. It switches between reporting and enforcement via spec.validationFailureAction: Audit|Enforce. It is fully supported and it is what most installed Kyverno runs. The newer dialect (ValidatingPolicy, MutatingPolicy, ImageValidatingPolicy, CEL expressions, spec.validationActions: [Audit|Deny|Warn]) arrived in Kyverno 1.14/1.15 and is what this lab installs. You do not get to choose which one the exam's cluster has, so recognize both, and let kubectl api-resources | grep kyverno plus kubectl explain settle which is in front of you. The lab's examples/kyverno/ pair is the newer form: require-resources.yaml validates that every container declares requests (in Audit mode; flip to Deny to enforce), and add-tenant-label.yaml mutates a cost-centre label onto Deployments at admission.

Gatekeeper is two-step by design: a ConstraintTemplate defines a parameterized policy in Rego and generates a CRD; a Constraint is an instance of that CRD binding it to resources with parameters. That indirection is what makes a library of reusable policies possible, and the gatekeeper-library repo is that library. This is also where the exam's "OPA" lives in practice: Rego inside templates.

ValidatingAdmissionPolicy is native: CEL, no controller to install, no webhook to keep alive, paired with a Binding that says which namespaces and what action. Worth one rep because it is what the other two increasingly compile down to, and because a task could hand it to you.

Audit → report → fix → enforce

The rollout choreography is a better exam answer than "apply the policy": ship in Audit, read the reports to find every existing offender, fix them (or exempt them explicitly), then flip to Deny. Kyverno's PolicyReports and Gatekeeper's audit violations exist for exactly that middle step, and the same staged posture appears in PSS (5.3) as warn/audit before enforce.

Kyverno by version and by field

which API you are looking at, and what each knob does

Which Kyverno is in front of you

KindAPISinceStatus
ValidatingPolicy, ImageValidatingPolicypolicies.kyverno.io/v11.14 (Apr 2025)stable since 1.18 (Apr 2026); v1alpha1 deprecated
MutatingPolicy, GeneratingPolicy, DeletingPolicypolicies.kyverno.io/v11.15 (Jul 2025)stable since 1.18
Namespaced* variantspolicies.kyverno.io/v1with the abovesame policy, scoped to one namespace
PolicyExceptionpolicies.kyverno.io/v11.14stable since 1.19; the kyverno.io one is deprecated
ClusterPolicy / Policy, CleanupPolicykyverno.io/v1, kyverno.io/v2the classic APIdeprecation marked 1.17 (Feb 2026), bug fixes only 1.18, deprecated 1.19 (Aug 2026), removal planned 1.20 (about Nov 2026)

From 1.19 creating a classic policy returns an admission warning and increments kyverno_deprecated_api_requests_total; kubectl api-resources | grep kyverno and the warnings tell you which generation the exam cluster runs. Both still work in 2026, and an exam task may hand you either; recognize the fields of each.

CEL-based policy fields

  • spec.matchConstraints (resourceRules[].apiGroups/apiVersions/operations/resources, plus namespaceSelector/objectSelector) and spec.matchConditions (CEL pre-filters) are copied from the Kubernetes VAP API. spec.variables name CEL expressions once; spec.validations[] hold expression, message or messageExpression.
  • spec.validationActions: Deny, Audit, Warn (a list; the lab flips ["Audit"] to ["Deny"]). spec.failurePolicy Fail (default) or Ignore covers evaluation errors and webhook timeouts.
  • spec.evaluation: admission.enabled and background.enabled switch the two engines independently (a policy with admission off is report-only); mode: JSON lets the same policy check a Terraform plan or any payload with the CLI.
  • spec.webhookConfiguration.timeoutSeconds: default 10, range 1-30; Kyverno writes it into the generated ValidatingWebhookConfiguration.
  • spec.autogen: podControllers.controllers: [deployments, statefulsets, ...] rewrites a Pod rule so it also matches templates at admission (the answer to "the policy only fires on pods, not on the Deployment"); validatingAdmissionPolicy.enabled: true compiles the policy into a native VAP so the API server enforces it in-process. The two are mutually exclusive; with pod-controller autogen on, Kyverno skips VAP generation and says so in the policy status.
  • MutatingPolicy adds mutations[] with patchType: ApplyConfiguration (a server-side-apply style partial object built by CEL) or JSONPatch, plus evaluation.mutateExisting.enabled for retrofitting objects that already exist. GeneratingPolicy uses generate[].expression: generator.Apply(namespace, [objects]) or YAML template blocks, with evaluation.synchronize, generateExisting and orphanDownstreamOnPolicyDelete. DeletingPolicy has a cron schedule, conditions and deletionPropagationPolicy.

Classic ClusterPolicy fields, still worth reading

A rule is match.any[]/exclude.any[] blocks of resources.kinds, namespaces, selector, subjects, then one of validate (pattern, anyPattern, deny.conditions, cel.expressions, podSecurity), mutate (patchStrategicMerge, patchesJson6902, targets for existing objects), generate (data or clone, synchronize) or verifyImages. Enforcement is validate.failureAction: Audit|Enforce per rule (the older spec.validationFailureAction and spec.webhookTimeoutSeconds, spec.failurePolicy are deprecated since 1.13 and the default action is Audit, so a copied sample policy never blocks anything until you change it). spec.background: true (default) drives report generation for existing objects. Pattern anchors: =(field) conditional, +(field) add if missing, X(field) negation, <(field) global, wildcards "*" and "?*".

Exceptions, reports, CLI, configuration

  • PolicyException (policies.kyverno.io/v1): policyRefs[].name/kind plus matchConditions in CEL; works in admission and background and shows up in the report as a skip with the exception's name. Exceptions are off until the controllers run with --enablePolicyException=true and --exceptionNamespace=<ns> (Helm values of the same names); a task that says "exempt this workload" fails silently if that flag is off.
  • Reports. PolicyReport (namespaced) and ClusterPolicyReport in wgpolicyk8s.io/v1alpha2, one per resource, with results[].result in pass|fail|warn|error|skip and a summary. Generated by admission events and by the background scan in the reports controller; --allowedResults=fail (1.17+) stores only failures to keep etcd small. Enforced (Deny) failures are not reported because the object never existed.
  • CLI. kyverno apply policy.yaml --resource pod.yaml (or --cluster) prints pass/fail per rule; kyverno test . runs a kyverno-test.yaml declaring policies, resources and expected results[].result per rule and resource, which is how policies get unit tests in CI; kyverno apply ... -o out/ shows mutated output. The CLI warns on classic kinds and --warnings-as-errors fails the run.
  • Configuration. The kyverno ConfigMap's resourceFilters ([Kind,namespace,name] triples, wildcards allowed) exclude objects from admission; excludeGroups defaults to system:serviceaccounts:kube-system,system:nodes and excludeUsernames to !system:kube-scheduler. Kyverno's own namespace is excluded by default and kube-system is not. The webhooks that matter are kyverno-resource-validating-webhook-cfg and kyverno-resource-mutating-webhook-cfg; deleting them is the documented emergency step when every write times out because the admission controller is down, and Kyverno recreates them when it recovers. --forceFailurePolicyIgnore flips every generated webhook to Ignore.

Gatekeeper and the native policies, field by field

constraints, mutation, audit, VAP and MAP, the decision table

Gatekeeper

  • ConstraintTemplate (templates.gatekeeper.sh/v1): spec.crd.spec.names.kind becomes the Constraint kind (K8sRequiredLabels), spec.crd.spec.validation.openAPIV3Schema types the parameters, and spec.targets[].target: admission.k8s.gatekeeper.sh carries the code: rego with a violation[{"msg": msg, "details": {...}}] rule over input.review.object and input.parameters, or code[].engine: K8sNativeValidation with CEL validations (and generateVAP: true to have Gatekeeper emit a real ValidatingAdmissionPolicy). When both engines are present, CEL wins and Rego is ignored.
  • Constraint (constraints.gatekeeper.sh/v1beta1, kind from the template): spec.match with kinds[].apiGroups/kinds, scope: Cluster|Namespaced|*, namespaces/excludedNamespaces (prefix globs like kube-*), labelSelector, namespaceSelector; spec.parameters; spec.enforcementAction: deny|warn|dryrun (default deny) or scoped with scopedEnforcementActions[] naming an action per enforcement point (validation.gatekeeper.sh the webhook, audit.gatekeeper.sh, gator.gatekeeper.sh shift-left, vap.k8s.io). status.totalViolations and status.violations[] (capped at 20 by --constraint-violations-limit) are filled by the audit loop every --audit-interval (60s).
  • Mutation (mutations.gatekeeper.sh/v1): Assign (any field under spec, location: "spec.containers[name: *].imagePullPolicy", parameters.assign.value, optional pathTests with MustExist/MustNotExist), AssignMetadata (labels and annotations only, can copy fromMetadata namespace or name), ModifySet (add to or prune a list), AssignImage (assignDomain, assignPath, assignTag). All but AssignMetadata need applyTo with explicit groups/kinds/versions (no globs). Mutation runs only on CREATE and UPDATE and only if the mutating webhook was enabled at install (--operation=mutation-webhook).
  • Referential and expansion. Rego that needs other objects (data.inventory) requires those kinds synced with a SyncSet (or Config.spec.sync.syncOnly). An ExpansionTemplate makes pod policies apply to Deployments by expanding spec.template into a pretend Pod at admission. External data: a Provider (externaldata.gatekeeper.sh) points at an in-cluster service, and templates call external_data({"provider": ..., "keys": [...]}); Ratify and the cosign provider are the signature-verification uses.
  • Operations. The webhook is installed with failurePolicy: Ignore by default (fail open); switching to Fail is a deliberate step and the docs pair it with a timeoutSeconds of a few seconds and the admission.gatekeeper.sh/ignore namespace label for exemptions. gator test -f manifests/ -f policies/ evaluates objects offline and exits 1 on violations; gator verify runs a Suite of test cases. Warn output appears in kubectl as Warning: [constraint-name] message.

Kubernetes native policies

FieldValidatingAdmissionPolicy (GA 1.30, admissionregistration.k8s.io/v1)MutatingAdmissionPolicy (GA 1.36)
matchConstraintsresourceRules[] (groups, versions, operations, resources), namespaceSelector, objectSelector, matchPolicy
matchConditionsCEL pre-filters; all must be true or the policy is skipped (a failing one obeys failurePolicy)
variablesnamed CEL expressions reusable as variables.x
validations[] / mutations[]expression, message, messageExpression, reasonpatchType: ApplyConfiguration (Object{...} partial) or JSONPatch (array of ops); reinvocationPolicy: Never|IfNeeded
auditAnnotationskey + valueExpression, written into the audit eventnone
paramKind + binding paramRefparameters from any object (a ConfigMap, a CRD); paramRef.parameterNotFoundAction: Allow|Deny is required
binding.validationActions[Deny], [Warn], [Audit] or [Warn, Audit]; Deny and Warn together are rejectedn/a
CEL variablesobject (null on DELETE), oldObject (null on CREATE), request, params, namespaceObject, authorizer
failurePolicyFail (default) or Ignore, on the policy, covering errors and cost-budget exhaustion

A denial reads ValidatingAdmissionPolicy 'x' with binding 'y' denied request: <message>. Type errors are surfaced in the policy's status.typeChecking rather than at apply time, so a policy with a typo in a field path is accepted and then either denies everything or nothing, depending on failurePolicy. Read the status after creating one.

Webhook mechanics every engine shares

In a ValidatingWebhookConfiguration or MutatingWebhookConfiguration: rules[] (operations, groups, versions, resources, scope), clientConfig (a Service plus caBundle, or a URL), failurePolicy (Fail is the API default; Gatekeeper installs Ignore, Kyverno Fail), timeoutSeconds (default 10, 1-30), sideEffects (None or NoneOnDryRun for the webhook to be called on --dry-run=server), matchConditions (CEL, since 1.30 GA), namespaceSelector/objectSelector, reinvocationPolicy: IfNeeded on mutating webhooks so a later mutation is re-checked by earlier ones, and matchPolicy: Equivalent so a rule on apps/v1 also catches apps/v1beta1 requests. Mutating webhooks run before validating ones, in an order you do not control; validating ones see the mutated object.

Which engine

NeedPickBecause
a handful of invariants, no extra component allowedValidatingAdmissionPolicy (+ MutatingAdmissionPolicy for defaults)in-process, no webhook to keep alive, GA on 1.36
generate resources (default NetworkPolicy, quota, RoleBinding per namespace)Kyverno GeneratingPolicythe only engine of the three that creates objects and keeps them in sync
image signature or attestation checksKyverno ImageValidatingPolicy (built in) or Gatekeeper + external data provider (Ratify)needs registry access; native policies cannot fetch anything
a library of reusable, parameterized policies with typed parameters and an audit of existing objectsGatekeeper ConstraintTemplate + Constraintsthe template/instance split and gatekeeper-library; audit violations on the Constraint status
policy that reads other cluster objectsKyverno (context, apiCall, GlobalContextEntry) or Gatekeeper with SyncSetVAP can read only params and namespaceObject
report-then-enforce rollout with per-workload exceptionsKyverno (Audit, PolicyReport, PolicyException) or Gatekeeper (dryrun/warn, audit)VAP's Audit action only writes audit annotations; no report objects
the same rule enforced in CI before the clusterKyverno CLI (kyverno test, JSON mode) or gator testboth evaluate manifests offline; VAP has no CLI
How this gets tested

Kyverno and Gatekeeper appear in separate tasks, so the choice is usually made for you; the skill is writing the right fields in the dialect named. In Kyverno, check validationActions/failureAction before anything else, because samples default to audit. In Gatekeeper, check enforcementAction and that the Constraint's kinds match the object you are testing. For both, prove the denial with the apply error text and the report or violation count, and remember the tenant-namespace LimitRange trap from the exercises.

Operating policy safely

failurePolicy, exemptions, and the blast radius
  • failurePolicy. Fail means an unreachable webhook blocks the write: secure, and capable of freezing the entire cluster if the policy pods die. Ignore means writes proceed unchecked: available, and a bypass window during an outage. There is no universally correct answer, only a deliberate choice. Bound the risk with timeoutSeconds and a well-scoped namespaceSelector.
  • Scope your webhooks. rules (which resources and operations), namespaceSelector and objectSelector decide what is intercepted. Exempting kube-system is near-universal, and it is itself a governance decision: it means a cluster-admin path exists that your policies do not see.
  • Latency. Every intercepted write pays a round trip. Broad wildcard rules on */* are how a policy engine becomes a cluster-wide performance problem.
  • Order. Mutating webhooks run in an order you do not fully control and may run more than once (re-invocation policy), so mutations must be idempotent.
  • Where the message goes. A denial arrives in the apply error, verbatim. Write policy messages a stranger can act on: what is wrong, and what to change.
Diagnosis, one command each
kubectl get validatingwebhookconfigurations,mutatingwebhookconfigurations
kubectl get policyreports -A                    # kyverno findings, governance-as-data
kubectl get constraints                         # gatekeeper, with violation counts
kubectl -n kyverno logs deploy/kyverno-admission-controller | tail -50
outputcaptured 2026-08-26
$ kubectl get validatingwebhookconfigurations,mutatingwebhookconfigurations
NAME                                                                                                        WEBHOOKS   AGE
validatingwebhookconfiguration.admissionregistration.k8s.io/cnpg-validating-webhook-configuration           5          52m
validatingwebhookconfiguration.admissionregistration.k8s.io/config.webhook.pipeline.tekton.dev              1          55m
validatingwebhookconfiguration.admissionregistration.k8s.io/config.webhook.triggers.tekton.dev              1          55m
validatingwebhookconfiguration.admissionregistration.k8s.io/crossplane-no-usages                            1          53m
validatingwebhookconfiguration.admissionregistration.k8s.io/externalsecret-validate                         1          39m
validatingwebhookconfiguration.admissionregistration.k8s.io/gatekeeper-validating-webhook-configuration     2          41m
validatingwebhookconfiguration.admissionregistration.k8s.io/kyverno-cel-exception-validating-webhook-cfg    1          41m
validatingwebhookconfiguration.admissionregistration.k8s.io/kyverno-cleanup-validating-webhook-cfg          1          42m
validatingwebhookconfiguration.admissionregistration.k8s.io/kyverno-exception-validating-webhook-cfg        1          41m
validatingwebhookconfiguration.admissionregistration.k8s.io/kyverno-global-context-validating-webhook-cfg   1          41m
validatingwebhookconfiguration.admissionregistration.k8s.io/kyverno-policy-validating-webhook-cfg           1          41m
validatingwebhookconfiguration.admissionregistration.k8s.io/kyverno-resource-validating-webhook-cfg         1          41m
validatingwebhookconfiguration.admissionregistration.k8s.io/kyverno-ttl-validating-webhook-cfg              1          42m
validatingwebhookconfiguration.admissionregistration.k8s.io/opentelemetry-operator-validation               4          46m
validatingwebhookconfiguration.admissionregistration.k8s.io/prometheus-kube-prometheus-admission            2          50m
validatingwebhookconfiguration.admissionregistration.k8s.io/secretstore-validate                            2          39m
validatingwebhookconfiguration.admissionregistration.k8s.io/spire-spire-controller-manager-webhook          2          37m
validatingwebhookconfiguration.admissionregistration.k8s.io/validation.webhook.pipeline.tekton.dev          1          55m
validatingwebhookconfiguration.admissionregistration.k8s.io/validation.webhook.triggers.tekton.dev          1          55m

NAME                                                                                                  WEBHOOKS   AGE
mutatingwebhookconfiguration.admissionregistration.k8s.io/cnpg-mutating-webhook-configuration         4          52m
mutatingwebhookconfiguration.admissionregistration.k8s.io/gatekeeper-mutating-webhook-configuration   1          41m
mutatingwebhookconfiguration.admissionregistration.k8s.io/kyverno-policy-mutating-webhook-cfg         1          41m
mutatingwebhookconfiguration.admissionregistration.k8s.io/kyverno-resource-mutating-webhook-cfg       1          41m
mutatingwebhookconfiguration.admissionregistration.k8s.io/kyverno-verify-mutating-webhook-cfg         1          41m
mutatingwebhookconfiguration.admissionregistration.k8s.io/opentelemetry-operator-mutation             3          46m
mutatingwebhookconfiguration.admissionregistration.k8s.io/prometheus-kube-prometheus-admission        1          50m
mutatingwebhookconfiguration.admissionregistration.k8s.io/vpa-webhook-config                          1          59m
mutatingwebhookconfiguration.admissionregistration.k8s.io/webhook.pipeline.tekton.dev                 1          55m
mutatingwebhookconfiguration.admissionregistration.k8s.io/webhook.triggers.tekton.dev                 1          55m
$ kubectl get policyreports -A                    # kyverno findings, governance-as-data
NAMESPACE                       NAME                                   KIND          NAME                                                         PASS   FAIL   WARN   ERROR   SKIP   AGE
argo-rollouts                   1086be52-6ae4-473e-adc9-539fecd83956   Pod           argo-rollouts-dashboard-768976b9f-pc4p7                      0      1      0      0       0      37m
argo-rollouts                   2b7dcfb8-31f3-4310-bc83-2cdf44fe01da   Deployment    argo-rollouts-dashboard                                      0      2      0      0       0      37m
argo-rollouts                   62bcfbc0-50de-47d1-8d4c-03cea8a7ada1   Pod           argo-rollouts-6c7457465f-ntvlt                               0      1      0      0       0      37m
argo-rollouts                   67550dfb-4b01-4853-8444-129e737f9de8   Pod           argo-rollouts-6c7457465f-mdx8c                               0      1      0      0       0      37m
argo-rollouts                   73977456-456f-498a-83b2-212c09d728b5   ReplicaSet    argo-rollouts-dashboard-768976b9f                            0      1      0      0       0      36m
argo-rollouts                   b1a56daf-7c4f-4e69-9006-6f4179ffe6a9   Deployment    argo-rollouts                                                0      2      0      0       0      37m
argo-rollouts                   b4f220d3-9df7-4917-930d-8dc71c5340a3   ReplicaSet    argo-rollouts-6c7457465f                                     0      1      0      0       0      36m
argo                            78dd4a7d-eae4-457e-9225-2944d01b88cd   Deployment    argo-workflows-server                                        0      2      0      0       0      37m
argo                            934e7f1b-532a-4feb-b8dd-455a293461f5   Pod           argo-workflows-server-68f86c79dc-s9xbn                       0      1      0      0       0      37m
argo                            b8bb7d41-6271-4f35-8a11-7ab70a5c9f0f   Deployment    argo-workflows-workflow-controller                           0      2      0      0       0      37m
argo                            e6b28c00-42d8-4682-b002-104ccee47f45   Pod           argo-workflows-workflow-controller-58b6c468bd-kgcpk          0      1      0      0       0      37m
argo                            f136c264-4b50-4677-a421-5e0efc391872   ReplicaSet    argo-workflows-server-68f86c79dc                             0      1      0      0       0      36m
argo                            fa9fb582-c591-4921-bb9d-6ff429152cbd   ReplicaSet    argo-workflows-workflow-controller-58b6c468bd                0      1      0      0       0      36m
argocd                          003a3a4b-490a-4c3f-b0d9-dc75f07e3f64   Pod           argocd-server-5dfccb7d6b-gnwnl                               0      1      0      0       0      37m
argocd                          4688cafe-d911-4b2b-8bb9-c3748d513b4e   Deployment    argocd-server                                                0      2      0      0       0      37m
argocd                          4f081113-6567-4441-b698-326bd9b0ef60   ReplicaSet    argocd-applicationset-controller-54db99f499                  0      1      0      0       0      36m
argocd                          6060093b-3490-4e57-a3dd-a313b25daddf   Pod           argocd-application-controller-0                              0      1      0      0       0      37m
argocd                          70b2a469-5782-4484-b1b2-91d3cc99c945   ReplicaSet    argocd-redis-546d94bfb4                                      0      1      0      0       0      36m
argocd                          8302c045-638c-4b05-8797-1bb9639e7a67   Deployment    argocd-repo-server                                           0      2      0      0       0      37m
$ kubectl get constraints                         # gatekeeper, with violation counts
error: the server doesn't have a resource type "constraints"
$ kubectl -n kyverno logs deploy/kyverno-admission-controller | tail -50
Defaulted container "kyverno" out of: kyverno, kyverno-pre (init)
2026-08-27T02:02:23Z INF sigs.k8s.io/controller-runtime@v0.24.1/pkg/internal/controller/controller.go:305 > Starting workers controller=mutatingpolicy controllerGroup=policies.kyverno.io controllerKind=MutatingPolicy v=0 worker count=1
2026-08-27T02:02:23Z INF sigs.k8s.io/controller-runtime@v0.24.1/pkg/internal/controller/controller.go:302 > Starting Controller controller=namespacedvalidatingpolicy controllerGroup=policies.kyverno.io controllerKind=NamespacedValidatingPolicy v=0
2026-08-27T02:02:23Z INF sigs.k8s.io/controller-runtime@v0.24.1/pkg/internal/controller/controller.go:302 > Starting Controller controller=imagevalidatingpolicy controllerGroup=policies.kyverno.io controllerKind=ImageValidatingPolicy v=0
2026-08-27T02:02:23Z INF sigs.k8s.io/controller-runtime@v0.24.1/pkg/internal/controller/controller.go:302 > Starting Controller controller=namespacedmutatingpolicy controllerGroup=policies.kyverno.io controllerKind=NamespacedMutatingPolicy v=0
2026-08-27T02:02:23Z INF sigs.k8s.io/controller-runtime@v0.24.1/pkg/internal/controller/controller.go:305 > Starting workers controller=namespacedvalidatingpolicy controllerGroup=policies.kyverno.io controllerKind=NamespacedValidatingPolicy v=0 worker count=1
2026-08-27T02:02:23Z INF sigs.k8s.io/controller-runtime@v0.24.1/pkg/internal/controller/controller.go:305 > Starting workers controller=namespacedmutatingpolicy controllerGroup=policies.kyverno.io controllerKind=NamespacedMutatingPolicy v=0 worker count=1
2026-08-27T02:02:23Z INF sigs.k8s.io/controller-runtime@v0.24.1/pkg/internal/controller/controller.go:305 > Starting workers controller=imagevalidatingpolicy controllerGroup=policies.kyverno.io controllerKind=ImageValidatingPolicy v=0 worker count=1
2026-08-27T02:02:23Z INF sigs.k8s.io/controller-runtime@v0.24.1/pkg/internal/controller/controller.go:302 > Starting Controller controller=namespacedimagevalidatingpolicy controllerGroup=policies.kyverno.io controllerKind=NamespacedImageValidatingPolicy v=0
2026-08-27T02:02:23Z INF sigs.k8s.io/controller-runtime@v0.24.1/pkg/internal/controller/controller.go:305 > Starting workers controller=namespacedimagevalidatingpolicy controllerGroup=policies.kyverno.io controllerKind=NamespacedImageValidatingPolicy v=0 worker count=1
2026-08-27T02:02:23Z TRC k8s.io/client-go@v0.36.3/tools/cache/reflector.go:507 > Caches populated logger=klog reflector=k8s.io/client-go@v0.36.3/tools/cache/reflector.go:343 type=*v1beta1.PolicyException v=2
2026-08-27T02:02:23Z INF k8s.io/client-go@v0.36.3/tools/leaderelection/leaderelection.go:258 > Attempting to acquire leader lease... lock=kyverno/kyverno logger=klog v=0
2026-08-27T02:02:23Z TRC github.com/kyverno/kyverno/cmd/internal/controller.go:32 > starting controller logger=setup name=kyverno-events v=2 workers=3
2026-08-27T02:02:23Z TRC github.com/kyverno/kyverno/cmd/internal/controller.go:32 > starting controller logger=setup name=global-context v=2 workers=1
2026-08-27T02:02:23Z TRC github.com/kyverno/kyverno/pkg/event/controller.go:125 > start logger=EventGenerator v=2
2026-08-27T02:02:23Z TRC github.com/kyverno/kyverno/cmd/internal/controller.go:32 > starting controller logger=setup/controllers name=policycache-controller v=2 workers=3
2026-08-27T02:02:23Z INF k8s.io/client-go@v0.36.3/tools/leaderelection/leaderelection.go:272 > Successfully acquired lease lock=kyverno/kyverno logger=klog v=0
2026-08-27T02:02:23Z TRC github.com/kyverno/kyverno/pkg/leaderelection/leaderelection.go:82 > started leading id=kyverno-admission-controller-5b78bf8c6b-sxsh4 logger=setup/leader-election v=2
2026-08-27T02:02:23Z INF github.com/kyverno/kyverno/cmd/kyverno/main.go:320 > Initializing MutatingAdmissionPolicy informers for v1 v=0
2026-08-27T02:02:23Z TRC k8s.io/client-go@v0.36.3/tools/cache/reflector.go:507 > Caches populated logger=klog reflector=k8s.io/client-go@v0.36.3/tools/cache/reflector.go:343 type=*v1beta1.NamespacedValidatingPolicy v=2
2026-08-27T02:02:23Z TRC k8s.io/client-go@v0.36.3/tools/cache/reflector.go:507 > Caches populated logger=klog reflector=k8s.io/client-go@v0.36.3/tools/cache/reflector.go:343 type=*v1.Lease v=2
2026-08-27T02:02:23Z TRC k8s.io/client-go@v0.36.3/tools/cache/reflector.go:507 > Caches populated logger=klog reflector=k8s.io/client-go@v0.36.3/tools/cache/reflector.go:343 type=*v1.MutatingAdmissionPolicyBinding v=2
2026-08-27T02:02:23Z TRC k8s.io/client-go@v0.36.3/tools/cache/reflector.go:507 > Caches populated logger=klog reflector=k8s.io/client-go@v0.36.3/tools/cache/reflector.go:343 type=*v1beta1.ImageValidatingPolicy v=2
2026-08-27T02:02:23Z TRC k8s.io/client-go@v0.36.3/tools/cache/reflector.go:507 > Caches populated logger=klog reflector=k8s.io/client-go@v0.36.3/tools/cache/reflector.go:343 type=*v2.PolicyException v=2
2026-08-27T02:02:23Z TRC k8s.io/client-go@v0.36.3/tools/cache/reflector.go:507 > Caches populated logger=klog reflector=k8s.io/client-go@v0.36.3/tools/cache/reflector.go:343 type=*v1.ValidatingAdmissionPolicyBinding v=2
2026-08-27T02:02:23Z TRC k8s.io/client-go@v0.36.3/tools/cache/reflector.go:507 > Caches populated logger=klog reflector=k8s.io/client-go@v0.36.3/tools/cache/reflector.go:343 type=*v1.MutatingAdmissionPolicy v=2
… (rest of the tail omitted)

A cluster that rejects everything mysteriously usually has a policy engine's webhook with a dead backend; the first command finds it, and failurePolicy explains it.

Exercises

tick the dot when its check passes

One trap to dodge first: the tenant namespaces cannot demonstrate this policy at all. Their LimitRanges inject requests, and LimitRanger is a built-in mutating admission plugin that runs before validating webhooks. By the time Kyverno sees a team-a or team-b pod, it already has requests. So drill in a namespace with no LimitRange:

kubectl create ns policy-test
kubectl -n policy-test run naked --image=busybox:1.37 --restart=Never -- sleep 60   # succeeds: Audit
kubectl get policyreports -n policy-test    # the violation is recorded instead
kubectl patch validatingpolicy require-resource-requests --type=merge \
  -p '{"spec":{"validationActions":["Deny"]}}'
kubectl -n policy-test run naked2 --image=busybox:1.37 --restart=Never -- sleep 60
outputcaptured 2026-08-26
$ kubectl create ns policy-test
namespace/policy-test created
$ kubectl -n policy-test run naked --image=busybox:1.37 --restart=Never -- sleep 60   # succeeds: Audit
pod/naked created
$ kubectl get policyreports -n policy-test    # the violation is recorded instead
NAME                                   KIND   NAME    PASS   FAIL   WARN   ERROR   SKIP   AGE
f60c73a9-33c8-4479-8708-7cbc10ad5c62   Pod    naked   0      1      0      0       0      25s
$ kubectl patch validatingpolicy require-resource-requests --type=merge \
  -p '{"spec":{"validationActions":["Deny"]}}'
validatingpolicy.policies.kyverno.io/require-resource-requests patched
$ kubectl -n policy-test run naked2 --image=busybox:1.37 --restart=Never -- sleep 60
Error from server: admission webhook "vpol.validate.kyverno.svc-fail" denied the request: Policy require-resource-requests failed: every container must set cpu and memory requests
verify: the second run is rejected and the error quotes the policy's own message ("every container must set cpu and memory requests"), while the same pod in team-b still sails through with LimitRange-injected requests. Flip back to ["Audit"] when done.

Create a Deployment in team-b, then read it back: kubectl -n team-b get deploy <name> -o jsonpath='{.metadata.labels.cost-centre}' prints the namespace name, put there at admission by add-cost-centre-label. Verify against a deployment created before the policy existed (none of the lab's have the label).

verify: mutation is admission-time only, and retro-fitting existing objects is a separate Kyverno capability (mutating existing resources) you should know exists without memorizing its current field name.

Same rule, Rego dialect, same LimitRange-free namespace. ConstraintTemplate k8srequireresources whose Rego denies containers missing resource requests, then a constraint targeting Pods in policy-test with enforcementAction: warn first. Gatekeeper's library (open-policy-agent/gatekeeper-library) has a containerrequests template to adapt; adapting library Rego rather than writing from scratch is the honest workflow.

verify: a naked pod triggers a warning on create (visible in the kubectl output), then set deny and confirm rejection; kubectl get k8srequireresources -o yaml shows audit violations counted. You can now articulate the trade: CEL policies read like schemas, Rego like code; Kyverno mutates and generates, Gatekeeper's audit and library are mature.

Express the same rule as a ValidatingAdmissionPolicy + binding (CEL: object.spec.containers.all(c, has(c.resources.requests)), match Pods, bound to policy-test).

verify: rejection with your message, no engine involved. Delete it after, along with the policy-test namespace, so the engines' results stay interpretable.

kubectl get validatingwebhookconfigurations,mutatingwebhookconfigurations and, for one Kyverno entry, read rules (which resources), namespaceSelector (what is exempt) and failurePolicy.

verify: you can answer "if Kyverno's pods all died right now, could anyone still deploy?" from the failurePolicy alone, and say what that choice trades. (Check kube-system in the selector while you are there; policy engines exempting the control plane is itself a governance decision.)

Kyverno has two policy APIs at once: the classic ClusterPolicy and the CEL-based ValidatingPolicy. Which one a cluster prefers changes the answer to every policy task, and the API server tells you.

kubectl api-resources | grep -E 'kyverno'
kubectl -n kyverno get deploy -o jsonpath='{range .items[*]}{.metadata.name} {.spec.template.spec.containers[0].image}{"\n"}{end}'
kubectl apply -f - <<'EOF'
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: { name: legacy-shape }
spec:
  validationFailureAction: Audit
  rules:
    - name: has-team-label
      match:
        any:
          - resources: { kinds: [Pod], namespaces: [default] }
      validate:
        message: "pods should carry a team label"
        pattern:
          metadata:
            labels:
              team: "?*"
EOF
kubectl delete clusterpolicy legacy-shape
outputcaptured 2026-09-12
$ kubectl api-resources | grep -E 'kyverno'
cleanuppolicies                                cleanpol                               kyverno.io/v2                             true         CleanupPolicy
clustercleanuppolicies                         ccleanpol                              kyverno.io/v2                             false        ClusterCleanupPolicy
clusterpolicies                                cpol                                   kyverno.io/v1                             false        ClusterPolicy
globalcontextentries                           gctxentry                              kyverno.io/v2                             false        GlobalContextEntry
policies                                       pol                                    kyverno.io/v1                             true         Policy
policyexceptions                               polex                                  kyverno.io/v2                             true         PolicyException
updaterequests                                 ur                                     kyverno.io/v2                             true         UpdateRequest
deletingpolicies                               dpol                                   policies.kyverno.io/v1                    false        DeletingPolicy
generatingpolicies                             gpol                                   policies.kyverno.io/v1                    false        GeneratingPolicy
imagevalidatingpolicies                        ivpol                                  policies.kyverno.io/v1                    false        ImageValidatingPolicy
mutatingpolicies                               mpol                                   policies.kyverno.io/v1                    false        MutatingPolicy
namespaceddeletingpolicies                     ndpol                                  policies.kyverno.io/v1                    true         NamespacedDeletingPolicy
namespacedgeneratingpolicies                   ngpol                                  policies.kyverno.io/v1                    true         NamespacedGeneratingPolicy
namespacedimagevalidatingpolicies              nivpol                                 policies.kyverno.io/v1                    true         NamespacedImageValidatingPolicy
namespacedmutatingpolicies                     nmpol                                  policies.kyverno.io/v1                    true         NamespacedMutatingPolicy
namespacedvalidatingpolicies                   nvpol                                  policies.kyverno.io/v1                    true         NamespacedValidatingPolicy
policyexceptions                                                                      policies.kyverno.io/v1                    true         PolicyException
validatingpolicies                             vpol                                   policies.kyverno.io/v1                    false        ValidatingPolicy
clusterephemeralreports                        cephr                                  reports.kyverno.io/v1                     false        ClusterEphemeralReport
ephemeralreports                               ephr                                   reports.kyverno.io/v1                     true         EphemeralReport
$ kubectl -n kyverno get deploy -o jsonpath='{range .items[*]}{.metadata.name} {.spec.template.spec.containers[0].image}{"\n"}{end}'
kyverno-admission-controller reg.kyverno.io/kyverno/kyverno:v1.19.1
kyverno-background-controller reg.kyverno.io/kyverno/background-controller:v1.19.1
kyverno-cleanup-controller reg.kyverno.io/kyverno/cleanup-controller:v1.19.1
kyverno-reports-controller reg.kyverno.io/kyverno/reports-controller:v1.19.1
$ kubectl apply -f - <<'EOF'
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: { name: legacy-shape }
spec:
  validationFailureAction: Audit
  rules:
    - name: has-team-label
      match:
        any:
          - resources: { kinds: [Pod], namespaces: [default] }
      validate:
        message: "pods should carry a team label"
        pattern:
          metadata:
            labels:
              team: "?*"
EOF
Warning: kyverno.io/v1 ClusterPolicy is deprecated and will be removed in a future release; migrate to ValidatingPolicy, MutatingPolicy, GeneratingPolicy or ImageValidatingPolicy (policies.kyverno.io), see https://kyverno.io/docs/guides/migration-to-cel/
clusterpolicy.kyverno.io/legacy-shape created
$ kubectl delete clusterpolicy legacy-shape
Warning: kyverno.io/v1 ClusterPolicy is deprecated and will be removed in a future release; migrate to ValidatingPolicy, MutatingPolicy, GeneratingPolicy or ImageValidatingPolicy (policies.kyverno.io), see https://kyverno.io/docs/guides/migration-to-cel/
clusterpolicy.kyverno.io "legacy-shape" deleted
verify: the resource list shows both API groups, and the apply either succeeds silently or prints a deprecation warning naming the replacement. Whichever you get, write down which API the cluster in front of you prefers before you author anything.

A pod-level policy catches the pod, which is created by a ReplicaSet, which means the developer's kubectl apply succeeds and the failure lands in an event nobody reads. Autogen moves the denial to where the human is.

kubectl apply -f - <<'EOF'
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata: { name: require-team-label }
spec:
  # autogen is on by default, so without this the first half is already a Deployment-level deny
  autogen:
    podControllers:
      controllers: ["none"]
  validationActions: [Deny]
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  validations:
    - expression: "has(object.metadata.labels) && has(object.metadata.labels.team)"
      message: "every pod must carry a team label"
EOF
sleep 15
kubectl -n default create deployment unlabelled --image=nginx:1.27-alpine
sleep 20
kubectl -n default get deploy unlabelled -o jsonpath='{.status.conditions}' | jq '.[] | {type, status, reason, message}'
kubectl -n default get events --field-selector reason=FailedCreate | tail -3
kubectl -n default delete deployment unlabelled
kubectl patch validatingpolicy require-team-label --type merge -p '{"spec":{"autogen":{"podControllers":{"controllers":["deployments"]}}}}'
sleep 20
kubectl -n default create deployment unlabelled --image=nginx:1.27-alpine
kubectl -n default delete deployment unlabelled --ignore-not-found
kubectl delete validatingpolicy require-team-label
kubectl get validatingpolicy require-team-label -o jsonpath='{.spec.autogen}{"\n"}'
outputcaptured 2026-09-13
$ kubectl apply -f - <<'EOF'
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata: { name: require-team-label }
spec:
  # autogen is on by default, so without this the first half is already a Deployment-level deny
  autogen:
    podControllers:
      controllers: ["none"]
  validationActions: [Deny]
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  validations:
    - expression: "has(object.metadata.labels) && has(object.metadata.labels.team)"
      message: "every pod must carry a team label"
EOF
validatingpolicy.policies.kyverno.io/require-team-label created
$ sleep 15
$ kubectl -n default create deployment unlabelled --image=nginx:1.27-alpine
deployment.apps/unlabelled created
$ sleep 20
$ kubectl -n default get deploy unlabelled -o jsonpath='{.status.conditions}' | jq '.[] | {type, status, reason, message}'
{
  "type": "Progressing",
  "status": "True",
  "reason": "NewReplicaSetCreated",
  "message": "Created new replica set \"unlabelled-6896b8687b\""
}
{
  "type": "Available",
  "status": "False",
  "reason": "MinimumReplicasUnavailable",
  "message": "Deployment does not have minimum availability."
}
{
  "type": "ReplicaFailure",
  "status": "True",
  "reason": "FailedCreate",
  "message": "admission webhook \"vpol.validate.kyverno.svc-fail\" denied the request: Policy require-team-label failed: every pod must carry a team label"
}
$ kubectl -n default get events --field-selector reason=FailedCreate | tail -3
LAST SEEN   TYPE      REASON         OBJECT                             MESSAGE
10s         Warning   FailedCreate   replicaset/unlabelled-6896b8687b   Error creating: admission webhook "vpol.validate.kyverno.svc-fail" denied the request: Policy require-team-label failed: every pod must carry a team label
$ kubectl -n default delete deployment unlabelled
deployment.apps "unlabelled" deleted from default namespace
$ kubectl patch validatingpolicy require-team-label --type merge -p '{"spec":{"autogen":{"podControllers":{"controllers":["deployments"]}}}}'
validatingpolicy.policies.kyverno.io/require-team-label patched
$ sleep 20
$ kubectl -n default create deployment unlabelled --image=nginx:1.27-alpine
error: failed to create deployment: admission webhook "vpol.validate.kyverno.svc-fail" denied the request: Policy require-team-label failed: every pod must carry a team label
$ kubectl -n default delete deployment unlabelled --ignore-not-found
$ kubectl delete validatingpolicy require-team-label
validatingpolicy.policies.kyverno.io "require-team-label" deleted
$ kubectl get validatingpolicy require-team-label -o jsonpath='{.spec.autogen}{"\n"}'
Error from server (NotFound): validatingpolicies.policies.kyverno.io "require-team-label" not found
verify: without autogen the Deployment is created and its ReplicaSet fails in an event; with autogen the kubectl create itself is refused.

Kyverno can compile a ValidatingPolicy down to a native ValidatingAdmissionPolicy, which then runs in the API server with no webhook at all. Read the generated object; it is the best available lesson in what CEL admission looks like.

kubectl apply -f - <<'EOF'
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata: { name: no-latest }
spec:
  validationActions: [Deny]
  autogen:
    validatingAdmissionPolicy: { enabled: true }
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  validations:
    - expression: "object.spec.containers.all(c, !c.image.endsWith(':latest'))"
      message: "no :latest tags"
EOF
sleep 60
kubectl get validatingadmissionpolicies
kubectl get validatingadmissionpolicybindings
kubectl get validatingadmissionpolicy -o jsonpath='{range .items[*]}{.metadata.name} {.spec.validations[*].expression}{"\n"}{end}'
kubectl get validatingpolicy no-latest -o jsonpath='{.spec.autogen}{"\n"}'
kubectl -n kyverno logs deploy/kyverno-admission-controller --tail=50 | grep -i -m5 'validatingadmissionpolicy' || true
kubectl delete validatingpolicy no-latest
outputcaptured 2026-09-13
$ kubectl apply -f - <<'EOF'
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata: { name: no-latest }
spec:
  validationActions: [Deny]
  autogen:
    validatingAdmissionPolicy: { enabled: true }
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  validations:
    - expression: "object.spec.containers.all(c, !c.image.endsWith(':latest'))"
      message: "no :latest tags"
EOF
validatingpolicy.policies.kyverno.io/no-latest created
$ sleep 60
$ kubectl get validatingadmissionpolicies
NAME                                      VALIDATIONS   PARAMKIND   AGE
safe-upgrades.gateway.networking.k8s.io   2             <unset>     20h
vpol-no-latest                            1             <unset>     60s
$ kubectl get validatingadmissionpolicybindings
NAME                                      POLICYNAME                                PARAMREF   AGE
safe-upgrades.gateway.networking.k8s.io   safe-upgrades.gateway.networking.k8s.io   <unset>    20h
vpol-no-latest-binding                    vpol-no-latest                            <unset>    60s
$ kubectl get validatingadmissionpolicy -o jsonpath='{range .items[*]}{.metadata.name} {.spec.validations[*].expression}{"\n"}{end}'
safe-upgrades.gateway.networking.k8s.io object.spec.group != 'gateway.networking.k8s.io' || oldObject == null || ( has(object.metadata.annotations) && object.metadata.annotations.exists(k, k == 'gateway.networking.k8s.io/channel') && object.metadata.annotations['gateway.networking.k8s.io/channel'] == 'standard' ) || ( oldObject != null && has(oldObject.metadata.annotations) && oldObject.metadata.annotations.exists(k, k == 'gateway.networking.k8s.io/channel') && oldObject.metadata.annotations['gateway.networking.k8s.io/channel'] == 'experimental' ) object.spec.group != 'gateway.networking.k8s.io' || (has(object.metadata.annotations) && object.metadata.annotations.exists(k, k == 'gateway.networking.k8s.io/bundle-version') && ( matches(object.metadata.annotations['gateway.networking.k8s.io/bundle-version'], '-(rc)') || ( !matches(object.metadata.annotations['gateway.networking.k8s.io/bundle-version'], 'v1.[0-5].\\d+') && !matches(object.metadata.annotations['gateway.networking.k8s.io/bundle-version'], 'v0') ) ))
vpol-no-latest object.spec.containers.all(c, !c.image.endsWith(':latest'))
$ kubectl get validatingpolicy no-latest -o jsonpath='{.spec.autogen}{"\n"}'
{"validatingAdmissionPolicy":{"enabled":true}}
$ kubectl -n kyverno logs deploy/kyverno-admission-controller --tail=50 | grep -i -m5 'validatingadmissionpolicy' || true
Defaulted container "kyverno" out of: kyverno, kyverno-pre (init)
$ kubectl delete validatingpolicy no-latest
validatingpolicy.policies.kyverno.io "no-latest" deleted
verify: the stored spec still carries the validatingAdmissionPolicy autogen block you asked for, and a ValidatingAdmissionPolicy and binding named no-latest appear alongside the pre-existing safe-upgrades.gateway.networking.k8s.io, carrying your CEL expression. If the autogen block reads back empty, the field was pruned by the API version you applied and nothing will ever be generated. If it is present and no policy appears, the admission controller log names the reason.

"Just this once" has to be an object, not a conversation. A PolicyException names the policy and the resources it excuses, and the report records a skip rather than a pass, which keeps the exception visible.

kubectl -n kyverno get deploy kyverno-admission-controller -o jsonpath='{.spec.template.spec.containers[0].args}' | jq
kubectl create ns policy-exception --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f - <<'EOF'
apiVersion: policies.kyverno.io/v1
kind: PolicyException
metadata: { name: legacy-job, namespace: policy-exception }
spec:
  policyRefs:
    - name: require-resource-requests
      kind: ValidatingPolicy
  matchConditions:
    - name: only-the-legacy-pod
      expression: "object.metadata.name == 'legacy'"
EOF
kubectl -n default run legacy --image=nginx:1.27-alpine --restart=Never
# the reports controller runs behind admission, so wait for the report rather than guessing a sleep
for i in $(seq 18); do kubectl -n default get policyreport -o json | jq -e '[.items[]|select(.scope.name=="legacy")]|length>0' >/dev/null && break; sleep 5; done; echo "report after ~$((i*5))s"
kubectl -n default get policyreport
kubectl -n default get policyreport -o json | jq -r '.items[] | select(.scope.name=="legacy") | .results[] | "\(.policy) \(.result)"'
kubectl -n default delete pod legacy --ignore-not-found
kubectl -n policy-exception delete policyexceptions.policies.kyverno.io legacy-job
kubectl delete ns policy-exception
outputcaptured 2026-09-13
$ kubectl -n kyverno get deploy kyverno-admission-controller -o jsonpath='{.spec.template.spec.containers[0].args}' | jq
[
  "--caSecretName=kyverno-svc.kyverno.svc.kyverno-tls-ca",
  "--tlsSecretName=kyverno-svc.kyverno.svc.kyverno-tls-pair",
  "--tlsKeyAlgorithm=RSA",
  "--backgroundServiceAccountName=system:serviceaccount:kyverno:kyverno-background-controller",
  "--reportsServiceAccountName=system:serviceaccount:kyverno:kyverno-reports-controller",
  "--servicePort=443",
  "--webhookServerPort=9443",
  "--resyncPeriod=15m",
  "--crdWatcher=false",
  "--disableMetrics=false",
  "--otelConfig=prometheus",
  "--metricsPort=8000",
  "--admissionReports=true",
  "--maxAdmissionReports=1000",
  "--autoUpdateWebhooks=true",
  "--excludeBootstrapResources=false",
  "--enableConfigMapCaching=true",
  "--controllerRuntimeMetricsAddress=:8080",
  "--enableDeferredLoading=true",
  "--dumpPayload=false",
  "--forceFailurePolicyIgnore=false",
  "--generateValidatingAdmissionPolicy=true",
  "--generateMutatingAdmissionPolicy=false",
  "--dumpPatches=false",
  "--maxAPICallResponseLength=2000000",
  "--apiCallTimeout=30s",
  "--maxGlobalContextEntries=0",
  "--loggingFormat=text",
  "--v=2",
  "--omitEvents=PolicyApplied,PolicySkipped",
  "--enablePolicyException=true",
  "--protectManagedResources=false",
  "--allowInsecureRegistry=true",
  "--registryCredentialHelpers=default,google,amazon,azure,github",
  "--enableReporting=validate,mutate,mutateExisting,imageVerify,generate"
]
$ kubectl create ns policy-exception --dry-run=client -o yaml | kubectl apply -f -
namespace/policy-exception created
$ kubectl apply -f - <<'EOF'
apiVersion: policies.kyverno.io/v1
kind: PolicyException
metadata: { name: legacy-job, namespace: policy-exception }
spec:
  policyRefs:
    - name: require-resource-requests
      kind: ValidatingPolicy
  matchConditions:
    - name: only-the-legacy-pod
      expression: "object.metadata.name == 'legacy'"
EOF
policyexception.policies.kyverno.io/legacy-job created
$ kubectl -n default run legacy --image=nginx:1.27-alpine --restart=Never
pod/legacy created
$ # the reports controller runs behind admission, so wait for the report rather than guessing a sleep
$ for i in $(seq 18); do kubectl -n default get policyreport -o json | jq -e '[.items[]|select(.scope.name=="legacy")]|length>0' >/dev/null && break; sleep 5; done; echo "report after ~$((i*5))s"
report after ~25s
$ kubectl -n default get policyreport
NAME                                   KIND   NAME     PASS   FAIL   WARN   ERROR   SKIP   AGE
7a647270-939c-44f6-a2cf-126b21a8210d   Pod    legacy   0      0      0      0       1      1s
$ kubectl -n default get policyreport -o json | jq -r '.items[] | select(.scope.name=="legacy") | .results[] | "\(.policy) \(.result)"'
require-resource-requests skip
$ kubectl -n default delete pod legacy --ignore-not-found
pod "legacy" deleted from default namespace
$ kubectl -n policy-exception delete policyexceptions.policies.kyverno.io legacy-job
policyexception.policies.kyverno.io "legacy-job" deleted from policy-exception namespace
$ kubectl delete ns policy-exception
namespace "policy-exception" deleted
verify: the report row for the pod counts one SKIP and nothing else, and the result line reads require-resource-requests skip: the exception is on the record rather than making the resource disappear from the report. The flags are the other half of the answer, since --enablePolicyException=true is what makes an exception legal at all.

The Kyverno CLI runs policies against manifests in CI, which is where a policy should fail first. A test file asserts the outcome per resource, so a policy change that breaks a team's manifests is caught before it merges.

START=$PWD   # the cd below would otherwise follow you into every later command
mkdir -p "$HOME/.local/bin"; export PATH="$HOME/.local/bin:$PATH"
URL=$(curl -s https://api.github.com/repos/kyverno/kyverno/releases/latest | jq -r '.assets[] | select(.name | startswith("kyverno-cli_") and endswith("_linux_x86_64.tar.gz")) | .browser_download_url'); echo "$URL"
rm -rf /tmp/kyverno-cli && mkdir -p /tmp/kyverno-cli && curl -sL "$URL" | tar xz -C /tmp/kyverno-cli && install /tmp/kyverno-cli/kyverno "$HOME/.local/bin/"
kyverno version | head -2
rm -rf /tmp/kyverno-test && mkdir -p /tmp/kyverno-test && cd /tmp/kyverno-test
cp "$REPO_ROOT"/examples/kyverno/require-resources.yaml policy.yaml
cat > good-pod.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata: { name: good, namespace: default }
spec:
  containers:
    - name: c
      image: nginx:1.27-alpine
      resources:
        requests: { cpu: 25m, memory: 32Mi }
EOF
cat > bad-pod.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata: { name: bad, namespace: default }
spec:
  containers:
    - name: c
      image: nginx:1.27-alpine
EOF
kyverno apply policy.yaml --resource good-pod.yaml --resource bad-pod.yaml
cat > kyverno-test.yaml <<'EOF'
apiVersion: cli.kyverno.io/v1alpha1
kind: Test
metadata: { name: require-resources }
policies: [policy.yaml]
resources: [good-pod.yaml, bad-pod.yaml]
results:
  - policy: require-resource-requests
    resources: [good]
    kind: Pod
    result: pass
  - policy: require-resource-requests
    resources: [bad]
    kind: Pod
    result: fail
EOF
kyverno test .
cd "$START"
outputcaptured 2026-09-13
$ START=$PWD   # the cd below would otherwise follow you into every later command
$ mkdir -p "$HOME/.local/bin"; export PATH="$HOME/.local/bin:$PATH"
$ URL=$(curl -s https://api.github.com/repos/kyverno/kyverno/releases/latest | jq -r '.assets[] | select(.name | startswith("kyverno-cli_") and endswith("_linux_x86_64.tar.gz")) | .browser_download_url'); echo "$URL"
https://github.com/kyverno/kyverno/releases/download/v1.19.1/kyverno-cli_v1.19.1_linux_x86_64.tar.gz
$ rm -rf /tmp/kyverno-cli && mkdir -p /tmp/kyverno-cli && curl -sL "$URL" | tar xz -C /tmp/kyverno-cli && install /tmp/kyverno-cli/kyverno "$HOME/.local/bin/"
$ kyverno version | head -2
Version: 1.19.1
Time: 2026-09-10T05:20:21Z
$ rm -rf /tmp/kyverno-test && mkdir -p /tmp/kyverno-test && cd /tmp/kyverno-test
$ cp "$REPO_ROOT"/examples/kyverno/require-resources.yaml policy.yaml
$ cat > good-pod.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata: { name: good, namespace: default }
spec:
  containers:
    - name: c
      image: nginx:1.27-alpine
      resources:
        requests: { cpu: 25m, memory: 32Mi }
EOF
$ cat > bad-pod.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata: { name: bad, namespace: default }
spec:
  containers:
    - name: c
      image: nginx:1.27-alpine
EOF
$ kyverno apply policy.yaml --resource good-pod.yaml --resource bad-pod.yaml

Applying 1 policy rule(s) to 2 resource(s)...
policy require-resource-requests -> resource default/Pod/bad failed:
1 -  every container must set cpu and memory requests

pass: 1, fail: 1, warn: 0, error: 0, skip: 0 
$ cat > kyverno-test.yaml <<'EOF'
apiVersion: cli.kyverno.io/v1alpha1
kind: Test
metadata: { name: require-resources }
policies: [policy.yaml]
resources: [good-pod.yaml, bad-pod.yaml]
results:
  - policy: require-resource-requests
    resources: [good]
    kind: Pod
    result: pass
  - policy: require-resource-requests
    resources: [bad]
    kind: Pod
    result: fail
EOF
$ kyverno test .
Loading test  ( kyverno-test.yaml ) ...
  Loading values/variables ...
  Loading policies ...
  Loading resources ...
  Loading exceptions ...
  Applying 1 policy to 2 resources with 0 exceptions ...
  Checking results ...

│────│───────────────────────────│──────│─────────────────────│────────│────────│
│ ID │ POLICY                    │ RULE │ RESOURCE            │ RESULT │ REASON │
│────│───────────────────────────│──────│─────────────────────│────────│────────│
│ 1  │ require-resource-requests │      │ v1/Pod/default/good │ Pass   │ Ok     │
│ 2  │ require-resource-requests │      │ v1/Pod/default/bad  │ Pass   │ Ok     │
│────│───────────────────────────│──────│─────────────────────│────────│────────│


Test Summary: 2 tests passed and 0 tests failed
$ cd "$START"
verify: kyverno apply passes one pod and fails the other, and kyverno test reports both assertions met.

Gatekeeper's scoped enforcement lets one constraint behave differently at admission and in audit. Warning the person typing while recording a violation for the report is a realistic rollout strategy, not a compromise.

kubectl apply -f - <<'EOF'
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata: { name: k8srequiredlabels }
spec:
  crd:
    spec:
      names: { kind: K8sRequiredLabels }
      validation:
        openAPIV3Schema:
          type: object
          properties:
            labels: { type: array, items: { type: string } }
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8srequiredlabels
        violation[{"msg": msg}] {
          required := input.parameters.labels[_]
          not input.review.object.metadata.labels[required]
          msg := sprintf("missing required label: %v", [required])
        }
EOF
sleep 20
kubectl apply -f - <<'EOF'
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata: { name: pods-need-owner }
spec:
  enforcementAction: scoped
  scopedEnforcementActions:
    - action: warn
      enforcementPoints: [{ name: validation.gatekeeper.sh }]
    - action: deny
      enforcementPoints: [{ name: audit.gatekeeper.sh }]
  match:
    kinds:
      - { apiGroups: [""], kinds: [Pod] }
    namespaces: [default]
  parameters:
    labels: [owner]
EOF
sleep 30
kubectl -n default run scoped-demo --image=nginx:1.27-alpine --restart=Never
sleep 90
kubectl get k8srequiredlabels pods-need-owner -o jsonpath='{.status.violations}' | jq
kubectl -n default delete pod scoped-demo --ignore-not-found
kubectl delete k8srequiredlabels pods-need-owner
kubectl delete constrainttemplate k8srequiredlabels
outputcaptured 2026-09-12
$ kubectl apply -f - <<'EOF'
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata: { name: k8srequiredlabels }
spec:
  crd:
    spec:
      names: { kind: K8sRequiredLabels }
      validation:
        openAPIV3Schema:
          type: object
          properties:
            labels: { type: array, items: { type: string } }
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8srequiredlabels
        violation[{"msg": msg}] {
          required := input.parameters.labels[_]
          not input.review.object.metadata.labels[required]
          msg := sprintf("missing required label: %v", [required])
        }
EOF
constrainttemplate.templates.gatekeeper.sh/k8srequiredlabels created
$ sleep 20
$ kubectl apply -f - <<'EOF'
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata: { name: pods-need-owner }
spec:
  enforcementAction: scoped
  scopedEnforcementActions:
    - action: warn
      enforcementPoints: [{ name: validation.gatekeeper.sh }]
    - action: deny
      enforcementPoints: [{ name: audit.gatekeeper.sh }]
  match:
    kinds:
      - { apiGroups: [""], kinds: [Pod] }
    namespaces: [default]
  parameters:
    labels: [owner]
EOF
k8srequiredlabels.constraints.gatekeeper.sh/pods-need-owner created
$ sleep 30
$ kubectl -n default run scoped-demo --image=nginx:1.27-alpine --restart=Never
Warning: [pods-need-owner] missing required label: owner
pod/scoped-demo created
$ sleep 90
$ kubectl get k8srequiredlabels pods-need-owner -o jsonpath='{.status.violations}' | jq
[
  {
    "enforcementAction": "scoped",
    "enforcementActions": [
      "deny"
    ],
    "group": "",
    "kind": "Pod",
    "message": "missing required label: owner",
    "name": "signed-86hrq-pod",
    "namespace": "default",
    "version": "v1"
  },
  {
    "enforcementAction": "scoped",
    "enforcementActions": [
      "deny"
    ],
    "group": "",
    "kind": "Pod",
    "message": "missing required label: owner",
    "name": "scoped-demo",
    "namespace": "default",
    "version": "v1"
  }
]
$ kubectl -n default delete pod scoped-demo --ignore-not-found
pod "scoped-demo" deleted from default namespace
$ kubectl delete k8srequiredlabels pods-need-owner
k8srequiredlabels.constraints.gatekeeper.sh "pods-need-owner" deleted
$ kubectl delete constrainttemplate k8srequiredlabels
constrainttemplate.templates.gatekeeper.sh "k8srequiredlabels" deleted
verify: the pod is created with a warning printed by kubectl, and the same constraint's audit status lists it as a violation.

Gatekeeper mutates objects on the way in, and what keeps it from clobbering a value someone set deliberately is the kind you reach for: AssignMetadata fills a label in only when it is absent, where Assign needs a pathTests condition to hold it back. Mutation with neither is how a platform team breaks a team's deployment quietly.

# Assign refuses to touch metadata; labels and annotations are AssignMetadata's job,
# and it only ever fills in a value that is absent, so no pathTests condition is needed
kubectl apply -f - <<'EOF'
apiVersion: mutations.gatekeeper.sh/v1
kind: AssignMetadata
metadata: { name: default-owner-label }
spec:
  match:
    scope: Namespaced
    namespaces: [default]
    kinds:
      - { apiGroups: ["*"], kinds: [Pod] }
  location: "metadata.labels.owner"
  parameters:
    assign:
      value: platform
EOF
sleep 30
kubectl -n default run mutated --image=nginx:1.27-alpine --restart=Never
kubectl -n default get pod mutated -o jsonpath='{.metadata.labels.owner}{"\n"}'
kubectl -n default run untouched --image=nginx:1.27-alpine --restart=Never --labels=owner=team-a
kubectl -n default get pod untouched -o jsonpath='{.metadata.labels.owner}{"\n"}'
kubectl -n default delete pod mutated untouched --ignore-not-found
kubectl delete assignmetadata default-owner-label
outputcaptured 2026-09-13
$ # Assign refuses to touch metadata; labels and annotations are AssignMetadata's job,
$ # and it only ever fills in a value that is absent, so no pathTests condition is needed
$ kubectl apply -f - <<'EOF'
apiVersion: mutations.gatekeeper.sh/v1
kind: AssignMetadata
metadata: { name: default-owner-label }
spec:
  match:
    scope: Namespaced
    namespaces: [default]
    kinds:
      - { apiGroups: ["*"], kinds: [Pod] }
  location: "metadata.labels.owner"
  parameters:
    assign:
      value: platform
EOF
assignmetadata.mutations.gatekeeper.sh/default-owner-label created
$ sleep 30
$ kubectl -n default run mutated --image=nginx:1.27-alpine --restart=Never
pod/mutated created
$ kubectl -n default get pod mutated -o jsonpath='{.metadata.labels.owner}{"\n"}'
platform
$ kubectl -n default run untouched --image=nginx:1.27-alpine --restart=Never --labels=owner=team-a
pod/untouched created
$ kubectl -n default get pod untouched -o jsonpath='{.metadata.labels.owner}{"\n"}'
team-a
$ kubectl -n default delete pod mutated untouched --ignore-not-found
pod "mutated" deleted from default namespace
pod "untouched" deleted from default namespace
$ kubectl delete assignmetadata default-owner-label
assignmetadata.mutations.gatekeeper.sh "default-owner-label" deleted
verify: the pod that said nothing comes back with owner set to platform, and the one that set owner=team-a keeps team-a. Note the kind: Assign refuses to touch metadata at all, so labels and annotations are AssignMetadata's job, and it only ever fills in a value that is absent. That "only if absent" is built into the kind rather than written as a condition, which is the difference between a default and a decree. imagePullPolicy is the wrong field for this drill either way: the API server defaults it before any mutating webhook runs, so a "mutate only when unset" rule on it can never fire.

gator test evaluates Gatekeeper constraints against manifests on a laptop or a build agent. Same policies, same answers, no cluster, which is what makes policy-as-code reviewable.

START=$PWD   # the cd below would otherwise follow you into every later command
mkdir -p "$HOME/.local/bin"; export PATH="$HOME/.local/bin:$PATH"
URL=$(curl -s https://api.github.com/repos/open-policy-agent/gatekeeper/releases/latest | jq -r '.assets[] | select(.name | startswith("gator-") and endswith("-linux-amd64.tar.gz")) | .browser_download_url'); echo "$URL"
rm -rf /tmp/gator-bin && mkdir -p /tmp/gator-bin && curl -sL "$URL" | tar xz -C /tmp/gator-bin && install /tmp/gator-bin/gator "$HOME/.local/bin/"
gator version
rm -rf /tmp/gator-ci && mkdir -p /tmp/gator-ci/policies /tmp/gator-ci/manifests && cd /tmp/gator-ci
cat > policies/template.yaml <<'EOF'
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata: { name: k8sdenylatest }
spec:
  crd:
    spec:
      names: { kind: K8sDenyLatest }
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8sdenylatest
        violation[{"msg": msg}] {
          c := input.review.object.spec.containers[_]
          endswith(c.image, ":latest")
          msg := sprintf("image uses the latest tag: %v", [c.image])
        }
EOF
cat > policies/constraint.yaml <<'EOF'
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sDenyLatest
metadata: { name: no-latest-anywhere }
spec:
  match:
    kinds:
      - { apiGroups: [""], kinds: [Pod] }
EOF
cat > manifests/pods.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata: { name: pinned }
spec:
  containers: [{ name: c, image: nginx:1.27-alpine }]
---
apiVersion: v1
kind: Pod
metadata: { name: floating }
spec:
  containers: [{ name: c, image: nginx:latest }]
EOF
gator test -f manifests/ -f policies/; echo "exit code: $?"
cd "$START"
outputcaptured 2026-09-12
$ START=$PWD   # the cd below would otherwise follow you into every later command
$ mkdir -p "$HOME/.local/bin"; export PATH="$HOME/.local/bin:$PATH"
$ URL=$(curl -s https://api.github.com/repos/open-policy-agent/gatekeeper/releases/latest | jq -r '.assets[] | select(.name | startswith("gator-") and endswith("-linux-amd64.tar.gz")) | .browser_download_url'); echo "$URL"
https://github.com/open-policy-agent/gatekeeper/releases/download/v3.23.1/gator-v3.23.1-linux-amd64.tar.gz
$ rm -rf /tmp/gator-bin && mkdir -p /tmp/gator-bin && curl -sL "$URL" | tar xz -C /tmp/gator-bin && install /tmp/gator-bin/gator "$HOME/.local/bin/"
$ gator version
 ::::::::      :::     :::::::::::  ::::::::  :::::::::
:+:    :+:   :+: :+:       :+:     :+:    :+: :+:    :+:
+:+         +:+   +:+      +:+     +:+    +:+ +:+    +:+
:#:        +#++:++#++:     +#+     +#+    +:+ +#++:++#:
+#+   +#+# +#+     +#+     +#+     +#+    +#+ +#+    +#+
#+#    #+# #+#     #+#     #+#     #+#    #+# #+#    #+#
 ########  ###     ###     ###      ########  ###    ###
gator: gator is a suite of authorship tools for Gatekeeper

GitVersion:    v3.23.1+dirty
GitCommit:     2d0b6ad97cf3a8b8436a00a1f4b07b3a56b92ca0
GitTreeState:  dirty
BuildDate:     2026-08-27T21:26:44
GoVersion:     go1.26.0
Compiler:      gc
Platform:      linux/amd64
$ rm -rf /tmp/gator-ci && mkdir -p /tmp/gator-ci/policies /tmp/gator-ci/manifests && cd /tmp/gator-ci
$ cat > policies/template.yaml <<'EOF'
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata: { name: k8sdenylatest }
spec:
  crd:
    spec:
      names: { kind: K8sDenyLatest }
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8sdenylatest
        violation[{"msg": msg}] {
          c := input.review.object.spec.containers[_]
          endswith(c.image, ":latest")
          msg := sprintf("image uses the latest tag: %v", [c.image])
        }
EOF
$ cat > policies/constraint.yaml <<'EOF'
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sDenyLatest
metadata: { name: no-latest-anywhere }
spec:
  match:
    kinds:
      - { apiGroups: [""], kinds: [Pod] }
EOF
$ cat > manifests/pods.yaml <<'EOF'
apiVersion: v1
kind: Pod
metadata: { name: pinned }
spec:
  containers: [{ name: c, image: nginx:1.27-alpine }]
---
apiVersion: v1
kind: Pod
metadata: { name: floating }
spec:
  containers: [{ name: c, image: nginx:latest }]
EOF
$ gator test -f manifests/ -f policies/; echo "exit code: $?"
v1/Pod floating: ["no-latest-anywhere"] Message: "image uses the latest tag: nginx:latest"
exit code: 1
$ cd "$START"
verify: the floating pod is reported as a violation with your message and the pinned one is not.

A VAP with a typo in a field path does not fail loudly; it fails at evaluation, and what happens then depends entirely on the failure policy. The API server type-checks the expression up front and puts the result in status.

kubectl apply -f - <<'EOF'
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata: { name: typo-policy }
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: ["apps"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["deployments"]
  validations:
    - expression: "object.spec.replicaCount <= 10"
      message: "at most ten replicas"
EOF
sleep 10
kubectl get validatingadmissionpolicy typo-policy -o jsonpath='{.status.typeChecking}' | jq
kubectl apply -f - <<'EOF'
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata: { name: typo-policy-binding }
spec:
  policyName: typo-policy
  validationActions: [Deny]
  matchResources:
    namespaceSelector:
      matchLabels: { kubernetes.io/metadata.name: default }
EOF
sleep 10
kubectl -n default create deployment typo-test --image=nginx:1.27-alpine
kubectl patch validatingadmissionpolicy typo-policy --type merge -p '{"spec":{"failurePolicy":"Ignore"}}'
sleep 10
kubectl -n default create deployment typo-test --image=nginx:1.27-alpine
kubectl -n default delete deployment typo-test --ignore-not-found
kubectl delete validatingadmissionpolicybinding typo-policy-binding
kubectl delete validatingadmissionpolicy typo-policy
outputcaptured 2026-09-12
$ kubectl apply -f - <<'EOF'
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata: { name: typo-policy }
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: ["apps"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["deployments"]
  validations:
    - expression: "object.spec.replicaCount <= 10"
      message: "at most ten replicas"
EOF
validatingadmissionpolicy.admissionregistration.k8s.io/typo-policy created
$ sleep 10
$ kubectl get validatingadmissionpolicy typo-policy -o jsonpath='{.status.typeChecking}' | jq
{
  "expressionWarnings": [
    {
      "fieldRef": "spec.validations[0].expression",
      "warning": "apps/v1, Kind=Deployment: ERROR: <input>:1:12: undefined field 'replicaCount'\n | object.spec.replicaCount <= 10\n | ...........^\n"
    }
  ]
}
$ kubectl apply -f - <<'EOF'
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata: { name: typo-policy-binding }
spec:
  policyName: typo-policy
  validationActions: [Deny]
  matchResources:
    namespaceSelector:
      matchLabels: { kubernetes.io/metadata.name: default }
EOF
validatingadmissionpolicybinding.admissionregistration.k8s.io/typo-policy-binding created
$ sleep 10
$ kubectl -n default create deployment typo-test --image=nginx:1.27-alpine
error: failed to create deployment: deployments.apps "typo-test" is forbidden: ValidatingAdmissionPolicy 'typo-policy' with binding 'typo-policy-binding' denied request: expression 'object.spec.replicaCount <= 10' resulted in error: no such key: replicaCount
$ kubectl patch validatingadmissionpolicy typo-policy --type merge -p '{"spec":{"failurePolicy":"Ignore"}}'
validatingadmissionpolicy.admissionregistration.k8s.io/typo-policy patched
$ sleep 10
$ kubectl -n default create deployment typo-test --image=nginx:1.27-alpine
deployment.apps/typo-test created
$ kubectl -n default delete deployment typo-test --ignore-not-found
deployment.apps "typo-test" deleted from default namespace
$ kubectl delete validatingadmissionpolicybinding typo-policy-binding
validatingadmissionpolicybinding.admissionregistration.k8s.io "typo-policy-binding" deleted
$ kubectl delete validatingadmissionpolicy typo-policy
validatingadmissionpolicy.admissionregistration.k8s.io "typo-policy" deleted
verify: status.typeChecking names the field that does not exist, the create is refused under Fail, and the identical broken policy lets everything through under Ignore. Read the type-checking status before you trust a policy you just wrote.

MutatingAdmissionPolicy does for defaults what VAP did for validation: CEL in the API server, no controller to keep alive. Add a label with an apply configuration and watch it land on a pod nobody patched.

kubectl api-resources | grep -i mutatingadmissionpolicy
kubectl apply -f - <<'EOF'
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicy
metadata: { name: add-platform-label }
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE"]
        resources: ["pods"]
  failurePolicy: Fail
  reinvocationPolicy: Never
  mutations:
    - patchType: ApplyConfiguration
      applyConfiguration:
        expression: |
          Object{ metadata: Object.metadata{ labels: {"managed-by": "platform"} } }
---
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicyBinding
metadata: { name: add-platform-label-binding }
spec:
  policyName: add-platform-label
  matchResources:
    namespaceSelector:
      matchLabels: { kubernetes.io/metadata.name: default }
EOF
sleep 15
kubectl -n default run mutated-native --image=nginx:1.27-alpine --restart=Never --overrides='{"spec":{"containers":[{"name":"mutated-native","image":"nginx:1.27-alpine","resources":{"requests":{"cpu":"25m","memory":"32Mi"}}}]}}'
kubectl -n default get pod mutated-native -o jsonpath='{.metadata.labels}{"\n"}'
kubectl -n default delete pod mutated-native --ignore-not-found
kubectl delete mutatingadmissionpolicybinding add-platform-label-binding
kubectl delete mutatingadmissionpolicy add-platform-label
outputcaptured 2026-09-12
$ kubectl api-resources | grep -i mutatingadmissionpolicy
mutatingadmissionpolicies                                                             admissionregistration.k8s.io/v1           false        MutatingAdmissionPolicy
mutatingadmissionpolicybindings                                                       admissionregistration.k8s.io/v1           false        MutatingAdmissionPolicyBinding
$ kubectl apply -f - <<'EOF'
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicy
metadata: { name: add-platform-label }
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE"]
        resources: ["pods"]
  failurePolicy: Fail
  reinvocationPolicy: Never
  mutations:
    - patchType: ApplyConfiguration
      applyConfiguration:
        expression: |
          Object{ metadata: Object.metadata{ labels: {"managed-by": "platform"} } }
---
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicyBinding
metadata: { name: add-platform-label-binding }
spec:
  policyName: add-platform-label
  matchResources:
    namespaceSelector:
      matchLabels: { kubernetes.io/metadata.name: default }
EOF
mutatingadmissionpolicy.admissionregistration.k8s.io/add-platform-label created
mutatingadmissionpolicybinding.admissionregistration.k8s.io/add-platform-label-binding created
$ sleep 15
$ kubectl -n default run mutated-native --image=nginx:1.27-alpine --restart=Never --overrides='{"spec":{"containers":[{"name":"mutated-native","image":"nginx:1.27-alpine","resources":{"requests":{"cpu":"25m","memory":"32Mi"}}}]}}'
pod/mutated-native created
$ kubectl -n default get pod mutated-native -o jsonpath='{.metadata.labels}{"\n"}'
{"managed-by":"platform","run":"mutated-native","topology.kubernetes.io/zone":"zone-b"}
$ kubectl -n default delete pod mutated-native --ignore-not-found
pod "mutated-native" deleted from default namespace
$ kubectl delete mutatingadmissionpolicybinding add-platform-label-binding
mutatingadmissionpolicybinding.admissionregistration.k8s.io "add-platform-label-binding" deleted
$ kubectl delete mutatingadmissionpolicy add-platform-label
mutatingadmissionpolicy.admissionregistration.k8s.io "add-platform-label" deleted
verify: the pod carries managed-by: platform and no mutating webhook exists. If the API is not served on this cluster, say so and name the feature gate; that is the honest answer to the same task on an older cluster.

Self-check

answer before opening
A "require requests" policy never fires in a tenant namespace. Why?

Its LimitRange defaults the requests during mutating admission, before the validating webhook runs, so the object Kyverno inspects already complies. Admission order (mutate then validate) is the whole explanation, and it generalizes to every defaulting mechanism.

failurePolicy: Fail versus Ignore: argue both, then say what bounds the risk.

Fail: no unvalidated writes, at the cost of the API server refusing writes when the webhook is down (a cluster-wide outage from a policy pod). Ignore: writes keep flowing, at the cost of a bypass window. Bound it with a tight namespaceSelector, low timeoutSeconds, exemptions for system namespaces, and running the engine with enough replicas that it is not a single point of failure.

You tighten a policy to Deny. What happens to the workloads that already violate it?

Nothing: admission is write-time. They keep running until their next write (a rollout, a scale, a controller re-create), and then they fail, possibly at 3am. That is why the Audit → report → fix → Enforce sequence exists, and why you check the reports before flipping the dial.

Which engine would you pick to also create a default NetworkPolicy in every new namespace?

Kyverno: generation is a first-class capability there (generate rules that create and keep resources in sync). Gatekeeper validates and can mutate but does not generate; ValidatingAdmissionPolicy only validates. Matching capability to requirement is the whole question.

Why might a platform team prefer ValidatingAdmissionPolicy for a simple rule?

No extra controller, no webhook, no availability dependency, no version skew; the API server evaluates CEL in-process. The trade is a narrower feature set (no mutation in older versions, no generation, no reporting), so it suits simple invariants rather than a governance program.

A Kyverno ValidatingPolicy denies bare pods but a Deployment with the same violating template is admitted. Field?

spec.autogen.podControllers.controllers listing deployments (and statefulsets, daemonsets, jobs, cronjobs as needed). Autogen rewrites the Pod rule to match the controller's spec.template. Without it the Deployment passes and its pods are rejected one level down at the ReplicaSet, the same indirection as PSS. Note autogen and autogen.validatingAdmissionPolicy are mutually exclusive.

Which two Kyverno controller flags must be on before a PolicyException has any effect, and how does the report show an applied exception?

--enablePolicyException=true and --exceptionNamespace=<namespace> (only exceptions created in that namespace count). When applied, the PolicyReport records the rule as skip with the exception's name in the result properties, in both admission and background modes.

A Gatekeeper Constraint is applied, the violating object is still admitted, and status.totalViolations grows. Two likely settings?

spec.enforcementAction: dryrun (or warn) on the Constraint, or the webhook running with failurePolicy: Ignore (Gatekeeper's install default) while the webhook pod is unhealthy or timing out. Audit still records violations in both cases, which is why the count grows without any denial. Set deny, and check the webhook's failurePolicy and timeoutSeconds.

You write a ValidatingAdmissionPolicy with a misspelt field path. Is it rejected? Where do you find out, and what decides the runtime effect?

It is accepted; CEL type errors appear in the policy's status.typeChecking after creation, not as an apply error. At runtime the bad expression errors, and failurePolicy decides: Fail denies every matching request (with the binding's validationActions), Ignore lets them through. Read the status, then fix the path.

Docs to know your way around

study time, not exam time
  • kyverno.io: ValidatingPolicy/MutatingPolicy references and the policy library.
  • open-policy-agent.github.io/gatekeeper: ConstraintTemplate walkthrough; the gatekeeper-library repo for adaptable Rego.
  • kubernetes.io: Validating Admission Policy, and Dynamic Admission Control for the webhook plumbing (failurePolicy, selectors, reinvocation).
  • Offline: kubectl explain validatingpolicy.spec, kubectl explain validatingadmissionpolicy.spec.validations, kubectl get validatingwebhookconfigurations -o yaml.
  • kyverno.io/docs/policy-types/overview: the version table and deprecation schedule; /policy-types/validating-policy for evaluation, webhookConfiguration and autogen; /guides/exceptions for the two flags.
  • open-policy-agent.github.io/gatekeeper/website/docs/howto (match fields), /mutation, /enforcement-points and /failing-closed: the pages a Gatekeeper task turns on.
  • kubernetes.io/docs/reference/access-authn-authz/validating-admission-policy and /mutating-admission-policy: field lists, parameterNotFoundAction, the CEL variables; /extensible-admission-controllers for webhook failurePolicy, timeoutSeconds, sideEffects, matchConditions.