Kaynağa Gözat

Implement AllocationFilter.Equals()

Introduce support for logical equivalence between values of the
AllocationFilter type. This equivalence is for checking e.g. that two
ORs with filters in different orders are, at their core, filtering
the same thing: (or a b c) = (or b c a)

This is motivated by the need to check filter equivalence in unit tests
that don't have stable filter construction, like when constructing a
filter while iterating over a map.

Signed-off-by: Michael Dresser <michaelmdresser@gmail.com>
Michael Dresser 3 yıl önce
ebeveyn
işleme
b1df8a2a78

+ 120 - 0
pkg/kubecost/allocationfilter.go

@@ -2,6 +2,7 @@ package kubecost
 
 import (
 	"fmt"
+	"sort"
 	"strings"
 
 	"github.com/opencost/opencost/pkg/log"
@@ -100,6 +101,14 @@ type AllocationFilter interface {
 	Flattened() AllocationFilter
 
 	String() string
+
+	// Equals returns true if the two AllocationFilters are logically
+	// equivalent.
+	Equals(AllocationFilter) bool
+
+	// empty returns true if the filter isn't filtering anything, i.e. the
+	// filter is a no-op.
+	empty() bool
 }
 
 // AllocationFilterCondition is the lowest-level type of filter. It represents
@@ -130,9 +139,21 @@ func (afc AllocationFilterCondition) String() string {
 
 // Flattened returns itself because you cannot flatten a base condition further
 func (filter AllocationFilterCondition) Flattened() AllocationFilter {
+
 	return filter
 }
 
+func (left AllocationFilterCondition) Equals(right AllocationFilter) bool {
+	if rightAFC, ok := right.(AllocationFilterCondition); ok {
+		return left == rightAFC
+	}
+	return false
+}
+
+func (filter AllocationFilterCondition) empty() bool {
+	return false
+}
+
 // AllocationFilterOr is a set of filters that should be evaluated as a logical
 // OR.
 type AllocationFilterOr struct {
@@ -185,6 +206,51 @@ func (filter AllocationFilterOr) Flattened() AllocationFilter {
 	return AllocationFilterOr{Filters: flattenedFilters}
 }
 
+func (filter AllocationFilterOr) sort() {
+	for _, inner := range filter.Filters {
+		if and, ok := inner.(AllocationFilterAnd); ok {
+			and.sort()
+		} else if or, ok := inner.(AllocationFilterOr); ok {
+			or.sort()
+		}
+	}
+
+	// While a slight hack, we can rely on the string serialization of the
+	// inner filters.
+	sort.SliceStable(filter.Filters, func(i, j int) bool {
+		return filter.Filters[i].String() < filter.Filters[j].String()
+	})
+}
+
+func (left AllocationFilterOr) Equals(right AllocationFilter) bool {
+	if left.empty() {
+		return right == nil || right.empty()
+	}
+
+	rightOr, ok := right.(AllocationFilterOr)
+	if !ok {
+		return false
+	}
+
+	// Once sorted, the string representations should be equal. We can sort
+	// because ordering of logical OR statements does not matter.
+	left.sort()
+	rightOr.sort()
+	return left.String() == rightOr.String()
+}
+
+func (filter AllocationFilterOr) empty() bool {
+	for _, inner := range filter.Filters {
+		if inner == nil {
+			continue
+		}
+		if !inner.empty() {
+			return false
+		}
+	}
+	return true
+}
+
 // AllocationFilterOr is a set of filters that should be evaluated as a logical
 // AND.
 type AllocationFilterAnd struct {
@@ -221,6 +287,51 @@ func (filter AllocationFilterAnd) Flattened() AllocationFilter {
 	return AllocationFilterAnd{Filters: flattenedFilters}
 }
 
+func (filter AllocationFilterAnd) sort() {
+	for _, inner := range filter.Filters {
+		if and, ok := inner.(AllocationFilterAnd); ok {
+			and.sort()
+		} else if or, ok := inner.(AllocationFilterOr); ok {
+			or.sort()
+		}
+	}
+
+	// While a slight hack, we can rely on the string serialization of the
+	// inner filters.
+	sort.SliceStable(filter.Filters, func(i, j int) bool {
+		return filter.Filters[i].String() < filter.Filters[j].String()
+	})
+}
+
+func (left AllocationFilterAnd) Equals(right AllocationFilter) bool {
+	if left.empty() {
+		return right == nil || right.empty()
+	}
+
+	rightAnd, ok := right.(AllocationFilterAnd)
+	if !ok {
+		return false
+	}
+
+	// Once sorted, the string representations should be equal. We can sort
+	// because ordering of logical AND statements does not matter.
+	left.sort()
+	rightAnd.sort()
+	return left.String() == rightAnd.String()
+}
+
+func (filter AllocationFilterAnd) empty() bool {
+	for _, inner := range filter.Filters {
+		if inner == nil {
+			continue
+		}
+		if !inner.empty() {
+			return false
+		}
+	}
+	return true
+}
+
 func (filter AllocationFilterCondition) Matches(a *Allocation) bool {
 	if a == nil {
 		return false
@@ -428,3 +539,12 @@ func (afn AllocationFilterNone) String() string { return "(none)" }
 func (afn AllocationFilterNone) Flattened() AllocationFilter { return afn }
 
 func (afn AllocationFilterNone) Matches(a *Allocation) bool { return false }
+
+func (left AllocationFilterNone) Equals(right AllocationFilter) bool {
+	_, ok := right.(AllocationFilterNone)
+	return ok
+}
+
+func (afn AllocationFilterNone) empty() bool {
+	return false
+}

+ 555 - 0
pkg/kubecost/allocationfilter_test.go

@@ -1,6 +1,7 @@
 package kubecost
 
 import (
+	"fmt"
 	"reflect"
 	"testing"
 )
@@ -1130,3 +1131,557 @@ func Test_AllocationFilter_Flattened(t *testing.T) {
 		})
 	}
 }
+
+func Test_AllocationFilter_Equals(t *testing.T) {
+	cases := []struct {
+		left     AllocationFilter
+		right    AllocationFilter
+		expected bool
+	}{
+		// AFC
+		{
+			left:     AllocationFilterCondition{},
+			right:    AllocationFilterCondition{},
+			expected: true,
+		},
+		{
+			left: AllocationFilterCondition{
+				Field: FilterNamespace,
+				Op:    FilterStartsWith,
+				Value: "kubecost-abc",
+			},
+			right: AllocationFilterCondition{
+				Field: FilterNamespace,
+				Op:    FilterStartsWith,
+				Value: "kubecost-abc",
+			},
+			expected: true,
+		},
+		{
+			left: AllocationFilterCondition{
+				Field: FilterLabel,
+				Op:    FilterEquals,
+				Key:   "app",
+				Value: "kubecost-abc",
+			},
+			right: AllocationFilterCondition{
+				Field: FilterLabel,
+				Op:    FilterEquals,
+				Key:   "app",
+				Value: "kubecost-abc",
+			},
+			expected: true,
+		},
+		{
+			left: AllocationFilterCondition{
+				Field: FilterLabel,
+				Op:    FilterEquals,
+				Key:   "app",
+				Value: "kubecost-abc",
+			},
+			right: AllocationFilterCondition{
+				Field: FilterLabel,
+				Op:    FilterEquals,
+				Value: "kubecost-abc",
+			},
+			expected: false,
+		},
+		{
+			left: AllocationFilterCondition{
+				Field: FilterLabel,
+				Op:    FilterEquals,
+				Value: "kubecost-abc",
+			},
+			right: AllocationFilterCondition{
+				Field: FilterLabel,
+				Op:    FilterEquals,
+				Key:   "app",
+				Value: "kubecost-abc",
+			},
+			expected: false,
+		},
+		{
+			left: AllocationFilterCondition{
+				Field: FilterNamespace,
+				Op:    FilterStartsWith,
+				Value: "kubecost-abc",
+			},
+			right: AllocationFilterCondition{
+				Field: FilterNamespace,
+				Op:    FilterStartsWith,
+				Value: "kubecost-abcd",
+			},
+			expected: false,
+		},
+		// OR
+		// EMPTY
+		{
+			left:     AllocationFilterOr{},
+			right:    nil,
+			expected: true,
+		},
+		{
+			left:     AllocationFilterOr{Filters: []AllocationFilter{}},
+			right:    nil,
+			expected: true,
+		},
+
+		{
+			left:     AllocationFilterOr{},
+			right:    AllocationFilterOr{},
+			expected: true,
+		},
+		{
+			left:     AllocationFilterOr{},
+			right:    AllocationFilterOr{Filters: []AllocationFilter{}},
+			expected: true,
+		},
+
+		{
+			left:     AllocationFilterOr{Filters: []AllocationFilter{}},
+			right:    AllocationFilterOr{},
+			expected: true,
+		},
+		{
+			left:     AllocationFilterOr{Filters: []AllocationFilter{}},
+			right:    AllocationFilterOr{Filters: []AllocationFilter{}},
+			expected: true,
+		},
+		// FILLED
+		{
+			left: AllocationFilterOr{Filters: []AllocationFilter{
+				AllocationFilterNone{},
+				AllocationFilterCondition{
+					Field: FilterLabel,
+					Op:    FilterStartsWith,
+					Key:   "xyz",
+					Value: "kubecost",
+				},
+				AllocationFilterAnd{
+					Filters: []AllocationFilter{
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns1",
+						},
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns2",
+						},
+					},
+				},
+			}},
+			right: AllocationFilterOr{Filters: []AllocationFilter{
+				AllocationFilterNone{},
+				AllocationFilterCondition{
+					Field: FilterLabel,
+					Op:    FilterStartsWith,
+					Key:   "xyz",
+					Value: "kubecost",
+				},
+				AllocationFilterAnd{
+					Filters: []AllocationFilter{
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns1",
+						},
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns2",
+						},
+					},
+				},
+			}},
+			expected: true,
+		},
+		{
+			left: AllocationFilterOr{Filters: []AllocationFilter{
+				AllocationFilterNone{},
+				AllocationFilterCondition{
+					Field: FilterLabel,
+					Op:    FilterStartsWith,
+					Key:   "xyz",
+					Value: "kubecost",
+				},
+				AllocationFilterAnd{
+					Filters: []AllocationFilter{
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns1",
+						},
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns2",
+						},
+					},
+				},
+			}},
+			right: AllocationFilterOr{Filters: []AllocationFilter{
+				AllocationFilterCondition{
+					Field: FilterLabel,
+					Op:    FilterStartsWith,
+					Key:   "xyz",
+					Value: "kubecost",
+				},
+				AllocationFilterNone{},
+				AllocationFilterAnd{
+					Filters: []AllocationFilter{
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns2",
+						},
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns1",
+						},
+					},
+				},
+			}},
+			expected: true,
+		},
+		{
+			left: AllocationFilterOr{Filters: []AllocationFilter{
+				AllocationFilterNone{},
+				AllocationFilterCondition{
+					Field: FilterLabel,
+					Op:    FilterStartsWith,
+					Key:   "xyz",
+					Value: "kubecost",
+				},
+				AllocationFilterAnd{
+					Filters: []AllocationFilter{
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns1",
+						},
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns2",
+						},
+					},
+				},
+			}},
+			right: AllocationFilterOr{Filters: []AllocationFilter{
+				AllocationFilterAnd{
+					Filters: []AllocationFilter{
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns1",
+						},
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns2",
+						},
+					},
+				},
+				AllocationFilterNone{},
+				AllocationFilterCondition{
+					Field: FilterLabel,
+					Op:    FilterStartsWith,
+					Key:   "xyz",
+					Value: "kubecost",
+				},
+			}},
+			expected: true,
+		},
+		{
+			left: AllocationFilterOr{Filters: []AllocationFilter{
+				AllocationFilterNone{},
+				AllocationFilterCondition{
+					Field: FilterLabel,
+					Op:    FilterStartsWith,
+					Key:   "xyz",
+					Value: "kubecost",
+				},
+				AllocationFilterAnd{
+					Filters: []AllocationFilter{
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns1",
+						},
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns2",
+						},
+					},
+				},
+			}},
+			right: AllocationFilterOr{Filters: []AllocationFilter{
+				AllocationFilterNone{},
+				AllocationFilterCondition{
+					Field: FilterLabel,
+					Op:    FilterStartsWith,
+					Key:   "xyz",
+					Value: "kubecost",
+				},
+				AllocationFilterAnd{
+					Filters: []AllocationFilter{
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns3",
+						},
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns2",
+						},
+					},
+				},
+			}},
+			expected: false,
+		},
+		// AND
+		// EMPTY
+		{
+			left:     AllocationFilterAnd{},
+			right:    nil,
+			expected: true,
+		},
+		{
+			left:     AllocationFilterAnd{Filters: []AllocationFilter{}},
+			right:    nil,
+			expected: true,
+		},
+
+		{
+			left:     AllocationFilterAnd{},
+			right:    AllocationFilterAnd{},
+			expected: true,
+		},
+		{
+			left:     AllocationFilterAnd{},
+			right:    AllocationFilterAnd{Filters: []AllocationFilter{}},
+			expected: true,
+		},
+
+		{
+			left:     AllocationFilterAnd{Filters: []AllocationFilter{}},
+			right:    AllocationFilterAnd{},
+			expected: true,
+		},
+		{
+			left:     AllocationFilterAnd{Filters: []AllocationFilter{}},
+			right:    AllocationFilterAnd{Filters: []AllocationFilter{}},
+			expected: true,
+		},
+		// FILLED
+		{
+			left: AllocationFilterAnd{Filters: []AllocationFilter{
+				AllocationFilterNone{},
+				AllocationFilterCondition{
+					Field: FilterLabel,
+					Op:    FilterStartsWith,
+					Key:   "xyz",
+					Value: "kubecost",
+				},
+				AllocationFilterOr{
+					Filters: []AllocationFilter{
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns1",
+						},
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns2",
+						},
+					},
+				},
+			}},
+			right: AllocationFilterAnd{Filters: []AllocationFilter{
+				AllocationFilterNone{},
+				AllocationFilterCondition{
+					Field: FilterLabel,
+					Op:    FilterStartsWith,
+					Key:   "xyz",
+					Value: "kubecost",
+				},
+				AllocationFilterOr{
+					Filters: []AllocationFilter{
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns1",
+						},
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns2",
+						},
+					},
+				},
+			}},
+			expected: true,
+		},
+		{
+			left: AllocationFilterAnd{Filters: []AllocationFilter{
+				AllocationFilterNone{},
+				AllocationFilterCondition{
+					Field: FilterLabel,
+					Op:    FilterStartsWith,
+					Key:   "xyz",
+					Value: "kubecost",
+				},
+				AllocationFilterOr{
+					Filters: []AllocationFilter{
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns1",
+						},
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns2",
+						},
+					},
+				},
+			}},
+			right: AllocationFilterAnd{Filters: []AllocationFilter{
+				AllocationFilterCondition{
+					Field: FilterLabel,
+					Op:    FilterStartsWith,
+					Key:   "xyz",
+					Value: "kubecost",
+				},
+				AllocationFilterNone{},
+				AllocationFilterOr{
+					Filters: []AllocationFilter{
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns2",
+						},
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns1",
+						},
+					},
+				},
+			}},
+			expected: true,
+		},
+		{
+			left: AllocationFilterAnd{Filters: []AllocationFilter{
+				AllocationFilterNone{},
+				AllocationFilterCondition{
+					Field: FilterLabel,
+					Op:    FilterStartsWith,
+					Key:   "xyz",
+					Value: "kubecost",
+				},
+				AllocationFilterOr{
+					Filters: []AllocationFilter{
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns1",
+						},
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns2",
+						},
+					},
+				},
+			}},
+			right: AllocationFilterAnd{Filters: []AllocationFilter{
+				AllocationFilterOr{
+					Filters: []AllocationFilter{
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns1",
+						},
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns2",
+						},
+					},
+				},
+				AllocationFilterNone{},
+				AllocationFilterCondition{
+					Field: FilterLabel,
+					Op:    FilterStartsWith,
+					Key:   "xyz",
+					Value: "kubecost",
+				},
+			}},
+			expected: true,
+		},
+		{
+			left: AllocationFilterAnd{Filters: []AllocationFilter{
+				AllocationFilterNone{},
+				AllocationFilterCondition{
+					Field: FilterLabel,
+					Op:    FilterStartsWith,
+					Key:   "xyz",
+					Value: "kubecost",
+				},
+				AllocationFilterOr{
+					Filters: []AllocationFilter{
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns1",
+						},
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns2",
+						},
+					},
+				},
+			}},
+			right: AllocationFilterAnd{Filters: []AllocationFilter{
+				AllocationFilterNone{},
+				AllocationFilterCondition{
+					Field: FilterLabel,
+					Op:    FilterStartsWith,
+					Key:   "xyz",
+					Value: "kubecost",
+				},
+				AllocationFilterOr{
+					Filters: []AllocationFilter{
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns3",
+						},
+						AllocationFilterCondition{
+							Field: FilterNamespace,
+							Op:    FilterEquals,
+							Value: "ns2",
+						},
+					},
+				},
+			}},
+			expected: false,
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(fmt.Sprintf("'%s' = '%s'", c.left, c.right), func(t *testing.T) {
+			if c.left.Equals(c.right) != c.expected {
+				t.Fatalf("Expected: %t", c.expected)
+			}
+		})
+	}
+}