diff --git a/examples/crd_derive_schema.rs b/examples/crd_derive_schema.rs index 8c58afacb..ebaa53ffa 100644 --- a/examples/crd_derive_schema.rs +++ b/examples/crd_derive_schema.rs @@ -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, }; @@ -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", @@ -87,7 +89,8 @@ pub struct FooSpec { set_listable: Vec, // 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, } // https://kubernetes.io/docs/reference/using-api/server-side-apply/#merge-strategy @@ -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() } @@ -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::::default_namespaced(client.clone()); @@ -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", diff --git a/kube-derive/src/custom_resource.rs b/kube-derive/src/custom_resource.rs index c342d99b4..c94ce95c9 100644 --- a/kube-derive/src/custom_resource.rs +++ b/kube-derive/src/custom_resource.rs @@ -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)] @@ -141,6 +141,21 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea .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( + attr, + r#"#[cel_validation] macro should be placed before the #[derive(JsonSchema)] macro to use with #[validated]"#, + ) + .to_compile_error(); + } + } + } + } + let kube_attrs = match KubeAttrs::from_derive_input(&derive_input) { Err(err) => return err.write_errors(), Ok(attrs) => attrs, @@ -539,6 +554,172 @@ fn generate_hasspec(spec_ident: &Ident, root_ident: &Ident, kube_core: &Path) -> } } +#[derive(FromAttributes)] +#[darling(attributes(validated))] +struct CELAttr { + rule: String, + message: Option, + message_expression: Option, + field_path: Option, + reason: Option, +} + +#[derive(FromMeta)] +struct CELSettings { + struct_name: Option, +} + +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, + }; + + let mut ast: DeriveInput = match syn::parse2(input) { + Err(err) => return err.to_compile_error(), + Ok(di) => di, + }; + + let args = match CELSettings::from_list(&args) { + Err(err) => return err.write_errors(), + Ok(attrs) => attrs, + }; + + 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(); + } + + // 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()); + + let mut validations: Vec = 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, + field_path, + message_expression, + reason, + } = match CELAttr::from_attributes(&vec![attr.clone()]) { + Ok(cel) => cel, + Err(e) => return e.with_span(&attr.meta).write_errors(), + }; + if let (Some(_), Some(_)) = (&message, &message_expression) { + return syn::Error::new_spanned( + attr, + r#"Either message or message_expression should be specified at once"#, + ) + .to_compile_error(); + } + let message = if let Some(message) = message { + quote! { "message": #message, } + } else { + quote! {} + }; + let field_path = if let Some(field_path) = field_path { + quote! { "fieldPath": #field_path, } + } else { + quote! {} + }; + let message_expression = if let Some(message_expression) = message_expression { + quote! { "messageExpression": #message_expression, } + } else { + quote! {} + }; + let reason = if let Some(reason) = reason { + quote! { "reason": #reason, } + } else { + quote! {} + }; + rules.push(quote! {{ + "rule": #rule, + #message + #field_path + #message_expression + #reason + },}); + } + + if rules.is_empty() { + continue; + } + + field.attrs = field + .attrs + .clone() + .into_iter() + .filter(|attr| !attr.path().is_ident("validated")) + .collect(); + + 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 #validation_struct {} + + impl #validation_struct { + #(#validations)* + } + + #ast + } +} + struct StatusInformation { /// The code to be used for the field in the main struct field: TokenStream, diff --git a/kube-derive/src/lib.rs b/kube-derive/src/lib.rs index 204a27e53..b29e4c88d 100644 --- a/kube-derive/src/lib.rs +++ b/kube-derive/src/lib.rs @@ -310,11 +310,46 @@ 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 = "")] 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, diff --git a/kube/src/lib.rs b/kube/src/lib.rs index e7be35690..9cbd4aa1f 100644 --- a/kube/src/lib.rs +++ b/kube/src/lib.rs @@ -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)]