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>) -> serde_json::Result<()> {
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) -> serde_json::Result<()> {
233 if let Some(properties) = s.properties_mut() {
234 for (n, (_, schema)) in properties.iter_mut().enumerate() {
235 if n == property_index {
236 let mut prop = Schema::try_from(schema.clone())?;
237 validate(&mut prop, rule.clone())?;
238 *schema = prop.to_value();
239 }
240 }
241 }
242 Ok(())
243}
244
245/// Merge schema properties in order to pass overrides or extension properties from the other schema.
246///
247/// ```rust
248/// use schemars::JsonSchema;
249/// use kube::core::{Rule, merge_properties};
250///
251/// #[derive(JsonSchema)]
252/// struct MyStruct {
253/// a: Option<bool>,
254/// }
255///
256/// #[derive(JsonSchema)]
257/// struct MySecondStruct {
258/// a: bool,
259/// b: Option<bool>,
260/// }
261/// let generate = &mut schemars::generate::SchemaSettings::openapi3().into_generator();
262/// let mut first = MyStruct::json_schema(generate);
263/// let mut second = MySecondStruct::json_schema(generate);
264/// merge_properties(&mut first, &mut second);
265///
266/// assert_eq!(
267/// serde_json::to_string(&first).unwrap(),
268/// r#"{"type":"object","properties":{"a":{"type":"boolean"},"b":{"type":["boolean","null"]}}}"#
269/// );
270/// # Ok::<(), serde_json::Error>(())
271#[cfg(feature = "schema")]
272#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
273pub fn merge_properties(s: &mut Schema, merge: &mut Schema) {
274 if let Some(properties) = s.properties_mut()
275 && let Some(merge_properties) = merge.properties_mut()
276 {
277 for (k, v) in merge_properties {
278 properties.insert(k.clone(), v.clone());
279 }
280 }
281}
282
283/// ListType represents x-kubernetes merge strategy for list.
284#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
285#[serde(rename_all = "lowercase")]
286pub enum ListMerge {
287 /// Atomic represents a list, where entire list is replaced during merge. At any point in time, a single manager owns the list.
288 Atomic,
289 /// Set applies to lists that include only scalar elements. These elements must be unique.
290 Set,
291 /// Map applies to lists of nested types only. The key values must be unique in the list.
292 Map(Vec<String>),
293}
294
295/// MapMerge represents x-kubernetes merge strategy for map.
296#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
297#[serde(rename_all = "lowercase")]
298pub enum MapMerge {
299 /// Atomic represents a map, which can only be entirely replaced by a single manager.
300 Atomic,
301 /// Granular represents a map, which supports separate managers updating individual fields.
302 Granular,
303}
304
305/// StructMerge represents x-kubernetes merge strategy for struct.
306#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
307#[serde(rename_all = "lowercase")]
308pub enum StructMerge {
309 /// Atomic represents a struct, which can only be entirely replaced by a single manager.
310 Atomic,
311 /// Granular represents a struct, which supports separate managers updating individual fields.
312 Granular,
313}
314
315/// MergeStrategy represents set of options for a server-side merge strategy applied to a field.
316///
317/// See upstream documentation of values at https://kubernetes.io/docs/reference/using-api/server-side-apply/#merge-strategy
318#[derive(From, Serialize, Deserialize, Clone, Debug, PartialEq)]
319pub enum MergeStrategy {
320 /// ListType represents x-kubernetes merge strategy for list.
321 #[serde(rename = "x-kubernetes-list-type")]
322 ListType(ListMerge),
323 /// MapType represents x-kubernetes merge strategy for map.
324 #[serde(rename = "x-kubernetes-map-type")]
325 MapType(MapMerge),
326 /// StructType represents x-kubernetes merge strategy for struct.
327 #[serde(rename = "x-kubernetes-struct-type")]
328 StructType(StructMerge),
329}
330
331impl MergeStrategy {
332 fn keys(self) -> serde_json::Result<BTreeMap<String, Value>> {
333 if let Self::ListType(ListMerge::Map(keys)) = self {
334 let mut data = BTreeMap::new();
335 data.insert("x-kubernetes-list-type".into(), "map".into());
336 data.insert("x-kubernetes-list-map-keys".into(), serde_json::to_value(&keys)?);
337
338 return Ok(data);
339 }
340
341 let value = serde_json::to_value(self)?;
342 serde_json::from_value(value)
343 }
344}
345
346/// Merge strategy property mutates property under property_index of the schema
347/// with the provided set of merge strategy rules.
348///
349/// ```rust
350/// use schemars::JsonSchema;
351/// use kube::core::{MapMerge, merge_strategy_property};
352///
353/// #[derive(JsonSchema)]
354/// struct MyStruct {
355/// field: Option<String>,
356/// }
357///
358/// let generate = &mut schemars::generate::SchemaSettings::openapi3().into_generator();
359/// let mut schema = MyStruct::json_schema(generate);
360/// merge_strategy_property(&mut schema, 0, MapMerge::Atomic)?;
361/// assert_eq!(
362/// serde_json::to_string(&schema).unwrap(),
363/// r#"{"type":"object","properties":{"field":{"type":["string","null"],"x-kubernetes-map-type":"atomic"}}}"#
364/// );
365///
366/// # Ok::<(), serde_json::Error>(())
367///```
368#[cfg(feature = "schema")]
369#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
370pub fn merge_strategy_property(
371 s: &mut Schema,
372 property_index: usize,
373 strategy: impl Into<MergeStrategy>,
374) -> serde_json::Result<()> {
375 if let Some(properties) = s.properties_mut() {
376 for (n, (_, schema)) in properties.iter_mut().enumerate() {
377 if n == property_index {
378 return merge_strategy(schema, strategy.into());
379 }
380 }
381 }
382
383 Ok(())
384}
385
386/// Merge strategy takes schema and applies a set of merge strategy x-kubernetes rules to it,
387/// such as "x-kubernetes-list-type" and "x-kubernetes-list-map-keys".
388///
389/// ```rust
390/// use kube::core::{ListMerge, Reason, Message, merge_strategy};
391///
392/// let mut schema = serde_json::Value::Object(Default::default());
393/// merge_strategy(&mut schema, ListMerge::Map(vec!["key".into(),"another".into()]).into())?;
394/// assert_eq!(
395/// serde_json::to_string(&schema).unwrap(),
396/// r#"{"x-kubernetes-list-map-keys":["key","another"],"x-kubernetes-list-type":"map"}"#,
397/// );
398///
399/// # Ok::<(), serde_json::Error>(())
400///```
401#[cfg(feature = "schema")]
402#[cfg_attr(docsrs, doc(cfg(feature = "schema")))]
403pub fn merge_strategy(s: &mut Value, strategy: MergeStrategy) -> serde_json::Result<()> {
404 for (key, value) in strategy.keys()? {
405 if let Some(s) = s.as_object_mut() {
406 s.insert(key, value);
407 }
408 }
409 Ok(())
410}
411
412#[cfg(feature = "schema")]
413trait SchemaExt {
414 fn properties_mut(&mut self) -> Option<&mut serde_json::Map<String, Value>>;
415}
416
417#[cfg(feature = "schema")]
418impl SchemaExt for Schema {
419 fn properties_mut(&mut self) -> Option<&mut serde_json::Map<String, Value>> {
420 self.ensure_object()
421 .entry("properties")
422 .or_insert_with(|| Value::Object(Default::default()))
423 .as_object_mut()
424 }
425}