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 ee96ec4
Show file tree
Hide file tree
Showing 6 changed files with 295 additions and 21 deletions.
58 changes: 41 additions & 17 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")]
#[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<String>,
}
// https://kubernetes.io/docs/reference/using-api/server-side-apply/#merge-strategy
Expand All @@ -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<String> {
Some("legal".into())
}

fn default_nullable() -> Option<String> {
Some("default_nullable".into())
}
Expand Down Expand Up @@ -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!({
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions kube-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down
118 changes: 118 additions & 0 deletions kube-core/src/validation.rs
Original file line number Diff line number Diff line change
@@ -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<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())

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

View check run for this annotation

Codecov / codecov/patch

kube-core/src/validation.rs#L50-L51

Added lines #L50 - L51 were not covered by tests
}
}

/// 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)

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

View check run for this annotation

Codecov / codecov/patch

kube-core/src/validation.rs#L78-L79

Added lines #L78 - L79 were not covered by tests
}
}

/// 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<Rule>) -> Result<Schema, serde_json::Error> {
let rules = serde_json::to_value(rules)?;
match s {

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

View check run for this annotation

Codecov / codecov/patch

kube-core/src/validation.rs#L106-L108

Added lines #L106 - L108 were not covered by tests
Schema::Bool(_) => (),
Schema::Object(schema_object) => {
schema_object

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

View check run for this annotation

Codecov / codecov/patch

kube-core/src/validation.rs#L110-L111

Added lines #L110 - L111 were not covered by tests
.extensions
.insert("x-kubernetes-validations".into(), rules);

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

View check run for this annotation

Codecov / codecov/patch

kube-core/src/validation.rs#L113

Added line #L113 was not covered by tests
}
};

Ok(s.clone())

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

View check run for this annotation

Codecov / codecov/patch

kube-core/src/validation.rs#L117

Added line #L117 was not covered by tests
}
96 changes: 92 additions & 4 deletions kube-derive/src/custom_resource.rs
Original file line number Diff line number Diff line change
@@ -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};

Check warning on line 10 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:10:24 | 10 | use syn::{parse_quote, spanned::Spanned, Data, DeriveInput, Expr, Path, Type, Visibility}; | ^^^^^^^^^^^^^^^^ | = note: `#[warn(unused_imports)]` on by default

Check warning on line 10 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:10:24 | 10 | use syn::{parse_quote, spanned::Spanned, Data, DeriveInput, Expr, Path, Type, Visibility}; | ^^^^^^^^^^^^^^^^ | = note: `#[warn(unused_imports)]` on by default

/// Values we can parse from #[kube(attrs)]
#[derive(Debug, FromDeriveInput)]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<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,

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

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L654-L657

Added lines #L654 - L657 were not covered by tests
};

let CELValidation {

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

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L660

Added line #L660 was not covered by tests
crates: Crates {
kube_core, schemars, ..

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

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L662

Added line #L662 was not covered by tests
},
data,

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

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L664

Added line #L664 was not covered by tests
..
} = match CELValidation::from_derive_input(&ast) {
Err(err) => return err.write_errors(),
Ok(attrs) => attrs,

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

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L667-L668

Added lines #L667 - L668 were not covered by tests
};

let mut validations: TokenStream = TokenStream::new();

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

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L671

Added line #L671 was not covered by tests

let fields = data.take_struct().map(|f| f.fields).unwrap_or_default();
for rule in fields.iter().filter(|r| !r.rules.is_empty()) {

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

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L673-L674

Added lines #L673 - L674 were not covered by tests
let Rule {
rules,
ident,
ty,
method,

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

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L676-L679

Added lines #L676 - L679 were not covered by tests
} = 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(),

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

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L681-L685

Added lines #L681 - L685 were not covered by tests
None => continue,
},
};

validations.append_all(quote! {

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

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L690

Added line #L690 was not covered by tests
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

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

View check run for this annotation

Codecov / codecov/patch

kube-derive/src/custom_resource.rs#L698

Added line #L698 was not covered by tests
}

struct StatusInformation {
/// The code to be used for the field in the main struct
field: TokenStream,
Expand Down Expand Up @@ -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);
}
}
Loading

0 comments on commit ee96ec4

Please sign in to comment.