Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement cel validation derive(Validated) macro for generated CRDs #1649

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should probably call the module cel.rs


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 {
Comment on lines +8 to +11
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note to self; this may perhaps be churned through a cel crate against an object to perform validation client side. should investigate this later.

/// 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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these doc strings taken from anywhere?

/// 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interestingly, I see these ones in the generated docs under https://github.com/kube-rs/k8s-pb/blob/ce4261fb52266f05cd7a06dbb8f4c0fcaa41c06a/k8s-pb/src/apiextensions_apiserver/pkg/apis/apiextensions/v1/mod.rs#L736 but because of bad go enum usage it's just a doc comment :(

#[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 @@
.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 @@
}
}

#[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>,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: may also be useful to provide naming template for common suffix/prefix per each attribute, in case when method is not specified. By default generated method inherits the name of the field for simplicity.

}

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 @@
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