|
| 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