Skip to content

Commit

Permalink
komega: add unstructured support to EqualObject
Browse files Browse the repository at this point in the history
  • Loading branch information
schrej committed May 5, 2022
1 parent 25e9b08 commit d86c00b
Show file tree
Hide file tree
Showing 2 changed files with 635 additions and 127 deletions.
179 changes: 103 additions & 76 deletions pkg/envtest/komega/equalobject.go
Expand Up @@ -17,7 +17,6 @@ package komega
import (
"fmt"
"reflect"
"strconv"
"strings"

"github.com/google/go-cmp/cmp"
Expand All @@ -33,15 +32,15 @@ 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"},
"metadata.uid",
"metadata.generation",
"metadata.creationTimestamp",
"metadata.resourceVersion",
"metadata.managedFields",
"metadata.deletionGracePeriodSeconds",
"metadata.deletionTimestamp",
"metadata.selfLink",
"metadata.generateName",
}
)

Expand Down Expand Up @@ -112,76 +111,67 @@ func (d diffPath) String() string {
// 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
stack []cmp.PathStep

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})
r.diffPaths = append(r.diffPaths, r.currentPath())
}
}

// 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:]
// }
// currentPath converts the current stack into string representations that match
// the IgnorePaths and MatchPaths syntax.
func (r *diffReporter) currentPath() diffPath {
p := diffPath{types: []string{""}, json: []string{""}}
for si, s := range r.stack[1:] {
switch s := s.(type) {
case cmp.StructField:
p.types = append(p.types, s.String()[1:])
// fetch the type information from the parent struct.
// Note: si has an offset of 1 compared to r.stack as we loop over r.stack[1:], so we don't need -1
field := r.stack[si].Type().Field(s.Index())
p.json = append(p.json, strings.Split(field.Tag.Get("json"), ",")[0])
case cmp.SliceIndex:
key := fmt.Sprintf("[%d]", s.Key())
p.types[len(p.types)-1] += key
p.json[len(p.json)-1] += key
case cmp.MapIndex:
key := fmt.Sprintf("%v", s.Key())
if strings.ContainsAny(key, ".[]/\\") {
key = fmt.Sprintf("[%s]", key)
p.types[len(p.types)-1] += key
p.json[len(p.json)-1] += key
} else {
p.types = append(p.types, key)
p.json = append(p.json, key)
}
}
}
// Empty strings were added as the first element. If they're still empty, remove them again.
if len(p.json) > 0 && len(p.json[0]) == 0 {
p.json = p.json[1:]
p.types = p.types[1:]
}
return p
}

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
// Remove the wrapping Object from unstructured.Unstructured to make comparison behave similar to
// regular objects.
if u, isUnstructured := actual.(*unstructured.Unstructured); isUnstructured {
actual = u.Object
}
Expand All @@ -196,33 +186,47 @@ func (m *equalObjectMatcher) calculateDiff(actual interface{}) []diffPath {
// 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)) {

for _, p := range paths {
if len(opts.matchPaths) > 0 && !hasAnyPathPrefix(p, opts.matchPaths) {
continue
}
if matchesAnyPath(c.types, opts.ignorePaths) || matchesAnyPath(c.json, opts.ignorePaths) {
if hasAnyPathPrefix(p, opts.ignorePaths) {
continue
}
result = append(result, c)

result = append(result, p)
}

return result
}

func matchesPath(path []string, prefix []string) bool {
// hasPathPrefix compares the segments of a path
func hasPathPrefix(path []string, prefix []string) bool {
for i, p := range prefix {
if i >= len(path) || p != path[i] {
if i >= len(path) {
return false
}
// return false if a segment doesn't match
if path[i] != p && (i < len(prefix)-1 || !segmentHasPrefix(path[i], p)) {
return false
}
}
return true
}

// matchesAnyPath returns true if path matches any of the path prefixes.
func segmentHasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[0:len(prefix)] == prefix &&
// if it is a prefix match, make sure the next character is a [ for array/map access
(len(s) == len(prefix) || s[len(prefix)] == '[')
}

// hasAnyPathPrefix 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 {
func hasAnyPathPrefix(path diffPath, prefixes [][]string) bool {
for _, prefix := range prefixes {
if matchesPath(path, prefix) {
if hasPathPrefix(path.types, prefix) || hasPathPrefix(path.json, prefix) {
return true
}
}
Expand All @@ -249,23 +253,46 @@ func (o *EqualObjectOptions) ApplyOptions(opts []EqualObjectOption) *EqualObject
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
// Paths are written in a syntax similar to Go with a few special cases. Both types and
// json/yaml field names are supported.
//
// Regular Paths
// "ObjectMeta.Name"
// "metadata.name"
// Arrays
// "metadata.ownerReferences[0].name"
// Maps, if they do not contain any of .[]/\
// "metadata.labels.something"
// Maps, if they contain any of .[]/\
// "metadata.labels[kubernetes.io/something]"
type IgnorePaths []string

// ApplyToEqualObjectMatcher applies this configuration to the given MatchOptions.
func (i IgnorePaths) ApplyToEqualObjectMatcher(opts *EqualObjectOptions) {
opts.ignorePaths = append(opts.ignorePaths, i...)
for _, p := range i {
opts.ignorePaths = append(opts.ignorePaths, strings.Split(p, "."))
}
}

// MatchPaths instructs the Matcher to restrict its diff to the given paths. If empty the Matcher will look at all paths.
type MatchPaths [][]string
// Paths are written in a syntax similar to Go with a few special cases. Both types and
// json/yaml field names are supported.
//
// Regular Paths
// "ObjectMeta.Name"
// "metadata.name"
// Arrays
// "metadata.ownerReferences[0].name"
// Maps, if they do not contain any of .[]/\
// "metadata.labels.something"
// Maps, if they contain any of .[]/\
// "metadata.labels[kubernetes.io/something]"
type MatchPaths []string

// ApplyToEqualObjectMatcher applies this configuration to the given MatchOptions.
func (i MatchPaths) ApplyToEqualObjectMatcher(opts *EqualObjectOptions) {
opts.matchPaths = append(opts.matchPaths, i...)
for _, p := range i {
opts.matchPaths = append(opts.ignorePaths, strings.Split(p, "."))
}
}

0 comments on commit d86c00b

Please sign in to comment.