diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index aaefc53a38..81a60ea88b 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -33,9 +33,9 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-runtime/pkg/client" - kscheme "k8s.io/client-go/kubernetes/scheme" + + "sigs.k8s.io/controller-runtime/pkg/client" ) const serverSideTimeoutSeconds = 10 diff --git a/pkg/client/dryrun.go b/pkg/client/dryrun.go new file mode 100644 index 0000000000..ced0548b1a --- /dev/null +++ b/pkg/client/dryrun.go @@ -0,0 +1,95 @@ +/* +Copyright 2020 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 ( + "context" + + "k8s.io/apimachinery/pkg/runtime" +) + +// NewDryRunClient wraps an existing client and enforces DryRun mode +// on all mutating api calls. +func NewDryRunClient(c Client) Client { + return &dryRunClient{client: c} +} + +var _ Client = &dryRunClient{} + +// dryRunClient is a Client that wraps another Client in order to enforce DryRun mode. +type dryRunClient struct { + client Client +} + +// Create implements client.Client +func (c *dryRunClient) Create(ctx context.Context, obj runtime.Object, opts ...CreateOption) error { + return c.client.Create(ctx, obj, append(opts, DryRunAll)...) +} + +// Update implements client.Client +func (c *dryRunClient) Update(ctx context.Context, obj runtime.Object, opts ...UpdateOption) error { + return c.client.Update(ctx, obj, append(opts, DryRunAll)...) +} + +// Delete implements client.Client +func (c *dryRunClient) Delete(ctx context.Context, obj runtime.Object, opts ...DeleteOption) error { + return c.client.Delete(ctx, obj, append(opts, DryRunAll)...) +} + +// DeleteAllOf implements client.Client +func (c *dryRunClient) DeleteAllOf(ctx context.Context, obj runtime.Object, opts ...DeleteAllOfOption) error { + return c.client.DeleteAllOf(ctx, obj, append(opts, DryRunAll)...) +} + +// Patch implements client.Client +func (c *dryRunClient) Patch(ctx context.Context, obj runtime.Object, patch Patch, opts ...PatchOption) error { + return c.client.Patch(ctx, obj, patch, append(opts, DryRunAll)...) +} + +// Get implements client.Client +func (c *dryRunClient) Get(ctx context.Context, key ObjectKey, obj runtime.Object) error { + return c.client.Get(ctx, key, obj) +} + +// List implements client.Client +func (c *dryRunClient) List(ctx context.Context, obj runtime.Object, opts ...ListOption) error { + return c.client.List(ctx, obj, opts...) +} + +// Status implements client.StatusClient +func (c *dryRunClient) Status() StatusWriter { + return &dryRunStatusWriter{client: c.client.Status()} +} + +// ensure dryRunStatusWriter implements client.StatusWriter +var _ StatusWriter = &dryRunStatusWriter{} + +// dryRunStatusWriter is client.StatusWriter that writes status subresource with dryRun mode +// enforced. +type dryRunStatusWriter struct { + client StatusWriter +} + +// Update implements client.StatusWriter +func (sw *dryRunStatusWriter) Update(ctx context.Context, obj runtime.Object, opts ...UpdateOption) error { + return sw.client.Update(ctx, obj, append(opts, DryRunAll)...) +} + +// Patch implements client.StatusWriter +func (sw *dryRunStatusWriter) Patch(ctx context.Context, obj runtime.Object, patch Patch, opts ...PatchOption) error { + return sw.client.Patch(ctx, obj, patch, append(opts, DryRunAll)...) +} diff --git a/pkg/client/dryrun_test.go b/pkg/client/dryrun_test.go new file mode 100644 index 0000000000..0a46e5617d --- /dev/null +++ b/pkg/client/dryrun_test.go @@ -0,0 +1,264 @@ +/* +Copyright 2020 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_test + +import ( + "context" + "fmt" + "sync/atomic" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ = Describe("DryRunClient", func() { + var dep *appsv1.Deployment + var count uint64 = 0 + var replicaCount int32 = 2 + var ns = "default" + ctx := context.Background() + + getClient := func() client.Client { + nonDryRunClient, err := client.New(cfg, client.Options{}) + Expect(err).NotTo(HaveOccurred()) + Expect(nonDryRunClient).NotTo(BeNil()) + return client.NewDryRunClient(nonDryRunClient) + } + + BeforeEach(func() { + atomic.AddUint64(&count, 1) + dep = &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("dry-run-deployment-%v", count), + Namespace: ns, + Labels: map[string]string{"name": fmt.Sprintf("dry-run-deployment-%v", count)}, + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicaCount, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"foo": "bar"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"foo": "bar"}}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}}, + }, + }, + } + + var err error + dep, err = clientset.AppsV1().Deployments(ns).Create(ctx, dep, metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + deleteDeployment(ctx, dep, ns) + }) + + It("should successfully Get an object", func() { + name := types.NamespacedName{Namespace: ns, Name: dep.Name} + result := &appsv1.Deployment{} + + Expect(getClient().Get(ctx, name, result)).NotTo(HaveOccurred()) + Expect(result).To(BeEquivalentTo(dep)) + }) + + It("should successfully List objects", func() { + result := &appsv1.DeploymentList{} + opts := client.MatchingLabels(dep.Labels) + + Expect(getClient().List(ctx, result, opts)).NotTo(HaveOccurred()) + + Expect(len(result.Items)).To(BeEquivalentTo(1)) + Expect(result.Items[0]).To(BeEquivalentTo(*dep)) + }) + + It("should not create an object", func() { + newDep := dep.DeepCopy() + newDep.Name = "new-deployment" + + Expect(getClient().Create(ctx, newDep)).ToNot(HaveOccurred()) + + _, err := clientset.AppsV1().Deployments(ns).Get(ctx, newDep.Name, metav1.GetOptions{}) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }) + + It("should not create an object with opts", func() { + newDep := dep.DeepCopy() + newDep.Name = "new-deployment" + opts := &client.CreateOptions{DryRun: []string{"Bye", "Pippa"}} + + Expect(getClient().Create(ctx, newDep, opts)).ToNot(HaveOccurred()) + + _, err := clientset.AppsV1().Deployments(ns).Get(ctx, newDep.Name, metav1.GetOptions{}) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }) + + It("should refuse a create request for an invalid object", func() { + changedDep := dep.DeepCopy() + changedDep.Spec.Template.Spec.Containers = nil + + err := getClient().Create(ctx, changedDep) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + }) + + It("should not change objects via update", func() { + changedDep := dep.DeepCopy() + *changedDep.Spec.Replicas = 2 + + Expect(getClient().Update(ctx, changedDep)).ToNot(HaveOccurred()) + + actual, err := clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual).To(BeEquivalentTo(dep)) + }) + + It("should not change objects via update with opts", func() { + changedDep := dep.DeepCopy() + *changedDep.Spec.Replicas = 2 + opts := &client.UpdateOptions{DryRun: []string{"Bye", "Pippa"}} + + Expect(getClient().Update(ctx, changedDep, opts)).ToNot(HaveOccurred()) + + actual, err := clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual).To(BeEquivalentTo(dep)) + }) + + It("should refuse an update request for an invalid change", func() { + changedDep := dep.DeepCopy() + changedDep.Spec.Template.Spec.Containers = nil + + err := getClient().Update(ctx, changedDep) + Expect(apierrors.IsInvalid(err)).To(BeTrue()) + }) + + It("should not change objects via patch", func() { + changedDep := dep.DeepCopy() + *changedDep.Spec.Replicas = 2 + + Expect(getClient().Patch(ctx, changedDep, client.MergeFrom(dep))).ToNot(HaveOccurred()) + + actual, err := clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual).To(BeEquivalentTo(dep)) + }) + + It("should not change objects via patch with opts", func() { + changedDep := dep.DeepCopy() + *changedDep.Spec.Replicas = 2 + opts := &client.PatchOptions{DryRun: []string{"Bye", "Pippa"}} + + Expect(getClient().Patch(ctx, changedDep, client.MergeFrom(dep), opts)).ToNot(HaveOccurred()) + + actual, err := clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual).To(BeEquivalentTo(dep)) + }) + + It("should not delete objects", func() { + Expect(getClient().Delete(ctx, dep)).NotTo(HaveOccurred()) + + actual, err := clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual).To(BeEquivalentTo(dep)) + }) + + It("should not delete objects with opts", func() { + opts := &client.DeleteOptions{DryRun: []string{"Bye", "Pippa"}} + + Expect(getClient().Delete(ctx, dep, opts)).NotTo(HaveOccurred()) + + actual, err := clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual).To(BeEquivalentTo(dep)) + }) + + It("should not delete objects via deleteAllOf", func() { + opts := []client.DeleteAllOfOption{client.InNamespace(ns), client.MatchingLabels(dep.Labels)} + + Expect(getClient().DeleteAllOf(ctx, dep, opts...)).NotTo(HaveOccurred()) + + actual, err := clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual).To(BeEquivalentTo(dep)) + }) + + It("should not change objects via update status", func() { + changedDep := dep.DeepCopy() + changedDep.Status.Replicas = 99 + + Expect(getClient().Status().Update(ctx, changedDep)).NotTo(HaveOccurred()) + + actual, err := clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual).To(BeEquivalentTo(dep)) + }) + + It("should not change objects via update status with opts", func() { + changedDep := dep.DeepCopy() + changedDep.Status.Replicas = 99 + opts := &client.UpdateOptions{DryRun: []string{"Bye", "Pippa"}} + + Expect(getClient().Status().Update(ctx, changedDep, opts)).NotTo(HaveOccurred()) + + actual, err := clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual).To(BeEquivalentTo(dep)) + }) + + It("should not change objects via status patch", func() { + changedDep := dep.DeepCopy() + changedDep.Status.Replicas = 99 + + Expect(getClient().Status().Patch(ctx, changedDep, client.MergeFrom(dep))).ToNot(HaveOccurred()) + + actual, err := clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual).To(BeEquivalentTo(dep)) + }) + + It("should not change objects via status patch with opts", func() { + changedDep := dep.DeepCopy() + changedDep.Status.Replicas = 99 + + opts := &client.PatchOptions{DryRun: []string{"Bye", "Pippa"}} + + Expect(getClient().Status().Patch(ctx, changedDep, client.MergeFrom(dep), opts)).ToNot(HaveOccurred()) + + actual, err := clientset.AppsV1().Deployments(ns).Get(ctx, dep.Name, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(actual).NotTo(BeNil()) + Expect(actual).To(BeEquivalentTo(dep)) + }) +}) diff --git a/pkg/client/options.go b/pkg/client/options.go index fe4bb2c41c..131bdc2a04 100644 --- a/pkg/client/options.go +++ b/pkg/client/options.go @@ -90,6 +90,9 @@ func (dryRunAll) ApplyToPatch(opts *PatchOptions) { func (dryRunAll) ApplyToDelete(opts *DeleteOptions) { opts.DryRun = []string{metav1.DryRunAll} } +func (dryRunAll) ApplyToDeleteAllOf(opts *DeleteAllOfOptions) { + opts.DryRun = []string{metav1.DryRunAll} +} // FieldOwner set the field manager name for the given server-side apply patch. type FieldOwner string diff --git a/pkg/manager/manager.go b/pkg/manager/manager.go index 852eb787b9..dbd23d197e 100644 --- a/pkg/manager/manager.go +++ b/pkg/manager/manager.go @@ -184,6 +184,10 @@ type Options struct { // use the cache for reads and the client for writes. NewClient NewClientFunc + // DryRunClient specifies whether the client should be configured to enforce + // dryRun mode. + DryRunClient bool + // EventBroadcaster records Events emitted by the manager and sends them to the Kubernetes API // Use this to customize the event correlator and spam filter EventBroadcaster record.EventBroadcaster @@ -257,6 +261,11 @@ func New(config *rest.Config, options Options) (Manager, error) { if err != nil { return nil, err } + + if options.DryRunClient { + writeObj = client.NewDryRunClient(writeObj) + } + // Create the recorder provider to inject event recorders for the components. // TODO(directxman12): the log for the event provider should have a context (name, tags, etc) specific // to the particular controller that it's being injected into, rather than a generic one like is here.