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