k8wiz
Back to Articles

Ready to test your Kyverno knowledge? Try our Kyverno quiz!

Kyverno 1.20: legacy ClusterPolicy is no longer enforced, how to upgrade

Kyverno 1.20 stops running ClusterPolicy and other kyverno.io policies. What breaks, how the Helm upgrade gate works and how to migrate first.

10 min read
By k8wiz Team
  • kyverno
  • upgrade
  • cel
  • policy
  • migration

Kyverno 1.20.0 came out this week (the GitHub release is dated November 12, 2026, after a series of release candidates that began on November 2). Most releases add features and you can upgrade on a quiet afternoon. This one is different. Kyverno 1.20 no longer runs the policy types most clusters have used since the project started: ClusterPolicy, Policy, CleanupPolicy, ClusterCleanupPolicy and the kyverno.io/v2 PolicyException.

The objects are not deleted. They stay in etcd, you can still list and export them, and nothing errors at runtime. They simply stop doing anything. A cluster that relied on a ClusterPolicy to block privileged pods would accept privileged pods after the upgrade, with no admission error to tell you. This guide covers what changes, how the Helm chart tries to stop you from losing enforcement by accident, and the order of work that gets you to 1.20 with every rule still active.

What changes in 1.20

The legacy policy engine is gone. The admission, background and reports controllers no longer execute rules from the five kyverno.io kinds above. The replacements live in the policies.kyverno.io API group, served at v1: ValidatingPolicy, MutatingPolicy, GeneratingPolicy, ImageValidatingPolicy, DeletingPolicy, their namespaced variants, and a new PolicyException. The legacy types were deprecated in 1.19, which also shipped the warnings that help you find what is left.

The legacy CRDs remain installed, so kubectl get cpol -A still works. Kyverno also denies writes to them. Every create is rejected, and so is almost every update, including a kubectl apply of an unchanged manifest and a label-only patch. Deletes always succeed, and you can still strip a finalizer from an object that is already terminating. In 1.19 a re-apply of an unchanged legacy policy was silently accepted, so this is new behavior.

That write denial matters if Argo CD or Flux manages your policies. Every reconcile re-applies the manifest, every apply is denied, and the application shows as permanently out of sync. The chart README describes this as the expected migration signal. There is no setting to bring back the 1.19 behavior. The fix is to remove the legacy manifest from Git once its replacement exists.

The CLI follows the same line. kyverno apply and kyverno test in 1.20 refuse legacy policy manifests outright. If your CI still tests a ClusterPolicy, pin the job to a 1.19 CLI until the policy is migrated.

Not everything in kyverno.io is going away. GlobalContextEntry and UpdateRequest are still supported at kyverno.io/v2, and policy reports are untouched.

Step 1: take an inventory

Start by counting what you have. Run this against every cluster you plan to upgrade, not only the one you think of as typical. Policies created by hand during an incident tend to live in exactly one cluster.

kubectl get clusterpolicies,policies,cleanuppolicies,clustercleanuppolicies -A
kubectl get policyexceptions.kyverno.io -A

The fully qualified name in the second command matters. Kyverno now has two PolicyException kinds, so a bare kubectl get policyexceptions may resolve to the wrong one.

If both commands return nothing, you can upgrade like any other release. Skip to step 6.

Step 2: back up the legacy objects

Export everything before you change anything. You will use this file as the source for conversion, and it is your way back if you need to roll back to 1.19.

kubectl get pol,cpol,cleanpol,ccleanpol,polex -A -o yaml > kyverno-legacy-backup.yaml

The short names cpol, pol, cleanpol, ccleanpol and polex all point to the legacy kinds. The new policies.kyverno.io PolicyException has no short name, so polex is unambiguous here.

Step 3: convert each rule to a CEL policy

The biggest structural change is that a ClusterPolicy held a list of rules that could mix validate, mutate and generate. The new types each do one job. A validate rule becomes a ValidatingPolicy, mutate becomes MutatingPolicy, generate becomes GeneratingPolicy, verifyImages becomes ImageValidatingPolicy, and a cleanup policy becomes a DeletingPolicy. A ClusterPolicy with four rules may turn into four policies.

The other change is the language. Patterns, anchors and JMESPath are replaced by CEL expressions. Here is a typical legacy rule that requires two labels on pods.

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-labels
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-labels
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        message: "Required labels missing"
        pattern:
          metadata:
            labels:
              app: "?*"
              version: "?*"

The same check as a ValidatingPolicy looks like this. Matching moves to matchConstraints, which uses the same shape as a Kubernetes ValidatingAdmissionPolicy. validationFailureAction: Enforce becomes validationActions: [Deny].

apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: require-labels
spec:
  validationActions:
    - Deny
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  validations:
    - expression: >-
        ['app', 'version'].all(label,
          object.metadata.?labels[label].orValue('') != ''
        )
      message: "Required labels missing"

The ? and orValue('') pair is worth learning early. Legacy patterns quietly handled missing fields. CEL does not, and an expression like object.metadata.labels.app errors on a pod with no labels at all. Optional field access returns a default instead.

A deny rule needs its logic inverted, because CEL validations describe what is allowed. A legacy rule that denied when {{ request.object.spec.replicas }} was GreaterThan 10 becomes an expression that must be true. This version also excludes kube-system the way a legacy exclude block would, through a namespace selector.

apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: limit-replicas
spec:
  validationActions:
    - Deny
  matchConstraints:
    resourceRules:
      - apiGroups: ["apps"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["deployments"]
    namespaceSelector:
      matchExpressions:
        - key: kubernetes.io/metadata.name
          operator: NotIn
          values: ["kube-system"]
  validations:
    - expression: "object.spec.?replicas.orValue(1) <= 10"
      message: "Replica count cannot exceed 10"

A few legacy fields have no direct equivalent, and you should know about them before you start. allowExistingViolations and failureActionOverrides are not supported, and the migration guide points you to policy exceptions instead. validate.podSecurity is not supported either, so each Pod Security control becomes its own CEL validation. patchStrategicMerge in mutate rules has to be rewritten as patchType: ApplyConfiguration or patchType: JSONPatch. The background setting moves to spec.evaluation.background.enabled, and emitWarning becomes the Warn validation action.

The full field-by-field mapping is in the Kyverno migration guide (kyverno.io/docs/guides/migration-to-cel). Keep it open while you convert. It is long because the old schema was large, but most clusters only use a small part of it.

Step 4: migrate the exceptions

Legacy exceptions referenced a policy name plus rule names. CEL policies have no named rules, so the new PolicyException points at a policy by kind and name and narrows the match with CEL conditions.

apiVersion: policies.kyverno.io/v1
kind: PolicyException
metadata:
  name: allow-team-tool
  namespace: delta
spec:
  policyRefs:
    - name: require-labels
      kind: ValidatingPolicy
  matchConditions:
    - name: match-pod-name
      expression: "object.metadata.name.startsWith('important-tool')"

Since kind is required in each policyRefs entry, an exception for a policy you split into several CEL policies needs one entry per new policy. That is easy to miss when one ClusterPolicy turned into three.

Step 5: run old and new side by side on 1.19

Do the switch while you are still on 1.19, where both engines work. Apply each new policy with validationActions: [Audit] next to the legacy one it replaces. Both evaluate the same requests, and the new policy writes its results to policy reports without blocking anything.

kubectl apply -f policies/require-labels-vpol.yaml
kubectl get policyreports -A

Compare the results. When the new policy fails the same resources as the old one and nothing else, change it to Deny and delete the legacy object. Differences usually come from fields the legacy pattern treated as optional and your CEL expression does not, so look there first.

This is also where you update your CLI tests. In a kyverno-test.yaml results entry, a CEL policy has no rule name, and you mark the entry with isValidatingPolicy: true (or isMutatingPolicy, isImageValidatingPolicy and so on) instead.

Step 6: stop legacy policies coming back

Once a repository is clean, keep it that way. The 1.19 CLI prints a deprecation warning when it loads a legacy policy or exception, and --warnings-as-errors turns that warning into a failed run.

kyverno test . --warnings-as-errors
kyverno apply policies/ --resource samples/ --warnings-as-errors

Add the flag to the CI job that tests your policies. A colleague who copies an old ClusterPolicy from a blog post will find out in review, not after the upgrade. After you move CI to the 1.20 CLI, the flag is no longer needed for this, because 1.20 rejects legacy manifests anyway.

Step 7: upgrade with Helm

The 1.20 chart refuses to install or upgrade while legacy policy resources exist on the cluster. It checks in two independent ways. At render time, a Helm lookup counts the five legacy kinds and fails with the counts, some resource names and the migration link. Separately, a pre-install,pre-upgrade hook Job runs the check server-side for GitOps tools that apply hooks but don't evaluate lookup against a live cluster.

If step 1 now returns nothing, the upgrade proceeds normally.

helm repo update
helm upgrade kyverno kyverno/kyverno -n kyverno

If you decide to go ahead with legacy objects still present, for example in a test cluster, you have to say so explicitly.

helm upgrade kyverno kyverno/kyverno -n kyverno \
  --set upgrade.acknowledgeLegacyPoliciesNotEnforced=true

Read the value name carefully. It does not keep anything running. It records that you accept that those policies are no longer enforced. The value was called upgrade.allowLegacyPolicies before 1.20, and it was renamed because that name suggested the opposite. A stale upgrade.allowLegacyPolicies: true in an old values file now fails the render on purpose.

The gate has known gaps. helm template, helm install --dry-run=client and ct lint never evaluate lookup. --no-hooks, Argo CD's Skip Hooks option and Flux disableHooks skip the Job. Static manifests have neither check. If your pipeline uses any of these, run the step 1 commands yourself before you upgrade.

When the hook Job blocks an install, Helm marks the release failed. After you fix the cause, helm upgrade --install picks up the failed release. You only need helm uninstall if you retry with a plain helm install under the same name.

If you install from the YAML manifests instead of Helm, nothing blocks you. Kyverno's init container marks legacy ClusterPolicy and Policy objects NotReady and logs warnings. That is easy to miss in a pipeline, so the inventory in step 1 is your real check.

Step 8: remove the legacy CRDs

The legacy CRDs carry the helm.sh/resource-policy: keep annotation, so Helm leaves them in place even on uninstall. That protects your old objects, and it lets you roll back to a 1.19 release that can still run them.

Once the new policies have run in Deny mode for a while and you are sure you won't roll back, delete the CRDs. Deleting a CRD deletes every remaining object of that kind, so check that the backup from step 2 exists first.

kubectl delete crd \
  clusterpolicies.kyverno.io \
  policies.kyverno.io \
  cleanuppolicies.kyverno.io \
  clustercleanuppolicies.kyverno.io \
  policyexceptions.kyverno.io

Leave globalcontextentries.kyverno.io and updaterequests.kyverno.io alone. Both are still in use.

How long to budget

For most teams the conversion is the slow part, not the upgrade. A simple label or image-tag rule converts in minutes. Rules with foreach, JMESPath context lookups or patchStrategicMerge take longer, because you are rewriting logic rather than syntax. Mutate and generate rules deserve the most care, since a mistake there changes objects instead of rejecting them. Sort your inventory by rule type, convert the validate rules first to build confidence with CEL, and plan the 1.20 upgrade only after the inventory comes back empty.

Test yourself

Ready to test your Kyverno knowledge? Try our Kyverno quiz!