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