diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go index c49324affcd..842c4cc7cf4 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/customresource_handler.go @@ -614,11 +614,24 @@ func (r *crdHandler) getOrCreateServingInfoFor(uid types.UID, name string) (*crd if val == nil { continue } - structuralSchemas[v.Name], err = structuralschema.NewStructural(val.OpenAPIV3Schema) + s, err := structuralschema.NewStructural(val.OpenAPIV3Schema) if *crd.Spec.PreserveUnknownFields == false && err != nil { - utilruntime.HandleError(err) + // This should never happen. If it does, it is a programming error. + utilruntime.HandleError(fmt.Errorf("failed to convert schema to structural: %v", err)) return nil, fmt.Errorf("the server could not properly serve the CR schema") // validation should avoid this } + + if *crd.Spec.PreserveUnknownFields == false { + // we don't own s completely, e.g. defaults are not deep-copied. So better make a copy here. + s = s.DeepCopy() + + if err := structuraldefaulting.PruneDefaults(s); err != nil { + // This should never happen. If it does, it is a programming error. + utilruntime.HandleError(fmt.Errorf("failed to prune defaults: %v", err)) + return nil, fmt.Errorf("the server could not properly serve the CR schema") // validation should avoid this + } + } + structuralSchemas[v.Name] = s } for _, v := range crd.Spec.Versions { diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/BUILD b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/BUILD index 6c01389d9e5..b4ac61b9dd3 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/BUILD +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/BUILD @@ -4,6 +4,7 @@ go_library( name = "go_default_library", srcs = [ "algorithm.go", + "prune.go", "surroundingobject.go", "validation.go", ], diff --git a/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/prune.go b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/prune.go new file mode 100644 index 00000000000..45c3789aef6 --- /dev/null +++ b/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting/prune.go @@ -0,0 +1,91 @@ +/* +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 ( + "fmt" + "reflect" + + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + structuralobjectmeta "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning" + "k8s.io/apimachinery/pkg/runtime" +) + +// PruneDefaults prunes default values according to the schema and according to +// the ObjectMeta definition of the running server. It mutates the passed schema. +func PruneDefaults(s *structuralschema.Structural) error { + p := pruner{s} + _, err := p.pruneDefaults(s, NewRootObjectFunc()) + return err +} + +type pruner struct { + rootSchema *structuralschema.Structural +} + +func (p *pruner) pruneDefaults(s *structuralschema.Structural, f SurroundingObjectFunc) (changed bool, err error) { + if s == nil { + return false, nil + } + + if s.Default.Object != nil { + orig := runtime.DeepCopyJSONValue(s.Default.Object) + + obj, acc, err := f(s.Default.Object) + if err != nil { + return false, fmt.Errorf("failed to prune default value: %v", err) + } + if err := structuralobjectmeta.Coerce(nil, obj, p.rootSchema, true, true); err != nil { + return false, fmt.Errorf("failed to prune default value: %v", err) + } + pruning.Prune(obj, p.rootSchema, true) + s.Default.Object, _, err = acc(obj) + if err != nil { + return false, fmt.Errorf("failed to prune default value: %v", err) + } + + changed = changed || !reflect.DeepEqual(orig, s.Default.Object) + } + + if s.AdditionalProperties != nil && s.AdditionalProperties.Structural != nil { + c, err := p.pruneDefaults(s.AdditionalProperties.Structural, f.Child("*")) + if err != nil { + return false, err + } + changed = changed || c + } + if s.Items != nil { + c, err := p.pruneDefaults(s.Items, f.Index()) + if err != nil { + return false, err + } + changed = changed || c + } + for k, subSchema := range s.Properties { + c, err := p.pruneDefaults(&subSchema, f.Child(k)) + if err != nil { + return false, err + } + if c { + s.Properties[k] = subSchema + changed = true + } + } + + return changed, nil +} diff --git a/staging/src/k8s.io/apiextensions-apiserver/test/integration/BUILD b/staging/src/k8s.io/apiextensions-apiserver/test/integration/BUILD index 54bf33f7282..d562ce9042c 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/test/integration/BUILD +++ b/staging/src/k8s.io/apiextensions-apiserver/test/integration/BUILD @@ -36,6 +36,7 @@ go_test( "//staging/src/k8s.io/apiextensions-apiserver/pkg/cmd/server/options:go_default_library", "//staging/src/k8s.io/apiextensions-apiserver/pkg/features:go_default_library", "//staging/src/k8s.io/apiextensions-apiserver/test/integration/fixtures:go_default_library", + "//staging/src/k8s.io/apiextensions-apiserver/test/integration/storage:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/api/errors:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/api/meta:go_default_library", "//staging/src/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library", diff --git a/staging/src/k8s.io/apiextensions-apiserver/test/integration/defaulting_test.go b/staging/src/k8s.io/apiextensions-apiserver/test/integration/defaulting_test.go index 87191df0cae..1c9688f47c3 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/test/integration/defaulting_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/test/integration/defaulting_test.go @@ -18,6 +18,7 @@ package integration import ( "fmt" + "reflect" "strings" "testing" "time" @@ -32,11 +33,15 @@ import ( "k8s.io/apimachinery/pkg/util/wait" "k8s.io/apimachinery/pkg/watch" utilfeature "k8s.io/apiserver/pkg/util/feature" + "k8s.io/client-go/dynamic" utilfeaturetesting "k8s.io/component-base/featuregate/testing" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + serveroptions "k8s.io/apiextensions-apiserver/pkg/cmd/server/options" "k8s.io/apiextensions-apiserver/pkg/features" "k8s.io/apiextensions-apiserver/test/integration/fixtures" + "k8s.io/apiextensions-apiserver/test/integration/storage" ) var defaultingFixture = &apiextensionsv1.CustomResourceDefinition{ @@ -146,6 +151,13 @@ properties: default: "v1beta2" ` +const defaultingFooInstance = ` +kind: Foo +apiVersion: tests.example.com/v1beta1 +metadata: + name: foo +` + func TestCustomResourceDefaultingWithWatchCache(t *testing.T) { testDefaulting(t, true) } @@ -252,7 +264,7 @@ func testDefaulting(t *testing.T, watchCache bool) { 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.Versions[0].Name, crd.Spec.Names.Plural}) foo := &unstructured.Unstructured{} - if err := yaml.Unmarshal([]byte(fooInstance), &foo.Object); err != nil { + if err := yaml.Unmarshal([]byte(defaultingFooInstance), &foo.Object); err != nil { t.Fatal(err) } unstructured.SetNestedField(foo.Object, "a", "spec", "a") @@ -400,6 +412,275 @@ func testDefaulting(t *testing.T, watchCache bool) { mustNotExist(foo.Object, [][]string{{"spec", "c"}}) } +var metaDefaultingFixture = &apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{Name: "foos.tests.example.com"}, + Spec: apiextensionsv1.CustomResourceDefinitionSpec{ + Group: "tests.example.com", + Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ + { + Name: "v1beta1", + Storage: true, + Served: true, + Subresources: &apiextensionsv1.CustomResourceSubresources{ + Status: &apiextensionsv1.CustomResourceSubresourceStatus{}, + }, + }, + }, + Names: apiextensionsv1.CustomResourceDefinitionNames{ + Plural: "foos", + Singular: "foo", + Kind: "Foo", + ListKind: "FooList", + }, + Scope: apiextensionsv1.ClusterScoped, + PreserveUnknownFields: false, + }, +} + +const metaDefaultingFooV1beta1Schema = ` +type: object +properties: + fields: + type: object + x-kubernetes-embedded-resource: true + properties: + apiVersion: + type: string + default: foos/v1 + kind: + type: string + default: Foo + metadata: + type: object + properties: + name: + type: string + default: Bar + unknown: + type: string + default: unknown + fullMetadata: + type: object + x-kubernetes-embedded-resource: true + properties: + apiVersion: + type: string + default: foos/v1 + kind: + type: string + default: Foo + metadata: + type: object + default: + name: Bar + unknown: unknown + fullObject: + type: object + x-kubernetes-embedded-resource: true + properties: + foo: + type: string + default: + apiVersion: foos/v1 + kind: Foo + metadata: + name: Bar + unknown: unknown + spanning: + type: object + properties: + embedded: + type: object + properties: + foo: + type: string + x-kubernetes-embedded-resource: true + default: + embedded: + apiVersion: foos/v1 + kind: Foo + metadata: + name: Bar + unknown: unknown + preserve-fields: + type: object + x-kubernetes-embedded-resource: true + x-kubernetes-preserve-unknown-fields: true + properties: + apiVersion: + type: string + default: foos/v1 + kind: + type: string + default: Foo + metadata: + type: object + properties: + name: + type: string + default: Bar + unknown: + type: string + default: unknown + preserve-fullMetadata: + type: object + x-kubernetes-embedded-resource: true + x-kubernetes-preserve-unknown-fields: true + properties: + apiVersion: + type: string + default: foos/v1 + kind: + type: string + default: Foo + metadata: + type: object + default: + name: Bar + unknown: unknown + preserve-fullObject: + type: object + x-kubernetes-embedded-resource: true + x-kubernetes-preserve-unknown-fields: true + default: + apiVersion: foos/v1 + kind: Foo + metadata: + name: Bar + unknown: unknown + preserve-spanning: + type: object + properties: + embedded: + type: object + x-kubernetes-embedded-resource: true + x-kubernetes-preserve-unknown-fields: true + default: + embedded: + apiVersion: foos/v1 + kind: Foo + metadata: + name: Bar + unknown: unknown +` + +const metaDefaultingFooInstance = ` +kind: Foo +apiVersion: tests.example.com/v1beta1 +metadata: + name: foo +` + +func TestCustomResourceDefaultingOfMetaFields(t *testing.T) { + defer utilfeaturetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.CustomResourceDefaulting, true)() + + tearDown, config, options, err := fixtures.StartDefaultServer(t) + if err != nil { + t.Fatal(err) + } + apiExtensionClient, err := clientset.NewForConfig(config) + if err != nil { + tearDown() + t.Fatal(err) + } + + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + tearDown() + t.Fatal(err) + } + defer tearDown() + + crd := metaDefaultingFixture.DeepCopy() + crd.Spec.Versions[0].Schema = &apiextensionsv1.CustomResourceValidation{} + if err := yaml.Unmarshal([]byte(metaDefaultingFooV1beta1Schema), &crd.Spec.Versions[0].Schema.OpenAPIV3Schema); err != nil { + t.Fatal(err) + } + crd, err = fixtures.CreateNewV1CustomResourceDefinition(crd, apiExtensionClient, dynamicClient) + if err != nil { + t.Fatal(err) + } + + t.Logf("Creating CR and expecting defaulted, embedded objects, with the unknown ObjectMeta fields pruned") + fooClient := dynamicClient.Resource(schema.GroupVersionResource{Group: crd.Spec.Group, Version: crd.Spec.Versions[0].Name, Resource: crd.Spec.Names.Plural}) + + tests := []struct { + path []string + value interface{} + }{ + {[]string{"fields"}, map[string]interface{}{"metadata": map[string]interface{}{}}}, + {[]string{"fullMetadata"}, map[string]interface{}{}}, + {[]string{"fullObject"}, nil}, + {[]string{"spanning", "embedded"}, nil}, + {[]string{"preserve-fields"}, map[string]interface{}{"metadata": map[string]interface{}{}}}, + {[]string{"preserve-fullMetadata"}, map[string]interface{}{}}, + {[]string{"preserve-fullObject"}, nil}, + {[]string{"preserve-spanning", "embedded"}, nil}, + } + + returnedFoo := &unstructured.Unstructured{} + if err := yaml.Unmarshal([]byte(metaDefaultingFooInstance), &returnedFoo.Object); err != nil { + t.Fatal(err) + } + for _, tst := range tests { + if tst.value != nil { + if err := unstructured.SetNestedField(returnedFoo.Object, tst.value, tst.path...); err != nil { + t.Fatal(err) + } + } + } + returnedFoo, err = fooClient.Create(returnedFoo, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("Unable to create CR: %v", err) + } + t.Logf("CR created: %#v", returnedFoo.UnstructuredContent()) + + // get persisted object + RESTOptionsGetter := serveroptions.NewCRDRESTOptionsGetter(*options.RecommendedOptions.Etcd) + restOptions, err := RESTOptionsGetter.GetRESTOptions(schema.GroupResource{Group: crd.Spec.Group, Resource: crd.Spec.Names.Plural}) + if err != nil { + t.Fatal(err) + } + etcdClient, _, err := storage.GetEtcdClients(restOptions.StorageConfig.Transport) + if err != nil { + t.Fatal(err) + } + defer etcdClient.Close() + etcdObjectReader := storage.NewEtcdObjectReader(etcdClient, &restOptions, crd) + + persistedFoo, err := etcdObjectReader.GetStoredCustomResource("", returnedFoo.GetName()) + if err != nil { + t.Fatalf("Unable read CR from stored: %v", err) + } + + // check that the returned and persisted object is pruned + for _, tst := range tests { + for _, foo := range []*unstructured.Unstructured{returnedFoo, persistedFoo} { + source := "request" + if foo == persistedFoo { + source = "persisted" + } + t.Run(fmt.Sprintf("%s of %s object", strings.Join(tst.path, "."), source), func(t *testing.T) { + obj, found, err := unstructured.NestedMap(foo.Object, tst.path...) + if err != nil { + t.Fatal(err) + } + if !found { + t.Errorf("expected defaulted objected, didn't find any") + } else if expected := map[string]interface{}{ + "apiVersion": "foos/v1", + "kind": "Foo", + "metadata": map[string]interface{}{ + "name": "Bar", + }, + }; !reflect.DeepEqual(obj, expected) { + t.Errorf("unexpected defaulted object\n expected: %v\n got: %v", expected, obj) + } + }) + } + } +} + func jsonPtr(x interface{}) *apiextensionsv1.JSON { bs, err := json.Marshal(x) if err != nil { diff --git a/staging/src/k8s.io/apiextensions-apiserver/test/integration/pruning_test.go b/staging/src/k8s.io/apiextensions-apiserver/test/integration/pruning_test.go index ebde1335d23..5cfacd77758 100644 --- a/staging/src/k8s.io/apiextensions-apiserver/test/integration/pruning_test.go +++ b/staging/src/k8s.io/apiextensions-apiserver/test/integration/pruning_test.go @@ -137,7 +137,7 @@ properties: type: string ` - fooSchemaEmbeddedResourceInstance = fooInstance + ` + fooSchemaEmbeddedResourceInstance = pruningFooInstance + ` embeddedPruning: apiVersion: foo/v1 kind: Foo @@ -170,7 +170,7 @@ embeddedNested: specified: bar ` - fooInstance = ` + pruningFooInstance = ` kind: Foo apiVersion: tests.example.com/v1beta1 metadata: @@ -199,7 +199,7 @@ func TestPruningCreate(t *testing.T) { t.Logf("Creating CR and expect 'unspecified' fields to be pruned") 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 { + if err := yaml.Unmarshal([]byte(pruningFooInstance), &foo.Object); err != nil { t.Fatal(err) } unstructured.SetNestedField(foo.Object, "bar", "unspecified") @@ -251,7 +251,7 @@ func TestPruningStatus(t *testing.T) { t.Logf("Creating CR and expect 'unspecified' fields to be pruned") 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 { + if err := yaml.Unmarshal([]byte(pruningFooInstance), &foo.Object); err != nil { t.Fatal(err) } foo, err = fooClient.Create(foo, metav1.CreateOptions{}) @@ -342,7 +342,7 @@ func TestPruningFromStorage(t *testing.T) { t.Logf("Creating object with unknown field manually in etcd") original := &unstructured.Unstructured{} - if err := yaml.Unmarshal([]byte(fooInstance), &original.Object); err != nil { + if err := yaml.Unmarshal([]byte(pruningFooInstance), &original.Object); err != nil { t.Fatal(err) } unstructured.SetNestedField(original.Object, "bar", "unspecified") @@ -404,7 +404,7 @@ func TestPruningPatch(t *testing.T) { 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 { + if err := yaml.Unmarshal([]byte(pruningFooInstance), &foo.Object); err != nil { t.Fatal(err) } foo, err = fooClient.Create(foo, metav1.CreateOptions{}) @@ -457,7 +457,7 @@ func TestPruningCreatePreservingUnknownFields(t *testing.T) { t.Logf("Creating CR and expect 'unspecified' field to be pruned") 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 { + if err := yaml.Unmarshal([]byte(pruningFooInstance), &foo.Object); err != nil { t.Fatal(err) } unstructured.SetNestedField(foo.Object, "bar", "unspecified")