Skip to content

Commit

Permalink
Add example and test for CRD schema generation/defaulting/validation
Browse files Browse the repository at this point in the history
  • Loading branch information
kazk committed Dec 21, 2020
1 parent aefb05b commit 8b69d8a
Show file tree
Hide file tree
Showing 3 changed files with 368 additions and 0 deletions.
4 changes: 4 additions & 0 deletions examples/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ path = "crd_apply.rs"
name = "crd_derive"
path = "crd_derive.rs"

[[example]]
name = "crd_derive_schema"
path = "crd_derive_schema.rs"

[[example]]
name = "crd_reflector"
path = "crd_reflector.rs"
Expand Down
244 changes: 244 additions & 0 deletions examples/crd_derive_schema.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
use anyhow::{anyhow, Result};
use futures::{StreamExt, TryStreamExt};
use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition;
use kube::CustomResource;
use kube::{
api::{Api, DeleteParams, ListParams, PostParams, Resource, WatchEvent},
Client,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

// This example shows how the generated schema affects defaulting and validation.
// The integration test `crd_schema_test` in `kube-derive` contains the full CRD JSON generated from this struct.
//
// References:
// - 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, Clone, JsonSchema)]
#[kube(
group = "clux.dev",
version = "v1",
kind = "Foo",
namespaced,
derive = "PartialEq",
derive = "Default"
)]
#[kube(apiextensions = "v1")]
pub struct FooSpec {
// Non-nullable without default is required.
//
// There shouldn't be any ambiguity here.
non_nullable: String,

// Non-nullable with default value.
//
// Serializing will work as expected because the field cannot be `None`.
//
// When deserializing a response from the server, the field should always be a string because
// the field is non-nullable and the server sets the value to the default specified in the schema.
//
// When deserializing some input, the default value will be set if missing.
// However, if `null` is specified, `serde` will panic.
// The server prunes `null` for non-nullable field since 1.20 and the default is applied.
// To match the server's behavior exactly, we can use a custom deserializer.
#[serde(default = "default_value")]
non_nullable_with_default: String,

// Nullable without default, skipping None.
//
// By skipping to serialize, the field won't be present in the object.
// If serialized as `null` (next field), the object will have the field set to `null`.
//
// Deserializing works as expected either way. `None` if it's missing or `null`.
#[serde(skip_serializing_if = "Option::is_none")]
nullable_skipped: Option<String>,
// Nullable without default, not skipping None.
nullable: Option<String>,

// Nullable with default, skipping None.
//
// By skipping to serialize when `None`, the server will set the the default value specified in the schema.
// If serialized as `null`, the server will conserve it and the defaulting does not happen (since 1.20).
//
// When deserializing, the default value is used only when it's missing (`null` is `None`).
// This is consistent with how the server handles it since 1.20.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default = "default_nullable")]
nullable_skipped_with_default: Option<String>,

// Nullable with default, not skipping None.
//
// The default value won't be used unless missing, so this will set the value to `null`.
// If the resource is created with `kubectl` and if this field was missing, defaulting will happen.
#[serde(default = "default_nullable")]
nullable_with_default: Option<String>,
}

fn default_value() -> String {
"default_value".into()
}

fn default_nullable() -> Option<String> {
Some("default_nullable".into())
}

#[tokio::main]
async fn main() -> Result<()> {
// Show the generated CRD
println!("Foo CRD:\n{}\n", serde_yaml::to_string(&Foo::crd())?);

// Creating CRD v1 works as expected.
println!("Creating CRD v1");
let client = Client::try_default().await?;
delete_crd(client.clone()).await?;
assert!(create_crd(client.clone()).await.is_ok());

// Test creating Foo resource.
let namespace = std::env::var("NAMESPACE").unwrap_or("default".into());
let foos = Api::<Foo>::namespaced(client.clone(), &namespace);
// Create with defaults using typed Api first.
// `non_nullable` and `non_nullable_with_default` are set to empty strings.
// Nullables defaults to `None` and only sent if it's not configured to skip.
let bar = Foo::new("bar", FooSpec { ..FooSpec::default() });
let bar = foos.create(&PostParams::default(), &bar).await?;
assert_eq!(
bar.spec,
FooSpec {
// Nonnullable without default is required.
non_nullable: String::default(),
// Defaulting didn't happen because an empty string was sent.
non_nullable_with_default: String::default(),
// `nullable_skipped` field does not exist in the object (see below).
nullable_skipped: None,
// `nullable` field exists in the object (see below).
nullable: None,
// Defaulting happened because serialization was skipped.
nullable_skipped_with_default: default_nullable(),
// Defaulting did not happen because `null` was sent.
// Deserialization does not apply the default either.
nullable_with_default: None,
}
);

// Set up dynamic resource to test using raw values.
let resource = Resource::dynamic("Foo")
.group("clux.dev")
.version("v1")
.within(&namespace)
.into_resource();

// Test that skipped nullable field without default is not defined.
let val = client
.request::<serde_json::Value>(resource.get("bar").unwrap())
.await?;
println!("{:?}", val["spec"]);
// `nullable_skipped` field does not exist, but `nullable` does.
let spec = val["spec"].as_object().unwrap();
assert!(!spec.contains_key("nullable_skipped"));
assert!(spec.contains_key("nullable"));

// Test defaulting of `non_nullable_with_default` field
let data = serde_json::to_vec(&serde_json::json!({
"apiVersion": "clux.dev/v1",
"kind": "Foo",
"metadata": {
"name": "baz"
},
"spec": {
"non_nullable": "a required field",
// `non_nullable_with_default` field is missing
}
}))?;
let val = client
.request::<serde_json::Value>(resource.create(&PostParams::default(), data).unwrap())
.await?;
println!("{:?}", val["spec"]);
// Defaulting happened for non-nullable field
assert_eq!(val["spec"]["non_nullable_with_default"], default_value());

// Missing required field (non-nullable without default) is an error
let data = serde_json::to_vec(&serde_json::json!({
"apiVersion": "clux.dev/v1",
"kind": "Foo",
"metadata": {
"name": "qux"
},
"spec": {}
}))?;
let res = client
.request::<serde_json::Value>(resource.create(&PostParams::default(), data).unwrap())
.await;
assert!(res.is_err());
match res.err() {
Some(kube::Error::Api(err)) => {
assert_eq!(err.code, 422);
assert_eq!(err.reason, "Invalid");
assert_eq!(err.status, "Failure");
assert_eq!(
err.message,
"Foo.clux.dev \"qux\" is invalid: spec.non_nullable: Required value"
);
}
_ => assert!(false),
}

delete_crd(client.clone()).await?;

Ok(())
}

// Create CRD and wait for it to be ready.
async fn create_crd(client: Client) -> Result<CustomResourceDefinition> {
let api = Api::<CustomResourceDefinition>::all(client);
api.create(&PostParams::default(), &Foo::crd()).await?;

// Wait until ready
let timeout_secs = 15;
let lp = ListParams::default()
.fields("metadata.name=foos.clux.dev")
.timeout(timeout_secs);
let mut stream = api.watch(&lp, "0").await?.boxed_local();
while let Some(status) = stream.try_next().await? {
if let WatchEvent::Modified(crd) = status {
let accepted = crd
.status
.as_ref()
.and_then(|s| s.conditions.as_ref())
.map(|cs| {
cs.iter()
.any(|c| c.type_ == "NamesAccepted" && c.status == "True")
})
.unwrap_or(false);
if accepted {
return Ok(crd);
}
}
}

Err(anyhow!(format!("CRD not ready after {} seconds", timeout_secs)))
}

// Delete the CRD if it exists and wait until it's deleted.
async fn delete_crd(client: Client) -> Result<()> {
let api = Api::<CustomResourceDefinition>::all(client);
if api.get("foos.clux.dev").await.is_ok() {
api.delete("foos.clux.dev", &DeleteParams::default()).await?;

// Wait until deleted
let timeout_secs = 15;
let lp = ListParams::default()
.fields("metadata.name=foos.clux.dev")
.timeout(timeout_secs);
let mut stream = api.watch(&lp, "0").await?.boxed_local();
while let Some(status) = stream.try_next().await? {
if let WatchEvent::Deleted(_) = status {
return Ok(());
}
}
Err(anyhow!(format!("CRD not deleted after {} seconds", timeout_secs)))
} else {
Ok(())
}
}
120 changes: 120 additions & 0 deletions kube-derive/tests/crd_schema_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
use kube_derive::CustomResource;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

// See `crd_derive_schema` example for how the schema generated from this struct affects defaulting and validation.
#[derive(CustomResource, Serialize, Deserialize, Default, Debug, PartialEq, Clone, JsonSchema)]
#[kube(
group = "clux.dev",
version = "v1",
kind = "Foo",
namespaced,
derive = "PartialEq",
derive = "Default"
)]
#[kube(apiextensions = "v1")]
struct FooSpec {
non_nullable: String,

#[serde(default = "default_value")]
non_nullable_with_default: String,

#[serde(skip_serializing_if = "Option::is_none")]
nullable_skipped: Option<String>,
nullable: Option<String>,

#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default = "default_nullable")]
nullable_skipped_with_default: Option<String>,

#[serde(default = "default_nullable")]
nullable_with_default: Option<String>,
}

fn default_value() -> String {
"default_value".into()
}

fn default_nullable() -> Option<String> {
Some("default_nullable".into())
}

#[test]
fn test_crd_schema_matches_expected() {
assert_eq!(
Foo::crd(),
serde_json::from_value(serde_json::json!({
"apiVersion": "apiextensions.k8s.io/v1",
"kind": "CustomResourceDefinition",
"metadata": {
"name": "foos.clux.dev"
},
"spec": {
"group": "clux.dev",
"names": {
"kind": "Foo",
"plural": "foos",
"shortNames": [],
"singular": "foo"
},
"scope": "Namespaced",
"versions": [
{
"name": "v1",
"served": true,
"storage": true,
"additionalPrinterColumns": [],
"schema": {
"openAPIV3Schema": {
"description": "Auto-generated derived type for FooSpec via `CustomResource`",
"properties": {
"spec": {
"properties": {
"non_nullable": {
"type": "string"
},
"non_nullable_with_default": {
"default": "default_value",
"type": "string"
},

"nullable_skipped": {
"nullable": true,
"type": "string"
},
"nullable": {
"nullable": true,
"type": "string"
},
"nullable_skipped_with_default": {
"default": "default_nullable",
"nullable": true,
"type": "string"
},
"nullable_with_default": {
"default": "default_nullable",
"nullable": true,
"type": "string"
},
},
"required": [
"non_nullable"
],
"type": "object"
}
},
"required": [
"spec"
],
"title": "Foo",
"type": "object"
}
},
"subresources": {},
}
]
}
}))
.unwrap()
);
}

0 comments on commit 8b69d8a

Please sign in to comment.