Skip to content

Commit

Permalink
Implement cel validation proc macro for generated CRDs
Browse files Browse the repository at this point in the history
Signed-off-by: Danil-Grigorev <[email protected]>
  • Loading branch information
Danil-Grigorev committed Oct 27, 2024
1 parent 568c4df commit 332423c
Show file tree
Hide file tree
Showing 4 changed files with 178 additions and 16 deletions.
40 changes: 27 additions & 13 deletions examples/crd_derive_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use kube::{
runtime::wait::{await_condition, conditions},
Client, CustomResource, CustomResourceExt,
};
use kube_derive::cel_validation;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

Expand All @@ -19,6 +20,7 @@ use serde::{Deserialize, Serialize};
// - https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#defaulting
// - https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#defaulting-and-nullable

#[cel_validation]
#[derive(CustomResource, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone, JsonSchema)]
#[kube(
group = "clux.dev",
Expand Down Expand Up @@ -87,7 +89,8 @@ pub struct FooSpec {
set_listable: Vec<u32>,
// Field with CEL validation
#[serde(default)]
#[schemars(schema_with = "cel_validations")]
#[validated(rule="self != 'illegal'", message="string cannot be illegal")]
#[validated(rule="self != 'not legal'")]
cel_validated: Option<String>,
}
// https://kubernetes.io/docs/reference/using-api/server-side-apply/#merge-strategy
Expand All @@ -104,18 +107,6 @@ fn set_listable_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::sche
.unwrap()
}

// https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#validation-rules
fn cel_validations(_: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
serde_json::from_value(serde_json::json!({
"type": "string",
"x-kubernetes-validations": [{
"rule": "self != 'illegal'",
"message": "string cannot be illegal"
}]
}))
.unwrap()
}

fn default_value() -> String {
"default_value".into()
}
Expand Down Expand Up @@ -248,6 +239,29 @@ async fn main() -> Result<()> {
}
_ => panic!(),
}

// cel validation triggers:
let cel_patch = serde_json::json!({
"apiVersion": "clux.dev/v1",
"kind": "Foo",
"spec": {
"cel_validated": Some("not legal")
}
});
let cel_res = foos.patch("baz", &ssapply, &Patch::Apply(cel_patch)).await;
assert!(cel_res.is_err());
match cel_res.err() {
Some(kube::Error::Api(err)) => {
assert_eq!(err.code, 422);
assert_eq!(err.reason, "Invalid");
assert_eq!(err.status, "Failure");
assert!(err.message.contains("Foo.clux.dev \"baz\" is invalid"));
assert!(err.message.contains("spec.cel_validated: Invalid value"));
assert!(err.message.contains("failed rule: self != 'not legal'"));
}
_ => panic!(),
}

// cel validation happy:
let cel_patch_ok = serde_json::json!({
"apiVersion": "clux.dev/v1",
Expand Down
116 changes: 114 additions & 2 deletions kube-derive/src/custom_resource.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// Generated by darling macros, out of our control
#![allow(clippy::manual_unwrap_or_default)]

use darling::{FromDeriveInput, FromMeta};
use darling::{FromAttributes, FromDeriveInput, FromMeta};
use proc_macro2::{Ident, Literal, Span, TokenStream};
use quote::ToTokens;
use syn::{parse_quote, Data, DeriveInput, Path, Visibility};
use syn::{parse::Parser, parse_quote, Attribute, Data, DeriveInput, Path, Visibility};

/// Values we can parse from #[kube(attrs)]
#[derive(Debug, FromDeriveInput)]
Expand Down Expand Up @@ -539,6 +539,118 @@ fn generate_hasspec(spec_ident: &Ident, root_ident: &Ident, kube_core: &Path) ->
}
}

#[derive(FromAttributes)]
#[darling(attributes(validated))]
struct CELAttr {
rule: String,
message: Option<String>,
}

pub(crate) fn cel_validation(_: TokenStream, input: TokenStream) -> TokenStream {
let mut ast: DeriveInput = match syn::parse2(input) {
Err(err) => return err.to_compile_error(),
Ok(di) => di,
};

if !ast
.attrs
.iter().any(|attr| attr.path().is_ident("derive"))
{
return syn::Error::new(
ast.ident.span(),
r#"#[cel_validation] macro should be placed before the #[derive(JsonSchema)] macro"#,
)
.to_compile_error();
}

let struct_name = ast.ident.to_string() + "Validation";
let anchor = Ident::new(&struct_name, Span::call_site());

let mut validations: Vec<TokenStream> = vec![];

let struct_data = match ast.data {
syn::Data::Struct(ref mut struct_data) => struct_data,
_ => {
return syn::Error::new(
ast.ident.span(),
r#"#[cel_validation] has to be used with structs"#,
)
.to_compile_error()
}
};

if let syn::Fields::Named(fields) = &mut struct_data.fields {
for field in &mut fields.named {
let mut rules = vec![];
for attr in field
.attrs
.iter()
.filter(|attr| attr.path().is_ident("validated"))
{
let CELAttr { rule, message } = match CELAttr::from_attributes(&vec![attr.clone()]) {
Ok(cel) => cel,
Err(e) => return e.with_span(&attr.meta).write_errors(),
};
let message = if let Some(message) = message {
quote! { "message": #message }
} else {
quote! {}
};
rules.push(quote! {{
"rule": #rule,
#message
},});
}

if rules.is_empty() {
continue;
}

let validation_method_name = field.ident.as_ref().map(|i| i.to_string()).unwrap_or_default();
let name = Ident::new(&validation_method_name, Span::call_site());
let field_type = &field.ty;

validations.push(quote! {
fn #name(gen: &mut ::schemars::gen::SchemaGenerator) -> ::schemars::schema::Schema {
let s = gen.subschema_for::<#field_type>();
let mut s = serde_json::to_value(s).unwrap();
let v = serde_json::json!([
#(#rules)*
]);
match &mut s {
serde_json::Value::Object(a) => {
a.insert("x-kubernetes-validations".into(), v.clone());
}
_ => (),
}
serde_json::from_value(s).unwrap()
}
});

let validator = struct_name.clone() + "::" + &validation_method_name;
let new_serde_attr = quote! {
#[schemars(schema_with = #validator)]
};

let parser = Attribute::parse_outer;
match parser.parse2(new_serde_attr) {
Ok(ref mut parsed) => field.attrs.append(parsed),
Err(e) => return e.to_compile_error(),
};
}
}

quote! {
struct #anchor {}

impl #anchor {
#(#validations)*
}

#ast
}
}

struct StatusInformation {
/// The code to be used for the field in the main struct
field: TokenStream,
Expand Down
34 changes: 33 additions & 1 deletion kube-derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,11 +310,43 @@ mod resource;
/// [`kube::Resource`]: https://docs.rs/kube/*/kube/trait.Resource.html
/// [`kube::core::ApiResource`]: https://docs.rs/kube/*/kube/core/struct.ApiResource.html
/// [`kube::CustomResourceExt`]: https://docs.rs/kube/*/kube/trait.CustomResourceExt.html
#[proc_macro_derive(CustomResource, attributes(kube))]
#[proc_macro_derive(CustomResource, attributes(kube, validated))]
pub fn derive_custom_resource(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
custom_resource::derive(proc_macro2::TokenStream::from(input)).into()
}

/// Generates a JsonSchema patch with a set of CEL expression validation rules applied on the CRD.
///
/// This macro should be placed before the `#[derive(JsonSchema)]` macro on the struct being validated,
/// as it performs addition of the #[schemars(schema_with = "<validation_injector>")] derive macro
/// on the validated field.
///
/// # Example
///
/// ```rust
/// use kube::cel_validation;
/// use kube::CustomResource;
/// use serde::Deserialize;
/// use serde::Serialize;
/// use schemars::JsonSchema;
/// use kube::core::crd::CustomResourceExt;
///
/// #[cel_validation]
/// #[derive(CustomResource, Serialize, Deserialize, Clone, Debug, JsonSchema)]
/// #[kube(group = "kube.rs", version = "v1", kind = "Struct")]
/// struct MyStruct {
/// #[validated(rule = "self != ''")]
/// field: String,
/// }
///
/// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains("x-kubernetes-validations"));
/// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains(r#""rule":"self != ''""#));
/// ```
#[proc_macro_attribute]
pub fn cel_validation(args: proc_macro::TokenStream, input: proc_macro::TokenStream) -> proc_macro::TokenStream {
custom_resource::cel_validation(args.into(), input.into()).into()
}

/// A custom derive for inheriting Resource impl for the type.
///
/// This will generate a [`kube::Resource`] trait implementation,
Expand Down
4 changes: 4 additions & 0 deletions kube/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ pub use kube_derive::CustomResource;
#[cfg_attr(docsrs, doc(cfg(feature = "derive")))]
pub use kube_derive::Resource;

#[cfg(feature = "derive")]
#[cfg_attr(docsrs, doc(cfg(feature = "derive")))]
pub use kube_derive::cel_validation;

#[cfg(feature = "runtime")]
#[cfg_attr(docsrs, doc(cfg(feature = "runtime")))]
#[doc(inline)]
Expand Down

0 comments on commit 332423c

Please sign in to comment.