Skip to content

Commit

Permalink
Add cel_validate proc macro for completion, rename
Browse files Browse the repository at this point in the history
Signed-off-by: Danil-Grigorev <[email protected]>
  • Loading branch information
Danil-Grigorev committed Nov 29, 2024
1 parent ee96ec4 commit abe840c
Show file tree
Hide file tree
Showing 6 changed files with 190 additions and 22 deletions.
47 changes: 42 additions & 5 deletions examples/crd_derive_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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",
Expand Down Expand Up @@ -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<String>,

foo_sub_spec: Option<FooSubSpec>,
}

#[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<String>,
}

// 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!({
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions kube-core/src/validation.rs → kube-core/src/cel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ pub struct Rule {
pub reason: Option<Reason>,
}

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")]
Expand Down
6 changes: 3 additions & 3 deletions kube-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
93 changes: 85 additions & 8 deletions kube-derive/src/custom_resource.rs
Original file line number Diff line number Diff line change
@@ -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},

Check warning on line 4 in kube-derive/src/custom_resource.rs

View workflow job for this annotation

GitHub Actions / clippy_nightly

unused import: `NestedMeta`

warning: unused import: `NestedMeta` --> kube-derive/src/custom_resource.rs:4:17 | 4 | ast::{self, NestedMeta}, | ^^^^^^^^^^ | = note: `#[warn(unused_imports)]` on by default

Check warning on line 4 in kube-derive/src/custom_resource.rs

View workflow job for this annotation

GitHub Actions / clippy_nightly

unused import: `NestedMeta`

warning: unused import: `NestedMeta` --> kube-derive/src/custom_resource.rs:4:17 | 4 | ast::{self, NestedMeta}, | ^^^^^^^^^^ | = note: `#[warn(unused_imports)]` on by default
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,

Check warning on line 11 in kube-derive/src/custom_resource.rs

View workflow job for this annotation

GitHub Actions / clippy_nightly

unused import: `spanned::Spanned`

warning: unused import: `spanned::Spanned` --> kube-derive/src/custom_resource.rs:11:38 | 11 | parse::Parser as _, parse_quote, spanned::Spanned, Attribute, Data, DeriveInput, Expr, Path, Type, | ^^^^^^^^^^^^^^^^

Check warning on line 11 in kube-derive/src/custom_resource.rs

View workflow job for this annotation

GitHub Actions / clippy_nightly

unused import: `spanned::Spanned`

warning: unused import: `spanned::Spanned` --> kube-derive/src/custom_resource.rs:11:38 | 11 | parse::Parser as _, parse_quote, spanned::Spanned, Attribute, Data, DeriveInput, Expr, Path, Type, | ^^^^^^^^^^^^^^^^
Visibility,
};

/// Values we can parse from #[kube(attrs)]
#[derive(Debug, FromDeriveInput)]
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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 }
}
Expand Down Expand Up @@ -237,6 +246,7 @@ pub(crate) fn derive(input: proc_macro2::TokenStream) -> proc_macro2::TokenStrea
serde,
serde_json,
std,
..
},
annotations,
labels,
Expand Down Expand Up @@ -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<Ident>,
ty: Type,
Expand All @@ -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<util::Ignored, Rule>,
}

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,
Expand Down Expand Up @@ -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<TokenStream> = 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,
Expand Down Expand Up @@ -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
}
};
Expand Down
46 changes: 41 additions & 5 deletions kube-derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
/// }
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion kube/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")))]
Expand Down

0 comments on commit abe840c

Please sign in to comment.