Unverified Commit bdc665cf authored by Kubernetes Prow Robot's avatar Kubernetes Prow Robot Committed by GitHub

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

apiextensions: implement defaulting
parents cc11d977 cbd19204
......@@ -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": {
......@@ -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
// ...
......
......@@ -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;
......
......@@ -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"`
......
......@@ -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",
],
)
......
......@@ -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",
......
......@@ -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",
......
......@@ -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"],
......
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"],
)
/*
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
}
}
/*
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())
}
})
}
}
......@@ -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)
}
......
......@@ -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},
}
......@@ -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",
......
......@@ -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
}
/*
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
}
......@@ -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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment