From 25e9b08975330e08b6a926aebf85dbd32c543a11 Mon Sep 17 00:00:00 2001 From: Jakob Schrettenbrunner Date: Thu, 13 Jan 2022 19:30:35 +0100 Subject: [PATCH] komega: add EqualObject matcher Co-authored-by: killianmuldoon Co-authored-by: Stefan Bueringer --- go.mod | 2 +- pkg/envtest/komega/equalobject.go | 271 +++++++++++++++++++++++++ pkg/envtest/komega/equalobject_test.go | 165 +++++++++++++++ 3 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 pkg/envtest/komega/equalobject.go create mode 100644 pkg/envtest/komega/equalobject_test.go diff --git a/go.mod b/go.mod index 59b6cd4464..f82fe76c90 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( 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/google/go-cmp v0.5.5 github.com/onsi/ginkgo v1.16.5 github.com/onsi/gomega v1.18.1 github.com/prometheus/client_golang v1.12.1 @@ -41,7 +42,6 @@ require ( github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/gnostic v0.5.7-v3refs // indirect - github.com/google/go-cmp v0.5.5 // indirect github.com/google/gofuzz v1.1.0 // indirect github.com/google/uuid v1.1.2 // indirect github.com/imdario/mergo v0.3.12 // indirect diff --git a/pkg/envtest/komega/equalobject.go b/pkg/envtest/komega/equalobject.go new file mode 100644 index 0000000000..dec272d07e --- /dev/null +++ b/pkg/envtest/komega/equalobject.go @@ -0,0 +1,271 @@ +/* +Copyright 2022 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 ( + "fmt" + "reflect" + "strconv" + "strings" + + "github.com/google/go-cmp/cmp" + "github.com/onsi/gomega/format" + "github.com/onsi/gomega/types" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +// 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{ + {"ObjectMeta", "UID"}, + {"ObjectMeta", "Generation"}, + {"ObjectMeta", "CreationTimestamp"}, + {"ObjectMeta", "ResourceVersion"}, + {"ObjectMeta", "ManagedFields"}, + {"ObjectMeta", "DeletionGracePeriodSeconds"}, + {"ObjectMeta", "DeletionTimestamp"}, + {"ObjectMeta", "SelfLink"}, + {"ObjectMeta", "GenerateName"}, + } +) + +type diffPath struct { + types []string + json []string +} + +// 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 + + // diffPaths contains the paths that differ between two objects. + diffPaths []diffPath + + // options holds the options that identify what should and should not be matched. + options *EqualObjectOptions +} + +// 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 ...EqualObjectOption) types.GomegaMatcher { + matchOptions := &EqualObjectOptions{} + matchOptions = matchOptions.ApplyOptions(opts) + + 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) + } + + m.diffPaths = m.calculateDiff(actual) + return len(m.diffPaths) == 0, 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%v\n%s", m.diffPaths, + format.Message(actual, "expected to match", m.original)) +} + +// NegatedFailureMessage returns a string stating that all fields matched, even though that was not expected. +func (m *equalObjectMatcher) NegatedFailureMessage(actual interface{}) (message string) { + return "it was expected that some fields do not match, but all of them did" +} + +func (d diffPath) String() string { + return fmt.Sprintf("(%s/%s)", strings.Join(d.types, "."), strings.Join(d.json, ".")) +} + +// diffReporter is a custom recorder for cmp.Diff which records all paths that are +// different between two objects. +type diffReporter struct { + stack []cmp.PathStep + path []string + jsonPath []string + + diffPaths []diffPath +} + +func (r *diffReporter) PushStep(s cmp.PathStep) { + r.stack = append(r.stack, s) + if len(r.stack) <= 1 { + return + } + switch s := s.(type) { + case cmp.SliceIndex: + r.path = append(r.path, strconv.Itoa(s.Key())) + r.jsonPath = append(r.jsonPath, strconv.Itoa(s.Key())) + case cmp.MapIndex: + key := fmt.Sprintf("%v", s.Key()) + // if strings.ContainsAny(key, ".[]/\\") { + // key = fmt.Sprintf("[%s]", key) + // } else { + // key = "." + key + // } + r.path = append(r.path, key) + r.jsonPath = append(r.jsonPath, key) + case cmp.StructField: + field := r.stack[len(r.stack)-2].Type().Field(s.Index()) + jsonName := strings.Split(field.Tag.Get("json"), ",")[0] + r.path = append(r.path, s.String()[1:]) + r.jsonPath = append(r.jsonPath, jsonName) + } +} + +func (r *diffReporter) Report(res cmp.Result) { + if !res.Equal() { + r.diffPaths = append(r.diffPaths, diffPath{types: r.path, json: r.jsonPath}) + } +} + +// func (r *diffReporter) currPath() string { +// p := []string{} +// for _, s := range r.stack[1:] { +// switch s := s.(type) { +// case cmp.StructField, cmp.SliceIndex, cmp.MapIndex: +// p = append(p, s.String()) +// } +// } +// return strings.Join(p, "")[1:] +// } + +func (r *diffReporter) PopStep() { + popped := r.stack[len(r.stack)-1] + r.stack = r.stack[:len(r.stack)-1] + if _, ok := popped.(cmp.Indirect); ok { + return + } + if len(r.stack) <= 1 { + return + } + switch popped.(type) { + case cmp.SliceIndex, cmp.MapIndex, cmp.StructField: + r.path = r.path[:len(r.path)-1] + r.jsonPath = r.jsonPath[:len(r.jsonPath)-1] + } +} + +// calculateDiff calculates the difference between two objects and returns the +// paths of the fields that do not match. +func (m *equalObjectMatcher) calculateDiff(actual interface{}) []diffPath { + var original interface{} = m.original + if u, isUnstructured := actual.(*unstructured.Unstructured); isUnstructured { + actual = u.Object + } + if u, ok := m.original.(*unstructured.Unstructured); ok { + original = u.Object + } + r := diffReporter{} + cmp.Diff(original, actual, cmp.Reporter(&r)) + return filterDiffPaths(*m.options, r.diffPaths) +} + +// filterDiffPaths filters the diff paths using the paths in EqualObjectOptions. +func filterDiffPaths(opts EqualObjectOptions, paths []diffPath) []diffPath { + result := []diffPath{} + for _, c := range paths { + if len(opts.matchPaths) > 0 && (!matchesAnyPath(c.types, opts.matchPaths) || !matchesAnyPath(c.json, opts.matchPaths)) { + continue + } + if matchesAnyPath(c.types, opts.ignorePaths) || matchesAnyPath(c.json, opts.ignorePaths) { + continue + } + result = append(result, c) + } + return result +} + +func matchesPath(path []string, prefix []string) bool { + for i, p := range prefix { + if i >= len(path) || p != path[i] { + return false + } + } + return true +} + +// matchesAnyPath returns true if path matches any of the path prefixes. +// It respects the name boundaries within paths, so 'ObjectMeta.Name' does not +// match 'ObjectMeta.Namespace' for example. +func matchesAnyPath(path []string, prefixes [][]string) bool { + for _, prefix := range prefixes { + if matchesPath(path, prefix) { + return true + } + } + return false +} + +// EqualObjectOption describes an Option that can be applied to a Matcher. +type EqualObjectOption interface { + // ApplyToEqualObjectMatcher applies this configuration to the given MatchOption. + ApplyToEqualObjectMatcher(options *EqualObjectOptions) +} + +// EqualObjectOptions holds the available types of EqualObjectOptions that can be applied to a Matcher. +type EqualObjectOptions struct { + ignorePaths [][]string + matchPaths [][]string +} + +// ApplyOptions adds the passed MatchOptions to the MatchOptions struct. +func (o *EqualObjectOptions) ApplyOptions(opts []EqualObjectOption) *EqualObjectOptions { + for _, opt := range opts { + opt.ApplyToEqualObjectMatcher(o) + } + return o +} + +// func parsePath(path string) []string { +// s := strings.Split(path, ".") +// return s +// } + +// IgnorePaths instructs the Matcher to ignore given paths when computing a diff. +type IgnorePaths [][]string + +// ApplyToEqualObjectMatcher applies this configuration to the given MatchOptions. +func (i IgnorePaths) ApplyToEqualObjectMatcher(opts *EqualObjectOptions) { + opts.ignorePaths = append(opts.ignorePaths, i...) +} + +// MatchPaths instructs the Matcher to restrict its diff to the given paths. If empty the Matcher will look at all paths. +type MatchPaths [][]string + +// ApplyToEqualObjectMatcher applies this configuration to the given MatchOptions. +func (i MatchPaths) ApplyToEqualObjectMatcher(opts *EqualObjectOptions) { + opts.matchPaths = append(opts.matchPaths, i...) +} diff --git a/pkg/envtest/komega/equalobject_test.go b/pkg/envtest/komega/equalobject_test.go new file mode 100644 index 0000000000..e675e82f70 --- /dev/null +++ b/pkg/envtest/komega/equalobject_test.go @@ -0,0 +1,165 @@ +package komega + +import ( + "testing" + + . "github.com/onsi/gomega" + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestEqualObjectMatcher(t *testing.T) { + cases := []struct { + desc string + expected client.Object + actual client.Object + opts []EqualObjectOption + result bool + }{ + { + desc: "succeed with equal objects", + expected: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + actual: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + result: true, + }, + { + desc: "fail with non equal objects", + expected: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + }, + actual: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "somethingelse", + }, + }, + result: false, + }, + { + desc: "succeeds if ignored fields do not match", + expected: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Labels: map[string]string{"somelabel": "somevalue"}, + OwnerReferences: []metav1.OwnerReference{{ + Name: "controller", + }}, + }, + }, + actual: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "somethingelse", + Labels: map[string]string{"somelabel": "anothervalue"}, + OwnerReferences: []metav1.OwnerReference{{ + Name: "another", + }}, + }, + }, + result: true, + opts: []EqualObjectOption{ + IgnorePaths{ + {"ObjectMeta", "Name"}, + {"ObjectMeta", "CreationTimestamp"}, + {"ObjectMeta", "Labels", "somelabel"}, + {"ObjectMeta", "OwnerReferences", "0", "Name"}, + {"Spec", "Template", "ObjectMeta"}, + }, + }, + }, + { + desc: "succeeds if ignored fields in json notation do not match", + expected: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Labels: map[string]string{"somelabel": "somevalue"}, + OwnerReferences: []metav1.OwnerReference{{ + Name: "controller", + }}, + }, + }, + actual: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "somethingelse", + Labels: map[string]string{"somelabel": "anothervalue"}, + OwnerReferences: []metav1.OwnerReference{{ + Name: "another", + }}, + }, + }, + result: true, + opts: []EqualObjectOption{ + IgnorePaths{ + {"metadata", "name"}, + {"metadata", "creationTimestamp"}, + {"metadata", "labels", "somelabel"}, + {"metadata", "ownerReferences", "0", "name"}, + {"spec", "template", "metadata"}, + }, + }, + }, + { + desc: "succeeds if all allowed fields match, and some others do not", + expected: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + }, + actual: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "special", + }, + }, + result: true, + opts: []EqualObjectOption{ + MatchPaths{ + {"ObjectMeta", "Name"}, + }, + }, + }, + { + desc: "works with unstructured.Unstructured", + expected: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "something", + }, + }, + }, + actual: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "somethingelse", + }, + }, + }, + result: true, + opts: []EqualObjectOption{ + IgnorePaths{ + {"metadata", "name"}, + }, + }, + }, + } + + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + g := NewWithT(t) + m := EqualObject(c.expected, c.opts...) + g.Expect(m.Match(c.actual)).To(Equal(c.result)) + //fmt.Println(m.FailureMessage(&c.actual)) + }) + } +}