Skip to content

Commit

Permalink
Implement cel validation proc macro for generated CRDs
Browse files Browse the repository at this point in the history
- 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 <[email protected]>
  • Loading branch information
Danil-Grigorev committed Nov 24, 2024
1 parent 3ee4ae5 commit d30f3ff
Show file tree
Hide file tree
Showing 6 changed files with 284 additions and 19 deletions.
51 changes: 35 additions & 16 deletions examples/crd_derive_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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",
Expand Down Expand Up @@ -85,9 +87,15 @@ pub struct FooSpec {
#[serde(default)]
#[schemars(schema_with = "set_listable_schema")]
set_listable: Vec<u32>,

// Field with CEL validation
#[serde(default)]
#[schemars(schema_with = "cel_validations")]
#[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<String>,
}
// https://kubernetes.io/docs/reference/using-api/server-side-apply/#merge-strategy
Expand All @@ -104,18 +112,6 @@ 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()
}
Expand Down Expand Up @@ -243,11 +239,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",
Expand Down
3 changes: 3 additions & 0 deletions kube-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ pub use dynamic::{ApiResource, DynamicObject};
pub mod crd;
pub use crd::CustomResourceExt;

pub mod validation;
pub use validation::{validate, Message, Reason, Rule};

pub mod gvk;
pub use gvk::{GroupVersion, GroupVersionKind, GroupVersionResource};

Expand Down
132 changes: 132 additions & 0 deletions kube-core/src/validation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//! CEL validation for CRDs

use std::str::FromStr;

use schemars::schema::Schema;
use serde::{Deserialize, Serialize};
use serde_json::Error;

/// 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<Message>,
/// 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<String>,
/// 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<Reason>,
}

/// 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<Self, Self::Err> {
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")]
pub fn validate(s: &mut Schema, rules: Vec<Rule>) -> Result<Schema, Error> {
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())
}

/// Docs
pub fn validate_field(s: &mut Schema, property: usize, rules: Vec<Rule>) -> Result<(), Error> {
match s {
Schema::Object(s) => {
if let Some(schema) = s.object().properties.values_mut().nth(property) {
validate(schema, rules)?;
}
},
_ => (),
};

Check warning on line 129 in kube-core/src/validation.rs

View workflow job for this annotation

GitHub Actions / clippy_nightly

you seem to be trying to use `match` for destructuring a single pattern. Consider using `if let`

warning: you seem to be trying to use `match` for destructuring a single pattern. Consider using `if let` --> kube-core/src/validation.rs:122:5 | 122 | / match s { 123 | | Schema::Object(s) => { 124 | | if let Some(schema) = s.object().properties.values_mut().nth(property) { 125 | | validate(schema, rules)?; ... | 128 | | _ => (), 129 | | }; | |_____^ | = help: for further information visit https://rust-lang.github.io/rust-clippy/master/index.html#single_match = note: `#[warn(clippy::single_match)]` on by default help: try | 122 ~ if let Schema::Object(s) = s { 123 + if let Some(schema) = s.object().properties.values_mut().nth(property) { 124 + validate(schema, rules)?; 125 + } 126 ~ }; |

Ok(())
}
84 changes: 81 additions & 3 deletions kube-derive/src/custom_resource.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
// Generated by darling macros, out of our control
#![allow(clippy::manual_unwrap_or_default)]
use std::collections::BTreeMap;

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

View workflow job for this annotation

GitHub Actions / clippy_nightly

unused import: `std::collections::BTreeMap`

warning: unused import: `std::collections::BTreeMap` --> kube-derive/src/custom_resource.rs:3:5 | 3 | use std::collections::BTreeMap; | ^^^^^^^^^^^^^^^^^^^^^^^^^^ | = note: `#[warn(unused_imports)]` on by default

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

View workflow job for this annotation

GitHub Actions / clippy_nightly

unused import: `std::collections::BTreeMap`

warning: unused import: `std::collections::BTreeMap` --> kube-derive/src/custom_resource.rs:3:5 | 3 | use std::collections::BTreeMap; | ^^^^^^^^^^^^^^^^^^^^^^^^^^ | = note: `#[warn(unused_imports)]` on by default

use darling::{FromDeriveInput, FromMeta};
use darling::{
ast,
util::{self, path_to_string, IdentString},

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

View workflow job for this annotation

GitHub Actions / clippy_nightly

unused imports: `FromAttributes` and `path_to_string`

warning: unused imports: `FromAttributes` and `path_to_string` --> kube-derive/src/custom_resource.rs:7:18 | 7 | util::{self, path_to_string, IdentString}, | ^^^^^^^^^^^^^^ 8 | FromAttributes, FromDeriveInput, FromField, FromMeta, | ^^^^^^^^^^^^^^

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

View workflow job for this annotation

GitHub Actions / clippy_nightly

unused imports: `FromAttributes` and `path_to_string`

warning: unused imports: `FromAttributes` and `path_to_string` --> kube-derive/src/custom_resource.rs:7:18 | 7 | util::{self, path_to_string, IdentString}, | ^^^^^^^^^^^^^^ 8 | FromAttributes, FromDeriveInput, FromField, FromMeta, | ^^^^^^^^^^^^^^
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,

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

View workflow job for this annotation

GitHub Actions / clippy_nightly

unused imports: `Attribute`, `ExprCall`, and `Stmt`

warning: unused imports: `Attribute`, `ExprCall`, and `Stmt` --> kube-derive/src/custom_resource.rs:13:36 | 13 | parse_quote, spanned::Spanned, Attribute, Data, DeriveInput, Expr, ExprCall, Path, Stmt, Type, Visibility, | ^^^^^^^^^ ^^^^^^^^ ^^^^

Check warning on line 13 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:13:18 | 13 | parse_quote, spanned::Spanned, Attribute, Data, DeriveInput, Expr, ExprCall, Path, Stmt, Type, Visibility, | ^^^^^^^^^^^^^^^^

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

View workflow job for this annotation

GitHub Actions / clippy_nightly

unused imports: `Attribute`, `ExprCall`, and `Stmt`

warning: unused imports: `Attribute`, `ExprCall`, and `Stmt` --> kube-derive/src/custom_resource.rs:13:36 | 13 | parse_quote, spanned::Spanned, Attribute, Data, DeriveInput, Expr, ExprCall, Path, Stmt, Type, Visibility, | ^^^^^^^^^ ^^^^^^^^ ^^^^

Check warning on line 13 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:13:18 | 13 | parse_quote, spanned::Spanned, Attribute, Data, DeriveInput, Expr, ExprCall, Path, Stmt, Type, Visibility, | ^^^^^^^^^^^^^^^^
};

/// Values we can parse from #[kube(attrs)]
#[derive(Debug, FromDeriveInput)]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -629,6 +637,76 @@ fn generate_hasspec(spec_ident: &Ident, root_ident: &Ident, kube_core: &Path) ->
}
}

#[derive(FromField)]
#[darling(attributes(validated))]
struct Rule {
ident: Option<Ident>,
ty: Type,
method: Option<Path>,
#[darling(multiple, rename = "rule")]
rules: Vec<Expr>,
}

#[derive(FromDeriveInput)]
#[darling(supports(struct_named))]
struct CELValidation {
#[darling(default)]
crates: Crates,
data: ast::Data<util::Ignored, Rule>,
}

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: Vec<TokenStream> = vec![];

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<TokenStream> = 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 => {
return syn::Error::new(
Span::call_site(),
r#"Validated can be used only on named sctuctures"#,
)
.to_compile_error()
}
},
};
validations.push(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()
}
});
}

quote! {#(#validations)*}
}

struct StatusInformation {
/// The code to be used for the field in the main struct
field: TokenStream,
Expand Down
29 changes: 29 additions & 0 deletions kube-derive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,35 @@ 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 {
/// #[validated(rule = Rule{rule: "self != ''".into(), message: Some("failure message".into()), ..Default::default()})]
/// #[schemars(schema_with = "field")]
/// field: String,
/// }
///
/// 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""#));
/// ```
#[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,
Expand Down
4 changes: 4 additions & 0 deletions kube/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down

0 comments on commit d30f3ff

Please sign in to comment.