kube_core/
schema.rs

1//! Utilities for managing [`CustomResourceDefinition`] schemas
2//!
3//! [`CustomResourceDefinition`]: `k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition`
4
5// Used in docs
6#[allow(unused_imports)] use schemars::gen::SchemaSettings;
7
8use schemars::{
9    schema::{InstanceType, Metadata, ObjectValidation, Schema, SchemaObject, SingleOrVec},
10    visit::Visitor,
11    MapEntry,
12};
13
14/// schemars [`Visitor`] that rewrites a [`Schema`] to conform to Kubernetes' "structural schema" rules
15///
16/// The following two transformations are applied
17///  * Rewrite enums from `oneOf` to `object`s with multiple variants ([schemars#84](https://github.com/GREsau/schemars/issues/84))
18///  * Rewrite untagged enums from `anyOf` to `object`s with multiple variants ([kube#1028](https://github.com/kube-rs/kube/pull/1028))
19///  * Rewrite `additionalProperties` from `#[serde(flatten)]` to `x-kubernetes-preserve-unknown-fields` ([kube#844](https://github.com/kube-rs/kube/issues/844))
20///
21/// This is used automatically by `kube::derive`'s `#[derive(CustomResource)]`,
22/// but it can also be used manually with [`SchemaSettings::with_visitor`].
23///
24/// # Panics
25///
26/// The [`Visitor`] functions may panic if the transform could not be applied. For example,
27/// there must not be any overlapping properties between `oneOf` branches.
28#[derive(Debug, Clone)]
29pub struct StructuralSchemaRewriter;
30
31impl Visitor for StructuralSchemaRewriter {
32    fn visit_schema_object(&mut self, schema: &mut schemars::schema::SchemaObject) {
33        schemars::visit::visit_schema_object(self, schema);
34
35        if let Some(subschemas) = &mut schema.subschemas {
36            if let Some(one_of) = subschemas.one_of.as_mut() {
37                // Tagged enums are serialized using `one_of`
38                hoist_subschema_properties(one_of, &mut schema.object, &mut schema.instance_type);
39
40                // "Plain" enums are serialized using `one_of` if they have doc tags
41                hoist_subschema_enum_values(one_of, &mut schema.enum_values, &mut schema.instance_type);
42
43                if one_of.is_empty() {
44                    subschemas.one_of = None;
45                }
46            }
47
48            if let Some(any_of) = &mut subschemas.any_of {
49                // Untagged enums are serialized using `any_of`
50                hoist_subschema_properties(any_of, &mut schema.object, &mut schema.instance_type);
51            }
52        }
53
54        // check for maps without with properties (i.e. flattened maps)
55        // and allow these to persist dynamically
56        if let Some(object) = &mut schema.object {
57            if !object.properties.is_empty()
58                && object.additional_properties.as_deref() == Some(&Schema::Bool(true))
59            {
60                object.additional_properties = None;
61                schema
62                    .extensions
63                    .insert("x-kubernetes-preserve-unknown-fields".into(), true.into());
64            }
65        }
66
67        // As of version 1.30 Kubernetes does not support setting `uniqueItems` to `true`,
68        // so we need to remove this fields.
69        // Users can still set `x-kubernetes-list-type=set` in case they want the apiserver
70        // to do validation, but we can't make an assumption about the Set contents here.
71        // See https://kubernetes.io/docs/reference/using-api/server-side-apply/ for details.
72        if let Some(array) = &mut schema.array {
73            array.unique_items = None;
74        }
75    }
76}
77
78/// Bring all plain enum values up to the root schema,
79/// since Kubernetes doesn't allow subschemas to define enum options.
80///
81/// (Enum here means a list of hard-coded values, not a tagged union.)
82fn hoist_subschema_enum_values(
83    subschemas: &mut Vec<Schema>,
84    common_enum_values: &mut Option<Vec<serde_json::Value>>,
85    instance_type: &mut Option<SingleOrVec<InstanceType>>,
86) {
87    subschemas.retain(|variant| {
88        if let Schema::Object(SchemaObject {
89            instance_type: variant_type,
90            enum_values: Some(variant_enum_values),
91            ..
92        }) = variant
93        {
94            if let Some(variant_type) = variant_type {
95                match instance_type {
96                    None => *instance_type = Some(variant_type.clone()),
97                    Some(tpe) => {
98                        if tpe != variant_type {
99                            panic!("Enum variant set {variant_enum_values:?} has type {variant_type:?} but was already defined as {instance_type:?}. The instance type must be equal for all subschema variants.")
100                        }
101                    }
102                }
103            }
104            common_enum_values
105                .get_or_insert_with(Vec::new)
106                .extend(variant_enum_values.iter().cloned());
107            false
108        } else {
109            true
110        }
111    })
112}
113
114/// Bring all property definitions from subschemas up to the root schema,
115/// since Kubernetes doesn't allow subschemas to define properties.
116fn hoist_subschema_properties(
117    subschemas: &mut Vec<Schema>,
118    common_obj: &mut Option<Box<ObjectValidation>>,
119    instance_type: &mut Option<SingleOrVec<InstanceType>>,
120) {
121    for variant in subschemas {
122        if let Schema::Object(SchemaObject {
123            instance_type: variant_type,
124            object: Some(variant_obj),
125            metadata: variant_metadata,
126            ..
127        }) = variant
128        {
129            let common_obj = common_obj.get_or_insert_with(Box::<ObjectValidation>::default);
130
131            if let Some(variant_metadata) = variant_metadata {
132                // Move enum variant description from oneOf clause to its corresponding property
133                if let Some(description) = std::mem::take(&mut variant_metadata.description) {
134                    if let Some(Schema::Object(variant_object)) =
135                        only_item(variant_obj.properties.values_mut())
136                    {
137                        let metadata = variant_object
138                            .metadata
139                            .get_or_insert_with(Box::<Metadata>::default);
140                        metadata.description = Some(description);
141                    }
142                }
143            }
144
145            // Move all properties
146            let variant_properties = std::mem::take(&mut variant_obj.properties);
147            for (property_name, property) in variant_properties {
148                match common_obj.properties.entry(property_name) {
149                    MapEntry::Vacant(entry) => {
150                        entry.insert(property);
151                    }
152                    MapEntry::Occupied(entry) => {
153                        if &property != entry.get() {
154                            panic!("Property {:?} has the schema {:?} but was already defined as {:?} in another subschema. The schemas for a property used in multiple subschemas must be identical",
155                            entry.key(),
156                            &property,
157                            entry.get());
158                        }
159                    }
160                }
161            }
162
163            // Kubernetes doesn't allow variants to set additionalProperties
164            variant_obj.additional_properties = None;
165
166            merge_metadata(instance_type, variant_type.take());
167        }
168    }
169}
170
171fn only_item<I: Iterator>(mut i: I) -> Option<I::Item> {
172    let item = i.next()?;
173    if i.next().is_some() {
174        return None;
175    }
176    Some(item)
177}
178
179fn merge_metadata(
180    instance_type: &mut Option<SingleOrVec<InstanceType>>,
181    variant_type: Option<SingleOrVec<InstanceType>>,
182) {
183    match (instance_type, variant_type) {
184        (_, None) => {}
185        (common_type @ None, variant_type) => {
186            *common_type = variant_type;
187        }
188        (Some(common_type), Some(variant_type)) => {
189            if *common_type != variant_type {
190                panic!(
191                    "variant defined type {variant_type:?}, conflicting with existing type {common_type:?}"
192                );
193            }
194        }
195    }
196}