Skip to content

Commit 25e9b08

Browse files
schrejkillianmuldoonsbueringer
committed
komega: add EqualObject matcher
Co-authored-by: killianmuldoon <[email protected]> Co-authored-by: Stefan Bueringer <[email protected]>
1 parent c868236 commit 25e9b08

File tree

3 files changed

+437
-1
lines changed

3 files changed

+437
-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: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
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+
"strconv"
21+
"strings"
22+
23+
"github.com/google/go-cmp/cmp"
24+
"github.com/onsi/gomega/format"
25+
"github.com/onsi/gomega/types"
26+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
27+
"k8s.io/apimachinery/pkg/runtime"
28+
)
29+
30+
// These package variables hold pre-created commonly used options that can be used to reduce the manual work involved in
31+
// identifying the paths that need to be compared for testing equality between objects.
32+
var (
33+
// IgnoreAutogeneratedMetadata contains the paths for all the metadata fields that are commonly set by the
34+
// client and APIServer. This is used as a MatchOption for situations when only user-provided metadata is relevant.
35+
IgnoreAutogeneratedMetadata = IgnorePaths{
36+
{"ObjectMeta", "UID"},
37+
{"ObjectMeta", "Generation"},
38+
{"ObjectMeta", "CreationTimestamp"},
39+
{"ObjectMeta", "ResourceVersion"},
40+
{"ObjectMeta", "ManagedFields"},
41+
{"ObjectMeta", "DeletionGracePeriodSeconds"},
42+
{"ObjectMeta", "DeletionTimestamp"},
43+
{"ObjectMeta", "SelfLink"},
44+
{"ObjectMeta", "GenerateName"},
45+
}
46+
)
47+
48+
type diffPath struct {
49+
types []string
50+
json []string
51+
}
52+
53+
// equalObjectMatcher is a Gomega matcher used to establish equality between two Kubernetes runtime.Objects.
54+
type equalObjectMatcher struct {
55+
// original holds the object that will be used to Match.
56+
original runtime.Object
57+
58+
// diffPaths contains the paths that differ between two objects.
59+
diffPaths []diffPath
60+
61+
// options holds the options that identify what should and should not be matched.
62+
options *EqualObjectOptions
63+
}
64+
65+
// EqualObject returns a Matcher for the passed Kubernetes runtime.Object with the passed Options. This function can be
66+
// used as a Gomega Matcher in Gomega Assertions.
67+
func EqualObject(original runtime.Object, opts ...EqualObjectOption) types.GomegaMatcher {
68+
matchOptions := &EqualObjectOptions{}
69+
matchOptions = matchOptions.ApplyOptions(opts)
70+
71+
return &equalObjectMatcher{
72+
options: matchOptions,
73+
original: original,
74+
}
75+
}
76+
77+
// Match compares the current object to the passed object and returns true if the objects are the same according to
78+
// the Matcher and MatchOptions.
79+
func (m *equalObjectMatcher) Match(actual interface{}) (success bool, err error) {
80+
// Nil checks required first here for:
81+
// 1) Nil equality which returns true
82+
// 2) One object nil which returns an error
83+
actualIsNil := reflect.ValueOf(actual).IsNil()
84+
originalIsNil := reflect.ValueOf(m.original).IsNil()
85+
86+
if actualIsNil && originalIsNil {
87+
return true, nil
88+
}
89+
if actualIsNil || originalIsNil {
90+
return false, fmt.Errorf("can not compare an object with a nil. original %v , actual %v", m.original, actual)
91+
}
92+
93+
m.diffPaths = m.calculateDiff(actual)
94+
return len(m.diffPaths) == 0, nil
95+
}
96+
97+
// FailureMessage returns a message comparing the full objects after an unexpected failure to match has occurred.
98+
func (m *equalObjectMatcher) FailureMessage(actual interface{}) (message string) {
99+
return fmt.Sprintf("the following fields were expected to match but did not:\n%v\n%s", m.diffPaths,
100+
format.Message(actual, "expected to match", m.original))
101+
}
102+
103+
// NegatedFailureMessage returns a string stating that all fields matched, even though that was not expected.
104+
func (m *equalObjectMatcher) NegatedFailureMessage(actual interface{}) (message string) {
105+
return "it was expected that some fields do not match, but all of them did"
106+
}
107+
108+
func (d diffPath) String() string {
109+
return fmt.Sprintf("(%s/%s)", strings.Join(d.types, "."), strings.Join(d.json, "."))
110+
}
111+
112+
// diffReporter is a custom recorder for cmp.Diff which records all paths that are
113+
// different between two objects.
114+
type diffReporter struct {
115+
stack []cmp.PathStep
116+
path []string
117+
jsonPath []string
118+
119+
diffPaths []diffPath
120+
}
121+
122+
func (r *diffReporter) PushStep(s cmp.PathStep) {
123+
r.stack = append(r.stack, s)
124+
if len(r.stack) <= 1 {
125+
return
126+
}
127+
switch s := s.(type) {
128+
case cmp.SliceIndex:
129+
r.path = append(r.path, strconv.Itoa(s.Key()))
130+
r.jsonPath = append(r.jsonPath, strconv.Itoa(s.Key()))
131+
case cmp.MapIndex:
132+
key := fmt.Sprintf("%v", s.Key())
133+
// if strings.ContainsAny(key, ".[]/\\") {
134+
// key = fmt.Sprintf("[%s]", key)
135+
// } else {
136+
// key = "." + key
137+
// }
138+
r.path = append(r.path, key)
139+
r.jsonPath = append(r.jsonPath, key)
140+
case cmp.StructField:
141+
field := r.stack[len(r.stack)-2].Type().Field(s.Index())
142+
jsonName := strings.Split(field.Tag.Get("json"), ",")[0]
143+
r.path = append(r.path, s.String()[1:])
144+
r.jsonPath = append(r.jsonPath, jsonName)
145+
}
146+
}
147+
148+
func (r *diffReporter) Report(res cmp.Result) {
149+
if !res.Equal() {
150+
r.diffPaths = append(r.diffPaths, diffPath{types: r.path, json: r.jsonPath})
151+
}
152+
}
153+
154+
// func (r *diffReporter) currPath() string {
155+
// p := []string{}
156+
// for _, s := range r.stack[1:] {
157+
// switch s := s.(type) {
158+
// case cmp.StructField, cmp.SliceIndex, cmp.MapIndex:
159+
// p = append(p, s.String())
160+
// }
161+
// }
162+
// return strings.Join(p, "")[1:]
163+
// }
164+
165+
func (r *diffReporter) PopStep() {
166+
popped := r.stack[len(r.stack)-1]
167+
r.stack = r.stack[:len(r.stack)-1]
168+
if _, ok := popped.(cmp.Indirect); ok {
169+
return
170+
}
171+
if len(r.stack) <= 1 {
172+
return
173+
}
174+
switch popped.(type) {
175+
case cmp.SliceIndex, cmp.MapIndex, cmp.StructField:
176+
r.path = r.path[:len(r.path)-1]
177+
r.jsonPath = r.jsonPath[:len(r.jsonPath)-1]
178+
}
179+
}
180+
181+
// calculateDiff calculates the difference between two objects and returns the
182+
// paths of the fields that do not match.
183+
func (m *equalObjectMatcher) calculateDiff(actual interface{}) []diffPath {
184+
var original interface{} = m.original
185+
if u, isUnstructured := actual.(*unstructured.Unstructured); isUnstructured {
186+
actual = u.Object
187+
}
188+
if u, ok := m.original.(*unstructured.Unstructured); ok {
189+
original = u.Object
190+
}
191+
r := diffReporter{}
192+
cmp.Diff(original, actual, cmp.Reporter(&r))
193+
return filterDiffPaths(*m.options, r.diffPaths)
194+
}
195+
196+
// filterDiffPaths filters the diff paths using the paths in EqualObjectOptions.
197+
func filterDiffPaths(opts EqualObjectOptions, paths []diffPath) []diffPath {
198+
result := []diffPath{}
199+
for _, c := range paths {
200+
if len(opts.matchPaths) > 0 && (!matchesAnyPath(c.types, opts.matchPaths) || !matchesAnyPath(c.json, opts.matchPaths)) {
201+
continue
202+
}
203+
if matchesAnyPath(c.types, opts.ignorePaths) || matchesAnyPath(c.json, opts.ignorePaths) {
204+
continue
205+
}
206+
result = append(result, c)
207+
}
208+
return result
209+
}
210+
211+
func matchesPath(path []string, prefix []string) bool {
212+
for i, p := range prefix {
213+
if i >= len(path) || p != path[i] {
214+
return false
215+
}
216+
}
217+
return true
218+
}
219+
220+
// matchesAnyPath returns true if path matches any of the path prefixes.
221+
// It respects the name boundaries within paths, so 'ObjectMeta.Name' does not
222+
// match 'ObjectMeta.Namespace' for example.
223+
func matchesAnyPath(path []string, prefixes [][]string) bool {
224+
for _, prefix := range prefixes {
225+
if matchesPath(path, prefix) {
226+
return true
227+
}
228+
}
229+
return false
230+
}
231+
232+
// EqualObjectOption describes an Option that can be applied to a Matcher.
233+
type EqualObjectOption interface {
234+
// ApplyToEqualObjectMatcher applies this configuration to the given MatchOption.
235+
ApplyToEqualObjectMatcher(options *EqualObjectOptions)
236+
}
237+
238+
// EqualObjectOptions holds the available types of EqualObjectOptions that can be applied to a Matcher.
239+
type EqualObjectOptions struct {
240+
ignorePaths [][]string
241+
matchPaths [][]string
242+
}
243+
244+
// ApplyOptions adds the passed MatchOptions to the MatchOptions struct.
245+
func (o *EqualObjectOptions) ApplyOptions(opts []EqualObjectOption) *EqualObjectOptions {
246+
for _, opt := range opts {
247+
opt.ApplyToEqualObjectMatcher(o)
248+
}
249+
return o
250+
}
251+
252+
// func parsePath(path string) []string {
253+
// s := strings.Split(path, ".")
254+
// return s
255+
// }
256+
257+
// IgnorePaths instructs the Matcher to ignore given paths when computing a diff.
258+
type IgnorePaths [][]string
259+
260+
// ApplyToEqualObjectMatcher applies this configuration to the given MatchOptions.
261+
func (i IgnorePaths) ApplyToEqualObjectMatcher(opts *EqualObjectOptions) {
262+
opts.ignorePaths = append(opts.ignorePaths, i...)
263+
}
264+
265+
// MatchPaths instructs the Matcher to restrict its diff to the given paths. If empty the Matcher will look at all paths.
266+
type MatchPaths [][]string
267+
268+
// ApplyToEqualObjectMatcher applies this configuration to the given MatchOptions.
269+
func (i MatchPaths) ApplyToEqualObjectMatcher(opts *EqualObjectOptions) {
270+
opts.matchPaths = append(opts.matchPaths, i...)
271+
}

0 commit comments

Comments
 (0)