From 5c175b9d72c4769eb0d18ee04ab92667415f4e01 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 17 Sep 2025 20:22:15 +0200 Subject: [PATCH] fix: normalize {} to [] for slice defaults in CRDs Signed-off-by: Andrei Kvapil --- pkg/crd/markers/validation.go | 72 ++++++++++++++++++- pkg/crd/testdata/cronjob_types.go | 17 +++++ .../testdata.kubebuilder.io_cronjobs.yaml | 25 +++++++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/pkg/crd/markers/validation.go b/pkg/crd/markers/validation.go index 10aa8206d..23cfb1671 100644 --- a/pkg/crd/markers/validation.go +++ b/pkg/crd/markers/validation.go @@ -552,15 +552,83 @@ func (m Nullable) ApplyToSchema(schema *apiext.JSONSchemaProps) error { return nil } +func coerceDefaultValueToSchema(val interface{}, schema *apiext.JSONSchemaProps) interface{} { + switch schema.Type { + case string(Array): + switch v := val.(type) { + case string: + s := strings.TrimSpace(v) + if s == "[]" || s == "{}" || s == "" { + return []interface{}{} + } + return v + case map[string]interface{}: + if len(v) == 0 { + return []interface{}{} + } + return v + case []interface{}: + if schema.Items != nil { + if schema.Items.Schema != nil { + for i := range v { + v[i] = coerceDefaultValueToSchema(v[i], schema.Items.Schema) + } + } else if len(schema.Items.JSONSchemas) > 0 { + for i := range v { + if i < len(schema.Items.JSONSchemas) { + v[i] = coerceDefaultValueToSchema(v[i], &schema.Items.JSONSchemas[i]) + } + } + } + } + return v + default: + return val + } + case string(Object): + switch v := val.(type) { + case string: + if strings.TrimSpace(v) == "{}" { + return map[string]interface{}{} + } + return v + case map[string]interface{}: + for name, p := range schema.Properties { + if child, ok := v[name]; ok { + v[name] = coerceDefaultValueToSchema(child, &p) + } + } + if schema.AdditionalProperties != nil && schema.AdditionalProperties.Schema != nil { + for k, child := range v { + if _, known := schema.Properties[k]; known { + continue + } + v[k] = coerceDefaultValueToSchema(child, schema.AdditionalProperties.Schema) + } + } + return v + default: + return val + } + default: + return val + } +} + // Defaults are only valid CRDs created with the v1 API func (m Default) ApplyToSchema(schema *apiext.JSONSchemaProps) error { - marshalledDefault, err := json.Marshal(m.Value) + val := m.Value + val = coerceDefaultValueToSchema(val, schema) + + marshalledDefault, err := json.Marshal(val) if err != nil { return err } - if schema.Type == "array" && string(marshalledDefault) == "{}" { + + if schema.Type == string(Array) && string(marshalledDefault) == "{}" { marshalledDefault = []byte("[]") } + schema.Default = &apiext.JSON{Raw: marshalledDefault} return nil } diff --git a/pkg/crd/testdata/cronjob_types.go b/pkg/crd/testdata/cronjob_types.go index 3d2b5ddac..82fcc7a0d 100644 --- a/pkg/crd/testdata/cronjob_types.go +++ b/pkg/crd/testdata/cronjob_types.go @@ -122,6 +122,14 @@ type CronJobSpec struct { // +kubebuilder:title=DefaultedSlice DefaultedSlice []string `json:"defaultedSlice"` + // +kubebuilder:default:={"md0":{"gpus":{}}} + // Expect: default.md0.gpus == [] + DefaultedNestedProfiles Profiles `json:"defaultedNestedProfiles,omitempty"` + + // +kubebuilder:default:={"md0":{}} + // Expect: default.md0 == [] + DefaultedNestedSliceMap map[string][]string `json:"defaultedNestedSliceMap,omitempty"` + // This tests that slice and object defaulting can be performed. // +kubebuilder:default={{nested: {foo: "baz", bar: true}},{nested: {foo: "qux", bar: false}}} // +kubebuilder:example={{nested: {foo: "baz", bar: true}},{nested: {foo: "qux", bar: false}}} @@ -420,6 +428,15 @@ type CronJobSpec struct { FieldLevelLocalDeclarationOverride LongerString `json:"fieldLevelLocalDeclarationOverride,omitempty"` } +// NodeProfile is used to verify nested array defaulting inside an object. +// gpus is a slice; when default supplies {}, it must become []. +type NodeProfile struct { + Gpus []string `json:"gpus,omitempty"` +} + +// Profiles map verifies nested coercion under properties. +type Profiles map[string]NodeProfile + type InlineAlias = EmbeddedStruct // EmbeddedStruct is for testing that embedded struct is handled correctly when it is used through an alias type. diff --git a/pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml b/pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml index 477d63017..4388c1907 100644 --- a/pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml +++ b/pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml @@ -133,6 +133,31 @@ spec: type: string title: '{}' type: array + defaultedNestedProfiles: + additionalProperties: + description: |- + NodeProfile is used to verify nested array defaulting inside an object. + gpus is a slice; when default supplies {}, it must become []. + properties: + gpus: + items: + type: string + type: array + type: object + default: + md0: + gpus: [] + description: 'Expect: default.md0.gpus == []' + type: object + defaultedNestedSliceMap: + additionalProperties: + items: + type: string + type: array + default: + md0: [] + description: 'Expect: default.md0 == []' + type: object defaultedObject: default: - nested: