Skip to content

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 string MaxLength) 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%