Server-Side CEL Type-Checking as a CI Lint

kubectl --dry-run=client accepts a ValidatingAdmissionPolicy with a broken CEL expression. --dry-run=server compiles it, names the column of the error, and exits 1 — with nothing persisted. That is a free CI lint.

Server-Side CEL Type-Checking as a CI Lint

A CEL expression in a ValidatingAdmissionPolicy is code, and it is code that runs on the admission path of every matching request. So the obvious question before merging one: can CI tell me it compiles?

Client-side validation cannot. kubectl apply --dry-run=client on the VAP manifests happily reports:

validatingadmissionpolicy.admissionregistration.k8s.io/poc-imgsec-vap-restrict-image-registries created (dry run)
validatingadmissionpolicy.admissionregistration.k8s.io/poc-imgsec-vap-require-image-digest created (dry run)
validatingadmissionpolicybinding.admissionregistration.k8s.io/poc-imgsec-vap-registries-phase1-warn created (dry run)
validatingadmissionpolicybinding.admissionregistration.k8s.io/poc-imgsec-vap-registries-phase3-deny created (dry run)

That is schema validation only. The CEL string is a string as far as the client is concerned; a deliberately broken expression sails through. Offline evaluation is not much better — running the broken policy through the CLI against three fixtures gave error: 3 and, unhelpfully, EXIT_CODE=0.

What the API server says

Send the same manifest with --dry-run=server and the API server compiles the expression. A reference to a variable that does not exist:

The ValidatingAdmissionPolicy "poc-imgsec-vap-restrict-image-registries" is invalid:
spec.validations[0].expression: Invalid value: "size(variables.noSuchVariable) == 0":
compilation failed: ERROR: <input>:1:15: undefined field 'noSuchVariable'
 | size(variables.noSuchVariable) == 0
 | ..............^
KUBECTL_EXIT=1

And a genuine type error — comparing a list to an integer:

compilation failed: ERROR: <input>:1:21: found no matching overload for '_==_'
applied to '(list(dyn), int)'
 | variables.badImages == 0
 | ....................^
KUBECTL_EXIT=1

A compiler diagnostic with a caret under the offending column, the inferred types of both operands, and exit 1. That is better feedback than most policy-as-code tooling manages, and it costs nothing to obtain.

Client dry-run accepting a broken expression, server dry-run compiling it and rejecting with a caret diagnostic
Client dry-run accepting a broken expression, server dry-run compiling it and rejecting with a caret diagnostic

Only the API server has the CEL compiler and the schema. Client-side dry-run validates shape; server-side dry-run validates meaning.

Nothing is persisted

The obvious worry is that a CI job talking to a real API server leaves residue. It does not — dry-run requests are not persisted, and we checked rather than assumed. Before the run, no poc-imgsec VAPs existed in the cluster. The two good policies came back created (server dry run). After the run: none, and no bindings either. Confirmed on both counts.

So the lint is genuinely read-only against a live cluster, which is what makes it usable from a merge-request pipeline with a narrow-scoped token.

The shape of the gate

kubectl apply --dry-run=server -f policies/vap/ || exit 1

One line. It catches undefined variable references, operator overload mismatches, and anything else the CEL compiler knows about — the class of mistake that otherwise surfaces when the policy is already admitted and the expression errors on a live request.

It is also the counterweight to the recurring theme of this series. Three posts in a row have been about gates that exit 0 while proving nothing: kyverno test going green on a weakened policy, a bundle applying zero rules, a broken CEL expression erroring three times and still exiting 0. This one exits 1 when it should, names the column, and writes nothing. When a tool behaves that well, use it.

Next: the policy you cannot phase in — the control where the warn-first ladder does not work at all.