From e757478c2b2621171f6e520d6349005107245fca Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Sun, 27 Oct 2024 15:21:55 +0100 Subject: [PATCH 01/15] Implement cel validation proc macro for generated CRDs - Extend with supported values from docs - https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#validation-rules - Implement as Validated derive macro - Use the raw Rule for the validated attribute Signed-off-by: Danil-Grigorev --- examples/crd_derive_schema.rs | 58 +++++++++----- kube-core/src/lib.rs | 5 ++ kube-core/src/validation.rs | 118 +++++++++++++++++++++++++++++ kube-derive/src/custom_resource.rs | 96 ++++++++++++++++++++++- kube-derive/src/lib.rs | 35 +++++++++ kube/src/lib.rs | 4 + 6 files changed, 295 insertions(+), 21 deletions(-) create mode 100644 kube-core/src/validation.rs diff --git a/examples/crd_derive_schema.rs b/examples/crd_derive_schema.rs index 8c58afacb..59ebd080a 100644 --- a/examples/crd_derive_schema.rs +++ b/examples/crd_derive_schema.rs @@ -7,7 +7,7 @@ use kube::{ WatchEvent, WatchParams, }, runtime::wait::{await_condition, conditions}, - Client, CustomResource, CustomResourceExt, + Client, CustomResource, CustomResourceExt, Validated, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -19,7 +19,9 @@ 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 -#[derive(CustomResource, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone, JsonSchema)] +#[derive( + CustomResource, Validated, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone, JsonSchema, +)] #[kube( group = "clux.dev", version = "v1", @@ -85,9 +87,15 @@ pub struct FooSpec { #[serde(default)] #[schemars(schema_with = "set_listable_schema")] set_listable: Vec, + // Field with CEL validation - #[serde(default)] - #[schemars(schema_with = "cel_validations")] + #[serde(default = "default_legal")] + #[validated( + method = cel_validated, + 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")] cel_validated: Option, } // https://kubernetes.io/docs/reference/using-api/server-side-apply/#merge-strategy @@ -104,22 +112,14 @@ 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() } +fn default_legal() -> Option { + Some("legal".into()) +} + fn default_nullable() -> Option { Some("default_nullable".into()) } @@ -194,6 +194,7 @@ async fn main() -> Result<()> { // Listables assert_eq!(serde_json::to_string(&val["spec"]["default_listable"])?, "[2]"); assert_eq!(serde_json::to_string(&val["spec"]["set_listable"])?, "[2]"); + assert_eq!(serde_json::to_string(&val["spec"]["cel_validated"])?, "\"legal\""); // Missing required field (non-nullable without default) is an error let data = DynamicObject::new("qux", &api_resource).data(serde_json::json!({ @@ -243,11 +244,34 @@ async fn main() -> Result<()> { 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("spec.cel_validated: Forbidden")); assert!(err.message.contains("string cannot be illegal")); } _ => 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-core/src/lib.rs b/kube-core/src/lib.rs index 969d10e0a..7ae121efc 100644 --- a/kube-core/src/lib.rs +++ b/kube-core/src/lib.rs @@ -25,6 +25,11 @@ pub use dynamic::{ApiResource, DynamicObject}; pub mod crd; pub use crd::CustomResourceExt; +pub mod validation; +pub use validation::{Message, Reason, Rule}; + +#[cfg(feature = "schema")] pub use validation::validate; + pub mod gvk; pub use gvk::{GroupVersion, GroupVersionKind, GroupVersionResource}; diff --git a/kube-core/src/validation.rs b/kube-core/src/validation.rs new file mode 100644 index 000000000..a424795d0 --- /dev/null +++ b/kube-core/src/validation.rs @@ -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 { + /// 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, + /// 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, + /// 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, +} + +/// 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. + /// 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) +#[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 { + 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) -> Result { + 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()) +} diff --git a/kube-derive/src/custom_resource.rs b/kube-derive/src/custom_resource.rs index 055664f31..0038f4abf 100644 --- a/kube-derive/src/custom_resource.rs +++ b/kube-derive/src/custom_resource.rs @@ -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}; /// Values we can parse from #[kube(attrs)] #[derive(Debug, FromDeriveInput)] @@ -201,6 +204,7 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea .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 @@ fn generate_hasspec(spec_ident: &Ident, root_ident: &Ident, kube_core: &Path) -> } } +#[derive(FromField)] +#[darling(attributes(validated))] +struct Rule { + ident: Option, + ty: Type, + method: Option, + #[darling(multiple, rename = "rule")] + rules: Vec, +} + +#[derive(FromDeriveInput)] +#[darling(supports(struct_named))] +struct CELValidation { + #[darling(default)] + crates: Crates, + data: ast::Data, +} + +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 = 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 @@ mod tests { 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); + } } diff --git a/kube-derive/src/lib.rs b/kube-derive/src/lib.rs index 36b7df07c..094640d6c 100644 --- a/kube-derive/src/lib.rs +++ b/kube-derive/src/lib.rs @@ -327,6 +327,41 @@ pub fn derive_custom_resource(input: proc_macro::TokenStream) -> proc_macro::Tok 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. +/// +/// # Example +/// +/// ```rust +/// use kube::Validated; +/// 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)] +/// #[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()})] +/// #[schemars(schema_with = "field")] +/// 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_derive(Validated, attributes(validated, schemars))] +pub fn derive_validated(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + custom_resource::derive_validated(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..a03d6a16e 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::Validated; + #[cfg(feature = "runtime")] #[cfg_attr(docsrs, doc(cfg(feature = "runtime")))] #[doc(inline)] From 800d58493c8a8933ed42a96d47fb6e1ad473c33e Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Fri, 29 Nov 2024 03:01:53 +0100 Subject: [PATCH 02/15] 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")))] From b7fbded53e8a945fdf7cddf007778e664d9d5765 Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Sat, 30 Nov 2024 14:34:27 +0100 Subject: [PATCH 03/15] Add builder for the Rule Signed-off-by: Danil-Grigorev --- kube-core/src/cel.rs | 72 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/kube-core/src/cel.rs b/kube-core/src/cel.rs index 5b0a34444..f674497fb 100644 --- a/kube-core/src/cel.rs +++ b/kube-core/src/cel.rs @@ -2,11 +2,12 @@ use std::str::FromStr; -#[cfg(feature = "schema")] use schemars::schema::Schema; +#[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)] +#[derive(Default, Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct Rule { /// rule represents the expression which will be evaluated by CEL. @@ -25,6 +26,68 @@ pub struct Rule { pub reason: Option, } +impl Rule { + /// Initialize the rule + /// + /// ```rust + /// use kube_core::Rule; + /// let r = Rule::new("self == oldSelf"); + /// + /// assert_eq!(r.rule, "self == oldSelf".to_string()) + /// ``` + pub fn new(rule: impl Into) -> Self { + Self { + rule: rule.into(), + ..Default::default() + } + } + + /// Set the rule message. + /// + /// use kube_core::Rule; + /// ```rust + /// use kube_core::{Rule, Message}; + /// + /// let r = Rule::new("self == oldSelf").message("is immutable"); + /// assert_eq!(r.rule, "self == oldSelf".to_string()); + /// assert_eq!(r.message, Some(Message::Message("is immutable".to_string()))); + /// ``` + pub fn message(mut self, message: impl Into) -> Self { + self.message = Some(message.into()); + self + } + + /// Set the failure reason. + /// + /// use kube_core::Rule; + /// ```rust + /// use kube_core::{Rule, Reason}; + /// + /// let r = Rule::new("self == oldSelf").reason(Reason::default()); + /// assert_eq!(r.rule, "self == oldSelf".to_string()); + /// assert_eq!(r.reason, Some(Reason::FieldValueInvalid)); + /// ``` + pub fn reason(mut self, reason: impl Into) -> Self { + self.reason = Some(reason.into()); + self + } + + /// Set the failure field_path. + /// + /// use kube_core::Rule; + /// ```rust + /// use kube_core::Rule; + /// + /// let r = Rule::new("self == oldSelf").field_path("obj.field"); + /// assert_eq!(r.rule, "self == oldSelf".to_string()); + /// assert_eq!(r.field_path, Some("obj.field".to_string())); + /// ``` + pub fn field_path(mut self, field_path: impl Into) -> Self { + self.field_path = Some(field_path.into()); + self + } +} + impl From<&str> for Rule { fn from(value: &str) -> Self { Self { @@ -44,7 +107,7 @@ impl From<(&str, &str)> for Rule { } } /// Message represents CEL validation message for the provided type -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(rename_all = "lowercase")] pub enum Message { /// Message represents the message displayed when validation fails. The message is required if the Rule contains @@ -73,10 +136,11 @@ impl From<&str> for Message { /// 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) -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq)] pub enum Reason { /// FieldValueInvalid is used to report malformed values (e.g. failed regex /// match, too long, out of bounds). + #[default] FieldValueInvalid, /// FieldValueForbidden is used to report valid (as per formatting rules) /// values which would be accepted under some conditions, but which are not From cc9dc90d3ef04a5964a680c2a9ab281980a78e86 Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Sat, 30 Nov 2024 14:35:09 +0100 Subject: [PATCH 04/15] Fmt fixes Signed-off-by: Danil-Grigorev --- kube-core/src/cel.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/kube-core/src/cel.rs b/kube-core/src/cel.rs index f674497fb..4105b517c 100644 --- a/kube-core/src/cel.rs +++ b/kube-core/src/cel.rs @@ -2,8 +2,7 @@ use std::str::FromStr; -#[cfg(feature = "schema")] -use schemars::schema::Schema; +#[cfg(feature = "schema")] use schemars::schema::Schema; use serde::{Deserialize, Serialize}; /// Rule is a CEL validation rule for the CRD field From 68276975e818c3f10dfe22bef3150331399c0a14 Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Sun, 1 Dec 2024 16:36:49 +0100 Subject: [PATCH 05/15] Implement as a JsonSchema generator via derive(ValidateSchema) Signed-off-by: Danil-Grigorev --- examples/crd_derive_schema.rs | 90 ++++++++++----- kube-core/src/cel.rs | 95 ++++++++++++++- kube-core/src/lib.rs | 3 +- kube-derive/src/custom_resource.rs | 179 +++++++++++++---------------- kube-derive/src/lib.rs | 56 ++------- kube/src/lib.rs | 2 +- 6 files changed, 241 insertions(+), 184 deletions(-) diff --git a/examples/crd_derive_schema.rs b/examples/crd_derive_schema.rs index e83e12134..06b237160 100644 --- a/examples/crd_derive_schema.rs +++ b/examples/crd_derive_schema.rs @@ -6,11 +6,9 @@ use kube::{ Api, ApiResource, DeleteParams, DynamicObject, GroupVersionKind, Patch, PatchParams, PostParams, WatchEvent, WatchParams, }, - cel_validate, runtime::wait::{await_condition, conditions}, - CELValidate, Client, CustomResource, CustomResourceExt, + Client, CustomResource, CustomResourceExt, ValidateSchema, }; -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; // This example shows how the generated schema affects defaulting and validation. @@ -20,9 +18,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 -#[derive( - CustomResource, CELValidate, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone, JsonSchema, -)] +#[derive(CustomResource, ValidateSchema, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone)] #[kube( group = "clux.dev", version = "v1", @@ -31,6 +27,8 @@ use serde::{Deserialize, Serialize}; derive = "PartialEq", derive = "Default" )] +#[serde(rename_all = "camelCase")] +#[cel_validate(rule = Rule::new("self.nonNullable == oldSelf.nonNullable"))] pub struct FooSpec { // Non-nullable without default is required. // @@ -92,18 +90,16 @@ pub struct FooSpec { // Field with CEL validation #[serde(default = "default_legal")] #[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()} + rule = Rule::new("self != 'illegal'").message(Message::Expression("'string cannot be illegal'".into())).reason(Reason::FieldValueForbidden), + rule = Rule::new("self != 'not legal'").reason(Reason::FieldValueInvalid), )] - #[schemars(schema_with = "cel_validate")] cel_validated: Option, + #[cel_validate(rule = Rule::new("self == oldSelf").message("is immutable"))] foo_sub_spec: Option, } -#[cel_validate] -#[derive(Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone, JsonSchema)] +#[derive(ValidateSchema, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone)] pub struct FooSubSpec { #[cel_validate(rule = "self != 'not legal'".into())] field: String, @@ -192,23 +188,23 @@ async fn main() -> Result<()> { // Test defaulting of `non_nullable_with_default` field let data = DynamicObject::new("baz", &api_resource).data(serde_json::json!({ "spec": { - "non_nullable": "a required field", + "nonNullable": "a required field", // `non_nullable_with_default` field is missing // listable values to patch later to verify merge strategies - "default_listable": vec![2], - "set_listable": vec![2], + "defaultListable": vec![2], + "setListable": vec![2], } })); let val = dynapi.create(&PostParams::default(), &data).await?.data; println!("{:?}", val["spec"]); // Defaulting happened for non-nullable field - assert_eq!(val["spec"]["non_nullable_with_default"], default_value()); + assert_eq!(val["spec"]["nonNullableWithDefault"], default_value()); // Listables - assert_eq!(serde_json::to_string(&val["spec"]["default_listable"])?, "[2]"); - assert_eq!(serde_json::to_string(&val["spec"]["set_listable"])?, "[2]"); - assert_eq!(serde_json::to_string(&val["spec"]["cel_validated"])?, "\"legal\""); + assert_eq!(serde_json::to_string(&val["spec"]["defaultListable"])?, "[2]"); + assert_eq!(serde_json::to_string(&val["spec"]["setListable"])?, "[2]"); + assert_eq!(serde_json::to_string(&val["spec"]["celValidated"])?, "\"legal\""); // Missing required field (non-nullable without default) is an error let data = DynamicObject::new("qux", &api_resource).data(serde_json::json!({ @@ -222,7 +218,7 @@ async fn main() -> Result<()> { assert_eq!(err.reason, "Invalid"); assert_eq!(err.status, "Failure"); assert!(err.message.contains("clux.dev \"qux\" is invalid")); - assert!(err.message.contains("spec.non_nullable: Required value")); + assert!(err.message.contains("spec.nonNullable: Required value")); } _ => panic!(), } @@ -233,8 +229,8 @@ async fn main() -> Result<()> { "apiVersion": "clux.dev/v1", "kind": "Foo", "spec": { - "default_listable": vec![3], - "set_listable": vec![3] + "defaultListable": vec![3], + "setListable": vec![3] } }); let pres = foos.patch("baz", &ssapply, &Patch::Apply(patch)).await?; @@ -247,7 +243,7 @@ async fn main() -> Result<()> { "apiVersion": "clux.dev/v1", "kind": "Foo", "spec": { - "cel_validated": Some("illegal") + "celValidated": Some("illegal") } }); let cel_res = foos.patch("baz", &ssapply, &Patch::Apply(cel_patch)).await; @@ -258,7 +254,7 @@ async fn main() -> Result<()> { 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: Forbidden")); + assert!(err.message.contains("spec.celValidated: Forbidden")); assert!(err.message.contains("string cannot be illegal")); } _ => panic!(), @@ -269,7 +265,7 @@ async fn main() -> Result<()> { "apiVersion": "clux.dev/v1", "kind": "Foo", "spec": { - "cel_validated": Some("not legal") + "celValidated": Some("not legal") } }); let cel_res = foos.patch("baz", &ssapply, &Patch::Apply(cel_patch)).await; @@ -280,7 +276,7 @@ async fn main() -> Result<()> { 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("spec.celValidated: Invalid value")); assert!(err.message.contains("failed rule: self != 'not legal'")); } _ => panic!(), @@ -290,7 +286,7 @@ async fn main() -> Result<()> { "apiVersion": "clux.dev/v1", "kind": "Foo", "spec": { - "foo_sub_spec": { + "fooSubSpec": { "field": Some("not legal"), } } @@ -303,18 +299,54 @@ async fn main() -> Result<()> { 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("spec.fooSubSpec.field: Invalid value")); assert!(err.message.contains("failed rule: self != 'not legal'")); } _ => panic!(), } + let cel_patch = serde_json::json!({ + "apiVersion": "clux.dev/v1", + "kind": "Foo", + "spec": { + "fooSubSpec": { + "field": Some("legal"), + } + } + }); + let cel_res = foos.patch("baz", &ssapply, &Patch::Apply(cel_patch)).await; + assert!(cel_res.is_ok()); + + let cel_patch = serde_json::json!({ + "apiVersion": "clux.dev/v1", + "kind": "Foo", + "spec": { + "fooSubSpec": { + "field": Some("legal"), + "other": "different", + } + } + }); + 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.fooSubSpec: Invalid value")); + assert!(err.message.contains("Invalid value: \"object\": is immutable")); + } + _ => panic!(), + } + // cel validation happy: let cel_patch_ok = serde_json::json!({ "apiVersion": "clux.dev/v1", "kind": "Foo", "spec": { - "cel_validated": Some("legal") + "celValidated": Some("legal") } }); foos.patch("baz", &ssapply, &Patch::Apply(cel_patch_ok)).await?; diff --git a/kube-core/src/cel.rs b/kube-core/src/cel.rs index 4105b517c..5816e2004 100644 --- a/kube-core/src/cel.rs +++ b/kube-core/src/cel.rs @@ -19,6 +19,7 @@ pub struct Rule { pub message: Option, /// 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 + #[serde(skip_serializing_if = "Option::is_none")] pub field_path: Option, /// reason is a machine-readable value providing more detail about why a field failed the validation. #[serde(skip_serializing_if = "Option::is_none")] @@ -162,7 +163,7 @@ impl FromStr for Reason { } /// Validate takes schema and applies a set of validation rules to it. The rules are stored -/// under the "x-kubernetes-validations". +/// on the top level under the "x-kubernetes-validations". /// /// ```rust /// use schemars::schema::Schema; @@ -175,7 +176,7 @@ impl FromStr for Reason { /// field_path: Some("spec.host".into()), /// ..Default::default() /// }]; -/// let schema = validate(&mut schema, rules)?; +/// 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"}]}"#, @@ -184,16 +185,98 @@ impl FromStr for Reason { ///``` #[cfg(feature = "schema")] #[cfg_attr(docsrs, doc(cfg(feature = "schema")))] -pub fn validate(s: &mut Schema, rules: Vec) -> Result { - let rules = serde_json::to_value(rules)?; +pub fn validate(s: &mut Schema, rules: Vec) -> Result<(), serde_json::Error> { match s { Schema::Bool(_) => (), Schema::Object(schema_object) => { schema_object .extensions - .insert("x-kubernetes-validations".into(), rules); + .insert("x-kubernetes-validations".into(), serde_json::to_value(rules)?); + } + }; + Ok(()) +} + +/// Validate property mutates property under property_index of the schema +/// with the provided set of validation rules. +/// +/// ```rust +/// use schemars::JsonSchema; +/// use kube::core::{Rule, validate_property}; +/// +/// #[derive(JsonSchema)] +/// struct MyStruct { +/// field: Option, +/// } +/// +/// let gen = &mut schemars::gen::SchemaSettings::openapi3().into_generator(); +/// let mut schema = MyStruct::json_schema(gen); +/// let rules = vec![Rule::new("self != oldSelf")]; +/// validate_property(&mut schema, 0, rules)?; +/// assert_eq!( +/// serde_json::to_string(&schema).unwrap(), +/// r#"{"type":"object","properties":{"field":{"type":"string","nullable":true,"x-kubernetes-validations":[{"rule":"self != oldSelf"}]}}}"# +/// ); +/// # Ok::<(), serde_json::Error>(()) +///``` +#[cfg(feature = "schema")] +#[cfg_attr(docsrs, doc(cfg(feature = "schema")))] +pub fn validate_property( + s: &mut Schema, + property_index: usize, + rules: Vec, +) -> Result<(), serde_json::Error> { + match s { + Schema::Bool(_) => (), + Schema::Object(schema_object) => { + let obj = schema_object.object(); + for (n, (_, schema)) in obj.properties.iter_mut().enumerate() { + if n == property_index { + return validate(schema, rules); + } + } } }; - Ok(s.clone()) + Ok(()) +} + +/// Merge schema properties in order to pass overrides or extension properties from the other schema. +/// +/// ```rust +/// use schemars::JsonSchema; +/// use kube::core::{Rule, merge_properties}; +/// +/// #[derive(JsonSchema)] +/// struct MyStruct { +/// a: Option, +/// } +/// +/// #[derive(JsonSchema)] +/// struct MySecondStruct { +/// a: bool, +/// b: Option, +/// } +/// let gen = &mut schemars::gen::SchemaSettings::openapi3().into_generator(); +/// let mut first = MyStruct::json_schema(gen); +/// let mut second = MySecondStruct::json_schema(gen); +/// merge_properties(&mut first, &mut second); +/// +/// assert_eq!( +/// serde_json::to_string(&first).unwrap(), +/// r#"{"type":"object","properties":{"a":{"type":"boolean"},"b":{"type":"boolean","nullable":true}}}"# +/// ); +/// # Ok::<(), serde_json::Error>(()) +#[cfg(feature = "schema")] +#[cfg_attr(docsrs, doc(cfg(feature = "schema")))] +pub fn merge_properties(s: &mut Schema, merge: &mut Schema) { + match s { + schemars::schema::Schema::Bool(_) => (), + schemars::schema::Schema::Object(schema_object) => { + let obj = schema_object.object(); + for (k, v) in &merge.clone().into_object().object().properties { + obj.properties.insert(k.clone(), v.clone()); + } + } + } } diff --git a/kube-core/src/lib.rs b/kube-core/src/lib.rs index 9f366d4d1..6ba9f81b6 100644 --- a/kube-core/src/lib.rs +++ b/kube-core/src/lib.rs @@ -28,7 +28,8 @@ pub use crd::CustomResourceExt; pub mod cel; pub use cel::{Message, Reason, Rule}; -#[cfg(feature = "schema")] pub use cel::validate; +#[cfg(feature = "schema")] +pub use cel::{merge_properties, validate, validate_property}; 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 80462a6a5..474368d3f 100644 --- a/kube-derive/src/custom_resource.rs +++ b/kube-derive/src/custom_resource.rs @@ -3,13 +3,13 @@ use darling::{ ast::{self, NestedMeta}, util::{self, path_to_string, IdentString}, - FromDeriveInput, FromField, FromMeta, + FromAttributes, FromDeriveInput, FromField, FromMeta, }; use proc_macro2::{Ident, Literal, Span, TokenStream}; use quote::{ToTokens, TokenStreamExt as _}; use syn::{ - parse::Parser as _, parse_quote, spanned::Spanned, Attribute, Data, DeriveInput, Expr, Path, Type, - Visibility, + parse::Parser as _, parse_quote, spanned::Spanned, Attribute, Data, DataStruct, DeriveInput, Expr, + FieldsNamed, Path, Type, Visibility, }; /// Values we can parse from #[kube(attrs)] @@ -107,8 +107,6 @@ 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")] @@ -136,10 +134,6 @@ 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 } } @@ -646,132 +640,122 @@ fn generate_hasspec(spec_ident: &Ident, root_ident: &Ident, kube_core: &Path) -> #[derive(FromField)] #[darling(attributes(cel_validate))] struct Rule { - ident: Option, - ty: Type, - method: Option, #[darling(multiple, rename = "rule")] rules: Vec, } #[derive(FromDeriveInput)] #[darling(attributes(cel_validate), supports(struct_named))] -struct CELValidation { +struct ValidateSchema { #[darling(default)] crates: Crates, - data: ast::Data, + ident: Ident, + #[darling(multiple, rename = "rule")] + rules: Vec, } -pub(crate) fn derive_cel_validate(input: TokenStream) -> TokenStream { - let ast: DeriveInput = match syn::parse2(input) { +pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { + let mut 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) { + let ValidateSchema { + crates: + Crates { + kube_core, + schemars, + serde, + .. + }, + ident, + rules, + } = match ValidateSchema::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 = 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() - } - }); - } + // Collect global structure validation rules + let struct_name = ident.to_string(); + let struct_rules: Vec = rules.iter().map(|r| quote! {#r,}).collect(); - 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, - }; + // Remove all non-serde, non-schemars attributes + // Has to happen on the original definition at all times, as we don't have #[derive] stanzes. + ast.attrs = ast + .attrs + .iter() + .filter(|attr| attr.path().is_ident("serde") || attr.path().is_ident("schemars")) + .cloned() + .collect(); let struct_data = match ast.data { syn::Data::Struct(ref mut struct_data) => struct_data, _ => return quote! {}, }; + // Preserve all serde attributes, to allow #[serde(rename_all = "camelCase")] or similar + let struct_attrs: Vec = ast.attrs.iter().map(|attr| quote! {#attr}).collect(); + let mut property_modifications = vec![]; 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, + let Rule { rules, .. } = match Rule::from_field(field) { Ok(rule) => rule, Err(err) => return err.write_errors(), }; - // Remove original attributes + // Remove all non-serde, non-schemars attributes + // Has to happen on the original definition at all times, as we don't have #[derive] stanzes. field.attrs = field .attrs .iter() - .filter(|attr| !attr.path().is_ident("cel_validate")) + .filter(|attr| attr.path().is_ident("serde") || attr.path().is_ident("schemars")) .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,}); + if rules.is_empty() { + continue; + } - // Prepare updated definition with shcemars injection - let new_serde_attr = quote! { - #[cel_validate(#method #(#rules)*)] - #[schemars(schema_with = #validator)] - }; + let rules: Vec = rules.iter().map(|r| quote! {#r,}).collect(); - // 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(), - }; + // We need to prepend derive macros, as they were consumed by this macro processing, being a derive by itself. + property_modifications.push(quote! { + { + #[derive(#serde::Serialize, #schemars::JsonSchema)] + #(#struct_attrs)* + struct Validated { + #field + } + + let merge = &mut Validated::json_schema(gen); + #kube_core::validate_property(merge, 0, [#(#rules)*].to_vec()).unwrap(); + #kube_core::merge_properties(s, merge); + } + }); } } quote! { - #[derive(#kube::CELValidate)] - #ast + impl #schemars::JsonSchema for #ident { + fn is_referenceable() -> bool { + false + } + + fn schema_name() -> String { + #struct_name.to_string() + "_kube_validation".into() + } + + fn json_schema(gen: &mut #schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + #[derive(#serde::Serialize, #schemars::JsonSchema)] + #ast + + use #kube_core::{Rule, Message, Reason}; + let s = &mut #ident::json_schema(gen); + #kube_core::validate(s, [#(#struct_rules)*].to_vec()).unwrap(); + #(#property_modifications)* + s.clone() + } + } } } @@ -904,19 +888,14 @@ mod tests { #[test] fn test_derive_validated() { let input = quote! { - #[derive(CustomResource, Serialize, Deserialize, Debug, PartialEq, Clone, JsonSchema, CELValidated)] + #[derive(CustomResource, ValidateSchema, Serialize, Deserialize, Debug, PartialEq, Clone)] #[kube(group = "clux.dev", version = "v1", kind = "Foo", namespaced)] struct FooSpec { - #[cel_validate(rule = "self != ''".into())] + #[cel_validate("self != ''".into())] 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); + ValidateSchema::from_derive_input(&input).unwrap(); } } diff --git a/kube-derive/src/lib.rs b/kube-derive/src/lib.rs index 97cf9604c..106c5d8a9 100644 --- a/kube-derive/src/lib.rs +++ b/kube-derive/src/lib.rs @@ -327,57 +327,21 @@ pub fn derive_custom_resource(input: proc_macro::TokenStream) -> proc_macro::Tok 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. -/// -/// # Example -/// -/// ```rust -/// use kube::CELValidate; -/// use kube::CustomResource; -/// use serde::Deserialize; -/// use serde::Serialize; -/// use schemars::JsonSchema; -/// use kube::core::crd::CustomResourceExt; -/// -/// #[derive(CustomResource, CELValidate, 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()})] -/// #[schemars(schema_with = "field")] -/// 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_derive(CELValidate, attributes(cel_validate))] -pub fn derive_validated(input: proc_macro::TokenStream) -> proc_macro::TokenStream { - custom_resource::derive_cel_validate(input.into()).into() -} - -/// Injects schemars overrides and the derive keyword for provided CEL rules via the [`CELValidate`]. +/// Generates a JsonSchema implementation a set of CEL validation rules applied on the CRD. /// /// ```rust -/// use kube::cel_validate; +/// use kube::ValidateSchema; /// 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)] +/// #[derive(CustomResource, ValidateSchema, Serialize, Deserialize, Clone, Debug)] /// #[kube(group = "kube.rs", version = "v1", kind = "Struct")] +/// #[cel_validate(rule = Rule::new("self == oldSelf"))] /// struct MyStruct { /// #[serde(default = "default")] -/// #[cel_validate(rule = Rule{rule: "self != ''".into(), message: Some("failure message".into()), ..Default::default()})] +/// #[cel_validate(rule = Rule::new("self != ''").message("failure message"))] /// field: String, /// } /// @@ -386,16 +350,14 @@ pub fn derive_validated(input: proc_macro::TokenStream) -> proc_macro::TokenStre /// } /// /// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains("x-kubernetes-validations")); +/// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains(r#""rule":"self == oldSelf""#)); /// 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() +#[proc_macro_derive(ValidateSchema, attributes(cel_validate, schemars))] +pub fn derive_schema_validation(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + custom_resource::derive_validated_schema(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 4360be6d5..bec8ba4d0 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::{cel_validate, CELValidate}; +pub use kube_derive::ValidateSchema; #[cfg(feature = "runtime")] #[cfg_attr(docsrs, doc(cfg(feature = "runtime")))] From 134d7caebadc698d5ae9e08669a8a7adc1a62a0d Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Mon, 2 Dec 2024 10:44:40 +0100 Subject: [PATCH 06/15] Allow to pass rules to the CRD struct Signed-off-by: Danil-Grigorev --- examples/crd_derive_schema.rs | 8 ++++- kube-derive/src/custom_resource.rs | 58 +++++++++++++++++++++--------- kube-derive/src/lib.rs | 8 ++++- 3 files changed, 55 insertions(+), 19 deletions(-) diff --git a/examples/crd_derive_schema.rs b/examples/crd_derive_schema.rs index 06b237160..a25037336 100644 --- a/examples/crd_derive_schema.rs +++ b/examples/crd_derive_schema.rs @@ -25,7 +25,8 @@ use serde::{Deserialize, Serialize}; kind = "Foo", namespaced, derive = "PartialEq", - derive = "Default" + derive = "Default", + rule = Rule::new("self.metadata.name != 'forbidden'"), )] #[serde(rename_all = "camelCase")] #[cel_validate(rule = Rule::new("self.nonNullable == oldSelf.nonNullable"))] @@ -223,6 +224,11 @@ async fn main() -> Result<()> { _ => panic!(), } + // Resource level metadata validations check + let forbidden = Foo::new("forbidden", FooSpec { ..FooSpec::default() }); + let res = foos.create(&PostParams::default(), &forbidden).await; + assert!(res.is_err()); + // Test the manually specified merge strategy let ssapply = PatchParams::apply("crd_derive_schema_example").force(); let patch = serde_json::json!({ diff --git a/kube-derive/src/custom_resource.rs b/kube-derive/src/custom_resource.rs index 474368d3f..d3e9288c7 100644 --- a/kube-derive/src/custom_resource.rs +++ b/kube-derive/src/custom_resource.rs @@ -1,16 +1,9 @@ // Generated by darling macros, out of our control #![allow(clippy::manual_unwrap_or_default)] -use darling::{ - ast::{self, NestedMeta}, - util::{self, path_to_string, IdentString}, - FromAttributes, FromDeriveInput, FromField, FromMeta, -}; +use darling::{FromDeriveInput, FromField, FromMeta}; use proc_macro2::{Ident, Literal, Span, TokenStream}; use quote::{ToTokens, TokenStreamExt as _}; -use syn::{ - parse::Parser as _, parse_quote, spanned::Spanned, Attribute, Data, DataStruct, DeriveInput, Expr, - FieldsNamed, Path, Type, Visibility, -}; +use syn::{parse_quote, Data, DeriveInput, Expr, Path, Visibility}; /// Values we can parse from #[kube(attrs)] #[derive(Debug, FromDeriveInput)] @@ -47,6 +40,8 @@ struct KubeAttrs { annotations: Vec, #[darling(multiple, rename = "label")] labels: Vec, + #[darling(multiple, rename = "rule")] + rules: Vec, /// Sets the `storage` property to `true` or `false`. /// @@ -107,6 +102,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")] @@ -134,6 +131,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 } } @@ -160,6 +161,7 @@ enum SchemaMode { Disabled, Manual, Derived, + Validated, } impl SchemaMode { @@ -168,6 +170,16 @@ impl SchemaMode { SchemaMode::Disabled => false, SchemaMode::Manual => false, SchemaMode::Derived => true, + SchemaMode::Validated => true, + } + } + + fn validated(self) -> bool { + match self { + SchemaMode::Disabled => false, + SchemaMode::Manual => false, + SchemaMode::Derived => false, + SchemaMode::Validated => true, } } @@ -176,6 +188,7 @@ impl SchemaMode { SchemaMode::Disabled => false, SchemaMode::Manual => true, SchemaMode::Derived => true, + SchemaMode::Validated => true, } } } @@ -230,17 +243,18 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea printcolums, selectable, scale, + rules, storage, served, crates: Crates { kube_core, + kube, k8s_openapi, schemars, serde, serde_json, std, - .. }, annotations, labels, @@ -306,19 +320,24 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea } // Enable schema generation by default as in v1 it is mandatory. - let schema_mode = schema_mode.unwrap_or(SchemaMode::Derived); + let schema_mode = schema_mode.unwrap_or(match rules.is_empty() { + true => SchemaMode::Derived, + false => SchemaMode::Validated, + }); // We exclude fields `apiVersion`, `kind`, and `metadata` from our schema because // these are validated by the API server implicitly. Also, we can't generate the // schema for `metadata` (`ObjectMeta`) because it doesn't implement `JsonSchema`. - let schemars_skip = if schema_mode.derive() { - quote! { #[schemars(skip)] } - } else { - quote! {} - }; - if schema_mode.derive() { + let schemars_skip = schema_mode.derive().then_some(quote! { #[schemars(skip)] }); + if schema_mode.validated() { + derive_paths.push(syn::parse_quote! { #kube::ValidateSchema }); + } else if schema_mode.derive() { derive_paths.push(syn::parse_quote! { #schemars::JsonSchema }); } + let struct_rules: Option> = + (!rules.is_empty()).then(|| rules.iter().map(|r| quote! {rule = #r,}).collect()); + let struct_rules = struct_rules.map(|r| quote! { #[cel_validate(#(#r)*)]}); + let meta_annotations = if !annotations.is_empty() { quote! { Some(std::collections::BTreeMap::from([#((#annotations.0.to_string(), #annotations.1.to_string()),)*])) } } else { @@ -341,6 +360,7 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea #[derive(#(#derive_paths),*)] #[serde(rename_all = "camelCase")] #[serde(crate = #quoted_serde)] + #struct_rules #visibility struct #rootident { #schemars_skip #visibility metadata: #k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta, @@ -723,6 +743,8 @@ pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { { #[derive(#serde::Serialize, #schemars::JsonSchema)] #(#struct_attrs)* + #[automatically_derived] + #[allow(missing_docs)] struct Validated { #field } @@ -747,6 +769,8 @@ pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { fn json_schema(gen: &mut #schemars::gen::SchemaGenerator) -> schemars::schema::Schema { #[derive(#serde::Serialize, #schemars::JsonSchema)] + #[automatically_derived] + #[allow(missing_docs)] #ast use #kube_core::{Rule, Message, Reason}; diff --git a/kube-derive/src/lib.rs b/kube-derive/src/lib.rs index 106c5d8a9..5a603b368 100644 --- a/kube-derive/src/lib.rs +++ b/kube-derive/src/lib.rs @@ -337,7 +337,12 @@ pub fn derive_custom_resource(input: proc_macro::TokenStream) -> proc_macro::Tok /// use kube::core::crd::CustomResourceExt; /// /// #[derive(CustomResource, ValidateSchema, Serialize, Deserialize, Clone, Debug)] -/// #[kube(group = "kube.rs", version = "v1", kind = "Struct")] +/// #[kube( +/// group = "kube.rs", +/// version = "v1", +/// kind = "Struct", +/// rule = Rule::new("self.matadata.name == 'singleton'"), +/// )] /// #[cel_validate(rule = Rule::new("self == oldSelf"))] /// struct MyStruct { /// #[serde(default = "default")] @@ -354,6 +359,7 @@ pub fn derive_custom_resource(input: proc_macro::TokenStream) -> proc_macro::Tok /// 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""#)); +/// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains(r#""rule":"self.matadata.name == 'singleton'""#)); /// ``` #[proc_macro_derive(ValidateSchema, attributes(cel_validate, schemars))] pub fn derive_schema_validation(input: proc_macro::TokenStream) -> proc_macro::TokenStream { From 5e007b08c49c52c0731b055e2241d66a94371189 Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Mon, 2 Dec 2024 15:01:49 +0100 Subject: [PATCH 07/15] Add derive tests and doc support Signed-off-by: Danil-Grigorev --- Cargo.toml | 1 + kube-derive/Cargo.toml | 1 + kube-derive/src/custom_resource.rs | 71 +++++++++++++++++++++++++--- kube-derive/tests/crd_schema_test.rs | 19 ++++++-- 4 files changed, 83 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index da72bb79c..583f30d29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,3 +91,4 @@ tower-test = "0.4.0" tracing = "0.1.36" tracing-subscriber = "0.3.17" trybuild = "1.0.48" +prettyplease = "0.2.25" \ No newline at end of file diff --git a/kube-derive/Cargo.toml b/kube-derive/Cargo.toml index 0b89ea2f0..01320eca3 100644 --- a/kube-derive/Cargo.toml +++ b/kube-derive/Cargo.toml @@ -34,3 +34,4 @@ chrono.workspace = true trybuild.workspace = true assert-json-diff.workspace = true runtime-macros = { git = "https://github.com/tyrone-wu/runtime-macros.git", rev = "e31f4de52e078d41aba4792a7ea30139606c1362" } +prettyplease.workspace = true diff --git a/kube-derive/src/custom_resource.rs b/kube-derive/src/custom_resource.rs index d3e9288c7..ac4d4ab61 100644 --- a/kube-derive/src/custom_resource.rs +++ b/kube-derive/src/custom_resource.rs @@ -699,12 +699,13 @@ pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { let struct_name = ident.to_string(); let struct_rules: Vec = rules.iter().map(|r| quote! {#r,}).collect(); - // Remove all non-serde, non-schemars attributes + // Remove all unknown attributes // Has to happen on the original definition at all times, as we don't have #[derive] stanzes. + let attribute_whitelist = ["serde", "schemars", "doc"]; ast.attrs = ast .attrs .iter() - .filter(|attr| attr.path().is_ident("serde") || attr.path().is_ident("schemars")) + .filter(|attr| attribute_whitelist.iter().any(|i| attr.path().is_ident(i))) .cloned() .collect(); @@ -723,12 +724,12 @@ pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { Err(err) => return err.write_errors(), }; - // Remove all non-serde, non-schemars attributes + // Remove all unknown attributes // Has to happen on the original definition at all times, as we don't have #[derive] stanzes. field.attrs = field .attrs .iter() - .filter(|attr| attr.path().is_ident("serde") || attr.path().is_ident("schemars")) + .filter(|attr| attribute_whitelist.iter().any(|i| attr.path().is_ident(i))) .cloned() .collect(); @@ -878,6 +879,9 @@ fn to_plural(word: &str) -> String { mod tests { use std::{env, fs}; + use prettyplease::unparse; + use syn::parse::{Parse as _, Parser as _}; + use super::*; #[test] @@ -914,12 +918,67 @@ mod tests { let input = quote! { #[derive(CustomResource, ValidateSchema, Serialize, Deserialize, Debug, PartialEq, Clone)] #[kube(group = "clux.dev", version = "v1", kind = "Foo", namespaced)] + #[cel_validate(rule = "self != ''".into())] struct FooSpec { - #[cel_validate("self != ''".into())] + #[cel_validate(rule = "self != ''".into())] foo: String } }; let input = syn::parse2(input).unwrap(); - ValidateSchema::from_derive_input(&input).unwrap(); + let v = ValidateSchema::from_derive_input(&input).unwrap(); + assert_eq!(v.rules.len(), 1); + } + + #[test] + fn test_derive_validated_full() { + let input = quote! { + #[derive(ValidateSchema)] + #[cel_validate(rule = "true".into())] + struct FooSpec { + #[cel_validate(rule = "true".into())] + foo: String + } + }; + + let expected = quote!{ + impl ::schemars::JsonSchema for FooSpec { + fn is_referenceable() -> bool { + false + } + fn schema_name() -> String { + "FooSpec".to_string() + "_kube_validation".into() + } + fn json_schema( + gen: &mut ::schemars::gen::SchemaGenerator, + ) -> schemars::schema::Schema { + #[derive(::serde::Serialize, ::schemars::JsonSchema)] + #[automatically_derived] + #[allow(missing_docs)] + struct FooSpec { + foo: String, + } + use ::kube::core::{Rule, Message, Reason}; + let s = &mut FooSpec::json_schema(gen); + ::kube::core::validate(s, ["true".into()].to_vec()).unwrap(); + { + #[derive(::serde::Serialize, ::schemars::JsonSchema)] + #[automatically_derived] + #[allow(missing_docs)] + struct Validated { + foo: String, + } + let merge = &mut Validated::json_schema(gen); + ::kube::core::validate_property(merge, 0, ["true".into()].to_vec()).unwrap(); + ::kube::core::merge_properties(s, merge); + } + s.clone() + } + } + }; + + let output = derive_validated_schema(input); + let output = unparse(&syn::File::parse.parse2(output).unwrap()); + let expected = unparse(&syn::File::parse.parse2(expected).unwrap()); + assert_eq!(output, expected); } } diff --git a/kube-derive/tests/crd_schema_test.rs b/kube-derive/tests/crd_schema_test.rs index e975d8ff3..aba8e4873 100644 --- a/kube-derive/tests/crd_schema_test.rs +++ b/kube-derive/tests/crd_schema_test.rs @@ -2,13 +2,14 @@ use assert_json_diff::assert_json_eq; use chrono::{DateTime, Utc}; +use kube::ValidateSchema; use kube_derive::CustomResource; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; // See `crd_derive_schema` example for how the schema generated from this struct affects defaulting and validation. -#[derive(CustomResource, Serialize, Deserialize, Debug, PartialEq, Clone, JsonSchema)] +#[derive(CustomResource, Serialize, Deserialize, Debug, PartialEq, Clone, ValidateSchema)] #[kube( group = "clux.dev", version = "v1", @@ -26,8 +27,10 @@ use std::collections::{HashMap, HashSet}; annotation("clux.dev", "cluxingv1"), annotation("clux.dev/firewall", "enabled"), label("clux.dev", "cluxingv1"), - label("clux.dev/persistence", "disabled") + label("clux.dev/persistence", "disabled"), + rule = Rule::new("self.metadata.name == 'singleton'"), )] +#[cel_validate(rule = Rule::new("has(self.nonNullable)"))] #[serde(rename_all = "camelCase")] struct FooSpec { non_nullable: String, @@ -50,6 +53,7 @@ struct FooSpec { timestamp: DateTime, /// This is a complex enum with a description + #[cel_validate(rule = Rule::new("!has(self.variantOne) || self.variantOne.int > 22"))] complex_enum: ComplexEnum, /// This is a untagged enum with a description @@ -303,6 +307,9 @@ fn test_crd_schema_matches_expected() { "required": ["variantThree"] } ], + "x-kubernetes-validations": [{ + "rule": "!has(self.variantOne) || self.variantOne.int > 22", + }], "description": "This is a complex enum with a description" }, "untaggedEnumPerson": { @@ -347,13 +354,19 @@ fn test_crd_schema_matches_expected() { "timestamp", "untaggedEnumPerson" ], + "x-kubernetes-validations": [{ + "rule": "has(self.nonNullable)", + }], "type": "object" } }, "required": [ "spec" ], - "title": "Foo", + "x-kubernetes-validations": [{ + "rule": "self.metadata.name == 'singleton'", + }], + "title": "Foo_kube_validation", "type": "object" } }, From 0f3107f33efe76b2f260aaee6cfe7bdf3693dd26 Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Mon, 2 Dec 2024 15:05:03 +0100 Subject: [PATCH 08/15] fmt fixes Signed-off-by: Danil-Grigorev --- Cargo.toml | 2 +- kube-derive/src/custom_resource.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 583f30d29..ed419bfd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,4 +91,4 @@ tower-test = "0.4.0" tracing = "0.1.36" tracing-subscriber = "0.3.17" trybuild = "1.0.48" -prettyplease = "0.2.25" \ No newline at end of file +prettyplease = "0.2.25" diff --git a/kube-derive/src/custom_resource.rs b/kube-derive/src/custom_resource.rs index ac4d4ab61..3a05c10c8 100644 --- a/kube-derive/src/custom_resource.rs +++ b/kube-derive/src/custom_resource.rs @@ -940,7 +940,7 @@ mod tests { } }; - let expected = quote!{ + let expected = quote! { impl ::schemars::JsonSchema for FooSpec { fn is_referenceable() -> bool { false From 5ff27f1e1c82d2f429ab915ec9a4b695caa2d173 Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Wed, 18 Dec 2024 17:15:02 +0100 Subject: [PATCH 09/15] Rename to CELSchema, simplify derive addition in kube macro Signed-off-by: Danil-Grigorev --- examples/crd_derive_schema.rs | 6 ++--- kube-derive/src/custom_resource.rs | 33 ++++++++-------------------- kube-derive/src/lib.rs | 6 ++--- kube-derive/tests/crd_schema_test.rs | 4 ++-- kube/src/lib.rs | 2 +- 5 files changed, 18 insertions(+), 33 deletions(-) diff --git a/examples/crd_derive_schema.rs b/examples/crd_derive_schema.rs index a25037336..a299f48ec 100644 --- a/examples/crd_derive_schema.rs +++ b/examples/crd_derive_schema.rs @@ -7,7 +7,7 @@ use kube::{ WatchEvent, WatchParams, }, runtime::wait::{await_condition, conditions}, - Client, CustomResource, CustomResourceExt, ValidateSchema, + Client, CustomResource, CustomResourceExt, CELSchema, }; use serde::{Deserialize, Serialize}; @@ -18,7 +18,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 -#[derive(CustomResource, ValidateSchema, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone)] +#[derive(CustomResource, CELSchema, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone)] #[kube( group = "clux.dev", version = "v1", @@ -100,7 +100,7 @@ pub struct FooSpec { foo_sub_spec: Option, } -#[derive(ValidateSchema, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone)] +#[derive(CELSchema, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone)] pub struct FooSubSpec { #[cel_validate(rule = "self != 'not legal'".into())] field: String, diff --git a/kube-derive/src/custom_resource.rs b/kube-derive/src/custom_resource.rs index 3a05c10c8..c74e26c07 100644 --- a/kube-derive/src/custom_resource.rs +++ b/kube-derive/src/custom_resource.rs @@ -161,7 +161,6 @@ enum SchemaMode { Disabled, Manual, Derived, - Validated, } impl SchemaMode { @@ -170,16 +169,6 @@ impl SchemaMode { SchemaMode::Disabled => false, SchemaMode::Manual => false, SchemaMode::Derived => true, - SchemaMode::Validated => true, - } - } - - fn validated(self) -> bool { - match self { - SchemaMode::Disabled => false, - SchemaMode::Manual => false, - SchemaMode::Derived => false, - SchemaMode::Validated => true, } } @@ -188,7 +177,6 @@ impl SchemaMode { SchemaMode::Disabled => false, SchemaMode::Manual => true, SchemaMode::Derived => true, - SchemaMode::Validated => true, } } } @@ -320,16 +308,13 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea } // Enable schema generation by default as in v1 it is mandatory. - let schema_mode = schema_mode.unwrap_or(match rules.is_empty() { - true => SchemaMode::Derived, - false => SchemaMode::Validated, - }); + let schema_mode = schema_mode.unwrap_or(SchemaMode::Derived); // We exclude fields `apiVersion`, `kind`, and `metadata` from our schema because // these are validated by the API server implicitly. Also, we can't generate the // schema for `metadata` (`ObjectMeta`) because it doesn't implement `JsonSchema`. let schemars_skip = schema_mode.derive().then_some(quote! { #[schemars(skip)] }); - if schema_mode.validated() { - derive_paths.push(syn::parse_quote! { #kube::ValidateSchema }); + if schema_mode.derive() && !rules.is_empty() { + derive_paths.push(syn::parse_quote! { #kube::CELSchema }); } else if schema_mode.derive() { derive_paths.push(syn::parse_quote! { #schemars::JsonSchema }); } @@ -666,7 +651,7 @@ struct Rule { #[derive(FromDeriveInput)] #[darling(attributes(cel_validate), supports(struct_named))] -struct ValidateSchema { +struct CELSchema { #[darling(default)] crates: Crates, ident: Ident, @@ -680,7 +665,7 @@ pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { Ok(di) => di, }; - let ValidateSchema { + let CELSchema { crates: Crates { kube_core, @@ -690,7 +675,7 @@ pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { }, ident, rules, - } = match ValidateSchema::from_derive_input(&ast) { + } = match CELSchema::from_derive_input(&ast) { Err(err) => return err.write_errors(), Ok(attrs) => attrs, }; @@ -916,7 +901,7 @@ mod tests { #[test] fn test_derive_validated() { let input = quote! { - #[derive(CustomResource, ValidateSchema, Serialize, Deserialize, Debug, PartialEq, Clone)] + #[derive(CustomResource, CELSchema, Serialize, Deserialize, Debug, PartialEq, Clone)] #[kube(group = "clux.dev", version = "v1", kind = "Foo", namespaced)] #[cel_validate(rule = "self != ''".into())] struct FooSpec { @@ -925,14 +910,14 @@ mod tests { } }; let input = syn::parse2(input).unwrap(); - let v = ValidateSchema::from_derive_input(&input).unwrap(); + let v = CELSchema::from_derive_input(&input).unwrap(); assert_eq!(v.rules.len(), 1); } #[test] fn test_derive_validated_full() { let input = quote! { - #[derive(ValidateSchema)] + #[derive(CELSchema)] #[cel_validate(rule = "true".into())] struct FooSpec { #[cel_validate(rule = "true".into())] diff --git a/kube-derive/src/lib.rs b/kube-derive/src/lib.rs index 5a603b368..4022a96af 100644 --- a/kube-derive/src/lib.rs +++ b/kube-derive/src/lib.rs @@ -330,13 +330,13 @@ pub fn derive_custom_resource(input: proc_macro::TokenStream) -> proc_macro::Tok /// Generates a JsonSchema implementation a set of CEL validation rules applied on the CRD. /// /// ```rust -/// use kube::ValidateSchema; +/// use kube::CELSchema; /// use kube::CustomResource; /// use serde::Deserialize; /// use serde::Serialize; /// use kube::core::crd::CustomResourceExt; /// -/// #[derive(CustomResource, ValidateSchema, Serialize, Deserialize, Clone, Debug)] +/// #[derive(CustomResource, CELSchema, Serialize, Deserialize, Clone, Debug)] /// #[kube( /// group = "kube.rs", /// version = "v1", @@ -361,7 +361,7 @@ pub fn derive_custom_resource(input: proc_macro::TokenStream) -> proc_macro::Tok /// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains(r#""default":"value""#)); /// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains(r#""rule":"self.matadata.name == 'singleton'""#)); /// ``` -#[proc_macro_derive(ValidateSchema, attributes(cel_validate, schemars))] +#[proc_macro_derive(CELSchema, attributes(cel_validate, schemars))] pub fn derive_schema_validation(input: proc_macro::TokenStream) -> proc_macro::TokenStream { custom_resource::derive_validated_schema(input.into()).into() } diff --git a/kube-derive/tests/crd_schema_test.rs b/kube-derive/tests/crd_schema_test.rs index aba8e4873..8e8c5cf07 100644 --- a/kube-derive/tests/crd_schema_test.rs +++ b/kube-derive/tests/crd_schema_test.rs @@ -2,14 +2,14 @@ use assert_json_diff::assert_json_eq; use chrono::{DateTime, Utc}; -use kube::ValidateSchema; +use kube::CELSchema; use kube_derive::CustomResource; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; // See `crd_derive_schema` example for how the schema generated from this struct affects defaulting and validation. -#[derive(CustomResource, Serialize, Deserialize, Debug, PartialEq, Clone, ValidateSchema)] +#[derive(CustomResource, Serialize, Deserialize, Debug, PartialEq, Clone, CELSchema)] #[kube( group = "clux.dev", version = "v1", diff --git a/kube/src/lib.rs b/kube/src/lib.rs index bec8ba4d0..1cb9f23c4 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::ValidateSchema; +pub use kube_derive::CELSchema; #[cfg(feature = "runtime")] #[cfg_attr(docsrs, doc(cfg(feature = "runtime")))] From 97a91312301025a32903be6417d3c3e00d678f9f Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Wed, 18 Dec 2024 17:33:42 +0100 Subject: [PATCH 10/15] Move to a separate package Signed-off-by: Danil-Grigorev --- kube-derive/src/cel_schema.rs | 238 +++++++++++++++++++++++++++++ kube-derive/src/custom_resource.rs | 201 +----------------------- kube-derive/src/lib.rs | 3 +- 3 files changed, 241 insertions(+), 201 deletions(-) create mode 100644 kube-derive/src/cel_schema.rs diff --git a/kube-derive/src/cel_schema.rs b/kube-derive/src/cel_schema.rs new file mode 100644 index 000000000..a85c9514e --- /dev/null +++ b/kube-derive/src/cel_schema.rs @@ -0,0 +1,238 @@ +use darling::{FromDeriveInput, FromField, FromMeta}; +use proc_macro2::TokenStream; +use syn::{parse_quote, DeriveInput, Expr, Ident, Path}; + +#[derive(FromField)] +#[darling(attributes(cel_validate))] +struct Rule { + #[darling(multiple, rename = "rule")] + rules: Vec, +} + +#[derive(FromDeriveInput)] +#[darling(attributes(cel_validate), supports(struct_named))] +struct CELSchema { + #[darling(default)] + crates: Crates, + ident: Ident, + #[darling(multiple, rename = "rule")] + rules: Vec, +} + +#[derive(Debug, FromMeta)] +struct Crates { + #[darling(default = "Self::default_kube_core")] + kube_core: Path, + #[darling(default = "Self::default_schemars")] + schemars: Path, + #[darling(default = "Self::default_serde")] + serde: Path, +} + +// Default is required when the subattribute isn't mentioned at all +// Delegate to darling rather than deriving, so that we can piggyback off the `#[darling(default)]` clauses +impl Default for Crates { + fn default() -> Self { + Self::from_list(&[]).unwrap() + } +} + +impl Crates { + fn default_kube_core() -> Path { + parse_quote! { ::kube::core } // by default must work well with people using facade crate + } + + fn default_schemars() -> Path { + parse_quote! { ::schemars } + } + + fn default_serde() -> Path { + parse_quote! { ::serde } + } +} + +pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { + let mut ast: DeriveInput = match syn::parse2(input) { + Err(err) => return err.to_compile_error(), + Ok(di) => di, + }; + + let CELSchema { + crates: Crates { + kube_core, + schemars, + serde, + }, + ident, + rules, + } = match CELSchema::from_derive_input(&ast) { + Err(err) => return err.write_errors(), + Ok(attrs) => attrs, + }; + + // Collect global structure validation rules + let struct_name = ident.to_string(); + let struct_rules: Vec = rules.iter().map(|r| quote! {#r,}).collect(); + + // Remove all unknown attributes + // Has to happen on the original definition at all times, as we don't have #[derive] stanzes. + let attribute_whitelist = ["serde", "schemars", "doc"]; + ast.attrs = ast + .attrs + .iter() + .filter(|attr| attribute_whitelist.iter().any(|i| attr.path().is_ident(i))) + .cloned() + .collect(); + + let struct_data = match ast.data { + syn::Data::Struct(ref mut struct_data) => struct_data, + _ => return quote! {}, + }; + + // Preserve all serde attributes, to allow #[serde(rename_all = "camelCase")] or similar + let struct_attrs: Vec = ast.attrs.iter().map(|attr| quote! {#attr}).collect(); + let mut property_modifications = vec![]; + if let syn::Fields::Named(fields) = &mut struct_data.fields { + for field in &mut fields.named { + let Rule { rules, .. } = match Rule::from_field(field) { + Ok(rule) => rule, + Err(err) => return err.write_errors(), + }; + + // Remove all unknown attributes + // Has to happen on the original definition at all times, as we don't have #[derive] stanzes. + field.attrs = field + .attrs + .iter() + .filter(|attr| attribute_whitelist.iter().any(|i| attr.path().is_ident(i))) + .cloned() + .collect(); + + if rules.is_empty() { + continue; + } + + let rules: Vec = rules.iter().map(|r| quote! {#r,}).collect(); + + // We need to prepend derive macros, as they were consumed by this macro processing, being a derive by itself. + property_modifications.push(quote! { + { + #[derive(#serde::Serialize, #schemars::JsonSchema)] + #(#struct_attrs)* + #[automatically_derived] + #[allow(missing_docs)] + struct Validated { + #field + } + + let merge = &mut Validated::json_schema(gen); + #kube_core::validate_property(merge, 0, [#(#rules)*].to_vec()).unwrap(); + #kube_core::merge_properties(s, merge); + } + }); + } + } + + quote! { + impl #schemars::JsonSchema for #ident { + fn is_referenceable() -> bool { + false + } + + fn schema_name() -> String { + #struct_name.to_string() + "_kube_validation".into() + } + + fn json_schema(gen: &mut #schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + #[derive(#serde::Serialize, #schemars::JsonSchema)] + #[automatically_derived] + #[allow(missing_docs)] + #ast + + use #kube_core::{Rule, Message, Reason}; + let s = &mut #ident::json_schema(gen); + #kube_core::validate(s, [#(#struct_rules)*].to_vec()).unwrap(); + #(#property_modifications)* + s.clone() + } + } + } +} + +#[test] +fn test_derive_validated() { + let input = quote! { + #[derive(CustomResource, CELSchema, Serialize, Deserialize, Debug, PartialEq, Clone)] + #[kube(group = "clux.dev", version = "v1", kind = "Foo", namespaced)] + #[cel_validate(rule = "self != ''".into())] + struct FooSpec { + #[cel_validate(rule = "self != ''".into())] + foo: String + } + }; + let input = syn::parse2(input).unwrap(); + let v = CELSchema::from_derive_input(&input).unwrap(); + assert_eq!(v.rules.len(), 1); +} + +#[cfg(test)] +mod tests { + use std::{env, fs}; + + use prettyplease::unparse; + use syn::parse::{Parse as _, Parser as _}; + + use super::*; + #[test] + fn test_derive_validated_full() { + let input = quote! { + #[derive(CELSchema)] + #[cel_validate(rule = "true".into())] + struct FooSpec { + #[cel_validate(rule = "true".into())] + foo: String + } + }; + + let expected = quote! { + impl ::schemars::JsonSchema for FooSpec { + fn is_referenceable() -> bool { + false + } + fn schema_name() -> String { + "FooSpec".to_string() + "_kube_validation".into() + } + fn json_schema( + gen: &mut ::schemars::gen::SchemaGenerator, + ) -> schemars::schema::Schema { + #[derive(::serde::Serialize, ::schemars::JsonSchema)] + #[automatically_derived] + #[allow(missing_docs)] + struct FooSpec { + foo: String, + } + use ::kube::core::{Rule, Message, Reason}; + let s = &mut FooSpec::json_schema(gen); + ::kube::core::validate(s, ["true".into()].to_vec()).unwrap(); + { + #[derive(::serde::Serialize, ::schemars::JsonSchema)] + #[automatically_derived] + #[allow(missing_docs)] + struct Validated { + foo: String, + } + let merge = &mut Validated::json_schema(gen); + ::kube::core::validate_property(merge, 0, ["true".into()].to_vec()).unwrap(); + ::kube::core::merge_properties(s, merge); + } + s.clone() + } + } + }; + + let output = derive_validated_schema(input); + let output = unparse(&syn::File::parse.parse2(output).unwrap()); + let expected = unparse(&syn::File::parse.parse2(expected).unwrap()); + assert_eq!(output, expected); + } +} diff --git a/kube-derive/src/custom_resource.rs b/kube-derive/src/custom_resource.rs index c74e26c07..e12d559a6 100644 --- a/kube-derive/src/custom_resource.rs +++ b/kube-derive/src/custom_resource.rs @@ -1,6 +1,6 @@ // Generated by darling macros, out of our control #![allow(clippy::manual_unwrap_or_default)] -use darling::{FromDeriveInput, FromField, FromMeta}; +use darling::{FromDeriveInput, FromMeta}; use proc_macro2::{Ident, Literal, Span, TokenStream}; use quote::{ToTokens, TokenStreamExt as _}; use syn::{parse_quote, Data, DeriveInput, Expr, Path, Visibility}; @@ -642,133 +642,6 @@ fn generate_hasspec(spec_ident: &Ident, root_ident: &Ident, kube_core: &Path) -> } } -#[derive(FromField)] -#[darling(attributes(cel_validate))] -struct Rule { - #[darling(multiple, rename = "rule")] - rules: Vec, -} - -#[derive(FromDeriveInput)] -#[darling(attributes(cel_validate), supports(struct_named))] -struct CELSchema { - #[darling(default)] - crates: Crates, - ident: Ident, - #[darling(multiple, rename = "rule")] - rules: Vec, -} - -pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { - let mut ast: DeriveInput = match syn::parse2(input) { - Err(err) => return err.to_compile_error(), - Ok(di) => di, - }; - - let CELSchema { - crates: - Crates { - kube_core, - schemars, - serde, - .. - }, - ident, - rules, - } = match CELSchema::from_derive_input(&ast) { - Err(err) => return err.write_errors(), - Ok(attrs) => attrs, - }; - - // Collect global structure validation rules - let struct_name = ident.to_string(); - let struct_rules: Vec = rules.iter().map(|r| quote! {#r,}).collect(); - - // Remove all unknown attributes - // Has to happen on the original definition at all times, as we don't have #[derive] stanzes. - let attribute_whitelist = ["serde", "schemars", "doc"]; - ast.attrs = ast - .attrs - .iter() - .filter(|attr| attribute_whitelist.iter().any(|i| attr.path().is_ident(i))) - .cloned() - .collect(); - - let struct_data = match ast.data { - syn::Data::Struct(ref mut struct_data) => struct_data, - _ => return quote! {}, - }; - - // Preserve all serde attributes, to allow #[serde(rename_all = "camelCase")] or similar - let struct_attrs: Vec = ast.attrs.iter().map(|attr| quote! {#attr}).collect(); - let mut property_modifications = vec![]; - if let syn::Fields::Named(fields) = &mut struct_data.fields { - for field in &mut fields.named { - let Rule { rules, .. } = match Rule::from_field(field) { - Ok(rule) => rule, - Err(err) => return err.write_errors(), - }; - - // Remove all unknown attributes - // Has to happen on the original definition at all times, as we don't have #[derive] stanzes. - field.attrs = field - .attrs - .iter() - .filter(|attr| attribute_whitelist.iter().any(|i| attr.path().is_ident(i))) - .cloned() - .collect(); - - if rules.is_empty() { - continue; - } - - let rules: Vec = rules.iter().map(|r| quote! {#r,}).collect(); - - // We need to prepend derive macros, as they were consumed by this macro processing, being a derive by itself. - property_modifications.push(quote! { - { - #[derive(#serde::Serialize, #schemars::JsonSchema)] - #(#struct_attrs)* - #[automatically_derived] - #[allow(missing_docs)] - struct Validated { - #field - } - - let merge = &mut Validated::json_schema(gen); - #kube_core::validate_property(merge, 0, [#(#rules)*].to_vec()).unwrap(); - #kube_core::merge_properties(s, merge); - } - }); - } - } - - quote! { - impl #schemars::JsonSchema for #ident { - fn is_referenceable() -> bool { - false - } - - fn schema_name() -> String { - #struct_name.to_string() + "_kube_validation".into() - } - - fn json_schema(gen: &mut #schemars::gen::SchemaGenerator) -> schemars::schema::Schema { - #[derive(#serde::Serialize, #schemars::JsonSchema)] - #[automatically_derived] - #[allow(missing_docs)] - #ast - - use #kube_core::{Rule, Message, Reason}; - let s = &mut #ident::json_schema(gen); - #kube_core::validate(s, [#(#struct_rules)*].to_vec()).unwrap(); - #(#property_modifications)* - s.clone() - } - } - } -} - struct StatusInformation { /// The code to be used for the field in the main struct field: TokenStream, @@ -864,9 +737,6 @@ fn to_plural(word: &str) -> String { mod tests { use std::{env, fs}; - use prettyplease::unparse; - use syn::parse::{Parse as _, Parser as _}; - use super::*; #[test] @@ -897,73 +767,4 @@ mod tests { 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, CELSchema, Serialize, Deserialize, Debug, PartialEq, Clone)] - #[kube(group = "clux.dev", version = "v1", kind = "Foo", namespaced)] - #[cel_validate(rule = "self != ''".into())] - struct FooSpec { - #[cel_validate(rule = "self != ''".into())] - foo: String - } - }; - let input = syn::parse2(input).unwrap(); - let v = CELSchema::from_derive_input(&input).unwrap(); - assert_eq!(v.rules.len(), 1); - } - - #[test] - fn test_derive_validated_full() { - let input = quote! { - #[derive(CELSchema)] - #[cel_validate(rule = "true".into())] - struct FooSpec { - #[cel_validate(rule = "true".into())] - foo: String - } - }; - - let expected = quote! { - impl ::schemars::JsonSchema for FooSpec { - fn is_referenceable() -> bool { - false - } - fn schema_name() -> String { - "FooSpec".to_string() + "_kube_validation".into() - } - fn json_schema( - gen: &mut ::schemars::gen::SchemaGenerator, - ) -> schemars::schema::Schema { - #[derive(::serde::Serialize, ::schemars::JsonSchema)] - #[automatically_derived] - #[allow(missing_docs)] - struct FooSpec { - foo: String, - } - use ::kube::core::{Rule, Message, Reason}; - let s = &mut FooSpec::json_schema(gen); - ::kube::core::validate(s, ["true".into()].to_vec()).unwrap(); - { - #[derive(::serde::Serialize, ::schemars::JsonSchema)] - #[automatically_derived] - #[allow(missing_docs)] - struct Validated { - foo: String, - } - let merge = &mut Validated::json_schema(gen); - ::kube::core::validate_property(merge, 0, ["true".into()].to_vec()).unwrap(); - ::kube::core::merge_properties(s, merge); - } - s.clone() - } - } - }; - - let output = derive_validated_schema(input); - let output = unparse(&syn::File::parse.parse2(output).unwrap()); - let expected = unparse(&syn::File::parse.parse2(expected).unwrap()); - assert_eq!(output, expected); - } } diff --git a/kube-derive/src/lib.rs b/kube-derive/src/lib.rs index 4022a96af..257b1ca2c 100644 --- a/kube-derive/src/lib.rs +++ b/kube-derive/src/lib.rs @@ -4,6 +4,7 @@ extern crate proc_macro; #[macro_use] extern crate quote; mod custom_resource; +mod cel_schema; mod resource; /// A custom derive for kubernetes custom resource definitions. @@ -363,7 +364,7 @@ pub fn derive_custom_resource(input: proc_macro::TokenStream) -> proc_macro::Tok /// ``` #[proc_macro_derive(CELSchema, attributes(cel_validate, schemars))] pub fn derive_schema_validation(input: proc_macro::TokenStream) -> proc_macro::TokenStream { - custom_resource::derive_validated_schema(input.into()).into() + cel_schema::derive_validated_schema(input.into()).into() } /// A custom derive for inheriting Resource impl for the type. From 35c39fe97d0fa1d074da8c30ec35f2f3ae833050 Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Wed, 18 Dec 2024 17:38:24 +0100 Subject: [PATCH 11/15] clippy/fmt fixes Signed-off-by: Danil-Grigorev --- examples/crd_derive_schema.rs | 2 +- kube-derive/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/crd_derive_schema.rs b/examples/crd_derive_schema.rs index a299f48ec..70513f221 100644 --- a/examples/crd_derive_schema.rs +++ b/examples/crd_derive_schema.rs @@ -7,7 +7,7 @@ use kube::{ WatchEvent, WatchParams, }, runtime::wait::{await_condition, conditions}, - Client, CustomResource, CustomResourceExt, CELSchema, + CELSchema, Client, CustomResource, CustomResourceExt, }; use serde::{Deserialize, Serialize}; diff --git a/kube-derive/src/lib.rs b/kube-derive/src/lib.rs index 257b1ca2c..4cc5dee05 100644 --- a/kube-derive/src/lib.rs +++ b/kube-derive/src/lib.rs @@ -3,8 +3,8 @@ extern crate proc_macro; #[macro_use] extern crate quote; -mod custom_resource; mod cel_schema; +mod custom_resource; mod resource; /// A custom derive for kubernetes custom resource definitions. From c0ec096e07b88fec01fee4fefe80a0296c8846f4 Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Sat, 21 Dec 2024 11:39:41 +0100 Subject: [PATCH 12/15] Add doc comments to lib.rs Signed-off-by: Danil-Grigorev --- kube-derive/src/cel_schema.rs | 5 +++-- kube-derive/src/lib.rs | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/kube-derive/src/cel_schema.rs b/kube-derive/src/cel_schema.rs index a85c9514e..4de915e7a 100644 --- a/kube-derive/src/cel_schema.rs +++ b/kube-derive/src/cel_schema.rs @@ -1,3 +1,4 @@ + use darling::{FromDeriveInput, FromField, FromMeta}; use proc_macro2::TokenStream; use syn::{parse_quote, DeriveInput, Expr, Ident, Path}; @@ -74,7 +75,7 @@ pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { let struct_name = ident.to_string(); let struct_rules: Vec = rules.iter().map(|r| quote! {#r,}).collect(); - // Remove all unknown attributes + // Remove all unknown attributes from the original structure copy // Has to happen on the original definition at all times, as we don't have #[derive] stanzes. let attribute_whitelist = ["serde", "schemars", "doc"]; ast.attrs = ast @@ -99,7 +100,7 @@ pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { Err(err) => return err.write_errors(), }; - // Remove all unknown attributes + // Remove all unknown attributes from each field // Has to happen on the original definition at all times, as we don't have #[derive] stanzes. field.attrs = field .attrs diff --git a/kube-derive/src/lib.rs b/kube-derive/src/lib.rs index 4cc5dee05..a98b086ee 100644 --- a/kube-derive/src/lib.rs +++ b/kube-derive/src/lib.rs @@ -161,6 +161,9 @@ mod resource; /// ## `#[kube(served = true)]` /// Sets the `served` property to `true` or `false`. /// +/// ## `#[kube(rule = Rule::new("self == oldSelf").message("field is immutable"))]` +/// Sets the CEL validation rules on the generated top level structure. +/// /// ## Example with all properties /// /// ```rust From 23df77bf1ab38d496bf2865be4ebc30dcb6bc363 Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Sat, 21 Dec 2024 11:51:08 +0100 Subject: [PATCH 13/15] Make attribute removal another fn Signed-off-by: Danil-Grigorev --- kube-derive/src/cel_schema.rs | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/kube-derive/src/cel_schema.rs b/kube-derive/src/cel_schema.rs index 4de915e7a..4a1baf70b 100644 --- a/kube-derive/src/cel_schema.rs +++ b/kube-derive/src/cel_schema.rs @@ -1,7 +1,6 @@ - use darling::{FromDeriveInput, FromField, FromMeta}; use proc_macro2::TokenStream; -use syn::{parse_quote, DeriveInput, Expr, Ident, Path}; +use syn::{parse_quote, Attribute, DeriveInput, Expr, Ident, Path}; #[derive(FromField)] #[darling(attributes(cel_validate))] @@ -78,12 +77,7 @@ pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { // Remove all unknown attributes from the original structure copy // Has to happen on the original definition at all times, as we don't have #[derive] stanzes. let attribute_whitelist = ["serde", "schemars", "doc"]; - ast.attrs = ast - .attrs - .iter() - .filter(|attr| attribute_whitelist.iter().any(|i| attr.path().is_ident(i))) - .cloned() - .collect(); + ast.attrs = remove_attributes(&ast.attrs, attribute_whitelist.to_vec()); let struct_data = match ast.data { syn::Data::Struct(ref mut struct_data) => struct_data, @@ -102,12 +96,7 @@ pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { // Remove all unknown attributes from each field // Has to happen on the original definition at all times, as we don't have #[derive] stanzes. - field.attrs = field - .attrs - .iter() - .filter(|attr| attribute_whitelist.iter().any(|i| attr.path().is_ident(i))) - .cloned() - .collect(); + field.attrs = remove_attributes(&field.attrs, attribute_whitelist.to_vec()); if rules.is_empty() { continue; @@ -160,6 +149,15 @@ pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { } } +// Remove all unknown attributes from the list +fn remove_attributes(attrs: &Vec, witelist: Vec<&str>) -> Vec { + attrs + .iter() + .filter(|attr| witelist.iter().any(|i| attr.path().is_ident(i))) + .cloned() + .collect() +} + #[test] fn test_derive_validated() { let input = quote! { From 1b0ad5e4cda5aeb899c9153f0cce115cad007cd2 Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Sat, 21 Dec 2024 11:54:37 +0100 Subject: [PATCH 14/15] Doc comment from suggestion Signed-off-by: Danil-Grigorev --- kube-derive/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/kube-derive/src/lib.rs b/kube-derive/src/lib.rs index a98b086ee..83e008caa 100644 --- a/kube-derive/src/lib.rs +++ b/kube-derive/src/lib.rs @@ -162,7 +162,8 @@ mod resource; /// Sets the `served` property to `true` or `false`. /// /// ## `#[kube(rule = Rule::new("self == oldSelf").message("field is immutable"))]` -/// Sets the CEL validation rules on the generated top level structure. +/// Inject a top level CEL validation rule for the top level generated struct. +/// This attribute is for resources deriving [`CELSchema`] instead of [`schemars::JsonSchema`]. /// /// ## Example with all properties /// From 060ad640c341cb1500974d51914dd0dfbc1c46bc Mon Sep 17 00:00:00 2001 From: Danil-Grigorev Date: Sat, 21 Dec 2024 12:00:19 +0100 Subject: [PATCH 15/15] Clippy nightly fixes Signed-off-by: Danil-Grigorev --- kube-core/src/cel.rs | 8 ++++---- kube-derive/src/cel_schema.rs | 16 +++++++--------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/kube-core/src/cel.rs b/kube-core/src/cel.rs index 5816e2004..44318720d 100644 --- a/kube-core/src/cel.rs +++ b/kube-core/src/cel.rs @@ -170,7 +170,7 @@ impl FromStr for Reason { /// use kube::core::{Rule, Reason, Message, validate}; /// /// let mut schema = Schema::Object(Default::default()); -/// let rules = vec![Rule{ +/// let rules = &[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()), @@ -185,7 +185,7 @@ impl FromStr for Reason { ///``` #[cfg(feature = "schema")] #[cfg_attr(docsrs, doc(cfg(feature = "schema")))] -pub fn validate(s: &mut Schema, rules: Vec) -> Result<(), serde_json::Error> { +pub fn validate(s: &mut Schema, rules: &[Rule]) -> Result<(), serde_json::Error> { match s { Schema::Bool(_) => (), Schema::Object(schema_object) => { @@ -211,7 +211,7 @@ pub fn validate(s: &mut Schema, rules: Vec) -> Result<(), serde_json::Erro /// /// let gen = &mut schemars::gen::SchemaSettings::openapi3().into_generator(); /// let mut schema = MyStruct::json_schema(gen); -/// let rules = vec![Rule::new("self != oldSelf")]; +/// let rules = &[Rule::new("self != oldSelf")]; /// validate_property(&mut schema, 0, rules)?; /// assert_eq!( /// serde_json::to_string(&schema).unwrap(), @@ -224,7 +224,7 @@ pub fn validate(s: &mut Schema, rules: Vec) -> Result<(), serde_json::Erro pub fn validate_property( s: &mut Schema, property_index: usize, - rules: Vec, + rules: &[Rule], ) -> Result<(), serde_json::Error> { match s { Schema::Bool(_) => (), diff --git a/kube-derive/src/cel_schema.rs b/kube-derive/src/cel_schema.rs index 4a1baf70b..9333e3508 100644 --- a/kube-derive/src/cel_schema.rs +++ b/kube-derive/src/cel_schema.rs @@ -77,7 +77,7 @@ pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { // Remove all unknown attributes from the original structure copy // Has to happen on the original definition at all times, as we don't have #[derive] stanzes. let attribute_whitelist = ["serde", "schemars", "doc"]; - ast.attrs = remove_attributes(&ast.attrs, attribute_whitelist.to_vec()); + ast.attrs = remove_attributes(&ast.attrs, &attribute_whitelist); let struct_data = match ast.data { syn::Data::Struct(ref mut struct_data) => struct_data, @@ -96,7 +96,7 @@ pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { // Remove all unknown attributes from each field // Has to happen on the original definition at all times, as we don't have #[derive] stanzes. - field.attrs = remove_attributes(&field.attrs, attribute_whitelist.to_vec()); + field.attrs = remove_attributes(&field.attrs, &attribute_whitelist); if rules.is_empty() { continue; @@ -116,7 +116,7 @@ pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { } let merge = &mut Validated::json_schema(gen); - #kube_core::validate_property(merge, 0, [#(#rules)*].to_vec()).unwrap(); + #kube_core::validate_property(merge, 0, &[#(#rules)*]).unwrap(); #kube_core::merge_properties(s, merge); } }); @@ -141,7 +141,7 @@ pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { use #kube_core::{Rule, Message, Reason}; let s = &mut #ident::json_schema(gen); - #kube_core::validate(s, [#(#struct_rules)*].to_vec()).unwrap(); + #kube_core::validate(s, &[#(#struct_rules)*]).unwrap(); #(#property_modifications)* s.clone() } @@ -150,7 +150,7 @@ pub(crate) fn derive_validated_schema(input: TokenStream) -> TokenStream { } // Remove all unknown attributes from the list -fn remove_attributes(attrs: &Vec, witelist: Vec<&str>) -> Vec { +fn remove_attributes(attrs: &[Attribute], witelist: &[&str]) -> Vec { attrs .iter() .filter(|attr| witelist.iter().any(|i| attr.path().is_ident(i))) @@ -176,8 +176,6 @@ fn test_derive_validated() { #[cfg(test)] mod tests { - use std::{env, fs}; - use prettyplease::unparse; use syn::parse::{Parse as _, Parser as _}; @@ -212,7 +210,7 @@ mod tests { } use ::kube::core::{Rule, Message, Reason}; let s = &mut FooSpec::json_schema(gen); - ::kube::core::validate(s, ["true".into()].to_vec()).unwrap(); + ::kube::core::validate(s, &["true".into()]).unwrap(); { #[derive(::serde::Serialize, ::schemars::JsonSchema)] #[automatically_derived] @@ -221,7 +219,7 @@ mod tests { foo: String, } let merge = &mut Validated::json_schema(gen); - ::kube::core::validate_property(merge, 0, ["true".into()].to_vec()).unwrap(); + ::kube::core::validate_property(merge, 0, &["true".into()]).unwrap(); ::kube::core::merge_properties(s, merge); } s.clone()