use std::collections::HashMap;
use cel::{ParseErrors, Program};
use crate::values::SchemaFormat;
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Rule {
pub rule: String,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub message_expression: Option<String>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub field_path: Option<String>,
#[serde(default)]
pub optional_old_self: Option<bool>,
}
#[derive(Debug)]
pub struct CompilationResult {
pub program: Program,
pub rule: Rule,
pub is_transition_rule: bool,
pub message_program: Option<Program>,
}
#[derive(Debug)]
pub enum CompilationError {
Parse {
rule: String,
source: ParseErrors,
},
InvalidRule(serde_json::Error),
}
impl std::fmt::Display for CompilationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CompilationError::Parse { rule, source } => {
write!(f, "failed to compile CEL rule \"{rule}\": {source}")
}
CompilationError::InvalidRule(err) => {
write!(f, "invalid rule definition: {err}")
}
}
}
}
impl std::error::Error for CompilationError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
CompilationError::Parse { source, .. } => Some(source),
CompilationError::InvalidRule(err) => Some(err),
}
}
}
pub fn compile_rule(rule: &Rule) -> Result<CompilationResult, CompilationError> {
let program = Program::compile(&rule.rule).map_err(|e| CompilationError::Parse {
rule: rule.rule.clone(),
source: e,
})?;
let is_transition_rule = program.references().has_variable("oldSelf");
let message_program = rule
.message_expression
.as_deref()
.and_then(|expr| Program::compile(expr).ok());
Ok(CompilationResult {
program,
rule: rule.clone(),
is_transition_rule,
message_program,
})
}
pub fn compile_schema_validations(
schema: &serde_json::Value,
) -> Vec<Result<CompilationResult, CompilationError>> {
let rules = match schema.get("x-kubernetes-validations") {
Some(serde_json::Value::Array(arr)) => arr,
_ => return Vec::new(),
};
rules
.iter()
.map(|raw| {
let rule: Rule = serde_json::from_value(raw.clone()).map_err(CompilationError::InvalidRule)?;
compile_rule(&rule)
})
.collect()
}
#[derive(Debug)]
pub struct CompiledSchema {
pub validations: Vec<Result<CompilationResult, CompilationError>>,
pub properties: HashMap<String, CompiledSchema>,
pub items: Option<Box<CompiledSchema>>,
pub additional_properties: Option<Box<CompiledSchema>>,
pub format: SchemaFormat,
pub all_of: Vec<CompiledSchema>,
pub one_of: Vec<CompiledSchema>,
pub any_of: Vec<CompiledSchema>,
pub max_length: Option<u64>,
pub max_items: Option<u64>,
pub max_properties: Option<u64>,
pub preserve_unknown_fields: bool,
pub embedded_resource: bool,
}
impl CompiledSchema {
#[must_use]
pub fn compilation_errors(&self) -> Vec<&CompilationError> {
self.validations.iter().filter_map(|r| r.as_ref().err()).collect()
}
#[must_use]
pub fn has_errors(&self) -> bool {
self.validations.iter().any(|r| r.is_err())
}
}
const MAX_SCHEMA_DEPTH: usize = 64;
fn compile_schema_array(schema: &serde_json::Value, key: &str, depth: usize) -> Vec<CompiledSchema> {
schema
.get(key)
.and_then(|v| v.as_array())
.map(|arr| arr.iter().map(|s| compile_schema_inner(s, depth)).collect())
.unwrap_or_default()
}
#[must_use]
pub fn compile_schema(schema: &serde_json::Value) -> CompiledSchema {
compile_schema_inner(schema, 0)
}
fn compile_schema_inner(schema: &serde_json::Value, depth: usize) -> CompiledSchema {
if depth > MAX_SCHEMA_DEPTH {
return CompiledSchema {
validations: Vec::new(),
properties: HashMap::new(),
items: None,
additional_properties: None,
format: SchemaFormat::None,
all_of: Vec::new(),
one_of: Vec::new(),
any_of: Vec::new(),
max_length: None,
max_items: None,
max_properties: None,
preserve_unknown_fields: false,
embedded_resource: false,
};
}
let validations = compile_schema_validations(schema);
let mut properties = HashMap::new();
if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
for (name, prop_schema) in props {
properties.insert(name.clone(), compile_schema_inner(prop_schema, depth + 1));
}
}
let items = schema
.get("items")
.map(|s| Box::new(compile_schema_inner(s, depth + 1)));
let additional_properties = schema
.get("additionalProperties")
.filter(|a| a.is_object())
.map(|s| Box::new(compile_schema_inner(s, depth + 1)));
let format = SchemaFormat::from_schema(schema);
let all_of = compile_schema_array(schema, "allOf", depth + 1);
let one_of = compile_schema_array(schema, "oneOf", depth + 1);
let any_of = compile_schema_array(schema, "anyOf", depth + 1);
let max_length = schema.get("maxLength").and_then(|v| v.as_u64());
let max_items = schema.get("maxItems").and_then(|v| v.as_u64());
let max_properties = schema.get("maxProperties").and_then(|v| v.as_u64());
let preserve_unknown_fields = schema
.get("x-kubernetes-preserve-unknown-fields")
.and_then(|v| v.as_bool())
== Some(true);
let embedded_resource = schema
.get("x-kubernetes-embedded-resource")
.and_then(|v| v.as_bool())
== Some(true);
CompiledSchema {
validations,
properties,
items,
additional_properties,
format,
all_of,
one_of,
any_of,
max_length,
max_items,
max_properties,
preserve_unknown_fields,
embedded_resource,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn compile_simple_rule() {
let rule = Rule {
rule: "self.replicas >= 0".into(),
message: None,
message_expression: None,
reason: None,
field_path: None,
optional_old_self: None,
};
let result = compile_rule(&rule).unwrap();
assert!(!result.is_transition_rule);
}
#[test]
fn detect_transition_rule() {
let rule = Rule {
rule: "self.replicas >= oldSelf.replicas".into(),
message: None,
message_expression: None,
reason: None,
field_path: None,
optional_old_self: None,
};
let result = compile_rule(&rule).unwrap();
assert!(result.is_transition_rule);
}
#[test]
fn detect_non_transition_rule() {
let rule = Rule {
rule: "self.replicas > 0".into(),
message: None,
message_expression: None,
reason: None,
field_path: None,
optional_old_self: None,
};
let result = compile_rule(&rule).unwrap();
assert!(!result.is_transition_rule);
}
#[test]
fn parse_error_on_invalid_cel() {
let rule = Rule {
rule: "self.replicas >=".into(),
message: None,
message_expression: None,
reason: None,
field_path: None,
optional_old_self: None,
};
let err = compile_rule(&rule).unwrap_err();
assert!(matches!(err, CompilationError::Parse { .. }));
let msg = err.to_string();
assert!(msg.contains("self.replicas >="));
}
#[test]
fn deserialize_rule_all_fields() {
let raw = json!({
"rule": "self.x > 0",
"message": "x must be positive",
"messageExpression": "\"x is \" + string(self.x)",
"reason": "FieldValueInvalid",
"fieldPath": ".spec.x",
"optionalOldSelf": true
});
let rule: Rule = serde_json::from_value(raw).unwrap();
assert_eq!(rule.rule, "self.x > 0");
assert_eq!(rule.message.as_deref(), Some("x must be positive"));
assert_eq!(
rule.message_expression.as_deref(),
Some("\"x is \" + string(self.x)")
);
assert_eq!(rule.reason.as_deref(), Some("FieldValueInvalid"));
assert_eq!(rule.field_path.as_deref(), Some(".spec.x"));
assert_eq!(rule.optional_old_self, Some(true));
}
#[test]
fn deserialize_rule_minimal() {
let raw = json!({"rule": "self.x > 0"});
let rule: Rule = serde_json::from_value(raw).unwrap();
assert_eq!(rule.rule, "self.x > 0");
assert!(rule.message.is_none());
assert!(rule.message_expression.is_none());
assert!(rule.reason.is_none());
assert!(rule.field_path.is_none());
assert!(rule.optional_old_self.is_none());
}
#[test]
fn schema_validations_extracts_and_compiles() {
let schema = json!({
"type": "object",
"x-kubernetes-validations": [
{"rule": "self.replicas >= 0", "message": "must be non-negative"},
{"rule": "self.name.size() > 0"}
]
});
let results = compile_schema_validations(&schema);
assert_eq!(results.len(), 2);
assert!(results[0].is_ok());
assert!(results[1].is_ok());
}
#[test]
fn schema_validations_no_key() {
let schema = json!({"type": "object"});
let results = compile_schema_validations(&schema);
assert!(results.is_empty());
}
#[test]
fn schema_validations_empty_array() {
let schema = json!({
"x-kubernetes-validations": []
});
let results = compile_schema_validations(&schema);
assert!(results.is_empty());
}
#[test]
fn message_expression_compiled() {
let rule = Rule {
rule: "self.x > 0".into(),
message: Some("x must be positive".into()),
message_expression: Some("'x is ' + string(self.x)".into()),
reason: None,
field_path: None,
optional_old_self: None,
};
let result = compile_rule(&rule).unwrap();
assert!(result.message_program.is_some());
}
#[test]
fn message_expression_invalid_ignored() {
let rule = Rule {
rule: "self.x > 0".into(),
message: Some("fallback".into()),
message_expression: Some("invalid >=".into()),
reason: None,
field_path: None,
optional_old_self: None,
};
let result = compile_rule(&rule).unwrap();
assert!(result.message_program.is_none());
}
#[test]
fn message_expression_none() {
let rule = Rule {
rule: "self.x > 0".into(),
message: None,
message_expression: None,
reason: None,
field_path: None,
optional_old_self: None,
};
let result = compile_rule(&rule).unwrap();
assert!(result.message_program.is_none());
}
#[test]
fn compile_schema_tree() {
let schema = json!({
"type": "object",
"x-kubernetes-validations": [{"rule": "has(self.spec)"}],
"properties": {
"spec": {
"type": "object",
"x-kubernetes-validations": [{"rule": "self.replicas >= 0"}],
"properties": {
"replicas": {"type": "integer"}
}
}
}
});
let compiled = compile_schema(&schema);
assert_eq!(compiled.validations.len(), 1);
assert!(compiled.properties.contains_key("spec"));
let spec = &compiled.properties["spec"];
assert_eq!(spec.validations.len(), 1);
assert!(spec.properties.contains_key("replicas"));
}
#[test]
fn compile_schema_with_items() {
let schema = json!({
"type": "array",
"items": {
"type": "object",
"x-kubernetes-validations": [{"rule": "self.name.size() > 0"}]
}
});
let compiled = compile_schema(&schema);
assert!(compiled.items.is_some());
assert_eq!(compiled.items.as_ref().unwrap().validations.len(), 1);
}
#[test]
fn compile_schema_empty() {
let schema = json!({"type": "object"});
let compiled = compile_schema(&schema);
assert!(compiled.validations.is_empty());
assert!(compiled.properties.is_empty());
assert!(compiled.items.is_none());
assert!(compiled.additional_properties.is_none());
}
#[test]
fn schema_validations_partial_errors() {
let schema = json!({
"x-kubernetes-validations": [
{"rule": "self.x > 0"},
{"rule": "self.y >="},
{"rule": "self.z == true"}
]
});
let results = compile_schema_validations(&schema);
assert_eq!(results.len(), 3);
assert!(results[0].is_ok());
assert!(results[1].is_err());
assert!(results[2].is_ok());
}
#[test]
fn compilation_errors_method() {
let schema = json!({
"x-kubernetes-validations": [
{"rule": "self.x > 0"},
{"rule": "self.y >="},
{"rule": "self.z == true"}
]
});
let compiled = compile_schema(&schema);
let errors = compiled.compilation_errors();
assert_eq!(errors.len(), 1);
assert!(matches!(errors[0], CompilationError::Parse { .. }));
assert!(compiled.has_errors());
}
#[test]
fn compilation_errors_empty_when_all_valid() {
let schema = json!({
"x-kubernetes-validations": [
{"rule": "self.x > 0"},
{"rule": "self.z == true"}
]
});
let compiled = compile_schema(&schema);
assert!(compiled.compilation_errors().is_empty());
assert!(!compiled.has_errors());
}
}