Kyverno 1.17 landed yesterday, and the big news is that CEL policy types are now GA. If you’ve been running Kyverno with JMESPath-based ClusterPolicy resources, the clock is ticking. They’re officially deprecated and scheduled for removal in v1.20 (October 2026).

I spent today migrating a production cluster with about 60 policies. Here is what actually happened.


Why This Matters

Kyverno has been using JMESPath expressions for years. They work, but they’re Kyverno-specific. CEL (Common Expression Language) is what Kubernetes itself uses for ValidatingAdmissionPolicy since 1.30. By switching to CEL, Kyverno aligns with upstream and gets significantly better evaluation performance.

The old way:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-labels
spec:
  rules:
    - name: check-team-label
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        message: "Label 'team' is required."
        pattern:
          metadata:
            labels:
              team: "?*"

The new way:

apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: require-labels
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["pods"]
        operations: ["CREATE", "UPDATE"]
  validations:
    - expression: "has(object.metadata.labels) && 'team' in object.metadata.labels && object.metadata.labels['team'] != ''"
      message: "Label 'team' is required."

Is it more verbose? Yes. But it is the same expression language you use in native Kubernetes admission policies, so there is one less DSL to keep in your head.


The Migration: What Actually Happened

Step 1: Upgrade Kyverno

helm repo update kyverno
helm upgrade kyverno kyverno/kyverno \
  --namespace kyverno-system \
  --version 1.17.0 \
  --set admissionController.replicas=3

The upgrade itself was clean. No CRD conflicts, no restarts needed beyond the helm upgrade. Both old and new API versions work simultaneously, so nothing breaks on day one.

Step 2: Inventory your policies

Before touching anything, I dumped what we had:

kubectl get clusterpolicy -o name | wc -l
# 47

kubectl get policy -A -o name | wc -l
# 15

62 policies total. I categorized them:

  • Validation only (38): Straightforward migration to ValidatingPolicy
  • Mutation (16): Now uses MutatingPolicy, this is where it got tricky
  • Generation (8): GeneratingPolicy, mostly ConfigMap and NetworkPolicy generators

Step 3: The actual rewrite

For validation policies, the migration guide on the new kyverno.io site is solid. Most patterns translate directly:

JMESPath pattern match → CEL expression

# Old: pattern-based
validate:
  pattern:
    spec:
      containers:
        - resources:
            limits:
              memory: "?*"

# New: CEL expression
validations:
  - expression: >-
      object.spec.containers.all(c,
        has(c.resources) &&
        has(c.resources.limits) &&
        'memory' in c.resources.limits
      )
    message: "All containers must have memory limits."

The all() macro is your best friend when iterating over containers.

Step 4: Mutation policies, here is where I got burned

The old patchStrategicMerge approach was simple. You literally wrote the YAML you wanted injected. The new MutatingPolicy uses CEL mutations, which are more like JSONPatch expressed in CEL:

apiVersion: policies.kyverno.io/v1
kind: MutatingPolicy
metadata:
  name: inject-sidecar-proxy
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["pods"]
        operations: ["CREATE"]
  mutations:
    - patchType: JSONPatch
      jsonPatch:
        expression: >-
          [
            JSONPatch{op: "add", path: "/metadata/labels/injected-proxy", value: "true"},
            JSONPatch{op: "add", path: "/spec/containers/-",
              value: object{
                "name": "proxy-sidecar",
                "image": "envoyproxy/envoy:v1.32-latest",
                "ports": [object{"containerPort": 15001}]
              }
            }
          ]          

Gotcha #1: If you’re patching into arrays (like adding a container), the path /spec/containers/- still works, but value construction in CEL differs from regular JSON. The object{} literal syntax took me a while to figure out. The docs still need better examples for complex nested objects.

Gotcha #2: patchStrategicMerge has no direct CEL equivalent. If you relied on strategic merge semantics (like merging into existing env vars without overwriting), you need to restructure as explicit JSONPatch operations. This was about 4 hours of my day.


Namespaced Policies: The Multi-Tenancy Win

The feature I’m most excited about is NamespacedMutatingPolicy and NamespacedGeneratingPolicy. Previously, only cluster admins could write mutation rules. Now namespace owners can define their own:

apiVersion: policies.kyverno.io/v1
kind: NamespacedMutatingPolicy
metadata:
  name: team-defaults
  namespace: team-alpha
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["pods"]
        operations: ["CREATE"]
  mutations:
    - patchType: JSONPatch
      jsonPatch:
        expression: >-
          [
            JSONPatch{op: "add", path: "/metadata/labels/cost-center", value: "alpha-2026"}
          ]          

In our setup, each team namespace has a dedicated service account with RBAC for their Namespaced*Policy resources. The platform team still controls cluster-wide policies. Clean separation.


New CEL Functions Worth Knowing

Kyverno 1.17 adds some genuinely useful CEL extensions:

# Hash a value (useful for comparing secrets or checksums)
sha256("my-config-data")

# Decode and inspect x509 certificates inline
x509.decode(object.data["tls.crt"]).notAfter

# Time-based policies (e.g., reject deployments outside business hours)
time.now().getDayOfWeek() >= 1 && time.now().getDayOfWeek() <= 5

# Parse embedded JSON/YAML in annotations
json.unmarshal(object.metadata.annotations["config"]).replicas > 0

The x509.decode() function is a game-changer for certificate validation policies. We had a custom controller doing this before, now it is a three-line policy.


Performance: It’s Noticeably Faster

I don’t have scientific benchmarks, but the webhook response times in our Prometheus dashboards dropped. Before the migration, the p99 admission webhook latency was around 45ms. After switching to CEL policies: roughly 18ms. CEL expressions are compiled once and cached, while JMESPath was interpreted on every evaluation.

For a cluster doing ~200 pod creations per minute, that adds up.


My Migration Checklist

If you’re planning this, here’s what I wish I had on day one:

  1. Don’t rush. Both APIs work side by side. You have until October 2026.
  2. Start with validation policies. They’re the most straightforward to convert.
  3. Test mutation policies in Audit mode first. The behavior might differ subtly from patchStrategicMerge.
  4. Use kubectl apply --dry-run=server to catch CEL syntax errors before they hit the cluster.
  5. Check the new policy catalog. Kyverno ships 300+ sample policies, now with CEL variants.
  6. Watch the deprecation timeline. v1.18 (April 2026) = critical fixes only for legacy APIs.

Bottom Line

This is one of those upgrades that pays off quickly. Aligning on CEL with upstream Kubernetes means less context switching, better performance, and proper multi-tenancy support. The migration is not painless, especially for mutation policies, but it is manageable if you do it in phases.

If you’re still running Kyverno < 1.16, you might want to skip straight to 1.17 now that everything is GA. No point adopting beta CEL APIs when stable ones are available.

Start migrating. The old APIs won’t be around forever.