diff --git a/go.mod b/go.mod index 9fa76b95ce..384304aaa3 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/fsnotify/fsnotify v1.5.1 github.com/go-logr/logr v1.2.0 github.com/go-logr/zapr v1.2.0 + github.com/google/go-cmp v0.5.5 github.com/onsi/ginkgo v1.16.5 github.com/onsi/gomega v1.18.1 github.com/prometheus/client_golang v1.11.1 @@ -33,7 +34,6 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.2 // indirect - github.com/google/go-cmp v0.5.5 // indirect github.com/google/gofuzz v1.1.0 // indirect github.com/google/uuid v1.1.2 // indirect github.com/googleapis/gnostic v0.5.5 // indirect diff --git a/pkg/envtest/komega/equalobject.go b/pkg/envtest/komega/equalobject.go new file mode 100644 index 0000000000..cc0689b463 --- /dev/null +++ b/pkg/envtest/komega/equalobject.go @@ -0,0 +1,209 @@ +/* +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/google/go-cmp/cmp" + "github.com/onsi/gomega/format" + "github.com/onsi/gomega/types" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" +) + +// These package variables hold pre-created commonly used options that can be used to reduce the manual work involved in +// identifying the paths that need to be compared for testing equality between objects. +var ( + // IgnoreAutogeneratedMetadata contains the paths for all the metadata fields that are commonly set by the + // client and APIServer. This is used as a MatchOption for situations when only user-provided metadata is relevant. + IgnoreAutogeneratedMetadata = IgnorePaths{ + "ObjectMeta.UID", + "ObjectMeta.Generation", + "ObjectMeta.CreationTimestamp", + "ObjectMeta.ResourceVersion", + "ObjectMeta.ManagedFields", + "ObjectMeta.DeletionGracePeriodSeconds", + "ObjectMeta.DeletionTimestamp", + "ObjectMeta.SelfLink", + "ObjectMeta.GenerateName", + } +) + +// equalObjectMatcher is a Gomega matcher used to establish equality between two Kubernetes runtime.Objects. +type equalObjectMatcher struct { + // original holds the object that will be used to Match. + original runtime.Object + + // diffPaths contains the paths that differ between two objects. + diffPaths []string + + // options holds the options that identify what should and should not be matched. + options *EqualObjectOptions +} + +// EqualObject returns a Matcher for the passed Kubernetes runtime.Object with the passed Options. This function can be +// used as a Gomega Matcher in Gomega Assertions. +func EqualObject(original runtime.Object, opts ...EqualObjectOption) types.GomegaMatcher { + matchOptions := &EqualObjectOptions{} + matchOptions = matchOptions.ApplyOptions(opts) + + return &equalObjectMatcher{ + options: matchOptions, + original: original, + } +} + +// Match compares the current object to the passed object and returns true if the objects are the same according to +// the Matcher and MatchOptions. +func (m *equalObjectMatcher) Match(actual interface{}) (success bool, err error) { + // Nil checks required first here for: + // 1) Nil equality which returns true + // 2) One object nil which returns an error + actualIsNil := reflect.ValueOf(actual).IsNil() + originalIsNil := reflect.ValueOf(m.original).IsNil() + + if actualIsNil && originalIsNil { + return true, nil + } + if actualIsNil || originalIsNil { + return false, fmt.Errorf("can not compare an object with a nil. original %v , actual %v", m.original, actual) + } + + m.diffPaths = m.calculateDiff(actual) + + return len(m.diffPaths) == 0, nil +} + +// FailureMessage returns a message comparing the full objects after an unexpected failure to match has occurred. +func (m *equalObjectMatcher) FailureMessage(actual interface{}) (message string) { + return fmt.Sprintf("the following fields were expected to match but did not:\n%v\n%s", m.diffPaths, + format.Message(actual, "expected to match", m.original)) +} + +// NegatedFailureMessage returns a string comparing the full objects after an unexpected match has occurred. +func (m *equalObjectMatcher) NegatedFailureMessage(actual interface{}) (message string) { + return fmt.Sprintf("the following fields were not expected to match \n%v\n%s", m.diffPaths, + format.Message(actual, "expected to match", m.original)) +} + +// diffReporter is a custom recorder for cmp.Diff which records all paths that are +// different between two objects. +type diffReporter struct { + stack []cmp.PathStep + diffPaths []string +} + +func (r *diffReporter) PushStep(s cmp.PathStep) { + r.stack = append(r.stack, s) +} + +func (r *diffReporter) Report(res cmp.Result) { + if !res.Equal() { + r.diffPaths = append(r.diffPaths, r.currPath()) + } +} + +func (r *diffReporter) currPath() string { + p := []string{} + for _, s := range r.stack[1:] { + switch s := s.(type) { + case cmp.StructField, cmp.SliceIndex, cmp.MapIndex: + p = append(p, s.String()) + } + } + return strings.Join(p, "")[1:] +} + +func (r *diffReporter) PopStep() { + r.stack = r.stack[:len(r.stack)-1] +} + +// calculateDiff calculates the difference between two objects and returns the +// paths of the fields that do not match. +func (m *equalObjectMatcher) calculateDiff(actual interface{}) []string { + r := diffReporter{} + cmp.Diff(m.original, actual, cmp.Reporter(&r)) + return filterDiffPaths(*m.options, r.diffPaths) +} + +// filterDiffPaths filters the diff paths using the paths in EqualObjectOptions. +func filterDiffPaths(opts EqualObjectOptions, paths []string) []string { + result := sets.NewString(paths...) + for _, c := range result.List() { + if len(opts.matchPaths) > 0 && !matchesAnyPath(c, opts.matchPaths) { + result.Delete(c) + continue + } + if matchesAnyPath(c, opts.ignorePaths) { + result.Delete(c) + } + } + return result.List() +} + +// matchesAnyPath returns true if path matches any of the path prefixes. +// It respects the name boundaries within paths, so 'ObjectMeta.Name' does not +// match 'ObjectMeta.Namespace' for example. +func matchesAnyPath(path string, prefixes []string) bool { + for _, prefix := range prefixes { + if strings.HasPrefix(path, prefix) { + rpath := path[len(prefix):] + // It's a full attribute name if it's a full match, or the next character of the path is + // either a . or a [ + if len(rpath) == 0 || rpath[0] == '.' || rpath[0] == '[' { + return true + } + } + } + return false +} + +// EqualObjectOption describes an Option that can be applied to a Matcher. +type EqualObjectOption interface { + // ApplyToEqualObjectMatcher applies this configuration to the given MatchOption. + ApplyToEqualObjectMatcher(options *EqualObjectOptions) +} + +// EqualObjectOptions holds the available types of EqualObjectOptions that can be applied to a Matcher. +type EqualObjectOptions struct { + ignorePaths []string + matchPaths []string +} + +// ApplyOptions adds the passed MatchOptions to the MatchOptions struct. +func (o *EqualObjectOptions) ApplyOptions(opts []EqualObjectOption) *EqualObjectOptions { + for _, opt := range opts { + opt.ApplyToEqualObjectMatcher(o) + } + return o +} + +// IgnorePaths instructs the Matcher to ignore given paths when computing a diff. +type IgnorePaths []string + +// ApplyToEqualObjectMatcher applies this configuration to the given MatchOptions. +func (i IgnorePaths) ApplyToEqualObjectMatcher(opts *EqualObjectOptions) { + opts.ignorePaths = append(opts.ignorePaths, i...) +} + +// MatchPaths instructs the Matcher to restrict its diff to the given paths. If empty the Matcher will look at all paths. +type MatchPaths []string + +// ApplyToEqualObjectMatcher applies this configuration to the given MatchOptions. +func (i MatchPaths) ApplyToEqualObjectMatcher(opts *EqualObjectOptions) { + opts.matchPaths = append(opts.matchPaths, i...) +} diff --git a/pkg/envtest/komega/equalobject_test.go b/pkg/envtest/komega/equalobject_test.go new file mode 100644 index 0000000000..918e1bb1e0 --- /dev/null +++ b/pkg/envtest/komega/equalobject_test.go @@ -0,0 +1,108 @@ +package komega + +import ( + "fmt" + "testing" + + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestEqualObjectMatcher(t *testing.T) { + cases := []struct { + desc string + expected appsv1.Deployment + actual appsv1.Deployment + opts []EqualObjectOption + result bool + }{ + { + desc: "succeed with equal objects", + expected: appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + actual: appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + result: true, + }, + { + desc: "fail with non equal objects", + expected: appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + actual: appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "somethingelse", + }, + }, + result: false, + }, + { + desc: "succeeds if ignored field does not match", + expected: appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Labels: map[string]string{"somelabel": "somevalue"}, + OwnerReferences: []metav1.OwnerReference{{ + Name: "controller", + }}, + }, + }, + actual: appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "somethingelse", + Labels: map[string]string{"somelabel": "anothervalue"}, + OwnerReferences: []metav1.OwnerReference{{ + Name: "another", + }}, + }, + }, + result: true, + opts: []EqualObjectOption{ + IgnorePaths{ + "ObjectMeta.Name", + "ObjectMeta.Labels[\"somelabel\"]", + "ObjectMeta.OwnerReferences[0].Name", + }, + }, + }, + { + desc: "succeeds if all allowed fields match, and some others do not", + expected: appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + actual: appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "special", + }, + }, + result: true, + opts: []EqualObjectOption{ + MatchPaths{ + "ObjectMeta.Name", + }, + }, + }, + } + + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + g := NewWithT(t) + m := EqualObject(&c.expected, c.opts...) + g.Expect(m.Match(&c.actual)).To(Equal(c.result)) + fmt.Println(m.FailureMessage(&c.actual)) + }) + } +}