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"`
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,119 | 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,351,744 | 72.35% |
| PullRequest | v1alpha1 | 629,162 | 0.63% |
| 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) |
.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,119 | 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) |
| Total | 72,351,744 | 72.35% |
PullRequest
Source: promoter.argoproj.io_pullrequests.yaml
Version v1alpha1
| Path | Cost | % of rule limit | Expression |
|---|---|---|---|
.spec.sourceBranch |
314,575 | 3.15% | self == oldSelf |
.spec.targetBranch |
314,575 | 3.15% | self == oldSelf |
(root) |
9 | 0.00% | self.spec.state == 'open' \|\| has(self.status.id) && self.status.id != "" |
.status.url |
3 | 0.00% | self == '' \|\| isURL(self) |
| Total | 629,162 | 0.63% |
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% |