diff --git a/Gopkg.lock b/Gopkg.lock index c888c14a01..fe6f143a7d 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -928,6 +928,7 @@ input-imports = [ "github.com/appscode/jsonpatch", "github.com/emicklei/go-restful", + "github.com/evanphx/json-patch", "github.com/ghodss/yaml", "github.com/go-logr/logr", "github.com/go-logr/logr/testing", diff --git a/pkg/client/client.go b/pkg/client/client.go index 88c3350c17..646d86a87e 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -130,6 +130,15 @@ func (c *client) Delete(ctx context.Context, obj runtime.Object, opts ...DeleteO return c.typedClient.Delete(ctx, obj, opts...) } +// Patch implements client.Client +func (c *client) Patch(ctx context.Context, obj runtime.Object, patch Patch, opts ...PatchOptionFunc) error { + _, ok := obj.(*unstructured.Unstructured) + if ok { + return c.unstructuredClient.Patch(ctx, obj, patch, opts...) + } + return c.typedClient.Patch(ctx, obj, patch, opts...) +} + // Get implements client.Client func (c *client) Get(ctx context.Context, key ObjectKey, obj runtime.Object) error { _, ok := obj.(*unstructured.Unstructured) diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index a701562fa3..b9b517b27a 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -18,9 +18,12 @@ package client_test import ( "context" + "encoding/json" "fmt" "sync/atomic" + "k8s.io/apimachinery/pkg/types" + . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" appsv1 "k8s.io/api/apps/v1" @@ -62,6 +65,7 @@ var _ = Describe("Client", func() { var count uint64 = 0 var replicaCount int32 = 2 var ns = "default" + var mergePatch []byte BeforeEach(func(done Done) { atomic.AddUint64(&count, 1) @@ -88,6 +92,15 @@ var _ = Describe("Client", func() { Spec: corev1.NodeSpec{}, } scheme = kscheme.Scheme + var err error + mergePatch, err = json.Marshal(map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "foo": "bar", + }, + }, + }) + Expect(err).NotTo(HaveOccurred()) close(done) }, serverSideTimeoutSeconds) @@ -964,6 +977,174 @@ var _ = Describe("Client", func() { }) }) + Describe("Patch", func() { + Context("with structured objects", func() { + It("should patch an existing object from a go struct", func(done Done) { + cl, err := client.New(cfg, client.Options{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cl).NotTo(BeNil()) + + By("initially creating a Deployment") + dep, err := clientset.AppsV1().Deployments(ns).Create(dep) + Expect(err).NotTo(HaveOccurred()) + + By("patching the Deployment") + err = cl.Patch(context.TODO(), dep, client.NewPatch(types.MergePatchType, mergePatch)) + Expect(err).NotTo(HaveOccurred()) + + By("validating patched Deployment has new annotation") + actual, err := clientset.AppsV1().Deployments(ns).Get(dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual.Annotations["foo"]).To(Equal("bar")) + + close(done) + }) + + It("should patch an existing object non-namespace object from a go struct", func(done Done) { + cl, err := client.New(cfg, client.Options{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cl).NotTo(BeNil()) + + By("initially creating a Node") + node, err := clientset.CoreV1().Nodes().Create(node) + Expect(err).NotTo(HaveOccurred()) + + By("patching the Node") + nodeName := node.Name + err = cl.Patch(context.TODO(), node, client.NewPatch(types.MergePatchType, mergePatch)) + Expect(err).NotTo(HaveOccurred()) + + By("validating the Node no longer exists") + actual, err := clientset.CoreV1().Nodes().Get(nodeName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual.Annotations["foo"]).To(Equal("bar")) + + close(done) + }) + + It("should fail if the object does not exists", func(done Done) { + cl, err := client.New(cfg, client.Options{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cl).NotTo(BeNil()) + + By("Patching node before it is ever created") + err = cl.Patch(context.TODO(), node, client.NewPatch(types.MergePatchType, mergePatch)) + Expect(err).To(HaveOccurred()) + + close(done) + }) + + PIt("should fail if the object doesn't have meta", func() { + + }) + + It("should fail if the object cannot be mapped to a GVK", func(done Done) { + By("creating client with empty Scheme") + emptyScheme := runtime.NewScheme() + cl, err := client.New(cfg, client.Options{Scheme: emptyScheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(cl).NotTo(BeNil()) + + By("initially creating a Deployment") + dep, err := clientset.AppsV1().Deployments(ns).Create(dep) + Expect(err).NotTo(HaveOccurred()) + + By("patching the Deployment fails") + err = cl.Patch(context.TODO(), dep, client.NewPatch(types.MergePatchType, mergePatch)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no kind is registered for the type")) + + close(done) + }) + + PIt("should fail if the GVK cannot be mapped to a Resource", func() { + + }) + }) + Context("with unstructured objects", func() { + It("should patch an existing object from a go struct", func(done Done) { + cl, err := client.New(cfg, client.Options{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cl).NotTo(BeNil()) + + By("initially creating a Deployment") + dep, err := clientset.AppsV1().Deployments(ns).Create(dep) + Expect(err).NotTo(HaveOccurred()) + + By("patching the Deployment") + depName := dep.Name + u := &unstructured.Unstructured{} + scheme.Convert(dep, u, nil) + u.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "apps", + Kind: "Deployment", + Version: "v1", + }) + err = cl.Patch(context.TODO(), u, client.NewPatch(types.MergePatchType, mergePatch)) + Expect(err).NotTo(HaveOccurred()) + + By("validating patched Deployment has new annotation") + actual, err := clientset.AppsV1().Deployments(ns).Get(depName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual.Annotations["foo"]).To(Equal("bar")) + + close(done) + }) + + It("should patch an existing object non-namespace object from a go struct", func(done Done) { + cl, err := client.New(cfg, client.Options{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cl).NotTo(BeNil()) + + By("initially creating a Node") + node, err := clientset.CoreV1().Nodes().Create(node) + Expect(err).NotTo(HaveOccurred()) + + By("patching the Node") + nodeName := node.Name + u := &unstructured.Unstructured{} + scheme.Convert(node, u, nil) + u.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "", + Kind: "Node", + Version: "v1", + }) + err = cl.Patch(context.TODO(), u, client.NewPatch(types.MergePatchType, mergePatch)) + Expect(err).NotTo(HaveOccurred()) + + By("validating pathed Node has new annotation") + actual, err := clientset.CoreV1().Nodes().Get(nodeName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual.Annotations["foo"]).To(Equal("bar")) + + close(done) + }) + + It("should fail if the object does not exist", func(done Done) { + cl, err := client.New(cfg, client.Options{}) + Expect(err).NotTo(HaveOccurred()) + Expect(cl).NotTo(BeNil()) + + By("Patching node before it is ever created") + u := &unstructured.Unstructured{} + scheme.Convert(node, u, nil) + u.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "", + Kind: "Node", + Version: "v1", + }) + err = cl.Patch(context.TODO(), node, client.NewPatch(types.MergePatchType, mergePatch)) + Expect(err).To(HaveOccurred()) + + close(done) + }) + }) + }) + Describe("Get", func() { Context("with structured objects", func() { It("should fetch an existing object for a go struct", func(done Done) { diff --git a/pkg/client/fake/client.go b/pkg/client/fake/client.go index 905f34cbb3..81b5864692 100644 --- a/pkg/client/fake/client.go +++ b/pkg/client/fake/client.go @@ -194,6 +194,34 @@ func (c *fakeClient) Update(ctx context.Context, obj runtime.Object, opts ...cli return c.tracker.Update(gvr, obj, accessor.GetNamespace()) } +func (c *fakeClient) Patch(ctx context.Context, obj runtime.Object, patch client.Patch, opts ...client.PatchOptionFunc) error { + gvr, err := getGVRFromObject(obj, c.scheme) + if err != nil { + return err + } + accessor, err := meta.Accessor(obj) + if err != nil { + return err + } + + reaction := testing.ObjectReaction(c.tracker) + handled, o, err := reaction(testing.NewPatchAction(gvr, accessor.GetNamespace(), accessor.GetName(), patch.Type(), patch.Data())) + if err != nil { + return err + } + if !handled { + panic("tracker could not handle patch method") + } + + j, err := json.Marshal(o) + if err != nil { + return err + } + decoder := scheme.Codecs.UniversalDecoder() + _, _, err = decoder.Decode(j, nil, obj) + return err +} + func (c *fakeClient) Status() client.StatusWriter { return &fakeStatusWriter{client: c} } diff --git a/pkg/client/fake/client_test.go b/pkg/client/fake/client_test.go index c96f996a20..0cbc09a2a3 100644 --- a/pkg/client/fake/client_test.go +++ b/pkg/client/fake/client_test.go @@ -17,6 +17,8 @@ limitations under the License. package fake import ( + "encoding/json" + . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" @@ -205,6 +207,30 @@ var _ = Describe("Fake client", func() { Expect(obj).To(Equal(cm)) }) }) + + It("should be able to Patch", func() { + By("Patching a deployment") + mergePatch, err := json.Marshal(map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "foo": "bar", + }, + }, + }) + Expect(err).NotTo(HaveOccurred()) + err = cl.Patch(nil, dep, client.NewPatch(types.JSONPatchType, mergePatch)) + Expect(err).NotTo(HaveOccurred()) + + By("Getting the patched deployment") + namespacedName := types.NamespacedName{ + Name: "test-deployment", + Namespace: "ns1", + } + obj := &appsv1.Deployment{} + err = cl.Get(nil, namespacedName, obj) + Expect(err).NotTo(HaveOccurred()) + Expect(obj.Annotations["foo"]).To(Equal("bar")) + }) } Context("with default scheme.Scheme", func() { diff --git a/pkg/client/interfaces.go b/pkg/client/interfaces.go index 1f075a7ec0..c027f18de8 100644 --- a/pkg/client/interfaces.go +++ b/pkg/client/interfaces.go @@ -39,6 +39,14 @@ func ObjectKeyFromObject(obj runtime.Object) (ObjectKey, error) { return ObjectKey{Namespace: accessor.GetNamespace(), Name: accessor.GetName()}, nil } +// Patch is a patch that can be applied to a Kubernetes object. +type Patch interface { + // Type is the PatchType of the patch. + Type() types.PatchType + // Data is the raw data representing the patch. + Data() []byte +} + // TODO(directxman12): is there a sane way to deal with get/delete options? // Reader knows how to read and list Kubernetes objects. @@ -65,6 +73,10 @@ type Writer interface { // Update updates the given obj in the Kubernetes cluster. obj must be a // struct pointer so that obj can be updated with the content returned by the Server. Update(ctx context.Context, obj runtime.Object, opts ...UpdateOptionFunc) error + + // Patch patches the given obj in the Kubernetes cluster. obj must be a + // struct pointer so that obj can be updated with the content returned by the Server. + Patch(ctx context.Context, obj runtime.Object, patch Patch, opts ...PatchOptionFunc) error } // StatusClient knows how to create a client which can update status subresource @@ -428,3 +440,12 @@ func UpdateDryRunAll() UpdateOptionFunc { opts.DryRun = []string{metav1.DryRunAll} } } + +// PatchOptions contains options for patch requests. +type PatchOptions struct { +} + +// PatchOptionFunc is a function that mutates a PatchOptions struct. It implements +// the functional options pattern. See +// https://github.com/tmrts/go-patterns/blob/master/idiom/functional-options.md. +type PatchOptionFunc func(*PatchOptions) diff --git a/pkg/client/patch.go b/pkg/client/patch.go new file mode 100644 index 0000000000..9fa62927a9 --- /dev/null +++ b/pkg/client/patch.go @@ -0,0 +1,41 @@ +/* +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 client + +import ( + "k8s.io/apimachinery/pkg/types" +) + +type patch struct { + patchType types.PatchType + data []byte +} + +// Type implements Patch. +func (s *patch) Type() types.PatchType { + return s.patchType +} + +// Data implements Patch. +func (s *patch) Data() []byte { + return s.data +} + +// NewPatch constructs a new Patch with the given PatchType and data. +func NewPatch(patchType types.PatchType, data []byte) Patch { + return &patch{patchType, data} +} diff --git a/pkg/client/typed_client.go b/pkg/client/typed_client.go index 82d6cc12ef..7b18147db7 100644 --- a/pkg/client/typed_client.go +++ b/pkg/client/typed_client.go @@ -86,6 +86,23 @@ func (c *typedClient) Delete(ctx context.Context, obj runtime.Object, opts ...De Error() } +// Patch implements client.Client +func (c *typedClient) Patch(ctx context.Context, obj runtime.Object, patch Patch, opts ...PatchOptionFunc) error { + o, err := c.cache.getObjMeta(obj) + if err != nil { + return err + } + + return o.Patch(patch.Type()). + NamespaceIfScoped(o.GetNamespace(), o.isNamespaced()). + Resource(o.resource()). + Name(o.GetName()). + Body(patch.Data()). + Context(ctx). + Do(). + Into(obj) +} + // Get implements client.Client func (c *typedClient) Get(ctx context.Context, key ObjectKey, obj runtime.Object) error { r, err := c.cache.getResource(obj) diff --git a/pkg/client/unstructured_client.go b/pkg/client/unstructured_client.go index c7a199586e..5cf10d745d 100644 --- a/pkg/client/unstructured_client.go +++ b/pkg/client/unstructured_client.go @@ -91,6 +91,25 @@ func (uc *unstructuredClient) Delete(_ context.Context, obj runtime.Object, opts return err } +// Patch implements client.Client +func (uc *unstructuredClient) Patch(_ context.Context, obj runtime.Object, patch Patch, opts ...PatchOptionFunc) error { + u, ok := obj.(*unstructured.Unstructured) + if !ok { + return fmt.Errorf("unstructured client did not understand object: %T", obj) + } + r, err := uc.getResourceInterface(u.GroupVersionKind(), u.GetNamespace()) + if err != nil { + return err + } + + i, err := r.Patch(u.GetName(), patch.Type(), patch.Data(), metav1.UpdateOptions{}) + if err != nil { + return err + } + u.Object = i.Object + return nil +} + // Get implements client.Client func (uc *unstructuredClient) Get(_ context.Context, key ObjectKey, obj runtime.Object) error { u, ok := obj.(*unstructured.Unstructured) diff --git a/pkg/controller/controllerutil/controllerutil.go b/pkg/controller/controllerutil/controllerutil.go index e9f44b65c5..41d156267c 100644 --- a/pkg/controller/controllerutil/controllerutil.go +++ b/pkg/controller/controllerutil/controllerutil.go @@ -18,9 +18,13 @@ package controllerutil import ( "context" + "encoding/json" "fmt" "reflect" + "github.com/evanphx/json-patch" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -168,3 +172,35 @@ func mutate(f MutateFn, key client.ObjectKey, obj runtime.Object) error { // MutateFn is a function which mutates the existing object into it's desired state. type MutateFn func() error + +// CreateMergePatch creates a MergePatch by applying the given MutateFn on the given object. +// It is assumed that MutateFn modifies the given obj. +func CreateMergePatch(obj runtime.Object, f MutateFn) (client.Patch, error) { + key, err := client.ObjectKeyFromObject(obj) + if err != nil { + return nil, err + } + + original := obj.DeepCopyObject() + + if err := mutate(f, key, obj); err != nil { + return nil, err + } + + originalJSON, err := json.Marshal(original) + if err != nil { + return nil, err + } + + modifiedJSON, err := json.Marshal(obj) + if err != nil { + return nil, err + } + + data, err := jsonpatch.CreateMergePatch(originalJSON, modifiedJSON) + if err != nil { + return nil, err + } + + return client.NewPatch(types.MergePatchType, data), nil +} diff --git a/pkg/controller/controllerutil/controllerutil_test.go b/pkg/controller/controllerutil/controllerutil_test.go index 15fed67f60..3d7c88db99 100644 --- a/pkg/controller/controllerutil/controllerutil_test.go +++ b/pkg/controller/controllerutil/controllerutil_test.go @@ -265,6 +265,42 @@ var _ = Describe("Controllerutil", func() { Expect(err).To(HaveOccurred()) }) }) + + Describe("CreateMergePatch", func() { + var cm *corev1.ConfigMap + + BeforeEach(func() { + cm = &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "cm", + }, + } + }) + + It("creates a merge patch with the modifications applied during the mutation", func() { + const ( + annotationKey = "test" + annotationValue = "foo" + ) + + By("creating a merge patch") + patch, err := controllerutil.CreateMergePatch(cm, func() error { + By("adding an annotation") + metav1.SetMetaDataAnnotation(&cm.ObjectMeta, annotationKey, annotationValue) + return nil + }) + + By("returning no error") + Expect(err).NotTo(HaveOccurred()) + + By("returning a patch with type MergePatch") + Expect(patch.Type()).To(Equal(types.MergePatchType)) + + By("returning a patch with data only containing the annotation change") + Expect(patch.Data()).To(Equal([]byte(fmt.Sprintf(`{"metadata":{"annotations":{"%s":"%s"}}}`, annotationKey, annotationValue)))) + }) + }) }) var _ metav1.Object = &errMetaObj{}