From 01ec3c72b3e5dd31b43d14f8e421ea49ab205277 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Thu, 30 Oct 2025 23:56:38 -0400 Subject: [PATCH 1/2] fix: hack to flatten broken Option schema This is a temporary workaround for an issue in kube 2.x: https://github.com/kube-rs/kube/issues/1821 Signed-off-by: Shane Utt --- kube-core/src/schema.rs | 75 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/kube-core/src/schema.rs b/kube-core/src/schema.rs index 479918a8e..f122f60e0 100644 --- a/kube-core/src/schema.rs +++ b/kube-core/src/schema.rs @@ -273,6 +273,15 @@ impl Transform for StructuralSchemaRewriter { // Untagged enums are serialized using `any_of` hoist_subschema_properties(any_of, &mut schema.object, &mut schema.instance_type); } + + // FIXME: hack for https://github.com/kube-rs/kube/issues/1821 + if let Some(any_of) = &mut subschemas.any_of { + if let Some(new_schema) = optional_enum_flatten_hack(any_of) { + let metadata = schema.metadata; + schema = new_schema.clone(); + schema.metadata = metadata; + } + } } // check for maps without with properties (i.e. flattened maps) @@ -424,3 +433,69 @@ fn merge_metadata( } } } + +/// In kube 2.x the schema output behavior for `Option` types changed. +/// +/// Previously given an enum like: +/// +/// ```rust +/// enum LogLevel { +/// Debug, +/// Info, +/// Error, +/// } +/// ``` +/// +/// The following would be generated for Optional: +/// +/// ```json +/// { "enum": ["Debug", "Info", "Error"], "type": "string", "nullable": true } +/// ``` +/// +/// Now, schemars generates `anyOf` for `Option` like: +/// +/// ```json +/// { +/// "anyOf": [ +/// { "enum": ["Debug", "Info", "Error"], "type": "string" }, +/// { "enum": [null], "nullable": true } +/// ] +/// } +/// ``` +/// +/// This is breaking, as Kubernetes validation will reject this structure. This +/// issue was reported in: +/// +/// https://github.com/kube-rs/kube/issues/1821 +/// +/// This hack does a precise check for this "empty" second enum that would +/// break validation, and removes it, flattening the schema object. +/// +/// FIXME: This should be removed once the problem is properly resolved. +fn optional_enum_flatten_hack(any_of: &mut Vec) -> Option<&SchemaObject> { + if let [ + Schema::Object(obj), + Schema::Object(SchemaObject { + enum_values: Some(null_enum), + metadata: None, + instance_type: None, + format: None, + subschemas: None, + array: None, + object: None, + extensions, + other: Value::Object(other), + }), + ] = any_of.as_mut_slice() + && null_enum.as_slice() == [Value::Null] + && extensions.len() == 1 + && extensions.get("nullable") == Some(&Value::Bool(true)) + && other.len() == 1 + && other.get("nullable") == Some(&Value::Bool(true)) + { + obj.extensions.insert("nullable".into(), Value::Bool(true)); + return Some(obj); + } + + None +} From 3e0290637a97779cecbc1bc437589ac9d00bacec Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Thu, 30 Oct 2025 23:56:58 -0400 Subject: [PATCH 2/2] test: add regression test for Optional schema Signed-off-by: Shane Utt --- kube-derive/tests/crd_schema_test.rs | 54 ++++++++++++++++++---------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/kube-derive/tests/crd_schema_test.rs b/kube-derive/tests/crd_schema_test.rs index c00009ff6..ee97d6fc2 100644 --- a/kube-derive/tests/crd_schema_test.rs +++ b/kube-derive/tests/crd_schema_test.rs @@ -77,6 +77,9 @@ struct FooSpec { #[x_kube(merge_strategy = ListMerge::Set)] x_kubernetes_set: Vec, + + /// Gender of a person + optional_enum: Option, } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)] @@ -170,24 +173,28 @@ fn test_shortnames() { #[test] fn test_serialized_matches_expected() { assert_json_eq!( - serde_json::to_value(Foo::new("bar", FooSpec { - non_nullable: "asdf".to_string(), - non_nullable_with_default: "asdf".to_string(), - nullable_skipped: None, - nullable: None, - nullable_skipped_with_default: None, - nullable_with_default: None, - timestamp: DateTime::from_timestamp(0, 0).unwrap(), - complex_enum: ComplexEnum::VariantOne { int: 23 }, - untagged_enum_person: UntaggedEnumPerson::GenderAndAge(GenderAndAge { - age: 42, - gender: Gender::Male, - }), - associated_default: false, - my_list: vec!["".into()], - set: HashSet::from(["foo".to_owned()]), - x_kubernetes_set: vec![], - })) + serde_json::to_value(Foo::new( + "bar", + FooSpec { + non_nullable: "asdf".to_string(), + non_nullable_with_default: "asdf".to_string(), + nullable_skipped: None, + nullable: None, + nullable_skipped_with_default: None, + nullable_with_default: None, + timestamp: DateTime::from_timestamp(0, 0).unwrap(), + complex_enum: ComplexEnum::VariantOne { int: 23 }, + untagged_enum_person: UntaggedEnumPerson::GenderAndAge(GenderAndAge { + age: 42, + gender: Gender::Male, + }), + associated_default: false, + my_list: vec!["".into()], + set: HashSet::from(["foo".to_owned()]), + x_kubernetes_set: vec![], + optional_enum: Some(Gender::Other), + } + )) .unwrap(), serde_json::json!({ "apiVersion": "clux.dev/v1", @@ -222,6 +229,7 @@ fn test_serialized_matches_expected() { "myList": [""], "set": ["foo"], "xKubernetesSet": [], + "optionalEnum": "Other", } }) ) @@ -410,6 +418,16 @@ fn test_crd_schema_matches_expected() { }, "x-kubernetes-list-type": "set", }, + "optionalEnum": { + "description": "Gender of a person", + "enum": [ + "Female", + "Male", + "Other" + ], + "type": "string", + "nullable": true + } }, "required": [ "complexEnum",