Skip to main content

kube_cel/validation/
compilation.rs

1//! Compilation of Kubernetes CRD `x-kubernetes-validations` rules into CEL programs.
2//!
3//! This module parses validation rules from CRD schemas and compiles them into
4//! [`cel::Program`] instances that can be evaluated against resource data.
5
6use std::collections::HashMap;
7
8use cel::Program;
9
10use crate::validation::values::SchemaFormat;
11
12/// A single CRD `x-kubernetes-validations` rule.
13#[derive(Clone, Debug, serde::Deserialize)]
14#[serde(rename_all = "camelCase")]
15pub struct Rule {
16    /// The CEL expression to evaluate.
17    pub rule: String,
18    /// Static error message returned when validation fails.
19    #[serde(default)]
20    pub message: Option<String>,
21    /// CEL expression that produces a dynamic error message.
22    #[serde(default)]
23    pub message_expression: Option<String>,
24    /// Machine-readable reason for the validation failure (e.g. "FieldValueForbidden").
25    #[serde(default)]
26    pub reason: Option<String>,
27    /// JSONPath to the field that caused the failure.
28    #[serde(default)]
29    pub field_path: Option<String>,
30    /// Whether `oldSelf` is optional. When `true`, transition rules are
31    /// evaluated even on create (with `oldSelf` bound to null).
32    #[serde(default)]
33    pub optional_old_self: Option<bool>,
34}
35
36/// The result of successfully compiling a [`Rule`].
37///
38/// `#[non_exhaustive]`: an output type the crate constructs; new fields may be
39/// added without a breaking change.
40#[derive(Debug)]
41#[non_exhaustive]
42pub struct CompilationResult {
43    /// The compiled CEL program.
44    pub program: Program,
45    /// The original rule that was compiled.
46    pub rule: Rule,
47    /// Whether the rule references `oldSelf` (transition rule).
48    pub is_transition_rule: bool,
49    /// Pre-compiled `messageExpression` program, or `None` if the rule had no
50    /// `messageExpression`. A `messageExpression` that fails to compile yields a
51    /// [`CompilationError::MessageExpressionParse`] instead of a `None` here.
52    pub message_program: Option<Program>,
53}
54
55/// Errors that can occur during rule compilation.
56#[derive(Debug)]
57#[non_exhaustive]
58pub enum CompilationError {
59    /// CEL expression failed to parse.
60    Parse {
61        /// The original CEL expression that failed to compile.
62        rule: String,
63        /// The boxed parse error reported by the CEL compiler. Boxed (rather
64        /// than carrying the concrete `cel::ParseErrors`) so the pre-1.0 `cel`
65        /// type is not frozen into this public enum variant; reach it via
66        /// [`Error::source`](std::error::Error::source).
67        source: Box<dyn std::error::Error + Send + Sync>,
68    },
69    /// A rule's `messageExpression` failed to parse. The `rule` itself is
70    /// well-formed, but its dynamic error message cannot be compiled. Surfaced as
71    /// a compilation error (rather than silently dropped) so the rule fails
72    /// closed, mirroring the apiserver, which rejects such a CRD at registration.
73    MessageExpressionParse {
74        /// The rule whose `messageExpression` failed to compile.
75        rule: String,
76        /// The `messageExpression` that failed to compile.
77        message_expression: String,
78        /// The boxed parse error reported by the CEL compiler. Boxed (rather
79        /// than carrying the concrete `cel::ParseErrors`) so the pre-1.0 `cel`
80        /// type is not frozen into this public enum variant; reach it via
81        /// [`Error::source`](std::error::Error::source).
82        source: Box<dyn std::error::Error + Send + Sync>,
83    },
84    /// JSON value could not be deserialized into a [`Rule`].
85    InvalidRule(serde_json::Error),
86    /// Schema nesting exceeded the maximum depth. The over-deep subtree was
87    /// refused rather than silently truncated, so a too-deep schema cannot
88    /// quietly drop the validation rules nested beneath the cap (fail-closed).
89    SchemaTooDeep {
90        /// The nesting depth at which the limit was exceeded.
91        depth: usize,
92    },
93}
94
95impl std::fmt::Display for CompilationError {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            CompilationError::Parse { rule, source } => {
99                write!(f, "failed to compile CEL rule \"{rule}\": {source}")
100            }
101            CompilationError::MessageExpressionParse {
102                rule,
103                message_expression,
104                source,
105            } => {
106                write!(
107                    f,
108                    "failed to compile messageExpression \"{message_expression}\" for rule \"{rule}\": {source}"
109                )
110            }
111            CompilationError::InvalidRule(err) => {
112                write!(f, "invalid rule definition: {err}")
113            }
114            CompilationError::SchemaTooDeep { depth } => {
115                write!(
116                    f,
117                    "schema nesting depth {depth} exceeds the maximum of {MAX_SCHEMA_DEPTH}"
118                )
119            }
120        }
121    }
122}
123
124impl std::error::Error for CompilationError {
125    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
126        match self {
127            CompilationError::Parse { source, .. } => Some(source.as_ref()),
128            CompilationError::MessageExpressionParse { source, .. } => Some(source.as_ref()),
129            CompilationError::InvalidRule(err) => Some(err),
130            CompilationError::SchemaTooDeep { .. } => None,
131        }
132    }
133}
134
135/// Compile a single [`Rule`] into a [`CompilationResult`].
136///
137/// Returns [`CompilationError::Parse`] if the rule expression is invalid, or
138/// [`CompilationError::MessageExpressionParse`] if its `messageExpression` is.
139pub(crate) fn compile_rule(rule: &Rule) -> Result<CompilationResult, CompilationError> {
140    let program = Program::compile(&rule.rule).map_err(|e| CompilationError::Parse {
141        rule: rule.rule.clone(),
142        source: Box::new(e),
143    })?;
144    let is_transition_rule = program.references().has_variable("oldSelf");
145
146    // Compile messageExpression if present. A failure fails closed (the apiserver
147    // rejects such a CRD at registration), mirroring the `rule` path above rather
148    // than silently dropping the dynamic message.
149    let message_program = match rule.message_expression.as_deref() {
150        Some(expr) => Some(
151            Program::compile(expr).map_err(|e| CompilationError::MessageExpressionParse {
152                rule: rule.rule.clone(),
153                message_expression: expr.to_string(),
154                source: Box::new(e),
155            })?,
156        ),
157        None => None,
158    };
159
160    Ok(CompilationResult {
161        program,
162        rule: rule.clone(),
163        is_transition_rule,
164        message_program,
165    })
166}
167
168/// Extract `x-kubernetes-validations` rules from a schema node and compile them.
169///
170/// If the schema has no `x-kubernetes-validations` key or it is not an array,
171/// returns an empty `Vec`. Each rule is compiled independently — failures in one
172/// rule do not prevent others from compiling.
173pub(crate) fn compile_schema_validations(
174    schema: &serde_json::Value,
175) -> Vec<Result<CompilationResult, CompilationError>> {
176    let rules = match schema.get("x-kubernetes-validations") {
177        Some(serde_json::Value::Array(arr)) => arr,
178        _ => return Vec::new(),
179    };
180
181    rules
182        .iter()
183        .map(|raw| {
184            let rule: Rule = serde_json::from_value(raw.clone()).map_err(CompilationError::InvalidRule)?;
185            compile_rule(&rule)
186        })
187        .collect()
188}
189
190/// A pre-compiled schema tree. Compile once with [`compile_schema`], then
191/// validate many objects via [`Validator::validate_compiled`](crate::Validator::validate_compiled).
192///
193/// # Note
194///
195/// `CompiledSchema` is not `Clone` because [`cel::Program`] is `!Clone`.
196/// Wrap in [`Arc`](std::sync::Arc) for shared ownership across threads.
197///
198/// `#[non_exhaustive]`: an output type the crate constructs; new fields may be
199/// added without a breaking change. Read its fields directly or via the
200/// accessor methods below.
201#[derive(Debug)]
202#[non_exhaustive]
203pub struct CompiledSchema {
204    /// Compiled validation rules at this schema node.
205    pub validations: Vec<Result<CompilationResult, CompilationError>>,
206    /// Compiled child property schemas.
207    pub properties: HashMap<String, CompiledSchema>,
208    /// Compiled array items schema.
209    pub items: Option<Box<CompiledSchema>>,
210    /// Compiled additionalProperties schema.
211    pub additional_properties: Option<Box<CompiledSchema>>,
212    /// Whether this node is a map type — its `additionalProperties` is a schema
213    /// or `true` (i.e. not absent/`false`). Map keys are user-supplied data, not
214    /// declared field names, so they are NOT field-name escaped during value
215    /// conversion, matching the apiserver's `MapValue` behavior (kube-rs/kube-cel#8).
216    pub is_map: bool,
217    /// The `format` hint from the schema (e.g., `date-time`, `duration`).
218    pub format: SchemaFormat,
219    /// Compiled `allOf` branch schemas.
220    pub all_of: Vec<CompiledSchema>,
221    /// Compiled `oneOf` branch schemas.
222    pub one_of: Vec<CompiledSchema>,
223    /// Compiled `anyOf` branch schemas.
224    pub any_of: Vec<CompiledSchema>,
225    /// `maxLength` from the schema (for cost estimation).
226    pub max_length: Option<u64>,
227    /// `maxItems` from the schema (for cost estimation).
228    pub max_items: Option<u64>,
229    /// `maxProperties` from the schema (for cost estimation).
230    pub max_properties: Option<u64>,
231    /// Whether `x-kubernetes-preserve-unknown-fields: true` is set on this node.
232    /// When true, `additionalProperties` walking is skipped.
233    pub preserve_unknown_fields: bool,
234    /// Whether `x-kubernetes-embedded-resource: true` is set on this node.
235    /// When true, `apiVersion`, `kind`, and `metadata` keys are injected with
236    /// defaults if absent during value conversion.
237    pub embedded_resource: bool,
238    /// The `default` value declared on this schema node, if any. Used by the
239    /// defaulting pass to fill a missing field before CEL evaluation, mirroring
240    /// the apiserver's admission order (defaults applied before CEL rules run).
241    pub default: Option<serde_json::Value>,
242}
243
244impl CompiledSchema {
245    /// Returns references to all compilation errors in this node's validations.
246    #[must_use]
247    pub fn compilation_errors(&self) -> Vec<&CompilationError> {
248        self.validations.iter().filter_map(|r| r.as_ref().err()).collect()
249    }
250
251    /// Returns `true` if any validation rule at this node failed to compile.
252    #[must_use]
253    pub fn has_errors(&self) -> bool {
254        self.validations.iter().any(|r| r.is_err())
255    }
256}
257
258/// Maximum schema nesting depth to prevent unbounded recursion.
259///
260/// Shared across compile (`compilation`), validate (`validation`), and default
261/// injection (`defaults`) so the three depth guards stay in lockstep.
262pub(crate) const MAX_SCHEMA_DEPTH: usize = 64;
263
264fn compile_schema_array(schema: &serde_json::Value, key: &str, depth: usize) -> Vec<CompiledSchema> {
265    schema
266        .get(key)
267        .and_then(|v| v.as_array())
268        .map(|arr| arr.iter().map(|s| compile_schema_inner(s, depth)).collect())
269        .unwrap_or_default()
270}
271
272/// Recursively compile all `x-kubernetes-validations` rules in a schema tree.
273///
274/// Returns a [`CompiledSchema`] that can be reused across multiple validation
275/// calls, avoiding repeated compilation.
276#[must_use]
277pub fn compile_schema(schema: &serde_json::Value) -> CompiledSchema {
278    compile_schema_inner(schema, 0)
279}
280
281fn compile_schema_inner(schema: &serde_json::Value, depth: usize) -> CompiledSchema {
282    if depth > MAX_SCHEMA_DEPTH {
283        // Fail-closed: carry a SchemaTooDeep marker rather than returning a
284        // silently-empty node, so `compilation_errors()` and the validators
285        // surface the truncation instead of dropping the deep rules.
286        return CompiledSchema {
287            validations: vec![Err(CompilationError::SchemaTooDeep { depth })],
288            properties: HashMap::new(),
289            items: None,
290            additional_properties: None,
291            is_map: false,
292            format: SchemaFormat::None,
293            all_of: Vec::new(),
294            one_of: Vec::new(),
295            any_of: Vec::new(),
296            max_length: None,
297            max_items: None,
298            max_properties: None,
299            preserve_unknown_fields: false,
300            embedded_resource: false,
301            default: None,
302        };
303    }
304
305    let validations = compile_schema_validations(schema);
306
307    let mut properties = HashMap::new();
308    if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
309        for (name, prop_schema) in props {
310            properties.insert(name.clone(), compile_schema_inner(prop_schema, depth + 1));
311        }
312    }
313
314    let items = schema
315        .get("items")
316        .map(|s| Box::new(compile_schema_inner(s, depth + 1)));
317
318    let additional = schema.get("additionalProperties");
319    let additional_properties = additional
320        .filter(|a| a.is_object())
321        .map(|s| Box::new(compile_schema_inner(s, depth + 1)));
322    // A node whose `additionalProperties` is a schema or `true` (not absent/`false`)
323    // is a map; its keys are user data and must not be field-name escaped (#8).
324    let is_map = !matches!(additional, None | Some(serde_json::Value::Bool(false)));
325
326    let format = SchemaFormat::from_schema(schema);
327
328    let all_of = compile_schema_array(schema, "allOf", depth + 1);
329    let one_of = compile_schema_array(schema, "oneOf", depth + 1);
330    let any_of = compile_schema_array(schema, "anyOf", depth + 1);
331
332    let max_length = schema.get("maxLength").and_then(|v| v.as_u64());
333    let max_items = schema.get("maxItems").and_then(|v| v.as_u64());
334    let max_properties = schema.get("maxProperties").and_then(|v| v.as_u64());
335
336    let preserve_unknown_fields = schema
337        .get("x-kubernetes-preserve-unknown-fields")
338        .and_then(|v| v.as_bool())
339        == Some(true);
340
341    let embedded_resource = schema
342        .get("x-kubernetes-embedded-resource")
343        .and_then(|v| v.as_bool())
344        == Some(true);
345
346    let default = schema.get("default").cloned();
347
348    CompiledSchema {
349        validations,
350        properties,
351        items,
352        additional_properties,
353        is_map,
354        format,
355        all_of,
356        one_of,
357        any_of,
358        max_length,
359        max_items,
360        max_properties,
361        preserve_unknown_fields,
362        embedded_resource,
363        default,
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use serde_json::json;
371
372    #[test]
373    fn compile_simple_rule() {
374        let rule = Rule {
375            rule: "self.replicas >= 0".into(),
376            message: None,
377            message_expression: None,
378            reason: None,
379            field_path: None,
380            optional_old_self: None,
381        };
382        let result = compile_rule(&rule).unwrap();
383        assert!(!result.is_transition_rule);
384    }
385
386    #[test]
387    fn detect_transition_rule() {
388        let rule = Rule {
389            rule: "self.replicas >= oldSelf.replicas".into(),
390            message: None,
391            message_expression: None,
392            reason: None,
393            field_path: None,
394            optional_old_self: None,
395        };
396        let result = compile_rule(&rule).unwrap();
397        assert!(result.is_transition_rule);
398    }
399
400    #[test]
401    fn detect_non_transition_rule() {
402        let rule = Rule {
403            rule: "self.replicas > 0".into(),
404            message: None,
405            message_expression: None,
406            reason: None,
407            field_path: None,
408            optional_old_self: None,
409        };
410        let result = compile_rule(&rule).unwrap();
411        assert!(!result.is_transition_rule);
412    }
413
414    #[test]
415    fn parse_error_on_invalid_cel() {
416        let rule = Rule {
417            rule: "self.replicas >=".into(),
418            message: None,
419            message_expression: None,
420            reason: None,
421            field_path: None,
422            optional_old_self: None,
423        };
424        let err = compile_rule(&rule).unwrap_err();
425        assert!(matches!(err, CompilationError::Parse { .. }));
426        // Display should contain the rule text
427        let msg = err.to_string();
428        assert!(msg.contains("self.replicas >="));
429    }
430
431    #[test]
432    fn deserialize_rule_all_fields() {
433        let raw = json!({
434            "rule": "self.x > 0",
435            "message": "x must be positive",
436            "messageExpression": "\"x is \" + string(self.x)",
437            "reason": "FieldValueInvalid",
438            "fieldPath": ".spec.x",
439            "optionalOldSelf": true
440        });
441        let rule: Rule = serde_json::from_value(raw).unwrap();
442        assert_eq!(rule.rule, "self.x > 0");
443        assert_eq!(rule.message.as_deref(), Some("x must be positive"));
444        assert_eq!(
445            rule.message_expression.as_deref(),
446            Some("\"x is \" + string(self.x)")
447        );
448        assert_eq!(rule.reason.as_deref(), Some("FieldValueInvalid"));
449        assert_eq!(rule.field_path.as_deref(), Some(".spec.x"));
450        assert_eq!(rule.optional_old_self, Some(true));
451    }
452
453    #[test]
454    fn deserialize_rule_minimal() {
455        let raw = json!({"rule": "self.x > 0"});
456        let rule: Rule = serde_json::from_value(raw).unwrap();
457        assert_eq!(rule.rule, "self.x > 0");
458        assert!(rule.message.is_none());
459        assert!(rule.message_expression.is_none());
460        assert!(rule.reason.is_none());
461        assert!(rule.field_path.is_none());
462        assert!(rule.optional_old_self.is_none());
463    }
464
465    #[test]
466    fn schema_validations_extracts_and_compiles() {
467        let schema = json!({
468            "type": "object",
469            "x-kubernetes-validations": [
470                {"rule": "self.replicas >= 0", "message": "must be non-negative"},
471                {"rule": "self.name.size() > 0"}
472            ]
473        });
474        let results = compile_schema_validations(&schema);
475        assert_eq!(results.len(), 2);
476        assert!(results[0].is_ok());
477        assert!(results[1].is_ok());
478    }
479
480    #[test]
481    fn schema_validations_no_key() {
482        let schema = json!({"type": "object"});
483        let results = compile_schema_validations(&schema);
484        assert!(results.is_empty());
485    }
486
487    #[test]
488    fn schema_validations_empty_array() {
489        let schema = json!({
490            "x-kubernetes-validations": []
491        });
492        let results = compile_schema_validations(&schema);
493        assert!(results.is_empty());
494    }
495
496    #[test]
497    fn message_expression_compiled() {
498        let rule = Rule {
499            rule: "self.x > 0".into(),
500            message: Some("x must be positive".into()),
501            message_expression: Some("'x is ' + string(self.x)".into()),
502            reason: None,
503            field_path: None,
504            optional_old_self: None,
505        };
506        let result = compile_rule(&rule).unwrap();
507        assert!(result.message_program.is_some());
508    }
509
510    #[test]
511    fn message_expression_invalid_rejected() {
512        let rule = Rule {
513            rule: "self.x > 0".into(),
514            message: Some("fallback".into()),
515            message_expression: Some("invalid >=".into()),
516            reason: None,
517            field_path: None,
518            optional_old_self: None,
519        };
520        // A messageExpression that fails to compile must fail closed (mirroring
521        // the rule path + the apiserver, which rejects such a CRD at
522        // registration), not be silently dropped with a fall-back to the static
523        // message.
524        let err = compile_rule(&rule).unwrap_err();
525        assert!(
526            matches!(err, CompilationError::MessageExpressionParse { .. }),
527            "expected MessageExpressionParse, got {err:?}"
528        );
529    }
530
531    #[test]
532    fn message_expression_none() {
533        let rule = Rule {
534            rule: "self.x > 0".into(),
535            message: None,
536            message_expression: None,
537            reason: None,
538            field_path: None,
539            optional_old_self: None,
540        };
541        let result = compile_rule(&rule).unwrap();
542        assert!(result.message_program.is_none());
543    }
544
545    #[test]
546    fn compile_schema_tree() {
547        let schema = json!({
548            "type": "object",
549            "x-kubernetes-validations": [{"rule": "has(self.spec)"}],
550            "properties": {
551                "spec": {
552                    "type": "object",
553                    "x-kubernetes-validations": [{"rule": "self.replicas >= 0"}],
554                    "properties": {
555                        "replicas": {"type": "integer"}
556                    }
557                }
558            }
559        });
560        let compiled = compile_schema(&schema);
561        assert_eq!(compiled.validations.len(), 1);
562        assert!(compiled.properties.contains_key("spec"));
563        let spec = &compiled.properties["spec"];
564        assert_eq!(spec.validations.len(), 1);
565        assert!(spec.properties.contains_key("replicas"));
566    }
567
568    #[test]
569    fn compile_schema_with_items() {
570        let schema = json!({
571            "type": "array",
572            "items": {
573                "type": "object",
574                "x-kubernetes-validations": [{"rule": "self.name.size() > 0"}]
575            }
576        });
577        let compiled = compile_schema(&schema);
578        assert!(compiled.items.is_some());
579        assert_eq!(compiled.items.as_ref().unwrap().validations.len(), 1);
580    }
581
582    #[test]
583    fn compile_schema_empty() {
584        let schema = json!({"type": "object"});
585        let compiled = compile_schema(&schema);
586        assert!(compiled.validations.is_empty());
587        assert!(compiled.properties.is_empty());
588        assert!(compiled.items.is_none());
589        assert!(compiled.additional_properties.is_none());
590    }
591
592    #[test]
593    fn schema_validations_partial_errors() {
594        let schema = json!({
595            "x-kubernetes-validations": [
596                {"rule": "self.x > 0"},
597                {"rule": "self.y >="},
598                {"rule": "self.z == true"}
599            ]
600        });
601        let results = compile_schema_validations(&schema);
602        assert_eq!(results.len(), 3);
603        assert!(results[0].is_ok());
604        assert!(results[1].is_err());
605        assert!(results[2].is_ok());
606    }
607
608    #[test]
609    fn compilation_errors_method() {
610        let schema = json!({
611            "x-kubernetes-validations": [
612                {"rule": "self.x > 0"},
613                {"rule": "self.y >="},
614                {"rule": "self.z == true"}
615            ]
616        });
617        let compiled = compile_schema(&schema);
618        let errors = compiled.compilation_errors();
619        assert_eq!(errors.len(), 1);
620        assert!(matches!(errors[0], CompilationError::Parse { .. }));
621        assert!(compiled.has_errors());
622    }
623
624    #[test]
625    fn compilation_errors_empty_when_all_valid() {
626        let schema = json!({
627            "x-kubernetes-validations": [
628                {"rule": "self.x > 0"},
629                {"rule": "self.z == true"}
630            ]
631        });
632        let compiled = compile_schema(&schema);
633        assert!(compiled.compilation_errors().is_empty());
634        assert!(!compiled.has_errors());
635    }
636}
637
638/// White-box end-to-end tests of `compile_schema` + `CompilationResult` that
639/// bind `self` via the now-internal `json_to_cel`. Relocated from
640/// `tests/compilation_integration.rs` when `json_to_cel` became `pub(crate)`.
641#[cfg(test)]
642mod end_to_end_tests {
643    use cel::{Context, Value};
644    use serde_json::json;
645
646    use super::{CompilationError, compile_schema};
647    use crate::{register_all, validation::values::json_to_cel};
648
649    /// Compile rules from a schema, bind `self`, evaluate the first program.
650    fn compile_and_eval_first(schema: serde_json::Value, self_val: serde_json::Value) -> Value {
651        let compiled = compile_schema(&schema);
652        let cr = compiled.validations.into_iter().next().unwrap().unwrap();
653
654        let mut ctx = Context::default();
655        register_all(&mut ctx);
656        ctx.add_variable_from_value("self", json_to_cel(&self_val));
657        cr.program.execute(&ctx).unwrap()
658    }
659
660    #[test]
661    fn crd_schema_end_to_end() {
662        let schema = json!({
663            "type": "object",
664            "properties": {
665                "spec": {
666                    "type": "object",
667                    "properties": {
668                        "replicas": {"type": "integer"},
669                        "minReplicas": {"type": "integer"}
670                    },
671                    "x-kubernetes-validations": [
672                        {"rule": "self.replicas >= self.minReplicas", "message": "replicas must be >= minReplicas"}
673                    ]
674                }
675            }
676        });
677
678        let spec_schema = &schema["properties"]["spec"];
679        let self_val = json!({"replicas": 5, "minReplicas": 2});
680
681        let spec_compiled = compile_schema(spec_schema);
682        assert_eq!(spec_compiled.validations.len(), 1);
683        let compiled = spec_compiled.validations.into_iter().next().unwrap().unwrap();
684
685        assert!(!compiled.is_transition_rule);
686        assert_eq!(
687            compiled.rule.message.as_deref(),
688            Some("replicas must be >= minReplicas")
689        );
690
691        let mut ctx = Context::default();
692        register_all(&mut ctx);
693        ctx.add_variable_from_value("self", json_to_cel(&self_val));
694        assert_eq!(compiled.program.execute(&ctx).unwrap(), Value::Bool(true));
695    }
696
697    #[test]
698    fn compile_and_eval_with_json_to_cel() {
699        let schema = json!({
700            "x-kubernetes-validations": [{"rule": "self.name.size() > 0", "message": "name required"}]
701        });
702        let result = compile_and_eval_first(schema, json!({"name": "my-app"}));
703        assert_eq!(result, Value::Bool(true));
704    }
705
706    #[test]
707    fn transition_rule_compile_and_eval() {
708        let schema = json!({
709            "x-kubernetes-validations": [
710                {"rule": "self.replicas >= oldSelf.replicas", "message": "cannot scale down", "reason": "FieldValueForbidden"}
711            ]
712        });
713
714        let compiled_schema = compile_schema(&schema);
715        let compiled = compiled_schema.validations.into_iter().next().unwrap().unwrap();
716
717        assert!(compiled.is_transition_rule);
718        assert_eq!(compiled.rule.message.as_deref(), Some("cannot scale down"));
719        assert_eq!(compiled.rule.reason.as_deref(), Some("FieldValueForbidden"));
720
721        let mut ctx = Context::default();
722        register_all(&mut ctx);
723        ctx.add_variable_from_value("self", json_to_cel(&json!({"replicas": 5})));
724        ctx.add_variable_from_value("oldSelf", json_to_cel(&json!({"replicas": 3})));
725        assert_eq!(compiled.program.execute(&ctx).unwrap(), Value::Bool(true));
726
727        let mut ctx2 = Context::default();
728        register_all(&mut ctx2);
729        ctx2.add_variable_from_value("self", json_to_cel(&json!({"replicas": 1})));
730        ctx2.add_variable_from_value("oldSelf", json_to_cel(&json!({"replicas": 3})));
731        assert_eq!(compiled.program.execute(&ctx2).unwrap(), Value::Bool(false));
732    }
733
734    #[test]
735    fn message_and_reason_preserved() {
736        let schema = json!({
737            "x-kubernetes-validations": [{
738                "rule": "self.x > 0",
739                "message": "x must be positive",
740                "messageExpression": "\"x is \" + string(self.x)",
741                "reason": "FieldValueInvalid",
742                "fieldPath": ".spec.x"
743            }]
744        });
745
746        let compiled_schema = compile_schema(&schema);
747        let compiled = compiled_schema.validations.into_iter().next().unwrap().unwrap();
748
749        assert_eq!(compiled.rule.message.as_deref(), Some("x must be positive"));
750        assert_eq!(
751            compiled.rule.message_expression.as_deref(),
752            Some("\"x is \" + string(self.x)")
753        );
754        assert_eq!(compiled.rule.reason.as_deref(), Some("FieldValueInvalid"));
755        assert_eq!(compiled.rule.field_path.as_deref(), Some(".spec.x"));
756    }
757
758    #[test]
759    fn multiple_rules_mixed_results() {
760        let schema = json!({
761            "x-kubernetes-validations": [
762                {"rule": "self.a > 0"},
763                {"rule": "invalid >="},
764                {"rule": "self.b == true"}
765            ]
766        });
767
768        let compiled = compile_schema(&schema);
769        assert_eq!(compiled.validations.len(), 3);
770
771        let cr = compiled.validations[0].as_ref().unwrap();
772        let mut ctx = Context::default();
773        register_all(&mut ctx);
774        ctx.add_variable_from_value("self", json_to_cel(&json!({"a": 5})));
775        assert_eq!(cr.program.execute(&ctx).unwrap(), Value::Bool(true));
776
777        assert!(matches!(
778            compiled.validations[1].as_ref().unwrap_err(),
779            CompilationError::Parse { .. }
780        ));
781
782        assert!(compiled.validations[2].is_ok());
783    }
784
785    #[test]
786    fn realistic_crd_with_multiple_validation_levels() {
787        let crd_schema = json!({
788            "type": "object",
789            "properties": {
790                "spec": {
791                    "type": "object",
792                    "properties": {
793                        "replicas": {"type": "integer"},
794                        "template": {
795                            "type": "object",
796                            "properties": {"name": {"type": "string"}},
797                            "x-kubernetes-validations": [
798                                {"rule": "self.name.size() > 0", "message": "template name required"}
799                            ]
800                        }
801                    },
802                    "x-kubernetes-validations": [
803                        {"rule": "self.replicas >= 1", "message": "at least one replica"}
804                    ]
805                }
806            }
807        });
808
809        let spec_compiled = compile_schema(&crd_schema["properties"]["spec"]);
810        assert_eq!(spec_compiled.validations.len(), 1);
811        let spec_cr = spec_compiled.validations.into_iter().next().unwrap().unwrap();
812
813        let mut ctx = Context::default();
814        register_all(&mut ctx);
815        ctx.add_variable_from_value(
816            "self",
817            json_to_cel(&json!({"replicas": 3, "template": {"name": "web"}})),
818        );
819        assert_eq!(spec_cr.program.execute(&ctx).unwrap(), Value::Bool(true));
820
821        let tmpl_compiled = compile_schema(&crd_schema["properties"]["spec"]["properties"]["template"]);
822        assert_eq!(tmpl_compiled.validations.len(), 1);
823        let tmpl_cr = tmpl_compiled.validations.into_iter().next().unwrap().unwrap();
824
825        let mut ctx2 = Context::default();
826        register_all(&mut ctx2);
827        ctx2.add_variable_from_value("self", json_to_cel(&json!({"name": "web"})));
828        assert_eq!(tmpl_cr.program.execute(&ctx2).unwrap(), Value::Bool(true));
829
830        let mut ctx3 = Context::default();
831        register_all(&mut ctx3);
832        ctx3.add_variable_from_value("self", json_to_cel(&json!({"name": ""})));
833        assert_eq!(tmpl_cr.program.execute(&ctx3).unwrap(), Value::Bool(false));
834    }
835
836    #[test]
837    #[cfg(feature = "strings")]
838    fn compiled_rule_with_extension_functions() {
839        let schema = json!({
840            "x-kubernetes-validations": [{"rule": "self.name.trim().lowerAscii().size() > 0"}]
841        });
842        let result = compile_and_eval_first(schema, json!({"name": "  Hello  "}));
843        assert_eq!(result, Value::Bool(true));
844    }
845}