From abe840c399c13d4aa68713aaad8c7c55771e9fa0 Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Fri, 29 Nov 2024 03:01:53 +0100 Subject: [PATCH] Add cel_validate proc macro for completion, rename Signed-off-by: Danil-Grigorev --- examples/crd_derive_schema.rs | 47 +++++++++++-- kube-core/src/{validation.rs => cel.rs} | 18 +++++ kube-core/src/lib.rs | 6 +- kube-derive/src/custom_resource.rs | 93 ++++++++++++++++++++++--- kube-derive/src/lib.rs | 46 ++++++++++-- kube/src/lib.rs | 2 +- 6 files changed, 190 insertions(+), 22 deletions(-) rename kube-core/src/{validation.rs => cel.rs} (93%) diff --git a/examples/crd_derive_schema.rs b/examples/crd_derive_schema.rs index 59ebd080a..e83e12134 100644 --- a/examples/crd_derive_schema.rs +++ b/examples/crd_derive_schema.rs @@ -6,8 +6,9 @@ use kube::{ Api, ApiResource, DeleteParams, DynamicObject, GroupVersionKind, Patch, PatchParams, PostParams, WatchEvent, WatchParams, }, + cel_validate, runtime::wait::{await_condition, conditions}, - Client, CustomResource, CustomResourceExt, Validated, + CELValidate, Client, CustomResource, CustomResourceExt, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -20,7 +21,7 @@ use serde::{Deserialize, Serialize}; // - https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#defaulting-and-nullable #[derive( - CustomResource, Validated, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone, JsonSchema, + CustomResource, CELValidate, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone, JsonSchema, )] #[kube( group = "clux.dev", @@ -90,14 +91,26 @@ pub struct FooSpec { // Field with CEL validation #[serde(default = "default_legal")] - #[validated( - method = cel_validated, + #[cel_validate( + method = cel_validate, rule = Rule{rule: "self != 'illegal'".into(), message: Some(Message::Expression("'string cannot be illegal'".into())), reason: Some(Reason::FieldValueForbidden), ..Default::default()}, rule = Rule{rule: "self != 'not legal'".into(), reason: Some(Reason::FieldValueInvalid), ..Default::default()} )] - #[schemars(schema_with = "cel_validated")] + #[schemars(schema_with = "cel_validate")] cel_validated: Option, + + foo_sub_spec: Option, +} + +#[cel_validate] +#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone, JsonSchema)] +pub struct FooSubSpec { + #[cel_validate(rule = "self != 'not legal'".into())] + field: String, + + other: Option, } + // https://kubernetes.io/docs/reference/using-api/server-side-apply/#merge-strategy fn set_listable_schema(_: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { serde_json::from_value(serde_json::json!({ @@ -160,6 +173,7 @@ async fn main() -> Result<()> { default_listable: Default::default(), set_listable: Default::default(), cel_validated: Default::default(), + foo_sub_spec: Default::default(), }); // Set up dynamic resource to test using raw values. @@ -272,6 +286,29 @@ async fn main() -> Result<()> { _ => panic!(), } + let cel_patch = serde_json::json!({ + "apiVersion": "clux.dev/v1", + "kind": "Foo", + "spec": { + "foo_sub_spec": { + "field": 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.foo_sub_spec.field: 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-core/src/validation.rs b/kube-core/src/cel.rs similarity index 93% rename from kube-core/src/validation.rs rename to kube-core/src/cel.rs index a424795d0..5b0a34444 100644 --- a/kube-core/src/validation.rs +++ b/kube-core/src/cel.rs @@ -25,6 +25,24 @@ pub struct Rule { pub reason: Option, } +impl From<&str> for Rule { + fn from(value: &str) -> Self { + Self { + rule: value.into(), + ..Default::default() + } + } +} + +impl From<(&str, &str)> for Rule { + fn from((rule, msg): (&str, &str)) -> Self { + Self { + rule: rule.into(), + message: Some(msg.into()), + ..Default::default() + } + } +} /// Message represents CEL validation message for the provided type #[derive(Serialize, Deserialize, Clone)] #[serde(rename_all = "lowercase")] diff --git a/kube-core/src/lib.rs b/kube-core/src/lib.rs index 7ae121efc..9f366d4d1 100644 --- a/kube-core/src/lib.rs +++ b/kube-core/src/lib.rs @@ -25,10 +25,10 @@ pub use dynamic::{ApiResource, DynamicObject}; pub mod crd; pub use crd::CustomResourceExt; -pub mod validation; -pub use validation::{Message, Reason, Rule}; +pub mod cel; +pub use cel::{Message, Reason, Rule}; -#[cfg(feature = "schema")] pub use validation::validate; +#[cfg(feature = "schema")] pub use cel::validate; pub mod gvk; pub use gvk::{GroupVersion, GroupVersionKind, GroupVersionResource}; diff --git a/kube-derive/src/custom_resource.rs b/kube-derive/src/custom_resource.rs index 0038f4abf..80462a6a5 100644 --- a/kube-derive/src/custom_resource.rs +++ b/kube-derive/src/custom_resource.rs @@ -1,13 +1,16 @@ // Generated by darling macros, out of our control #![allow(clippy::manual_unwrap_or_default)] use darling::{ - ast, - util::{self, IdentString}, + ast::{self, NestedMeta}, + util::{self, path_to_string, IdentString}, FromDeriveInput, FromField, FromMeta, }; use proc_macro2::{Ident, Literal, Span, TokenStream}; use quote::{ToTokens, TokenStreamExt as _}; -use syn::{parse_quote, spanned::Spanned, Data, DeriveInput, Expr, Path, Type, Visibility}; +use syn::{ + parse::Parser as _, parse_quote, spanned::Spanned, Attribute, Data, DeriveInput, Expr, Path, Type, + Visibility, +}; /// Values we can parse from #[kube(attrs)] #[derive(Debug, FromDeriveInput)] @@ -104,6 +107,8 @@ fn default_served_arg() -> bool { #[derive(Debug, FromMeta)] struct Crates { + #[darling(default = "Self::default_kube")] + kube: Path, #[darling(default = "Self::default_kube_core")] kube_core: Path, #[darling(default = "Self::default_k8s_openapi")] @@ -131,6 +136,10 @@ impl Crates { parse_quote! { ::kube::core } // by default must work well with people using facade crate } + fn default_kube() -> Path { + parse_quote! { ::kube } + } + fn default_k8s_openapi() -> Path { parse_quote! { ::k8s_openapi } } @@ -237,6 +246,7 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea serde, serde_json, std, + .. }, annotations, labels, @@ -634,7 +644,7 @@ fn generate_hasspec(spec_ident: &Ident, root_ident: &Ident, kube_core: &Path) -> } #[derive(FromField)] -#[darling(attributes(validated))] +#[darling(attributes(cel_validate))] struct Rule { ident: Option, ty: Type, @@ -644,14 +654,14 @@ struct Rule { } #[derive(FromDeriveInput)] -#[darling(supports(struct_named))] +#[darling(attributes(cel_validate), supports(struct_named))] struct CELValidation { #[darling(default)] crates: Crates, data: ast::Data, } -pub(crate) fn derive_validated(input: TokenStream) -> TokenStream { +pub(crate) fn derive_cel_validate(input: TokenStream) -> TokenStream { let ast: DeriveInput = match syn::parse2(input) { Err(err) => return err.to_compile_error(), Ok(di) => di, @@ -698,6 +708,73 @@ pub(crate) fn derive_validated(input: TokenStream) -> TokenStream { validations } +#[derive(FromDeriveInput)] +#[darling(attributes(cel_validate), supports(struct_named))] +struct CELCompletion { + #[darling(default)] + crates: Crates, +} + +pub(crate) fn cel_validate(_args: TokenStream, input: TokenStream) -> TokenStream { + let mut ast: DeriveInput = match syn::parse2(input) { + Err(err) => return err.to_compile_error(), + Ok(di) => di, + }; + + let CELCompletion { + crates: Crates { kube, .. }, + } = match CELCompletion::from_derive_input(&ast) { + Err(err) => return err.write_errors(), + Ok(attrs) => attrs, + }; + + let struct_data = match ast.data { + syn::Data::Struct(ref mut struct_data) => struct_data, + _ => return quote! {}, + }; + + if let syn::Fields::Named(fields) = &mut struct_data.fields { + for field in &mut fields.named { + let Rule { rules, method, .. } = match Rule::from_field(field) { + Ok(rule) if rule.rules.is_empty() => continue, + Ok(rule) => rule, + Err(err) => return err.write_errors(), + }; + + // Remove original attributes + field.attrs = field + .attrs + .iter() + .filter(|attr| !attr.path().is_ident("cel_validate")) + .cloned() + .collect(); + + let rules: Vec = rules.iter().map(|r| quote! {rule = #r,}).collect(); + let validator = field.ident.as_ref().map(|i| i.to_string()).unwrap_or_default(); + let validator = method.as_ref().map(path_to_string).unwrap_or(validator); + let method = method.map(|m| quote! {method = #m,}); + + // Prepare updated definition with shcemars injection + let new_serde_attr = quote! { + #[cel_validate(#method #(#rules)*)] + #[schemars(schema_with = #validator)] + }; + + // Modify directly in tree + 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! { + #[derive(#kube::CELValidate)] + #ast + } +} + struct StatusInformation { /// The code to be used for the field in the main struct field: TokenStream, @@ -827,10 +904,10 @@ mod tests { #[test] fn test_derive_validated() { let input = quote! { - #[derive(CustomResource, Serialize, Deserialize, Debug, PartialEq, Clone, JsonSchema, Validated)] + #[derive(CustomResource, Serialize, Deserialize, Debug, PartialEq, Clone, JsonSchema, CELValidated)] #[kube(group = "clux.dev", version = "v1", kind = "Foo", namespaced)] struct FooSpec { - #[validated(rule = Rule{rule: "self != ''".into(), ..Default::default()})] + #[cel_validate(rule = "self != ''".into())] foo: String } }; diff --git a/kube-derive/src/lib.rs b/kube-derive/src/lib.rs index 094640d6c..97cf9604c 100644 --- a/kube-derive/src/lib.rs +++ b/kube-derive/src/lib.rs @@ -332,18 +332,18 @@ pub fn derive_custom_resource(input: proc_macro::TokenStream) -> proc_macro::Tok /// # Example /// /// ```rust -/// use kube::Validated; +/// use kube::CELValidate; /// use kube::CustomResource; /// use serde::Deserialize; /// use serde::Serialize; /// use schemars::JsonSchema; /// use kube::core::crd::CustomResourceExt; /// -/// #[derive(CustomResource, Validated, Serialize, Deserialize, Clone, Debug, JsonSchema)] +/// #[derive(CustomResource, CELValidate, Serialize, Deserialize, Clone, Debug, JsonSchema)] /// #[kube(group = "kube.rs", version = "v1", kind = "Struct")] /// struct MyStruct { /// #[serde(default = "default")] -/// #[validated(rule = Rule{rule: "self != ''".into(), message: Some("failure message".into()), ..Default::default()})] +/// #[cel_validate(rule = Rule{rule: "self != ''".into(), message: Some("failure message".into()), ..Default::default()})] /// #[schemars(schema_with = "field")] /// field: String, /// } @@ -357,9 +357,45 @@ pub fn derive_custom_resource(input: proc_macro::TokenStream) -> proc_macro::Tok /// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains(r#""message":"failure message""#)); /// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains(r#""default":"value""#)); /// ``` -#[proc_macro_derive(Validated, attributes(validated, schemars))] +#[proc_macro_derive(CELValidate, attributes(cel_validate))] pub fn derive_validated(input: proc_macro::TokenStream) -> proc_macro::TokenStream { - custom_resource::derive_validated(input.into()).into() + custom_resource::derive_cel_validate(input.into()).into() +} + +/// Injects schemars overrides and the derive keyword for provided CEL rules via the [`CELValidate`]. +/// +/// ```rust +/// use kube::cel_validate; +/// use kube::CustomResource; +/// use serde::Deserialize; +/// use serde::Serialize; +/// use schemars::JsonSchema; +/// use kube::core::crd::CustomResourceExt; +/// +/// #[cel_validate] +/// #[derive(CustomResource, Serialize, Deserialize, Clone, Debug, JsonSchema)] +/// #[kube(group = "kube.rs", version = "v1", kind = "Struct")] +/// struct MyStruct { +/// #[serde(default = "default")] +/// #[cel_validate(rule = Rule{rule: "self != ''".into(), message: Some("failure message".into()), ..Default::default()})] +/// field: String, +/// } +/// +/// fn default() -> String { +/// "value".into() +/// } +/// +/// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains("x-kubernetes-validations")); +/// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains(r#""rule":"self != ''""#)); +/// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains(r#""message":"failure message""#)); +/// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains(r#""default":"value""#)); +/// ``` +#[proc_macro_attribute] +pub fn cel_validate( + args: proc_macro::TokenStream, + input: proc_macro::TokenStream, +) -> proc_macro::TokenStream { + custom_resource::cel_validate(args.into(), input.into()).into() } /// A custom derive for inheriting Resource impl for the type. diff --git a/kube/src/lib.rs b/kube/src/lib.rs index a03d6a16e..4360be6d5 100644 --- a/kube/src/lib.rs +++ b/kube/src/lib.rs @@ -170,7 +170,7 @@ pub use kube_derive::Resource; #[cfg(feature = "derive")] #[cfg_attr(docsrs, doc(cfg(feature = "derive")))] -pub use kube_derive::Validated; +pub use kube_derive::{cel_validate, CELValidate}; #[cfg(feature = "runtime")] #[cfg_attr(docsrs, doc(cfg(feature = "runtime")))]