diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/fuzzer/fuzzer.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/fuzzer/fuzzer.go index b948e617917..87183ec1aca 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/fuzzer/fuzzer.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/fuzzer/fuzzer.go @@ -143,5 +143,9 @@ func Funcs(codecs runtimeserializer.CodecFactory) []interface{} { c.Fuzz(&obj.Property) } }, + func(obj *int64, c fuzz.Continue) { + // JSON only supports 53 bits because everything is a float + *obj = int64(c.Uint64()) & ((int64(1) << 53) - 1) + }, } } diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/types_jsonschema.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/types_jsonschema.go index 79f34e8bf65..af78c34fb6e 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/types_jsonschema.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/types_jsonschema.go @@ -23,6 +23,7 @@ type JSONSchemaProps struct { Ref *string Description string Type string + Nullable bool Format string Title string Default *JSON diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/types_jsonschema.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/types_jsonschema.go index 7d25c538e21..54c0a4ae13f 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/types_jsonschema.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/types_jsonschema.go @@ -54,6 +54,7 @@ type JSONSchemaProps struct { Definitions JSONSchemaDefinitions `json:"definitions,omitempty" protobuf:"bytes,34,opt,name=definitions"` ExternalDocs *ExternalDocumentation `json:"externalDocs,omitempty" protobuf:"bytes,35,opt,name=externalDocs"` Example *JSON `json:"example,omitempty" protobuf:"bytes,36,opt,name=example"` + Nullable bool `json:"nullable,omitempty" protobuf:"bytes,37,opt,name=nullable"` } // JSON represents any valid JSON value. diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/zz_generated.conversion.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/zz_generated.conversion.go index cab8019b458..c4b11c9cc5e 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/zz_generated.conversion.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/zz_generated.conversion.go @@ -902,6 +902,7 @@ func autoConvert_v1beta1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(in *JS } else { out.Example = nil } + out.Nullable = in.Nullable return nil } @@ -916,6 +917,7 @@ func autoConvert_apiextensions_JSONSchemaProps_To_v1beta1_JSONSchemaProps(in *ap out.Ref = (*string)(unsafe.Pointer(in.Ref)) out.Description = in.Description out.Type = in.Type + out.Nullable = in.Nullable out.Format = in.Format out.Title = in.Title if in.Default != nil { diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation.go index 4f3c27e66cc..ce844197e31 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation.go @@ -497,6 +497,10 @@ func ValidateCustomResourceDefinitionValidation(customResourceValidation *apiext } } + if schema.Nullable { + allErrs = append(allErrs, field.Forbidden(fldPath.Child("openAPIV3Schema.nullable"), fmt.Sprintf(`nullable cannot be true at the root`))) + } + openAPIV3Schema := &specStandardValidatorV3{} allErrs = append(allErrs, ValidateCustomResourceDefinitionOpenAPISchema(schema, fldPath.Child("openAPIV3Schema"), openAPIV3Schema)...) } @@ -641,7 +645,10 @@ func (v *specStandardValidatorV3) validate(schema *apiextensions.JSONSchemaProps } if schema.Type == "null" { - allErrs = append(allErrs, field.Forbidden(fldPath.Child("type"), "type cannot be set to null")) + allErrs = append(allErrs, field.Forbidden(fldPath.Child("type"), "type cannot be set to null, use nullable as an alternative")) + } + if schema.Nullable && schema.Type != "object" && schema.Type != "array" { + allErrs = append(allErrs, field.Forbidden(fldPath.Child("nullable"), "nullable can only be set for object and array types")) } if schema.Items != nil && len(schema.Items.JSONSchemas) != 0 { diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation_test.go index 692aa8b0c0a..ce102447725 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/validation_test.go @@ -1225,6 +1225,74 @@ func TestValidateCustomResourceDefinitionValidation(t *testing.T) { statusEnabled: true, wantError: false, }, + { + name: "null type", + input: apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "null": { + Type: "null", + }, + }, + }, + }, + wantError: true, + }, + { + name: "nullable at the root", + input: apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Nullable: true, + }, + }, + wantError: true, + }, + { + name: "nullable without type", + input: apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "nullable": { + Nullable: true, + }, + }, + }, + }, + wantError: true, + }, + { + name: "nullable with wrong type", + input: apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "string": { + Type: "string", + Nullable: true, + }, + }, + }, + }, + wantError: true, + }, + { + name: "nullable with right types", + input: apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "object": { + Type: "object", + Nullable: true, + }, + "array": { + Type: "array", + Nullable: true, + }, + }, + }, + }, + wantError: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation.go index 99ee9211650..4cf459025a0 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation.go @@ -71,6 +71,10 @@ func ConvertJSONSchemaPropsWithPostProcess(in *apiextensions.JSONSchemaProps, ou if in.Type != "" { out.Type = spec.StringOrArray([]string{in.Type}) } + if in.Nullable { + // by validation, in.Type is either "object" or "array" + out.Type = append(out.Type, "null") + } out.Format = in.Format out.Title = in.Title out.Maximum = in.Maximum diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation_test.go index ef73c008b0d..5b404571e9c 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation_test.go @@ -21,16 +21,14 @@ import ( "testing" "github.com/go-openapi/spec" - + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + apiextensionsfuzzer "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/fuzzer" + apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" "k8s.io/apimachinery/pkg/api/apitesting/fuzzer" apiequality "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/util/json" - - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" - apiextensionsfuzzer "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/fuzzer" - apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" ) // TestRoundTrip checks the conversion to go-openapi types. @@ -68,6 +66,17 @@ func TestRoundTrip(t *testing.T) { t.Fatal(err) } + // JSON -> in-memory JSON => convertNullTypeToNullable => JSON + var j interface{} + if err := json.Unmarshal(openAPIJSON, &j); err != nil { + t.Fatal(err) + } + j = convertNullTypeToNullable(j) + openAPIJSON, err = json.Marshal(j) + if err != nil { + t.Fatal(err) + } + // JSON -> external external := &apiextensionsv1beta1.JSONSchemaProps{} if err := json.Unmarshal(openAPIJSON, external); err != nil { @@ -85,3 +94,140 @@ func TestRoundTrip(t *testing.T) { } } } + +func convertNullTypeToNullable(x interface{}) interface{} { + switch x := x.(type) { + case map[string]interface{}: + if t, found := x["type"]; found { + switch t := t.(type) { + case []interface{}: + for i, typ := range t { + if s, ok := typ.(string); !ok || s != "null" { + continue + } + t = append(t[:i], t[i+1:]...) + switch len(t) { + case 0: + delete(x, "type") + case 1: + x["type"] = t[0] + default: + x["type"] = t + } + x["nullable"] = true + break + } + case string: + if t == "null" { + delete(x, "type") + x["nullable"] = true + } + } + } + for k := range x { + x[k] = convertNullTypeToNullable(x[k]) + } + return x + case []interface{}: + for i := range x { + x[i] = convertNullTypeToNullable(x[i]) + } + return x + default: + return x + } +} + +func TestNullable(t *testing.T) { + type args struct { + schema apiextensions.JSONSchemaProps + object interface{} + } + tests := []struct { + name string + args args + wantErr bool + }{ + {"!nullable against non-null", args{ + apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "field": { + Type: "object", + Nullable: false, + }, + }, + }, + map[string]interface{}{"field": map[string]interface{}{}}, + }, false}, + {"!nullable against null", args{ + apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "field": { + Type: "object", + Nullable: false, + }, + }, + }, + map[string]interface{}{"field": nil}, + }, true}, + {"!nullable against undefined", args{ + apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "field": { + Type: "object", + Nullable: false, + }, + }, + }, + map[string]interface{}{}, + }, false}, + {"nullable against non-null", args{ + apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "field": { + Type: "object", + Nullable: true, + }, + }, + }, + map[string]interface{}{"field": map[string]interface{}{}}, + }, false}, + {"nullable against null", args{ + apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "field": { + Type: "object", + Nullable: true, + }, + }, + }, + map[string]interface{}{"field": nil}, + }, false}, + {"!nullable against undefined", args{ + apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "field": { + Type: "object", + Nullable: true, + }, + }, + }, + map[string]interface{}{}, + }, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + validator, _, err := NewSchemaValidator(&apiextensions.CustomResourceValidation{OpenAPIV3Schema: &tt.args.schema}) + if err != nil { + t.Fatal(err) + } + if err := ValidateCustomResource(tt.args.object, validator); (err != nil) != tt.wantErr { + if err == nil { + t.Error("expected error, but didn't get one") + } else { + t.Errorf("unexpected validation error: %v", err) + } + } + }) + } +}