diff --git a/pkg/envtest/komega/default.go b/pkg/envtest/komega/default.go new file mode 100644 index 0000000000..1a58984cb6 --- /dev/null +++ b/pkg/envtest/komega/default.go @@ -0,0 +1,55 @@ +package komega + +import ( + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// defaultK is the Komega used by the package global functions. +var defaultK = &komega{} + +// SetDefaultClient sets the client used by the package global functions. +func SetDefaultClient(c client.Client) { + defaultK = &komega{client: c} +} + +func checkDefaultClient() { + if defaultK.client == nil { + panic("Default Komega's client is not set. Use WithDefaultClient to set it.") + } +} + +// Get fetches an object until the forwarded error matches. +func Get(obj client.Object) func() error { + checkDefaultClient() + return defaultK.Get(obj) +} + +// List fetches a list until the forwarded error matches. +func List(obj client.ObjectList, opts ...client.ListOption) func() error { + checkDefaultClient() + return defaultK.List(obj, opts...) +} + +// Update tries to update an object by applying the updateFunc until the forwarded error matches. +func Update(obj client.Object, f UpdateFunc, opts ...client.UpdateOption) func() error { + checkDefaultClient() + return defaultK.Update(obj, f, opts...) +} + +// UpdateStatus tries to update an object's status by applying the updateFunc until the forwarded error matches. +func UpdateStatus(obj client.Object, f UpdateFunc, opts ...client.UpdateOption) func() error { + checkDefaultClient() + return defaultK.UpdateStatus(obj, f, opts...) +} + +// Object +func Object(obj client.Object) func() (client.Object, error) { + checkDefaultClient() + return defaultK.Object(obj) +} + +// ObjectList +func ObjectList(obj client.ObjectList, opts ...client.ListOption) func() (client.ObjectList, error) { + checkDefaultClient() + return defaultK.ObjectList(obj, opts...) +} diff --git a/pkg/envtest/komega/interfaces.go b/pkg/envtest/komega/interfaces.go index 8b355637a0..f4a8135647 100644 --- a/pkg/envtest/komega/interfaces.go +++ b/pkg/envtest/komega/interfaces.go @@ -18,39 +18,64 @@ package komega import ( "context" - "time" - "github.com/onsi/gomega" - "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/controller-runtime/pkg/client" ) // Komega is the root interface that the Matcher implements. type Komega interface { - KomegaAsync - KomegaSync - WithContext(context.Context) Komega -} + // Get returns a function that fetches a resource and returns the occuring error. + // It can be used with gomega.Eventually() like this + // deployment := appsv1.Deployment{ ... } + // gomega.Eventually(k.Get(&deployment)).To(gomega.Succeed()) + // By calling the returned function directly it can also be used with gomega.Expect(k.Get(...)()).To(...) + Get(client.Object) func() error -// KomegaSync is the interface for any sync assertions that -// the matcher implements. -type KomegaSync interface { - Create(client.Object, ...client.CreateOption) gomega.GomegaAssertion - Delete(client.Object, ...client.DeleteOption) gomega.GomegaAssertion - WithExtras(...interface{}) KomegaSync -} + // List returns a function that lists resources and returns the occuring error. + // It can be used with gomega.Eventually() like this + // deployments := v1.DeploymentList{ ... } + // gomega.Eventually(k.List(&deployments)).To(gomega.Succeed()) + // By calling the returned function directly it can also be used as gomega.Expect(k.List(...)()).To(...) + List(client.ObjectList, ...client.ListOption) func() error + + // Update returns a function that fetches a resource, applies the provided update function and then updates the resource. + // It can be used with gomega.Eventually() like this: + // deployment := appsv1.Deployment{ ... } + // gomega.Eventually(k.Update(&deployment, func (o client.Object) { + // deployment.Spec.Replicas = 3 + // return &deployment + // })).To(gomega.Scucceed()) + // By calling the returned function directly it can also be used as gomega.Expect(k.Update(...)()).To(...) + Update(client.Object, UpdateFunc, ...client.UpdateOption) func() error -// KomegaAsync is the interface for any async assertions that -// the matcher implements. -type KomegaAsync interface { - Consistently(runtime.Object, ...client.ListOption) gomega.AsyncAssertion - Eventually(runtime.Object, ...client.ListOption) gomega.AsyncAssertion - Get(client.Object) gomega.AsyncAssertion - List(client.ObjectList, ...client.ListOption) gomega.AsyncAssertion - Update(client.Object, UpdateFunc, ...client.UpdateOption) gomega.AsyncAssertion - UpdateStatus(client.Object, UpdateFunc, ...client.UpdateOption) gomega.AsyncAssertion - WithTimeout(time.Duration) KomegaAsync - WithPollInterval(time.Duration) KomegaAsync + // UpdateStatus returns a function that fetches a resource, applies the provided update function and then updates the resource's status. + // It can be used with gomega.Eventually() like this: + // deployment := appsv1.Deployment{ ... } + // gomega.Eventually(k.Update(&deployment, func (o client.Object) { + // deployment.Status.AvailableReplicas = 1 + // return &deployment + // })).To(gomega.Scucceed()) + // By calling the returned function directly it can also be used as gomega.Expect(k.UpdateStatus(...)()).To(...) + UpdateStatus(client.Object, UpdateFunc, ...client.UpdateOption) func() error + + // Object returns a function that fetches a resource and returns the object. + // It can be used with gomega.Eventually() like this: + // deployment := appsv1.Deployment{ ... } + // gomega.Eventually(k.Object(&deployment)).To(HaveField("Spec.Replicas", gomega.Equal(pointer.Int32(3)))) + // By calling the returned function directly it can also be used as gomega.Expect(k.Object(...)()).To(...) + Object(client.Object) func() (client.Object, error) + + // ObjectList returns a function that fetches a resource and returns the object. + // It can be used with gomega.Eventually() like this: + // deployments := appsv1.DeploymentList{ ... } + // gomega.Eventually(k.ObjectList(&deployments)).To(HaveField("Items", HaveLen(1))) + // By calling the returned function directly it can also be used as gomega.Expect(k.ObjectList(...)()).To(...) + ObjectList(client.ObjectList, ...client.ListOption) func() (client.ObjectList, error) + + // WithClient returns a copy that uses the given client. + WithClient(client.Client) Komega + // WithContext returns a copy that uses the given context. + WithContext(context.Context) Komega } // UpdateFunc modifies the object fetched from the API server before sending diff --git a/pkg/envtest/komega/komega.go b/pkg/envtest/komega/komega.go new file mode 100644 index 0000000000..75ea795e16 --- /dev/null +++ b/pkg/envtest/komega/komega.go @@ -0,0 +1,127 @@ +/* +Copyright 2021 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 komega + +import ( + "context" + + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// komega is a collection of +type komega struct { + ctx context.Context + client client.Client +} + +var _ Komega = &komega{} + +// New creates a new Komega instance with the given client. +func New(c client.Client) Komega { + return &komega{ + client: c, + } +} + +// WithContext returns a copy that uses the given context. +func (k komega) WithContext(ctx context.Context) Komega { + k.ctx = ctx + return &k +} + +// WithClient returns a copy that uses the given client. +func (k komega) WithClient(c client.Client) Komega { + k.client = c + return &k +} + +// context returns the matcher context if one has been set or context.Background() otherwise. +func (k *komega) context() context.Context { + if k.ctx == nil { + return context.Background() + } + return k.ctx +} + +// Get returns a function that fetches a resource and returns the occuring error. +func (k *komega) Get(obj client.Object) func() error { + key := types.NamespacedName{ + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + } + return func() error { + return k.client.Get(k.context(), key, obj) + } +} + +// List returns a function that lists resources and returns the occuring error. +func (k *komega) List(obj client.ObjectList, opts ...client.ListOption) func() error { + return func() error { + return k.client.List(k.context(), obj, opts...) + } +} + +// Update returns a function that fetches a resource, applies the provided update function and then updates the resource. +func (k *komega) Update(obj client.Object, updateFunc UpdateFunc, opts ...client.UpdateOption) func() error { + key := types.NamespacedName{ + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + } + return func() error { + err := k.client.Get(k.context(), key, obj) + if err != nil { + return err + } + return k.client.Update(k.context(), updateFunc(obj), opts...) + } +} + +// UpdateStatus returns a function that fetches a resource, applies the provided update function and then updates the resource's status. +func (k *komega) UpdateStatus(obj client.Object, updateFunc UpdateFunc, opts ...client.UpdateOption) func() error { + key := types.NamespacedName{ + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + } + return func() error { + err := k.client.Get(k.context(), key, obj) + if err != nil { + return err + } + return k.client.Status().Update(k.context(), updateFunc(obj), opts...) + } +} + +// Object returns a function that fetches a resource and returns the object. +func (k *komega) Object(obj client.Object) func() (client.Object, error) { + key := types.NamespacedName{ + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + } + return func() (client.Object, error) { + err := k.client.Get(k.context(), key, obj) + return obj, err + } +} + +// ObjectList returns a function that fetches a resource and returns the object. +func (k *komega) ObjectList(obj client.ObjectList, opts ...client.ListOption) func() (client.ObjectList, error) { + return func() (client.ObjectList, error) { + err := k.client.List(k.context(), obj, opts...) + return obj, err + } +} diff --git a/pkg/envtest/komega/komega_test.go b/pkg/envtest/komega/komega_test.go new file mode 100644 index 0000000000..e25ec0641a --- /dev/null +++ b/pkg/envtest/komega/komega_test.go @@ -0,0 +1,140 @@ +package komega + +import ( + "testing" + + gomega "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/pointer" + + "sigs.k8s.io/controller-runtime/pkg/client" + fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func exampleDeployment() *appsv1.Deployment { + return &appsv1.Deployment{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Name: "test", + }, + Spec: appsv1.DeploymentSpec{ + Replicas: pointer.Int32(5), + }, + } +} + +func createFakeClient() client.Client { + return fakeclient.NewClientBuilder(). + WithObjects(exampleDeployment()). + Build() +} + +func TestGet(t *testing.T) { + g := gomega.NewWithT(t) + + fc := createFakeClient() + k := New(fc) + + fetched := appsv1.Deployment{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Name: "test", + }, + } + g.Eventually(k.Get(&fetched)).Should(gomega.Succeed()) + + g.Expect(*fetched.Spec.Replicas).To(gomega.BeEquivalentTo(5)) +} + +func TestList(t *testing.T) { + g := gomega.NewWithT(t) + + fc := createFakeClient() + k := New(fc) + + list := appsv1.DeploymentList{} + g.Eventually(k.List(&list)).Should(gomega.Succeed()) + + g.Expect(list.Items).To(gomega.HaveLen(1)) + depl := exampleDeployment() + g.Expect(list.Items[0]).To(gomega.And( + HaveField("ObjectMeta.Name", gomega.Equal(depl.ObjectMeta.Name)), + HaveField("ObjectMeta.Namespace", gomega.Equal(depl.ObjectMeta.Namespace)), + )) +} + +func TestUpdate(t *testing.T) { + g := gomega.NewWithT(t) + + fc := createFakeClient() + k := New(fc) + + updateDeployment := appsv1.Deployment{ + ObjectMeta: exampleDeployment().ObjectMeta, + } + g.Eventually(k.Update(&updateDeployment, func(o client.Object) client.Object { + updateDeployment.Annotations = map[string]string{"updated": "true"} + return &updateDeployment + })).Should(gomega.Succeed()) + + fetched := appsv1.Deployment{ + ObjectMeta: exampleDeployment().ObjectMeta, + } + g.Expect(k.Object(&fetched)()).To(HaveField("ObjectMeta.Annotations", gomega.HaveKeyWithValue("updated", "true"))) +} + +func TestUpdateStatus(t *testing.T) { + g := gomega.NewWithT(t) + + fc := createFakeClient() + k := New(fc) + + updateDeployment := appsv1.Deployment{ + ObjectMeta: exampleDeployment().ObjectMeta, + } + g.Eventually(k.UpdateStatus(&updateDeployment, func(o client.Object) client.Object { + updateDeployment.Status.AvailableReplicas = 1 + return &updateDeployment + })).Should(gomega.Succeed()) + + fetched := appsv1.Deployment{ + ObjectMeta: exampleDeployment().ObjectMeta, + } + g.Expect(k.Object(&fetched)()).To(HaveField("Status.AvailableReplicas", gomega.BeEquivalentTo(1))) + +} + +func TestObject(t *testing.T) { + g := gomega.NewWithT(t) + + fc := createFakeClient() + k := New(fc) + + fetched := appsv1.Deployment{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + Name: "test", + }, + } + g.Eventually(k.Object(&fetched)).Should(gomega.And( + gomega.Not(gomega.BeNil()), + HaveField("Spec.Replicas", gomega.Equal(pointer.Int32(5))), + )) +} + +func TestObjectList(t *testing.T) { + g := gomega.NewWithT(t) + + fc := createFakeClient() + k := New(fc) + + list := appsv1.DeploymentList{} + g.Eventually(k.ObjectList(&list)).Should(gomega.And( + gomega.Not(gomega.BeNil()), + HaveField("Items", gomega.And( + gomega.HaveLen(1), + gomega.ContainElement(WithField("Spec.Replicas", gomega.Equal(pointer.Int32(5)))), + )), + )) +} diff --git a/pkg/envtest/komega/matcher.go b/pkg/envtest/komega/matcher.go deleted file mode 100644 index 4f835501aa..0000000000 --- a/pkg/envtest/komega/matcher.go +++ /dev/null @@ -1,236 +0,0 @@ -/* -Copyright 2021 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 komega - -import ( - "context" - "time" - - "github.com/onsi/gomega" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -// Matcher has Gomega Matchers that use the controller-runtime client. -type Matcher struct { - Client client.Client - ctx context.Context - extras []interface{} - timeout time.Duration - pollInterval time.Duration -} - -// WithContext sets the context to be used for the underlying client -// during assertions. -func (m *Matcher) WithContext(ctx context.Context) Komega { - m.ctx = ctx - return m -} - -// context returns the matcher context if one has been set. -// Else it returns the context.TODO(). -func (m *Matcher) context() context.Context { - if m.ctx == nil { - return context.TODO() - } - return m.ctx -} - -// WithExtras sets extra arguments for sync assertions. -// Any extras passed will be expected to be nil during assertion. -func (m *Matcher) WithExtras(extras ...interface{}) KomegaSync { - m.extras = extras - return m -} - -// WithTimeout sets the timeout for any async assertions. -func (m *Matcher) WithTimeout(timeout time.Duration) KomegaAsync { - m.timeout = timeout - return m -} - -// WithPollInterval sets the poll interval for any async assertions. -// Note: This will only work if an explicit timeout has been set with WithTimeout. -func (m *Matcher) WithPollInterval(pollInterval time.Duration) KomegaAsync { - m.pollInterval = pollInterval - return m -} - -// intervals constructs the intervals for async assertions. -// If no timeout is set, the list will be empty. -func (m *Matcher) intervals() []interface{} { - if m.timeout == 0 { - return []interface{}{} - } - out := []interface{}{m.timeout} - if m.pollInterval != 0 { - out = append(out, m.pollInterval) - } - return out -} - -// Create creates the object on the API server. -func (m *Matcher) Create(obj client.Object, opts ...client.CreateOption) gomega.GomegaAssertion { - err := m.Client.Create(m.context(), obj, opts...) - return gomega.Expect(err, m.extras...) -} - -// Delete deletes the object from the API server. -func (m *Matcher) Delete(obj client.Object, opts ...client.DeleteOption) gomega.GomegaAssertion { - err := m.Client.Delete(m.context(), obj, opts...) - return gomega.Expect(err, m.extras...) -} - -// Update udpates the object on the API server by fetching the object -// and applying a mutating UpdateFunc before sending the update. -func (m *Matcher) Update(obj client.Object, fn UpdateFunc, opts ...client.UpdateOption) gomega.GomegaAsyncAssertion { - key := types.NamespacedName{ - Name: obj.GetName(), - Namespace: obj.GetNamespace(), - } - update := func() error { - err := m.Client.Get(m.context(), key, obj) - if err != nil { - return err - } - return m.Client.Update(m.context(), fn(obj), opts...) - } - return gomega.Eventually(update, m.intervals()...) -} - -// UpdateStatus udpates the object's status subresource on the API server by -// fetching the object and applying a mutating UpdateFunc before sending the -// update. -func (m *Matcher) UpdateStatus(obj client.Object, fn UpdateFunc, opts ...client.UpdateOption) gomega.GomegaAsyncAssertion { - key := types.NamespacedName{ - Name: obj.GetName(), - Namespace: obj.GetNamespace(), - } - update := func() error { - err := m.Client.Get(m.context(), key, obj) - if err != nil { - return err - } - return m.Client.Status().Update(m.context(), fn(obj), opts...) - } - return gomega.Eventually(update, m.intervals()...) -} - -// Get gets the object from the API server. -func (m *Matcher) Get(obj client.Object) gomega.GomegaAsyncAssertion { - key := types.NamespacedName{ - Name: obj.GetName(), - Namespace: obj.GetNamespace(), - } - get := func() error { - return m.Client.Get(m.context(), key, obj) - } - return gomega.Eventually(get, m.intervals()...) -} - -// List gets the list object from the API server. -func (m *Matcher) List(obj client.ObjectList, opts ...client.ListOption) gomega.GomegaAsyncAssertion { - list := func() error { - return m.Client.List(m.context(), obj, opts...) - } - return gomega.Eventually(list, m.intervals()...) -} - -// Consistently continually gets the object from the API for comparison. -// It can be used to check for either List types or regular Objects. -func (m *Matcher) Consistently(obj runtime.Object, opts ...client.ListOption) gomega.GomegaAsyncAssertion { - // If the object is a list, return a list - if o, ok := obj.(client.ObjectList); ok { - return m.consistentlyList(o, opts...) - } - if o, ok := obj.(client.Object); ok { - return m.consistentlyObject(o) - } - //Should not get here - panic("Unknown object.") -} - -// consistentlyclient.Object gets an individual object from the API server. -func (m *Matcher) consistentlyObject(obj client.Object) gomega.GomegaAsyncAssertion { - key := types.NamespacedName{ - Name: obj.GetName(), - Namespace: obj.GetNamespace(), - } - get := func() client.Object { - err := m.Client.Get(m.context(), key, obj) - if err != nil { - panic(err) - } - return obj - } - return gomega.Consistently(get, m.intervals()...) -} - -// consistentlyList gets an list of objects from the API server. -func (m *Matcher) consistentlyList(obj client.ObjectList, opts ...client.ListOption) gomega.GomegaAsyncAssertion { - list := func() client.ObjectList { - err := m.Client.List(m.context(), obj, opts...) - if err != nil { - panic(err) - } - return obj - } - return gomega.Consistently(list, m.intervals()...) -} - -// Eventually continually gets the object from the API for comparison. -// It can be used to check for either List types or regular Objects. -func (m *Matcher) Eventually(obj runtime.Object, opts ...client.ListOption) gomega.GomegaAsyncAssertion { - // If the object is a list, return a list - if o, ok := obj.(client.ObjectList); ok { - return m.eventuallyList(o, opts...) - } - if o, ok := obj.(client.Object); ok { - return m.eventuallyObject(o) - } - //Should not get here - panic("Unknown object.") -} - -// eventuallyObject gets an individual object from the API server. -func (m *Matcher) eventuallyObject(obj client.Object) gomega.GomegaAsyncAssertion { - key := types.NamespacedName{ - Name: obj.GetName(), - Namespace: obj.GetNamespace(), - } - get := func() client.Object { - err := m.Client.Get(m.context(), key, obj) - if err != nil { - panic(err) - } - return obj - } - return gomega.Eventually(get, m.intervals()...) -} - -// eventuallyList gets a list type from the API server. -func (m *Matcher) eventuallyList(obj client.ObjectList, opts ...client.ListOption) gomega.GomegaAsyncAssertion { - list := func() client.ObjectList { - err := m.Client.List(m.context(), obj, opts...) - if err != nil { - panic(err) - } - return obj - } - return gomega.Eventually(list, m.intervals()...) -} diff --git a/pkg/envtest/komega/transforms.go b/pkg/envtest/komega/transforms.go index 165dd55d7d..7ec3b73756 100644 --- a/pkg/envtest/komega/transforms.go +++ b/pkg/envtest/komega/transforms.go @@ -25,20 +25,20 @@ import ( gtypes "github.com/onsi/gomega/types" ) -// WithField gets the value of the named field from the object. +// HaveField gets the value of the named field from the object. // This is intended to be used in assertions with the Matcher make it easy // to check the value of a particular field in a resource. // To access nested fields uses a `.` separator. // Eg. -// m.Eventually(deployment).Should(WithField("spec.replicas", BeZero())) +// m.Eventually(deployment).Should(HaveField("spec.replicas", BeZero())) // To access nested lists, use one of the Gomega list matchers in conjunction with this. // Eg. -// m.Eventually(deploymentList).Should(WithField("items", ConsistOf(...))) -func WithField(field string, matcher gtypes.GomegaMatcher) gtypes.GomegaMatcher { +// m.Eventually(deploymentList).Should(HaveField("items", ConsistOf(...))) +func HaveField(field string, matcher gtypes.GomegaMatcher) gtypes.GomegaMatcher { // Addressing Field by . can be recursed fields := strings.SplitN(field, ".", 2) if len(fields) == 2 { - matcher = WithField(fields[1], matcher) + matcher = HaveField(fields[1], matcher) } return gomega.WithTransform(func(obj interface{}) interface{} { @@ -50,3 +50,5 @@ func WithField(field string, matcher gtypes.GomegaMatcher) gtypes.GomegaMatcher return f.Interface() }, matcher) } + +var WithField = HaveField