-
-
Notifications
You must be signed in to change notification settings - Fork 319
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement cel validation derive(Validated)
macro for generated CRDs
#1649
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
//! CEL validation for CRDs | ||
|
||
use std::str::FromStr; | ||
|
||
#[cfg(feature = "schema")] use schemars::schema::Schema; | ||
use serde::{Deserialize, Serialize}; | ||
|
||
/// Rule is a CEL validation rule for the CRD field | ||
#[derive(Default, Serialize, Deserialize, Clone)] | ||
#[serde(rename_all = "camelCase")] | ||
pub struct Rule { | ||
Comment on lines
+8
to
+11
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. note to self; this may perhaps be churned through a cel crate against an object to perform validation client side. should investigate this later. |
||
/// rule represents the expression which will be evaluated by CEL. | ||
/// The `self` variable in the CEL expression is bound to the scoped value. | ||
pub rule: String, | ||
/// message represents CEL validation message for the provided type | ||
/// If unset, the message is "failed rule: {Rule}". | ||
#[serde(flatten)] | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
pub message: Option<Message>, | ||
/// fieldPath represents the field path returned when the validation fails. | ||
/// It must be a relative JSON path, scoped to the location of the field in the schema | ||
pub field_path: Option<String>, | ||
/// reason is a machine-readable value providing more detail about why a field failed the validation. | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
pub reason: Option<Reason>, | ||
} | ||
|
||
/// Message represents CEL validation message for the provided type | ||
#[derive(Serialize, Deserialize, Clone)] | ||
#[serde(rename_all = "lowercase")] | ||
pub enum Message { | ||
/// Message represents the message displayed when validation fails. The message is required if the Rule contains | ||
/// line breaks. The message must not contain line breaks. | ||
/// Example: | ||
/// "must be a URL with the host matching spec.host" | ||
Message(String), | ||
/// Expression declares a CEL expression that evaluates to the validation failure message that is returned when this rule fails. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. are these doc strings taken from anywhere? |
||
/// 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 | ||
/// as if the messageExpression field were unset. If messageExpression evaluates to an empty string, a string with only spaces, or a string | ||
/// that contains line breaks, then the validation failure message will also be produced as if the messageExpression field were unset, and | ||
/// the fact that messageExpression produced an empty string/string with only spaces/string with line breaks will be logged. | ||
/// messageExpression has access to all the same variables as the rule; the only difference is the return type. | ||
/// Example: | ||
/// "x must be less than max ("+string(self.max)+")" | ||
#[serde(rename = "messageExpression")] | ||
Expression(String), | ||
} | ||
|
||
impl From<&str> for Message { | ||
fn from(value: &str) -> Self { | ||
Message::Message(value.to_string()) | ||
} | ||
} | ||
|
||
/// Reason is a machine-readable value providing more detail about why a field failed the validation. | ||
/// | ||
/// More in [docs](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#field-reason) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interestingly, I see these ones in the generated docs under https://github.com/kube-rs/k8s-pb/blob/ce4261fb52266f05cd7a06dbb8f4c0fcaa41c06a/k8s-pb/src/apiextensions_apiserver/pkg/apis/apiextensions/v1/mod.rs#L736 but because of bad go enum usage it's just a doc comment :( |
||
#[derive(Serialize, Deserialize, Clone)] | ||
pub enum Reason { | ||
/// FieldValueInvalid is used to report malformed values (e.g. failed regex | ||
/// match, too long, out of bounds). | ||
FieldValueInvalid, | ||
/// FieldValueForbidden is used to report valid (as per formatting rules) | ||
/// values which would be accepted under some conditions, but which are not | ||
/// permitted by the current conditions (such as security policy). | ||
FieldValueForbidden, | ||
/// FieldValueRequired is used to report required values that are not | ||
/// provided (e.g. empty strings, null values, or empty arrays). | ||
FieldValueRequired, | ||
/// FieldValueDuplicate is used to report collisions of values that must be | ||
/// unique (e.g. unique IDs). | ||
FieldValueDuplicate, | ||
} | ||
|
||
impl FromStr for Reason { | ||
type Err = serde_json::Error; | ||
|
||
fn from_str(s: &str) -> Result<Self, Self::Err> { | ||
serde_json::from_str(s) | ||
} | ||
} | ||
|
||
/// Validate takes schema and applies a set of validation rules to it. The rules are stored | ||
/// under the "x-kubernetes-validations". | ||
/// | ||
/// ```rust | ||
/// use schemars::schema::Schema; | ||
/// use kube::core::{Rule, Reason, Message, validate}; | ||
/// | ||
/// let mut schema = Schema::Object(Default::default()); | ||
/// let rules = vec![Rule{ | ||
/// rule: "self.spec.host == self.url.host".into(), | ||
/// message: Some("must be a URL with the host matching spec.host".into()), | ||
/// field_path: Some("spec.host".into()), | ||
/// ..Default::default() | ||
/// }]; | ||
/// let schema = validate(&mut schema, rules)?; | ||
/// assert_eq!( | ||
/// serde_json::to_string(&schema).unwrap(), | ||
/// 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"}]}"#, | ||
/// ); | ||
/// # Ok::<(), serde_json::Error>(()) | ||
///``` | ||
#[cfg(feature = "schema")] | ||
#[cfg_attr(docsrs, doc(cfg(feature = "schema")))] | ||
pub fn validate(s: &mut Schema, rules: Vec<Rule>) -> Result<Schema, serde_json::Error> { | ||
let rules = serde_json::to_value(rules)?; | ||
match s { | ||
Schema::Bool(_) => (), | ||
Schema::Object(schema_object) => { | ||
schema_object | ||
.extensions | ||
.insert("x-kubernetes-validations".into(), rules); | ||
} | ||
}; | ||
|
||
Ok(s.clone()) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,13 @@ | ||
// Generated by darling macros, out of our control | ||
#![allow(clippy::manual_unwrap_or_default)] | ||
|
||
use darling::{FromDeriveInput, FromMeta}; | ||
use darling::{ | ||
ast, | ||
util::{self, IdentString}, | ||
FromDeriveInput, FromField, FromMeta, | ||
}; | ||
use proc_macro2::{Ident, Literal, Span, TokenStream}; | ||
use quote::{ToTokens, TokenStreamExt}; | ||
use syn::{parse_quote, Data, DeriveInput, Path, Visibility}; | ||
use quote::{ToTokens, TokenStreamExt as _}; | ||
use syn::{parse_quote, spanned::Spanned, Data, DeriveInput, Expr, Path, Type, Visibility}; | ||
Check warning on line 10 in kube-derive/src/custom_resource.rs GitHub Actions / clippy_nightlyunused import: `spanned::Spanned`
Check warning on line 10 in kube-derive/src/custom_resource.rs GitHub Actions / clippy_nightlyunused import: `spanned::Spanned`
|
||
|
||
/// Values we can parse from #[kube(attrs)] | ||
#[derive(Debug, FromDeriveInput)] | ||
|
@@ -201,6 +204,7 @@ | |
.to_compile_error() | ||
} | ||
} | ||
|
||
let kube_attrs = match KubeAttrs::from_derive_input(&derive_input) { | ||
Err(err) => return err.write_errors(), | ||
Ok(attrs) => attrs, | ||
|
@@ -629,6 +633,71 @@ | |
} | ||
} | ||
|
||
#[derive(FromField)] | ||
#[darling(attributes(validated))] | ||
struct Rule { | ||
ident: Option<Ident>, | ||
ty: Type, | ||
method: Option<Path>, | ||
#[darling(multiple, rename = "rule")] | ||
rules: Vec<Expr>, | ||
} | ||
|
||
#[derive(FromDeriveInput)] | ||
#[darling(supports(struct_named))] | ||
struct CELValidation { | ||
#[darling(default)] | ||
crates: Crates, | ||
data: ast::Data<util::Ignored, Rule>, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note: may also be useful to provide naming template for common suffix/prefix per each attribute, in case when |
||
} | ||
|
||
pub(crate) fn derive_validated(input: TokenStream) -> TokenStream { | ||
let ast: DeriveInput = match syn::parse2(input) { | ||
Err(err) => return err.to_compile_error(), | ||
Ok(di) => di, | ||
}; | ||
|
||
let CELValidation { | ||
crates: Crates { | ||
kube_core, schemars, .. | ||
}, | ||
data, | ||
.. | ||
} = match CELValidation::from_derive_input(&ast) { | ||
Err(err) => return err.write_errors(), | ||
Ok(attrs) => attrs, | ||
}; | ||
|
||
let mut validations: TokenStream = TokenStream::new(); | ||
|
||
let fields = data.take_struct().map(|f| f.fields).unwrap_or_default(); | ||
for rule in fields.iter().filter(|r| !r.rules.is_empty()) { | ||
let Rule { | ||
rules, | ||
ident, | ||
ty, | ||
method, | ||
} = rule; | ||
let rules: Vec<TokenStream> = rules.iter().map(|r| quote! {#r,}).collect(); | ||
let method = match method { | ||
Some(method) => method.to_token_stream(), | ||
None => match ident { | ||
Some(ident) => IdentString::new(ident.clone()).to_token_stream(), | ||
None => continue, | ||
}, | ||
}; | ||
|
||
validations.append_all(quote! { | ||
fn #method(gen: &mut #schemars::gen::SchemaGenerator) -> #schemars::schema::Schema { | ||
use #kube_core::{Rule, Message, Reason}; | ||
#kube_core::validate(&mut gen.subschema_for::<#ty>(), [#(#rules)*].to_vec()).unwrap() | ||
} | ||
}); | ||
} | ||
|
||
validations | ||
} | ||
|
||
struct StatusInformation { | ||
/// The code to be used for the field in the main struct | ||
field: TokenStream, | ||
|
@@ -754,4 +823,23 @@ | |
let file = fs::File::open(path).unwrap(); | ||
runtime_macros::emulate_derive_macro_expansion(file, &[("CustomResource", derive)]).unwrap(); | ||
} | ||
|
||
#[test] | ||
fn test_derive_validated() { | ||
let input = quote! { | ||
#[derive(CustomResource, Serialize, Deserialize, Debug, PartialEq, Clone, JsonSchema, Validated)] | ||
#[kube(group = "clux.dev", version = "v1", kind = "Foo", namespaced)] | ||
struct FooSpec { | ||
#[validated(rule = Rule{rule: "self != ''".into(), ..Default::default()})] | ||
foo: String | ||
} | ||
}; | ||
let input = syn::parse2(input).unwrap(); | ||
let validation = CELValidation::from_derive_input(&input).unwrap(); | ||
let data = validation.data.take_struct(); | ||
assert!(data.is_some()); | ||
let data = data.unwrap(); | ||
assert_eq!(data.len(), 1); | ||
assert_eq!(data.fields[0].rules.len(), 1); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should probably call the module
cel.rs