Writing CEL Validation Rules
Our CRDs use CEL validation rules
(x-kubernetes-validations) to enforce constraints the OpenAPI schema can't express on its own —
for example "this field must be a URL" or "this field is immutable". You add them as
kubebuilder markers on the API
types in api/v1alpha1/,
and make build-installer compiles them into the CRD bases under config/crd/.
// +kubebuilder:validation:XValidation:rule="self == '' || isURL(self)",message="must be a valid URL"
RepoURL string `json:"repoURL,omitempty"`
Don't mix +k8s: markers with XValidation on one field
controller-gen v0.21+ also understands +k8s: declarative validation
markers (for example
+k8s:immutable, +k8s:minLength). Like hand-written XValidation markers, these compile into
x-kubernetes-validations CEL rules on the field.
Do not put both marker families on the same field. When a field mixes +k8s: markers with
+kubebuilder:validation:XValidation, controller-gen emits the rules from each family in
declared order internally, but the relative order of the two families is
nondeterministic across
runs. Committed CRD bases under
config/crd/bases/ then flap between equivalent orderings, which breaks CI checks that diff
generated manifests.
Workaround: keep all CEL rules on a field under one marker family. When a field needs both
immutability and custom CEL checks, replace +k8s:immutable with an explicit immutability rule so
every rule is an XValidation marker:
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="field is immutable"
// +kubebuilder:validation:XValidation:rule="!self.startsWith('-')",message="branch must not start with '-'"
Branch string `json:"branch"`
In-tree example: PullRequest sourceBranch / targetBranch in
api/v1alpha1/pullrequest_types.go.
+k8s:immutable (and other +k8s: markers that emit CEL) is still fine on fields that have no
other XValidation rules on the same field.
The apiserver cost budget
When a CRD is created or updated, the kube-apiserver estimates a static worst-case cost for
every CEL rule and rejects the CRD if it is too expensive. There are two limits
(from k8s.io/apiextensions-apiserver):
| Limit | Value | Applies to |
|---|---|---|
| Per-rule | 10,000,000 |
A single expression (rule or its messageExpression) |
| Per-schema | 100,000,000 |
The sum of all expressions in one CRD version's schema |
The critical thing to understand is cardinality. A rule's cost is its base expression cost
multiplied by the worst-case number of times it can be evaluated. A rule on a scalar field is
evaluated once; the same rule on a field inside an unbounded (or large) array or map is multiplied
by that collection's maximum size — and the multiplier compounds through nested collections. So a
cheap-looking rule placed deep inside repeated status lists can dominate the budget.
Keeping rules cheap
- Bound your collections. Add
+kubebuilder:validation:MaxItems/MaxProperties(and stringMaxLength) wherever practical. An unbounded collection makes the apiserver assume a very large worst-case cardinality. - Push rules up, not down. Validating at the smallest necessary scope (a scalar field rather than every element of a nested list) avoids cardinality multiplication.
- Prefer cheap operations. Simple comparisons and library calls like
isURL()are inexpensive; regex,.all()/.exists()over large collections, and string manipulation are not. - Watch
messageExpression. Its cost counts toward both limits too.
Checking the cost
Estimate the cost of the current CRDs at any time:
make build-installer # regenerates CRDs and the report
# or, if CRDs are already up to date:
make cel-cost-report
This runs hack/celcost,
which reproduces the apiserver's budget calculation against config/crd/bases and writes the report
embedded below to hack/celcost/report.md. CI fails if that file is out of date, so after any change
that alters the CRDs (new fields, new rules, changed MaxItems, etc.) run make build-installer
and commit the full diff.
Note
The estimate is the library's stricter, create-time, version-portable cost. A given kube-apiserver binary may be slightly more lenient (e.g. on updates that reuse already-stored expressions), so treat the report as a conservative early-warning signal rather than the exact server verdict.
Current cost report
Estimated static CEL costs versus kube-apiserver limits, computed from k8s.io/apiextensions-apiserver (ValidateCustomResourceDefinition).
- Per-rule limit:
10,000,000 - Per-schema (per CRD version) limit:
100,000,000
Summary
| Resource | Version | Total cost | % of schema limit |
|---|---|---|---|
| ArgoCDCommitStatus | v1alpha1 | 0 | 0.00% |
| ChangeTransferPolicy | v1alpha1 | 56,623,293 | 56.62% |
| ClusterScmProvider | v1alpha1 | 135 | 0.00% |
| CommitStatus | v1alpha1 | 3 | 0.00% |
| ControllerConfiguration | v1alpha1 | 2,840 | 0.00% |
| GitCommitStatus | v1alpha1 | 0 | 0.00% |
| GitRepository | v1alpha1 | 128 | 0.00% |
| PromotionStrategy | v1alpha1 | 72,438,744 | 72.44% |
| PullRequest | v1alpha1 | 270 | 0.00% |
| RevertCommit | v1alpha1 | 0 | 0.00% |
| ScmProvider | v1alpha1 | 135 | 0.00% |
| TimedCommitStatus | v1alpha1 | 0 | 0.00% |
| WebRequestCommitStatus | v1alpha1 | 162 | 0.00% |
Per-resource detail
ArgoCDCommitStatus
Source: promoter.argoproj.io_argocdcommitstatuses.yaml
Version v1alpha1
No CEL validation rules.
ChangeTransferPolicy
Source: promoter.argoproj.io_changetransferpolicies.yaml
Version v1alpha1
| Path | Cost | % of rule limit | Expression |
|---|---|---|---|
.status.active.commitStatuses[].url |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.active.dry.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.active.hydrated.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.active.note.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.history[].active.commitStatuses[].url |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.history[].active.dry.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.history[].active.dry.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.history[].active.hydrated.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.history[].active.hydrated.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.history[].active.note.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.history[].proposed.commitStatuses[].url |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.history[].proposed.hydrated.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.history[].proposed.hydrated.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.history[].pullRequest.url |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.proposed.commitStatuses[].url |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.proposed.dry.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.proposed.hydrated.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.proposed.note.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.spec.activeBranch |
42 | 0.00% | !self.contains(':') |
.spec.activeBranch |
42 | 0.00% | !self.contains('..') |
.spec.proposedBranch |
42 | 0.00% | !self.contains(':') |
.spec.proposedBranch |
42 | 0.00% | !self.contains('..') |
.spec.activeBranch |
3 | 0.00% | !self.startsWith('-') |
.spec.proposedBranch |
3 | 0.00% | !self.startsWith('-') |
.status.active.dry.repoURL |
3 | 0.00% | self == '' \|\| isURL(self) |
.status.active.hydrated.repoURL |
3 | 0.00% | self == '' \|\| isURL(self) |
.status.proposed.dry.repoURL |
3 | 0.00% | self == '' \|\| isURL(self) |
.status.proposed.hydrated.repoURL |
3 | 0.00% | self == '' \|\| isURL(self) |
.status.pullRequest.url |
3 | 0.00% | self == '' \|\| isURL(self) |
| Total | 56,623,293 | 56.62% |
ClusterScmProvider
Source: promoter.argoproj.io_clusterscmproviders.yaml
Version v1alpha1
| Path | Cost | % of rule limit | Expression |
|---|---|---|---|
.spec |
128 | 0.00% | [has(self.github),has(self.gitlab),has(self.forgejo),has(self.gitea),has(self.bitbucketCloud),has(self.azureDevOps),h... |
.spec.azureDevOps.domain |
3 | 0.00% | self != "dev.azure.com" |
.spec.github.domain |
2 | 0.00% | self != "github.com" |
.spec.gitlab.domain |
2 | 0.00% | self != "gitlab.com" |
| Total | 135 | 0.00% |
CommitStatus
Source: promoter.argoproj.io_commitstatuses.yaml
Version v1alpha1
| Path | Cost | % of rule limit | Expression |
|---|---|---|---|
.spec.url |
3 | 0.00% | self == '' \|\| isURL(self) |
| Total | 3 | 0.00% |
ControllerConfiguration
Source: promoter.argoproj.io_controllerconfigurations.yaml
Version v1alpha1
| Path | Cost | % of rule limit | Expression |
|---|---|---|---|
.spec.argocdCommitStatus.workQueue.rateLimiter.maxOf[] |
204 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() <= 1 |
.spec.changeTransferPolicy.workQueue.rateLimiter.maxOf[] |
204 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() <= 1 |
.spec.commitStatus.workQueue.rateLimiter.maxOf[] |
204 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() <= 1 |
.spec.gitCommitStatus.workQueue.rateLimiter.maxOf[] |
204 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() <= 1 |
.spec.promotionStrategy.workQueue.rateLimiter.maxOf[] |
204 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() <= 1 |
.spec.pullRequest.workQueue.rateLimiter.maxOf[] |
204 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() <= 1 |
.spec.timedCommitStatus.workQueue.rateLimiter.maxOf[] |
204 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() <= 1 |
.spec.webRequestCommitStatus.workQueue.rateLimiter.maxOf[] |
204 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() <= 1 |
.spec.argocdCommitStatus.workQueue.rateLimiter |
83 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket),has(self.maxOf)].filter(x,x==true).size() <= 1 |
.spec.changeTransferPolicy.workQueue.rateLimiter |
83 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket),has(self.maxOf)].filter(x,x==true).size() <= 1 |
.spec.commitStatus.workQueue.rateLimiter |
83 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket),has(self.maxOf)].filter(x,x==true).size() <= 1 |
.spec.gitCommitStatus.workQueue.rateLimiter |
83 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket),has(self.maxOf)].filter(x,x==true).size() <= 1 |
.spec.promotionStrategy.workQueue.rateLimiter |
83 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket),has(self.maxOf)].filter(x,x==true).size() <= 1 |
.spec.pullRequest.workQueue.rateLimiter |
83 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket),has(self.maxOf)].filter(x,x==true).size() <= 1 |
.spec.timedCommitStatus.workQueue.rateLimiter |
83 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket),has(self.maxOf)].filter(x,x==true).size() <= 1 |
.spec.webRequestCommitStatus.workQueue.rateLimiter |
83 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket),has(self.maxOf)].filter(x,x==true).size() <= 1 |
.spec.argocdCommitStatus.workQueue.rateLimiter |
68 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() <= 1 |
.spec.changeTransferPolicy.workQueue.rateLimiter |
68 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() <= 1 |
.spec.commitStatus.workQueue.rateLimiter |
68 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() <= 1 |
.spec.gitCommitStatus.workQueue.rateLimiter |
68 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() <= 1 |
.spec.promotionStrategy.workQueue.rateLimiter |
68 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() <= 1 |
.spec.pullRequest.workQueue.rateLimiter |
68 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() <= 1 |
.spec.timedCommitStatus.workQueue.rateLimiter |
68 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() <= 1 |
.spec.webRequestCommitStatus.workQueue.rateLimiter |
68 | 0.00% | [has(self.fastSlow),has(self.exponentialFailure),has(self.bucket)].filter(x,x==true).size() <= 1 |
| Total | 2,840 | 0.00% |
GitCommitStatus
Source: promoter.argoproj.io_gitcommitstatuses.yaml
Version v1alpha1
No CEL validation rules.
GitRepository
Source: promoter.argoproj.io_gitrepositories.yaml
Version v1alpha1
| Path | Cost | % of rule limit | Expression |
|---|---|---|---|
.spec |
128 | 0.00% | [has(self.github),has(self.gitlab),has(self.forgejo),has(self.gitea),has(self.bitbucketCloud),has(self.azureDevOps),h... |
| Total | 128 | 0.00% |
PromotionStrategy
Source: promoter.argoproj.io_promotionstrategies.yaml
Version v1alpha1
| Path | Cost | % of rule limit | Expression |
|---|---|---|---|
.status.environments[].active.commitStatuses[].url |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].active.dry.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].active.dry.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].active.hydrated.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].active.hydrated.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].active.note.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].history[].active.commitStatuses[].url |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].history[].active.dry.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].history[].active.dry.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].history[].active.hydrated.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].history[].active.hydrated.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].history[].active.note.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].history[].proposed.commitStatuses[].url |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].history[].proposed.hydrated.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].history[].proposed.hydrated.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].history[].pullRequest.url |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].proposed.commitStatuses[].url |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].proposed.dry.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].proposed.dry.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].proposed.hydrated.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].proposed.hydrated.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].proposed.note.references[].commit.repoURL |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.status.environments[].pullRequest.url |
3,145,728 | 31.46% | self == '' \|\| isURL(self) |
.spec.environments[].branch |
42,000 | 0.42% | !self.contains(':') |
.spec.environments[].branch |
42,000 | 0.42% | !self.contains('..') |
.spec.environments[].branch |
3,000 | 0.03% | !self.startsWith('-') |
| Total | 72,438,744 | 72.44% |
PullRequest
Source: promoter.argoproj.io_pullrequests.yaml
Version v1alpha1
| Path | Cost | % of rule limit | Expression |
|---|---|---|---|
.spec.sourceBranch |
42 | 0.00% | self == oldSelf |
.spec.sourceBranch |
42 | 0.00% | !self.contains(':') |
.spec.sourceBranch |
42 | 0.00% | !self.contains('..') |
.spec.targetBranch |
42 | 0.00% | self == oldSelf |
.spec.targetBranch |
42 | 0.00% | !self.contains(':') |
.spec.targetBranch |
42 | 0.00% | !self.contains('..') |
(root) |
9 | 0.00% | self.spec.state == 'open' \|\| has(self.status.id) && self.status.id != "" |
.spec.sourceBranch |
3 | 0.00% | !self.startsWith('-') |
.spec.targetBranch |
3 | 0.00% | !self.startsWith('-') |
.status.url |
3 | 0.00% | self == '' \|\| isURL(self) |
| Total | 270 | 0.00% |
RevertCommit
Source: promoter.argoproj.io_revertcommits.yaml
Version v1alpha1
No CEL validation rules.
ScmProvider
Source: promoter.argoproj.io_scmproviders.yaml
Version v1alpha1
| Path | Cost | % of rule limit | Expression |
|---|---|---|---|
.spec |
128 | 0.00% | [has(self.github),has(self.gitlab),has(self.forgejo),has(self.gitea),has(self.bitbucketCloud),has(self.azureDevOps),h... |
.spec.azureDevOps.domain |
3 | 0.00% | self != "dev.azure.com" |
.spec.github.domain |
2 | 0.00% | self != "github.com" |
.spec.gitlab.domain |
2 | 0.00% | self != "gitlab.com" |
| Total | 135 | 0.00% |
TimedCommitStatus
Source: promoter.argoproj.io_timedcommitstatuses.yaml
Version v1alpha1
No CEL validation rules.
WebRequestCommitStatus
Source: promoter.argoproj.io_webrequestcommitstatuses.yaml
Version v1alpha1
| Path | Cost | % of rule limit | Expression |
|---|---|---|---|
.spec.httpRequest.authentication |
98 | 0.00% | [has(self.basic),has(self.bearer),has(self.oauth2),has(self.tls),has(self.scm)].filter(x,x==true).size() <= 1 |
.spec.mode |
53 | 0.00% | [has(self.polling),has(self.trigger)].filter(x,x==true).size() == 1 |
.spec.httpRequest |
11 | 0.00% | (has(self.method) && self.method.size() > 0) != (has(self.methodTemplate) && self.methodTemplate.size() > 0) |
| Total | 162 | 0.00% |