-
-
Notifications
You must be signed in to change notification settings - Fork 331
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add example and test for CRD schema generation/defaulting/validation
- Loading branch information
Showing
3 changed files
with
368 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(()) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
); | ||
} |