Skip to content

Commit 4876004

Browse files
committed
fix: normalize {} to [] for slice defaults in CRDs
Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent 03cd0b7 commit 4876004

File tree

3 files changed

+111
-1
lines changed

3 files changed

+111
-1
lines changed

pkg/crd/markers/validation.go

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,15 +552,83 @@ func (m Nullable) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
552552
return nil
553553
}
554554

555+
func coerceDefaultValueToSchema(val interface{}, schema *apiext.JSONSchemaProps) interface{} {
556+
switch schema.Type {
557+
case "array":
558+
switch v := val.(type) {
559+
case string:
560+
s := strings.TrimSpace(v)
561+
if s == "[]" || s == "{}" || s == "" {
562+
return []interface{}{}
563+
}
564+
return v
565+
case map[string]interface{}:
566+
if len(v) == 0 {
567+
return []interface{}{}
568+
}
569+
return v
570+
case []interface{}:
571+
if schema.Items != nil {
572+
if schema.Items.Schema != nil {
573+
for i := range v {
574+
v[i] = coerceDefaultValueToSchema(v[i], schema.Items.Schema)
575+
}
576+
} else if len(schema.Items.JSONSchemas) > 0 {
577+
for i := range v {
578+
if i < len(schema.Items.JSONSchemas) {
579+
v[i] = coerceDefaultValueToSchema(v[i], &schema.Items.JSONSchemas[i])
580+
}
581+
}
582+
}
583+
}
584+
return v
585+
default:
586+
return val
587+
}
588+
case "object":
589+
switch v := val.(type) {
590+
case string:
591+
if strings.TrimSpace(v) == "{}" {
592+
return map[string]interface{}{}
593+
}
594+
return v
595+
case map[string]interface{}:
596+
for name, p := range schema.Properties {
597+
if child, ok := v[name]; ok {
598+
v[name] = coerceDefaultValueToSchema(child, &p)
599+
}
600+
}
601+
if schema.AdditionalProperties != nil && schema.AdditionalProperties.Schema != nil {
602+
for k, child := range v {
603+
if _, known := schema.Properties[k]; known {
604+
continue
605+
}
606+
v[k] = coerceDefaultValueToSchema(child, schema.AdditionalProperties.Schema)
607+
}
608+
}
609+
return v
610+
default:
611+
return val
612+
}
613+
default:
614+
return val
615+
}
616+
}
617+
555618
// Defaults are only valid CRDs created with the v1 API
556619
func (m Default) ApplyToSchema(schema *apiext.JSONSchemaProps) error {
557-
marshalledDefault, err := json.Marshal(m.Value)
620+
val := m.Value
621+
val = coerceDefaultValueToSchema(val, schema)
622+
623+
marshalledDefault, err := json.Marshal(val)
558624
if err != nil {
559625
return err
560626
}
627+
561628
if schema.Type == "array" && string(marshalledDefault) == "{}" {
562629
marshalledDefault = []byte("[]")
563630
}
631+
564632
schema.Default = &apiext.JSON{Raw: marshalledDefault}
565633
return nil
566634
}

pkg/crd/testdata/cronjob_types.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ type CronJobSpec struct {
122122
// +kubebuilder:title=DefaultedSlice
123123
DefaultedSlice []string `json:"defaultedSlice"`
124124

125+
// +kubebuilder:default:={"md0":{"gpus":{}}}
126+
// Expect: default.md0.gpus == []
127+
DefaultedNestedProfiles Profiles `json:"defaultedNestedProfiles,omitempty"`
128+
129+
// +kubebuilder:default:={"md0":{}}
130+
// Expect: default.md0 == []
131+
DefaultedNestedSliceMap map[string][]string `json:"defaultedNestedSliceMap,omitempty"`
132+
125133
// This tests that slice and object defaulting can be performed.
126134
// +kubebuilder:default={{nested: {foo: "baz", bar: true}},{nested: {foo: "qux", bar: false}}}
127135
// +kubebuilder:example={{nested: {foo: "baz", bar: true}},{nested: {foo: "qux", bar: false}}}
@@ -420,6 +428,15 @@ type CronJobSpec struct {
420428
FieldLevelLocalDeclarationOverride LongerString `json:"fieldLevelLocalDeclarationOverride,omitempty"`
421429
}
422430

431+
// NodeProfile is used to verify nested array defaulting inside an object.
432+
// gpus is a slice; when default supplies {}, it must become [].
433+
type NodeProfile struct {
434+
Gpus []string `json:"gpus,omitempty"`
435+
}
436+
437+
// Profiles map verifies nested coercion under properties.
438+
type Profiles map[string]NodeProfile
439+
423440
type InlineAlias = EmbeddedStruct
424441

425442
// EmbeddedStruct is for testing that embedded struct is handled correctly when it is used through an alias type.

pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,31 @@ spec:
133133
type: string
134134
title: '{}'
135135
type: array
136+
defaultedNestedProfiles:
137+
additionalProperties:
138+
description: |-
139+
NodeProfile is used to verify nested array defaulting inside an object.
140+
gpus is a slice; when default supplies {}, it must become [].
141+
properties:
142+
gpus:
143+
items:
144+
type: string
145+
type: array
146+
type: object
147+
default:
148+
md0:
149+
gpus: []
150+
description: 'Expect: default.md0.gpus == []'
151+
type: object
152+
defaultedNestedSliceMap:
153+
additionalProperties:
154+
items:
155+
type: string
156+
type: array
157+
default:
158+
md0: []
159+
description: 'Expect: default.md0 == []'
160+
type: object
136161
defaultedObject:
137162
default:
138163
- nested:

0 commit comments

Comments
 (0)