Skip to content

Setting PreserverUnknownFields marker on both type and field generates an invalid schema #688

@eddycharly

Description

@eddycharly

When setting the kubebuilder:pruning:PreserveUnknownFields on a struct and a field using that struct, the generated schema is not valid.

For example:

// +kubebuilder:pruning:PreserveUnknownFields
type Preserved struct {
	ConcreteField string                 `json:"concreteField"`
	Rest          map[string]interface{} `json:"-"`
}

type Spec struct {
	// +kubebuilder:pruning:PreserveUnknownFields
	Preserved Preserved `json:"preserved"`
}

// +kubebuilder:object:root=true
type Test struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec Spec `json:"spec,omitempty"`
}

Will generate the following schema:

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  annotations:
    controller-gen.kubebuilder.io/version: (devel)
  creationTimestamp: null
  name: tests.testdata.kubebuilder.io
spec:
  group: testdata.kubebuilder.io
  names:
    kind: Test
    listKind: TestList
    plural: tests
    singular: test
  scope: Namespaced
  versions:
  - name: v1
    schema:
      openAPIV3Schema:
        properties:
          apiVersion:
            description: 'APIVersion defines the versioned schema of this representation
              of an object. Servers should convert recognized schemas to the latest
              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
            type: string
          kind:
            description: 'Kind is a string value representing the REST resource this
              object represents. Servers may infer this from the endpoint the client
              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
            type: string
          metadata:
            type: object
          spec:
            properties:
              preserved:
                allOf:
                - x-kubernetes-preserve-unknown-fields: true
                - x-kubernetes-preserve-unknown-fields: true
                properties:
                  concreteField:
                    type: string
                required:
                - concreteField
                type: object
            required:
            - preserved
            type: object
        type: object
    served: true
    storage: true

The problematic part being:

             preserved:
                allOf:
                - x-kubernetes-preserve-unknown-fields: true
                - x-kubernetes-preserve-unknown-fields: true

From what I observed, this happens when flattening the schema.
I managed to fix the bug by adding a case for XPreserveUnknownFields here to prevent hoisting:

switch fieldName {
case "Properties":
// merge if possible, use all of otherwise
srcMap := srcInt.(map[string]apiext.JSONSchemaProps)
dstMap := dstInt.(map[string]apiext.JSONSchemaProps)
for k, v := range srcMap {
dstProp, exists := dstMap[k]
if !exists {
dstMap[k] = v
continue
}
flattenAllOfInto(&dstProp, v, errRec)
dstMap[k] = dstProp
}
case "Required":
// merge
dstField.Set(reflect.AppendSlice(dstField, srcField))
case "Type":
if srcInt != dstInt {
// TODO(directxman12): figure out how to attach this back to a useful point in the Go source or in the schema
errRec.AddError(fmt.Errorf("conflicting types in allOf branches in schema: %s vs %s", dstInt, srcInt))
}
// keep the destination value, for now
// TODO(directxman12): Default -- use field?
// TODO(directxman12):
// - Dependencies: if field x is present, then either schema validates or all props are present
// - AdditionalItems: like AdditionalProperties
// - Definitions: common named validation sets that can be references (merge, bail if duplicate)
case "AdditionalProperties":
// as of the time of writing, `allows: false` is not allowed, so we don't have to handle it
srcProps := srcInt.(*apiext.JSONSchemaPropsOrBool)
if srcProps.Schema == nil {
// nothing to merge
continue
}
dstProps := dstInt.(*apiext.JSONSchemaPropsOrBool)
if dstProps.Schema == nil {
dstProps.Schema = &apiext.JSONSchemaProps{}
}
flattenAllOfInto(dstProps.Schema, *srcProps.Schema, errRec)
// NB(directxman12): no need to explicitly handle nullable -- false is considered to be the zero value
// TODO(directxman12): src isn't necessarily the field value -- it's just the most recent allOf entry
default:
// hoist into allOf...
hoisted = true
srcRemVal.Field(i).Set(srcField)
dstRemVal.Field(i).Set(dstField)
// ...and clear the original
dstField.Set(zeroVal)
}

While this fixed the bug, I'm not too sure about the fix itself.

I can open a PR but would like to confirm it's a bug first and eventually if the fix I tried makes sense.

cc @akutz @sbueringer (per Alvaro's recommendation).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions