diff --git a/Cargo.toml b/Cargo.toml index da72bb79c..ed419bfd5 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" diff --git a/examples/crd_derive_schema.rs b/examples/crd_derive_schema.rs index 8c58afacb..70513f221 100644 --- a/examples/crd_derive_schema.rs +++ b/examples/crd_derive_schema.rs @@ -7,9 +7,8 @@ use kube::{ WatchEvent, WatchParams, }, runtime::wait::{await_condition, conditions}, - Client, CustomResource, CustomResourceExt, + CELSchema, Client, CustomResource, CustomResourceExt, }; -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; // This example shows how the generated schema affects defaulting and validation. @@ -19,15 +18,18 @@ 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, CELSchema, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone)] #[kube( group = "clux.dev", version = "v1", 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"))] pub struct FooSpec { // Non-nullable without default is required. // @@ -85,11 +87,27 @@ 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")] + #[cel_validate( + 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), + )] cel_validated: Option, + + #[cel_validate(rule = Rule::new("self == oldSelf").message("is immutable"))] + foo_sub_spec: Option, +} + +#[derive(CELSchema, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone)] +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!({ @@ -104,22 +122,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()) } @@ -160,6 +170,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. @@ -178,22 +189,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"]["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!({ @@ -207,19 +219,24 @@ 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!(), } + // 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!({ "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?; @@ -232,7 +249,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; @@ -243,17 +260,99 @@ 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: 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": { + "celValidated": 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.celValidated: 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("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.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 new file mode 100644 index 000000000..5816e2004 --- /dev/null +++ b/kube-core/src/cel.rs @@ -0,0 +1,282 @@ +//! 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, Debug)] +#[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 + #[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")] + 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 { + 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, 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 + /// 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, 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 + /// 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 +/// on the top level 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() +/// }]; +/// 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<(), serde_json::Error> { + match s { + Schema::Bool(_) => (), + Schema::Object(schema_object) => { + schema_object + .extensions + .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(()) +} + +/// 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 969d10e0a..6ba9f81b6 100644 --- a/kube-core/src/lib.rs +++ b/kube-core/src/lib.rs @@ -25,6 +25,12 @@ pub use dynamic::{ApiResource, DynamicObject}; pub mod crd; pub use crd::CustomResourceExt; +pub mod cel; +pub use cel::{Message, Reason, Rule}; + +#[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/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/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 055664f31..e12d559a6 100644 --- a/kube-derive/src/custom_resource.rs +++ b/kube-derive/src/custom_resource.rs @@ -1,10 +1,9 @@ // Generated by darling macros, out of our control #![allow(clippy::manual_unwrap_or_default)] - use darling::{FromDeriveInput, 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, Data, DeriveInput, Expr, Path, Visibility}; /// Values we can parse from #[kube(attrs)] #[derive(Debug, FromDeriveInput)] @@ -41,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`. /// @@ -101,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")] @@ -128,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 } } @@ -201,6 +208,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, @@ -223,11 +231,13 @@ 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, @@ -302,15 +312,17 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea // 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.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 }); } + 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 { @@ -333,6 +345,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, diff --git a/kube-derive/src/lib.rs b/kube-derive/src/lib.rs index 36b7df07c..4cc5dee05 100644 --- a/kube-derive/src/lib.rs +++ b/kube-derive/src/lib.rs @@ -3,6 +3,7 @@ extern crate proc_macro; #[macro_use] extern crate quote; +mod cel_schema; mod custom_resource; mod resource; @@ -327,6 +328,45 @@ pub fn derive_custom_resource(input: proc_macro::TokenStream) -> proc_macro::Tok custom_resource::derive(proc_macro2::TokenStream::from(input)).into() } +/// Generates a JsonSchema implementation a set of CEL validation rules applied on the CRD. +/// +/// ```rust +/// use kube::CELSchema; +/// use kube::CustomResource; +/// use serde::Deserialize; +/// use serde::Serialize; +/// use kube::core::crd::CustomResourceExt; +/// +/// #[derive(CustomResource, CELSchema, Serialize, Deserialize, Clone, Debug)] +/// #[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")] +/// #[cel_validate(rule = Rule::new("self != ''").message("failure message"))] +/// 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 == 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""#)); +/// assert!(serde_json::to_string(&Struct::crd()).unwrap().contains(r#""rule":"self.matadata.name == 'singleton'""#)); +/// ``` +#[proc_macro_derive(CELSchema, attributes(cel_validate, schemars))] +pub fn derive_schema_validation(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + cel_schema::derive_validated_schema(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-derive/tests/crd_schema_test.rs b/kube-derive/tests/crd_schema_test.rs index e975d8ff3..8e8c5cf07 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::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, JsonSchema)] +#[derive(CustomResource, Serialize, Deserialize, Debug, PartialEq, Clone, CELSchema)] #[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" } }, diff --git a/kube/src/lib.rs b/kube/src/lib.rs index e7be35690..1cb9f23c4 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::CELSchema; + #[cfg(feature = "runtime")] #[cfg_attr(docsrs, doc(cfg(feature = "runtime")))] #[doc(inline)]