Merge pull request #77558 from sttts/sttts-structural-defaulting

apiextensions: implement defaulting
This commit is contained in:
Kubernetes Prow Robot 2019-05-29 11:26:13 -07:00 committed by GitHub
commit bdc665cf36
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1386 additions and 57 deletions

View File

@ -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": {

View File

@ -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
// ...

View File

@ -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;

View File

@ -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"`

View File

@ -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",
],
)

View File

@ -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
}

View File

@ -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
}

View File

@ -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",

View File

@ -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",

View File

@ -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"],

View File

@ -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"],
)

View File

@ -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
}
}

View File

@ -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())
}
})
}
}

View File

@ -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)
}

View File

@ -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},
}

View File

@ -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",

View File

@ -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
}

View File

@ -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
}

1
vendor/modules.txt vendored
View File

@ -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