use std::{collections::HashMap, sync::Arc};
use cel::{
Context, Program, Value,
objects::{Key, Map},
};
use crate::values::json_to_cel;
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GroupVersionKind {
pub group: String,
pub version: String,
pub kind: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GroupVersionResource {
pub group: String,
pub version: String,
pub resource: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AdmissionRequest {
pub operation: String,
pub username: String,
pub uid: String,
pub groups: Vec<String>,
pub name: String,
pub namespace: String,
pub dry_run: bool,
pub kind: GroupVersionKind,
pub resource: GroupVersionResource,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VapExpression {
pub expression: String,
pub message: Option<String>,
pub message_expression: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct VapResult {
pub expression: String,
pub passed: bool,
pub message: Option<String>,
}
pub struct CompiledVapExpression {
program: Program,
expression: String,
message: Option<String>,
message_program: Option<Program>,
}
pub struct VapEvaluator {
object: serde_json::Value,
old_object: Option<serde_json::Value>,
request: AdmissionRequest,
params: Option<serde_json::Value>,
namespace_object: Option<serde_json::Value>,
}
#[derive(Default)]
pub struct VapEvaluatorBuilder {
object: Option<serde_json::Value>,
old_object: Option<serde_json::Value>,
request: AdmissionRequest,
params: Option<serde_json::Value>,
namespace_object: Option<serde_json::Value>,
}
impl VapEvaluatorBuilder {
pub fn object(mut self, obj: serde_json::Value) -> Self {
self.object = Some(obj);
self
}
pub fn old_object(mut self, obj: serde_json::Value) -> Self {
self.old_object = Some(obj);
self
}
pub fn request(mut self, req: AdmissionRequest) -> Self {
self.request = req;
self
}
pub fn params(mut self, p: serde_json::Value) -> Self {
self.params = Some(p);
self
}
pub fn namespace_object(mut self, ns: serde_json::Value) -> Self {
self.namespace_object = Some(ns);
self
}
pub fn build(self) -> VapEvaluator {
VapEvaluator {
object: self.object.unwrap_or(serde_json::Value::Null),
old_object: self.old_object,
request: self.request,
params: self.params,
namespace_object: self.namespace_object,
}
}
}
impl VapEvaluator {
pub fn builder() -> VapEvaluatorBuilder {
VapEvaluatorBuilder::default()
}
fn build_context(&self) -> Context<'static> {
let mut ctx = Context::default();
crate::register_all(&mut ctx);
let _ = ctx.add_variable("object", json_to_cel(&self.object));
let old_object_val = match &self.old_object {
Some(v) => json_to_cel(v),
None => Value::Null,
};
let _ = ctx.add_variable("oldObject", old_object_val);
let _ = ctx.add_variable("request", request_to_cel(&self.request));
if let Some(params) = &self.params {
let _ = ctx.add_variable("params", json_to_cel(params));
}
if let Some(ns) = &self.namespace_object {
let _ = ctx.add_variable("namespaceObject", json_to_cel(ns));
}
ctx
}
#[must_use]
pub fn compile_expressions(
&self,
expressions: &[VapExpression],
) -> Vec<Result<CompiledVapExpression, String>> {
expressions
.iter()
.map(|expr| {
let program =
Program::compile(&expr.expression).map_err(|e| format!("compilation error: {e}"))?;
let message_program = expr
.message_expression
.as_deref()
.and_then(|me| Program::compile(me).ok());
Ok(CompiledVapExpression {
program,
expression: expr.expression.clone(),
message: expr.message.clone(),
message_program,
})
})
.collect()
}
#[must_use]
pub fn evaluate_compiled(&self, compiled: &[Result<CompiledVapExpression, String>]) -> Vec<VapResult> {
let ctx = self.build_context();
compiled
.iter()
.map(|c| match c {
Ok(ce) => match ce.program.execute(&ctx) {
Ok(Value::Bool(true)) => VapResult {
expression: ce.expression.clone(),
passed: true,
message: None,
},
Ok(Value::Bool(false)) => {
let msg = ce
.message_program
.as_ref()
.and_then(|prog| match prog.execute(&ctx) {
Ok(Value::String(s)) => Some((*s).clone()),
_ => None,
})
.or_else(|| ce.message.clone())
.unwrap_or_else(|| {
format!("validation expression '{}' evaluated to false", ce.expression)
});
VapResult {
expression: ce.expression.clone(),
passed: false,
message: Some(msg),
}
}
Ok(other) => VapResult {
expression: ce.expression.clone(),
passed: false,
message: Some(format!("expression returned non-boolean: {other:?}")),
},
Err(e) => VapResult {
expression: ce.expression.clone(),
passed: false,
message: Some(format!("evaluation error: {e}")),
},
},
Err(e) => VapResult {
expression: String::new(),
passed: false,
message: Some(e.clone()),
},
})
.collect()
}
#[must_use]
pub fn evaluate(&self, expressions: &[VapExpression]) -> Vec<VapResult> {
let compiled = self.compile_expressions(expressions);
self.evaluate_compiled(&compiled)
}
}
fn request_to_cel(req: &AdmissionRequest) -> Value {
let mut map: HashMap<Key, Value> = HashMap::new();
map.insert(
Key::String(Arc::new("operation".into())),
Value::String(Arc::new(req.operation.clone())),
);
map.insert(
Key::String(Arc::new("name".into())),
Value::String(Arc::new(req.name.clone())),
);
map.insert(
Key::String(Arc::new("namespace".into())),
Value::String(Arc::new(req.namespace.clone())),
);
map.insert(Key::String(Arc::new("dryRun".into())), Value::Bool(req.dry_run));
let mut kind_map: HashMap<Key, Value> = HashMap::new();
kind_map.insert(
Key::String(Arc::new("group".into())),
Value::String(Arc::new(req.kind.group.clone())),
);
kind_map.insert(
Key::String(Arc::new("version".into())),
Value::String(Arc::new(req.kind.version.clone())),
);
kind_map.insert(
Key::String(Arc::new("kind".into())),
Value::String(Arc::new(req.kind.kind.clone())),
);
map.insert(
Key::String(Arc::new("kind".into())),
Value::Map(Map {
map: Arc::new(kind_map),
}),
);
let mut resource_map: HashMap<Key, Value> = HashMap::new();
resource_map.insert(
Key::String(Arc::new("group".into())),
Value::String(Arc::new(req.resource.group.clone())),
);
resource_map.insert(
Key::String(Arc::new("version".into())),
Value::String(Arc::new(req.resource.version.clone())),
);
resource_map.insert(
Key::String(Arc::new("resource".into())),
Value::String(Arc::new(req.resource.resource.clone())),
);
map.insert(
Key::String(Arc::new("resource".into())),
Value::Map(Map {
map: Arc::new(resource_map),
}),
);
let groups_list: Vec<Value> = req
.groups
.iter()
.map(|g| Value::String(Arc::new(g.clone())))
.collect();
let mut user_info_map: HashMap<Key, Value> = HashMap::new();
user_info_map.insert(
Key::String(Arc::new("username".into())),
Value::String(Arc::new(req.username.clone())),
);
user_info_map.insert(
Key::String(Arc::new("uid".into())),
Value::String(Arc::new(req.uid.clone())),
);
user_info_map.insert(
Key::String(Arc::new("groups".into())),
Value::List(Arc::new(groups_list)),
);
map.insert(
Key::String(Arc::new("userInfo".into())),
Value::Map(Map {
map: Arc::new(user_info_map),
}),
);
Value::Map(Map { map: Arc::new(map) })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn vap_basic_validation_passes() {
let evaluator = VapEvaluator::builder()
.object(json!({"metadata": {"name": "test"}, "spec": {"replicas": 3}}))
.request(AdmissionRequest {
operation: "CREATE".into(),
..Default::default()
})
.build();
let results = evaluator.evaluate(&[VapExpression {
expression: "object.spec.replicas >= 0".into(),
message: Some("replicas must be non-negative".into()),
message_expression: None,
}]);
assert_eq!(results.len(), 1);
assert!(results[0].passed);
}
#[test]
fn vap_validation_fails_with_message() {
let evaluator = VapEvaluator::builder()
.object(json!({"spec": {"replicas": -1}}))
.request(AdmissionRequest {
operation: "CREATE".into(),
..Default::default()
})
.build();
let results = evaluator.evaluate(&[VapExpression {
expression: "object.spec.replicas >= 0".into(),
message: Some("replicas must be non-negative".into()),
message_expression: None,
}]);
assert!(!results[0].passed);
assert_eq!(
results[0].message.as_deref(),
Some("replicas must be non-negative")
);
}
#[test]
fn vap_request_variables_accessible() {
let evaluator = VapEvaluator::builder()
.object(json!({"spec": {}}))
.request(AdmissionRequest {
operation: "CREATE".into(),
username: "admin".into(),
namespace: "default".into(),
..Default::default()
})
.build();
let results = evaluator.evaluate(&[VapExpression {
expression: "request.operation == 'CREATE' && request.userInfo.username == 'admin'".into(),
message: None,
message_expression: None,
}]);
assert!(results[0].passed);
}
#[test]
fn vap_old_object_null_on_create() {
let evaluator = VapEvaluator::builder()
.object(json!({"spec": {}}))
.request(AdmissionRequest {
operation: "CREATE".into(),
..Default::default()
})
.build();
let results = evaluator.evaluate(&[VapExpression {
expression: "oldObject == null".into(),
message: None,
message_expression: None,
}]);
assert!(results[0].passed);
}
#[test]
fn vap_params_accessible() {
let evaluator = VapEvaluator::builder()
.object(json!({"spec": {"replicas": 5}}))
.params(json!({"maxReplicas": 10}))
.request(AdmissionRequest::default())
.build();
let results = evaluator.evaluate(&[VapExpression {
expression: "object.spec.replicas <= params.maxReplicas".into(),
message: None,
message_expression: None,
}]);
assert!(results[0].passed);
}
#[test]
fn vap_message_expression() {
let evaluator = VapEvaluator::builder()
.object(json!({"spec": {"replicas": -1}}))
.request(AdmissionRequest::default())
.build();
let results = evaluator.evaluate(&[VapExpression {
expression: "object.spec.replicas >= 0".into(),
message: Some("static fallback".into()),
message_expression: Some("'replicas is ' + string(object.spec.replicas)".into()),
}]);
assert!(!results[0].passed);
assert_eq!(results[0].message.as_deref(), Some("replicas is -1"));
}
#[test]
fn vap_compiled_expressions_reusable() {
let evaluator = VapEvaluator::builder()
.object(json!({"spec": {"replicas": 3}}))
.request(AdmissionRequest {
operation: "CREATE".into(),
..Default::default()
})
.build();
let expressions = vec![VapExpression {
expression: "object.spec.replicas >= 0".into(),
message: Some("bad".into()),
message_expression: None,
}];
let compiled = evaluator.compile_expressions(&expressions);
assert!(compiled[0].is_ok());
let r1 = evaluator.evaluate_compiled(&compiled);
let r2 = evaluator.evaluate_compiled(&compiled);
assert!(r1[0].passed);
assert!(r2[0].passed);
}
#[test]
fn vap_compiled_error_preserved() {
let evaluator = VapEvaluator::builder()
.object(json!({}))
.request(AdmissionRequest::default())
.build();
let expressions = vec![VapExpression {
expression: "invalid >=".into(),
message: None,
message_expression: None,
}];
let compiled = evaluator.compile_expressions(&expressions);
assert!(compiled[0].is_err());
let results = evaluator.evaluate_compiled(&compiled);
assert!(!results[0].passed);
assert!(results[0].message.as_ref().unwrap().contains("compilation error"));
}
#[test]
fn vap_multiple_expressions() {
let evaluator = VapEvaluator::builder()
.object(json!({"spec": {"replicas": -1, "name": ""}}))
.request(AdmissionRequest::default())
.build();
let results = evaluator.evaluate(&[
VapExpression {
expression: "object.spec.replicas >= 0".into(),
message: Some("bad replicas".into()),
message_expression: None,
},
VapExpression {
expression: "object.spec.name.size() > 0".into(),
message: Some("name required".into()),
message_expression: None,
},
]);
assert_eq!(results.len(), 2);
assert!(!results[0].passed);
assert!(!results[1].passed);
}
}