Skip to content

Commit 87675c9

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

File tree

4 files changed

+769
-1
lines changed

4 files changed

+769
-1
lines changed

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ go 1.17
44

55
require (
66
github.com/evanphx/json-patch v4.12.0+incompatible
7+
github.com/evanphx/json-patch/v5 v5.6.0
78
github.com/fsnotify/fsnotify v1.5.1
89
github.com/go-logr/logr v1.2.0
910
github.com/go-logr/zapr v1.2.0
1011
github.com/onsi/ginkgo v1.16.5
1112
github.com/onsi/gomega v1.17.0
13+
github.com/pkg/errors v0.9.1
1214
github.com/prometheus/client_golang v1.11.0
1315
github.com/prometheus/client_model v0.2.0
1416
go.uber.org/goleak v1.1.12
@@ -43,7 +45,6 @@ require (
4345
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
4446
github.com/modern-go/reflect2 v1.0.2 // indirect
4547
github.com/nxadm/tail v1.4.8 // indirect
46-
github.com/pkg/errors v0.9.1 // indirect
4748
github.com/prometheus/common v0.28.0 // indirect
4849
github.com/prometheus/procfs v0.6.0 // indirect
4950
github.com/spf13/pflag v1.0.5 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
125125
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
126126
github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84=
127127
github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
128+
github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
129+
github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
128130
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
129131
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
130132
github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=

pkg/envtest/komega/equalobject.go

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
/*
2+
Copyright 2021 The Kubernetes Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
http://www.apache.org/licenses/LICENSE-2.0
7+
Unless required by applicable law or agreed to in writing, software
8+
distributed under the License is distributed on an "AS IS" BASIS,
9+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
See the License for the specific language governing permissions and
11+
limitations under the License.
12+
*/
13+
14+
package komega
15+
16+
import (
17+
"bytes"
18+
"encoding/json"
19+
"fmt"
20+
"reflect"
21+
22+
jsonpatch "github.com/evanphx/json-patch/v5"
23+
"github.com/onsi/gomega/format"
24+
"github.com/onsi/gomega/types"
25+
"github.com/pkg/errors"
26+
"k8s.io/apimachinery/pkg/runtime"
27+
)
28+
29+
// This code is adappted from the mergePatch code at controllers/topology/internal/mergepatch pkg.
30+
31+
// These package variables hold pre-created commonly used options that can be used to reduce the manual work involved in
32+
// identifying the paths that need to be compared for testing equality between objects.
33+
var (
34+
// IgnoreAutogeneratedMetadata contains the paths for all the metadata fields that are commonly set by the
35+
// client and APIServer. This is used as a MatchOption for situations when only user-provided metadata is relevant.
36+
IgnoreAutogeneratedMetadata = IgnorePaths{
37+
{"metadata", "uid"},
38+
{"metadata", "generation"},
39+
{"metadata", "creationTimestamp"},
40+
{"metadata", "resourceVersion"},
41+
{"metadata", "managedFields"},
42+
{"metadata", "deletionGracePeriodSeconds"},
43+
{"metadata", "deletionTimestamp"},
44+
{"metadata", "selfLink"},
45+
{"metadata", "generateName"},
46+
}
47+
)
48+
49+
// equalObjectMatcher is a Gomega matcher used to establish equality between two Kubernetes runtime.Objects.
50+
type equalObjectMatcher struct {
51+
// original holds the object that will be used to Match.
52+
original runtime.Object
53+
54+
// diff contains the delta between the two compared objects.
55+
diff []byte
56+
57+
// options holds the options that identify what should and should not be matched.
58+
options *MatchOptions
59+
}
60+
61+
// EqualObject returns a Matcher for the passed Kubernetes runtime.Object with the passed Options. This function can be
62+
// used as a Gomega Matcher in Gomega Assertions.
63+
func EqualObject(original runtime.Object, opts ...MatchOption) types.GomegaMatcher {
64+
matchOptions := &MatchOptions{}
65+
matchOptions = matchOptions.ApplyOptions(opts)
66+
67+
// set the allowPaths to '*' by default to not exclude any paths from the comparison.
68+
if len(matchOptions.allowPaths) == 0 {
69+
matchOptions.allowPaths = [][]string{{"*"}}
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+
// Calculate diff returns a json diff between the two objects.
94+
m.diff, err = m.calculateDiff(actual)
95+
if err != nil {
96+
return false, err
97+
}
98+
return bytes.Equal(m.diff, []byte("{}")), nil
99+
}
100+
101+
// FailureMessage returns a message comparing the full objects after an unexpected failure to match has occurred.
102+
func (m *equalObjectMatcher) FailureMessage(actual interface{}) (message string) {
103+
return fmt.Sprintf("the following fields were expected to match but did not:\n%s\n%s", string(m.diff),
104+
format.Message(actual, "expected to match", m.original))
105+
}
106+
107+
// NegatedFailureMessage returns a string comparing the full objects after an unexpected match has occurred.
108+
func (m *equalObjectMatcher) NegatedFailureMessage(actual interface{}) (message string) {
109+
return format.Message("the following fields were not expected to match \n%s\n%s", string(m.diff),
110+
format.Message(actual, "expected to match", m.original))
111+
}
112+
113+
// calculateDiff applies the MatchOptions and identifies the diff between the Matcher object and the actual object.
114+
func (m *equalObjectMatcher) calculateDiff(actual interface{}) ([]byte, error) {
115+
// Convert the original and actual objects to json.
116+
originalJSON, err := json.Marshal(m.original)
117+
if err != nil {
118+
return nil, err
119+
}
120+
121+
actualJSON, err := json.Marshal(actual)
122+
if err != nil {
123+
return nil, err
124+
}
125+
126+
// Use a mergePatch to produce a diff between the two objects.
127+
diff, err := jsonpatch.CreateMergePatch(originalJSON, actualJSON)
128+
if err != nil {
129+
return nil, err
130+
}
131+
132+
// Filter the diff according to the rules attached to the Matcher.
133+
diff, err = filterDiff(diff, m.options.allowPaths, m.options.ignorePaths)
134+
if err != nil {
135+
return nil, err
136+
}
137+
return diff, nil
138+
}
139+
140+
// MatchOption describes an Option that can be applied to a Matcher.
141+
type MatchOption interface {
142+
// ApplyToMatcher applies this configuration to the given MatchOption.
143+
ApplyToMatcher(options *MatchOptions)
144+
}
145+
146+
// MatchOptions holds the available types of MatchOptions that can be applied to a Matcher.
147+
type MatchOptions struct {
148+
ignorePaths [][]string
149+
allowPaths [][]string
150+
}
151+
152+
// ApplyOptions adds the passed MatchOptions to the MatchOptions struct.
153+
func (o *MatchOptions) ApplyOptions(opts []MatchOption) *MatchOptions {
154+
for _, opt := range opts {
155+
opt.ApplyToMatcher(o)
156+
}
157+
return o
158+
}
159+
160+
// IgnorePaths instructs the Matcher to ignore given paths when computing a diff.
161+
type IgnorePaths [][]string
162+
163+
// ApplyToMatcher applies this configuration to the given MatchOptions.
164+
func (i IgnorePaths) ApplyToMatcher(opts *MatchOptions) {
165+
opts.ignorePaths = append(opts.ignorePaths, i...)
166+
}
167+
168+
// AllowPaths instructs the Matcher to restrict its diff to the given paths. If empty the Matcher will look at all paths.
169+
type AllowPaths [][]string
170+
171+
// ApplyToMatcher applies this configuration to the given MatchOptions.
172+
func (i AllowPaths) ApplyToMatcher(opts *MatchOptions) {
173+
opts.allowPaths = append(opts.allowPaths, i...)
174+
}
175+
176+
// filterDiff limits the diff to allowPaths if given and excludes ignorePaths if given. It returns the altered diff.
177+
func filterDiff(diff []byte, allowPaths, ignorePaths [][]string) ([]byte, error) {
178+
// converts the diff into a Map
179+
diffMap := make(map[string]interface{})
180+
err := json.Unmarshal(diff, &diffMap)
181+
if err != nil {
182+
return nil, errors.Wrap(err, "failed to unmarshal merge diff")
183+
}
184+
185+
// Removes from diffs everything not in the allowpaths.
186+
filterDiffMap(diffMap, allowPaths)
187+
188+
// Removes from diffs everything in the ignore paths.
189+
for _, path := range ignorePaths {
190+
removePath(diffMap, path)
191+
}
192+
193+
// Converts Map back into the diff.
194+
diff, err = json.Marshal(&diffMap)
195+
if err != nil {
196+
return nil, errors.Wrap(err, "failed to marshal merge diff")
197+
}
198+
return diff, nil
199+
}
200+
201+
// filterDiffMap limits the diffMap to those paths allowed by the MatchOptions.
202+
func filterDiffMap(diffMap map[string]interface{}, allowPaths [][]string) {
203+
// if the allowPaths only contains "*" return the full diffmap.
204+
if len(allowPaths) == 1 && allowPaths[0][0] == "*" {
205+
return
206+
}
207+
208+
// Loop through the entries in the map.
209+
for k, m := range diffMap {
210+
// Check if item is in the allowPaths.
211+
allowed := false
212+
for _, path := range allowPaths {
213+
if k == path[0] {
214+
allowed = true
215+
break
216+
}
217+
}
218+
219+
if !allowed {
220+
delete(diffMap, k)
221+
continue
222+
}
223+
224+
nestedMap, ok := m.(map[string]interface{})
225+
if !ok {
226+
continue
227+
}
228+
nestedPaths := make([][]string, 0)
229+
for _, path := range allowPaths {
230+
if k == path[0] && len(path) > 1 {
231+
nestedPaths = append(nestedPaths, path[1:])
232+
}
233+
}
234+
if len(nestedPaths) == 0 {
235+
continue
236+
}
237+
filterDiffMap(nestedMap, nestedPaths)
238+
239+
if len(nestedMap) == 0 {
240+
delete(diffMap, k)
241+
}
242+
}
243+
}
244+
245+
// removePath excludes any path passed in the ignorePath MatchOption from the diff.
246+
func removePath(diffMap map[string]interface{}, path []string) {
247+
switch len(path) {
248+
case 0:
249+
// If path is empty, no-op.
250+
return
251+
case 1:
252+
// If we are at the end of a path, remove the corresponding entry.
253+
delete(diffMap, path[0])
254+
default:
255+
// If in the middle of a path, go into the nested map.
256+
nestedMap, ok := diffMap[path[0]].(map[string]interface{})
257+
if !ok {
258+
return
259+
}
260+
removePath(nestedMap, path[1:])
261+
262+
// Ensure we are not leaving empty maps around.
263+
if len(nestedMap) == 0 {
264+
delete(diffMap, path[0])
265+
}
266+
}
267+
}

0 commit comments

Comments
 (0)