diff --git a/api/openapi-spec/swagger.json b/api/openapi-spec/swagger.json index 4d379ef298a..c66df3721d2 100644 --- a/api/openapi-spec/swagger.json +++ b/api/openapi-spec/swagger.json @@ -16709,7 +16709,8 @@ "type": "array" }, "default": { - "$ref": "#/definitions/io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1beta1.JSON" + "$ref": "#/definitions/io.k8s.apiextensions-apiserver.pkg.apis.apiextensions.v1beta1.JSON", + "description": "default is a default value for undefined object fields. Defaulting is an alpha feature under the CustomResourceDefaulting feature gate. Defaulting requires spec.preserveUnknownFields to be false." }, "definitions": { "additionalProperties": { diff --git a/pkg/features/kube_features.go b/pkg/features/kube_features.go index 0bf79e5ddd7..911f8c5a4c1 100644 --- a/pkg/features/kube_features.go +++ b/pkg/features/kube_features.go @@ -533,6 +533,7 @@ var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureS apiextensionsfeatures.CustomResourceSubresources: {Default: true, PreRelease: featuregate.Beta}, apiextensionsfeatures.CustomResourceWebhookConversion: {Default: false, PreRelease: featuregate.Alpha}, apiextensionsfeatures.CustomResourcePublishOpenAPI: {Default: true, PreRelease: featuregate.Beta}, + apiextensionsfeatures.CustomResourceDefaulting: {Default: false, PreRelease: featuregate.Alpha}, // features that enable backwards compatibility but are scheduled to be removed // ... diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/generated.proto b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/generated.proto index ec030190e6e..9308988f70e 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/generated.proto +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1/generated.proto @@ -394,6 +394,9 @@ message JSONSchemaProps { optional string title = 7; + // default is a default value for undefined object fields. + // Defaulting is an alpha feature under the CustomResourceDefaulting feature gate. + // Defaulting requires spec.preserveUnknownFields to be false. optional JSON default = 8; optional double maximum = 9; 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 da5e857f110..ed893bdff57 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 @@ -18,13 +18,16 @@ package v1beta1 // JSONSchemaProps is a JSON-Schema following Specification Draft 4 (http://json-schema.org/). type JSONSchemaProps struct { - ID string `json:"id,omitempty" protobuf:"bytes,1,opt,name=id"` - Schema JSONSchemaURL `json:"$schema,omitempty" protobuf:"bytes,2,opt,name=schema"` - Ref *string `json:"$ref,omitempty" protobuf:"bytes,3,opt,name=ref"` - Description string `json:"description,omitempty" protobuf:"bytes,4,opt,name=description"` - Type string `json:"type,omitempty" protobuf:"bytes,5,opt,name=type"` - Format string `json:"format,omitempty" protobuf:"bytes,6,opt,name=format"` - Title string `json:"title,omitempty" protobuf:"bytes,7,opt,name=title"` + ID string `json:"id,omitempty" protobuf:"bytes,1,opt,name=id"` + Schema JSONSchemaURL `json:"$schema,omitempty" protobuf:"bytes,2,opt,name=schema"` + Ref *string `json:"$ref,omitempty" protobuf:"bytes,3,opt,name=ref"` + Description string `json:"description,omitempty" protobuf:"bytes,4,opt,name=description"` + Type string `json:"type,omitempty" protobuf:"bytes,5,opt,name=type"` + Format string `json:"format,omitempty" protobuf:"bytes,6,opt,name=format"` + Title string `json:"title,omitempty" protobuf:"bytes,7,opt,name=title"` + // default is a default value for undefined object fields. + // Defaulting is an alpha feature under the CustomResourceDefaulting feature gate. + // Defaulting requires spec.preserveUnknownFields to be false. Default *JSON `json:"default,omitempty" protobuf:"bytes,8,opt,name=default"` Maximum *float64 `json:"maximum,omitempty" protobuf:"bytes,9,opt,name=maximum"` ExclusiveMaximum bool `json:"exclusiveMaximum,omitempty" protobuf:"bytes,10,opt,name=exclusiveMaximum"` diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/BUILD b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/BUILD index 1cb403092cb..27d96a3177b 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/BUILD +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation/BUILD @@ -15,15 +15,19 @@ go_library( "//staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions:go_default_library", "//staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1:go_default_library", "//staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema:go_default_library", + "//staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning:go_default_library", "//staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation:go_default_library", "//staging/src/k8s.io/apiextensions-apiserver/pkg/features:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/api/equality:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/api/validation:go_default_library", + "//staging/src/k8s.io/apimachinery/pkg/runtime:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/util/sets:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/util/validation:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/util/validation/field:go_default_library", "//staging/src/k8s.io/apiserver/pkg/util/feature:go_default_library", "//staging/src/k8s.io/apiserver/pkg/util/webhook:go_default_library", + "//vendor/github.com/go-openapi/strfmt:go_default_library", + "//vendor/github.com/go-openapi/validate:go_default_library", ], ) @@ -33,8 +37,18 @@ go_test( embed = [":go_default_library"], deps = [ "//staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions:go_default_library", + "//staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/fuzzer:go_default_library", + "//staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1:go_default_library", + "//staging/src/k8s.io/apiextensions-apiserver/pkg/features:go_default_library", + "//staging/src/k8s.io/apimachinery/pkg/api/apitesting/fuzzer:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library", + "//staging/src/k8s.io/apimachinery/pkg/runtime:go_default_library", + "//staging/src/k8s.io/apimachinery/pkg/runtime/serializer:go_default_library", + "//staging/src/k8s.io/apimachinery/pkg/util/json:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/util/validation/field:go_default_library", + "//staging/src/k8s.io/apiserver/pkg/util/feature:go_default_library", + "//staging/src/k8s.io/component-base/featuregate:go_default_library", + "//staging/src/k8s.io/component-base/featuregate/testing:go_default_library", "//vendor/k8s.io/utils/pointer:go_default_library", ], ) 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 1986930d6b1..be134821a4c 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 @@ -21,9 +21,12 @@ import ( "reflect" "strings" - structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "github.com/go-openapi/strfmt" + govalidate "github.com/go-openapi/validate" + apiequality "k8s.io/apimachinery/pkg/api/equality" genericvalidation "k8s.io/apimachinery/pkg/api/validation" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/sets" utilvalidation "k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/validation/field" @@ -32,6 +35,8 @@ import ( "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning" apiservervalidation "k8s.io/apiextensions-apiserver/pkg/apiserver/validation" apiextensionsfeatures "k8s.io/apiextensions-apiserver/pkg/features" ) @@ -103,9 +108,9 @@ func ValidateUpdateCustomResourceDefinitionStatus(obj, oldObj *apiextensions.Cus } // ValidateCustomResourceDefinitionVersion statically validates. -func ValidateCustomResourceDefinitionVersion(version *apiextensions.CustomResourceDefinitionVersion, fldPath *field.Path, mustBeStructural, statusEnabled bool) field.ErrorList { +func ValidateCustomResourceDefinitionVersion(version *apiextensions.CustomResourceDefinitionVersion, fldPath *field.Path, mustBeStructural, statusEnabled, allowDefaults bool) field.ErrorList { allErrs := field.ErrorList{} - allErrs = append(allErrs, ValidateCustomResourceDefinitionValidation(version.Schema, mustBeStructural, statusEnabled, fldPath.Child("schema"))...) + allErrs = append(allErrs, ValidateCustomResourceDefinitionValidation(version.Schema, mustBeStructural, statusEnabled, allowDefaults, fldPath.Child("schema"))...) allErrs = append(allErrs, ValidateCustomResourceDefinitionSubresources(version.Subresources, fldPath.Child("subresources"))...) for i := range version.AdditionalPrinterColumns { allErrs = append(allErrs, ValidateCustomResourceColumnDefinition(&version.AdditionalPrinterColumns[i], fldPath.Child("additionalPrinterColumns").Index(i))...) @@ -115,10 +120,11 @@ func ValidateCustomResourceDefinitionVersion(version *apiextensions.CustomResour // ValidateCustomResourceDefinitionSpec statically validates func ValidateCustomResourceDefinitionSpec(spec *apiextensions.CustomResourceDefinitionSpec, fldPath *field.Path) field.ErrorList { - return validateCustomResourceDefinitionSpec(spec, true, fldPath) + allowDefaults := utilfeature.DefaultFeatureGate.Enabled(apiextensionsfeatures.CustomResourceDefaulting) + return validateCustomResourceDefinitionSpec(spec, true, allowDefaults, fldPath) } -func validateCustomResourceDefinitionSpec(spec *apiextensions.CustomResourceDefinitionSpec, requireRecognizedVersion bool, fldPath *field.Path) field.ErrorList { +func validateCustomResourceDefinitionSpec(spec *apiextensions.CustomResourceDefinitionSpec, requireRecognizedVersion, allowDefaults bool, fldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} if len(spec.Group) == 0 { @@ -144,6 +150,12 @@ func validateCustomResourceDefinitionSpec(spec *apiextensions.CustomResourceDefi } } } + if allowDefaults && specHasDefaults(spec) { + mustBeStructural = true + if spec.PreserveUnknownFields == nil || *spec.PreserveUnknownFields == true { + allErrs = append(allErrs, field.Invalid(fldPath.Child("preserveUnknownFields"), true, "must be false in order to use defaults in the schema")) + } + } storageFlagCount := 0 versionsMap := map[string]bool{} @@ -161,7 +173,7 @@ func validateCustomResourceDefinitionSpec(spec *apiextensions.CustomResourceDefi allErrs = append(allErrs, field.Invalid(fldPath.Child("versions").Index(i).Child("name"), spec.Versions[i].Name, strings.Join(errs, ","))) } subresources := getSubresourcesForVersion(spec, version.Name) - allErrs = append(allErrs, ValidateCustomResourceDefinitionVersion(&version, fldPath.Child("versions").Index(i), mustBeStructural, hasStatusEnabled(subresources))...) + allErrs = append(allErrs, ValidateCustomResourceDefinitionVersion(&version, fldPath.Child("versions").Index(i), mustBeStructural, hasStatusEnabled(subresources), allowDefaults)...) } // The top-level and per-version fields are mutual exclusive @@ -216,7 +228,7 @@ func validateCustomResourceDefinitionSpec(spec *apiextensions.CustomResourceDefi } allErrs = append(allErrs, ValidateCustomResourceDefinitionNames(&spec.Names, fldPath.Child("names"))...) - allErrs = append(allErrs, ValidateCustomResourceDefinitionValidation(spec.Validation, mustBeStructural, hasAnyStatusEnabled(spec), fldPath.Child("validation"))...) + allErrs = append(allErrs, ValidateCustomResourceDefinitionValidation(spec.Validation, mustBeStructural, hasAnyStatusEnabled(spec), allowDefaults, fldPath.Child("validation"))...) allErrs = append(allErrs, ValidateCustomResourceDefinitionSubresources(spec.Subresources, fldPath.Child("subresources"))...) for i := range spec.AdditionalPrinterColumns { @@ -343,7 +355,11 @@ func validateCustomResourceConversion(conversion *apiextensions.CustomResourceCo // ValidateCustomResourceDefinitionSpecUpdate statically validates func ValidateCustomResourceDefinitionSpecUpdate(spec, oldSpec *apiextensions.CustomResourceDefinitionSpec, established bool, fldPath *field.Path) field.ErrorList { requireRecognizedVersion := oldSpec.Conversion == nil || hasValidConversionReviewVersionOrEmpty(oldSpec.Conversion.ConversionReviewVersions) - allErrs := validateCustomResourceDefinitionSpec(spec, requireRecognizedVersion, fldPath) + + // find out whether any schema had default before. Then we keep allowing it. + allowDefaults := utilfeature.DefaultFeatureGate.Enabled(apiextensionsfeatures.CustomResourceDefaulting) || specHasDefaults(oldSpec) + + allErrs := validateCustomResourceDefinitionSpec(spec, requireRecognizedVersion, allowDefaults, fldPath) if established { // these effect the storage and cannot be changed therefore @@ -546,7 +562,7 @@ type specStandardValidator interface { } // ValidateCustomResourceDefinitionValidation statically validates -func ValidateCustomResourceDefinitionValidation(customResourceValidation *apiextensions.CustomResourceValidation, mustBeStructural, statusSubresourceEnabled bool, fldPath *field.Path) field.ErrorList { +func ValidateCustomResourceDefinitionValidation(customResourceValidation *apiextensions.CustomResourceValidation, mustBeStructural, statusSubresourceEnabled, allowDefaults bool, fldPath *field.Path) field.ErrorList { allErrs := field.ErrorList{} if customResourceValidation == nil { @@ -586,7 +602,9 @@ func ValidateCustomResourceDefinitionValidation(customResourceValidation *apiext allErrs = append(allErrs, field.Forbidden(fldPath.Child("openAPIV3Schema.nullable"), fmt.Sprintf(`nullable cannot be true at the root`))) } - openAPIV3Schema := &specStandardValidatorV3{} + openAPIV3Schema := &specStandardValidatorV3{ + allowDefaults: allowDefaults, + } allErrs = append(allErrs, ValidateCustomResourceDefinitionOpenAPISchema(schema, fldPath.Child("openAPIV3Schema"), openAPIV3Schema)...) if mustBeStructural { @@ -706,7 +724,9 @@ func ValidateCustomResourceDefinitionOpenAPISchema(schema *apiextensions.JSONSch return allErrs } -type specStandardValidatorV3 struct{} +type specStandardValidatorV3 struct { + allowDefaults bool +} // validate validates against OpenAPI Schema v3. func (v *specStandardValidatorV3) validate(schema *apiextensions.JSONSchemaProps, fldPath *field.Path) field.ErrorList { @@ -721,7 +741,24 @@ func (v *specStandardValidatorV3) validate(schema *apiextensions.JSONSchemaProps // if schema.Default != nil { - allErrs = append(allErrs, field.Forbidden(fldPath.Child("default"), "default is not supported")) + if v.allowDefaults { + if s, err := structuralschema.NewStructural(schema); err == nil { + // ignore errors here locally. They will show up for the root of the schema. + pruned := runtime.DeepCopyJSONValue(*schema.Default) + pruning.Prune(pruned, s) + if !reflect.DeepEqual(pruned, *schema.Default) { + allErrs = append(allErrs, field.Invalid(fldPath.Child("default"), schema.Default, "must not have unspecified fields")) + } + + // validate the default value. Only validating and pruned defaults are allowed. + validator := govalidate.NewSchemaValidator(s.ToGoOpenAPI(), nil, "", strfmt.Default) + if err := apiservervalidation.ValidateCustomResource(pruned, validator); err != nil { + allErrs = append(allErrs, field.Invalid(fldPath.Child("default"), schema.Default, fmt.Sprintf("must validate: %v", err))) + } + } + } else { + allErrs = append(allErrs, field.Forbidden(fldPath.Child("default"), "must not be set")) + } } if schema.ID != "" { @@ -830,3 +867,86 @@ func allowedAtRootSchema(field string) bool { } return false } + +func specHasDefaults(spec *apiextensions.CustomResourceDefinitionSpec) bool { + if spec.Validation != nil && schemaHasDefaults(spec.Validation.OpenAPIV3Schema) { + return true + } + for _, v := range spec.Versions { + if v.Schema != nil && schemaHasDefaults(v.Schema.OpenAPIV3Schema) { + return true + } + } + return false +} + +func schemaHasDefaults(s *apiextensions.JSONSchemaProps) bool { + if s == nil { + return false + } + + if s.Default != nil { + return true + } + + if s.Items != nil { + if s.Items != nil && schemaHasDefaults(s.Items.Schema) { + return true + } + for _, s := range s.Items.JSONSchemas { + if schemaHasDefaults(&s) { + return true + } + } + } + for _, s := range s.AllOf { + if schemaHasDefaults(&s) { + return true + } + } + for _, s := range s.AnyOf { + if schemaHasDefaults(&s) { + return true + } + } + for _, s := range s.OneOf { + if schemaHasDefaults(&s) { + return true + } + } + if schemaHasDefaults(s.Not) { + return true + } + for _, s := range s.Properties { + if schemaHasDefaults(&s) { + return true + } + } + if s.AdditionalProperties != nil { + if schemaHasDefaults(s.AdditionalProperties.Schema) { + return true + } + } + for _, s := range s.PatternProperties { + if schemaHasDefaults(&s) { + return true + } + } + if s.AdditionalItems != nil { + if schemaHasDefaults(s.AdditionalItems.Schema) { + return true + } + } + for _, s := range s.Definitions { + if schemaHasDefaults(&s) { + return true + } + } + for _, d := range s.Dependencies { + if schemaHasDefaults(d.Schema) { + return true + } + } + + return false +} 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 14bc3be9f96..475f6110950 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 @@ -17,11 +17,23 @@ limitations under the License. package validation import ( + "math/rand" + "strings" "testing" "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/apiextensions-apiserver/pkg/features" + "k8s.io/apimachinery/pkg/api/apitesting/fuzzer" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer" + "k8s.io/apimachinery/pkg/util/json" "k8s.io/apimachinery/pkg/util/validation/field" + utilfeature "k8s.io/apiserver/pkg/util/feature" + "k8s.io/component-base/featuregate" + featuregatetesting "k8s.io/component-base/featuregate/testing" "k8s.io/utils/pointer" ) @@ -64,9 +76,10 @@ func TestValidateCustomResourceDefinition(t *testing.T) { }, } tests := []struct { - name string - resource *apiextensions.CustomResourceDefinition - errors []validationMatch + name string + resource *apiextensions.CustomResourceDefinition + errors []validationMatch + enabledFeatures []featuregate.Feature }{ { name: "webhookconfig: invalid port 0", @@ -1239,10 +1252,325 @@ func TestValidateCustomResourceDefinition(t *testing.T) { invalid("spec", "versions[3]", "subresources", "scale", "labelSelectorPath"), }, }, + { + name: "defaults with disabled feature gate", + resource: &apiextensions.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "plural.group.com"}, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "group.com", + Version: "version", + Versions: singleVersionList, + Scope: apiextensions.NamespaceScoped, + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "plural", + Singular: "singular", + Kind: "Plural", + ListKind: "PluralList", + }, + Validation: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "a": {Default: jsonPtr(42.0)}, + }, + }, + }, + PreserveUnknownFields: pointer.BoolPtr(true), + }, + Status: apiextensions.CustomResourceDefinitionStatus{ + StoredVersions: []string{"version"}, + }, + }, + errors: []validationMatch{ + forbidden("spec", "validation", "openAPIV3Schema", "properties[a]", "default"), // disabled feature-gate + }, + }, + { + name: "defaults with enabled feature gate, unstructural schema", + resource: &apiextensions.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "plural.group.com"}, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "group.com", + Version: "version", + Versions: singleVersionList, + Scope: apiextensions.NamespaceScoped, + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "plural", + Singular: "singular", + Kind: "Plural", + ListKind: "PluralList", + }, + Validation: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "a": {Default: jsonPtr(42.0)}, + }, + }, + }, + PreserveUnknownFields: pointer.BoolPtr(false), + }, + Status: apiextensions.CustomResourceDefinitionStatus{ + StoredVersions: []string{"version"}, + }, + }, + errors: []validationMatch{ + required("spec", "validation", "openAPIV3Schema", "properties[a]", "type"), + required("spec", "validation", "openAPIV3Schema", "type"), + }, + enabledFeatures: []featuregate.Feature{features.CustomResourceDefaulting}, + }, + { + name: "defaults with enabled feature gate, structural schema", + resource: &apiextensions.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "plural.group.com"}, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "group.com", + Version: "version", + Versions: singleVersionList, + Scope: apiextensions.NamespaceScoped, + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "plural", + Singular: "singular", + Kind: "Plural", + ListKind: "PluralList", + }, + Validation: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "a": { + Type: "number", + Default: jsonPtr(42.0), + }, + }, + }, + }, + PreserveUnknownFields: pointer.BoolPtr(false), + }, + Status: apiextensions.CustomResourceDefinitionStatus{ + StoredVersions: []string{"version"}, + }, + }, + errors: []validationMatch{}, + enabledFeatures: []featuregate.Feature{features.CustomResourceDefaulting}, + }, + { + name: "defaults in value validation with enabled feature gate, structural schema", + resource: &apiextensions.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "plural.group.com"}, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "group.com", + Version: "version", + Versions: singleVersionList, + Scope: apiextensions.NamespaceScoped, + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "plural", + Singular: "singular", + Kind: "Plural", + ListKind: "PluralList", + }, + Validation: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "a": { + Type: "number", + Not: &apiextensions.JSONSchemaProps{ + Default: jsonPtr(42.0), + }, + AnyOf: []apiextensions.JSONSchemaProps{ + { + Default: jsonPtr(42.0), + }, + }, + AllOf: []apiextensions.JSONSchemaProps{ + { + Default: jsonPtr(42.0), + }, + }, + OneOf: []apiextensions.JSONSchemaProps{ + { + Default: jsonPtr(42.0), + }, + }, + }, + }, + }, + }, + PreserveUnknownFields: pointer.BoolPtr(false), + }, + Status: apiextensions.CustomResourceDefinitionStatus{ + StoredVersions: []string{"version"}, + }, + }, + errors: []validationMatch{ + forbidden("spec", "validation", "openAPIV3Schema", "properties[a]", "not", "default"), + forbidden("spec", "validation", "openAPIV3Schema", "properties[a]", "allOf[0]", "default"), + forbidden("spec", "validation", "openAPIV3Schema", "properties[a]", "anyOf[0]", "default"), + forbidden("spec", "validation", "openAPIV3Schema", "properties[a]", "oneOf[0]", "default"), + }, + enabledFeatures: []featuregate.Feature{features.CustomResourceDefaulting}, + }, + { + name: "invalid defaults with enabled feature gate, structural schema", + resource: &apiextensions.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "plural.group.com"}, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "group.com", + Version: "version", + Versions: singleVersionList, + Scope: apiextensions.NamespaceScoped, + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "plural", + Singular: "singular", + Kind: "Plural", + ListKind: "PluralList", + }, + Validation: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "a": { + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "foo": { + Type: "string", + }, + }, + Default: jsonPtr(map[string]interface{}{ + "foo": "abc", + "bar": int64(42.0), + }), + }, + "b": { + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "foo": { + Type: "string", + }, + }, + Default: jsonPtr(map[string]interface{}{ + "foo": "abc", + }), + }, + "c": { + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "foo": { + Type: "string", + }, + }, + Default: jsonPtr(map[string]interface{}{ + "foo": int64(42), + }), + }, + "d": { + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "good": { + Type: "string", + Pattern: "a", + }, + "bad": { + Type: "string", + Pattern: "+", + }, + }, + Default: jsonPtr(map[string]interface{}{ + "good": "a", + "bad": "a", + }), + }, + "e": { + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "preserveUnknownFields": { + Type: "object", + Default: jsonPtr(map[string]interface{}{ + "foo": "abc", + // this is under x-kubernetes-preserve-unknown-fields + "bar": int64(42.0), + }), + }, + "nestedProperties": { + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "foo": { + Type: "string", + }, + }, + Default: jsonPtr(map[string]interface{}{ + "foo": "abc", + "bar": int64(42.0), + }), + }, + }, + XPreserveUnknownFields: pointer.BoolPtr(true), + }, + }, + }, + }, + PreserveUnknownFields: pointer.BoolPtr(false), + }, + Status: apiextensions.CustomResourceDefinitionStatus{ + StoredVersions: []string{"version"}, + }, + }, + errors: []validationMatch{ + invalid("spec", "validation", "openAPIV3Schema", "properties[a]", "default"), + invalid("spec", "validation", "openAPIV3Schema", "properties[c]", "default"), + invalid("spec", "validation", "openAPIV3Schema", "properties[d]", "default"), + // we also expected unpruned and valid defaults under x-kubernetes-preserve-unknown-fields. We could be more + // strict here, but want to encourage proper specifications by forbidding other defaults. + invalid("spec", "validation", "openAPIV3Schema", "properties[e]", "properties[preserveUnknownFields]", "default"), + invalid("spec", "validation", "openAPIV3Schema", "properties[e]", "properties[nestedProperties]", "default"), + }, + + enabledFeatures: []featuregate.Feature{features.CustomResourceDefaulting}, + }, + { + name: "defaults with enabled feature gate, structural schema, without pruning", + resource: &apiextensions.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "plural.group.com"}, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "group.com", + Version: "version", + Versions: singleVersionList, + Scope: apiextensions.NamespaceScoped, + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "plural", + Singular: "singular", + Kind: "Plural", + ListKind: "PluralList", + }, + Validation: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "a": { + Type: "number", + Default: jsonPtr(42.0), + }, + }, + }, + }, + PreserveUnknownFields: pointer.BoolPtr(true), + }, + Status: apiextensions.CustomResourceDefinitionStatus{ + StoredVersions: []string{"version"}, + }, + }, + errors: []validationMatch{ + invalid("spec", "preserveUnknownFields"), + }, + enabledFeatures: []featuregate.Feature{features.CustomResourceDefaulting}, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { + for _, gate := range tc.enabledFeatures { + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, gate, true)() + } // duplicate defaulting behaviour if tc.resource.Spec.Conversion != nil && tc.resource.Spec.Conversion.Strategy == apiextensions.WebhookConverter && len(tc.resource.Spec.Conversion.ConversionReviewVersions) == 0 { tc.resource.Spec.Conversion.ConversionReviewVersions = []string{"v1beta1"} @@ -1276,10 +1604,11 @@ func TestValidateCustomResourceDefinition(t *testing.T) { func TestValidateCustomResourceDefinitionUpdate(t *testing.T) { tests := []struct { - name string - old *apiextensions.CustomResourceDefinition - resource *apiextensions.CustomResourceDefinition - errors []validationMatch + name string + old *apiextensions.CustomResourceDefinition + resource *apiextensions.CustomResourceDefinition + errors []validationMatch + enabledFeatures []featuregate.Feature }{ { name: "webhookconfig: should pass on invalid ConversionReviewVersion with old invalid versions", @@ -2180,32 +2509,206 @@ func TestValidateCustomResourceDefinitionUpdate(t *testing.T) { }, errors: []validationMatch{}, }, + { + name: "setting defaults with enabled feature gate", + old: &apiextensions.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "plural.group.com", + ResourceVersion: "42", + }, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "group.com", + Version: "version", + Versions: []apiextensions.CustomResourceDefinitionVersion{ + { + Name: "version", + Served: true, + Storage: true, + }, + }, + Scope: apiextensions.NamespaceScoped, + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "plural", + Singular: "singular", + Kind: "Plural", + ListKind: "PluralList", + }, + Validation: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "a": { + Type: "number", + }, + }, + }, + }, + PreserveUnknownFields: pointer.BoolPtr(false), + }, + Status: apiextensions.CustomResourceDefinitionStatus{ + StoredVersions: []string{"version"}, + }, + }, + resource: &apiextensions.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "plural.group.com", + ResourceVersion: "42", + }, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "group.com", + Version: "version", + Versions: []apiextensions.CustomResourceDefinitionVersion{ + { + Name: "version", + Served: true, + Storage: true, + }, + }, + Scope: apiextensions.NamespaceScoped, + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "plural", + Singular: "singular", + Kind: "Plural", + ListKind: "PluralList", + }, + Validation: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "a": { + Type: "number", + Default: jsonPtr(42.0), + }, + }, + }, + }, + PreserveUnknownFields: pointer.BoolPtr(false), + }, + Status: apiextensions.CustomResourceDefinitionStatus{ + StoredVersions: []string{"version"}, + }, + }, + errors: []validationMatch{}, + enabledFeatures: []featuregate.Feature{features.CustomResourceDefaulting}, + }, + { + name: "ratcheting validation of defaults with disabled feature gate", + old: &apiextensions.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "plural.group.com", + ResourceVersion: "42", + }, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "group.com", + Version: "version", + Versions: []apiextensions.CustomResourceDefinitionVersion{ + { + Name: "version", + Served: true, + Storage: true, + }, + }, + Scope: apiextensions.NamespaceScoped, + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "plural", + Singular: "singular", + Kind: "Plural", + ListKind: "PluralList", + }, + Validation: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "a": { + Type: "number", + Default: jsonPtr(42.0), + }, + }, + }, + }, + PreserveUnknownFields: pointer.BoolPtr(false), + }, + Status: apiextensions.CustomResourceDefinitionStatus{ + StoredVersions: []string{"version"}, + }, + }, + resource: &apiextensions.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "plural.group.com", + ResourceVersion: "42", + }, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "group.com", + Version: "version", + Versions: []apiextensions.CustomResourceDefinitionVersion{ + { + Name: "version", + Served: true, + Storage: true, + }, + }, + Scope: apiextensions.NamespaceScoped, + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "plural", + Singular: "singular", + Kind: "Plural", + ListKind: "PluralList", + }, + Validation: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "a": { + Type: "number", + Default: jsonPtr(42.0), + }, + "b": { + Type: "number", + Default: jsonPtr(43.0), + }, + }, + }, + }, + PreserveUnknownFields: pointer.BoolPtr(false), + }, + Status: apiextensions.CustomResourceDefinitionStatus{ + StoredVersions: []string{"version"}, + }, + }, + errors: []validationMatch{}, + }, } for _, tc := range tests { - errs := ValidateCustomResourceDefinitionUpdate(tc.resource, tc.old) - seenErrs := make([]bool, len(errs)) + t.Run(tc.name, func(t *testing.T) { + for _, gate := range tc.enabledFeatures { + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, gate, true)() + } - for _, expectedError := range tc.errors { - found := false - for i, err := range errs { - if expectedError.matches(err) && !seenErrs[i] { - found = true - seenErrs[i] = true - break + errs := ValidateCustomResourceDefinitionUpdate(tc.resource, tc.old) + seenErrs := make([]bool, len(errs)) + + for _, expectedError := range tc.errors { + found := false + for i, err := range errs { + if expectedError.matches(err) && !seenErrs[i] { + found = true + seenErrs[i] = true + break + } + } + + if !found { + t.Errorf("expected %v at %v, got %v", expectedError.errorType, expectedError.path.String(), errs) } } - if !found { - t.Errorf("%s: expected %v at %v, got %v", tc.name, expectedError.errorType, expectedError.path.String(), errs) + for i, seen := range seenErrs { + if !seen { + t.Errorf("unexpected error: %v", errs[i]) + } } - } - - for i, seen := range seenErrs { - if !seen { - t.Errorf("%s: unexpected error: %v", tc.name, errs[i]) - } - } + }) } } @@ -2356,7 +2859,7 @@ func TestValidateCustomResourceDefinitionValidation(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := ValidateCustomResourceDefinitionValidation(&tt.input, tt.mustBeStructural, tt.statusEnabled, field.NewPath("spec", "validation")) + got := ValidateCustomResourceDefinitionValidation(&tt.input, tt.mustBeStructural, tt.statusEnabled, false, field.NewPath("spec", "validation")) if !tt.wantError && len(got) > 0 { t.Errorf("Expected no error, but got: %v", got) } else if tt.wantError && len(got) == 0 { @@ -2366,6 +2869,40 @@ func TestValidateCustomResourceDefinitionValidation(t *testing.T) { } } +func TestSchemaHasDefaults(t *testing.T) { + scheme := runtime.NewScheme() + codecs := serializer.NewCodecFactory(scheme) + if err := apiextensions.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + + seed := rand.Int63() + t.Logf("seed: %d", seed) + fuzzerFuncs := fuzzer.MergeFuzzerFuncs(apiextensionsfuzzer.Funcs) + f := fuzzer.FuzzerFor(fuzzerFuncs, rand.NewSource(seed), codecs) + + for i := 0; i < 10000; i++ { + // fuzz internal types + schema := &apiextensions.JSONSchemaProps{} + f.Fuzz(schema) + + v1beta1Schema := &apiextensionsv1beta1.JSONSchemaProps{} + if err := apiextensionsv1beta1.Convert_apiextensions_JSONSchemaProps_To_v1beta1_JSONSchemaProps(schema, v1beta1Schema, nil); err != nil { + t.Fatal(err) + } + + bs, err := json.Marshal(v1beta1Schema) + if err != nil { + t.Fatal(err) + } + + expected := strings.Contains(strings.Replace(string(bs), `"default":null`, `"deleted":null`, -1), `"default":`) + if got := schemaHasDefaults(schema); got != expected { + t.Errorf("expected %v, got %v for: %s", expected, got, string(bs)) + } + } +} + var example = apiextensions.JSON(`"This is an example"`) var validValidationSchema = &apiextensions.JSONSchemaProps{ @@ -2442,3 +2979,8 @@ func float64Ptr(f float64) *float64 { func int64Ptr(f int64) *int64 { return &f } + +func jsonPtr(x interface{}) *apiextensions.JSON { + ret := apiextensions.JSON(x) + return &ret +} diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/BUILD b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/BUILD index 1c2dd779f40..dbd44f4f64b 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/BUILD +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/BUILD @@ -24,6 +24,7 @@ go_library( "//staging/src/k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1:go_default_library", "//staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/conversion:go_default_library", "//staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema:go_default_library", + "//staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting:go_default_library", "//staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning:go_default_library", "//staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation:go_default_library", "//staging/src/k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset:go_default_library", diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go index 4471e4cc5f9..a110a3a9b2a 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go @@ -31,6 +31,7 @@ import ( "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" "k8s.io/apiextensions-apiserver/pkg/apiserver/conversion" structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + structuraldefaulting "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting" structuralpruning "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning" apiservervalidation "k8s.io/apiextensions-apiserver/pkg/apiserver/validation" informers "k8s.io/apiextensions-apiserver/pkg/client/informers/internalversion/apiextensions/internalversion" @@ -645,7 +646,7 @@ func (r *crdHandler) getOrCreateServingInfoFor(crd *apiextensions.CustomResource Creater: creator, Convertor: safeConverter, - Defaulter: unstructuredDefaulter{parameterScheme}, + Defaulter: unstructuredDefaulter{parameterScheme, structuralSchemas, kind.GroupKind()}, Typer: typer, UnsafeConvertor: unsafeConverter, @@ -771,7 +772,11 @@ func (s unstructuredNegotiatedSerializer) EncoderForVersion(encoder runtime.Enco func (s unstructuredNegotiatedSerializer) DecoderToVersion(decoder runtime.Decoder, gv runtime.GroupVersioner) runtime.Decoder { d := schemaCoercingDecoder{delegate: decoder, validator: unstructuredSchemaCoercer{structuralSchemas: s.structuralSchemas, structuralSchemaGK: s.structuralSchemaGK, preserveUnknownFields: s.preserveUnknownFields}} - return versioning.NewDefaultingCodecForScheme(Scheme, nil, d, nil, gv) + return versioning.NewCodec(nil, d, runtime.UnsafeObjectConvertor(Scheme), Scheme, Scheme, unstructuredDefaulter{ + delegate: Scheme, + structuralSchemas: s.structuralSchemas, + structuralSchemaGK: s.structuralSchemaGK, + }, nil, gv, "unstructuredNegotiatedSerializer") } type UnstructuredObjectTyper struct { @@ -807,14 +812,20 @@ func (c unstructuredCreator) New(kind schema.GroupVersionKind) (runtime.Object, } type unstructuredDefaulter struct { - delegate runtime.ObjectDefaulter + delegate runtime.ObjectDefaulter + structuralSchemas map[string]*structuralschema.Structural // by version + structuralSchemaGK schema.GroupKind } func (d unstructuredDefaulter) Default(in runtime.Object) { - // Delegate for things other than Unstructured. - if _, ok := in.(runtime.Unstructured); !ok { + // Delegate for things other than Unstructured, and other GKs + u, ok := in.(runtime.Unstructured) + if !ok || u.GetObjectKind().GroupVersionKind().GroupKind() != d.structuralSchemaGK { d.delegate.Default(in) + return } + + structuraldefaulting.Default(u.UnstructuredContent(), d.structuralSchemas[u.GetObjectKind().GroupVersionKind().Version]) } type CRDRESTOptionsGetter struct { @@ -888,7 +899,11 @@ func (t crdConversionRESTOptionsGetter) GetRESTOptions(resource schema.GroupReso c, &unstructuredCreator{}, crdserverscheme.NewUnstructuredObjectTyper(), - &unstructuredDefaulter{delegate: Scheme}, + &unstructuredDefaulter{ + delegate: Scheme, + structuralSchemaGK: t.structuralSchemaGK, + structuralSchemas: t.structuralSchemas, + }, t.encoderVersion, t.decoderVersion, "crdRESTOptions", diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/BUILD b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/BUILD index 6717f67507f..1ff62e23a24 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/BUILD +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/BUILD @@ -34,6 +34,7 @@ filegroup( name = "all-srcs", srcs = [ ":package-srcs", + "//staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting:all-srcs", "//staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning:all-srcs", ], tags = ["automanaged"], diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/BUILD b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/BUILD new file mode 100644 index 00000000000..b2168fca3a8 --- /dev/null +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/BUILD @@ -0,0 +1,37 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["algorithm.go"], + importmap = "k8s.io/kubernetes/vendor/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting", + importpath = "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting", + visibility = ["//visibility:public"], + deps = [ + "//staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema:go_default_library", + "//staging/src/k8s.io/apimachinery/pkg/runtime:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["algorithm_test.go"], + embed = [":go_default_library"], + deps = [ + "//staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema:go_default_library", + "//staging/src/k8s.io/apimachinery/pkg/util/json:go_default_library", + ], +) + +filegroup( + name = "package-srcs", + srcs = glob(["**"]), + tags = ["automanaged"], + visibility = ["//visibility:private"], +) + +filegroup( + name = "all-srcs", + srcs = [":package-srcs"], + tags = ["automanaged"], + visibility = ["//visibility:public"], +) diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/algorithm.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/algorithm.go new file mode 100644 index 00000000000..2c699898e11 --- /dev/null +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/algorithm.go @@ -0,0 +1,55 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package defaulting + +import ( + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apimachinery/pkg/runtime" +) + +// Default does defaulting of x depending on default values in s. +// Default values from s are deep-copied. +func Default(x interface{}, s *structuralschema.Structural) { + if s == nil { + return + } + + switch x := x.(type) { + case map[string]interface{}: + for k, prop := range s.Properties { + if prop.Default.Object == nil { + continue + } + if _, found := x[k]; !found { + x[k] = runtime.DeepCopyJSONValue(prop.Default.Object) + } + } + for k, v := range x { + if prop, found := s.Properties[k]; found { + Default(v, &prop) + } else if s.AdditionalProperties != nil { + Default(v, s.AdditionalProperties.Structural) + } + } + case []interface{}: + for _, v := range x { + Default(v, s.Items) + } + default: + // scalars, do nothing + } +} diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/algorithm_test.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/algorithm_test.go new file mode 100644 index 00000000000..ceef3c91399 --- /dev/null +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/algorithm_test.go @@ -0,0 +1,165 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package defaulting + +import ( + "bytes" + "reflect" + "testing" + + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apimachinery/pkg/util/json" +) + +func TestDefault(t *testing.T) { + tests := []struct { + name string + json string + schema *structuralschema.Structural + expected string + }{ + {"empty", "null", nil, "null"}, + {"scalar", "4", &structuralschema.Structural{ + Generic: structuralschema.Generic{ + Default: structuralschema.JSON{"foo"}, + }, + }, "4"}, + {"scalar array", "[1,2]", &structuralschema.Structural{ + Items: &structuralschema.Structural{ + Generic: structuralschema.Generic{ + Default: structuralschema.JSON{"foo"}, + }, + }, + }, "[1,2]"}, + {"object array", `[{"a":1},{"b":1},{"c":1}]`, &structuralschema.Structural{ + Items: &structuralschema.Structural{ + Properties: map[string]structuralschema.Structural{ + "a": { + Generic: structuralschema.Generic{ + Default: structuralschema.JSON{"A"}, + }, + }, + "b": { + Generic: structuralschema.Generic{ + Default: structuralschema.JSON{"B"}, + }, + }, + "c": { + Generic: structuralschema.Generic{ + Default: structuralschema.JSON{"C"}, + }, + }, + }, + }, + }, `[{"a":1,"b":"B","c":"C"},{"a":"A","b":1,"c":"C"},{"a":"A","b":"B","c":1}]`}, + {"object array object", `{"array":[{"a":1},{"b":2}],"object":{"a":1},"additionalProperties":{"x":{"a":1},"y":{"b":2}}}`, &structuralschema.Structural{ + Properties: map[string]structuralschema.Structural{ + "array": { + Items: &structuralschema.Structural{ + Properties: map[string]structuralschema.Structural{ + "a": { + Generic: structuralschema.Generic{ + Default: structuralschema.JSON{"A"}, + }, + }, + "b": { + Generic: structuralschema.Generic{ + Default: structuralschema.JSON{"B"}, + }, + }, + }, + }, + }, + "object": { + Properties: map[string]structuralschema.Structural{ + "a": { + Generic: structuralschema.Generic{ + Default: structuralschema.JSON{"N"}, + }, + }, + "b": { + Generic: structuralschema.Generic{ + Default: structuralschema.JSON{"O"}, + }, + }, + }, + }, + "additionalProperties": { + Generic: structuralschema.Generic{ + AdditionalProperties: &structuralschema.StructuralOrBool{ + Structural: &structuralschema.Structural{ + Properties: map[string]structuralschema.Structural{ + "a": { + Generic: structuralschema.Generic{ + Default: structuralschema.JSON{"alpha"}, + }, + }, + "b": { + Generic: structuralschema.Generic{ + Default: structuralschema.JSON{"beta"}, + }, + }, + }, + }, + }, + }, + }, + "foo": { + Generic: structuralschema.Generic{ + Default: structuralschema.JSON{"bar"}, + }, + }, + }, + }, `{"array":[{"a":1,"b":"B"},{"a":"A","b":2}],"object":{"a":1,"b":"O"},"additionalProperties":{"x":{"a":1,"b":"beta"},"y":{"a":"alpha","b":2}},"foo":"bar"}`}, + {"empty and null", `[{},{"a":1},{"a":0},{"a":0.0},{"a":""},{"a":null},{"a":[]},{"a":{}}]`, &structuralschema.Structural{ + Items: &structuralschema.Structural{ + Properties: map[string]structuralschema.Structural{ + "a": { + Generic: structuralschema.Generic{ + Default: structuralschema.JSON{"A"}, + }, + }, + }, + }, + }, `[{"a":"A"},{"a":1},{"a":0},{"a":0.0},{"a":""},{"a":null},{"a":[]},{"a":{}}]`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var in interface{} + if err := json.Unmarshal([]byte(tt.json), &in); err != nil { + t.Fatal(err) + } + + var expected interface{} + if err := json.Unmarshal([]byte(tt.expected), &expected); err != nil { + t.Fatal(err) + } + + Default(in, tt.schema) + if !reflect.DeepEqual(in, expected) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + err := enc.Encode(in) + if err != nil { + t.Fatalf("unexpected result mashalling error: %v", err) + } + t.Errorf("expected: %s\ngot: %s", tt.expected, buf.String()) + } + }) + } +} diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning/algorithm.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning/algorithm.go index eb03eb6b8ca..c5432cb263d 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning/algorithm.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning/algorithm.go @@ -21,7 +21,7 @@ import ( ) // Prune removes object fields in obj which are not specified in s. -func Prune(obj map[string]interface{}, s *structuralschema.Structural) { +func Prune(obj interface{}, s *structuralschema.Structural) { prune(obj, s) } diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/features/kube_features.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/features/kube_features.go index 72ff90993ca..e5ed44f3cbf 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/features/kube_features.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/features/kube_features.go @@ -53,6 +53,12 @@ const ( // // CustomResourceWebhookConversion defines the webhook conversion for Custom Resources. CustomResourceWebhookConversion featuregate.Feature = "CustomResourceWebhookConversion" + + // owner: @sttts + // alpha: v1.15 + // + // CustomResourceDefaulting enables OpenAPI defaulting in CustomResources. + CustomResourceDefaulting featuregate.Feature = "CustomResourceDefaulting" ) func init() { @@ -67,4 +73,5 @@ var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureS CustomResourceSubresources: {Default: true, PreRelease: featuregate.Beta}, CustomResourceWebhookConversion: {Default: false, PreRelease: featuregate.Alpha}, CustomResourcePublishOpenAPI: {Default: true, PreRelease: featuregate.Beta}, + CustomResourceDefaulting: {Default: false, PreRelease: featuregate.Alpha}, } diff --git a/staging/src/k8s.io/apiextensions-apiserver/test/integration/BUILD b/staging/src/k8s.io/apiextensions-apiserver/test/integration/BUILD index 252307c8e06..20bcfa4cb76 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/test/integration/BUILD +++ b/staging/src/k8s.io/apiextensions-apiserver/test/integration/BUILD @@ -12,6 +12,7 @@ go_test( "apply_test.go", "basic_test.go", "change_test.go", + "defaulting_test.go", "finalization_test.go", "objectmeta_test.go", "pruning_test.go", diff --git a/staging/src/k8s.io/apiextensions-apiserver/test/integration/conversion/conversion_test.go b/staging/src/k8s.io/apiextensions-apiserver/test/integration/conversion/conversion_test.go index e0c77cb8b06..be28d93609b 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/test/integration/conversion/conversion_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/test/integration/conversion/conversion_test.go @@ -59,14 +59,18 @@ func checks(checkers ...Checker) []Checker { } func TestWebhookConverter(t *testing.T) { - testWebhookConverter(t, false) + testWebhookConverter(t, false, false) } func TestWebhookConverterWithPruning(t *testing.T) { - testWebhookConverter(t, true) + testWebhookConverter(t, true, false) } -func testWebhookConverter(t *testing.T, pruning bool) { +func TestWebhookConverterWithDefaulting(t *testing.T) { + testWebhookConverter(t, true, true) +} + +func testWebhookConverter(t *testing.T, pruning, defaulting bool) { tests := []struct { group string handler http.Handler @@ -80,7 +84,7 @@ func testWebhookConverter(t *testing.T, pruning bool) { { group: "nontrivial-converter", handler: NewObjectConverterWebhookHandler(t, nontrivialConverter), - checks: checks(validateStorageVersion, validateServed, validateMixedStorageVersions("v1alpha1", "v1beta1", "v1beta2"), validateNonTrivialConverted, validateNonTrivialConvertedList, validateStoragePruning), + checks: checks(validateStorageVersion, validateServed, validateMixedStorageVersions("v1alpha1", "v1beta1", "v1beta2"), validateNonTrivialConverted, validateNonTrivialConvertedList, validateStoragePruning, validateDefaulting), }, { group: "metadata-mutating-converter", @@ -110,7 +114,12 @@ func testWebhookConverter(t *testing.T, pruning bool) { etcd3watcher.TestOnlySetFatalOnDecodeError(false) defer etcd3watcher.TestOnlySetFatalOnDecodeError(true) + // enable necessary features defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, apiextensionsfeatures.CustomResourceWebhookConversion, true)() + if defaulting { + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, apiextensionsfeatures.CustomResourceDefaulting, true)() + } + tearDown, config, options, err := fixtures.StartDefaultServer(t) if err != nil { t.Fatal(err) @@ -132,6 +141,12 @@ func testWebhookConverter(t *testing.T, pruning bool) { crd := multiVersionFixture.DeepCopy() crd.Spec.PreserveUnknownFields = pointer.BoolPtr(!pruning) + if !defaulting { + for i := range crd.Spec.Versions { + delete(crd.Spec.Versions[i].Schema.OpenAPIV3Schema.Properties, "defaults") + } + } + RESTOptionsGetter := serveroptions.NewCRDRESTOptionsGetter(*options.RecommendedOptions.Etcd) restOptions, err := RESTOptionsGetter.GetRESTOptions(schema.GroupResource{Group: crd.Spec.Group, Resource: crd.Spec.Names.Plural}) if err != nil { @@ -520,6 +535,91 @@ func validateUIDMutation(t *testing.T, ctc *conversionTestContext) { } } +func validateDefaulting(t *testing.T, ctc *conversionTestContext) { + if _, defaulting := ctc.crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["defaults"]; !defaulting { + return + } + + ns := ctc.namespace + storageVersion := "v1beta1" + + for _, createVersion := range ctc.crd.Spec.Versions { + t.Run(fmt.Sprintf("getting objects created as %s", createVersion.Name), func(t *testing.T) { + name := "defaulting-" + createVersion.Name + client := ctc.versionedClient(ns, createVersion.Name) + + fixture := newConversionMultiVersionFixture(ns, name, createVersion.Name) + if err := unstructured.SetNestedField(fixture.Object, map[string]interface{}{}, "defaults"); err != nil { + t.Fatal(err) + } + created, err := client.Create(fixture, metav1.CreateOptions{}) + if err != nil { + t.Fatal(err) + } + + // check that defaulting happens + // - in the request version when doing no-op conversion when deserializing + // - when reading back from storage in the storage version + // only the first is persisted. + defaults, found, err := unstructured.NestedMap(created.Object, "defaults") + if err != nil { + t.Fatal(err) + } else if !found { + t.Fatalf("expected .defaults to exist") + } + expectedLen := 1 + if !createVersion.Storage { + expectedLen++ + } + if len(defaults) != expectedLen { + t.Fatalf("after %s create expected .defaults to have %d values, but got: %v", createVersion.Name, expectedLen, defaults) + } + if _, found := defaults[createVersion.Name].(bool); !found { + t.Errorf("after %s create expected .defaults[%s] to be true, but .defaults is: %v", createVersion.Name, createVersion.Name, defaults) + } + if _, found := defaults[storageVersion].(bool); !found { + t.Errorf("after %s create expected .defaults[%s] to be true because it is the storage version, but .defaults is: %v", createVersion.Name, storageVersion, defaults) + } + + // verify that only the request version default is persisted + persisted, err := ctc.etcdObjectReader.GetStoredCustomResource(ns, name) + if err != nil { + t.Fatal(err) + } + if _, found, err := unstructured.NestedBool(persisted.Object, "defaults", storageVersion); err != nil { + t.Fatal(err) + } else if createVersion.Name != storageVersion && found { + t.Errorf("after %s create .defaults[storage version %s] not to be persisted, but got in etcd: %v", createVersion.Name, storageVersion, defaults) + } + + // check that when reading any other version, we do not default that version, but only the (non-persisted) storage version default + for _, v := range ctc.crd.Spec.Versions { + if v.Name == createVersion.Name { + // create version is persisted anyway, nothing to verify + continue + } + + got, err := ctc.versionedClient(ns, v.Name).Get(created.GetName(), metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + + if _, found, err := unstructured.NestedBool(got.Object, "defaults", v.Name); err != nil { + t.Fatal(err) + } else if v.Name != storageVersion && found { + t.Errorf("after %s GET expected .defaults[%s] not to be true because only storage version %s is defaulted on read, but .defaults is: %v", v.Name, v.Name, storageVersion, defaults) + } + + if _, found, err := unstructured.NestedBool(got.Object, "defaults", storageVersion); err != nil { + t.Fatal(err) + } else if !found { + t.Errorf("after non-create, non-storage %s GET expected .defaults[storage version %s] to be true, but .defaults is: %v", v.Name, storageVersion, defaults) + } + } + }) + } +} + func expectConversionFailureMessage(id, message string) func(t *testing.T, ctc *conversionTestContext) { return func(t *testing.T, ctc *conversionTestContext) { ns := ctc.namespace @@ -918,6 +1018,14 @@ var multiVersionFixture = &apiextensionsv1beta1.CustomResourceDefinition{ "num2": {Type: "integer"}, }, }, + "defaults": { + Type: "object", + Properties: map[string]apiextensionsv1beta1.JSONSchemaProps{ + "v1alpha1": {Type: "boolean"}, + "v1beta1": {Type: "boolean", Default: jsonPtr(true)}, + "v1beta2": {Type: "boolean"}, + }, + }, }, }, }, @@ -944,6 +1052,14 @@ var multiVersionFixture = &apiextensionsv1beta1.CustomResourceDefinition{ "num2": {Type: "integer"}, }, }, + "defaults": { + Type: "object", + Properties: map[string]apiextensionsv1beta1.JSONSchemaProps{ + "v1alpha1": {Type: "boolean", Default: jsonPtr(true)}, + "v1beta1": {Type: "boolean"}, + "v1beta2": {Type: "boolean"}, + }, + }, }, }, }, @@ -970,6 +1086,14 @@ var multiVersionFixture = &apiextensionsv1beta1.CustomResourceDefinition{ "num2": {Type: "integer"}, }, }, + "defaults": { + Type: "object", + Properties: map[string]apiextensionsv1beta1.JSONSchemaProps{ + "v1alpha1": {Type: "boolean"}, + "v1beta1": {Type: "boolean"}, + "v1beta2": {Type: "boolean", Default: jsonPtr(true)}, + }, + }, }, }, }, @@ -1089,3 +1213,12 @@ func closeOnCall(h http.Handler) (chan struct{}, http.Handler) { h.ServeHTTP(w, r) }) } + +func jsonPtr(x interface{}) *apiextensionsv1beta1.JSON { + bs, err := json.Marshal(x) + if err != nil { + panic(err) + } + ret := apiextensionsv1beta1.JSON{Raw: bs} + return &ret +} diff --git a/staging/src/k8s.io/apiextensions-apiserver/test/integration/defaulting_test.go b/staging/src/k8s.io/apiextensions-apiserver/test/integration/defaulting_test.go new file mode 100644 index 00000000000..b9c5749ab0c --- /dev/null +++ b/staging/src/k8s.io/apiextensions-apiserver/test/integration/defaulting_test.go @@ -0,0 +1,229 @@ +/* +Copyright 2019 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package integration + +import ( + "strings" + "testing" + "time" + + "sigs.k8s.io/yaml" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/json" + "k8s.io/apimachinery/pkg/util/wait" + utilfeature "k8s.io/apiserver/pkg/util/feature" + utilfeaturetesting "k8s.io/component-base/featuregate/testing" + "k8s.io/utils/pointer" + + apiextensionsv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apiextensions-apiserver/pkg/features" + "k8s.io/apiextensions-apiserver/test/integration/fixtures" +) + +var defaultingFixture = &apiextensionsv1beta1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "foos.tests.apiextensions.k8s.io"}, + Spec: apiextensionsv1beta1.CustomResourceDefinitionSpec{ + Group: "tests.apiextensions.k8s.io", + Version: "v1beta1", + Names: apiextensionsv1beta1.CustomResourceDefinitionNames{ + Plural: "foos", + Singular: "foo", + Kind: "Foo", + ListKind: "FooList", + }, + Scope: apiextensionsv1beta1.ClusterScoped, + PreserveUnknownFields: pointer.BoolPtr(false), + Subresources: &apiextensionsv1beta1.CustomResourceSubresources{ + Status: &apiextensionsv1beta1.CustomResourceSubresourceStatus{}, + }, + }, +} + +const defaultingFooSchema = ` +type: object +properties: + spec: + type: object + properties: + a: + type: string + default: "A" + b: + type: string + default: "B" + status: + type: object + properties: + a: + type: string + default: "A" + b: + type: string + default: "B" +` + +func TestCustomResourceDefaulting(t *testing.T) { + defer utilfeaturetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.CustomResourceDefaulting, true)() + + tearDownFn, apiExtensionClient, dynamicClient, err := fixtures.StartDefaultServerWithClients(t) + if err != nil { + t.Fatal(err) + } + defer tearDownFn() + + crd := defaultingFixture.DeepCopy() + crd.Spec.Validation = &apiextensionsv1beta1.CustomResourceValidation{} + if err := yaml.Unmarshal([]byte(defaultingFooSchema), &crd.Spec.Validation.OpenAPIV3Schema); err != nil { + t.Fatal(err) + } + + crd, err = fixtures.CreateNewCustomResourceDefinition(crd, apiExtensionClient, dynamicClient) + if err != nil { + t.Fatal(err) + } + + mustExist := func(obj map[string]interface{}, pths [][]string) { + for _, pth := range pths { + if _, found, _ := unstructured.NestedFieldNoCopy(obj, pth...); !found { + t.Errorf("Expected '%s' field exist", strings.Join(pth, ".")) + } + } + } + mustNotExist := func(obj map[string]interface{}, pths [][]string) { + for _, pth := range pths { + if fld, found, _ := unstructured.NestedFieldNoCopy(obj, pth...); found { + t.Errorf("Expected '%s' field to not exist, but it does: %v", strings.Join(pth, "."), fld) + } + } + } + updateCRD := func(update func(*apiextensionsv1beta1.CustomResourceDefinition)) { + var err error + for retry := 0; retry < 10; retry++ { + var obj *apiextensionsv1beta1.CustomResourceDefinition + obj, err = apiExtensionClient.ApiextensionsV1beta1().CustomResourceDefinitions().Get(crd.Name, metav1.GetOptions{}) + if err != nil { + t.Fatal(err) + } + update(obj) + obj, err = apiExtensionClient.ApiextensionsV1beta1().CustomResourceDefinitions().Update(obj) + if err != nil && apierrors.IsConflict(err) { + continue + } else if err != nil { + t.Fatal(err) + } + crd = obj + break + } + if err != nil { + t.Fatal(err) + } + } + addDefault := func(key string, value interface{}) { + updateCRD(func(obj *apiextensionsv1beta1.CustomResourceDefinition) { + for _, root := range []string{"spec", "status"} { + obj.Spec.Validation.OpenAPIV3Schema.Properties[root].Properties[key] = apiextensionsv1beta1.JSONSchemaProps{ + Type: "string", + Default: jsonPtr(value), + } + } + }) + } + removeDefault := func(key string) { + updateCRD(func(obj *apiextensionsv1beta1.CustomResourceDefinition) { + for _, root := range []string{"spec", "status"} { + props := obj.Spec.Validation.OpenAPIV3Schema.Properties[root].Properties[key] + props.Default = nil + obj.Spec.Validation.OpenAPIV3Schema.Properties[root].Properties[key] = props + } + }) + } + + t.Logf("Creating CR and expecting defaulted fields in spec, but status does not exist at all") + fooClient := dynamicClient.Resource(schema.GroupVersionResource{crd.Spec.Group, crd.Spec.Version, crd.Spec.Names.Plural}) + foo := &unstructured.Unstructured{} + if err := yaml.Unmarshal([]byte(fooInstance), &foo.Object); err != nil { + t.Fatal(err) + } + unstructured.SetNestedField(foo.Object, "a", "spec", "a") + unstructured.SetNestedField(foo.Object, "b", "status", "b") + foo, err = fooClient.Create(foo, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Unable to create CR: %v", err) + } + t.Logf("CR created: %#v", foo.UnstructuredContent()) + mustExist(foo.Object, [][]string{{"spec", "a"}, {"spec", "b"}}) + mustNotExist(foo.Object, [][]string{{"status"}}) + + t.Logf("Updating status and expecting 'a' and 'b' to show up.") + unstructured.SetNestedField(foo.Object, map[string]interface{}{}, "status") + if foo, err = fooClient.UpdateStatus(foo, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + mustExist(foo.Object, [][]string{{"spec", "a"}, {"spec", "b"}, {"status", "a"}, {"status", "b"}}) + + t.Logf("Add 'c' default and wait until GET sees it in both status and spec") + addDefault("c", "C") + if err := wait.PollImmediate(100*time.Millisecond, wait.ForeverTestTimeout, func() (bool, error) { + obj, err := fooClient.Get(foo.GetName(), metav1.GetOptions{}) + if err != nil { + return false, err + } + _, found, _ := unstructured.NestedString(obj.Object, "spec", "c") + foo = obj + return found, nil + }); err != nil { + t.Fatal(err) + } + mustExist(foo.Object, [][]string{{"spec", "a"}, {"spec", "b"}, {"spec", "c"}, {"status", "a"}, {"status", "b"}, {"status", "c"}}) + + t.Logf("Updating status, expecting 'c' to be set in spec and status") + if foo, err = fooClient.UpdateStatus(foo, metav1.UpdateOptions{}); err != nil { + t.Fatal(err) + } + mustExist(foo.Object, [][]string{{"spec", "a"}, {"spec", "b"}, {"spec", "c"}, {"status", "a"}, {"status", "b"}, {"status", "c"}}) + + t.Logf("Removing 'a', 'b' and `c` properties. Expecting that 'c' goes away in spec, but not in status. 'a' and 'b' were peristed.") + removeDefault("a") + removeDefault("b") + removeDefault("c") + if err := wait.PollImmediate(100*time.Millisecond, wait.ForeverTestTimeout, func() (bool, error) { + obj, err := fooClient.Get(foo.GetName(), metav1.GetOptions{}) + if err != nil { + return false, err + } + _, found, _ := unstructured.NestedString(obj.Object, "spec", "c") + foo = obj + return !found, nil + }); err != nil { + t.Fatal(err) + } + mustExist(foo.Object, [][]string{{"spec", "a"}, {"spec", "b"}, {"status", "a"}, {"status", "b"}, {"status", "c"}}) + mustNotExist(foo.Object, [][]string{{"spec", "c"}}) +} + +func jsonPtr(x interface{}) *apiextensionsv1beta1.JSON { + bs, err := json.Marshal(x) + if err != nil { + panic(err) + } + ret := apiextensionsv1beta1.JSON{Raw: bs} + return &ret +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 2fd3db5a816..ee910ce07f3 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1073,6 +1073,7 @@ k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation k8s.io/apiextensions-apiserver/pkg/apiserver k8s.io/apiextensions-apiserver/pkg/apiserver/conversion k8s.io/apiextensions-apiserver/pkg/apiserver/schema +k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning k8s.io/apiextensions-apiserver/pkg/apiserver/validation k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset