Kubernetes CEL extension functions for Rust, built on top of cel.
Implements the Kubernetes-specific CEL libraries defined in k8s.io/apiserver/pkg/cel/library and cel-go/ext, enabling client-side evaluation of CRD validation rules.
[dependencies]
kube-cel = "0.8"kube-cel re-exports the cel crate it was built against as kube_cel::cel. Import cel types through that re-export rather than declaring a separate cel dependency, otherwise a version mismatch surfaces as a cryptic Context type error.
use kube_cel::KubeCelExt;
use kube_cel::cel::{Context, Program};
let ctx = Context::default().with_all();
// String functions
let result = Program::compile("'hello'.upperAscii()")
.unwrap().execute(&ctx).unwrap();
// Quantity comparison
let result = Program::compile("quantity('1Gi').isGreaterThan(quantity('500Mi'))")
.unwrap().execute(&ctx).unwrap();
// Semver
let result = Program::compile("semver('1.2.3').isLessThan(semver('2.0.0'))")
.unwrap().execute(&ctx).unwrap();With the validation feature, you can compile and evaluate x-kubernetes-validations CEL rules client-side — no API server required.
[dependencies]
kube-cel = { version = "0.8", features = ["validation"] }use kube_cel::Validator;
use serde_json::json;
let schema = json!({
"type": "object",
"properties": {
"spec": {
"type": "object",
"properties": {
"replicas": {
"type": "integer",
"x-kubernetes-validations": [
{"rule": "self >= 0", "message": "replicas must be non-negative"}
]
}
},
"x-kubernetes-validations": [
{"rule": "self.replicas >= 1", "message": "at least one replica"}
]
}
}
});
let object = json!({"spec": {"replicas": -1}});
let validator = Validator::new();
let errors = validator.validate(&schema, &object, None).unwrap_err();
assert_eq!(errors.len(), 2);
assert_eq!(errors[0].field_path, "spec");
assert_eq!(errors[1].field_path, "spec.replicas");Validation entry points return Result<(), ValidationErrors>: Ok(()) when every rule passes (so a caller can ?-propagate failure), and Err(ValidationErrors) otherwise. ValidationErrors derefs to [ValidationError] and is iterable, so every individual failure stays inspectable; into_vec() recovers the owned Vec.
The validator walks the schema tree, compiles rules at each node, and evaluates them with self bound to the corresponding object value. Transition rules (referencing oldSelf) are supported by passing old_object.
Fields with format: "date-time" or format: "duration" in the schema are automatically converted to CEL Timestamp / Duration values, matching K8s API server behavior:
let schema = json!({
"type": "object",
"properties": {
"expiresAt": { "type": "string", "format": "date-time" },
"timeout": { "type": "string", "format": "duration" }
},
"x-kubernetes-validations": [
{"rule": "self.expiresAt > timestamp('2024-01-01T00:00:00Z')", "message": "expired"},
{"rule": "self.timeout <= duration('1h')", "message": "too long"}
]
});Invalid strings gracefully fall back to Value::String.
JSON field names that are CEL reserved words or contain special characters are automatically escaped when converting to CEL map keys, matching K8s API server behavior:
| JSON field name | CEL access |
|---|---|
namespace |
self.__namespace__ |
foo-bar |
self.foo__dash__bar |
a.b |
self.a__dot__b |
x/y |
self.x__slash__y |
my_field |
self.my__field |
Apply schema default values before validation, matching K8s API server behavior:
use kube_cel::apply_defaults;
let schema = json!({
"type": "object",
"properties": {
"replicas": {"type": "integer", "default": 1},
"strategy": {"type": "string", "default": "RollingUpdate"}
}
});
let object = json!({"replicas": 3});
let defaulted = apply_defaults(&schema, &object);
// defaulted = {"replicas": 3, "strategy": "RollingUpdate"}Or use the convenience method:
let result = Validator::new().validate_with_defaults(&schema, &object, None);Bind CRD-level apiVersion, apiGroup, kind variables for root-level rules:
use kube_cel::{Validator, RootContext};
let root_ctx = RootContext {
api_version: "apps/v1".into(),
api_group: "apps".into(),
kind: "Deployment".into(),
};
let result = Validator::new().validate_with_context(&schema, &object, None, Some(&root_ctx));Evaluate ValidatingAdmissionPolicy CEL expressions client-side — no API server required. Supports all VAP variables except authorizer.
use kube_cel::{VapEvaluator, VapExpression, AdmissionRequest};
let evaluator = VapEvaluator::builder()
.object(json!({"spec": {"replicas": 3}}))
.request(AdmissionRequest {
operation: "CREATE".into(),
namespace: "production".into(),
..Default::default()
})
.params(json!({"maxReplicas": 5}))
.build();
let results = evaluator.evaluate(&[VapExpression {
expression: "object.spec.replicas <= params.maxReplicas".into(),
message: Some("too many replicas".into()),
message_expression: None,
}]);
assert!(results[0].passed);For repeated evaluation, pre-compile expressions:
let compiled = evaluator.compile_expressions(&expressions);
let results = evaluator.evaluate_compiled(&compiled); // no re-parsingCatch CEL rule issues before deployment — variable scope violations and cost budget warnings:
use kube_cel::{analyze_rule, ScopeContext};
use kube_cel::compile_schema;
let compiled = compile_schema(&schema);
let warnings = analyze_rule(
"self.items.all(item, item.size() > 0)",
&compiled,
ScopeContext::CrdValidation,
);
// Warns: "list field has no maxItems bound" (may exceed K8s 1M cost budget)ScopeContext::CrdValidation catches admission-only variables (request, object, etc.) used in CRD rules. ScopeContext::AdmissionPolicy allows the full VAP variable set.
charAt, indexOf, lastIndexOf, lowerAscii, upperAscii, replace, split, substring, trim, join, reverse, strings.quote
isSorted, sum, min, max, indexOf, lastIndexOf, slice, sort, flatten, reverse, distinct, first, last, lists.range
sets.contains, sets.equivalent, sets.intersects
find, findAll
url, isURL, getScheme, getHost, getHostname, getPort, getEscapedPath, getQuery
ip, isIP, isIPv4, isIPv6, ip.isCanonical, family, isLoopback, isUnspecified, isLinkLocalMulticast, isLinkLocalUnicast, isGlobalUnicast, <IP>.string(), cidr, isCIDR, isCIDRv4, isCIDRv6, containsIP, containsCIDR, prefixLength, masked, <CIDR>.ip(), <CIDR>.string()
semver, isSemver, major, minor, patch, isGreaterThan, isLessThan, compareTo
quantity, isQuantity, isInteger, asInteger, asApproximateFloat, sign, add, sub, isGreaterThan, isLessThan, compareTo
<string>.format(<list>) with verbs: %s, %d, %f, %e, %b, %o, %x, %X
format.dns1123Label, format.dns1123Subdomain, format.dns1035Label, format.dns1035LabelPrefix, format.dns1123LabelPrefix, format.dns1123SubdomainPrefix, format.qualifiedName, format.labelValue, format.uri, format.uuid, format.byte, format.date, format.datetime, format.named, validate
// Returns optional: none = valid, of([...errors]) = invalid
// K8s pattern: !format.<name>().validate(value).hasValue()
let result = Program::compile("!format.dns1123Label().validate('my-name').hasValue()")
.unwrap().execute(&ctx).unwrap();
// Value::Bool(true)
// Dynamic format lookup
let result = Program::compile("!format.named('uuid').validate('550e8400-e29b-41d4-a716-446655440000').hasValue()")
.unwrap().execute(&ctx).unwrap();
// Value::Bool(true)math.ceil, math.floor, math.round, math.trunc, math.abs, math.sign, math.sqrt, math.isInf, math.isNaN, math.isFinite, math.bitAnd, math.bitOr, math.bitXor, math.bitNot, math.bitShiftLeft, math.bitShiftRight, math.greatest, math.least
base64.decode, base64.encode
jsonpatch.escapeKey
// RFC 6901: ~ → ~0, / → ~1
let result = Program::compile("jsonpatch.escapeKey('k8s.io/my~label')")
.unwrap().execute(&ctx).unwrap();
// Value::String("k8s.io~1my~0label")Cargo features are the only granularity axis — there is no runtime per-library registration. All extension-function features are enabled by default. To narrow the set you must disable the defaults first:
# Correct: only string + list helpers.
kube-cel = { version = "0.8", default-features = false, features = ["strings", "lists"] }
# No-op narrowing: without `default-features = false` the list is ADDED to the
# already-complete default set, so you still get everything.
kube-cel = { version = "0.8", features = ["strings", "lists"] }
# Restore the whole surface (all 13 function groups + the validation engine)
# in one flag, e.g. after narrowing for a downstream build profile.
kube-cel = { version = "0.8", default-features = false, features = ["full"] }| Feature | Dependencies | Description |
|---|---|---|
strings |
- | String extension functions |
lists |
- | List extension functions |
sets |
- | Set operations |
regex_funcs |
regex |
Regex find/findAll |
urls |
url |
URL parsing and accessors |
ip |
ipnet |
IP/CIDR parsing and operations |
semver_funcs |
semver |
Semantic versioning |
format |
- | String formatting |
quantity |
- | Kubernetes resource quantities |
jsonpatch |
- | JSONPatch key escaping (RFC 6901) |
named_format |
- | Named format validation (format.dns1123Label(), etc.) |
math |
- | Math functions (math.ceil, math.abs, bitwise, etc.) |
encoders |
base64 |
Base64 encode/decode |
validation |
serde_json, serde, chrono |
CRD validation pipeline, VAP evaluation, static analysis, schema defaults |
full |
(all of the above) | Umbrella: every extension-function group plus validation. Not in default (which is functions-only) |
kube-cel is pre-1.0 and cannot reach 1.0 until the cel crate does. Its
public signatures expose cel::Context and cel::Value, and a crate cannot be
stable while its public dependencies are not (Rust API Guidelines,
C-STABLE).
Once cel reaches 1.0, kube-cel 1.x will track cel 1.y; a cel major bump
forces a kube-cel major bump. While below 1.0, minor releases may contain
breaking changes (the Cargo convention for 0.x).
Two stability tiers, by surface:
- Tier 1 — registration (committed). The
KubeCelExttrait (with_all/register_all), thepub use celre-export, and the set of registered Kubernetes CEL functions. This is the crate's core identity and changes only deliberately. - Tier 2 — validation engine (evolving,
validationfeature).Validator, the VAP evaluator, static analysis, schema defaults, and thecompilation/validation/vap/analysistypes. Still maturing; expect its surface to change across pre-1.0 minors as the validation pipeline hardens.
The validation pipeline aims to return the same verdict the Kubernetes API
server would, client-side. Where it cannot today, it diverges fail-closed —
it reports an error rather than silently accepting — so a passing result is
always at least as strict as the API server, never less. These divergences are
pinned by tests/apiserver_divergence.rs.
| Feature | API server | kube-cel | Direction |
|---|---|---|---|
<list>.sortBy(var, expr) |
evaluates | UnsupportedReference |
fail-closed |
cel.bind(var, init, expr) |
evaluates | UnsupportedReference |
fail-closed |
Two-arg comprehensions (all(i, v, …), transformList, transformMap; K8s 1.33+) |
evaluates | UnsupportedReference |
fail-closed |
| Schema nesting deeper than 64 levels | enforces (rejects over-limit schemas at registration) | SchemaTooDeep error |
fail-closed |
Rule whose messageExpression does not compile |
rejects the CRD at registration | CompilationFailure (the rule is rejected, not just its message) |
fail-closed |
Authz library (authorizer.*) |
evaluates against the cluster | not available | out of scope |
The unsupported CEL macros above parse successfully but error at evaluation
(cel 0.13 has no such reference), so they surface as the dedicated
ErrorKind::UnsupportedReference — distinct from EvaluationError (reserved for
genuine runtime errors in a supported rule), so a consumer can tell a kube-cel
coverage gap apart from an actual rule failure. Single-argument comprehensions
(list.all(x, …), map(x, …), etc.) are fully supported and do not diverge.
The macro gaps lift once the cel crate gains compiler-macro support; the authz
library requires a live API server and is out of scope for a client library.
- kube-rs - Rust Kubernetes client and controller runtime
- cel - Rust CEL interpreter
- Kubernetes CEL docs
Apache-2.0