Skip to content
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 proc macro for generated CRDs #1621

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 28 additions & 14 deletions examples/crd_derive_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use kube::{
Api, ApiResource, DeleteParams, DynamicObject, GroupVersionKind, Patch, PatchParams, PostParams,
WatchEvent, WatchParams,
},
cel_validation,
runtime::wait::{await_condition, conditions},
Client, CustomResource, CustomResourceExt,
};
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(struct_name = "FooSpecValidation")]
#[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_expression = "'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 All @@ -133,7 +124,7 @@ async fn main() -> Result<()> {
println!("Creating CRD v1");
let client = Client::try_default().await?;
delete_crd(client.clone()).await?;
assert!(create_crd(client.clone()).await.is_ok());
assert!(dbg!(create_crd(client.clone()).await).is_ok());

// Test creating Foo resource.
let foos = Api::<Foo>::default_namespaced(client.clone());
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'"));
clux marked this conversation as resolved.
Show resolved Hide resolved
}
_ => panic!(),
}

// cel validation happy:
let cel_patch_ok = serde_json::json!({
"apiVersion": "clux.dev/v1",
Expand Down
185 changes: 183 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::{ast::NestedMeta, 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 @@ -141,6 +141,21 @@
.to_compile_error()
}
}

if let Data::Struct(struct_data) = &derive_input.data {
if let syn::Fields::Named(fields) = &struct_data.fields {
for field in &fields.named {
if let Some(attr) = field.attrs.iter().find(|attr| attr.path().is_ident("validated")) {
return syn::Error::new_spanned(

Check warning on line 149 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L149

Added line #L149 was not covered by tests
attr,
r#"#[cel_validation] macro should be placed before the #[derive(JsonSchema)] macro to use with #[validated]"#,
)
.to_compile_error();

Check warning on line 153 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L153

Added line #L153 was not covered by tests
}
}
}
}

let kube_attrs = match KubeAttrs::from_derive_input(&derive_input) {
Err(err) => return err.write_errors(),
Ok(attrs) => attrs,
Expand Down Expand Up @@ -539,6 +554,172 @@
}
}

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

#[derive(FromMeta)]
struct CELSettings {
struct_name: Option<String>,
}

pub(crate) fn cel_validation(args: TokenStream, input: TokenStream) -> TokenStream {
let args = match NestedMeta::parse_meta_list(args) {
Err(err) => return err.to_compile_error(),
Ok(args) => args,

Check warning on line 575 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L572-L575

Added lines #L572 - L575 were not covered by tests
};

let mut ast: DeriveInput = match syn::parse2(input) {
Err(err) => return err.to_compile_error(),
Ok(di) => di,

Check warning on line 580 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L578-L580

Added lines #L578 - L580 were not covered by tests
};

let args = match CELSettings::from_list(&args) {
Err(err) => return err.write_errors(),
Ok(attrs) => attrs,

Check warning on line 585 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L583-L585

Added lines #L583 - L585 were not covered by tests
};

if !ast.attrs.iter().any(|attr| attr.path().is_ident("derive")) {
return syn::Error::new(
ast.ident.span(),

Check warning on line 590 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L588-L590

Added lines #L588 - L590 were not covered by tests
r#"#[cel_validation] macro should be placed before the #[derive(JsonSchema)] macro"#,
)
.to_compile_error();

Check warning on line 593 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L593

Added line #L593 was not covered by tests
}

// Create a struct name for added validation rules, following the original struct name + "Validation"
let struct_name = args.struct_name.unwrap_or(ast.ident.to_string() + "Validation");
let validation_struct = Ident::new(&struct_name, Span::call_site());

Check warning on line 598 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L597-L598

Added lines #L597 - L598 were not covered by tests

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

Check warning on line 600 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L600

Added line #L600 was not covered by tests

let struct_data = match ast.data {
syn::Data::Struct(ref mut struct_data) => struct_data,

Check warning on line 603 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L602-L603

Added lines #L602 - L603 were not covered by tests
_ => {
return syn::Error::new(
ast.ident.span(),

Check warning on line 606 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L605-L606

Added lines #L605 - L606 were not covered by tests
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

Check warning on line 616 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L613-L616

Added lines #L613 - L616 were not covered by tests
.attrs
.iter()
.filter(|attr| attr.path().is_ident("validated"))

Check warning on line 619 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L619

Added line #L619 was not covered by tests
{
let CELAttr {
rule,
message,
field_path,
message_expression,
reason,

Check warning on line 626 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L621-L626

Added lines #L621 - L626 were not covered by tests
} = match CELAttr::from_attributes(&vec![attr.clone()]) {
Ok(cel) => cel,
Err(e) => return e.with_span(&attr.meta).write_errors(),

Check warning on line 629 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L628-L629

Added lines #L628 - L629 were not covered by tests
};
if let (Some(_), Some(_)) = (&message, &message_expression) {
return syn::Error::new_spanned(

Check warning on line 632 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L631-L632

Added lines #L631 - L632 were not covered by tests
attr,
r#"Either message or message_expression should be specified at once"#,
)
.to_compile_error();

Check warning on line 636 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L636

Added line #L636 was not covered by tests
}
let message = if let Some(message) = message {
quote! { "message": #message, }

Check warning on line 639 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L638-L639

Added lines #L638 - L639 were not covered by tests
} else {
quote! {}

Check warning on line 641 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L641

Added line #L641 was not covered by tests
};
let field_path = if let Some(field_path) = field_path {
quote! { "fieldPath": #field_path, }

Check warning on line 644 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L643-L644

Added lines #L643 - L644 were not covered by tests
} else {
quote! {}

Check warning on line 646 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L646

Added line #L646 was not covered by tests
};
let message_expression = if let Some(message_expression) = message_expression {
quote! { "messageExpression": #message_expression, }

Check warning on line 649 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L648-L649

Added lines #L648 - L649 were not covered by tests
} else {
quote! {}

Check warning on line 651 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L651

Added line #L651 was not covered by tests
};
let reason = if let Some(reason) = reason {
quote! { "reason": #reason, }

Check warning on line 654 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L653-L654

Added lines #L653 - L654 were not covered by tests
} else {
quote! {}

Check warning on line 656 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L656

Added line #L656 was not covered by tests
};
rules.push(quote! {{

Check warning on line 658 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L658

Added line #L658 was not covered by tests
"rule": #rule,
#message
#field_path
#message_expression
#reason
},});
}

if rules.is_empty() {

Check warning on line 667 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L667

Added line #L667 was not covered by tests
continue;
}

field.attrs = field

Check warning on line 671 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L671

Added line #L671 was not covered by tests
.attrs
.clone()
.into_iter()
.filter(|attr| !attr.path().is_ident("validated"))
.collect();

Check warning on line 676 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L675-L676

Added lines #L675 - L676 were not covered by tests

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;

Check warning on line 680 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L678-L680

Added lines #L678 - L680 were not covered by tests
Comment on lines +678 to +680
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stuff here could do with some comments. You're implementing a method fith a fixed name. Could this ave clashing issues? Can we parametrise such a function from kube-core instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as there is no structure Validation in the scope, there should not be a clash, but makes sense to add an override for such occasion 👍🏻

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be added now - #[cel_validation(struct_name = "FooSpecValidation")]

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i guess i am still confused about the temporary struct you are making. it seems to me that the #[cel_validation(struct_name = X struct X is there for you to implement a method, but that method feels like something we can define cleanly inside kube-core, and invoke from kube-derive.


validations.push(quote! {

Check warning on line 682 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L682

Added line #L682 was not covered by tests
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! {

Check warning on line 700 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L699-L700

Added lines #L699 - L700 were not covered by tests
#[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(),

Check warning on line 707 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L705-L707

Added lines #L705 - L707 were not covered by tests
};
}
}

quote! {

Check warning on line 712 in kube-derive/src/custom_resource.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L712

Added line #L712 was not covered by tests
struct #validation_struct {}

impl #validation_struct {
#(#validations)*
}

#ast
}
}

struct StatusInformation {
/// The code to be used for the field in the main struct
field: TokenStream,
Expand Down
37 changes: 36 additions & 1 deletion kube-derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,11 +310,46 @@
/// [`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(

Check warning on line 346 in kube-derive/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/lib.rs#L346

Added line #L346 was not covered by tests
args: proc_macro::TokenStream,
input: proc_macro::TokenStream,
) -> proc_macro::TokenStream {
custom_resource::cel_validation(args.into(), input.into()).into()

Check warning on line 350 in kube-derive/src/lib.rs

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/lib.rs#L350

Added line #L350 was not covered by tests
}

/// 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
Loading