From 337fc9ccde54b0da3b084d69807ec770d12f33da Mon Sep 17 00:00:00 2001 From: Antoine Pelisse Date: Wed, 16 Jan 2019 21:14:42 -0800 Subject: [PATCH] API Machinery, Kubectl and tests Kubernetes-commit: 0e1d50e70fdc9ed838d75a7a1abbe5fa607d22a1 --- pkg/endpoints/handlers/create.go | 14 + .../handlers/fieldmanager/fieldmanager.go | 174 +++++++++++ .../handlers/fieldmanager/internal/fields.go | 95 ++++++ .../fieldmanager/internal/fields_test.go | 109 +++++++ .../fieldmanager/internal/gvkparser.go | 116 ++++++++ .../fieldmanager/internal/managedfields.go | 119 ++++++++ .../internal/managedfields_test.go | 143 +++++++++ .../fieldmanager/internal/pathelement.go | 140 +++++++++ .../fieldmanager/internal/pathelement_test.go | 84 ++++++ .../fieldmanager/internal/typeconverter.go | 99 +++++++ .../internal/typeconverter_test.go | 109 +++++++ .../fieldmanager/internal/versionconverter.go | 83 ++++++ .../internal/versionconverter_test.go | 107 +++++++ pkg/endpoints/handlers/patch.go | 275 ++++++++++++++---- pkg/endpoints/handlers/rest.go | 7 +- pkg/endpoints/handlers/rest_test.go | 23 +- pkg/endpoints/handlers/update.go | 10 +- pkg/endpoints/handlers/watch.go | 0 pkg/endpoints/installer.go | 29 +- pkg/endpoints/patchhandler_test.go | 176 +++++++++++ pkg/features/kube_features.go | 7 + pkg/registry/rest/create.go | 8 + pkg/registry/rest/update.go | 8 + pkg/server/genericapiserver.go | 141 +++++---- 24 files changed, 1946 insertions(+), 130 deletions(-) create mode 100644 pkg/endpoints/handlers/fieldmanager/fieldmanager.go create mode 100644 pkg/endpoints/handlers/fieldmanager/internal/fields.go create mode 100644 pkg/endpoints/handlers/fieldmanager/internal/fields_test.go create mode 100644 pkg/endpoints/handlers/fieldmanager/internal/gvkparser.go create mode 100644 pkg/endpoints/handlers/fieldmanager/internal/managedfields.go create mode 100644 pkg/endpoints/handlers/fieldmanager/internal/managedfields_test.go create mode 100644 pkg/endpoints/handlers/fieldmanager/internal/pathelement.go create mode 100644 pkg/endpoints/handlers/fieldmanager/internal/pathelement_test.go create mode 100644 pkg/endpoints/handlers/fieldmanager/internal/typeconverter.go create mode 100644 pkg/endpoints/handlers/fieldmanager/internal/typeconverter_test.go create mode 100644 pkg/endpoints/handlers/fieldmanager/internal/versionconverter.go create mode 100644 pkg/endpoints/handlers/fieldmanager/internal/versionconverter_test.go mode change 100755 => 100644 pkg/endpoints/handlers/watch.go diff --git a/pkg/endpoints/handlers/create.go b/pkg/endpoints/handlers/create.go index c28e44b95..9a2f80e37 100644 --- a/pkg/endpoints/handlers/create.go +++ b/pkg/endpoints/handlers/create.go @@ -133,6 +133,20 @@ func createHandler(r rest.NamedCreater, scope RequestScope, admit admission.Inte } } + if scope.FieldManager != nil { + liveObj, err := scope.Creater.New(scope.Kind) + if err != nil { + scope.err(fmt.Errorf("failed to create new object: %v", err), w, req) + return + } + + obj, err = scope.FieldManager.Update(liveObj, obj, "create") + if err != nil { + scope.err(fmt.Errorf("failed to update object managed fields: %v", err), w, req) + return + } + } + trace.Step("About to store object in database") result, err := finishRequest(timeout, func() (runtime.Object, error) { return r.Create( diff --git a/pkg/endpoints/handlers/fieldmanager/fieldmanager.go b/pkg/endpoints/handlers/fieldmanager/fieldmanager.go new file mode 100644 index 000000000..9e21d03ac --- /dev/null +++ b/pkg/endpoints/handlers/fieldmanager/fieldmanager.go @@ -0,0 +1,174 @@ +/* +Copyright 2018 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 fieldmanager + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal" + openapiproto "k8s.io/kube-openapi/pkg/util/proto" + "sigs.k8s.io/structured-merge-diff/fieldpath" + "sigs.k8s.io/structured-merge-diff/merge" +) + +const applyManager = "apply" + +// FieldManager updates the managed fields and merge applied +// configurations. +type FieldManager struct { + typeConverter internal.TypeConverter + objectConverter runtime.ObjectConvertor + objectDefaulter runtime.ObjectDefaulter + groupVersion schema.GroupVersion + hubVersion schema.GroupVersion + updater merge.Updater +} + +// NewFieldManager creates a new FieldManager that merges apply requests +// and update managed fields for other types of requests. +func NewFieldManager(models openapiproto.Models, objectConverter runtime.ObjectConvertor, objectDefaulter runtime.ObjectDefaulter, gv schema.GroupVersion, hub schema.GroupVersion) (*FieldManager, error) { + typeConverter, err := internal.NewTypeConverter(models) + if err != nil { + return nil, err + } + return &FieldManager{ + typeConverter: typeConverter, + objectConverter: objectConverter, + objectDefaulter: objectDefaulter, + groupVersion: gv, + hubVersion: hub, + updater: merge.Updater{ + Converter: internal.NewVersionConverter(typeConverter, objectConverter, hub), + }, + }, nil +} + +// Update is used when the object has already been merged (non-apply +// use-case), and simply updates the managed fields in the output +// object. +func (f *FieldManager) Update(liveObj, newObj runtime.Object, manager string) (runtime.Object, error) { + managed, err := internal.DecodeObjectManagedFields(newObj) + // If the managed field is empty or we failed to decode it, + // let's try the live object + if err != nil || len(managed) == 0 { + managed, err = internal.DecodeObjectManagedFields(liveObj) + if err != nil { + return nil, fmt.Errorf("failed to decode managed fields: %v", err) + } + } + newObjVersioned, err := f.toVersioned(newObj) + if err != nil { + return nil, fmt.Errorf("failed to convert new object to proper version: %v", err) + } + liveObjVersioned, err := f.toVersioned(liveObj) + if err != nil { + return nil, fmt.Errorf("failed to convert live object to proper version: %v", err) + } + if err := internal.RemoveObjectManagedFields(liveObjVersioned); err != nil { + return nil, fmt.Errorf("failed to remove managed fields from live obj: %v", err) + } + if err := internal.RemoveObjectManagedFields(newObjVersioned); err != nil { + return nil, fmt.Errorf("failed to remove managed fields from new obj: %v", err) + } + + newObjTyped, err := f.typeConverter.ObjectToTyped(newObjVersioned) + if err != nil { + return nil, fmt.Errorf("failed to create typed new object: %v", err) + } + liveObjTyped, err := f.typeConverter.ObjectToTyped(liveObjVersioned) + if err != nil { + return nil, fmt.Errorf("failed to create typed live object: %v", err) + } + apiVersion := fieldpath.APIVersion(f.groupVersion.String()) + managed, err = f.updater.Update(liveObjTyped, newObjTyped, apiVersion, managed, manager) + if err != nil { + return nil, fmt.Errorf("failed to update ManagedFields: %v", err) + } + + if err := internal.EncodeObjectManagedFields(newObj, managed); err != nil { + return nil, fmt.Errorf("failed to encode managed fields: %v", err) + } + + return newObj, nil +} + +// Apply is used when server-side apply is called, as it merges the +// object and update the managed fields. +func (f *FieldManager) Apply(liveObj runtime.Object, patch []byte, force bool) (runtime.Object, error) { + managed, err := internal.DecodeObjectManagedFields(liveObj) + if err != nil { + return nil, fmt.Errorf("failed to decode managed fields: %v", err) + } + // We can assume that patchObj is already on the proper version: + // it shouldn't have to be converted so that it's not defaulted. + liveObjVersioned, err := f.toVersioned(liveObj) + if err != nil { + return nil, fmt.Errorf("failed to convert live object to proper version: %v", err) + } + if err := internal.RemoveObjectManagedFields(liveObjVersioned); err != nil { + return nil, fmt.Errorf("failed to remove managed fields from live obj: %v", err) + } + + patchObjTyped, err := f.typeConverter.YAMLToTyped(patch) + if err != nil { + return nil, fmt.Errorf("failed to create typed patch object: %v", err) + } + liveObjTyped, err := f.typeConverter.ObjectToTyped(liveObjVersioned) + if err != nil { + return nil, fmt.Errorf("failed to create typed live object: %v", err) + } + apiVersion := fieldpath.APIVersion(f.groupVersion.String()) + newObjTyped, managed, err := f.updater.Apply(liveObjTyped, patchObjTyped, apiVersion, managed, applyManager, force) + if err != nil { + if conflicts, ok := err.(merge.Conflicts); ok { + return nil, errors.NewApplyConflict(conflicts) + } + return nil, err + } + + newObj, err := f.typeConverter.TypedToObject(newObjTyped) + if err != nil { + return nil, fmt.Errorf("failed to convert new typed object to object: %v", err) + } + + if err := internal.EncodeObjectManagedFields(newObj, managed); err != nil { + return nil, fmt.Errorf("failed to encode managed fields: %v", err) + } + + newObjVersioned, err := f.toVersioned(newObj) + if err != nil { + return nil, fmt.Errorf("failed to convert new object to proper version: %v", err) + } + f.objectDefaulter.Default(newObjVersioned) + + newObjUnversioned, err := f.toUnversioned(newObjVersioned) + if err != nil { + return nil, fmt.Errorf("failed to convert to unversioned: %v", err) + } + return newObjUnversioned, nil +} + +func (f *FieldManager) toVersioned(obj runtime.Object) (runtime.Object, error) { + return f.objectConverter.ConvertToVersion(obj, f.groupVersion) +} + +func (f *FieldManager) toUnversioned(obj runtime.Object) (runtime.Object, error) { + return f.objectConverter.ConvertToVersion(obj, f.hubVersion) +} diff --git a/pkg/endpoints/handlers/fieldmanager/internal/fields.go b/pkg/endpoints/handlers/fieldmanager/internal/fields.go new file mode 100644 index 000000000..4fbf52c8b --- /dev/null +++ b/pkg/endpoints/handlers/fieldmanager/internal/fields.go @@ -0,0 +1,95 @@ +/* +Copyright 2018 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 internal + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/structured-merge-diff/fieldpath" +) + +func newFields() metav1.Fields { + return metav1.Fields{Map: map[string]metav1.Fields{}} +} + +func fieldsSet(f metav1.Fields, path fieldpath.Path, set *fieldpath.Set) error { + if len(f.Map) == 0 { + set.Insert(path) + } + for k := range f.Map { + if k == "." { + set.Insert(path) + continue + } + pe, err := NewPathElement(k) + if err != nil { + return err + } + path = append(path, pe) + err = fieldsSet(f.Map[k], path, set) + if err != nil { + return err + } + path = path[:len(path)-1] + } + return nil +} + +// FieldsToSet creates a set paths from an input trie of fields +func FieldsToSet(f metav1.Fields) (fieldpath.Set, error) { + set := fieldpath.Set{} + return set, fieldsSet(f, fieldpath.Path{}, &set) +} + +func removeUselessDots(f metav1.Fields) metav1.Fields { + if _, ok := f.Map["."]; ok && len(f.Map) == 1 { + delete(f.Map, ".") + return f + } + for k, tf := range f.Map { + f.Map[k] = removeUselessDots(tf) + } + return f +} + +// SetToFields creates a trie of fields from an input set of paths +func SetToFields(s fieldpath.Set) (metav1.Fields, error) { + var err error + f := newFields() + s.Iterate(func(path fieldpath.Path) { + if err != nil { + return + } + tf := f + for _, pe := range path { + var str string + str, err = PathElementString(pe) + if err != nil { + break + } + if _, ok := tf.Map[str]; ok { + tf = tf.Map[str] + } else { + tf.Map[str] = newFields() + tf = tf.Map[str] + } + } + tf.Map["."] = newFields() + }) + f = removeUselessDots(f) + return f, err +} diff --git a/pkg/endpoints/handlers/fieldmanager/internal/fields_test.go b/pkg/endpoints/handlers/fieldmanager/internal/fields_test.go new file mode 100644 index 000000000..681c22cbc --- /dev/null +++ b/pkg/endpoints/handlers/fieldmanager/internal/fields_test.go @@ -0,0 +1,109 @@ +/* +Copyright 2018 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 internal + +import ( + "reflect" + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/structured-merge-diff/fieldpath" +) + +// TestFieldsRoundTrip tests that a fields trie can be round tripped as a path set +func TestFieldsRoundTrip(t *testing.T) { + tests := []metav1.Fields{ + { + Map: map[string]metav1.Fields{ + "f:metadata": { + Map: map[string]metav1.Fields{ + ".": newFields(), + "f:name": newFields(), + }, + }, + }, + }, + } + + for _, test := range tests { + set, err := FieldsToSet(test) + if err != nil { + t.Fatalf("Failed to create path set: %v", err) + } + output, err := SetToFields(set) + if err != nil { + t.Fatalf("Failed to create fields trie from path set: %v", err) + } + if !reflect.DeepEqual(test, output) { + t.Fatalf("Expected round-trip:\ninput: %v\noutput: %v", test, output) + } + } +} + +// TestFieldsToSetError tests that errors are picked up by FieldsToSet +func TestFieldsToSetError(t *testing.T) { + tests := []struct { + fields metav1.Fields + errString string + }{ + { + fields: metav1.Fields{ + Map: map[string]metav1.Fields{ + "k:{invalid json}": { + Map: map[string]metav1.Fields{ + ".": newFields(), + "f:name": newFields(), + }, + }, + }, + }, + errString: "invalid character", + }, + } + + for _, test := range tests { + _, err := FieldsToSet(test.fields) + if err == nil || !strings.Contains(err.Error(), test.errString) { + t.Fatalf("Expected error to contain %q but got: %v", test.errString, err) + } + } +} + +// TestSetToFieldsError tests that errors are picked up by SetToFields +func TestSetToFieldsError(t *testing.T) { + validName := "ok" + invalidPath := fieldpath.Path([]fieldpath.PathElement{{}, {FieldName: &validName}}) + + tests := []struct { + set fieldpath.Set + errString string + }{ + { + set: *fieldpath.NewSet(invalidPath), + errString: "Invalid type of path element", + }, + } + + for _, test := range tests { + _, err := SetToFields(test.set) + if err == nil || !strings.Contains(err.Error(), test.errString) { + t.Fatalf("Expected error to contain %q but got: %v", test.errString, err) + } + } +} diff --git a/pkg/endpoints/handlers/fieldmanager/internal/gvkparser.go b/pkg/endpoints/handlers/fieldmanager/internal/gvkparser.go new file mode 100644 index 000000000..ce9a04924 --- /dev/null +++ b/pkg/endpoints/handlers/fieldmanager/internal/gvkparser.go @@ -0,0 +1,116 @@ +/* +Copyright 2018 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 internal + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/kube-openapi/pkg/schemaconv" + "k8s.io/kube-openapi/pkg/util/proto" + "sigs.k8s.io/structured-merge-diff/typed" +) + +// groupVersionKindExtensionKey is the key used to lookup the +// GroupVersionKind value for an object definition from the +// definition's "extensions" map. +const groupVersionKindExtensionKey = "x-kubernetes-group-version-kind" + +type gvkParser struct { + gvks map[schema.GroupVersionKind]string + parser typed.Parser +} + +func (p *gvkParser) Type(gvk schema.GroupVersionKind) *typed.ParseableType { + typeName, ok := p.gvks[gvk] + if !ok { + return nil + } + return p.parser.Type(typeName) +} + +func newGVKParser(models proto.Models) (*gvkParser, error) { + typeSchema, err := schemaconv.ToSchema(models) + if err != nil { + return nil, fmt.Errorf("failed to convert models to schema: %v", err) + } + parser := gvkParser{ + gvks: map[schema.GroupVersionKind]string{}, + } + parser.parser = typed.Parser{Schema: *typeSchema} + for _, modelName := range models.ListModels() { + model := models.LookupModel(modelName) + if model == nil { + panic("ListModels returns a model that can't be looked-up.") + } + gvkList := parseGroupVersionKind(model) + for _, gvk := range gvkList { + if len(gvk.Kind) > 0 { + parser.gvks[gvk] = modelName + } + } + } + return &parser, nil +} + +// Get and parse GroupVersionKind from the extension. Returns empty if it doesn't have one. +func parseGroupVersionKind(s proto.Schema) []schema.GroupVersionKind { + extensions := s.GetExtensions() + + gvkListResult := []schema.GroupVersionKind{} + + // Get the extensions + gvkExtension, ok := extensions[groupVersionKindExtensionKey] + if !ok { + return []schema.GroupVersionKind{} + } + + // gvk extension must be a list of at least 1 element. + gvkList, ok := gvkExtension.([]interface{}) + if !ok { + return []schema.GroupVersionKind{} + } + + for _, gvk := range gvkList { + // gvk extension list must be a map with group, version, and + // kind fields + gvkMap, ok := gvk.(map[interface{}]interface{}) + if !ok { + continue + } + group, ok := gvkMap["group"].(string) + if !ok { + continue + } + version, ok := gvkMap["version"].(string) + if !ok { + continue + } + kind, ok := gvkMap["kind"].(string) + if !ok { + continue + } + + gvkListResult = append(gvkListResult, schema.GroupVersionKind{ + Group: group, + Version: version, + Kind: kind, + }) + } + + return gvkListResult +} diff --git a/pkg/endpoints/handlers/fieldmanager/internal/managedfields.go b/pkg/endpoints/handlers/fieldmanager/internal/managedfields.go new file mode 100644 index 000000000..ffe7485e9 --- /dev/null +++ b/pkg/endpoints/handlers/fieldmanager/internal/managedfields.go @@ -0,0 +1,119 @@ +/* +Copyright 2018 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 internal + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/structured-merge-diff/fieldpath" +) + +// RemoveObjectManagedFields removes the ManagedFields from the object +// before we merge so that it doesn't appear in the ManagedFields +// recursively. +func RemoveObjectManagedFields(obj runtime.Object) error { + accessor, err := meta.Accessor(obj) + if err != nil { + return fmt.Errorf("couldn't get accessor: %v", err) + } + accessor.SetManagedFields(nil) + return nil +} + +// DecodeObjectManagedFields extracts and converts the objects ManagedFields into a fieldpath.ManagedFields. +func DecodeObjectManagedFields(from runtime.Object) (fieldpath.ManagedFields, error) { + if from == nil { + return make(map[string]*fieldpath.VersionedSet), nil + } + accessor, err := meta.Accessor(from) + if err != nil { + return nil, fmt.Errorf("couldn't get accessor: %v", err) + } + + managed, err := decodeManagedFields(accessor.GetManagedFields()) + if err != nil { + return nil, fmt.Errorf("failed to convert managed fields from API: %v", err) + } + return managed, err +} + +// EncodeObjectManagedFields converts and stores the fieldpathManagedFields into the objects ManagedFields +func EncodeObjectManagedFields(obj runtime.Object, fields fieldpath.ManagedFields) error { + accessor, err := meta.Accessor(obj) + if err != nil { + return fmt.Errorf("couldn't get accessor: %v", err) + } + + managed, err := encodeManagedFields(fields) + if err != nil { + return fmt.Errorf("failed to convert back managed fields to API: %v", err) + } + accessor.SetManagedFields(managed) + + return nil +} + +// decodeManagedFields converts ManagedFields from the wire format (api format) +// to the format used by sigs.k8s.io/structured-merge-diff +func decodeManagedFields(encodedManagedFields map[string]metav1.VersionedFields) (managedFields fieldpath.ManagedFields, err error) { + managedFields = make(map[string]*fieldpath.VersionedSet, len(encodedManagedFields)) + for manager, encodedVersionedSet := range encodedManagedFields { + managedFields[manager], err = decodeVersionedSet(&encodedVersionedSet) + if err != nil { + return nil, fmt.Errorf("error decoding versioned set for %v: %v", manager, err) + } + } + return managedFields, nil +} + +func decodeVersionedSet(encodedVersionedSet *metav1.VersionedFields) (versionedSet *fieldpath.VersionedSet, err error) { + versionedSet = &fieldpath.VersionedSet{} + versionedSet.APIVersion = fieldpath.APIVersion(encodedVersionedSet.APIVersion) + set, err := FieldsToSet(encodedVersionedSet.Fields) + if err != nil { + return nil, fmt.Errorf("error decoding set: %v", err) + } + versionedSet.Set = &set + return versionedSet, nil +} + +// encodeManagedFields converts ManagedFields from the the format used by +// sigs.k8s.io/structured-merge-diff to the the wire format (api format) +func encodeManagedFields(managedFields fieldpath.ManagedFields) (encodedManagedFields map[string]metav1.VersionedFields, err error) { + encodedManagedFields = make(map[string]metav1.VersionedFields, len(managedFields)) + for manager, versionedSet := range managedFields { + v, err := encodeVersionedSet(versionedSet) + if err != nil { + return nil, fmt.Errorf("error encoding versioned set for %v: %v", manager, err) + } + encodedManagedFields[manager] = *v + } + return encodedManagedFields, nil +} + +func encodeVersionedSet(versionedSet *fieldpath.VersionedSet) (encodedVersionedSet *metav1.VersionedFields, err error) { + encodedVersionedSet = &metav1.VersionedFields{} + encodedVersionedSet.APIVersion = string(versionedSet.APIVersion) + encodedVersionedSet.Fields, err = SetToFields(*versionedSet.Set) + if err != nil { + return nil, fmt.Errorf("error encoding set: %v", err) + } + return encodedVersionedSet, nil +} diff --git a/pkg/endpoints/handlers/fieldmanager/internal/managedfields_test.go b/pkg/endpoints/handlers/fieldmanager/internal/managedfields_test.go new file mode 100644 index 000000000..dca8b93c7 --- /dev/null +++ b/pkg/endpoints/handlers/fieldmanager/internal/managedfields_test.go @@ -0,0 +1,143 @@ +/* +Copyright 2018 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 internal + +import ( + "reflect" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/yaml" +) + +// TestRoundTripManagedFields will roundtrip ManagedFields from the format used by +// sigs.k8s.io/structured-merge-diff to the wire format (api format) and back +func TestRoundTripManagedFields(t *testing.T) { + tests := []string{ + `foo: + apiVersion: v1 + fields: + i:5: + f:i: {} + v:3: + f:alsoPi: {} + v:3.1415: + f:pi: {} + v:false: + f:notTrue: {} +`, + `foo: + apiVersion: v1 + fields: + f:spec: + f:containers: + k:{"name":"c"}: + f:image: {} + f:name: {} +`, + `foo: + apiVersion: v1 + fields: + f:apiVersion: {} + f:kind: {} + f:metadata: + f:labels: + f:app: {} + f:name: {} + f:spec: + f:replicas: {} + f:selector: + f:matchLabels: + f:app: {} + f:template: + f:medatada: + f:labels: + f:app: {} + f:spec: + f:containers: + k:{"name":"nginx"}: + .: {} + f:image: {} + f:name: {} + f:ports: + i:0: + f:containerPort: {} +`, + `foo: + apiVersion: v1 + fields: + f:allowVolumeExpansion: {} + f:apiVersion: {} + f:kind: {} + f:metadata: + f:name: {} + f:parameters: + f:resturl: {} + f:restuser: {} + f:secretName: {} + f:secretNamespace: {} + f:provisioner: {} +`, + `foo: + apiVersion: v1 + fields: + f:apiVersion: {} + f:kind: {} + f:metadata: + f:name: {} + f:spec: + f:group: {} + f:names: + f:kind: {} + f:plural: {} + f:shortNames: + i:0: {} + f:singular: {} + f:scope: {} + f:versions: + k:{"name":"v1"}: + f:name: {} + f:served: {} + f:storage: {} +`, + } + + for _, test := range tests { + t.Run(test, func(t *testing.T) { + var unmarshaled map[string]metav1.VersionedFields + if err := yaml.Unmarshal([]byte(test), &unmarshaled); err != nil { + t.Fatalf("did not expect yaml unmarshalling error but got: %v", err) + } + decoded, err := decodeManagedFields(unmarshaled) + if err != nil { + t.Fatalf("did not expect decoding error but got: %v", err) + } + encoded, err := encodeManagedFields(decoded) + if err != nil { + t.Fatalf("did not expect encoding error but got: %v", err) + } + marshaled, err := yaml.Marshal(&encoded) + if err != nil { + t.Fatalf("did not expect yaml marshalling error but got: %v", err) + } + if !reflect.DeepEqual(string(marshaled), test) { + t.Fatalf("expected:\n%v\nbut got:\n%v", test, string(marshaled)) + } + }) + } +} diff --git a/pkg/endpoints/handlers/fieldmanager/internal/pathelement.go b/pkg/endpoints/handlers/fieldmanager/internal/pathelement.go new file mode 100644 index 000000000..e2b63362f --- /dev/null +++ b/pkg/endpoints/handlers/fieldmanager/internal/pathelement.go @@ -0,0 +1,140 @@ +/* +Copyright 2018 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 internal + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "sigs.k8s.io/structured-merge-diff/fieldpath" + "sigs.k8s.io/structured-merge-diff/value" +) + +const ( + // Field indicates that the content of this path element is a field's name + Field = "f" + + // Value indicates that the content of this path element is a field's value + Value = "v" + + // Index indicates that the content of this path element is an index in an array + Index = "i" + + // Key indicates that the content of this path element is a key value map + Key = "k" + + // Separator separates the type of a path element from the contents + Separator = ":" +) + +// NewPathElement parses a serialized path element +func NewPathElement(s string) (fieldpath.PathElement, error) { + split := strings.SplitN(s, Separator, 2) + if len(split) < 2 { + return fieldpath.PathElement{}, fmt.Errorf("missing colon: %v", s) + } + switch split[0] { + case Field: + return fieldpath.PathElement{ + FieldName: &split[1], + }, nil + case Value: + val, err := value.FromJSON([]byte(split[1])) + if err != nil { + return fieldpath.PathElement{}, err + } + return fieldpath.PathElement{ + Value: &val, + }, nil + case Index: + i, err := strconv.Atoi(split[1]) + if err != nil { + return fieldpath.PathElement{}, err + } + return fieldpath.PathElement{ + Index: &i, + }, nil + case Key: + kv := map[string]json.RawMessage{} + err := json.Unmarshal([]byte(split[1]), &kv) + if err != nil { + return fieldpath.PathElement{}, err + } + fields := []value.Field{} + for k, v := range kv { + b, err := json.Marshal(v) + if err != nil { + return fieldpath.PathElement{}, err + } + val, err := value.FromJSON(b) + if err != nil { + return fieldpath.PathElement{}, err + } + + fields = append(fields, value.Field{ + Name: k, + Value: val, + }) + } + return fieldpath.PathElement{ + Key: fields, + }, nil + default: + // Ignore unknown key types + return fieldpath.PathElement{}, nil + } +} + +// PathElementString serializes a path element +func PathElementString(pe fieldpath.PathElement) (string, error) { + switch { + case pe.FieldName != nil: + return Field + Separator + *pe.FieldName, nil + case len(pe.Key) > 0: + kv := map[string]json.RawMessage{} + for _, k := range pe.Key { + b, err := k.Value.ToJSON() + if err != nil { + return "", err + } + m := json.RawMessage{} + err = json.Unmarshal(b, &m) + if err != nil { + return "", err + } + kv[k.Name] = m + } + b, err := json.Marshal(kv) + if err != nil { + return "", err + } + return Key + ":" + string(b), nil + case pe.Value != nil: + b, err := pe.Value.ToJSON() + if err != nil { + return "", err + } + return Value + ":" + string(b), nil + case pe.Index != nil: + return Index + ":" + strconv.Itoa(*pe.Index), nil + default: + return "", errors.New("Invalid type of path element") + } +} diff --git a/pkg/endpoints/handlers/fieldmanager/internal/pathelement_test.go b/pkg/endpoints/handlers/fieldmanager/internal/pathelement_test.go new file mode 100644 index 000000000..81b9dd417 --- /dev/null +++ b/pkg/endpoints/handlers/fieldmanager/internal/pathelement_test.go @@ -0,0 +1,84 @@ +/* +Copyright 2018 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 internal + +import "testing" + +func TestPathElementRoundTrip(t *testing.T) { + tests := []string{ + "i:0", + "i:1234", + "f:", + "f:spec", + "f:more-complicated-string", + "k:{\"name\":\"my-container\"}", + "k:{\"port\":\"8080\",\"protocol\":\"TCP\"}", + "k:{\"optionalField\":null}", + "k:{\"jsonField\":{\"A\":1,\"B\":null,\"C\":\"D\",\"E\":{\"F\":\"G\"}}}", + "k:{\"listField\":[\"1\",\"2\",\"3\"]}", + "v:null", + "v:\"some-string\"", + "v:1234", + "v:{\"some\":\"json\"}", + } + + for _, test := range tests { + t.Run(test, func(t *testing.T) { + pe, err := NewPathElement(test) + if err != nil { + t.Fatalf("Failed to create path element: %v", err) + } + output, err := PathElementString(pe) + if err != nil { + t.Fatalf("Failed to create string from path element: %v", err) + } + if test != output { + t.Fatalf("Expected round-trip:\ninput: %v\noutput: %v", test, output) + } + }) + } +} + +func TestPathElementIgnoreUnknown(t *testing.T) { + _, err := NewPathElement("r:Hello") + if err != nil { + t.Fatalf("Unknown qualifiers should be ignored") + } +} + +func TestNewPathElementError(t *testing.T) { + tests := []string{ + "", + "no-colon", + "i:index is not a number", + "i:1.23", + "i:", + "v:invalid json", + "v:", + "k:invalid json", + "k:{\"name\":invalid}", + } + + for _, test := range tests { + t.Run(test, func(t *testing.T) { + _, err := NewPathElement(test) + if err == nil { + t.Fatalf("Expected error, no error found") + } + }) + } +} diff --git a/pkg/endpoints/handlers/fieldmanager/internal/typeconverter.go b/pkg/endpoints/handlers/fieldmanager/internal/typeconverter.go new file mode 100644 index 000000000..79f3017cb --- /dev/null +++ b/pkg/endpoints/handlers/fieldmanager/internal/typeconverter.go @@ -0,0 +1,99 @@ +/* +Copyright 2018 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 internal + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/kube-openapi/pkg/util/proto" + "sigs.k8s.io/structured-merge-diff/typed" + "sigs.k8s.io/yaml" +) + +// TypeConverter allows you to convert from runtime.Object to +// typed.TypedValue and the other way around. +type TypeConverter interface { + NewTyped(schema.GroupVersionKind) (typed.TypedValue, error) + ObjectToTyped(runtime.Object) (typed.TypedValue, error) + YAMLToTyped([]byte) (typed.TypedValue, error) + TypedToObject(typed.TypedValue) (runtime.Object, error) +} + +type typeConverter struct { + parser *gvkParser +} + +var _ TypeConverter = &typeConverter{} + +// NewTypeConverter builds a TypeConverter from a proto.Models. This +// will automatically find the proper version of the object, and the +// corresponding schema information. +func NewTypeConverter(models proto.Models) (TypeConverter, error) { + parser, err := newGVKParser(models) + if err != nil { + return nil, err + } + return &typeConverter{parser: parser}, nil +} + +func (c *typeConverter) NewTyped(gvk schema.GroupVersionKind) (typed.TypedValue, error) { + t := c.parser.Type(gvk) + if t == nil { + return typed.TypedValue{}, fmt.Errorf("no corresponding type for %v", gvk) + } + + u, err := t.New() + if err != nil { + return typed.TypedValue{}, fmt.Errorf("new typed: %v", err) + } + return u, nil +} + +func (c *typeConverter) ObjectToTyped(obj runtime.Object) (typed.TypedValue, error) { + u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return typed.TypedValue{}, err + } + gvk := obj.GetObjectKind().GroupVersionKind() + t := c.parser.Type(gvk) + if t == nil { + return typed.TypedValue{}, fmt.Errorf("no corresponding type for %v", gvk) + } + return t.FromUnstructured(u) +} + +func (c *typeConverter) YAMLToTyped(from []byte) (typed.TypedValue, error) { + unstructured := &unstructured.Unstructured{Object: map[string]interface{}{}} + + if err := yaml.Unmarshal(from, &unstructured.Object); err != nil { + return typed.TypedValue{}, fmt.Errorf("error decoding YAML: %v", err) + } + + return c.ObjectToTyped(unstructured) +} + +func (c *typeConverter) TypedToObject(value typed.TypedValue) (runtime.Object, error) { + vu := value.AsValue().ToUnstructured(false) + u, ok := vu.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("failed to convert typed to unstructured: want map, got %T", vu) + } + return &unstructured.Unstructured{Object: u}, nil +} diff --git a/pkg/endpoints/handlers/fieldmanager/internal/typeconverter_test.go b/pkg/endpoints/handlers/fieldmanager/internal/typeconverter_test.go new file mode 100644 index 000000000..bced6d632 --- /dev/null +++ b/pkg/endpoints/handlers/fieldmanager/internal/typeconverter_test.go @@ -0,0 +1,109 @@ +/* +Copyright 2018 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 internal_test + +import ( + "path/filepath" + "reflect" + "testing" + + "github.com/ghodss/yaml" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal" + "k8s.io/kube-openapi/pkg/util/proto" + prototesting "k8s.io/kube-openapi/pkg/util/proto/testing" +) + +var fakeSchema = prototesting.Fake{ + Path: filepath.Join( + "..", "..", "..", "..", "..", "..", "..", "..", "..", + "api", "openapi-spec", "swagger.json"), +} + +func TestTypeConverter(t *testing.T) { + d, err := fakeSchema.OpenAPISchema() + if err != nil { + t.Fatalf("Failed to parse OpenAPI schema: %v", err) + } + m, err := proto.NewOpenAPIData(d) + if err != nil { + t.Fatalf("Failed to build OpenAPI models: %v", err) + } + + tc, err := internal.NewTypeConverter(m) + if err != nil { + t.Fatalf("Failed to build TypeConverter: %v", err) + } + + y := ` +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + labels: + app: nginx +spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.15.4 +` + + obj := &unstructured.Unstructured{Object: map[string]interface{}{}} + if err := yaml.Unmarshal([]byte(y), &obj.Object); err != nil { + t.Fatalf("Failed to parse yaml object: %v", err) + } + typed, err := tc.ObjectToTyped(obj) + if err != nil { + t.Fatalf("Failed to convert object to typed: %v", err) + } + newObj, err := tc.TypedToObject(typed) + if err != nil { + t.Fatalf("Failed to convert typed to object: %v", err) + } + if !reflect.DeepEqual(obj, newObj) { + t.Errorf(`Round-trip failed: +Original object: +%#v +Final object: +%#v`, obj, newObj) + } + + yamlTyped, err := tc.YAMLToTyped([]byte(y)) + if err != nil { + t.Fatalf("Failed to convert yaml to typed: %v", err) + } + newObj, err = tc.TypedToObject(yamlTyped) + if err != nil { + t.Fatalf("Failed to convert typed to object: %v", err) + } + if !reflect.DeepEqual(obj, newObj) { + t.Errorf(`YAML conversion resulted in different object failed: +Original object: +%#v +Final object: +%#v`, obj, newObj) + } +} diff --git a/pkg/endpoints/handlers/fieldmanager/internal/versionconverter.go b/pkg/endpoints/handlers/fieldmanager/internal/versionconverter.go new file mode 100644 index 000000000..05770b0ae --- /dev/null +++ b/pkg/endpoints/handlers/fieldmanager/internal/versionconverter.go @@ -0,0 +1,83 @@ +/* +Copyright 2018 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 internal + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/structured-merge-diff/fieldpath" + "sigs.k8s.io/structured-merge-diff/merge" + "sigs.k8s.io/structured-merge-diff/typed" +) + +// versionConverter is an implementation of +// sigs.k8s.io/structured-merge-diff/merge.Converter +type versionConverter struct { + typeConverter TypeConverter + objectConvertor runtime.ObjectConvertor + hubVersion schema.GroupVersion +} + +var _ merge.Converter = &versionConverter{} + +// NewVersionConverter builds a VersionConverter from a TypeConverter and an ObjectConvertor. +func NewVersionConverter(t TypeConverter, o runtime.ObjectConvertor, h schema.GroupVersion) merge.Converter { + return &versionConverter{ + typeConverter: t, + objectConvertor: o, + hubVersion: h, + } +} + +// Convert implements sigs.k8s.io/structured-merge-diff/merge.Converter +func (v *versionConverter) Convert(object typed.TypedValue, version fieldpath.APIVersion) (typed.TypedValue, error) { + // Convert the smd typed value to a kubernetes object. + objectToConvert, err := v.typeConverter.TypedToObject(object) + if err != nil { + return object, err + } + + // Parse the target groupVersion. + groupVersion, err := schema.ParseGroupVersion(string(version)) + if err != nil { + return object, err + } + + // If attempting to convert to the same version as we already have, just return it. + if objectToConvert.GetObjectKind().GroupVersionKind().GroupVersion() == groupVersion { + return object, nil + } + + // Convert to internal + internalObject, err := v.objectConvertor.ConvertToVersion(objectToConvert, v.hubVersion) + if err != nil { + return object, fmt.Errorf("failed to convert object (%v to %v): %v", + objectToConvert.GetObjectKind().GroupVersionKind(), v.hubVersion, err) + } + + // Convert the object into the target version + convertedObject, err := v.objectConvertor.ConvertToVersion(internalObject, groupVersion) + if err != nil { + return object, fmt.Errorf("failed to convert object (%v to %v): %v", + internalObject.GetObjectKind().GroupVersionKind(), groupVersion, err) + } + + // Convert the object back to a smd typed value and return it. + return v.typeConverter.ObjectToTyped(convertedObject) +} diff --git a/pkg/endpoints/handlers/fieldmanager/internal/versionconverter_test.go b/pkg/endpoints/handlers/fieldmanager/internal/versionconverter_test.go new file mode 100644 index 000000000..18365f6d3 --- /dev/null +++ b/pkg/endpoints/handlers/fieldmanager/internal/versionconverter_test.go @@ -0,0 +1,107 @@ +/* +Copyright 2018 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 internal_test + +import ( + "fmt" + "reflect" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager/internal" + "k8s.io/kube-openapi/pkg/util/proto" + "sigs.k8s.io/structured-merge-diff/fieldpath" +) + +// TestVersionConverter tests the version converter +func TestVersionConverter(t *testing.T) { + d, err := fakeSchema.OpenAPISchema() + if err != nil { + t.Fatalf("Failed to parse OpenAPI schema: %v", err) + } + m, err := proto.NewOpenAPIData(d) + if err != nil { + t.Fatalf("Failed to build OpenAPI models: %v", err) + } + tc, err := internal.NewTypeConverter(m) + if err != nil { + t.Fatalf("Failed to build TypeConverter: %v", err) + } + oc := fakeObjectConvertor{ + gvkForVersion("v1beta1"): objForGroupVersion("apps/v1beta1"), + gvkForVersion("v1"): objForGroupVersion("apps/v1"), + } + vc := internal.NewVersionConverter(tc, oc, schema.GroupVersion{Group: "apps", Version: runtime.APIVersionInternal}) + + input, err := tc.ObjectToTyped(objForGroupVersion("apps/v1beta1")) + if err != nil { + t.Fatalf("error creating converting input object to a typed value: %v", err) + } + expected := objForGroupVersion("apps/v1") + output, err := vc.Convert(input, fieldpath.APIVersion("apps/v1")) + if err != nil { + t.Fatalf("expected err to be nil but got %v", err) + } + actual, err := tc.TypedToObject(output) + if err != nil { + t.Fatalf("error converting output typed value to an object %v", err) + } + + if !reflect.DeepEqual(expected, actual) { + t.Fatalf("expected to get %v but got %v", expected, actual) + } +} + +func gvkForVersion(v string) schema.GroupVersionKind { + return schema.GroupVersionKind{ + Group: "apps", + Version: v, + Kind: "Deployment", + } +} + +func objForGroupVersion(gv string) runtime.Object { + return &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": gv, + "kind": "Deployment", + }, + } +} + +type fakeObjectConvertor map[schema.GroupVersionKind]runtime.Object + +var _ runtime.ObjectConvertor = fakeObjectConvertor{} + +func (c fakeObjectConvertor) ConvertToVersion(_ runtime.Object, gv runtime.GroupVersioner) (runtime.Object, error) { + allKinds := make([]schema.GroupVersionKind, 0) + for kind := range c { + allKinds = append(allKinds, kind) + } + gvk, _ := gv.KindForGroupVersionKinds(allKinds) + return c[gvk], nil +} + +func (fakeObjectConvertor) Convert(_, _, _ interface{}) error { + return fmt.Errorf("function not implemented") +} + +func (fakeObjectConvertor) ConvertFieldLabel(_ schema.GroupVersionKind, _, _ string) (string, string, error) { + return "", "", fmt.Errorf("function not implemented") +} diff --git a/pkg/endpoints/handlers/patch.go b/pkg/endpoints/handlers/patch.go index 384c27668..b674dcdd4 100644 --- a/pkg/endpoints/handlers/patch.go +++ b/pkg/endpoints/handlers/patch.go @@ -24,8 +24,8 @@ import ( "time" "github.com/evanphx/json-patch" - "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/validation" @@ -38,6 +38,8 @@ import ( "k8s.io/apimachinery/pkg/util/strategicpatch" "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/audit" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager" "k8s.io/apiserver/pkg/endpoints/handlers/negotiation" "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/features" @@ -94,20 +96,20 @@ func PatchResource(r rest.Patcher, scope RequestScope, admit admission.Interface return } - patchJS, err := readBody(req) + patchBytes, err := readBody(req) if err != nil { scope.err(err, w, req) return } - options := &metav1.UpdateOptions{} + options := &metav1.PatchOptions{} if err := metainternalversion.ParameterCodec.DecodeParameters(req.URL.Query(), scope.MetaGroupVersion, options); err != nil { err = errors.NewBadRequest(err.Error()) scope.err(err, w, req) return } - if errs := validation.ValidateUpdateOptions(options); len(errs) > 0 { - err := errors.NewInvalid(schema.GroupKind{Group: metav1.GroupName, Kind: "UpdateOptions"}, "", errs) + if errs := validation.ValidatePatchOptions(options, patchType); len(errs) > 0 { + err := errors.NewInvalid(schema.GroupKind{Group: metav1.GroupName, Kind: "PatchOptions"}, "", errs) scope.err(err, w, req) return } @@ -115,12 +117,16 @@ func PatchResource(r rest.Patcher, scope RequestScope, admit admission.Interface ae := request.AuditEventFrom(ctx) admit = admission.WithAudit(admit, ae) - audit.LogRequestPatch(ae, patchJS) + audit.LogRequestPatch(ae, patchBytes) trace.Step("Recorded the audit event") - s, ok := runtime.SerializerInfoForMediaType(scope.Serializer.SupportedMediaTypes(), runtime.ContentTypeJSON) + baseContentType := runtime.ContentTypeJSON + if patchType == types.ApplyPatchType { + baseContentType = runtime.ContentTypeYAML + } + s, ok := runtime.SerializerInfoForMediaType(scope.Serializer.SupportedMediaTypes(), baseContentType) if !ok { - scope.err(fmt.Errorf("no serializer defined for JSON"), w, req) + scope.err(fmt.Errorf("no serializer defined for %v", baseContentType), w, req) return } gv := scope.Kind.GroupVersion() @@ -131,7 +137,18 @@ func PatchResource(r rest.Patcher, scope RequestScope, admit admission.Interface ) userInfo, _ := request.UserFrom(ctx) - staticAdmissionAttributes := admission.NewAttributesRecord( + staticCreateAttributes := admission.NewAttributesRecord( + nil, + nil, + scope.Kind, + namespace, + name, + scope.Resource, + scope.Subresource, + admission.Create, + dryrun.IsDryRun(options.DryRun), + userInfo) + staticUpdateAttributes := admission.NewAttributesRecord( nil, nil, scope.Kind, @@ -143,38 +160,37 @@ func PatchResource(r rest.Patcher, scope RequestScope, admit admission.Interface dryrun.IsDryRun(options.DryRun), userInfo, ) - admissionCheck := func(updatedObject runtime.Object, currentObject runtime.Object) error { - // if we allow create-on-patch, we have this TODO: call the mutating admission chain with the CREATE verb instead of UPDATE - if mutatingAdmission, ok := admit.(admission.MutationInterface); ok && admit.Handles(admission.Update) { - return mutatingAdmission.Admit(admission.NewAttributesRecord( - updatedObject, - currentObject, - scope.Kind, - namespace, - name, - scope.Resource, - scope.Subresource, - admission.Update, - dryrun.IsDryRun(options.DryRun), - userInfo, - )) - } - return nil + + mutatingAdmission, _ := admit.(admission.MutationInterface) + createAuthorizerAttributes := authorizer.AttributesRecord{ + User: userInfo, + ResourceRequest: true, + Path: req.URL.Path, + Verb: "create", + APIGroup: scope.Resource.Group, + APIVersion: scope.Resource.Version, + Resource: scope.Resource.Resource, + Subresource: scope.Subresource, + Namespace: namespace, + Name: name, } p := patcher{ namer: scope.Namer, creater: scope.Creater, defaulter: scope.Defaulter, + typer: scope.Typer, unsafeConvertor: scope.UnsafeConvertor, kind: scope.Kind, resource: scope.Resource, + subresource: scope.Subresource, + dryRun: dryrun.IsDryRun(options.DryRun), hubGroupVersion: scope.HubGroupVersion, - createValidation: rest.AdmissionToValidateObjectFunc(admit, staticAdmissionAttributes), - updateValidation: rest.AdmissionToValidateObjectUpdateFunc(admit, staticAdmissionAttributes), - admissionCheck: admissionCheck, + createValidation: withAuthorization(rest.AdmissionToValidateObjectFunc(admit, staticCreateAttributes), scope.Authorizer, createAuthorizerAttributes), + updateValidation: rest.AdmissionToValidateObjectUpdateFunc(admit, staticUpdateAttributes), + admissionCheck: mutatingAdmission, codec: codec, @@ -184,20 +200,35 @@ func PatchResource(r rest.Patcher, scope RequestScope, admit admission.Interface restPatcher: r, name: name, patchType: patchType, - patchJS: patchJS, + patchBytes: patchBytes, trace: trace, } - result, err := p.patchResource(ctx) + result, wasCreated, err := p.patchResource(ctx, scope) if err != nil { scope.err(err, w, req) return } trace.Step("Object stored in database") + requestInfo, ok := request.RequestInfoFrom(ctx) + if !ok { + scope.err(fmt.Errorf("missing requestInfo"), w, req) + return + } + if err := setSelfLink(result, requestInfo, scope.Namer); err != nil { + scope.err(err, w, req) + return + } + trace.Step("Self-link added") + + status := http.StatusOK + if wasCreated { + status = http.StatusCreated + } scope.Trace = trace - transformResponseObject(ctx, scope, req, w, http.StatusOK, outputMediaType, result) + transformResponseObject(ctx, scope, req, w, status, outputMediaType, result) } } @@ -213,27 +244,30 @@ type patcher struct { namer ScopeNamer creater runtime.ObjectCreater defaulter runtime.ObjectDefaulter + typer runtime.ObjectTyper unsafeConvertor runtime.ObjectConvertor resource schema.GroupVersionResource kind schema.GroupVersionKind + subresource string + dryRun bool hubGroupVersion schema.GroupVersion // Validation functions createValidation rest.ValidateObjectFunc updateValidation rest.ValidateObjectUpdateFunc - admissionCheck mutateObjectUpdateFunc + admissionCheck admission.MutationInterface codec runtime.Codec timeout time.Duration - options *metav1.UpdateOptions + options *metav1.PatchOptions // Operation information restPatcher rest.Patcher name string patchType types.PatchType - patchJS []byte + patchBytes []byte trace *utiltrace.Trace @@ -241,14 +275,18 @@ type patcher struct { namespace string updatedObjectInfo rest.UpdatedObjectInfo mechanism patchMechanism + forceAllowCreate bool } type patchMechanism interface { applyPatchToCurrentObject(currentObject runtime.Object) (runtime.Object, error) + createNewObject() (runtime.Object, error) } type jsonPatcher struct { *patcher + + fieldManager *fieldmanager.FieldManager } func (p *jsonPatcher) applyPatchToCurrentObject(currentObject runtime.Object) (runtime.Object, error) { @@ -270,15 +308,24 @@ func (p *jsonPatcher) applyPatchToCurrentObject(currentObject runtime.Object) (r return nil, err } + if p.fieldManager != nil { + if objToUpdate, err = p.fieldManager.Update(currentObject, objToUpdate, "jsonPatcher"); err != nil { + return nil, fmt.Errorf("failed to update object managed fields: %v", err) + } + } return objToUpdate, nil } -// patchJS applies the patch. Input and output objects must both have +func (p *jsonPatcher) createNewObject() (runtime.Object, error) { + return nil, errors.NewNotFound(p.resource.GroupResource(), p.name) +} + +// applyJSPatch applies the patch. Input and output objects must both have // the external version, since that is what the patch must have been constructed against. func (p *jsonPatcher) applyJSPatch(versionedJS []byte) (patchedJS []byte, retErr error) { switch p.patchType { case types.JSONPatchType: - patchObj, err := jsonpatch.DecodePatch(p.patchJS) + patchObj, err := jsonpatch.DecodePatch(p.patchBytes) if err != nil { return nil, errors.NewBadRequest(err.Error()) } @@ -288,7 +335,7 @@ func (p *jsonPatcher) applyJSPatch(versionedJS []byte) (patchedJS []byte, retErr } return patchedJS, nil case types.MergePatchType: - return jsonpatch.MergePatch(versionedJS, p.patchJS) + return jsonpatch.MergePatch(versionedJS, p.patchBytes) default: // only here as a safety net - go-restful filters content-type return nil, fmt.Errorf("unknown Content-Type header for patch: %v", p.patchType) @@ -300,6 +347,7 @@ type smpPatcher struct { // Schema schemaReferenceObj runtime.Object + fieldManager *fieldmanager.FieldManager } func (p *smpPatcher) applyPatchToCurrentObject(currentObject runtime.Object) (runtime.Object, error) { @@ -313,22 +361,60 @@ func (p *smpPatcher) applyPatchToCurrentObject(currentObject runtime.Object) (ru if err != nil { return nil, err } - if err := strategicPatchObject(p.defaulter, currentVersionedObject, p.patchJS, versionedObjToUpdate, p.schemaReferenceObj); err != nil { + if err := strategicPatchObject(p.defaulter, currentVersionedObject, p.patchBytes, versionedObjToUpdate, p.schemaReferenceObj); err != nil { return nil, err } // Convert the object back to the hub version - return p.unsafeConvertor.ConvertToVersion(versionedObjToUpdate, p.hubGroupVersion) + newObj, err := p.unsafeConvertor.ConvertToVersion(versionedObjToUpdate, p.hubGroupVersion) + if err != nil { + return nil, err + } + + if p.fieldManager != nil { + if newObj, err = p.fieldManager.Update(currentObject, newObj, "smPatcher"); err != nil { + return nil, fmt.Errorf("failed to update object managed fields: %v", err) + } + } + return newObj, nil } -// strategicPatchObject applies a strategic merge patch of to +func (p *smpPatcher) createNewObject() (runtime.Object, error) { + return nil, errors.NewNotFound(p.resource.GroupResource(), p.name) +} + +type applyPatcher struct { + patch []byte + options *metav1.PatchOptions + creater runtime.ObjectCreater + kind schema.GroupVersionKind + fieldManager *fieldmanager.FieldManager +} + +func (p *applyPatcher) applyPatchToCurrentObject(obj runtime.Object) (runtime.Object, error) { + force := false + if p.options.Force != nil { + force = *p.options.Force + } + return p.fieldManager.Apply(obj, p.patch, force) +} + +func (p *applyPatcher) createNewObject() (runtime.Object, error) { + obj, err := p.creater.New(p.kind) + if err != nil { + return nil, fmt.Errorf("failed to create new object: %v", obj) + } + return p.applyPatchToCurrentObject(obj) +} + +// strategicPatchObject applies a strategic merge patch of to // and stores the result in . // It additionally returns the map[string]interface{} representation of the -// and . +// and . // NOTE: Both and are supposed to be versioned. func strategicPatchObject( defaulter runtime.ObjectDefaulter, originalObject runtime.Object, - patchJS []byte, + patchBytes []byte, objToUpdate runtime.Object, schemaReferenceObj runtime.Object, ) error { @@ -338,7 +424,7 @@ func strategicPatchObject( } patchMap := make(map[string]interface{}) - if err := json.Unmarshal(patchJS, &patchMap); err != nil { + if err := json.Unmarshal(patchBytes, &patchMap); err != nil { return errors.NewBadRequest(err.Error()) } @@ -350,52 +436,113 @@ func strategicPatchObject( // applyPatch is called every time GuaranteedUpdate asks for the updated object, // and is given the currently persisted object as input. -func (p *patcher) applyPatch(_ context.Context, _, currentObject runtime.Object) (runtime.Object, error) { +// TODO: rename this function because the name implies it is related to applyPatcher +func (p *patcher) applyPatch(_ context.Context, _, currentObject runtime.Object) (objToUpdate runtime.Object, patchErr error) { // Make sure we actually have a persisted currentObject p.trace.Step("About to apply patch") - if hasUID, err := hasUID(currentObject); err != nil { + currentObjectHasUID, err := hasUID(currentObject) + if err != nil { return nil, err - } else if !hasUID { - return nil, errors.NewNotFound(p.resource.GroupResource(), p.name) + } else if !currentObjectHasUID { + objToUpdate, patchErr = p.mechanism.createNewObject() + } else { + objToUpdate, patchErr = p.mechanism.applyPatchToCurrentObject(currentObject) } - objToUpdate, err := p.mechanism.applyPatchToCurrentObject(currentObject) + if patchErr != nil { + return nil, patchErr + } + + objToUpdateHasUID, err := hasUID(objToUpdate) if err != nil { return nil, err } + if objToUpdateHasUID && !currentObjectHasUID { + accessor, err := meta.Accessor(objToUpdate) + if err != nil { + return nil, err + } + return nil, errors.NewConflict(p.resource.GroupResource(), p.name, fmt.Errorf("uid mismatch: the provided object specified uid %s, and no existing object was found", accessor.GetUID())) + } + if err := checkName(objToUpdate, p.name, p.namespace, p.namer); err != nil { return nil, err } return objToUpdate, nil } +func (p *patcher) admissionAttributes(ctx context.Context, updatedObject runtime.Object, currentObject runtime.Object, operation admission.Operation) admission.Attributes { + userInfo, _ := request.UserFrom(ctx) + return admission.NewAttributesRecord(updatedObject, currentObject, p.kind, p.namespace, p.name, p.resource, p.subresource, operation, p.dryRun, userInfo) +} + // applyAdmission is called every time GuaranteedUpdate asks for the updated object, // and is given the currently persisted object and the patched object as input. +// TODO: rename this function because the name implies it is related to applyPatcher func (p *patcher) applyAdmission(ctx context.Context, patchedObject runtime.Object, currentObject runtime.Object) (runtime.Object, error) { p.trace.Step("About to check admission control") - return patchedObject, p.admissionCheck(patchedObject, currentObject) + var operation admission.Operation + if hasUID, err := hasUID(currentObject); err != nil { + return nil, err + } else if !hasUID { + operation = admission.Create + currentObject = nil + } else { + operation = admission.Update + } + if p.admissionCheck != nil && p.admissionCheck.Handles(operation) { + attributes := p.admissionAttributes(ctx, patchedObject, currentObject, operation) + return patchedObject, p.admissionCheck.Admit(attributes) + } + return patchedObject, nil } // patchResource divides PatchResource for easier unit testing -func (p *patcher) patchResource(ctx context.Context) (runtime.Object, error) { +func (p *patcher) patchResource(ctx context.Context, scope RequestScope) (runtime.Object, bool, error) { p.namespace = request.NamespaceValue(ctx) switch p.patchType { case types.JSONPatchType, types.MergePatchType: - p.mechanism = &jsonPatcher{patcher: p} + p.mechanism = &jsonPatcher{ + patcher: p, + fieldManager: scope.FieldManager, + } case types.StrategicMergePatchType: schemaReferenceObj, err := p.unsafeConvertor.ConvertToVersion(p.restPatcher.New(), p.kind.GroupVersion()) + if err != nil { + return nil, false, err + } + p.mechanism = &smpPatcher{ + patcher: p, + schemaReferenceObj: schemaReferenceObj, + fieldManager: scope.FieldManager, + } + // this case is unreachable if ServerSideApply is not enabled because we will have already rejected the content type + case types.ApplyPatchType: + p.mechanism = &applyPatcher{ + fieldManager: scope.FieldManager, + patch: p.patchBytes, + options: p.options, + creater: p.creater, + kind: p.kind, + } + p.forceAllowCreate = true + default: + return nil, false, fmt.Errorf("%v: unimplemented patch type", p.patchType) + } + + wasCreated := false + p.updatedObjectInfo = rest.DefaultUpdatedObjectInfo(nil, p.applyPatch, p.applyAdmission) + result, err := finishRequest(p.timeout, func() (runtime.Object, error) { + // TODO: Pass in UpdateOptions to override UpdateStrategy.AllowUpdateOnCreate + options, err := patchToUpdateOptions(p.options) if err != nil { return nil, err } - p.mechanism = &smpPatcher{patcher: p, schemaReferenceObj: schemaReferenceObj} - default: - return nil, fmt.Errorf("%v: unimplemented patch type", p.patchType) - } - p.updatedObjectInfo = rest.DefaultUpdatedObjectInfo(nil, p.applyPatch, p.applyAdmission) - return finishRequest(p.timeout, func() (runtime.Object, error) { - updateObject, _, updateErr := p.restPatcher.Update(ctx, p.name, p.updatedObjectInfo, p.createValidation, p.updateValidation, false, p.options) + updateObject, created, updateErr := p.restPatcher.Update(ctx, p.name, p.updatedObjectInfo, p.createValidation, p.updateValidation, p.forceAllowCreate, options) + wasCreated = created return updateObject, updateErr }) + return result, wasCreated, err } // applyPatchToObject applies a strategic merge patch of to @@ -434,3 +581,13 @@ func interpretStrategicMergePatchError(err error) error { return err } } + +func patchToUpdateOptions(po *metav1.PatchOptions) (*metav1.UpdateOptions, error) { + b, err := json.Marshal(po) + if err != nil { + return nil, err + } + uo := metav1.UpdateOptions{} + err = json.Unmarshal(b, &uo) + return &uo, err +} diff --git a/pkg/endpoints/handlers/rest.go b/pkg/endpoints/handlers/rest.go index 43b091945..744ab6ea1 100644 --- a/pkg/endpoints/handlers/rest.go +++ b/pkg/endpoints/handlers/rest.go @@ -27,8 +27,6 @@ import ( "strings" "time" - "k8s.io/klog" - "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -37,11 +35,12 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager" "k8s.io/apiserver/pkg/endpoints/handlers/responsewriters" "k8s.io/apiserver/pkg/endpoints/metrics" "k8s.io/apiserver/pkg/endpoints/request" "k8s.io/apiserver/pkg/registry/rest" - openapiproto "k8s.io/kube-openapi/pkg/util/proto" + "k8s.io/klog" utiltrace "k8s.io/utils/trace" ) @@ -61,7 +60,7 @@ type RequestScope struct { Trace *utiltrace.Trace TableConvertor rest.TableConvertor - OpenAPIModels openapiproto.Models + FieldManager *fieldmanager.FieldManager Resource schema.GroupVersionResource Kind schema.GroupVersionKind diff --git a/pkg/endpoints/handlers/rest_test.go b/pkg/endpoints/handlers/rest_test.go index d410bc461..04febe03a 100644 --- a/pkg/endpoints/handlers/rest_test.go +++ b/pkg/endpoints/handlers/rest_test.go @@ -27,7 +27,6 @@ import ( "time" "github.com/evanphx/json-patch" - apiequality "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -39,6 +38,7 @@ import ( "k8s.io/apimachinery/pkg/util/diff" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/apimachinery/pkg/util/strategicpatch" + "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/apis/example" examplev1 "k8s.io/apiserver/pkg/apis/example/v1" "k8s.io/apiserver/pkg/endpoints/request" @@ -149,10 +149,10 @@ func TestJSONPatch(t *testing.T) { }, } { p := &patcher{ - patchType: types.JSONPatchType, - patchJS: []byte(test.patch), + patchType: types.JSONPatchType, + patchBytes: []byte(test.patch), } - jp := jsonPatcher{p} + jp := jsonPatcher{patcher: p} codec := codecs.LegacyCodec(examplev1.SchemeGroupVersion) pod := &examplev1.Pod{} pod.Name = "podA" @@ -454,12 +454,12 @@ func (tc *patchTestCase) Run(t *testing.T) { restPatcher: testPatcher, name: name, patchType: patchType, - patchJS: patch, + patchBytes: patch, trace: utiltrace.New("Patch" + name), } - resultObj, err := p.patchResource(ctx) + resultObj, _, err := p.patchResource(ctx, RequestScope{}) if len(tc.expectedError) != 0 { if err == nil || err.Error() != tc.expectedError { t.Errorf("%s: expected error %v, but got %v", tc.name, tc.expectedError, err) @@ -795,6 +795,9 @@ func TestPatchWithVersionConflictThenAdmissionFailure(t *testing.T) { tc.Run(t) } +// TODO: Add test case for "apply with existing uid" verify it gives a conflict error, +// not a creation or an authz creation forbidden message + func TestHasUID(t *testing.T) { testcases := []struct { obj runtime.Object @@ -939,3 +942,11 @@ func setTcPod(tcPod *example.Pod, name string, namespace string, uid types.UID, tcPod.Spec.NodeName = nodeName } } + +func (f mutateObjectUpdateFunc) Handles(operation admission.Operation) bool { + return true +} + +func (f mutateObjectUpdateFunc) Admit(a admission.Attributes) (err error) { + return f(a.GetObject(), a.GetOldObject()) +} diff --git a/pkg/endpoints/handlers/update.go b/pkg/endpoints/handlers/update.go index 9080b9b42..e78346c5b 100644 --- a/pkg/endpoints/handlers/update.go +++ b/pkg/endpoints/handlers/update.go @@ -121,7 +121,15 @@ func UpdateResource(r rest.Updater, scope RequestScope, admit admission.Interfac } userInfo, _ := request.UserFrom(ctx) - var transformers []rest.TransformFunc + transformers := []rest.TransformFunc{} + if scope.FieldManager != nil { + transformers = append(transformers, func(_ context.Context, liveObj, newObj runtime.Object) (runtime.Object, error) { + if obj, err = scope.FieldManager.Update(liveObj, newObj, "update"); err != nil { + return nil, fmt.Errorf("failed to update object managed fields: %v", err) + } + return obj, nil + }) + } if mutatingAdmission, ok := admit.(admission.MutationInterface); ok { transformers = append(transformers, func(ctx context.Context, newObj, oldObj runtime.Object) (runtime.Object, error) { isNotZeroObject, err := hasUID(oldObj) diff --git a/pkg/endpoints/handlers/watch.go b/pkg/endpoints/handlers/watch.go old mode 100755 new mode 100644 diff --git a/pkg/endpoints/installer.go b/pkg/endpoints/installer.go index 89f1caa97..4b9650b55 100644 --- a/pkg/endpoints/installer.go +++ b/pkg/endpoints/installer.go @@ -27,7 +27,6 @@ import ( "unicode" restful "github.com/emicklei/go-restful" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/conversion" "k8s.io/apimachinery/pkg/runtime" @@ -35,10 +34,13 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/endpoints/handlers" + "k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager" "k8s.io/apiserver/pkg/endpoints/handlers/negotiation" "k8s.io/apiserver/pkg/endpoints/metrics" + "k8s.io/apiserver/pkg/features" "k8s.io/apiserver/pkg/registry/rest" genericfilters "k8s.io/apiserver/pkg/server/filters" + utilfeature "k8s.io/apiserver/pkg/util/feature" ) const ( @@ -264,6 +266,10 @@ func (a *APIInstaller) registerResourceHandlers(path string, storage rest.Storag if err != nil { return nil, err } + versionedPatchOptions, err := a.group.Creater.New(optionsExternalVersion.WithKind("PatchOptions")) + if err != nil { + return nil, err + } versionedUpdateOptions, err := a.group.Creater.New(optionsExternalVersion.WithKind("UpdateOptions")) if err != nil { return nil, err @@ -511,7 +517,19 @@ func (a *APIInstaller) registerResourceHandlers(path string, storage rest.Storag if a.group.MetaGroupVersion != nil { reqScope.MetaGroupVersion = *a.group.MetaGroupVersion } - reqScope.OpenAPIModels = a.group.OpenAPIModels + if a.group.OpenAPIModels != nil && utilfeature.DefaultFeatureGate.Enabled(features.ServerSideApply) { + fm, err := fieldmanager.NewFieldManager( + a.group.OpenAPIModels, + a.group.UnsafeConvertor, + a.group.Defaulter, + fqKindToRegister.GroupVersion(), + reqScope.HubGroupVersion, + ) + if err != nil { + return nil, fmt.Errorf("failed to create field manager: %v", err) + } + reqScope.FieldManager = fm + } for _, action := range actions { producedObject := storageMeta.ProducesObject(action.Verb) if producedObject == nil { @@ -671,17 +689,20 @@ func (a *APIInstaller) registerResourceHandlers(path string, storage rest.Storag string(types.MergePatchType), string(types.StrategicMergePatchType), } + if utilfeature.DefaultFeatureGate.Enabled(features.ServerSideApply) { + supportedTypes = append(supportedTypes, string(types.ApplyPatchType)) + } handler := metrics.InstrumentRouteFunc(action.Verb, group, version, resource, subresource, requestScope, metrics.APIServerComponent, restfulPatchResource(patcher, reqScope, admit, supportedTypes)) route := ws.PATCH(action.Path).To(handler). Doc(doc). Param(ws.QueryParameter("pretty", "If 'true', then the output is pretty printed.")). - Consumes(string(types.JSONPatchType), string(types.MergePatchType), string(types.StrategicMergePatchType)). + Consumes(supportedTypes...). Operation("patch"+namespaced+kind+strings.Title(subresource)+operationSuffix). Produces(append(storageMeta.ProducesMIMETypes(action.Verb), mediaTypes...)...). Returns(http.StatusOK, "OK", producedObject). Reads(metav1.Patch{}). Writes(producedObject) - if err := addObjectParams(ws, route, versionedUpdateOptions); err != nil { + if err := addObjectParams(ws, route, versionedPatchOptions); err != nil { return nil, err } addParams(route, action.Params) diff --git a/pkg/endpoints/patchhandler_test.go b/pkg/endpoints/patchhandler_test.go index 6d0475c77..a630fd4e0 100644 --- a/pkg/endpoints/patchhandler_test.go +++ b/pkg/endpoints/patchhandler_test.go @@ -25,7 +25,10 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" genericapitesting "k8s.io/apiserver/pkg/endpoints/testing" + genericfeatures "k8s.io/apiserver/pkg/features" "k8s.io/apiserver/pkg/registry/rest" + utilfeature "k8s.io/apiserver/pkg/util/feature" + utilfeaturetesting "k8s.io/apiserver/pkg/util/feature/testing" ) func TestPatch(t *testing.T) { @@ -69,6 +72,58 @@ func TestPatch(t *testing.T) { } } +func TestForbiddenForceOnNonApply(t *testing.T) { + storage := map[string]rest.Storage{} + ID := "id" + item := &genericapitesting.Simple{ + ObjectMeta: metav1.ObjectMeta{ + Name: ID, + Namespace: "", // update should allow the client to send an empty namespace + UID: "uid", + }, + Other: "bar", + } + simpleStorage := SimpleRESTStorage{item: *item} + storage["simple"] = &simpleStorage + selfLinker := &setTestSelfLinker{ + t: t, + expectedSet: "/" + prefix + "/" + testGroupVersion.Group + "/" + testGroupVersion.Version + "/namespaces/default/simple/" + ID, + name: ID, + namespace: metav1.NamespaceDefault, + } + handler := handleLinker(storage, selfLinker) + server := httptest.NewServer(handler) + defer server.Close() + + client := http.Client{} + request, err := http.NewRequest("PATCH", server.URL+"/"+prefix+"/"+testGroupVersion.Group+"/"+testGroupVersion.Version+"/namespaces/default/simple/"+ID, bytes.NewReader([]byte(`{"labels":{"foo":"bar"}}`))) + request.Header.Set("Content-Type", "application/merge-patch+json; charset=UTF-8") + _, err = client.Do(request) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + request, err = http.NewRequest("PATCH", server.URL+"/"+prefix+"/"+testGroupVersion.Group+"/"+testGroupVersion.Version+"/namespaces/default/simple/"+ID+"?force=true", bytes.NewReader([]byte(`{"labels":{"foo":"bar"}}`))) + request.Header.Set("Content-Type", "application/merge-patch+json; charset=UTF-8") + response, err := client.Do(request) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if response.StatusCode != http.StatusUnprocessableEntity { + t.Errorf("Unexpected response %#v", response) + } + + request, err = http.NewRequest("PATCH", server.URL+"/"+prefix+"/"+testGroupVersion.Group+"/"+testGroupVersion.Version+"/namespaces/default/simple/"+ID+"?force=false", bytes.NewReader([]byte(`{"labels":{"foo":"bar"}}`))) + request.Header.Set("Content-Type", "application/merge-patch+json; charset=UTF-8") + response, err = client.Do(request) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if response.StatusCode != http.StatusUnprocessableEntity { + t.Errorf("Unexpected response %#v", response) + } +} + func TestPatchRequiresMatchingName(t *testing.T) { storage := map[string]rest.Storage{} ID := "id" @@ -97,3 +152,124 @@ func TestPatchRequiresMatchingName(t *testing.T) { t.Errorf("Unexpected response %#v", response) } } + +func TestPatchApply(t *testing.T) { + t.Skip("apply is being refactored") + defer utilfeaturetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, genericfeatures.ServerSideApply, true)() + storage := map[string]rest.Storage{} + item := &genericapitesting.Simple{ + ObjectMeta: metav1.ObjectMeta{ + Name: "id", + Namespace: "", + UID: "uid", + }, + Other: "bar", + } + simpleStorage := SimpleRESTStorage{item: *item} + storage["simple"] = &simpleStorage + handler := handle(storage) + server := httptest.NewServer(handler) + defer server.Close() + + client := http.Client{} + request, err := http.NewRequest( + "PATCH", + server.URL+"/"+prefix+"/"+testGroupVersion.Group+"/"+testGroupVersion.Version+"/namespaces/default/simple/id", + bytes.NewReader([]byte(`{"metadata":{"name":"id"}, "labels": {"test": "yes"}}`)), + ) + request.Header.Set("Content-Type", "application/apply-patch+yaml") + response, err := client.Do(request) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if response.StatusCode != http.StatusOK { + t.Errorf("Unexpected response %#v", response) + } + if simpleStorage.updated.Labels["test"] != "yes" { + t.Errorf(`Expected labels to have "test": "yes", found %q`, simpleStorage.updated.Labels["test"]) + } + if simpleStorage.updated.Other != "bar" { + t.Errorf(`Merge should have kept initial "bar" value for Other: %v`, simpleStorage.updated.Other) + } + if _, ok := simpleStorage.updated.ObjectMeta.ManagedFields["default"]; !ok { + t.Errorf(`Expected managedFields field to be set, but is empty`) + } +} + +func TestApplyAddsGVK(t *testing.T) { + t.Skip("apply is being refactored") + defer utilfeaturetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, genericfeatures.ServerSideApply, true)() + storage := map[string]rest.Storage{} + item := &genericapitesting.Simple{ + ObjectMeta: metav1.ObjectMeta{ + Name: "id", + Namespace: "", + UID: "uid", + }, + Other: "bar", + } + simpleStorage := SimpleRESTStorage{item: *item} + storage["simple"] = &simpleStorage + handler := handle(storage) + server := httptest.NewServer(handler) + defer server.Close() + + client := http.Client{} + request, err := http.NewRequest( + "PATCH", + server.URL+"/"+prefix+"/"+testGroupVersion.Group+"/"+testGroupVersion.Version+"/namespaces/default/simple/id", + bytes.NewReader([]byte(`{"metadata":{"name":"id"}, "labels": {"test": "yes"}}`)), + ) + request.Header.Set("Content-Type", "application/apply-patch+yaml") + response, err := client.Do(request) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if response.StatusCode != http.StatusOK { + t.Errorf("Unexpected response %#v", response) + } + // TODO: Need to fix this + expected := `{"apiVersion":"test.group/version","kind":"Simple","labels":{"test":"yes"},"metadata":{"name":"id"}}` + if simpleStorage.updated.ObjectMeta.ManagedFields["default"].APIVersion != expected { + t.Errorf( + `Expected managedFields field to be %q, got %q`, + expected, + simpleStorage.updated.ObjectMeta.ManagedFields["default"].APIVersion, + ) + } +} + +func TestApplyCreatesWithManagedFields(t *testing.T) { + t.Skip("apply is being refactored") + defer utilfeaturetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, genericfeatures.ServerSideApply, true)() + storage := map[string]rest.Storage{} + simpleStorage := SimpleRESTStorage{} + storage["simple"] = &simpleStorage + handler := handle(storage) + server := httptest.NewServer(handler) + defer server.Close() + + client := http.Client{} + request, err := http.NewRequest( + "PATCH", + server.URL+"/"+prefix+"/"+testGroupVersion.Group+"/"+testGroupVersion.Version+"/namespaces/default/simple/id", + bytes.NewReader([]byte(`{"metadata":{"name":"id"}, "labels": {"test": "yes"}}`)), + ) + request.Header.Set("Content-Type", "application/apply-patch+yaml") + response, err := client.Do(request) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if response.StatusCode != http.StatusOK { + t.Errorf("Unexpected response %#v", response) + } + // TODO: Need to fix this + expected := `{"apiVersion":"test.group/version","kind":"Simple","labels":{"test":"yes"},"metadata":{"name":"id"}}` + if simpleStorage.updated.ObjectMeta.ManagedFields["default"].APIVersion != expected { + t.Errorf( + `Expected managedFields field to be %q, got %q`, + expected, + simpleStorage.updated.ObjectMeta.ManagedFields["default"].APIVersion, + ) + } +} diff --git a/pkg/features/kube_features.go b/pkg/features/kube_features.go index ff4aa7044..966e91ac9 100644 --- a/pkg/features/kube_features.go +++ b/pkg/features/kube_features.go @@ -82,6 +82,12 @@ const ( // validation, merging, mutation can be tested without // committing. DryRun utilfeature.Feature = "DryRun" + + // owner: @apelisse, @lavalamp + // alpha: v1.11 + // + // Server-side apply. Merging happens on the server. + ServerSideApply utilfeature.Feature = "ServerSideApply" ) func init() { @@ -99,4 +105,5 @@ var defaultKubernetesFeatureGates = map[utilfeature.Feature]utilfeature.FeatureS APIResponseCompression: {Default: false, PreRelease: utilfeature.Alpha}, APIListChunking: {Default: true, PreRelease: utilfeature.Beta}, DryRun: {Default: true, PreRelease: utilfeature.Beta}, + ServerSideApply: {Default: false, PreRelease: utilfeature.Alpha}, } diff --git a/pkg/registry/rest/create.go b/pkg/registry/rest/create.go index 8e69cb76b..7d0c4a183 100644 --- a/pkg/registry/rest/create.go +++ b/pkg/registry/rest/create.go @@ -28,7 +28,9 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/features" "k8s.io/apiserver/pkg/storage/names" + utilfeature "k8s.io/apiserver/pkg/util/feature" ) // RESTCreateStrategy defines the minimum validation, accepted input, and @@ -92,6 +94,12 @@ func BeforeCreate(strategy RESTCreateStrategy, ctx context.Context, obj runtime. // Initializers are a deprecated alpha field and should not be saved objectMeta.SetInitializers(nil) + + // Ensure managedFields is not set unless the feature is enabled + if !utilfeature.DefaultFeatureGate.Enabled(features.ServerSideApply) { + objectMeta.SetManagedFields(nil) + } + // ClusterName is ignored and should not be saved if len(objectMeta.GetClusterName()) > 0 { objectMeta.SetClusterName("") diff --git a/pkg/registry/rest/update.go b/pkg/registry/rest/update.go index 048c35fa4..290b016f4 100644 --- a/pkg/registry/rest/update.go +++ b/pkg/registry/rest/update.go @@ -28,6 +28,8 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/features" + utilfeature "k8s.io/apiserver/pkg/util/feature" ) // RESTUpdateStrategy defines the minimum validation, accepted input, and @@ -106,6 +108,12 @@ func BeforeUpdate(strategy RESTUpdateStrategy, ctx context.Context, obj, old run oldMeta.SetInitializers(nil) objectMeta.SetInitializers(nil) + // Ensure managedFields state is removed unless ServerSideApply is enabled + if !utilfeature.DefaultFeatureGate.Enabled(features.ServerSideApply) { + oldMeta.SetManagedFields(nil) + objectMeta.SetManagedFields(nil) + } + strategy.PrepareForUpdate(ctx, obj, old) // ClusterName is ignored and should not be saved diff --git a/pkg/server/genericapiserver.go b/pkg/server/genericapiserver.go index da3c6c045..3974bf9b5 100644 --- a/pkg/server/genericapiserver.go +++ b/pkg/server/genericapiserver.go @@ -324,11 +324,7 @@ func (s preparedGenericAPIServer) NonBlockingRun(stopCh <-chan struct{}) error { } // installAPIResources is a private method for installing the REST storage backing each api groupversionresource -func (s *GenericAPIServer) installAPIResources(apiPrefix string, apiGroupInfo *APIGroupInfo) error { - openAPIGroupModels, err := s.getOpenAPIModelsForGroup(apiPrefix, apiGroupInfo) - if err != nil { - return fmt.Errorf("unable to get openapi models for group %v: %v", apiPrefix, err) - } +func (s *GenericAPIServer) installAPIResources(apiPrefix string, apiGroupInfo *APIGroupInfo, openAPIModels openapiproto.Models) error { for _, groupVersion := range apiGroupInfo.PrioritizedVersions { if len(apiGroupInfo.VersionedResourcesStorageMap[groupVersion.Version]) == 0 { klog.Warningf("Skipping API %v because it has no resources.", groupVersion) @@ -339,7 +335,7 @@ func (s *GenericAPIServer) installAPIResources(apiPrefix string, apiGroupInfo *A if apiGroupInfo.OptionsExternalVersion != nil { apiGroupVersion.OptionsExternalVersion = apiGroupInfo.OptionsExternalVersion } - apiGroupVersion.OpenAPIModels = openAPIGroupModels + apiGroupVersion.OpenAPIModels = openAPIModels if err := apiGroupVersion.InstallREST(s.Handler.GoRestfulContainer); err != nil { return fmt.Errorf("unable to setup API %v: %v", apiGroupInfo, err) @@ -353,7 +349,13 @@ func (s *GenericAPIServer) InstallLegacyAPIGroup(apiPrefix string, apiGroupInfo if !s.legacyAPIGroupPrefixes.Has(apiPrefix) { return fmt.Errorf("%q is not in the allowed legacy API prefixes: %v", apiPrefix, s.legacyAPIGroupPrefixes.List()) } - if err := s.installAPIResources(apiPrefix, apiGroupInfo); err != nil { + + openAPIModels, err := s.getOpenAPIModels(apiPrefix, apiGroupInfo) + if err != nil { + return fmt.Errorf("unable to get openapi models: %v", err) + } + + if err := s.installAPIResources(apiPrefix, apiGroupInfo, openAPIModels); err != nil { return err } @@ -364,49 +366,62 @@ func (s *GenericAPIServer) InstallLegacyAPIGroup(apiPrefix string, apiGroupInfo return nil } +// Exposes given api groups in the API. +func (s *GenericAPIServer) InstallAPIGroups(apiGroupInfos ...*APIGroupInfo) error { + for _, apiGroupInfo := range apiGroupInfos { + // Do not register empty group or empty version. Doing so claims /apis/ for the wrong entity to be returned. + // Catching these here places the error much closer to its origin + if len(apiGroupInfo.PrioritizedVersions[0].Group) == 0 { + return fmt.Errorf("cannot register handler with an empty group for %#v", *apiGroupInfo) + } + if len(apiGroupInfo.PrioritizedVersions[0].Version) == 0 { + return fmt.Errorf("cannot register handler with an empty version for %#v", *apiGroupInfo) + } + } + + openAPIModels, err := s.getOpenAPIModels(APIGroupPrefix, apiGroupInfos...) + if err != nil { + return fmt.Errorf("unable to get openapi models: %v", err) + } + + for _, apiGroupInfo := range apiGroupInfos { + if err := s.installAPIResources(APIGroupPrefix, apiGroupInfo, openAPIModels); err != nil { + return fmt.Errorf("unable to install api resources: %v", err) + } + + // setup discovery + // Install the version handler. + // Add a handler at /apis/ to enumerate all versions supported by this group. + apiVersionsForDiscovery := []metav1.GroupVersionForDiscovery{} + for _, groupVersion := range apiGroupInfo.PrioritizedVersions { + // Check the config to make sure that we elide versions that don't have any resources + if len(apiGroupInfo.VersionedResourcesStorageMap[groupVersion.Version]) == 0 { + continue + } + apiVersionsForDiscovery = append(apiVersionsForDiscovery, metav1.GroupVersionForDiscovery{ + GroupVersion: groupVersion.String(), + Version: groupVersion.Version, + }) + } + preferredVersionForDiscovery := metav1.GroupVersionForDiscovery{ + GroupVersion: apiGroupInfo.PrioritizedVersions[0].String(), + Version: apiGroupInfo.PrioritizedVersions[0].Version, + } + apiGroup := metav1.APIGroup{ + Name: apiGroupInfo.PrioritizedVersions[0].Group, + Versions: apiVersionsForDiscovery, + PreferredVersion: preferredVersionForDiscovery, + } + + s.DiscoveryGroupManager.AddGroup(apiGroup) + s.Handler.GoRestfulContainer.Add(discovery.NewAPIGroupHandler(s.Serializer, apiGroup).WebService()) + } + return nil +} + // Exposes the given api group in the API. func (s *GenericAPIServer) InstallAPIGroup(apiGroupInfo *APIGroupInfo) error { - // Do not register empty group or empty version. Doing so claims /apis/ for the wrong entity to be returned. - // Catching these here places the error much closer to its origin - if len(apiGroupInfo.PrioritizedVersions[0].Group) == 0 { - return fmt.Errorf("cannot register handler with an empty group for %#v", *apiGroupInfo) - } - if len(apiGroupInfo.PrioritizedVersions[0].Version) == 0 { - return fmt.Errorf("cannot register handler with an empty version for %#v", *apiGroupInfo) - } - - if err := s.installAPIResources(APIGroupPrefix, apiGroupInfo); err != nil { - return err - } - - // setup discovery - // Install the version handler. - // Add a handler at /apis/ to enumerate all versions supported by this group. - apiVersionsForDiscovery := []metav1.GroupVersionForDiscovery{} - for _, groupVersion := range apiGroupInfo.PrioritizedVersions { - // Check the config to make sure that we elide versions that don't have any resources - if len(apiGroupInfo.VersionedResourcesStorageMap[groupVersion.Version]) == 0 { - continue - } - apiVersionsForDiscovery = append(apiVersionsForDiscovery, metav1.GroupVersionForDiscovery{ - GroupVersion: groupVersion.String(), - Version: groupVersion.Version, - }) - } - preferredVersionForDiscovery := metav1.GroupVersionForDiscovery{ - GroupVersion: apiGroupInfo.PrioritizedVersions[0].String(), - Version: apiGroupInfo.PrioritizedVersions[0].Version, - } - apiGroup := metav1.APIGroup{ - Name: apiGroupInfo.PrioritizedVersions[0].Group, - Versions: apiVersionsForDiscovery, - PreferredVersion: preferredVersionForDiscovery, - } - - s.DiscoveryGroupManager.AddGroup(apiGroup) - s.Handler.GoRestfulContainer.Add(discovery.NewAPIGroupHandler(s.Serializer, apiGroup).WebService()) - - return nil + return s.InstallAPIGroups(apiGroupInfo) } func (s *GenericAPIServer) getAPIGroupVersion(apiGroupInfo *APIGroupInfo, groupVersion schema.GroupVersion, apiPrefix string) *genericapi.APIGroupVersion { @@ -455,12 +470,31 @@ func NewDefaultAPIGroupInfo(group string, scheme *runtime.Scheme, parameterCodec } } -// getOpenAPIModelsForGroup is a private method for getting the OpenAPI Schemas for each api group -func (s *GenericAPIServer) getOpenAPIModelsForGroup(apiPrefix string, apiGroupInfo *APIGroupInfo) (openapiproto.Models, error) { +// getOpenAPIModels is a private method for getting the OpenAPI models +func (s *GenericAPIServer) getOpenAPIModels(apiPrefix string, apiGroupInfos ...*APIGroupInfo) (openapiproto.Models, error) { if s.openAPIConfig == nil { return nil, nil } pathsToIgnore := openapiutil.NewTrie(s.openAPIConfig.IgnorePrefixes) + resourceNames := make([]string, 0) + for _, apiGroupInfo := range apiGroupInfos { + groupResources, err := getResourceNamesForGroup(apiPrefix, apiGroupInfo, pathsToIgnore) + if err != nil { + return nil, err + } + resourceNames = append(resourceNames, groupResources...) + } + + // Build the openapi definitions for those resources and convert it to proto models + openAPISpec, err := openapibuilder.BuildOpenAPIDefinitionsForResources(s.openAPIConfig, resourceNames...) + if err != nil { + return nil, err + } + return utilopenapi.ToProtoModels(openAPISpec) +} + +// getResourceNamesForGroup is a private method for getting the canonical names for each resource to build in an api group +func getResourceNamesForGroup(apiPrefix string, apiGroupInfo *APIGroupInfo, pathsToIgnore openapiutil.Trie) ([]string, error) { // Get the canonical names of every resource we need to build in this api group resourceNames := make([]string, 0) for _, groupVersion := range apiGroupInfo.PrioritizedVersions { @@ -481,10 +515,5 @@ func (s *GenericAPIServer) getOpenAPIModelsForGroup(apiPrefix string, apiGroupIn } } - // Build the openapi definitions for those resources and convert it to proto models - openAPISpec, err := openapibuilder.BuildOpenAPIDefinitionsForResources(s.openAPIConfig, resourceNames...) - if err != nil { - return nil, err - } - return utilopenapi.ToProtoModels(openAPISpec) + return resourceNames, nil }