diff --git a/go.mod b/go.mod index c843d2031f..df2da9eb83 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,13 @@ go 1.17 require ( github.com/evanphx/json-patch v4.12.0+incompatible + github.com/evanphx/json-patch/v5 v5.6.0 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/onsi/ginkgo v1.16.5 github.com/onsi/gomega v1.17.0 + github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.11.0 github.com/prometheus/client_model v0.2.0 go.uber.org/goleak v1.1.12 @@ -43,7 +45,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/nxadm/tail v1.4.8 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/common v0.28.0 // indirect github.com/prometheus/procfs v0.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect diff --git a/go.sum b/go.sum index 1872ab8ce0..d393b169f5 100644 --- a/go.sum +++ b/go.sum @@ -125,6 +125,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= +github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= diff --git a/pkg/envtest/komega/equalobject.go b/pkg/envtest/komega/equalobject.go new file mode 100644 index 0000000000..b9542e5343 --- /dev/null +++ b/pkg/envtest/komega/equalobject.go @@ -0,0 +1,267 @@ +/* +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 ( + "bytes" + "encoding/json" + "fmt" + "reflect" + + jsonpatch "github.com/evanphx/json-patch/v5" + "github.com/onsi/gomega/format" + "github.com/onsi/gomega/types" + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/runtime" +) + +// This code is adappted from the mergePatch code at controllers/topology/internal/mergepatch pkg. + +// 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{ + {"metadata", "uid"}, + {"metadata", "generation"}, + {"metadata", "creationTimestamp"}, + {"metadata", "resourceVersion"}, + {"metadata", "managedFields"}, + {"metadata", "deletionGracePeriodSeconds"}, + {"metadata", "deletionTimestamp"}, + {"metadata", "selfLink"}, + {"metadata", "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 + + // diff contains the delta between the two compared objects. + diff []byte + + // options holds the options that identify what should and should not be matched. + options *MatchOptions +} + +// 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 ...MatchOption) types.GomegaMatcher { + matchOptions := &MatchOptions{} + matchOptions = matchOptions.ApplyOptions(opts) + + // set the allowPaths to '*' by default to not exclude any paths from the comparison. + if len(matchOptions.allowPaths) == 0 { + matchOptions.allowPaths = [][]string{{"*"}} + } + 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) + } + + // Calculate diff returns a json diff between the two objects. + m.diff, err = m.calculateDiff(actual) + if err != nil { + return false, err + } + return bytes.Equal(m.diff, []byte("{}")), 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%s\n%s", string(m.diff), + 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 format.Message("the following fields were not expected to match \n%s\n%s", string(m.diff), + format.Message(actual, "expected to match", m.original)) +} + +// calculateDiff applies the MatchOptions and identifies the diff between the Matcher object and the actual object. +func (m *equalObjectMatcher) calculateDiff(actual interface{}) ([]byte, error) { + // Convert the original and actual objects to json. + originalJSON, err := json.Marshal(m.original) + if err != nil { + return nil, err + } + + actualJSON, err := json.Marshal(actual) + if err != nil { + return nil, err + } + + // Use a mergePatch to produce a diff between the two objects. + diff, err := jsonpatch.CreateMergePatch(originalJSON, actualJSON) + if err != nil { + return nil, err + } + + // Filter the diff according to the rules attached to the Matcher. + diff, err = filterDiff(diff, m.options.allowPaths, m.options.ignorePaths) + if err != nil { + return nil, err + } + return diff, nil +} + +// MatchOption describes an Option that can be applied to a Matcher. +type MatchOption interface { + // ApplyToMatcher applies this configuration to the given MatchOption. + ApplyToMatcher(options *MatchOptions) +} + +// MatchOptions holds the available types of MatchOptions that can be applied to a Matcher. +type MatchOptions struct { + ignorePaths [][]string + allowPaths [][]string +} + +// ApplyOptions adds the passed MatchOptions to the MatchOptions struct. +func (o *MatchOptions) ApplyOptions(opts []MatchOption) *MatchOptions { + for _, opt := range opts { + opt.ApplyToMatcher(o) + } + return o +} + +// IgnorePaths instructs the Matcher to ignore given paths when computing a diff. +type IgnorePaths [][]string + +// ApplyToMatcher applies this configuration to the given MatchOptions. +func (i IgnorePaths) ApplyToMatcher(opts *MatchOptions) { + opts.ignorePaths = append(opts.ignorePaths, i...) +} + +// AllowPaths instructs the Matcher to restrict its diff to the given paths. If empty the Matcher will look at all paths. +type AllowPaths [][]string + +// ApplyToMatcher applies this configuration to the given MatchOptions. +func (i AllowPaths) ApplyToMatcher(opts *MatchOptions) { + opts.allowPaths = append(opts.allowPaths, i...) +} + +// filterDiff limits the diff to allowPaths if given and excludes ignorePaths if given. It returns the altered diff. +func filterDiff(diff []byte, allowPaths, ignorePaths [][]string) ([]byte, error) { + // converts the diff into a Map + diffMap := make(map[string]interface{}) + err := json.Unmarshal(diff, &diffMap) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal merge diff") + } + + // Removes from diffs everything not in the allowpaths. + filterDiffMap(diffMap, allowPaths) + + // Removes from diffs everything in the ignore paths. + for _, path := range ignorePaths { + removePath(diffMap, path) + } + + // Converts Map back into the diff. + diff, err = json.Marshal(&diffMap) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal merge diff") + } + return diff, nil +} + +// filterDiffMap limits the diffMap to those paths allowed by the MatchOptions. +func filterDiffMap(diffMap map[string]interface{}, allowPaths [][]string) { + // if the allowPaths only contains "*" return the full diffmap. + if len(allowPaths) == 1 && allowPaths[0][0] == "*" { + return + } + + // Loop through the entries in the map. + for k, m := range diffMap { + // Check if item is in the allowPaths. + allowed := false + for _, path := range allowPaths { + if k == path[0] { + allowed = true + break + } + } + + if !allowed { + delete(diffMap, k) + continue + } + + nestedMap, ok := m.(map[string]interface{}) + if !ok { + continue + } + nestedPaths := make([][]string, 0) + for _, path := range allowPaths { + if k == path[0] && len(path) > 1 { + nestedPaths = append(nestedPaths, path[1:]) + } + } + if len(nestedPaths) == 0 { + continue + } + filterDiffMap(nestedMap, nestedPaths) + + if len(nestedMap) == 0 { + delete(diffMap, k) + } + } +} + +// removePath excludes any path passed in the ignorePath MatchOption from the diff. +func removePath(diffMap map[string]interface{}, path []string) { + switch len(path) { + case 0: + // If path is empty, no-op. + return + case 1: + // If we are at the end of a path, remove the corresponding entry. + delete(diffMap, path[0]) + default: + // If in the middle of a path, go into the nested map. + nestedMap, ok := diffMap[path[0]].(map[string]interface{}) + if !ok { + return + } + removePath(nestedMap, path[1:]) + + // Ensure we are not leaving empty maps around. + if len(nestedMap) == 0 { + delete(diffMap, path[0]) + } + } +} diff --git a/pkg/envtest/komega/equalobject_test.go b/pkg/envtest/komega/equalobject_test.go new file mode 100644 index 0000000000..2f26c9e675 --- /dev/null +++ b/pkg/envtest/komega/equalobject_test.go @@ -0,0 +1,498 @@ +/* +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 ( + "testing" + + "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestEqualObjectMatcher(t *testing.T) { + tests := []struct { + name string + original *unstructured.Unstructured + modified *unstructured.Unstructured + options []MatchOption + want bool + }{ + + // Test when objects are equal. + { + name: "Equal field (spec) both in original and in modified", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + want: true, + }, + + { + name: "Equal nested field both in original and in modified", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + }, + want: true, + }, + + // Test when there is a difference between the objects. + { + name: "Unequal field both in original and in modified", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "foo": "bar-changed", + }, + }, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + want: false, + }, + { + name: "Unequal nested field both in original and modified", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A-Changed", + }, + }, + }, + }, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + }, + want: false, + }, + + { + name: "Value of type map with different values", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "map": map[string]string{ + "A": "A-changed", + "B": "B", + // C missing + }, + }, + }, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "map": map[string]string{ + "A": "A", + // B missing + "C": "C", + }, + }, + }, + }, + want: false, + }, + + { + name: "Value of type Array or Slice with same length but different values", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "slice": []string{ + "D", + "C", + "B", + }, + }, + }, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "slice": []string{ + "A", + "B", + "C", + }, + }, + }, + }, + want: false, + }, + + // This tests specific behaviour in how Kubernetes marshals the zero value of metav1.Time{}. + { + name: "Creation timestamp set to empty value on both original and modified", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + "metadata": map[string]interface{}{ + "selfLink": "foo", + "creationTimestamp": metav1.Time{}, + }, + }, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + "metadata": map[string]interface{}{ + "selfLink": "foo", + "creationTimestamp": metav1.Time{}, + }, + }, + }, + want: true, + }, + + // Cases to test diff when fields exist only in modified object. + { + name: "Field only in modified", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + want: false, + }, + { + name: "Nested field only in modified", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + }, + want: false, + }, + { + name: "Creation timestamp exists on modified but not on original", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + "metadata": map[string]interface{}{ + "selfLink": "foo", + "creationTimestamp": "2021-11-03T11:05:17Z", + }, + }, + }, + want: false, + }, + + // Test when fields are exists only in the original object. + { + name: "Field only in original", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + want: false, + }, + { + name: "Nested field only in original", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + }, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + want: false, + }, + { + name: "Creation timestamp exists on original but not on modified", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + "metadata": map[string]interface{}{ + "selfLink": "foo", + "creationTimestamp": "2021-11-03T11:05:17Z", + }, + }, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + + want: false, + }, + + // Test metadata fields computed by the system or in status are compared. + { + name: "Unequal Metadata fields computed by the system or in status", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "selfLink": "foo", + "uid": "foo", + "resourceVersion": "foo", + "generation": "foo", + "managedFields": "foo", + }, + "status": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + want: false, + }, + { + name: "Unequal labels and annotations", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "foo": "bar", + }, + "annotations": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + }, + want: false, + }, + + // Ignore fields MatchOption + { + name: "Unequal metadata fields ignored by IgnorePaths MatchOption", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "selfLink": "foo", + "uid": "foo", + "resourceVersion": "foo", + "generation": "foo", + "managedFields": "foo", + }, + }, + }, + options: []MatchOption{IgnoreAutogeneratedMetadata}, + want: true, + }, + { + name: "Unequal labels and annotations ignored by IgnorePaths MatchOption", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]interface{}{ + "foo": "bar", + }, + "annotations": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + }, + options: []MatchOption{IgnorePaths{{"metadata", "labels"}, {"metadata", "annotations"}}}, + want: true, + }, + { + name: "Ignore fields are not compared", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{}, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "controlPlaneEndpoint": map[string]interface{}{ + "host": "", + "port": 0, + }, + }, + }, + }, + options: []MatchOption{IgnorePaths{{"spec", "controlPlaneEndpoint"}}}, + want: true, + }, + + // AllowPaths MatchOption + { + name: "Unequal metadata fields not compared by setting AllowPaths MatchOption", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + }, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + }, + "metadata": map[string]interface{}{ + "selfLink": "foo", + "uid": "foo", + }, + }, + }, + options: []MatchOption{AllowPaths{{"spec"}}}, + want: true, + }, + + // More tests + { + name: "No changes", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + "B": "B", + "C": "C", // C only in modified + }, + }, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + "B": "B", + }, + }, + }, + want: false, + }, + { + name: "Many changes", + original: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + // B missing + "C": "C", // C only in modified + }, + }, + }, + modified: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "A": "A", + "B": "B", + }, + }, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + matcher := EqualObject(tt.original, tt.options...) + g.Expect(matcher.Match(tt.modified)).To(gomega.Equal(tt.want)) + }) + } +}