From 4f20d071685ee5347847f36f934069edd2cd3199 Mon Sep 17 00:00:00 2001 From: Joel Speed Date: Fri, 29 Jan 2021 19:22:08 +0000 Subject: [PATCH 1/2] Add Komega matcher and interfaces This adds a utility that is intended to be used with envtest to make it easier for users to write tests. The Matcher wraps common operations that you might do with gomega when interacting with Kubernetes to allow simpler test assertions. --- pkg/envtest/komega/interfaces.go | 58 ++++++++ pkg/envtest/komega/matcher.go | 236 +++++++++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 pkg/envtest/komega/interfaces.go create mode 100644 pkg/envtest/komega/matcher.go diff --git a/pkg/envtest/komega/interfaces.go b/pkg/envtest/komega/interfaces.go new file mode 100644 index 0000000000..8b355637a0 --- /dev/null +++ b/pkg/envtest/komega/interfaces.go @@ -0,0 +1,58 @@ +/* +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" + "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 +} + +// 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 +} + +// 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 +} + +// UpdateFunc modifies the object fetched from the API server before sending +// the update +type UpdateFunc func(client.Object) client.Object diff --git a/pkg/envtest/komega/matcher.go b/pkg/envtest/komega/matcher.go new file mode 100644 index 0000000000..4f835501aa --- /dev/null +++ b/pkg/envtest/komega/matcher.go @@ -0,0 +1,236 @@ +/* +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()...) +} From 38101e5834f41dbcc5f3018622b1ad3ba970428e Mon Sep 17 00:00:00 2001 From: Joel Speed Date: Fri, 29 Jan 2021 19:26:56 +0000 Subject: [PATCH 2/2] Add WithField transformer This should allow people to access fields within their objects fetched by the Matcher. This allows easier assertions by allowing specific fields to be compared during tests. --- pkg/envtest/komega/transforms.go | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 pkg/envtest/komega/transforms.go diff --git a/pkg/envtest/komega/transforms.go b/pkg/envtest/komega/transforms.go new file mode 100644 index 0000000000..165dd55d7d --- /dev/null +++ b/pkg/envtest/komega/transforms.go @@ -0,0 +1,52 @@ +/* +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 ( + "fmt" + "reflect" + "strings" + + "github.com/onsi/gomega" + gtypes "github.com/onsi/gomega/types" +) + +// WithField 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())) +// 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 { + // Addressing Field by . can be recursed + fields := strings.SplitN(field, ".", 2) + if len(fields) == 2 { + matcher = WithField(fields[1], matcher) + } + + return gomega.WithTransform(func(obj interface{}) interface{} { + r := reflect.ValueOf(obj) + f := reflect.Indirect(r).FieldByName(fields[0]) + if !f.IsValid() { + panic(fmt.Sprintf("Object '%s' does not have a field '%s'", reflect.TypeOf(obj), fields[0])) + } + return f.Interface() + }, matcher) +}