Skip to content

Commit

Permalink
Implement derive(CELSchema) macro for generating cel validation on …
Browse files Browse the repository at this point in the history
…CRDs (#1649)

* Implement cel validation proc macro for generated CRDs

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

* Add cel_validate proc macro for completion, rename

Signed-off-by: Danil-Grigorev <[email protected]>

* Add builder for the Rule

Signed-off-by: Danil-Grigorev <[email protected]>

* Fmt fixes

Signed-off-by: Danil-Grigorev <[email protected]>

* Implement as a JsonSchema generator via derive(ValidateSchema)

Signed-off-by: Danil-Grigorev <[email protected]>

* Allow to pass rules to the CRD struct

Signed-off-by: Danil-Grigorev <[email protected]>

* Add derive tests and doc support

Signed-off-by: Danil-Grigorev <[email protected]>

* fmt fixes

Signed-off-by: Danil-Grigorev <[email protected]>

* Rename to CELSchema, simplify derive addition in kube macro

Signed-off-by: Danil-Grigorev <[email protected]>

* Move to a separate package

Signed-off-by: Danil-Grigorev <[email protected]>

* clippy/fmt fixes

Signed-off-by: Danil-Grigorev <[email protected]>

* Add doc comments to lib.rs

Signed-off-by: Danil-Grigorev <[email protected]>

* Make attribute removal another fn

Signed-off-by: Danil-Grigorev <[email protected]>

* Doc comment from suggestion

Signed-off-by: Danil-Grigorev <[email protected]>

* Clippy nightly fixes

Signed-off-by: Danil-Grigorev <[email protected]>

---------

Signed-off-by: Danil-Grigorev <[email protected]>
  • Loading branch information
Danil-Grigorev authored Dec 22, 2024
1 parent 0424cb4 commit b104472
Show file tree
Hide file tree
Showing 10 changed files with 740 additions and 42 deletions.
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
159 changes: 129 additions & 30 deletions examples/crd_derive_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
//
Expand Down Expand Up @@ -85,11 +87,27 @@ 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")]
#[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<String>,

#[cel_validate(rule = Rule::new("self == oldSelf").message("is immutable"))]
foo_sub_spec: Option<FooSubSpec>,
}

#[derive(CELSchema, Serialize, Deserialize, Default, Debug, PartialEq, Eq, Clone)]
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 All @@ -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<String> {
Some("legal".into())
}

fn default_nullable() -> Option<String> {
Some("default_nullable".into())
}
Expand Down Expand Up @@ -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.
Expand All @@ -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!({
Expand All @@ -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?;
Expand All @@ -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;
Expand All @@ -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?;
Expand Down
Loading

0 comments on commit b104472

Please sign in to comment.