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..efedd69c0 100644 --- a/kube-derive/src/custom_resource.rs +++ b/kube-derive/src/custom_resource.rs @@ -1,10 +1,15 @@ // Generated by darling macros, out of our control #![allow(clippy::manual_unwrap_or_default)] - -use darling::{FromDeriveInput, FromMeta}; +use darling::{ + ast, + util::{self, path_to_string, IdentString}, + FromAttributes, 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, Attribute, Data, DeriveInput, Expr, ExprCall, Path, Stmt, Type, Visibility, +}; /// Values we can parse from #[kube(attrs)] #[derive(Debug, FromDeriveInput)] @@ -201,6 +206,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 +635,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 +825,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)]