Skip to main content

kube_core/cel/
mod.rs

1//! CEL validation for CRDs
2//!
3//! When the `cel` feature is enabled, this module also provides client-side CEL evaluation
4//! via Kubernetes CEL extension functions, schema compilation, and validation.
5
6use std::{collections::BTreeMap, str::FromStr};
7
8// --- Client-side CEL evaluation (feature = "cel") ---
9// Re-exported from the kube-cel crate when the `cel` feature is enabled.
10
11#[cfg(feature = "cel")]
12#[cfg_attr(docsrs, doc(cfg(feature = "cel")))]
13pub use kube_cel::*;
14
15use derive_more::From;
16#[cfg(feature = "schema")] use schemars::Schema;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20// --- Client-side CEL validation helpers (feature = "cel") ---
21//
22// These back the `validate_cel` / `validate_cel_update` methods that kube-derive generates for
23// `#[kube(cel)]` / `#[x_kube(cel)]`. The derive emits thin inherent wrappers that delegate here,
24// so the validation logic is compiled once in this crate instead of being re-expanded (and the
25// proc-macro re-parsed) at every derive site. The wrappers keep the public method surface, so the
26// derive stays the opt-in gatekeeper (only `#[kube(cel)]` types get the methods) and the
27// `schema = "manual"` compile-time rejection still lives in the macro.
28
29/// Validate a derived custom resource against its CEL creation rules (`x-kubernetes-validations`)
30/// client-side, without an apiserver. Backs the generated `Foo::validate_cel(&self)`.
31#[cfg(feature = "cel")]
32#[cfg_attr(docsrs, doc(cfg(feature = "cel")))]
33pub fn validate_cel<T>(obj: &T) -> Result<(), ValidationErrors>
34where
35    T: crate::CustomResourceExt + Serialize,
36{
37    validate_cel_with_old(obj, None)
38}
39
40/// Validate a derived custom resource against its CEL transition rules (rules using `oldSelf`)
41/// client-side, comparing against `old`. Backs the generated `Foo::validate_cel_update(&self, old)`.
42#[cfg(feature = "cel")]
43#[cfg_attr(docsrs, doc(cfg(feature = "cel")))]
44pub fn validate_cel_update<T>(obj: &T, old: &T) -> Result<(), ValidationErrors>
45where
46    T: crate::CustomResourceExt + Serialize,
47{
48    let old = serde_json::to_value(old).expect("resource serializes to JSON");
49    validate_cel_with_old(obj, Some(old))
50}
51
52#[cfg(feature = "cel")]
53fn validate_cel_with_old<T>(obj: &T, old: Option<Value>) -> Result<(), ValidationErrors>
54where
55    T: crate::CustomResourceExt + Serialize,
56{
57    let crd = T::crd();
58    let schema = serde_json::to_value(
59        crd.spec.versions[0]
60            .schema
61            .as_ref()
62            .and_then(|s| s.open_api_v3_schema.as_ref())
63            .expect("derived CRD has an openAPIV3Schema"),
64    )
65    .expect("CRD schema serializes to JSON");
66    let object = serde_json::to_value(obj).expect("resource serializes to JSON");
67    Validator::new().validate(&schema, &object, old.as_ref())
68}
69
70/// Validate a serialized value of a `KubeSchema` type against its CEL validation rules
71/// (`x-kubernetes-validations`) client-side, without an apiserver. Backs the generated static
72/// `T::validate_cel(value, old)`.
73///
74/// The schema is generated with the same openAPIV3 settings and structural transforms the CRD path
75/// uses, so the `x-kubernetes-validations` rules and structure match what an apiserver would
76/// validate; `schemars::schema_for!` (plain JSON-Schema 2020-12, non-inlined `$ref`s) would not be
77/// walkable by kube-cel.
78#[cfg(all(feature = "cel", feature = "schema"))]
79#[cfg_attr(docsrs, doc(cfg(all(feature = "cel", feature = "schema"))))]
80pub fn validate_cel_schema<T>(value: &Value, old: Option<&Value>) -> Result<(), ValidationErrors>
81where
82    T: schemars::JsonSchema,
83{
84    let generate = schemars::generate::SchemaSettings::openapi3()
85        .with(|s| {
86            s.inline_subschemas = true;
87            s.meta_schema = None;
88        })
89        .with_transform(schemars::transform::AddNullable::default())
90        .with_transform(crate::schema::StructuralSchemaRewriter)
91        .with_transform(crate::schema::OptionalEnum)
92        .with_transform(crate::schema::OptionalIntOrString)
93        .into_generator();
94    let schema = generate.into_root_schema_for::<T>();
95    let schema = serde_json::to_value(&schema).expect("schema serializes to JSON");
96    Validator::new().validate(&schema, value, old)
97}
98
99/// Rule is a CEL validation rule for the CRD field
100#[derive(Default, Serialize, Deserialize, Clone, Debug)]
101#[serde(rename_all = "camelCase")]
102pub struct Rule {
103    /// rule represents the expression which will be evaluated by CEL.
104    /// The `self` variable in the CEL expression is bound to the scoped value.
105    pub rule: String,
106    /// message represents CEL validation message for the provided type
107    /// If unset, the message is "failed rule: {Rule}".
108    #[serde(flatten)]
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub message: Option<Message>,
111    /// fieldPath represents the field path returned when the validation fails.
112    /// It must be a relative JSON path, scoped to the location of the field in the schema
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub field_path: Option<String>,
115    /// reason is a machine-readable value providing more detail about why a field failed the validation.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub reason: Option<Reason>,
118    /// optionalOldSelf allows transition rules (using oldSelf) to also evaluate during object creation
119    /// When enabled, `oldSelf` becomes a CEL `optional_type`. You must use functions like `optMap()`, `hasValue()`, or `orValue()` to safely compare it against `self`.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub optional_old_self: Option<bool>,
122}
123
124impl Rule {
125    /// Initialize the rule
126    ///
127    /// ```rust
128    /// use kube_core::Rule;
129    /// let r = Rule::new("self == oldSelf");
130    ///
131    /// assert_eq!(r.rule, "self == oldSelf".to_string())
132    /// ```
133    pub fn new(rule: impl Into<String>) -> Self {
134        Self {
135            rule: rule.into(),
136            ..Default::default()
137        }
138    }
139
140    /// Set the rule message.
141    ///
142    /// use kube_core::Rule;
143    /// ```rust
144    /// use kube_core::{Rule, Message};
145    ///
146    /// let r = Rule::new("self == oldSelf").message("is immutable");
147    /// assert_eq!(r.rule, "self == oldSelf".to_string());
148    /// assert_eq!(r.message, Some(Message::Message("is immutable".to_string())));
149    /// ```
150    pub fn message(mut self, message: impl Into<Message>) -> Self {
151        self.message = Some(message.into());
152        self
153    }
154
155    /// Set the failure reason.
156    ///
157    /// use kube_core::Rule;
158    /// ```rust
159    /// use kube_core::{Rule, Reason};
160    ///
161    /// let r = Rule::new("self == oldSelf").reason(Reason::default());
162    /// assert_eq!(r.rule, "self == oldSelf".to_string());
163    /// assert_eq!(r.reason, Some(Reason::FieldValueInvalid));
164    /// ```
165    pub fn reason(mut self, reason: impl Into<Reason>) -> Self {
166        self.reason = Some(reason.into());
167        self
168    }
169
170    /// Set the failure field_path.
171    ///
172    /// use kube_core::Rule;
173    /// ```rust
174    /// use kube_core::Rule;
175    ///
176    /// let r = Rule::new("self == oldSelf").field_path("obj.field");
177    /// assert_eq!(r.rule, "self == oldSelf".to_string());
178    /// assert_eq!(r.field_path, Some("obj.field".to_string()));
179    /// ```
180    pub fn field_path(mut self, field_path: impl Into<String>) -> Self {
181        self.field_path = Some(field_path.into());
182        self
183    }
184
185    /// Set the optionalOldSelf configuration.
186    ///
187    /// ```rust
188    /// use kube_core::Rule;
189    ///
190    /// let r = Rule::new("oldSelf.optMap(o, o == self).orValue(true)").optional_old_self(true);
191    /// assert_eq!(r.optional_old_self, Some(true));
192    /// ```
193    pub fn optional_old_self(mut self, optional: bool) -> Self {
194        self.optional_old_self = Some(optional);
195        self
196    }
197}
198
199impl From<&str> for Rule {
200    fn from(value: &str) -> Self {
201        Self {
202            rule: value.into(),
203            ..Default::default()
204        }
205    }
206}
207
208impl From<(&str, &str)> for Rule {
209    fn from((rule, msg): (&str, &str)) -> Self {
210        Self {
211            rule: rule.into(),
212            message: Some(msg.into()),
213            ..Default::default()
214        }
215    }
216}
217/// Message represents CEL validation message for the provided type
218#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
219#[serde(rename_all = "lowercase")]
220pub enum Message {
221    /// Message represents the message displayed when validation fails. The message is required if the Rule contains
222    /// line breaks. The message must not contain line breaks.
223    /// Example:
224    /// "must be a URL with the host matching spec.host"
225    Message(String),
226    /// Expression declares a CEL expression that evaluates to the validation failure message that is returned when this rule fails.
227    /// Since messageExpression is used as a failure message, it must evaluate to a string. If messageExpression results in a runtime error, the runtime error is logged, and the validation failure message is produced
228    /// as if the messageExpression field were unset. If messageExpression evaluates to an empty string, a string with only spaces, or a string
229    /// that contains line breaks, then the validation failure message will also be produced as if the messageExpression field were unset, and
230    /// the fact that messageExpression produced an empty string/string with only spaces/string with line breaks will be logged.
231    /// messageExpression has access to all the same variables as the rule; the only difference is the return type.
232    /// Example:
233    /// "x must be less than max ("+string(self.max)+")"
234    #[serde(rename = "messageExpression")]
235    Expression(String),
236}
237
238impl From<&str> for Message {
239    fn from(value: &str) -> Self {
240        Message::Message(value.to_string())
241    }
242}
243
244/// Reason is a machine-readable value providing more detail about why a field failed the validation.
245///
246/// More in [docs](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#field-reason)
247#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq)]
248pub enum Reason {
249    /// FieldValueInvalid is used to report malformed values (e.g. failed regex
250    /// match, too long, out of bounds).
251    #[default]
252    FieldValueInvalid,
253    /// FieldValueForbidden is used to report valid (as per formatting rules)
254    /// values which would be accepted under some conditions, but which are not
255    /// permitted by the current conditions (such as security policy).
256    FieldValueForbidden,
257    /// FieldValueRequired is used to report required values that are not
258    /// provided (e.g. empty strings, null values, or empty arrays).
259    FieldValueRequired,
260    /// FieldValueDuplicate is used to report collisions of values that must be
261    /// unique (e.g. unique IDs).
262    FieldValueDuplicate,
263}
264
265impl FromStr for Reason {
266    type Err = serde_json::Error;
267
268    fn from_str(s: &str) -> Result<Self, Self::Err> {
269        serde_json::from_str(s)
270    }
271}
272
273/// Validate takes schema and applies a set of validation rules to it. The rules are stored
274/// on the top level under the "x-kubernetes-validations".
275///
276/// ```rust
277/// use schemars::Schema;
278/// use kube::core::{Rule, Reason, Message, validate};
279///
280/// let mut schema = Schema::default();
281/// let rule = Rule{
282///     rule: "self.spec.host == self.url.host".into(),
283///     message: Some("must be a URL with the host matching spec.host".into()),
284///     field_path: Some("spec.host".into()),
285///     ..Default::default()
286/// };
287/// validate(&mut schema, rule)?;
288/// assert_eq!(
289///     serde_json::to_string(&schema).unwrap(),
290///     r#"{"x-kubernetes-validations":[{"fieldPath":"spec.host","message":"must be a URL with the host matching spec.host","rule":"self.spec.host == self.url.host"}]}"#,
291/// );
292/// # Ok::<(), serde_json::Error>(())
293///```
294#[cfg(feature = "schema")]
295#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
296pub fn validate(s: &mut Schema, rule: impl Into<Rule>) -> serde_json::Result<()> {
297    let rule: Rule = rule.into();
298    let rule = serde_json::to_value(rule)?;
299    s.ensure_object()
300        .entry("x-kubernetes-validations")
301        .and_modify(|rules| {
302            if let Value::Array(rules) = rules {
303                rules.push(rule.clone());
304            }
305        })
306        .or_insert(serde_json::to_value(&[rule])?);
307    Ok(())
308}
309
310/// Validate property mutates property under property_index of the schema
311/// with the provided set of validation rules.
312///
313/// ```rust
314/// use schemars::JsonSchema;
315/// use kube::core::{Rule, validate_property};
316///
317/// #[derive(JsonSchema)]
318/// struct MyStruct {
319///     field: Option<String>,
320/// }
321///
322/// let generate = &mut schemars::generate::SchemaSettings::openapi3().into_generator();
323/// let mut schema = MyStruct::json_schema(generate);
324/// let rule = Rule::new("self != oldSelf");
325/// validate_property(&mut schema, 0, rule)?;
326/// assert_eq!(
327///     serde_json::to_string(&schema).unwrap(),
328///     r#"{"type":"object","properties":{"field":{"type":["string","null"],"x-kubernetes-validations":[{"rule":"self != oldSelf"}]}}}"#
329/// );
330/// # Ok::<(), serde_json::Error>(())
331///```
332#[cfg(feature = "schema")]
333#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
334pub fn validate_property(
335    s: &mut Schema,
336    property_index: usize,
337    rule: impl Into<Rule> + Clone,
338) -> serde_json::Result<()> {
339    if let Some(properties) = s.properties_mut() {
340        for (n, (_, schema)) in properties.iter_mut().enumerate() {
341            if n == property_index {
342                let mut prop = Schema::try_from(schema.clone())?;
343                validate(&mut prop, rule.clone())?;
344                *schema = prop.to_value();
345            }
346        }
347    }
348    Ok(())
349}
350
351/// Merge schema properties in order to pass overrides or extension properties from the other schema.
352///
353/// ```rust
354/// use schemars::JsonSchema;
355/// use kube::core::{Rule, merge_properties};
356///
357/// #[derive(JsonSchema)]
358/// struct MyStruct {
359///     a: Option<bool>,
360/// }
361///
362/// #[derive(JsonSchema)]
363/// struct MySecondStruct {
364///     a: bool,
365///     b: Option<bool>,
366/// }
367/// let generate = &mut schemars::generate::SchemaSettings::openapi3().into_generator();
368/// let mut first = MyStruct::json_schema(generate);
369/// let mut second = MySecondStruct::json_schema(generate);
370/// merge_properties(&mut first, &mut second);
371///
372/// assert_eq!(
373///     serde_json::to_string(&first).unwrap(),
374///     r#"{"type":"object","properties":{"a":{"type":"boolean"},"b":{"type":["boolean","null"]}}}"#
375/// );
376/// # Ok::<(), serde_json::Error>(())
377#[cfg(feature = "schema")]
378#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
379pub fn merge_properties(s: &mut Schema, merge: &mut Schema) {
380    if let Some(properties) = s.properties_mut()
381        && let Some(merge_properties) = merge.properties_mut()
382    {
383        for (k, v) in merge_properties {
384            properties.insert(k.clone(), v.clone());
385        }
386    }
387}
388
389/// ListType represents x-kubernetes merge strategy for list.
390#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
391#[serde(rename_all = "lowercase")]
392pub enum ListMerge {
393    /// Atomic represents a list, where entire list is replaced during merge. At any point in time, a single manager owns the list.
394    Atomic,
395    /// Set applies to lists that include only scalar elements. These elements must be unique.
396    Set,
397    /// Map applies to lists of nested types only. The key values must be unique in the list.
398    Map(Vec<String>),
399}
400
401/// MapMerge represents x-kubernetes merge strategy for map.
402#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
403#[serde(rename_all = "lowercase")]
404pub enum MapMerge {
405    /// Atomic represents a map, which can only be entirely replaced by a single manager.
406    Atomic,
407    /// Granular represents a map, which supports separate managers updating individual fields.
408    Granular,
409}
410
411/// StructMerge represents x-kubernetes merge strategy for struct.
412#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
413#[serde(rename_all = "lowercase")]
414pub enum StructMerge {
415    /// Atomic represents a struct, which can only be entirely replaced by a single manager.
416    Atomic,
417    /// Granular represents a struct, which supports separate managers updating individual fields.
418    Granular,
419}
420
421/// MergeStrategy represents set of options for a server-side merge strategy applied to a field.
422///
423/// See upstream documentation of values at https://kubernetes.io/docs/reference/using-api/server-side-apply/#merge-strategy
424#[derive(From, Serialize, Deserialize, Clone, Debug, PartialEq)]
425pub enum MergeStrategy {
426    /// ListType represents x-kubernetes merge strategy for list.
427    #[serde(rename = "x-kubernetes-list-type")]
428    ListType(ListMerge),
429    /// MapType represents x-kubernetes merge strategy for map.
430    #[serde(rename = "x-kubernetes-map-type")]
431    MapType(MapMerge),
432    /// StructType represents x-kubernetes merge strategy for struct.
433    #[serde(rename = "x-kubernetes-struct-type")]
434    StructType(StructMerge),
435}
436
437impl MergeStrategy {
438    #[allow(dead_code)] // only used in some feature combinations
439    fn keys(self) -> serde_json::Result<BTreeMap<String, Value>> {
440        if let Self::ListType(ListMerge::Map(keys)) = self {
441            let mut data = BTreeMap::new();
442            data.insert("x-kubernetes-list-type".into(), "map".into());
443            data.insert("x-kubernetes-list-map-keys".into(), serde_json::to_value(&keys)?);
444
445            return Ok(data);
446        }
447
448        let value = serde_json::to_value(self)?;
449        serde_json::from_value(value)
450    }
451}
452
453/// Merge strategy property mutates property under property_index of the schema
454/// with the provided set of merge strategy rules.
455///
456/// ```rust
457/// use schemars::JsonSchema;
458/// use kube::core::{MapMerge, merge_strategy_property};
459///
460/// #[derive(JsonSchema)]
461/// struct MyStruct {
462///     field: Option<String>,
463/// }
464///
465/// let generate = &mut schemars::generate::SchemaSettings::openapi3().into_generator();
466/// let mut schema = MyStruct::json_schema(generate);
467/// merge_strategy_property(&mut schema, 0, MapMerge::Atomic)?;
468/// assert_eq!(
469///     serde_json::to_string(&schema).unwrap(),
470///     r#"{"type":"object","properties":{"field":{"type":["string","null"],"x-kubernetes-map-type":"atomic"}}}"#
471/// );
472///
473/// # Ok::<(), serde_json::Error>(())
474///```
475#[cfg(feature = "schema")]
476#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
477pub fn merge_strategy_property(
478    s: &mut Schema,
479    property_index: usize,
480    strategy: impl Into<MergeStrategy>,
481) -> serde_json::Result<()> {
482    if let Some(properties) = s.properties_mut() {
483        for (n, (_, schema)) in properties.iter_mut().enumerate() {
484            if n == property_index {
485                return merge_strategy(schema, strategy.into());
486            }
487        }
488    }
489
490    Ok(())
491}
492
493/// Merge strategy takes schema and applies a set of merge strategy x-kubernetes rules to it,
494/// such as "x-kubernetes-list-type" and "x-kubernetes-list-map-keys".
495///
496/// ```rust
497/// use kube::core::{ListMerge, Reason, Message, merge_strategy};
498///
499/// let mut schema = serde_json::Value::Object(Default::default());
500/// merge_strategy(&mut schema, ListMerge::Map(vec!["key".into(),"another".into()]).into())?;
501/// assert_eq!(
502///     serde_json::to_string(&schema).unwrap(),
503///     r#"{"x-kubernetes-list-map-keys":["key","another"],"x-kubernetes-list-type":"map"}"#,
504/// );
505///
506/// # Ok::<(), serde_json::Error>(())
507///```
508#[cfg(feature = "schema")]
509#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
510pub fn merge_strategy(s: &mut Value, strategy: MergeStrategy) -> serde_json::Result<()> {
511    for (key, value) in strategy.keys()? {
512        if let Some(s) = s.as_object_mut() {
513            s.insert(key, value);
514        }
515    }
516    Ok(())
517}
518
519#[cfg(feature = "schema")]
520trait SchemaExt {
521    fn properties_mut(&mut self) -> Option<&mut serde_json::Map<String, Value>>;
522}
523
524#[cfg(feature = "schema")]
525impl SchemaExt for Schema {
526    fn properties_mut(&mut self) -> Option<&mut serde_json::Map<String, Value>> {
527        self.ensure_object()
528            .entry("properties")
529            .or_insert_with(|| Value::Object(Default::default()))
530            .as_object_mut()
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use serde_json::json;
538
539    #[test]
540    fn test_rule_serialization_optional_old_self() {
541        let rule_str = "oldSelf.optMap(o, o == self).orValue(true)";
542        let r = Rule::new(rule_str).optional_old_self(true);
543
544        // Test Serialization
545        let json = serde_json::to_value(&r).unwrap();
546        assert_eq!(
547            json,
548            json!({
549                "rule": rule_str,
550                "optionalOldSelf": true
551            })
552        );
553
554        // Test Round-trip (Deserialization)
555        let r2: Rule = serde_json::from_value(json).unwrap();
556        assert_eq!(r2.rule, rule_str);
557        assert_eq!(r2.optional_old_self, Some(true));
558    }
559}