Skip to content

Commit 165a8c8

Browse files
authored
Merge pull request #1833 from schrej/feature/komega-equalobjects
✨ komega: add EqualObject matcher
2 parents a759a0d + 8fe64fa commit 165a8c8

File tree

3 files changed

+961
-1
lines changed

3 files changed

+961
-1
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
github.com/fsnotify/fsnotify v1.5.1
88
github.com/go-logr/logr v1.2.0
99
github.com/go-logr/zapr v1.2.0
10+
github.com/google/go-cmp v0.5.5
1011
github.com/onsi/ginkgo v1.16.5
1112
github.com/onsi/gomega v1.18.1
1213
github.com/prometheus/client_golang v1.12.1
@@ -41,7 +42,6 @@ require (
4142
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
4243
github.com/golang/protobuf v1.5.2 // indirect
4344
github.com/google/gnostic v0.5.7-v3refs // indirect
44-
github.com/google/go-cmp v0.5.5 // indirect
4545
github.com/google/gofuzz v1.1.0 // indirect
4646
github.com/google/uuid v1.1.2 // indirect
4747
github.com/imdario/mergo v0.3.12 // indirect

pkg/envtest/komega/equalobject.go

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
/*
2+
Copyright 2022 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
Unless required by applicable law or agreed to in writing, software
9+
distributed under the License is distributed on an "AS IS" BASIS,
10+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
See the License for the specific language governing permissions and
12+
limitations under the License.
13+
*/
14+
15+
package komega
16+
17+
import (
18+
"fmt"
19+
"reflect"
20+
"strings"
21+
22+
"github.com/google/go-cmp/cmp"
23+
"github.com/onsi/gomega/format"
24+
"github.com/onsi/gomega/types"
25+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
26+
"k8s.io/apimachinery/pkg/runtime"
27+
)
28+
29+
// These package variables hold pre-created commonly used options that can be used to reduce the manual work involved in
30+
// identifying the paths that need to be compared for testing equality between objects.
31+
var (
32+
// IgnoreAutogeneratedMetadata contains the paths for all the metadata fields that are commonly set by the
33+
// client and APIServer. This is used as a MatchOption for situations when only user-provided metadata is relevant.
34+
IgnoreAutogeneratedMetadata = IgnorePaths{
35+
"metadata.uid",
36+
"metadata.generation",
37+
"metadata.creationTimestamp",
38+
"metadata.resourceVersion",
39+
"metadata.managedFields",
40+
"metadata.deletionGracePeriodSeconds",
41+
"metadata.deletionTimestamp",
42+
"metadata.selfLink",
43+
"metadata.generateName",
44+
}
45+
)
46+
47+
type diffPath struct {
48+
types []string
49+
json []string
50+
}
51+
52+
// equalObjectMatcher is a Gomega matcher used to establish equality between two Kubernetes runtime.Objects.
53+
type equalObjectMatcher struct {
54+
// original holds the object that will be used to Match.
55+
original runtime.Object
56+
57+
// diffPaths contains the paths that differ between two objects.
58+
diffPaths []diffPath
59+
60+
// options holds the options that identify what should and should not be matched.
61+
options *EqualObjectOptions
62+
}
63+
64+
// EqualObject returns a Matcher for the passed Kubernetes runtime.Object with the passed Options. This function can be
65+
// used as a Gomega Matcher in Gomega Assertions.
66+
func EqualObject(original runtime.Object, opts ...EqualObjectOption) types.GomegaMatcher {
67+
matchOptions := &EqualObjectOptions{}
68+
matchOptions = matchOptions.ApplyOptions(opts)
69+
70+
return &equalObjectMatcher{
71+
options: matchOptions,
72+
original: original,
73+
}
74+
}
75+
76+
// Match compares the current object to the passed object and returns true if the objects are the same according to
77+
// the Matcher and MatchOptions.
78+
func (m *equalObjectMatcher) Match(actual interface{}) (success bool, err error) {
79+
// Nil checks required first here for:
80+
// 1) Nil equality which returns true
81+
// 2) One object nil which returns an error
82+
actualIsNil := reflect.ValueOf(actual).IsNil()
83+
originalIsNil := reflect.ValueOf(m.original).IsNil()
84+
85+
if actualIsNil && originalIsNil {
86+
return true, nil
87+
}
88+
if actualIsNil || originalIsNil {
89+
return false, fmt.Errorf("can not compare an object with a nil. original %v , actual %v", m.original, actual)
90+
}
91+
92+
m.diffPaths = m.calculateDiff(actual)
93+
return len(m.diffPaths) == 0, nil
94+
}
95+
96+
// FailureMessage returns a message comparing the full objects after an unexpected failure to match has occurred.
97+
func (m *equalObjectMatcher) FailureMessage(actual interface{}) (message string) {
98+
return fmt.Sprintf("the following fields were expected to match but did not:\n%v\n%s", m.diffPaths,
99+
format.Message(actual, "expected to match", m.original))
100+
}
101+
102+
// NegatedFailureMessage returns a string stating that all fields matched, even though that was not expected.
103+
func (m *equalObjectMatcher) NegatedFailureMessage(actual interface{}) (message string) {
104+
return "it was expected that some fields do not match, but all of them did"
105+
}
106+
107+
func (d diffPath) String() string {
108+
return fmt.Sprintf("(%s/%s)", strings.Join(d.types, "."), strings.Join(d.json, "."))
109+
}
110+
111+
// diffReporter is a custom recorder for cmp.Diff which records all paths that are
112+
// different between two objects.
113+
type diffReporter struct {
114+
stack []cmp.PathStep
115+
116+
diffPaths []diffPath
117+
}
118+
119+
func (r *diffReporter) PushStep(s cmp.PathStep) {
120+
r.stack = append(r.stack, s)
121+
}
122+
123+
func (r *diffReporter) Report(res cmp.Result) {
124+
if !res.Equal() {
125+
r.diffPaths = append(r.diffPaths, r.currentPath())
126+
}
127+
}
128+
129+
// currentPath converts the current stack into string representations that match
130+
// the IgnorePaths and MatchPaths syntax.
131+
func (r *diffReporter) currentPath() diffPath {
132+
p := diffPath{types: []string{""}, json: []string{""}}
133+
for si, s := range r.stack[1:] {
134+
switch s := s.(type) {
135+
case cmp.StructField:
136+
p.types = append(p.types, s.String()[1:])
137+
// fetch the type information from the parent struct.
138+
// Note: si has an offset of 1 compared to r.stack as we loop over r.stack[1:], so we don't need -1
139+
field := r.stack[si].Type().Field(s.Index())
140+
p.json = append(p.json, strings.Split(field.Tag.Get("json"), ",")[0])
141+
case cmp.SliceIndex:
142+
key := fmt.Sprintf("[%d]", s.Key())
143+
p.types[len(p.types)-1] += key
144+
p.json[len(p.json)-1] += key
145+
case cmp.MapIndex:
146+
key := fmt.Sprintf("%v", s.Key())
147+
if strings.ContainsAny(key, ".[]/\\") {
148+
key = fmt.Sprintf("[%s]", key)
149+
p.types[len(p.types)-1] += key
150+
p.json[len(p.json)-1] += key
151+
} else {
152+
p.types = append(p.types, key)
153+
p.json = append(p.json, key)
154+
}
155+
}
156+
}
157+
// Empty strings were added as the first element. If they're still empty, remove them again.
158+
if len(p.json) > 0 && len(p.json[0]) == 0 {
159+
p.json = p.json[1:]
160+
p.types = p.types[1:]
161+
}
162+
return p
163+
}
164+
165+
func (r *diffReporter) PopStep() {
166+
r.stack = r.stack[:len(r.stack)-1]
167+
}
168+
169+
// calculateDiff calculates the difference between two objects and returns the
170+
// paths of the fields that do not match.
171+
func (m *equalObjectMatcher) calculateDiff(actual interface{}) []diffPath {
172+
var original interface{} = m.original
173+
// Remove the wrapping Object from unstructured.Unstructured to make comparison behave similar to
174+
// regular objects.
175+
if u, isUnstructured := actual.(*unstructured.Unstructured); isUnstructured {
176+
actual = u.Object
177+
}
178+
if u, ok := m.original.(*unstructured.Unstructured); ok {
179+
original = u.Object
180+
}
181+
r := diffReporter{}
182+
cmp.Diff(original, actual, cmp.Reporter(&r))
183+
return filterDiffPaths(*m.options, r.diffPaths)
184+
}
185+
186+
// filterDiffPaths filters the diff paths using the paths in EqualObjectOptions.
187+
func filterDiffPaths(opts EqualObjectOptions, paths []diffPath) []diffPath {
188+
result := []diffPath{}
189+
190+
for _, p := range paths {
191+
if len(opts.matchPaths) > 0 && !hasAnyPathPrefix(p, opts.matchPaths) {
192+
continue
193+
}
194+
if hasAnyPathPrefix(p, opts.ignorePaths) {
195+
continue
196+
}
197+
198+
result = append(result, p)
199+
}
200+
201+
return result
202+
}
203+
204+
// hasPathPrefix compares the segments of a path.
205+
func hasPathPrefix(path []string, prefix []string) bool {
206+
for i, p := range prefix {
207+
if i >= len(path) {
208+
return false
209+
}
210+
// return false if a segment doesn't match
211+
if path[i] != p && (i < len(prefix)-1 || !segmentHasPrefix(path[i], p)) {
212+
return false
213+
}
214+
}
215+
return true
216+
}
217+
218+
func segmentHasPrefix(s, prefix string) bool {
219+
return len(s) >= len(prefix) && s[0:len(prefix)] == prefix &&
220+
// if it is a prefix match, make sure the next character is a [ for array/map access
221+
(len(s) == len(prefix) || s[len(prefix)] == '[')
222+
}
223+
224+
// hasAnyPathPrefix returns true if path matches any of the path prefixes.
225+
// It respects the name boundaries within paths, so 'ObjectMeta.Name' does not
226+
// match 'ObjectMeta.Namespace' for example.
227+
func hasAnyPathPrefix(path diffPath, prefixes [][]string) bool {
228+
for _, prefix := range prefixes {
229+
if hasPathPrefix(path.types, prefix) || hasPathPrefix(path.json, prefix) {
230+
return true
231+
}
232+
}
233+
return false
234+
}
235+
236+
// EqualObjectOption describes an Option that can be applied to a Matcher.
237+
type EqualObjectOption interface {
238+
// ApplyToEqualObjectMatcher applies this configuration to the given MatchOption.
239+
ApplyToEqualObjectMatcher(options *EqualObjectOptions)
240+
}
241+
242+
// EqualObjectOptions holds the available types of EqualObjectOptions that can be applied to a Matcher.
243+
type EqualObjectOptions struct {
244+
ignorePaths [][]string
245+
matchPaths [][]string
246+
}
247+
248+
// ApplyOptions adds the passed MatchOptions to the MatchOptions struct.
249+
func (o *EqualObjectOptions) ApplyOptions(opts []EqualObjectOption) *EqualObjectOptions {
250+
for _, opt := range opts {
251+
opt.ApplyToEqualObjectMatcher(o)
252+
}
253+
return o
254+
}
255+
256+
// IgnorePaths instructs the Matcher to ignore given paths when computing a diff.
257+
// Paths are written in a syntax similar to Go with a few special cases. Both types and
258+
// json/yaml field names are supported.
259+
//
260+
// Regular Paths
261+
// "ObjectMeta.Name"
262+
// "metadata.name"
263+
// Arrays
264+
// "metadata.ownerReferences[0].name"
265+
// Maps, if they do not contain any of .[]/\
266+
// "metadata.labels.something"
267+
// Maps, if they contain any of .[]/\
268+
// "metadata.labels[kubernetes.io/something]"
269+
type IgnorePaths []string
270+
271+
// ApplyToEqualObjectMatcher applies this configuration to the given MatchOptions.
272+
func (i IgnorePaths) ApplyToEqualObjectMatcher(opts *EqualObjectOptions) {
273+
for _, p := range i {
274+
opts.ignorePaths = append(opts.ignorePaths, strings.Split(p, "."))
275+
}
276+
}
277+
278+
// MatchPaths instructs the Matcher to restrict its diff to the given paths. If empty the Matcher will look at all paths.
279+
// Paths are written in a syntax similar to Go with a few special cases. Both types and
280+
// json/yaml field names are supported.
281+
//
282+
// Regular Paths
283+
// "ObjectMeta.Name"
284+
// "metadata.name"
285+
// Arrays
286+
// "metadata.ownerReferences[0].name"
287+
// Maps, if they do not contain any of .[]/\
288+
// "metadata.labels.something"
289+
// Maps, if they contain any of .[]/\
290+
// "metadata.labels[kubernetes.io/something]"
291+
type MatchPaths []string
292+
293+
// ApplyToEqualObjectMatcher applies this configuration to the given MatchOptions.
294+
func (i MatchPaths) ApplyToEqualObjectMatcher(opts *EqualObjectOptions) {
295+
for _, p := range i {
296+
opts.matchPaths = append(opts.ignorePaths, strings.Split(p, "."))
297+
}
298+
}

0 commit comments

Comments
 (0)