Skip to main content

kube_core/
cel.rs

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