ソースを参照

Merge pull request #1982 from opencost/mmd/filter-v2.1-allocation-switchover

Switch Allocation filters to v2.1
Michael Dresser 2 年 前
コミット
e03f762cb4

+ 2 - 2
pkg/filter/util/cloudcost.go → pkg/filter/cloudcost/cloudcost.go

@@ -1,4 +1,4 @@
-package util
+package cloudcost
 
 import (
 	"reflect"
@@ -105,7 +105,7 @@ func filterV1SingleValueFromList(rawFilterValues []string, field string) filter.
 		}
 
 		if wildcard {
-			subFilter.Op = kubecost.FilterStartsWith
+			subFilter.Op = filter.StringStartsWith
 		}
 
 		result.Filters = append(result.Filters, subFilter)

+ 1 - 1
pkg/filter/util/cloudcost_test.go → pkg/filter/cloudcost/cloudcost_test.go

@@ -1,4 +1,4 @@
-package util
+package cloudcost
 
 import (
 	"testing"

+ 11 - 0
pkg/filter21/ast/ops.go

@@ -52,6 +52,9 @@ const (
 	// FilterOpVoid is base-depth operator that is used for an empty filter
 	FilterOpVoid = "void"
 
+	// FilterOpContradiction is a base-depth operator that filters all data.
+	FilterOpContradiction = "contradiction"
+
 	// FilterOpAnd is an operator that succeeds if all parameters succeed.
 	FilterOpAnd = "and"
 
@@ -70,6 +73,14 @@ func (_ *VoidOp) Op() FilterOp {
 	return FilterOpVoid
 }
 
+// ContradictionOp is a base-depth operator that filters all data.
+type ContradictionOp struct{}
+
+// Op returns the FilterOp enumeration value for the operator.
+func (_ *ContradictionOp) Op() FilterOp {
+	return FilterOpContradiction
+}
+
 // AndOp is a filter operation that contains a flat list of nodes which should all resolve
 // to true in order for the result to be true.
 type AndOp struct {

+ 6 - 2
pkg/filter21/ast/walker.go

@@ -220,7 +220,6 @@ func Clone(filter FilterNode) FilterNode {
 					result = currentOps.Pop()
 				}
 			}
-
 		case *NotOp:
 			if state == TraversalStateEnter {
 				currentOps.Push(&NotOp{})
@@ -232,7 +231,12 @@ func Clone(filter FilterNode) FilterNode {
 					result = currentOps.Pop()
 				}
 			}
-
+		case *ContradictionOp:
+			if currentOps.Length() == 0 {
+				result = &ContradictionOp{}
+			} else {
+				currentOps.Top().Add(&ContradictionOp{})
+			}
 		case *EqualOp:
 			var field Field = *n.Left.Field
 			sm := &EqualOp{

+ 6 - 1
pkg/filter21/matcher/compiler.go

@@ -100,7 +100,12 @@ func (mc *MatchCompiler[T]) Compile(filter ast.FilterNode) (Matcher[T], error) {
 					result = currentOps.Pop()
 				}
 			}
-
+		case *ast.ContradictionOp:
+			if currentOps.Length() == 0 {
+				result = &AllCut[T]{}
+			} else {
+				currentOps.Top().Add(&AllCut[T]{})
+			}
 		case *ast.EqualOp:
 			sm := mc.stringMatcher.NewStringMatcher(n.Op(), n.Left, n.Right)
 			if currentOps.Length() == 0 {

+ 44 - 27
pkg/kubecost/allocation.go

@@ -6,6 +6,9 @@ import (
 	"strings"
 	"time"
 
+	filter21 "github.com/opencost/opencost/pkg/filter21"
+	"github.com/opencost/opencost/pkg/filter21/ast"
+	"github.com/opencost/opencost/pkg/filter21/matcher"
 	"github.com/opencost/opencost/pkg/log"
 	"github.com/opencost/opencost/pkg/util"
 	"github.com/opencost/opencost/pkg/util/timeutil"
@@ -1038,7 +1041,7 @@ func NewAllocationSet(start, end time.Time, allocs ...*Allocation) *AllocationSe
 // simple flag for sharing idle resources.
 type AllocationAggregationOptions struct {
 	AllocationTotalsStore                 AllocationTotalsStore
-	Filter                                AllocationFilter
+	Filter                                filter21.Filter
 	IdleByNode                            bool
 	IncludeProportionalAssetResourceCosts bool
 	LabelConfig                           *LabelConfig
@@ -1056,6 +1059,13 @@ type AllocationAggregationOptions struct {
 	IncludeAggregatedMetadata             bool
 }
 
+func isFilterEmpty(filter AllocationMatcher) bool {
+	if _, isAllPass := filter.(*matcher.AllPass[*Allocation]); isAllPass {
+		return true
+	}
+	return false
+}
+
 // AggregateBy aggregates the Allocations in the given AllocationSet by the given
 // AllocationProperty. This will only be legal if the AllocationSet is divisible by the
 // given AllocationProperty; e.g. Containers can be divided by Namespace, but not vice-a-versa.
@@ -1123,10 +1133,19 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 		options.ShareIdle = ShareNone
 	}
 
-	// Pre-flatten the filter so we can just check == nil to see if there are
-	// filters.
-	if options.Filter != nil {
-		options.Filter = options.Filter.Flattened()
+	var filter AllocationMatcher
+	if options.Filter == nil {
+		filter = &matcher.AllPass[*Allocation]{}
+	} else {
+		compiler := NewAllocationMatchCompiler()
+		var err error
+		filter, err = compiler.Compile(options.Filter)
+		if err != nil {
+			return fmt.Errorf("compiling filter '%s': %w", ast.ToPreOrderShortString(options.Filter), err)
+		}
+	}
+	if filter == nil {
+		return fmt.Errorf("unexpected nil filter")
 	}
 
 	var allocatedTotalsMap map[string]map[string]float64
@@ -1135,7 +1154,7 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 	// an empty slice implies that we should aggregate everything. See
 	// generateKey for why that makes sense.
 	shouldAggregate := aggregateBy != nil
-	shouldFilter := options.Filter != nil
+	shouldFilter := !isFilterEmpty(filter)
 	shouldShare := len(options.SharedHourlyCosts) > 0 || len(options.ShareFuncs) > 0
 	if !shouldAggregate && !shouldFilter && !shouldShare && options.ShareIdle == ShareNone && !options.IncludeProportionalAssetResourceCosts {
 		// There is nothing for AggregateBy to do, so simply return nil
@@ -1357,14 +1376,9 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 			log.DedupedWarningf(3, "AllocationSet.AggregateBy: missing idleId for allocation: %s", alloc.Name)
 		}
 
-		skip := false
-
 		// (3) If the allocation does not match the filter, immediately skip the
 		// allocation.
-		if options.Filter != nil {
-			skip = !options.Filter.Matches(alloc)
-		}
-		if skip {
+		if !filter.Matches(alloc) {
 			// If we are tracking idle filtration coefficients, delete the
 			// entry corresponding to the filtered allocation. (Deleting the
 			// entry will result in that proportional amount being removed
@@ -1576,11 +1590,7 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 	// exact key match, given each external allocation's proerties, and
 	// aggregate if an exact match is found.
 	for _, alloc := range externalSet.Allocations {
-		skip := false
-		if options.Filter != nil {
-			skip = !options.Filter.Matches(alloc)
-		}
-		if !skip {
+		if filter.Matches(alloc) {
 			key := alloc.generateKey(aggregateBy, options.LabelConfig)
 
 			alloc.Name = key
@@ -1610,11 +1620,7 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 	if idleSet.Length() > 0 {
 		for _, idleAlloc := range idleSet.Allocations {
 			// if the idle does not apply to the non-filtered values, skip it
-			skip := false
-			if options.Filter != nil {
-				skip = !options.Filter.Matches(idleAlloc)
-			}
-			if skip {
+			if !filter.Matches(idleAlloc) {
 				continue
 			}
 
@@ -1725,6 +1731,21 @@ func computeShareCoeffs(aggregateBy []string, options *AllocationAggregationOpti
 	// counts each aggregation proportionally to its respective costs
 	shareType := options.ShareSplit
 
+	var filter AllocationMatcher
+	if options.Filter == nil {
+		filter = &matcher.AllPass[*Allocation]{}
+	} else {
+		compiler := NewAllocationMatchCompiler()
+		var err error
+		filter, err = compiler.Compile(options.Filter)
+		if err != nil {
+			return nil, fmt.Errorf("compiling filter '%s': %w", ast.ToPreOrderShortString(options.Filter), err)
+		}
+	}
+	if filter == nil {
+		return nil, fmt.Errorf("unexpected nil filter")
+	}
+
 	// Record allocation values first, then normalize by totals to get percentages
 	for _, alloc := range as.Allocations {
 		if alloc.IsIdle() {
@@ -1746,11 +1767,7 @@ func computeShareCoeffs(aggregateBy []string, options *AllocationAggregationOpti
 		// of a non-filtered allocation will be conserved even when the filter
 		// is removed. (Otherwise, all the shared cost will get redistributed
 		// over the unfiltered results, inflating their shared costs.)
-		filtered := false
-		if options.Filter != nil {
-			filtered = !options.Filter.Matches(alloc)
-		}
-		if filtered {
+		if !filter.Matches(alloc) {
 			name = "__filtered__"
 		}
 

+ 64 - 16
pkg/kubecost/allocation_test.go

@@ -9,12 +9,39 @@ import (
 	"time"
 
 	"github.com/davecgh/go-spew/spew"
+	filter21 "github.com/opencost/opencost/pkg/filter21"
+	allocfilter "github.com/opencost/opencost/pkg/filter21/allocation"
+	"github.com/opencost/opencost/pkg/filter21/ops"
 	"github.com/opencost/opencost/pkg/log"
 	"github.com/opencost/opencost/pkg/util"
 	"github.com/opencost/opencost/pkg/util/json"
 	"github.com/opencost/opencost/pkg/util/timeutil"
 )
 
+var filterParser = allocfilter.NewAllocationFilterParser()
+var matcherCompiler = NewAllocationMatchCompiler()
+
+// useful for creating filters on the fly when testing. panics
+// on parse errors!
+func mustParseFilter(s string) filter21.Filter {
+	filter, err := filterParser.Parse(s)
+	if err != nil {
+		panic(err)
+	}
+	return filter
+}
+
+// useful for creating filters on the fly when testing. panics
+// on parse or compile errors!
+func mustCompileFilter(s string) AllocationMatcher {
+	filter := mustParseFilter(s)
+	m, err := matcherCompiler.Compile(filter)
+	if err != nil {
+		panic(err)
+	}
+	return m
+}
+
 func TestAllocation_Add(t *testing.T) {
 	var nilAlloc *Allocation
 	zeroAlloc := &Allocation{}
@@ -1191,11 +1218,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationClusterProp},
 			aggOpts: &AllocationAggregationOptions{
-				Filter: AllocationFilterCondition{
-					Field: FilterClusterID,
-					Op:    FilterEquals,
-					Value: "cluster1",
-				},
+				Filter:    mustParseFilter(`cluster:"cluster1"`),
 				ShareIdle: ShareNone,
 			},
 			numResults: 1 + numIdle,
@@ -1213,7 +1236,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationClusterProp},
 			aggOpts: &AllocationAggregationOptions{
-				Filter:    AllocationFilterCondition{Field: FilterClusterID, Op: FilterEquals, Value: "cluster1"},
+				Filter:    mustParseFilter(`cluster:"cluster1"`),
 				ShareIdle: ShareWeighted,
 			},
 			numResults: 1,
@@ -1230,7 +1253,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				Filter:    AllocationFilterCondition{Field: FilterClusterID, Op: FilterEquals, Value: "cluster1"},
+				Filter:    mustParseFilter(`cluster:"cluster1"`),
 				ShareIdle: ShareNone,
 			},
 			numResults: 2 + numIdle,
@@ -1249,7 +1272,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationClusterProp},
 			aggOpts: &AllocationAggregationOptions{
-				Filter:    AllocationFilterCondition{Field: FilterNamespace, Op: FilterEquals, Value: "namespace2"},
+				Filter:    mustParseFilter(`namespace:"namespace2"`),
 				ShareIdle: ShareNone,
 			},
 			numResults: numClusters + numIdle,
@@ -1292,7 +1315,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				Filter:    AllocationFilterCondition{Field: FilterNamespace, Op: FilterEquals, Value: "namespace2"},
+				Filter:    mustParseFilter(`namespace:"namespace2"`),
 				ShareIdle: ShareWeighted,
 			},
 			numResults: 1,
@@ -1317,7 +1340,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				Filter:            AllocationFilterCondition{Field: FilterNamespace, Op: FilterEquals, Value: "namespace2"},
+				Filter:            mustParseFilter(`namespace:"namespace2"`),
 				SharedHourlyCosts: map[string]float64{"total": sharedOverheadHourlyCost},
 				ShareSplit:        ShareWeighted,
 			},
@@ -1336,7 +1359,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				Filter:     AllocationFilterCondition{Field: FilterNamespace, Op: FilterEquals, Value: "namespace2"},
+				Filter:     mustParseFilter(`namespace:"namespace2"`),
 				ShareFuncs: []AllocationMatchFunc{isNamespace("namespace1")},
 				ShareSplit: ShareWeighted,
 			},
@@ -1355,7 +1378,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				Filter:     AllocationFilterCondition{Field: FilterNamespace, Op: FilterEquals, Value: "namespace2"},
+				Filter:     mustParseFilter(`namespace:"namespace2"`),
 				ShareFuncs: []AllocationMatchFunc{isNamespace("namespace1")},
 				ShareSplit: ShareWeighted,
 				ShareIdle:  ShareWeighted,
@@ -1461,7 +1484,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				Filter:     AllocationFilterCondition{Field: FilterNamespace, Op: FilterEquals, Value: "namespace2"},
+				Filter:     mustParseFilter(`namespace:"namespace2"`),
 				ShareFuncs: []AllocationMatchFunc{isNamespace("namespace1")},
 				ShareSplit: ShareWeighted,
 				ShareIdle:  ShareWeighted,
@@ -1507,7 +1530,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				Filter:            AllocationFilterCondition{Field: FilterNamespace, Op: FilterEquals, Value: "namespace2"},
+				Filter:            mustParseFilter(`namespace:"namespace2"`),
 				ShareSplit:        ShareWeighted,
 				ShareIdle:         ShareWeighted,
 				SharedHourlyCosts: map[string]float64{"total": sharedOverheadHourlyCost},
@@ -1687,7 +1710,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				Filter:     AllocationFilterCondition{Field: FilterNamespace, Op: FilterEquals, Value: "namespace2"},
+				Filter:     mustParseFilter(`namespace:"namespace2"`),
 				ShareIdle:  ShareWeighted,
 				IdleByNode: true,
 			},
@@ -3187,7 +3210,7 @@ func Test_AggregateByService_UnmountedLBs(t *testing.T) {
 	set.Insert(idle)
 
 	set.AggregateBy([]string{AllocationServiceProp}, &AllocationAggregationOptions{
-		Filter: AllocationFilterCondition{Field: FilterServices, Op: FilterContains, Value: "nginx-plus-nginx-ingress"},
+		Filter: ops.Contains(allocfilter.AllocationFieldServices, "nginx-plus-nginx-ingress"),
 	})
 
 	for _, alloc := range set.Allocations {
@@ -3413,3 +3436,28 @@ func Test_DetermineSharingName(t *testing.T) {
 		t.Fatalf("determineSharingName: expected \"unknown\"; actual \"%s\"", name)
 	}
 }
+
+func TestIsFilterEmptyTrue(t *testing.T) {
+	compiler := NewAllocationMatchCompiler()
+	matcher, err := compiler.Compile(nil)
+	if err != nil {
+		t.Fatalf("compiling nil filter: %s", err)
+	}
+
+	result := isFilterEmpty(matcher)
+	if !result {
+		t.Errorf("matcher '%+v' should be reported empty but wasn't", matcher)
+	}
+}
+
+func TestIsFilterEmptyFalse(t *testing.T) {
+	compiler := NewAllocationMatchCompiler()
+	matcher, err := compiler.Compile(ops.Eq(allocfilter.AllocationFieldClusterID, "test"))
+	if err != nil {
+		t.Fatalf("compiling nil filter: %s", err)
+	}
+	result := isFilterEmpty(matcher)
+	if result {
+		t.Errorf("matcher '%+v' should be not be reported empty but was", matcher)
+	}
+}

+ 0 - 534
pkg/kubecost/allocationfilter.go

@@ -1,534 +0,0 @@
-package kubecost
-
-import (
-	"fmt"
-	"sort"
-	"strings"
-
-	"github.com/opencost/opencost/pkg/log"
-)
-
-// FilterField is an enum that represents Allocation-specific fields that can be
-// filtered on (namespace, label, etc.)
-type FilterField string
-
-// If you add a FilterField, MAKE SURE TO UPDATE ALL FILTER IMPLEMENTATIONS! Go
-// does not enforce exhaustive pattern matching on "enum" types.
-const (
-	FilterClusterID      FilterField = "clusterid"
-	FilterNode                       = "node"
-	FilterNamespace                  = "namespace"
-	FilterControllerKind             = "controllerkind"
-	FilterControllerName             = "controllername"
-	FilterPod                        = "pod"
-	FilterContainer                  = "container"
-
-	// Filtering based on label aliases (team, department, etc.) should be a
-	// responsibility of the query handler. By the time it reaches this
-	// structured representation, we shouldn't have to be aware of what is
-	// aliased to what.
-
-	FilterLabel      = "label"
-	FilterAnnotation = "annotation"
-	FilterAlias      = "alias"
-
-	FilterServices = "services"
-)
-
-// FilterOp is an enum that represents operations that can be performed
-// when filtering (equality, inequality, etc.)
-type FilterOp string
-
-// If you add a FilterOp, MAKE SURE TO UPDATE ALL FILTER IMPLEMENTATIONS! Go
-// does not enforce exhaustive pattern matching on "enum" types.
-const (
-	// FilterEquals is the equality operator
-	// "kube-system" FilterEquals "kube-system" = true
-	// "kube-syste" FilterEquals "kube-system" = false
-	FilterEquals FilterOp = "equals"
-
-	// FilterNotEquals is the inequality operator
-	FilterNotEquals = "notequals"
-
-	// FilterContains is an array/slice membership operator
-	// ["a", "b", "c"] FilterContains "a" = true
-	FilterContains = "contains"
-
-	// FilterNotContains is an array/slice non-membership operator
-	// ["a", "b", "c"] FilterNotContains "d" = true
-	FilterNotContains = "notcontains"
-
-	// FilterStartsWith matches strings with the given prefix.
-	// "kube-system" StartsWith "kube" = true
-	//
-	// When comparing with a field represented by an array/slice, this is like
-	// applying FilterContains to every element of the slice.
-	FilterStartsWith = "startswith"
-
-	// FilterContainsPrefix is like FilterContains, but using StartsWith instead
-	// of Equals.
-	// ["kube-system", "abc123"] ContainsPrefix ["kube"] = true
-	FilterContainsPrefix = "containsprefix"
-)
-
-// AllocationFilter represents anything that can be used to filter an
-// Allocation.
-//
-// Implement this interface with caution. While it is generic, it
-// is intended to be introspectable so query handlers can perform various
-// optimizations. These optimizations include:
-// - Routing a query to the most optimal cache
-// - Querying backing data stores efficiently (e.g. translation to SQL)
-//
-// Custom implementations of this interface outside of this package should not
-// expect to receive these benefits. Passing a custom implementation to a
-// handler may in errors.
-type AllocationFilter interface {
-	// Matches is the canonical in-Go function for determining if an Allocation
-	// matches a filter.
-	Matches(a *Allocation) bool
-
-	// Flattened converts a filter into a minimal form, removing unnecessary
-	// intermediate objects, like single-element or zero-element AND and OR
-	// conditions.
-	//
-	// It returns nil if the filter is filtering nothing.
-	//
-	// Example:
-	// (and (or (namespaceequals "kubecost")) (or)) ->
-	// (namespaceequals "kubecost")
-	//
-	// (and (or)) -> nil
-	Flattened() AllocationFilter
-
-	String() string
-
-	// Equals returns true if the two AllocationFilters are logically
-	// equivalent.
-	Equals(AllocationFilter) bool
-}
-
-// AllocationFilterCondition is the lowest-level type of filter. It represents
-// the a filter operation (equality, inequality, etc.) on a field (namespace,
-// label, etc.).
-type AllocationFilterCondition struct {
-	Field FilterField
-	Op    FilterOp
-
-	// Key is for filters that require key-value pairs, like labels or
-	// annotations.
-	//
-	// A filter of 'label[app]:"foo"' has Key="app" and Value="foo"
-	Key string
-
-	// Value is for _all_ filters. A filter of 'namespace:"kubecost"' has
-	// Value="kubecost"
-	Value string
-}
-
-func (afc AllocationFilterCondition) String() string {
-	if afc.Key == "" {
-		return fmt.Sprintf(`(%s %s "%s")`, afc.Op, afc.Field, afc.Value)
-	}
-
-	return fmt.Sprintf(`(%s %s[%s] "%s")`, afc.Op, afc.Field, afc.Key, afc.Value)
-}
-
-// 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
-}
-
-// AllocationFilterOr is a set of filters that should be evaluated as a logical
-// OR.
-type AllocationFilterOr struct {
-	Filters []AllocationFilter
-}
-
-func (af AllocationFilterOr) String() string {
-	s := "(or"
-	for _, f := range af.Filters {
-		s += fmt.Sprintf(" %s", f)
-	}
-
-	s += ")"
-	return s
-}
-
-// flattened returns a new slice of filters after flattening.
-func flattened(filters []AllocationFilter) []AllocationFilter {
-	var flattenedFilters []AllocationFilter
-	for _, innerFilter := range filters {
-		if innerFilter == nil {
-			continue
-		}
-		flattenedInner := innerFilter.Flattened()
-		if flattenedInner != nil {
-			flattenedFilters = append(flattenedFilters, flattenedInner)
-		}
-	}
-
-	return flattenedFilters
-}
-
-// Flattened converts a filter into a minimal form, removing unnecessary
-// intermediate objects
-//
-// Flattened returns:
-// - nil if filter contains no filters
-// - the inner filter if filter contains one filter
-// - an equivalent AllocationFilterOr if filter contains more than one filter
-func (filter AllocationFilterOr) Flattened() AllocationFilter {
-	flattenedFilters := flattened(filter.Filters)
-	if len(flattenedFilters) == 0 {
-		return nil
-	}
-
-	if len(flattenedFilters) == 1 {
-		return flattenedFilters[0]
-	}
-
-	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 to get a sortable representation.
-	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 {
-	// The type cast takes care of right == nil as well
-	rightOr, ok := right.(AllocationFilterOr)
-	if !ok {
-		return false
-	}
-
-	if len(left.Filters) != len(rightOr.Filters) {
-		return false
-	}
-
-	left.sort()
-	rightOr.sort()
-
-	for i := range left.Filters {
-		if !left.Filters[i].Equals(rightOr.Filters[i]) {
-			return false
-		}
-	}
-	return true
-}
-
-// AllocationFilterOr is a set of filters that should be evaluated as a logical
-// AND.
-type AllocationFilterAnd struct {
-	Filters []AllocationFilter
-}
-
-func (af AllocationFilterAnd) String() string {
-	s := "(and"
-	for _, f := range af.Filters {
-		s += fmt.Sprintf(" %s", f)
-	}
-
-	s += ")"
-	return s
-}
-
-// Flattened converts a filter into a minimal form, removing unnecessary
-// intermediate objects
-//
-// Flattened returns:
-// - nil if filter contains no filters
-// - the inner filter if filter contains one filter
-// - an equivalent AllocationFilterAnd if filter contains more than one filter
-func (filter AllocationFilterAnd) Flattened() AllocationFilter {
-	flattenedFilters := flattened(filter.Filters)
-	if len(flattenedFilters) == 0 {
-		return nil
-	}
-
-	if len(flattenedFilters) == 1 {
-		return flattenedFilters[0]
-	}
-
-	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 {
-	// The type cast takes care of right == nil as well
-	rightAnd, ok := right.(AllocationFilterAnd)
-	if !ok {
-		return false
-	}
-
-	if len(left.Filters) != len(rightAnd.Filters) {
-		return false
-	}
-
-	left.sort()
-	rightAnd.sort()
-
-	for i := range left.Filters {
-		if !left.Filters[i].Equals(rightAnd.Filters[i]) {
-			return false
-		}
-	}
-	return true
-}
-
-func (filter AllocationFilterCondition) Matches(a *Allocation) bool {
-	if a == nil {
-		return false
-	}
-	if a.Properties == nil {
-		return false
-	}
-
-	// The Allocation's value for the field to compare
-	// We use an interface{} so this can contain the services []string slice
-	var valueToCompare interface{}
-
-	// toCompareMissing will be true if the value to be compared is missing in
-	// the Allocation. For example, if we're filtering based on the value of
-	// the "app" label, but the Allocation doesn't have an "app" label, this
-	// will become true. This lets us deal with != gracefully.
-	toCompareMissing := false
-
-	// This switch maps the filter.Field to the field to be compared in
-	// a.Properties and sets valueToCompare from the value in a.Properties.
-	switch filter.Field {
-	case FilterClusterID:
-		valueToCompare = a.Properties.Cluster
-	case FilterNode:
-		valueToCompare = a.Properties.Node
-	case FilterNamespace:
-		valueToCompare = a.Properties.Namespace
-	case FilterControllerKind:
-		valueToCompare = a.Properties.ControllerKind
-	case FilterControllerName:
-		valueToCompare = a.Properties.Controller
-	case FilterPod:
-		valueToCompare = a.Properties.Pod
-	case FilterContainer:
-		valueToCompare = a.Properties.Container
-	// Comes from GetAnnotation/LabelFilterFunc in KCM
-	case FilterLabel:
-		val, ok := a.Properties.Labels[filter.Key]
-
-		if !ok {
-			toCompareMissing = true
-		} else {
-			valueToCompare = val
-		}
-	case FilterAnnotation:
-		val, ok := a.Properties.Annotations[filter.Key]
-
-		if !ok {
-			toCompareMissing = true
-		} else {
-			valueToCompare = val
-		}
-	case FilterAlias:
-		var ok bool
-		valueToCompare, ok = a.Properties.Labels[filter.Key]
-		if !ok {
-			valueToCompare, ok = a.Properties.Annotations[filter.Key]
-			if !ok {
-				toCompareMissing = true
-			}
-		}
-	case FilterServices:
-		valueToCompare = a.Properties.Services
-	default:
-		log.Errorf("Allocation Filter: Unhandled filter field. This is a filter implementation error and requires immediate patching. Field: %s", filter.Field)
-		return false
-	}
-
-	switch filter.Op {
-	case FilterEquals:
-		// namespace:"__unallocated__" should match a.Properties.Namespace = ""
-		// label[app]:"__unallocated__" should match _, ok := Labels[app]; !ok
-		if toCompareMissing || valueToCompare == "" {
-			return filter.Value == UnallocatedSuffix
-		}
-
-		if valueToCompare == filter.Value {
-			return true
-		}
-	case FilterNotEquals:
-		// namespace!:"__unallocated__" should match
-		// a.Properties.Namespace != ""
-		// label[app]!:"__unallocated__" should match _, ok := Labels[app]; ok
-		if filter.Value == UnallocatedSuffix {
-			if toCompareMissing {
-				return false
-			}
-			return valueToCompare != ""
-		}
-
-		if toCompareMissing {
-			return true
-		}
-
-		if valueToCompare != filter.Value {
-			return true
-		}
-	case FilterContains:
-		if stringSlice, ok := valueToCompare.([]string); ok {
-			if len(stringSlice) == 0 {
-				return filter.Value == UnallocatedSuffix
-			}
-
-			for _, s := range stringSlice {
-				if s == filter.Value {
-					return true
-				}
-			}
-		} else {
-			log.Warnf("Allocation Filter: invalid 'contains' call for non-list filter value")
-		}
-	case FilterNotContains:
-		if stringSlice, ok := valueToCompare.([]string); ok {
-			// services!:"__unallocated__" should match
-			// len(a.Properties.Services) > 0
-			//
-			// TODO: is this true?
-			if filter.Value == UnallocatedSuffix {
-				return len(stringSlice) > 0
-			}
-
-			for _, s := range stringSlice {
-				if s == filter.Value {
-					return false
-				}
-			}
-
-			return true
-		} else {
-			log.Warnf("Allocation Filter: invalid 'notcontains' call for non-list filter value")
-		}
-	case FilterStartsWith:
-		if toCompareMissing {
-			return false
-		}
-
-		// We don't need special __unallocated__ logic here because a query
-		// asking for "__unallocated__" won't have a wildcard and unallocated
-		// properties are the empty string.
-
-		s, ok := valueToCompare.(string)
-		if !ok {
-			log.Warnf("Allocation Filter: invalid 'startswith' call for field with unsupported type")
-			return false
-		}
-		return strings.HasPrefix(s, filter.Value)
-	case FilterContainsPrefix:
-		if toCompareMissing {
-			return false
-		}
-
-		// We don't need special __unallocated__ logic here because a query
-		// asking for "__unallocated__" won't have a wildcard and unallocated
-		// properties are the empty string.
-
-		values, ok := valueToCompare.([]string)
-		if !ok {
-			log.Warnf("Allocation Filter: invalid '%s' call for field with unsupported type", FilterContainsPrefix)
-			return false
-		}
-
-		for _, s := range values {
-			if strings.HasPrefix(s, filter.Value) {
-				return true
-			}
-		}
-
-		return false
-	default:
-		log.Errorf("Allocation Filter: Unhandled filter op. This is a filter implementation error and requires immediate patching. Op: %s", filter.Op)
-		return false
-	}
-
-	return false
-}
-
-func (and AllocationFilterAnd) Matches(a *Allocation) bool {
-	filters := and.Filters
-	if len(filters) == 0 {
-		return true
-	}
-
-	for _, filter := range filters {
-		if !filter.Matches(a) {
-			return false
-		}
-	}
-
-	return true
-}
-
-func (or AllocationFilterOr) Matches(a *Allocation) bool {
-	filters := or.Filters
-	if len(filters) == 0 {
-		return true
-	}
-
-	for _, filter := range filters {
-		if filter.Matches(a) {
-			return true
-		}
-	}
-
-	return false
-}
-
-// AllocationFilterNone is a filter that matches no allocations. This is useful
-// for applications like authorization, where a user/group/role may be disallowed
-// from viewing Allocation data entirely.
-type AllocationFilterNone struct{}
-
-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
-}

+ 105 - 1149
pkg/kubecost/allocationfilter_test.go

@@ -1,16 +1,19 @@
 package kubecost
 
 import (
-	"fmt"
-	"reflect"
 	"testing"
+
+	filter21 "github.com/opencost/opencost/pkg/filter21"
+	allocfilter "github.com/opencost/opencost/pkg/filter21/allocation"
+	"github.com/opencost/opencost/pkg/filter21/ast"
+	"github.com/opencost/opencost/pkg/filter21/ops"
 )
 
 func Test_AllocationFilterCondition_Matches(t *testing.T) {
 	cases := []struct {
 		name   string
 		a      *Allocation
-		filter AllocationFilter
+		filter filter21.Filter
 
 		expected bool
 	}{
@@ -21,12 +24,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Cluster: "cluster-one",
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterClusterID,
-				Op:    FilterEquals,
-				Value: "cluster-one",
-			},
-
+			filter:   ops.Eq(allocfilter.AllocationFieldClusterID, "cluster-one"),
 			expected: true,
 		},
 		{
@@ -36,12 +34,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Cluster: "cluster-one",
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterClusterID,
-				Op:    FilterStartsWith,
-				Value: "cluster",
-			},
-
+			filter:   ops.ContainsPrefix(allocfilter.AllocationFieldClusterID, "cluster"),
 			expected: true,
 		},
 		{
@@ -51,11 +44,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Cluster: "k8s-one",
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterClusterID,
-				Op:    FilterStartsWith,
-				Value: "cluster",
-			},
+			filter: ops.ContainsPrefix(allocfilter.AllocationFieldClusterID, "cluster"),
 
 			expected: false,
 		},
@@ -66,12 +55,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Cluster: "",
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterClusterID,
-				Op:    FilterStartsWith,
-				Value: "",
-			},
-
+			filter:   ops.ContainsPrefix(allocfilter.AllocationFieldClusterID, ""),
 			expected: true,
 		},
 		{
@@ -81,12 +65,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Cluster: "abc",
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterClusterID,
-				Op:    FilterStartsWith,
-				Value: "",
-			},
-
+			filter:   ops.ContainsPrefix(allocfilter.AllocationFieldClusterID, ""),
 			expected: true,
 		},
 		{
@@ -96,12 +75,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Node: "node123",
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterNode,
-				Op:    FilterEquals,
-				Value: "node123",
-			},
-
+			filter:   ops.Eq(allocfilter.AllocationFieldNode, "node123"),
 			expected: true,
 		},
 		{
@@ -111,12 +85,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Namespace: "kube-system",
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterNamespace,
-				Op:    FilterNotEquals,
-				Value: "kube-system",
-			},
-
+			filter:   ops.NotEq(allocfilter.AllocationFieldNamespace, "kube-system"),
 			expected: false,
 		},
 		{
@@ -126,12 +95,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Namespace: "kube-system",
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterNamespace,
-				Op:    FilterNotEquals,
-				Value: UnallocatedSuffix,
-			},
-
+			filter:   ops.NotEq(allocfilter.AllocationFieldNamespace, UnallocatedSuffix),
 			expected: true,
 		},
 		{
@@ -141,12 +105,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Namespace: "",
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterNamespace,
-				Op:    FilterNotEquals,
-				Value: UnallocatedSuffix,
-			},
-
+			filter:   ops.NotEq(allocfilter.AllocationFieldNamespace, UnallocatedSuffix),
 			expected: false,
 		},
 		{
@@ -156,12 +115,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Namespace: "",
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterNamespace,
-				Op:    FilterEquals,
-				Value: UnallocatedSuffix,
-			},
-
+			filter:   ops.Eq(allocfilter.AllocationFieldNamespace, UnallocatedSuffix),
 			expected: true,
 		},
 		{
@@ -171,12 +125,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					ControllerKind: "deployment", // We generally store controller kinds as all lowercase
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterControllerKind,
-				Op:    FilterEquals,
-				Value: "deployment",
-			},
-
+			filter:   ops.Eq(allocfilter.AllocationFieldControllerKind, "deployment"),
 			expected: true,
 		},
 		{
@@ -186,12 +135,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Controller: "kc-cost-analyzer",
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterControllerName,
-				Op:    FilterEquals,
-				Value: "kc-cost-analyzer",
-			},
-
+			filter:   ops.Eq(allocfilter.AllocationFieldControllerName, "kc-cost-analyzer"),
 			expected: true,
 		},
 		{
@@ -201,12 +145,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Pod: "pod-123 UID-ABC",
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterPod,
-				Op:    FilterEquals,
-				Value: "pod-123 UID-ABC",
-			},
-
+			filter:   ops.Eq(allocfilter.AllocationFieldPod, "pod-123 UID-ABC"),
 			expected: true,
 		},
 		{
@@ -216,12 +155,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Container: "cost-model",
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterContainer,
-				Op:    FilterEquals,
-				Value: "cost-model",
-			},
-
+			filter:   ops.Eq(allocfilter.AllocationFieldContainer, "cost-model"),
 			expected: true,
 		},
 		{
@@ -233,13 +167,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterLabel,
-				Op:    FilterEquals,
-				Key:   "app",
-				Value: "foo",
-			},
-
+			filter:   ops.Eq(ops.WithKey(allocfilter.AllocationFieldLabel, "app"), "foo"),
 			expected: true,
 		},
 		{
@@ -251,13 +179,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterLabel,
-				Op:    FilterEquals,
-				Key:   "app",
-				Value: "foo",
-			},
-
+			filter:   ops.Eq(ops.WithKey(allocfilter.AllocationFieldLabel, "app"), "foo"),
 			expected: false,
 		},
 		{
@@ -269,13 +191,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterLabel,
-				Op:    FilterEquals,
-				Key:   "app",
-				Value: "foo",
-			},
-
+			filter:   ops.Eq(ops.WithKey(allocfilter.AllocationFieldLabel, "app"), "foo"),
 			expected: false,
 		},
 		{
@@ -287,13 +203,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterLabel,
-				Op:    FilterEquals,
-				Key:   "app",
-				Value: UnallocatedSuffix,
-			},
-
+			filter:   ops.Eq(ops.WithKey(allocfilter.AllocationFieldLabel, "app"), UnallocatedSuffix),
 			expected: true,
 		},
 		{
@@ -305,13 +215,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterLabel,
-				Op:    FilterEquals,
-				Key:   "app",
-				Value: UnallocatedSuffix,
-			},
-
+			filter:   ops.Eq(ops.WithKey(allocfilter.AllocationFieldLabel, "app"), UnallocatedSuffix),
 			expected: false,
 		},
 		{
@@ -323,13 +227,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterLabel,
-				Op:    FilterNotEquals,
-				Key:   "app",
-				Value: UnallocatedSuffix,
-			},
-
+			filter:   ops.NotEq(ops.WithKey(allocfilter.AllocationFieldLabel, "app"), UnallocatedSuffix),
 			expected: false,
 		},
 		{
@@ -341,13 +239,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterLabel,
-				Op:    FilterNotEquals,
-				Key:   "app",
-				Value: UnallocatedSuffix,
-			},
-
+			filter:   ops.NotEq(ops.WithKey(allocfilter.AllocationFieldLabel, "app"), UnallocatedSuffix),
 			expected: true,
 		},
 		{
@@ -359,13 +251,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterLabel,
-				Op:    FilterNotEquals,
-				Key:   "app",
-				Value: "foo",
-			},
-
+			filter:   ops.NotEq(ops.WithKey(allocfilter.AllocationFieldLabel, "app"), "foo"),
 			expected: true,
 		},
 		{
@@ -377,13 +263,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterAnnotation,
-				Op:    FilterEquals,
-				Key:   "prom_modified_name",
-				Value: "testing123",
-			},
-
+			filter:   ops.Eq(ops.WithKey(allocfilter.AllocationFieldAnnotation, "prom_modified_name"), "testing123"),
 			expected: true,
 		},
 		{
@@ -395,13 +275,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterAnnotation,
-				Op:    FilterEquals,
-				Key:   "app",
-				Value: "foo",
-			},
-
+			filter:   ops.Eq(ops.WithKey(allocfilter.AllocationFieldAnnotation, "app"), "foo"),
 			expected: false,
 		},
 		{
@@ -413,13 +287,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterAnnotation,
-				Op:    FilterEquals,
-				Key:   "app",
-				Value: "foo",
-			},
-
+			filter:   ops.Eq(ops.WithKey(allocfilter.AllocationFieldAnnotation, "app"), "foo"),
 			expected: false,
 		},
 		{
@@ -431,13 +299,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterAnnotation,
-				Op:    FilterNotEquals,
-				Key:   "app",
-				Value: "foo",
-			},
-
+			filter:   ops.NotEq(ops.WithKey(allocfilter.AllocationFieldAnnotation, "app"), "foo"),
 			expected: true,
 		},
 		{
@@ -447,12 +309,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Namespace: "",
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterNamespace,
-				Op:    FilterEquals,
-				Value: UnallocatedSuffix,
-			},
-
+			filter:   ops.Eq(allocfilter.AllocationFieldNamespace, UnallocatedSuffix),
 			expected: true,
 		},
 		{
@@ -462,12 +319,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Services: []string{"serv1", "serv2"},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterServices,
-				Op:    FilterContains,
-				Value: "serv2",
-			},
-
+			filter:   ops.Contains(allocfilter.AllocationFieldServices, "serv2"),
 			expected: true,
 		},
 		{
@@ -477,12 +329,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Services: []string{"serv1", "serv2"},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterServices,
-				Op:    FilterContains,
-				Value: "serv3",
-			},
-
+			filter:   ops.Contains(allocfilter.AllocationFieldServices, "serv3"),
 			expected: false,
 		},
 		{
@@ -492,12 +339,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Services: []string{"serv1", "serv2"},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterServices,
-				Op:    FilterNotContains,
-				Value: "serv3",
-			},
-
+			filter:   ops.NotContains(allocfilter.AllocationFieldServices, "serv3"),
 			expected: true,
 		},
 		{
@@ -507,12 +349,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Services: []string{"serv1", "serv2"},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterServices,
-				Op:    FilterNotContains,
-				Value: "serv2",
-			},
-
+			filter:   ops.NotContains(allocfilter.AllocationFieldServices, "serv2"),
 			expected: false,
 		},
 		{
@@ -522,12 +359,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Services: []string{"serv1", "serv2"},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterServices,
-				Op:    FilterNotContains,
-				Value: UnallocatedSuffix,
-			},
-
+			filter:   ops.NotContains(allocfilter.AllocationFieldServices, UnallocatedSuffix),
 			expected: true,
 		},
 		{
@@ -537,12 +369,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Services: []string{},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterServices,
-				Op:    FilterNotContains,
-				Value: UnallocatedSuffix,
-			},
-
+			filter:   ops.NotContains(allocfilter.AllocationFieldServices, UnallocatedSuffix),
 			expected: false,
 		},
 		{
@@ -552,12 +379,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Services: []string{"serv1", "serv2"},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterServices,
-				Op:    FilterContainsPrefix,
-				Value: "serv",
-			},
-
+			filter:   ops.ContainsPrefix(allocfilter.AllocationFieldServices, "serv"),
 			expected: true,
 		},
 		{
@@ -567,12 +389,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Services: []string{"foo", "bar"},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterServices,
-				Op:    FilterContainsPrefix,
-				Value: "serv",
-			},
-
+			filter:   ops.ContainsPrefix(allocfilter.AllocationFieldServices, "serv"),
 			expected: false,
 		},
 		{
@@ -582,12 +399,7 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Services: []string{"serv1", "serv2"},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterServices,
-				Op:    FilterContains,
-				Value: UnallocatedSuffix,
-			},
-
+			filter:   ops.Contains(allocfilter.AllocationFieldServices, UnallocatedSuffix),
 			expected: false,
 		},
 		{
@@ -597,26 +409,26 @@ func Test_AllocationFilterCondition_Matches(t *testing.T) {
 					Services: []string{},
 				},
 			},
-			filter: AllocationFilterCondition{
-				Field: FilterServices,
-				Op:    FilterContains,
-				Value: UnallocatedSuffix,
-			},
-
+			filter:   ops.Contains(allocfilter.AllocationFieldServices, UnallocatedSuffix),
 			expected: true,
 		},
 	}
 
 	for _, c := range cases {
-		result := c.filter.Matches(c.a)
+		compiler := NewAllocationMatchCompiler()
+		compiled, err := compiler.Compile(c.filter)
+		if err != nil {
+			t.Fatalf("err compiling filter '%s': %s", ast.ToPreOrderShortString(c.filter), err)
+		}
 
+		result := compiled.Matches(c.a)
 		if result != c.expected {
 			t.Errorf("%s: expected %t, got %t", c.name, c.expected, result)
 		}
 	}
 }
 
-func Test_AllocationFilterNone_Matches(t *testing.T) {
+func Test_AllocationFilterContradiction_Matches(t *testing.T) {
 	cases := []struct {
 		name string
 		a    *Allocation
@@ -724,8 +536,14 @@ func Test_AllocationFilterNone_Matches(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		result := AllocationFilterNone{}.Matches(c.a)
+		filter := &ast.ContradictionOp{}
+		compiler := NewAllocationMatchCompiler()
+		compiled, err := compiler.Compile(filter)
+		if err != nil {
+			t.Fatalf("err compiling filter '%s': %s", ast.ToPreOrderShortString(filter), err)
+		}
 
+		result := compiled.Matches(c.a)
 		if result {
 			t.Errorf("%s: should have been rejected", c.name)
 		}
@@ -735,7 +553,7 @@ func Test_AllocationFilterAnd_Matches(t *testing.T) {
 	cases := []struct {
 		name   string
 		a      *Allocation
-		filter AllocationFilter
+		filter filter21.Filter
 
 		expected bool
 	}{
@@ -749,19 +567,10 @@ func Test_AllocationFilterAnd_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterAnd{[]AllocationFilter{
-				AllocationFilterCondition{
-					Field: FilterLabel,
-					Op:    FilterEquals,
-					Key:   "app",
-					Value: "foo",
-				},
-				AllocationFilterCondition{
-					Field: FilterNamespace,
-					Op:    FilterEquals,
-					Value: "kubecost",
-				},
-			}},
+			filter: ops.And(
+				ops.Eq(ops.WithKey(allocfilter.AllocationFieldLabel, "app"), "foo"),
+				ops.Eq(allocfilter.AllocationFieldNamespace, "kubecost"),
+			),
 			expected: true,
 		},
 		{
@@ -774,19 +583,10 @@ func Test_AllocationFilterAnd_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterAnd{[]AllocationFilter{
-				AllocationFilterCondition{
-					Field: FilterLabel,
-					Op:    FilterEquals,
-					Key:   "app",
-					Value: "foo",
-				},
-				AllocationFilterCondition{
-					Field: FilterNamespace,
-					Op:    FilterEquals,
-					Value: "kubecost",
-				},
-			}},
+			filter: ops.And(
+				ops.Eq(ops.WithKey(allocfilter.AllocationFieldLabel, "app"), "foo"),
+				ops.Eq(allocfilter.AllocationFieldNamespace, "kubecost"),
+			),
 			expected: false,
 		},
 		{
@@ -799,19 +599,10 @@ func Test_AllocationFilterAnd_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterAnd{[]AllocationFilter{
-				AllocationFilterCondition{
-					Field: FilterLabel,
-					Op:    FilterEquals,
-					Key:   "app",
-					Value: "foo",
-				},
-				AllocationFilterCondition{
-					Field: FilterNamespace,
-					Op:    FilterEquals,
-					Value: "kubecost",
-				},
-			}},
+			filter: ops.And(
+				ops.Eq(ops.WithKey(allocfilter.AllocationFieldLabel, "app"), "foo"),
+				ops.Eq(allocfilter.AllocationFieldNamespace, "kubecost"),
+			),
 			expected: false,
 		},
 		{
@@ -824,23 +615,14 @@ func Test_AllocationFilterAnd_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterAnd{[]AllocationFilter{
-				AllocationFilterCondition{
-					Field: FilterLabel,
-					Op:    FilterEquals,
-					Key:   "app",
-					Value: "foo",
-				},
-				AllocationFilterCondition{
-					Field: FilterNamespace,
-					Op:    FilterEquals,
-					Value: "kubecost",
-				},
-			}},
+			filter: ops.And(
+				ops.Eq(ops.WithKey(allocfilter.AllocationFieldLabel, "app"), "foo"),
+				ops.Eq(allocfilter.AllocationFieldNamespace, "kubecost"),
+			),
 			expected: false,
 		},
 		{
-			name: `(and none) matches nothing`,
+			name: `contradiction matches nothing`,
 			a: &Allocation{
 				Properties: &AllocationProperties{
 					Namespace: "kube-system",
@@ -849,16 +631,19 @@ func Test_AllocationFilterAnd_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterAnd{[]AllocationFilter{
-				AllocationFilterNone{},
-			}},
+			filter:   &ast.ContradictionOp{},
 			expected: false,
 		},
 	}
 
 	for _, c := range cases {
-		result := c.filter.Matches(c.a)
+		compiler := NewAllocationMatchCompiler()
+		compiled, err := compiler.Compile(c.filter)
+		if err != nil {
+			t.Fatalf("err compiling filter '%s': %s", ast.ToPreOrderShortString(c.filter), err)
+		}
 
+		result := compiled.Matches(c.a)
 		if result != c.expected {
 			t.Errorf("%s: expected %t, got %t", c.name, c.expected, result)
 		}
@@ -869,7 +654,7 @@ func Test_AllocationFilterOr_Matches(t *testing.T) {
 	cases := []struct {
 		name   string
 		a      *Allocation
-		filter AllocationFilter
+		filter filter21.Filter
 
 		expected bool
 	}{
@@ -883,19 +668,10 @@ func Test_AllocationFilterOr_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterOr{[]AllocationFilter{
-				AllocationFilterCondition{
-					Field: FilterLabel,
-					Op:    FilterEquals,
-					Key:   "app",
-					Value: "foo",
-				},
-				AllocationFilterCondition{
-					Field: FilterNamespace,
-					Op:    FilterEquals,
-					Value: "kubecost",
-				},
-			}},
+			filter: ops.Or(
+				ops.Eq(ops.WithKey(allocfilter.AllocationFieldLabel, "app"), "foo"),
+				ops.Eq(allocfilter.AllocationFieldNamespace, "kubecost"),
+			),
 			expected: true,
 		},
 		{
@@ -908,19 +684,10 @@ func Test_AllocationFilterOr_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterOr{[]AllocationFilter{
-				AllocationFilterCondition{
-					Field: FilterLabel,
-					Op:    FilterEquals,
-					Key:   "app",
-					Value: "foo",
-				},
-				AllocationFilterCondition{
-					Field: FilterNamespace,
-					Op:    FilterEquals,
-					Value: "kubecost",
-				},
-			}},
+			filter: ops.Or(
+				ops.Eq(ops.WithKey(allocfilter.AllocationFieldLabel, "app"), "foo"),
+				ops.Eq(allocfilter.AllocationFieldNamespace, "kubecost"),
+			),
 			expected: true,
 		},
 		{
@@ -933,19 +700,10 @@ func Test_AllocationFilterOr_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterOr{[]AllocationFilter{
-				AllocationFilterCondition{
-					Field: FilterLabel,
-					Op:    FilterEquals,
-					Key:   "app",
-					Value: "foo",
-				},
-				AllocationFilterCondition{
-					Field: FilterNamespace,
-					Op:    FilterEquals,
-					Value: "kubecost",
-				},
-			}},
+			filter: ops.Or(
+				ops.Eq(ops.WithKey(allocfilter.AllocationFieldLabel, "app"), "foo"),
+				ops.Eq(allocfilter.AllocationFieldNamespace, "kubecost"),
+			),
 			expected: true,
 		},
 		{
@@ -958,826 +716,24 @@ func Test_AllocationFilterOr_Matches(t *testing.T) {
 					},
 				},
 			},
-			filter: AllocationFilterOr{[]AllocationFilter{
-				AllocationFilterCondition{
-					Field: FilterLabel,
-					Op:    FilterEquals,
-					Key:   "app",
-					Value: "foo",
-				},
-				AllocationFilterCondition{
-					Field: FilterNamespace,
-					Op:    FilterEquals,
-					Value: "kubecost",
-				},
-			}},
+			filter: ops.Or(
+				ops.Eq(ops.WithKey(allocfilter.AllocationFieldLabel, "app"), "foo"),
+				ops.Eq(allocfilter.AllocationFieldNamespace, "kubecost"),
+			),
 			expected: false,
 		},
 	}
 
 	for _, c := range cases {
-		result := c.filter.Matches(c.a)
+		compiler := NewAllocationMatchCompiler()
+		compiled, err := compiler.Compile(c.filter)
+		if err != nil {
+			t.Fatalf("err compiling filter '%s': %s", ast.ToPreOrderShortString(c.filter), err)
+		}
 
+		result := compiled.Matches(c.a)
 		if result != c.expected {
 			t.Errorf("%s: expected %t, got %t", c.name, c.expected, result)
 		}
 	}
 }
-
-func Test_AllocationFilter_Flattened(t *testing.T) {
-	cases := []struct {
-		name string
-
-		input    AllocationFilter
-		expected AllocationFilter
-	}{
-		{
-			name: "AllocationFilterCondition",
-			input: AllocationFilterCondition{
-				Field: FilterNamespace,
-				Op:    FilterEquals,
-			},
-			expected: AllocationFilterCondition{
-				Field: FilterNamespace,
-				Op:    FilterEquals,
-			},
-		},
-		{
-			name:     "empty AllocationFilterAnd (nil)",
-			input:    AllocationFilterAnd{},
-			expected: nil,
-		},
-		{
-			name:     "empty AllocationFilterAnd (len 0)",
-			input:    AllocationFilterAnd{Filters: []AllocationFilter{}},
-			expected: nil,
-		},
-		{
-			name:     "empty AllocationFilterOr (nil)",
-			input:    AllocationFilterOr{},
-			expected: nil,
-		},
-		{
-			name:     "empty AllocationFilterOr (len 0)",
-			input:    AllocationFilterOr{Filters: []AllocationFilter{}},
-			expected: nil,
-		},
-		{
-			name: "single-element AllocationFilterAnd",
-			input: AllocationFilterAnd{Filters: []AllocationFilter{
-				AllocationFilterCondition{
-					Field: FilterNamespace,
-					Op:    FilterEquals,
-				},
-			}},
-
-			expected: AllocationFilterCondition{
-				Field: FilterNamespace,
-				Op:    FilterEquals,
-			},
-		},
-		{
-			name: "single-element AllocationFilterOr",
-			input: AllocationFilterOr{Filters: []AllocationFilter{
-				AllocationFilterCondition{
-					Field: FilterNamespace,
-					Op:    FilterEquals,
-				},
-			}},
-
-			expected: AllocationFilterCondition{
-				Field: FilterNamespace,
-				Op:    FilterEquals,
-			},
-		},
-		{
-			name: "multi-element AllocationFilterAnd",
-			input: AllocationFilterAnd{Filters: []AllocationFilter{
-				AllocationFilterCondition{
-					Field: FilterNamespace,
-					Op:    FilterEquals,
-				},
-				AllocationFilterCondition{
-					Field: FilterClusterID,
-					Op:    FilterNotEquals,
-				},
-				AllocationFilterCondition{
-					Field: FilterServices,
-					Op:    FilterContains,
-				},
-			}},
-
-			expected: AllocationFilterAnd{Filters: []AllocationFilter{
-				AllocationFilterCondition{
-					Field: FilterNamespace,
-					Op:    FilterEquals,
-				},
-				AllocationFilterCondition{
-					Field: FilterClusterID,
-					Op:    FilterNotEquals,
-				},
-				AllocationFilterCondition{
-					Field: FilterServices,
-					Op:    FilterContains,
-				},
-			}},
-		},
-		{
-			name: "multi-element AllocationFilterOr",
-			input: AllocationFilterOr{Filters: []AllocationFilter{
-				AllocationFilterCondition{
-					Field: FilterNamespace,
-					Op:    FilterEquals,
-				},
-				AllocationFilterCondition{
-					Field: FilterClusterID,
-					Op:    FilterNotEquals,
-				},
-				AllocationFilterCondition{
-					Field: FilterServices,
-					Op:    FilterContains,
-				},
-			}},
-
-			expected: AllocationFilterOr{Filters: []AllocationFilter{
-				AllocationFilterCondition{
-					Field: FilterNamespace,
-					Op:    FilterEquals,
-				},
-				AllocationFilterCondition{
-					Field: FilterClusterID,
-					Op:    FilterNotEquals,
-				},
-				AllocationFilterCondition{
-					Field: FilterServices,
-					Op:    FilterContains,
-				},
-			}},
-		},
-		{
-			name:     "AllocationFilterNone",
-			input:    AllocationFilterNone{},
-			expected: AllocationFilterNone{},
-		},
-	}
-
-	for _, c := range cases {
-		t.Run(c.name, func(t *testing.T) {
-			result := c.input.Flattened()
-
-			if !reflect.DeepEqual(result, c.expected) {
-				t.Errorf("Expected: '%s'. Got '%s'.", c.expected, result)
-			}
-		})
-	}
-}
-
-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: false,
-		},
-		{
-			left:     AllocationFilterOr{Filters: []AllocationFilter{}},
-			right:    nil,
-			expected: false,
-		},
-
-		{
-			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,
-		},
-		{
-			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",
-				},
-				AllocationFilterAnd{
-					Filters: []AllocationFilter{
-						AllocationFilterCondition{
-							Field: FilterNamespace,
-							Op:    FilterEquals,
-							Value: "ns1",
-						},
-						AllocationFilterCondition{
-							Field: FilterNamespace,
-							Op:    FilterEquals,
-							Value: "ns2",
-						},
-					},
-				},
-			}},
-			expected: false,
-		},
-		// AND
-		// EMPTY
-		{
-			left:     AllocationFilterAnd{},
-			right:    nil,
-			expected: false,
-		},
-		{
-			left:     AllocationFilterAnd{Filters: []AllocationFilter{}},
-			right:    nil,
-			expected: false,
-		},
-
-		{
-			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,
-		},
-		{
-			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",
-				},
-				AllocationFilterOr{
-					Filters: []AllocationFilter{
-						AllocationFilterCondition{
-							Field: FilterNamespace,
-							Op:    FilterEquals,
-							Value: "ns1",
-						},
-						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)
-			}
-		})
-	}
-}

+ 3 - 1
pkg/kubecost/query.go

@@ -2,6 +2,8 @@ package kubecost
 
 import (
 	"time"
+
+	filter21 "github.com/opencost/opencost/pkg/filter21"
 )
 
 // Querier is an aggregate interface which has the ability to query each Kubecost store type
@@ -38,7 +40,7 @@ type AllocationQueryOptions struct {
 	AggregateBy             []string
 	Compute                 bool
 	DisableAggregatedStores bool
-	Filter                  AllocationFilter
+	Filter                  filter21.Filter
 	IdleByNode              bool
 	IncludeExternal         bool
 	IncludeIdle             bool

+ 23 - 24
pkg/kubecost/summaryallocation.go

@@ -7,6 +7,8 @@ import (
 	"sync"
 	"time"
 
+	"github.com/opencost/opencost/pkg/filter21/ast"
+	"github.com/opencost/opencost/pkg/filter21/matcher"
 	"github.com/opencost/opencost/pkg/log"
 	"github.com/opencost/opencost/pkg/util/timeutil"
 )
@@ -370,17 +372,15 @@ type SummaryAllocationSet struct {
 // required for unfortunate reasons to do with performance and legacy order-of-
 // operations details, as well as the fact that reconciliation has been
 // pushed down to the conversion step between Allocation and SummaryAllocation.
-func NewSummaryAllocationSet(as *AllocationSet, filter AllocationFilter, kfs []AllocationMatchFunc, reconcile, reconcileNetwork bool) *SummaryAllocationSet {
+//
+// This filter is an AllocationMatcher, not an AST, because at this point we
+// already have the data and want to make sure that the filter has already
+// gone through a compile step to deal with things like aliases.
+func NewSummaryAllocationSet(as *AllocationSet, filter AllocationMatcher, kfs []AllocationMatchFunc, reconcile, reconcileNetwork bool) *SummaryAllocationSet {
 	if as == nil {
 		return nil
 	}
 
-	// Pre-flatten the filter so we can just check == nil to see if there are
-	// filters.
-	if filter != nil {
-		filter = filter.Flattened()
-	}
-
 	// If we can know the exact size of the map, use it. If filters or sharing
 	// functions are present, we can't know the size, so we make a default map.
 	var sasMap map[string]*SummaryAllocation
@@ -542,10 +542,19 @@ func (sas *SummaryAllocationSet) AggregateBy(aggregateBy []string, options *Allo
 		options.LabelConfig = NewLabelConfig()
 	}
 
-	// Pre-flatten the filter so we can just check == nil to see if there are
-	// filters.
-	if options.Filter != nil {
-		options.Filter = options.Filter.Flattened()
+	var filter AllocationMatcher
+	if options.Filter == nil {
+		filter = &matcher.AllPass[*Allocation]{}
+	} else {
+		compiler := NewAllocationMatchCompiler()
+		var err error
+		filter, err = compiler.Compile(options.Filter)
+		if err != nil {
+			return fmt.Errorf("compiling filter '%s': %w", ast.ToPreOrderShortString(options.Filter), err)
+		}
+	}
+	if filter == nil {
+		return fmt.Errorf("unexpected nil filter")
 	}
 
 	// Check if we have any work to do; if not, then early return. If
@@ -1027,19 +1036,13 @@ func (sas *SummaryAllocationSet) AggregateBy(aggregateBy []string, options *Allo
 
 	// 12. Insert external allocations into the result set.
 	for _, sa := range externalSet.SummaryAllocations {
-		skip := false
-
 		// Make an allocation with the same properties and test that
 		// against the FilterFunc to see if the external allocation should
 		// be filtered or not.
 		// TODO:CLEANUP do something about external cost, this stinks
 		ea := &Allocation{Properties: sa.Properties}
 
-		if options.Filter != nil {
-			skip = !options.Filter.Matches(ea)
-		}
-
-		if !skip {
+		if filter.Matches(ea) {
 			key := sa.generateKey(aggregateBy, options.LabelConfig)
 
 			sa.Name = key
@@ -1051,17 +1054,13 @@ func (sas *SummaryAllocationSet) AggregateBy(aggregateBy []string, options *Allo
 	// per-resource idle cost for which there can be no idle coefficient
 	// computed because there is zero usage across all allocations.
 	for _, isa := range idleSet.SummaryAllocations {
-		// if the idle does not apply to the non-filtered values, skip it
-		skip := false
 		// Make an allocation with the same properties and test that
 		// against the FilterFunc to see if the external allocation should
 		// be filtered or not.
 		// TODO:CLEANUP do something about external cost, this stinks
 		ia := &Allocation{Properties: isa.Properties}
-		if options.Filter != nil {
-			skip = !options.Filter.Matches(ia)
-		}
-		if skip {
+		// if the idle does not apply to the non-filtered values, skip it
+		if !filter.Matches(ia) {
 			continue
 		}
 

+ 0 - 499
pkg/util/allocationfilterutil/queryfilters.go

@@ -1,499 +0,0 @@
-package allocationfilterutil
-
-import (
-	"fmt"
-	"reflect"
-	"strings"
-
-	"github.com/opencost/opencost/pkg/costmodel/clusters"
-	"github.com/opencost/opencost/pkg/kubecost"
-	"github.com/opencost/opencost/pkg/log"
-	"github.com/opencost/opencost/pkg/prom"
-	"github.com/opencost/opencost/pkg/util/mapper"
-)
-
-const (
-	ParamFilterClusters        = "filterClusters"
-	ParamFilterNodes           = "filterNodes"
-	ParamFilterNamespaces      = "filterNamespaces"
-	ParamFilterControllerKinds = "filterControllerKinds"
-	ParamFilterControllers     = "filterControllers"
-	ParamFilterPods            = "filterPods"
-	ParamFilterContainers      = "filterContainers"
-
-	ParamFilterDepartments  = "filterDepartments"
-	ParamFilterEnvironments = "filterEnvironments"
-	ParamFilterOwners       = "filterOwners"
-	ParamFilterProducts     = "filterProducts"
-	ParamFilterTeams        = "filterTeams"
-
-	ParamFilterAnnotations = "filterAnnotations"
-	ParamFilterLabels      = "filterLabels"
-	ParamFilterServices    = "filterServices"
-)
-
-var allocationFilterFieldMap = map[string]string{
-	kubecost.AllocationClusterProp:        ParamFilterClusters,
-	kubecost.FilterNode:                   ParamFilterNodes,
-	kubecost.AllocationNamespaceProp:      ParamFilterNamespaces,
-	kubecost.AllocationControllerKindProp: ParamFilterControllerKinds,
-	kubecost.AllocationControllerProp:     ParamFilterControllers,
-	kubecost.AllocationPodProp:            ParamFilterPods,
-	kubecost.AllocationContainerProp:      ParamFilterContainers,
-	kubecost.AllocationDepartmentProp:     ParamFilterDepartments,
-	kubecost.AllocationEnvironmentProp:    ParamFilterEnvironments,
-	kubecost.AllocationOwnerProp:          ParamFilterOwners,
-	kubecost.AllocationProductProp:        ParamFilterProducts,
-	kubecost.AllocationTeamProp:           ParamFilterTeams,
-	kubecost.AllocationAnnotationProp:     ParamFilterAnnotations,
-	kubecost.AllocationLabelProp:          ParamFilterLabels,
-	kubecost.AllocationServiceProp:        ParamFilterServices,
-}
-
-func GetAllocationFilterForTheAllocationProperty(allocationProp string) (string, error) {
-	if _, ok := allocationFilterFieldMap[allocationProp]; !ok {
-		return "", fmt.Errorf("unknown allocation property %s", allocationProp)
-	}
-	return allocationFilterFieldMap[allocationProp], nil
-}
-
-// AllHTTPParamKeys returns all HTTP GET parameters used for v1 filters. It is
-// intended to help validate HTTP queries in handlers to help avoid e.g.
-// spelling errors.
-func AllHTTPParamKeys() []string {
-	return []string{
-		ParamFilterClusters,
-		ParamFilterNodes,
-		ParamFilterNamespaces,
-		ParamFilterControllerKinds,
-		ParamFilterControllers,
-		ParamFilterPods,
-		ParamFilterContainers,
-
-		ParamFilterDepartments,
-		ParamFilterEnvironments,
-		ParamFilterOwners,
-		ParamFilterProducts,
-		ParamFilterTeams,
-
-		ParamFilterAnnotations,
-		ParamFilterLabels,
-		ParamFilterServices,
-	}
-}
-
-type FilterV1 struct {
-	Annotations     []string `json:"annotations,omitempty"`
-	Containers      []string `json:"containers,omitempty"`
-	Controllers     []string `json:"controllers,omitempty"`
-	ControllerKinds []string `json:"controllerKinds,omitempty"`
-	Clusters        []string `json:"clusters,omitempty"`
-	Departments     []string `json:"departments,omitempty"`
-	Environments    []string `json:"environments,omitempty"`
-	Labels          []string `json:"labels,omitempty"`
-	Namespaces      []string `json:"namespaces,omitempty"`
-	Nodes           []string `json:"nodes,omitempty"`
-	Owners          []string `json:"owners,omitempty"`
-	Pods            []string `json:"pods,omitempty"`
-	Products        []string `json:"products,omitempty"`
-	Services        []string `json:"services,omitempty"`
-	Teams           []string `json:"teams,omitempty"`
-}
-
-func (f FilterV1) Equals(that FilterV1) bool {
-	return reflect.DeepEqual(f.Annotations, that.Annotations) &&
-		reflect.DeepEqual(f.Containers, that.Containers) &&
-		reflect.DeepEqual(f.Controllers, that.Controllers) &&
-		reflect.DeepEqual(f.ControllerKinds, that.ControllerKinds) &&
-		reflect.DeepEqual(f.Clusters, that.Clusters) &&
-		reflect.DeepEqual(f.Departments, that.Departments) &&
-		reflect.DeepEqual(f.Environments, that.Environments) &&
-		reflect.DeepEqual(f.Labels, that.Labels) &&
-		reflect.DeepEqual(f.Namespaces, that.Namespaces) &&
-		reflect.DeepEqual(f.Nodes, that.Nodes) &&
-		reflect.DeepEqual(f.Owners, that.Owners) &&
-		reflect.DeepEqual(f.Pods, that.Pods) &&
-		reflect.DeepEqual(f.Products, that.Products) &&
-		reflect.DeepEqual(f.Services, that.Services) &&
-		reflect.DeepEqual(f.Teams, that.Teams)
-}
-
-// ============================================================================
-// This file contains:
-// Parsing (HTTP query params -> AllocationFilter) for V1 of filters
-//
-// e.g. "filterNamespaces=ku&filterControllers=deployment:kc"
-// ============================================================================
-
-// parseWildcardEnd checks if the given filter value is wildcarded, meaning
-// it ends in "*". If it does, it removes the suffix and returns the cleaned
-// string and true. Otherwise, it returns the same filter and false.
-//
-// parseWildcardEnd("kube*") = "kube", true
-// parseWildcardEnd("kube") = "kube", false
-func parseWildcardEnd(rawFilterValue string) (string, bool) {
-	return strings.TrimSuffix(rawFilterValue, "*"), strings.HasSuffix(rawFilterValue, "*")
-}
-
-// ParseAllocationFilterV1 takes a FilterV1 struct and
-// converts them to an AllocationFilter, which is a structured in-Go
-// representation of a set of filters.
-//
-// The HTTP query parameters are the "v1" filters attached to the Allocation
-// API: "filterNamespaces=", "filterNodes=", etc.
-//
-// It takes an optional LabelConfig, which if provided enables "label-mapped"
-// filters like "filterDepartments".
-//
-// It takes an optional ClusterMap, which if provided enables cluster name
-// filtering. This turns all `filterClusters=foo` arguments into the equivalent
-// of `clusterID = "foo" OR clusterName = "foo"`.
-func ParseAllocationFilterV1(filters FilterV1, labelConfig *kubecost.LabelConfig, clusterMap clusters.ClusterMap) kubecost.AllocationFilter {
-	filter := kubecost.AllocationFilterAnd{
-		Filters: []kubecost.AllocationFilter{},
-	}
-
-	// ClusterMap does not provide a cluster name -> cluster ID mapping in the
-	// interface, probably because there could be multiple IDs with the same
-	// name. However, V1 filter logic demands that the parameters to
-	// filterClusters= be checked against both cluster ID AND cluster name.
-	//
-	// To support expected filterClusters= behavior, we construct a mapping
-	// of cluster name -> cluster IDs (could be multiple IDs for the same name)
-	// so that we can create AllocationFilters that use only ClusterIDEquals.
-	//
-	//
-	// AllocationFilter intentionally does not support cluster name filters
-	// because those should be considered presentation-layer only.
-	clusterNameToIDs := map[string][]string{}
-	if clusterMap != nil {
-		cMap := clusterMap.AsMap()
-		for _, info := range cMap {
-			if info == nil {
-				continue
-			}
-
-			if _, ok := clusterNameToIDs[info.Name]; ok {
-				clusterNameToIDs[info.Name] = append(clusterNameToIDs[info.Name], info.ID)
-			} else {
-				clusterNameToIDs[info.Name] = []string{info.ID}
-			}
-		}
-	}
-
-	// The proliferation of > 0 guards in the function is to avoid constructing
-	// empty filter structs. While it is functionally equivalent to add empty
-	// filter structs (they evaluate to true always) there could be overhead
-	// when calling Matches() repeatedly for no purpose.
-
-	if len(filters.Clusters) > 0 {
-		clustersOr := kubecost.AllocationFilterOr{
-			Filters: []kubecost.AllocationFilter{},
-		}
-
-		if idFilters := filterV1SingleValueFromList(filters.Clusters, kubecost.FilterClusterID); len(idFilters.Filters) > 0 {
-			clustersOr.Filters = append(clustersOr.Filters, idFilters)
-		}
-		for _, rawFilterValue := range filters.Clusters {
-			clusterNameFilter, wildcard := parseWildcardEnd(rawFilterValue)
-
-			clusterIDsToFilter := []string{}
-			for clusterName := range clusterNameToIDs {
-				if wildcard && strings.HasPrefix(clusterName, clusterNameFilter) {
-					clusterIDsToFilter = append(clusterIDsToFilter, clusterNameToIDs[clusterName]...)
-				} else if !wildcard && clusterName == clusterNameFilter {
-					clusterIDsToFilter = append(clusterIDsToFilter, clusterNameToIDs[clusterName]...)
-				}
-			}
-
-			for _, clusterID := range clusterIDsToFilter {
-				clustersOr.Filters = append(clustersOr.Filters,
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterClusterID,
-						Op:    kubecost.FilterEquals,
-						Value: clusterID,
-					},
-				)
-			}
-		}
-		filter.Filters = append(filter.Filters, clustersOr)
-	}
-
-	if len(filters.Nodes) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(filters.Nodes, kubecost.FilterNode))
-	}
-
-	if len(filters.Namespaces) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(filters.Namespaces, kubecost.FilterNamespace))
-	}
-
-	if len(filters.ControllerKinds) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(filters.ControllerKinds, kubecost.FilterControllerKind))
-	}
-
-	// filterControllers= accepts controllerkind:controllername filters, e.g.
-	// "deployment:kubecost-cost-analyzer"
-	//
-	// Thus, we have to make a custom OR filter for this condition.
-	if len(filters.Controllers) > 0 {
-		controllersOr := kubecost.AllocationFilterOr{
-			Filters: []kubecost.AllocationFilter{},
-		}
-		for _, rawFilterValue := range filters.Controllers {
-			split := strings.Split(rawFilterValue, ":")
-			if len(split) == 1 {
-				filterValue, wildcard := parseWildcardEnd(split[0])
-				subFilter := kubecost.AllocationFilterCondition{
-					Field: kubecost.FilterControllerName,
-					Op:    kubecost.FilterEquals,
-					Value: filterValue,
-				}
-
-				if wildcard {
-					subFilter.Op = kubecost.FilterStartsWith
-				}
-				controllersOr.Filters = append(controllersOr.Filters, subFilter)
-			} else if len(split) == 2 {
-				kindFilterVal := split[0]
-				nameFilterVal, wildcard := parseWildcardEnd(split[1])
-
-				kindFilter := kubecost.AllocationFilterCondition{
-					Field: kubecost.FilterControllerKind,
-					Op:    kubecost.FilterEquals,
-					Value: kindFilterVal,
-				}
-				nameFilter := kubecost.AllocationFilterCondition{
-					Field: kubecost.FilterControllerName,
-					Op:    kubecost.FilterEquals,
-					Value: nameFilterVal,
-				}
-
-				if wildcard {
-					nameFilter.Op = kubecost.FilterStartsWith
-				}
-
-				// The controller name AND the controller kind must match
-				multiFilter := kubecost.AllocationFilterAnd{
-					Filters: []kubecost.AllocationFilter{kindFilter, nameFilter},
-				}
-				controllersOr.Filters = append(controllersOr.Filters, multiFilter)
-			} else {
-				log.Warnf("illegal filter for controller: %s", rawFilterValue)
-			}
-		}
-		if len(controllersOr.Filters) > 0 {
-			filter.Filters = append(filter.Filters, controllersOr)
-		}
-	}
-
-	if len(filters.Pods) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(filters.Pods, kubecost.FilterPod))
-	}
-
-	if len(filters.Containers) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(filters.Containers, kubecost.FilterContainer))
-	}
-
-	// Label-mapped queries require a label config to be present.
-	if labelConfig != nil {
-		if len(filters.Departments) > 0 {
-			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(filters.Departments, labelConfig.DepartmentLabel))
-		}
-		if len(filters.Environments) > 0 {
-			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(filters.Environments, labelConfig.EnvironmentLabel))
-		}
-		if len(filters.Owners) > 0 {
-			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(filters.Owners, labelConfig.OwnerLabel))
-		}
-		if len(filters.Products) > 0 {
-			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(filters.Products, labelConfig.ProductLabel))
-		}
-		if len(filters.Teams) > 0 {
-			filter.Filters = append(filter.Filters, filterV1LabelAliasMappedFromList(filters.Teams, labelConfig.TeamLabel))
-		}
-	} else {
-		log.Debugf("No label config is available. Not creating filters for label-mapped 'fields'.")
-	}
-
-	if len(filters.Annotations) > 0 {
-		filter.Filters = append(filter.Filters, filterV1DoubleValueFromList(filters.Annotations, kubecost.FilterAnnotation))
-	}
-
-	if len(filters.Labels) > 0 {
-		filter.Filters = append(filter.Filters, filterV1DoubleValueFromList(filters.Labels, kubecost.FilterLabel))
-	}
-
-	if len(filters.Services) > 0 {
-		// filterServices= is the only filter that uses the "contains" operator.
-		servicesFilter := kubecost.AllocationFilterOr{
-			Filters: []kubecost.AllocationFilter{},
-		}
-		for _, filterValue := range filters.Services {
-			// TODO: wildcard support
-			filterValue, wildcard := parseWildcardEnd(filterValue)
-			subFilter := kubecost.AllocationFilterCondition{
-				Field: kubecost.FilterServices,
-				Op:    kubecost.FilterContains,
-				Value: filterValue,
-			}
-			if wildcard {
-				subFilter.Op = kubecost.FilterContainsPrefix
-			}
-			servicesFilter.Filters = append(servicesFilter.Filters, subFilter)
-		}
-		filter.Filters = append(filter.Filters, servicesFilter)
-	}
-
-	return filter
-}
-
-// AllocationFilterFromParamsV1 takes a set of HTTP query parameters and
-// converts them to an AllocationFilter, which is a structured in-Go
-// representation of a set of filters.
-//
-// The HTTP query parameters are the "v1" filters attached to the Allocation
-// API: "filterNamespaces=", "filterNodes=", etc.
-//
-// It takes an optional LabelConfig, which if provided enables "label-mapped"
-// filters like "filterDepartments".
-//
-// It takes an optional ClusterMap, which if provided enables cluster name
-// filtering. This turns all `filterClusters=foo` arguments into the equivalent
-// of `clusterID = "foo" OR clusterName = "foo"`.
-func AllocationFilterFromParamsV1(
-	qp mapper.PrimitiveMapReader,
-	labelConfig *kubecost.LabelConfig,
-	clusterMap clusters.ClusterMap,
-) kubecost.AllocationFilter {
-	filter := ConvertFilterQueryParams(qp, labelConfig)
-	return ParseAllocationFilterV1(filter, labelConfig, clusterMap)
-}
-
-// filterV1SingleValueFromList creates an OR of equality filters for a given
-// filter field.
-//
-// The v1 query language (e.g. "filterNamespaces=XYZ,ABC") uses OR within
-// a field (e.g. namespace = XYZ OR namespace = ABC)
-func filterV1SingleValueFromList(rawFilterValues []string, filterField kubecost.FilterField) kubecost.AllocationFilterOr {
-	filter := kubecost.AllocationFilterOr{
-		Filters: []kubecost.AllocationFilter{},
-	}
-
-	for _, filterValue := range rawFilterValues {
-		filterValue = strings.TrimSpace(filterValue)
-		filterValue, wildcard := parseWildcardEnd(filterValue)
-
-		subFilter := kubecost.AllocationFilterCondition{
-			Field: filterField,
-			// All v1 filters are equality comparisons
-			Op:    kubecost.FilterEquals,
-			Value: filterValue,
-		}
-
-		if wildcard {
-			subFilter.Op = kubecost.FilterStartsWith
-		}
-
-		filter.Filters = append(filter.Filters, subFilter)
-	}
-
-	return filter
-}
-
-func ConvertFilterQueryParams(qp mapper.PrimitiveMapReader, labelConfig *kubecost.LabelConfig) FilterV1 {
-	filter := FilterV1{
-		Annotations:     qp.GetList(ParamFilterAnnotations, ","),
-		Containers:      qp.GetList(ParamFilterContainers, ","),
-		Controllers:     qp.GetList(ParamFilterControllers, ","),
-		ControllerKinds: qp.GetList(ParamFilterControllerKinds, ","),
-		Clusters:        qp.GetList(ParamFilterClusters, ","),
-		Labels:          qp.GetList(ParamFilterLabels, ","),
-		Namespaces:      qp.GetList(ParamFilterNamespaces, ","),
-		Nodes:           qp.GetList(ParamFilterNodes, ","),
-		Pods:            qp.GetList(ParamFilterPods, ","),
-		Services:        qp.GetList(ParamFilterServices, ","),
-	}
-
-	if labelConfig != nil {
-		filter.Departments = qp.GetList(ParamFilterDepartments, ",")
-		filter.Environments = qp.GetList(ParamFilterEnvironments, ",")
-		filter.Owners = qp.GetList(ParamFilterOwners, ",")
-		filter.Products = qp.GetList(ParamFilterProducts, ",")
-		filter.Teams = qp.GetList(ParamFilterTeams, ",")
-	} else {
-		log.Debugf("No label config is available. Not creating filters for label-mapped 'fields'.")
-	}
-
-	return filter
-}
-
-// filterV1LabelAliasMappedFromList is like filterV1SingleValueFromList but is
-// explicitly for labels and annotations because "label-mapped" filters (like filterTeams=)
-// are actually label filters with a fixed label key.
-func filterV1LabelAliasMappedFromList(rawFilterValues []string, labelName string) kubecost.AllocationFilterOr {
-	filter := kubecost.AllocationFilterOr{
-		Filters: []kubecost.AllocationFilter{},
-	}
-	labelName = prom.SanitizeLabelName(labelName)
-
-	for _, filterValue := range rawFilterValues {
-		filterValue = strings.TrimSpace(filterValue)
-		filterValue, wildcard := parseWildcardEnd(filterValue)
-
-		subFilter := kubecost.AllocationFilterCondition{
-			Field: kubecost.FilterAlias,
-			// All v1 filters are equality comparisons
-			Op:    kubecost.FilterEquals,
-			Key:   labelName,
-			Value: filterValue,
-		}
-
-		if wildcard {
-			subFilter.Op = kubecost.FilterStartsWith
-		}
-
-		filter.Filters = append(filter.Filters, subFilter)
-	}
-
-	return filter
-}
-
-// filterV1DoubleValueFromList creates an OR of key:value equality filters for
-// colon-split filter values.
-//
-// The v1 query language (e.g. "filterLabels=app:foo,l2:bar") uses OR within
-// a field (e.g. label[app] = foo OR label[l2] = bar)
-func filterV1DoubleValueFromList(rawFilterValuesUnsplit []string, filterField kubecost.FilterField) kubecost.AllocationFilterOr {
-	filter := kubecost.AllocationFilterOr{
-		Filters: []kubecost.AllocationFilter{},
-	}
-
-	for _, unsplit := range rawFilterValuesUnsplit {
-		if unsplit != "" {
-			split := strings.Split(unsplit, ":")
-			if len(split) != 2 {
-				log.Warnf("illegal key/value filter (ignoring): %s", unsplit)
-				continue
-			}
-			labelName := prom.SanitizeLabelName(strings.TrimSpace(split[0]))
-			val := strings.TrimSpace(split[1])
-			val, wildcard := parseWildcardEnd(val)
-
-			subFilter := kubecost.AllocationFilterCondition{
-				Field: filterField,
-				// All v1 filters are equality comparisons
-				Op:    kubecost.FilterEquals,
-				Key:   labelName,
-				Value: val,
-			}
-
-			if wildcard {
-				subFilter.Op = kubecost.FilterStartsWith
-			}
-
-			filter.Filters = append(filter.Filters, subFilter)
-		}
-	}
-
-	return filter
-}

+ 0 - 276
pkg/util/allocationfilterutil/v2/lexer.go

@@ -1,276 +0,0 @@
-package allocationfilterutil
-
-import (
-	"fmt"
-
-	multierror "github.com/hashicorp/go-multierror"
-
-	"github.com/opencost/opencost/pkg/kubecost"
-)
-
-// ============================================================================
-// This file contains:
-// Lexing (string -> []token) for V2 of allocation filters
-// ============================================================================
-//
-// See parser.go for a formal grammar and external links.
-
-type tokenKind int
-
-const (
-	colon tokenKind = iota // ':'
-	comma                  // ','
-	plus                   // '+'
-
-	bangColon // '!:'
-
-	str // '"foo"'
-
-	filterField1 // 'namespace', 'cluster'
-	filterField2 // 'label', 'annotation'
-	keyedAccess  // '[app]', '[foo]', etc.
-	identifier   // K8s valid name + sanitized Prom: 'app', 'abc_label'
-
-	eof
-)
-
-// These maps serve a dual purpose. (1) to help the lexer identify special
-// strings that should become filterField1/2 instead of identifiers and (2) to
-// help the parser convert tokens into AllocationFilterConditions.
-var ff1ToKCFilterField = map[string]kubecost.FilterField{
-	"cluster":        kubecost.FilterClusterID,
-	"node":           kubecost.FilterNode,
-	"namespace":      kubecost.FilterNamespace,
-	"controllerName": kubecost.FilterControllerName,
-	"controllerKind": kubecost.FilterControllerKind,
-	"container":      kubecost.FilterContainer,
-	"pod":            kubecost.FilterPod,
-	"services":       kubecost.FilterServices,
-}
-var ff2ToKCFilterField = map[string]kubecost.FilterField{
-	"label":      kubecost.FilterLabel,
-	"annotation": kubecost.FilterAnnotation,
-}
-
-func (tk tokenKind) String() string {
-	switch tk {
-	case colon:
-		return "colon"
-	case comma:
-		return "comma"
-	case plus:
-		return "plus"
-	case bangColon:
-		return "bangColon"
-	case str:
-		return "str"
-	case filterField1:
-		return "filterField1"
-	case filterField2:
-		return "filterField2"
-	case keyedAccess:
-		return "keyedAccess"
-	case identifier:
-		return "identifier"
-	case eof:
-		return "eof"
-	default:
-		return fmt.Sprintf("Unspecified: %d", tk)
-	}
-}
-
-// ============================================================================
-// Lexer/Scanner
-//
-// Based on the Scanner class in Chapter 4: Scanning of Crafting Interpreters by
-// Robert Nystrom
-// ============================================================================
-
-type token struct {
-	kind tokenKind
-	s    string
-}
-
-func (t token) String() string {
-	return fmt.Sprintf("%s:%s", t.kind, t.s)
-}
-
-type scanner struct {
-	source string
-	tokens []token
-	errors []error
-
-	lexemeStartByte int
-	nextByte        int
-}
-
-func (s *scanner) scanTokens() {
-	for !s.atEnd() {
-		s.lexemeStartByte = s.nextByte
-		s.scanToken()
-	}
-
-	s.tokens = append(s.tokens, token{kind: eof})
-}
-
-func (s scanner) atEnd() bool {
-	return s.nextByte >= len(s.source)
-}
-
-// advance returns a byte because we only accept ASCII, which has to fit in a
-// byte
-//
-// TODO: If we add unicode support, advance() will probably have to return a
-// rune.
-func (s *scanner) advance() byte {
-	b := s.source[s.nextByte]
-	s.nextByte += 1
-	return b
-}
-
-func (s *scanner) match(expected byte) bool {
-	if s.atEnd() {
-		return false
-	}
-	if s.source[s.nextByte] != expected {
-		return false
-	}
-	s.nextByte += 1
-	return true
-}
-
-func (s *scanner) addToken(kind tokenKind) {
-	lexemeString := s.source[s.lexemeStartByte:s.nextByte]
-	switch kind {
-	// Eliminate surrounding characters like " and []
-	case str, keyedAccess:
-		lexemeString = lexemeString[1 : len(lexemeString)-1]
-	}
-
-	s.tokens = append(s.tokens, token{
-		kind: kind,
-		s:    lexemeString,
-	})
-}
-
-func (s *scanner) peek() byte {
-	if s.atEnd() {
-		return 0
-	}
-	return s.source[s.nextByte]
-}
-
-func (s *scanner) scanToken() {
-	c := s.advance()
-	switch c {
-	case ':':
-		s.addToken(colon)
-	case ',':
-		s.addToken(comma)
-	case '+':
-		s.addToken(plus)
-	case '!':
-		if s.match(':') {
-			s.addToken(bangColon)
-		} else {
-			s.errors = append(s.errors, fmt.Errorf("Position %d: Unexpected '!'", s.nextByte-1))
-		}
-	// strings
-	case '"':
-		s.string()
-	// keyed access
-	case '[':
-		s.keyedAccess()
-	// Ignore whitespace chars outside of "" and [].
-	case ' ', '\t', '\n', '\r':
-		break
-	default:
-		// identifiers
-		//
-		// We can keep it simple and not _force_ the first character to be a
-		// non-number because we don't need numbers in this language. If we need
-		// to extend the language to support numbers, this has to become just
-		// isAlpha() and then s.identifier() will use isIdentifierChar() in
-		// its main loop.
-		if isIdentifierChar(c) {
-			s.identifier()
-			break
-		}
-
-		// TODO: We could return a more exact error message for Unicode chars if
-		// we added extra handling:
-		// https://stackoverflow.com/questions/53069040/checking-a-string-contains-only-ascii-characters
-		s.errors = append(s.errors, fmt.Errorf("unexpected character/byte at position %d. Please avoid Unicode.", s.nextByte-1))
-	}
-}
-
-// isIdentifierChar should match Kubernetes-supported name characters.
-//
-// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/
-//
-// TODO: This may not match all characters we support for cluster IDs (it may be
-// the case that cluster IDs can contain UTF-8 characters).
-func isIdentifierChar(b byte) bool {
-	return (b >= '0' && b <= '9') || // 0-9
-		(b >= 'A' && b <= 'Z') || // A-Z
-		(b >= 'a' && b <= 'z') || // a-z
-		b == '-' || // hyphens are allowed according to K8s spec
-		b == '_' // underscores are allowed because of Prometheus sanitization
-}
-
-func (s *scanner) string() {
-	for s.peek() != '"' && !s.atEnd() {
-		s.advance()
-	}
-
-	if s.atEnd() {
-		s.errors = append(s.errors, fmt.Errorf("unterminated string starting at %d", s.lexemeStartByte))
-		return
-	}
-
-	// Consume closing '"'
-	s.advance()
-
-	s.addToken(str)
-}
-
-func (s *scanner) keyedAccess() {
-	for s.peek() != ']' && !s.atEnd() {
-		s.advance()
-	}
-
-	if s.atEnd() {
-		s.errors = append(s.errors, fmt.Errorf("unterminated access starting at %d", s.lexemeStartByte))
-		return
-	}
-
-	// Consume closing ']'
-	s.advance()
-	s.addToken(keyedAccess)
-}
-
-func (s *scanner) identifier() {
-	for isIdentifierChar(s.peek()) {
-		s.advance()
-	}
-
-	tokenText := s.source[s.lexemeStartByte:s.nextByte]
-	if _, ok := ff1ToKCFilterField[tokenText]; ok {
-		s.addToken(filterField1)
-	} else if _, ok := ff2ToKCFilterField[tokenText]; ok {
-		s.addToken(filterField2)
-	} else {
-		s.addToken(identifier)
-	}
-}
-
-func lexAllocationFilterV2(raw string) ([]token, error) {
-	s := scanner{source: raw}
-	s.scanTokens()
-
-	if len(s.errors) > 0 {
-		return s.tokens, multierror.Append(nil, s.errors...)
-	}
-
-	return s.tokens, nil
-}

+ 0 - 115
pkg/util/allocationfilterutil/v2/lexer_test.go

@@ -1,115 +0,0 @@
-package allocationfilterutil
-
-import (
-	"testing"
-)
-
-func TestLexer(t *testing.T) {
-	cases := []struct {
-		name string
-
-		input       string
-		expectError bool
-		expected    []token
-	}{
-		{
-			name:     "Empty string",
-			input:    "",
-			expected: []token{{kind: eof}},
-		},
-		{
-			name:     "colon",
-			input:    ":",
-			expected: []token{{kind: colon, s: ":"}, {kind: eof}},
-		},
-		{
-			name:     "comma",
-			input:    ",",
-			expected: []token{{kind: comma, s: ","}, {kind: eof}},
-		},
-		{
-			name:     "plus",
-			input:    "+",
-			expected: []token{{kind: plus, s: "+"}, {kind: eof}},
-		},
-		{
-			name:     "bangColon",
-			input:    "!:",
-			expected: []token{{kind: bangColon, s: "!:"}, {kind: eof}},
-		},
-		{
-			name: "multiple symbols",
-			// This is a valid string to lex but not to parse.
-			input:    "!::,+",
-			expected: []token{{kind: bangColon, s: "!:"}, {kind: colon, s: ":"}, {kind: comma, s: ","}, {kind: plus, s: "+"}, {kind: eof}},
-		},
-		{
-			name:     "string",
-			input:    `"test"`,
-			expected: []token{{kind: str, s: `test`}, {kind: eof}},
-		},
-		{
-			name:     "keyed access",
-			input:    "[app]",
-			expected: []token{{kind: keyedAccess, s: "app"}, {kind: eof}},
-		},
-		{
-			name:     "identifier pure alpha",
-			input:    "abc",
-			expected: []token{{kind: identifier, s: "abc"}, {kind: eof}},
-		},
-		{
-			name:     "label access",
-			input:    "app[kubecost]",
-			expected: []token{{kind: identifier, s: "app"}, {kind: keyedAccess, s: "kubecost"}, {kind: eof}},
-		},
-		{
-			name:  "whitespace variety",
-			input: "1 2" + string('\n') + `" ` + string('\n') + string('\t') + string('\r') + `a"` + string('\t') + string('\r') + "abc[foo a]" + " ",
-			expected: []token{
-				{kind: identifier, s: "1"},
-				{kind: identifier, s: "2"},
-				{kind: str, s: " " + string('\n') + string('\t') + string('\r') + "a"},
-				{kind: identifier, s: "abc"},
-				{kind: keyedAccess, s: "foo a"},
-				{kind: eof},
-			},
-		},
-		{
-			name:  "whitespace separated accesses",
-			input: `node : "abc" , "def" ` + string('\r') + string('\n') + string('\t') + `namespace : "123"`,
-			expected: []token{
-				{kind: filterField1, s: "node"},
-				{kind: colon, s: ":"},
-				{kind: str, s: "abc"},
-				{kind: comma, s: ","},
-				{kind: str, s: "def"},
-				{kind: filterField1, s: "namespace"},
-				{kind: colon, s: ":"},
-				{kind: str, s: "123"},
-				{kind: eof},
-			},
-		},
-	}
-
-	for _, c := range cases {
-		t.Run(c.name, func(t *testing.T) {
-			t.Logf("Input: '%s'", c.input)
-			result, err := lexAllocationFilterV2(c.input)
-			if c.expectError && err == nil {
-				t.Errorf("expected error but got nil")
-			} else if !c.expectError && err != nil {
-				t.Errorf("unexpected error: %s", err)
-			} else {
-				if len(c.expected) != len(result) {
-					t.Fatalf("Token slices don't match in length.\nExpected: %+v\nGot: %+v", c.expected, result)
-				}
-				for i := range c.expected {
-					if c.expected[i] != result[i] {
-						t.Fatalf("Incorrect token at position %d.\nExpected: %+v\nGot: %+v", i, c.expected, result)
-					}
-				}
-			}
-		})
-	}
-}

+ 0 - 340
pkg/util/allocationfilterutil/v2/parser.go

@@ -1,340 +0,0 @@
-// allocationfilterutil provides functionality for parsing V2 of the Kubecost
-// filter language for Allocation types.
-//
-// e.g. "filter=namespace:kubecost+controllerkind:deployment"
-package allocationfilterutil
-
-import (
-	"fmt"
-
-	"github.com/hashicorp/go-multierror"
-	"github.com/opencost/opencost/pkg/kubecost"
-)
-
-// ParseAllocationFilter converts a string of the V2 Allocation Filter language
-// into a kubecost.AllocationFilter.
-//
-// Example queries:
-//
-//	namespace:"kubecost"
-//	label[app]:"cost-analyzer"
-//	node!:"node1","node2"
-//	cluster:"cluster-one"+namespace!:"kube-system"
-//
-// The grammar is approximately as follows:
-//
-// Original design doc [1] contains first grammar. This is a slight modification
-// of that grammar to help guide the implementation of the parser.
-//
-// [1] https://docs.google.com/document/d/1HKkp2bv3mnvfQoBZlpHjfZwQ0FzDLOHKpnwV9gQ_KgU/edit?pli=1
-//
-// <filter> ::= <comparison> ('+' <comparison>)*
-//
-//	NOTE: Language can be extended to support ORs between
-//	comparisons by adding a '|' operator in between comparisons,
-//	though precedence will have to be carefully defined and it may
-//	require adding support for ()-enclosed statements to deal with
-//	precedence.
-//	This would allow for queries like:
-//	  namespace:"x"|label[app]="foo"
-//
-// <comparison> ::= <filter-key> <filter-op> <filter-value>
-//
-// <filter-key> ::= <filter-field-2> <keyed-access>
-//
-//	| <filter-field-1>
-//
-// <filter-op> ::= ':' | '!:'
-//
-// <filter-value> ::= '"' <identifier> '"' (',' <filter-value>)*
-//
-// <filter-field-2> ::= 'label' | 'annotation'
-//
-// <filter-field-1> ::= 'cluster' | 'node' | 'namespace'
-//
-//	| 'controllerName' | 'controllerKind'
-//	| 'container' | 'pod' | 'services'
-//
-// <keyed-access> ::= '[' <identifier> ']'
-//
-// <identifier> ::= --- valid K8s name or Prom-sanitized K8s name
-func ParseAllocationFilter(filter string) (kubecost.AllocationFilter, error) {
-	tokens, err := lexAllocationFilterV2(filter)
-	if err != nil {
-		return nil, fmt.Errorf("lexing filter: %s", err)
-	}
-
-	p := parser{tokens: tokens}
-
-	parsedFilter, err := p.filter()
-	if err != nil {
-		return nil, fmt.Errorf("parsing filter: %s", err)
-	}
-
-	return parsedFilter, nil
-}
-
-// ============================================================================
-// Parser
-//
-// Based on the Parser class in Chapter 6: Parsing Expressions of Crafting
-// Interpreters by Robert Nystrom
-// ============================================================================
-
-// parseError produces error messages tailored to the needs of the parser
-func parseError(t token, message string) error {
-	if t.kind == eof {
-		return fmt.Errorf("at end: %s", message)
-	}
-
-	return fmt.Errorf("at '%s': %s", t.s, message)
-}
-
-type parser struct {
-	tokens  []token
-	current int
-}
-
-// ----------------------------------------------------------------------------
-// Parser helper methods for token handling
-// ----------------------------------------------------------------------------
-
-func (p *parser) atEnd() bool {
-	return p.peek().kind == eof
-}
-
-func (p *parser) advance() token {
-	if !p.atEnd() {
-		p.current += 1
-	}
-
-	return p.previous()
-}
-
-func (p *parser) previous() token {
-	return p.tokens[p.current-1]
-}
-
-// match return true and advances the parser by one token if the next token has
-// a kind that matches one of the arguments. Otherwise, it returns false and
-// DOES NOT advance the parser.
-func (p *parser) match(tokenKinds ...tokenKind) bool {
-	for _, kind := range tokenKinds {
-		if p.check(kind) {
-			p.advance()
-			return true
-		}
-	}
-	return false
-}
-
-// check returns true iff the next token matches the provided kind.
-func (p *parser) check(tk tokenKind) bool {
-	if p.atEnd() {
-		return false
-	}
-	return p.peek().kind == tk
-}
-
-func (p *parser) peek() token {
-	return p.tokens[p.current]
-}
-
-// consume is a "next token must be this kind" method. If the next token is of
-// the correct kind, the parser is advanced and that token is returned. If it
-// is not of the correct kind, a parse error is returned and the parser is NOT
-// advanced.
-func (p *parser) consume(tk tokenKind, message string) (token, error) {
-	if p.check(tk) {
-		return p.advance(), nil
-	}
-
-	return token{}, parseError(p.peek(), message)
-}
-
-// synchronize attempts to skip forward until the next '+', indicating the
-// start of a new <comparison>. This lets us do best-effort reporting of
-// multiple parse errors.
-func (p *parser) synchronize() {
-	p.advance()
-	for !p.atEnd() {
-		if p.previous().kind == plus {
-			return
-		}
-
-		p.advance()
-	}
-}
-
-// ----------------------------------------------------------------------------
-// Parser grammar rules as recursive descent methods
-// ----------------------------------------------------------------------------
-
-// filter is the main method of the parser. It turns the token stream into an
-// AllocationFilter, reporting parse errors that occurred along the way.
-func (p *parser) filter() (kubecost.AllocationFilter, error) {
-	var errs *multierror.Error
-
-	// Currently, a filter is only a sequence of AND operations
-	f := kubecost.AllocationFilterAnd{}
-	comparison, err := p.comparison()
-	if err != nil {
-		errs = multierror.Append(errs, err)
-		p.synchronize()
-	} else {
-		f.Filters = append(f.Filters, comparison)
-	}
-	for p.match(plus) {
-		right, err := p.comparison()
-		if err != nil {
-			errs = multierror.Append(errs, err)
-			p.synchronize()
-		} else {
-			f.Filters = append(f.Filters, right)
-		}
-	}
-
-	return f, errs.ErrorOrNil()
-}
-
-func (p *parser) comparison() (kubecost.AllocationFilter, error) {
-	field, key, err := p.filterKey()
-	if err != nil {
-		return nil, err
-	}
-
-	opToken, err := p.filterOp()
-	if err != nil {
-		return nil, err
-	}
-
-	var op kubecost.FilterOp
-
-	switch field {
-	case "services":
-		switch opToken.kind {
-		case colon:
-			op = kubecost.FilterContains
-		case bangColon:
-			op = kubecost.FilterNotContains
-		default:
-			return nil, parseError(opToken, "implementation problem: unhandled op token for services filter")
-		}
-	default:
-		switch opToken.kind {
-		case colon:
-			op = kubecost.FilterEquals
-		case bangColon:
-			op = kubecost.FilterNotEquals
-		default:
-			return nil, parseError(opToken, "implementation problem: unhandled op token")
-		}
-
-	}
-
-	values, err := p.filterValues()
-	if err != nil {
-		return nil, err
-	}
-
-	switch opToken.kind {
-	// In the != case, a sequence of filter values is ANDed
-	// Example:
-	// namespace!:"foo","bar" -> (and (notequals namespace foo)
-	//                                (notequals namespace bar))
-	case bangColon:
-		baseFilter := kubecost.AllocationFilterAnd{}
-
-		for _, v := range values {
-			baseFilter.Filters = append(baseFilter.Filters, kubecost.AllocationFilterCondition{
-				Field: field,
-				Key:   key,
-				Op:    op,
-				Value: v,
-			})
-		}
-
-		return baseFilter, nil
-	default:
-		baseFilter := kubecost.AllocationFilterOr{}
-
-		for _, v := range values {
-			baseFilter.Filters = append(baseFilter.Filters, kubecost.AllocationFilterCondition{
-				Field: field,
-				Key:   key,
-				Op:    op,
-				Value: v,
-			})
-		}
-
-		return baseFilter, nil
-	}
-
-}
-
-// filterKey parses a series of tokens that represent a "filter key", returning
-// an error if a filter key cannot be constructed.
-//
-// Examples:
-// tokens = [filterField2:label keyedAccess:app] -> FilterLabel, app, nil
-// tokens = [filterField1:namespace] -> FilterNamespace, "", nil
-func (p *parser) filterKey() (field kubecost.FilterField, key string, err error) {
-
-	if p.match(filterField2) {
-		rawField := p.previous().s
-		mappedField, ok := ff2ToKCFilterField[rawField]
-		if !ok {
-			return "", "", parseError(p.previous(), "expect key-mapped filter field, like 'label' or 'annotation'")
-		}
-
-		_, err := p.consume(keyedAccess, "expect keyed access like '[app]' after a mapped field")
-		if err != nil {
-			return "", "", err
-		}
-
-		key = p.previous().s
-		return mappedField, key, nil
-	}
-
-	_, err = p.consume(filterField1, "expect filter field")
-	if err != nil {
-		return "", "", err
-	}
-
-	rawField := p.previous().s
-	mappedField, ok := ff1ToKCFilterField[rawField]
-	if !ok {
-		return "", "", parseError(p.previous(), "expect known filter field, like 'cluster' or 'namespace'")
-	}
-
-	return mappedField, "", nil
-}
-
-func (p *parser) filterOp() (token, error) {
-	if p.match(bangColon, colon) {
-		return p.previous(), nil
-	}
-
-	return token{}, parseError(p.peek(), "expect filter op like ':' or '!:'")
-}
-
-func (p *parser) filterValues() ([]string, error) {
-	vals := []string{}
-
-	_, err := p.consume(str, "expect string as filter value")
-	if err != nil {
-		return nil, err
-	}
-	vals = append(vals, p.previous().s)
-
-	for p.match(comma) {
-		_, err := p.consume(str, "expect string as filter value")
-		if err != nil {
-			return nil, err
-		}
-
-		vals = append(vals, p.previous().s)
-	}
-
-	return vals, nil
-}

+ 0 - 545
pkg/util/allocationfilterutil/v2/parser_test.go

@@ -1,545 +0,0 @@
-package allocationfilterutil
-
-import (
-	"fmt"
-	"reflect"
-	"testing"
-
-	"github.com/opencost/opencost/pkg/kubecost"
-)
-
-func allocGenerator(props kubecost.AllocationProperties) kubecost.Allocation {
-	a := kubecost.Allocation{
-		Properties: &props,
-	}
-
-	a.Name = a.Properties.String()
-	return a
-}
-
-func TestParse(t *testing.T) {
-	cases := []struct {
-		input          string
-		expected       kubecost.AllocationFilter
-		shouldMatch    []kubecost.Allocation
-		shouldNotMatch []kubecost.Allocation
-	}{
-		{
-			input: `namespace:"kubecost"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterNamespace,
-						Op:    kubecost.FilterEquals,
-						Value: "kubecost",
-					},
-				}},
-			}},
-			shouldMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Namespace: "kubecost"}),
-			},
-			shouldNotMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Namespace: "kube-system"}),
-			},
-		},
-		{
-			input: `cluster:"cluster-one"+namespace:"kubecost"+controllerKind:"daemonset"+controllerName:"kubecost-network-costs"+container:"kubecost-network-costs"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterClusterID,
-						Op:    kubecost.FilterEquals,
-						Value: "cluster-one",
-					},
-				}},
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterNamespace,
-						Op:    kubecost.FilterEquals,
-						Value: "kubecost",
-					},
-				}},
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterControllerKind,
-						Op:    kubecost.FilterEquals,
-						Value: "daemonset",
-					},
-				}},
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterControllerName,
-						Op:    kubecost.FilterEquals,
-						Value: "kubecost-network-costs",
-					},
-				}},
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterContainer,
-						Op:    kubecost.FilterEquals,
-						Value: "kubecost-network-costs",
-					},
-				}},
-			}},
-			shouldMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{
-					Cluster:        "cluster-one",
-					Namespace:      "kubecost",
-					ControllerKind: "daemonset",
-					Controller:     "kubecost-network-costs",
-					Pod:            "kubecost-network-costs-abc123",
-					Container:      "kubecost-network-costs",
-				}),
-			},
-			shouldNotMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{
-					Cluster:        "cluster-one",
-					Namespace:      "default",
-					ControllerKind: "deployment",
-					Controller:     "workload-abc",
-					Pod:            "workload-abc-123abc",
-					Container:      "abc",
-				}),
-			},
-		},
-		{
-			input: `namespace!:"kubecost","kube-system"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterNamespace,
-						Op:    kubecost.FilterNotEquals,
-						Value: "kubecost",
-					},
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterNamespace,
-						Op:    kubecost.FilterNotEquals,
-						Value: "kube-system",
-					},
-				}},
-			}},
-			shouldMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Namespace: "abc"}),
-			},
-			shouldNotMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Namespace: "kubecost"}),
-				allocGenerator(kubecost.AllocationProperties{Namespace: "kube-system"}),
-			},
-		},
-		{
-			input: `namespace:"kubecost","kube-system"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterNamespace,
-						Op:    kubecost.FilterEquals,
-						Value: "kubecost",
-					},
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterNamespace,
-						Op:    kubecost.FilterEquals,
-						Value: "kube-system",
-					},
-				}},
-			}},
-			shouldMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Namespace: "kubecost"}),
-				allocGenerator(kubecost.AllocationProperties{Namespace: "kube-system"}),
-			},
-			shouldNotMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Namespace: "abc"}),
-			},
-		},
-		{
-			input: `node:"node a b c" , "node 12 3"` + string('\n') + "+" + string('\n') + string('\r') + `namespace : "kubecost"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterNode,
-						Op:    kubecost.FilterEquals,
-						Value: "node a b c",
-					},
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterNode,
-						Op:    kubecost.FilterEquals,
-						Value: "node 12 3",
-					},
-				}},
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterNamespace,
-						Op:    kubecost.FilterEquals,
-						Value: "kubecost",
-					},
-				}},
-			}},
-		},
-		{
-			input: `label[app_abc]:"cost_analyzer"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterLabel,
-						Key:   "app_abc",
-						Op:    kubecost.FilterEquals,
-						Value: "cost_analyzer",
-					},
-				}},
-			}},
-		},
-		{
-			input: `services:"123","abc"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterServices,
-						Op:    kubecost.FilterContains,
-						Value: "123",
-					},
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterServices,
-						Op:    kubecost.FilterContains,
-						Value: "abc",
-					},
-				}},
-			}},
-		},
-		{
-			input: `services!:"123","abc"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterServices,
-						Op:    kubecost.FilterNotContains,
-						Value: "123",
-					},
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterServices,
-						Op:    kubecost.FilterNotContains,
-						Value: "abc",
-					},
-				}},
-			}},
-		},
-		{
-			input: `label[app_abc]:"cost_analyzer"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterLabel,
-						Key:   "app_abc",
-						Op:    kubecost.FilterEquals,
-						Value: "cost_analyzer",
-					},
-				}},
-			}},
-		},
-		{
-			input: `label[app_abc]:"cost_analyzer"+label[foo]:"bar"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterLabel,
-						Key:   "app_abc",
-						Op:    kubecost.FilterEquals,
-						Value: "cost_analyzer",
-					},
-				}},
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterLabel,
-						Key:   "foo",
-						Op:    kubecost.FilterEquals,
-						Value: "bar",
-					},
-				}},
-			}},
-		},
-		{
-			input: `
-namespace:"kubecost" +
-label[app]:"cost_analyzer" +
-annotation[a1]:"b2" +
-cluster:"cluster-one" +
-node!:
-  "node-123",
-  "node-456" +
-controllerName:
-  "kubecost-cost-analyzer",
-  "kubecost-prometheus-server" +
-controllerKind!:
-  "daemonset",
-  "statefulset",
-  "job" +
-container!:"123-abc_foo" +
-pod!:"aaaaaaaaaaaaaaaaaaaaaaaaa" +
-services!:"abc123"
-`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterNamespace,
-						Op:    kubecost.FilterEquals,
-						Value: "kubecost",
-					},
-				}},
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterLabel,
-						Key:   "app",
-						Op:    kubecost.FilterEquals,
-						Value: "cost_analyzer",
-					},
-				}},
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterAnnotation,
-						Key:   "a1",
-						Op:    kubecost.FilterEquals,
-						Value: "b2",
-					},
-				}},
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterClusterID,
-						Op:    kubecost.FilterEquals,
-						Value: "cluster-one",
-					},
-				}},
-				kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterNode,
-						Op:    kubecost.FilterNotEquals,
-						Value: "node-123",
-					},
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterNode,
-						Op:    kubecost.FilterNotEquals,
-						Value: "node-456",
-					},
-				}},
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterControllerName,
-						Op:    kubecost.FilterEquals,
-						Value: "kubecost-cost-analyzer",
-					},
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterControllerName,
-						Op:    kubecost.FilterEquals,
-						Value: "kubecost-prometheus-server",
-					},
-				}},
-				kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterControllerKind,
-						Op:    kubecost.FilterNotEquals,
-						Value: "daemonset",
-					},
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterControllerKind,
-						Op:    kubecost.FilterNotEquals,
-						Value: "statefulset",
-					},
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterControllerKind,
-						Op:    kubecost.FilterNotEquals,
-						Value: "job",
-					},
-				}},
-				kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterContainer,
-						Op:    kubecost.FilterNotEquals,
-						Value: "123-abc_foo",
-					},
-				}},
-				kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterPod,
-						Op:    kubecost.FilterNotEquals,
-						Value: "aaaaaaaaaaaaaaaaaaaaaaaaa",
-					},
-				}},
-				kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterServices,
-						Op:    kubecost.FilterNotContains,
-						Value: "abc123",
-					},
-				}},
-			}},
-		},
-		{
-			input: `namespace:"__unallocated__"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterNamespace,
-						Op:    kubecost.FilterEquals,
-						Value: kubecost.UnallocatedSuffix,
-					},
-				}},
-			}},
-			shouldMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Namespace: ""}),
-			},
-			shouldNotMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Namespace: "kube-system"}),
-			},
-		},
-		{
-			input: `namespace!:"__unallocated__"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterNamespace,
-						Op:    kubecost.FilterNotEquals,
-						Value: kubecost.UnallocatedSuffix,
-					},
-				}},
-			}},
-			shouldMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Namespace: "kubecost"}),
-			},
-			shouldNotMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Namespace: ""}),
-			},
-		},
-		{
-			input: `controllerKind:"__unallocated__"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterControllerKind,
-						Op:    kubecost.FilterEquals,
-						Value: kubecost.UnallocatedSuffix,
-					},
-				}},
-			}},
-			shouldMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{ControllerKind: ""}),
-			},
-			shouldNotMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{ControllerKind: "deployment"}),
-			},
-		},
-		{
-			input: `controllerKind!:"__unallocated__"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterControllerKind,
-						Op:    kubecost.FilterNotEquals,
-						Value: kubecost.UnallocatedSuffix,
-					},
-				}},
-			}},
-			shouldMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{ControllerKind: "deployment"}),
-			},
-			shouldNotMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{ControllerKind: ""}),
-			},
-		},
-		{
-			input: `label[app]:"__unallocated__"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterLabel,
-						Key:   "app",
-						Op:    kubecost.FilterEquals,
-						Value: kubecost.UnallocatedSuffix,
-					},
-				}},
-			}},
-			shouldMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Labels: map[string]string{"foo": "bar"}}),
-			},
-			shouldNotMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Labels: map[string]string{"app": "test"}}),
-			},
-		},
-		{
-			input: `label[app]!:"__unallocated__"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterLabel,
-						Key:   "app",
-						Op:    kubecost.FilterNotEquals,
-						Value: kubecost.UnallocatedSuffix,
-					},
-				}},
-			}},
-			shouldMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Labels: map[string]string{"app": "test"}}),
-			},
-			shouldNotMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Labels: map[string]string{"foo": "bar"}}),
-			},
-		},
-		{
-			input: `services:"__unallocated__"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterOr{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterServices,
-						Op:    kubecost.FilterContains,
-						Value: kubecost.UnallocatedSuffix,
-					},
-				}},
-			}},
-			shouldMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Services: []string{}}),
-			},
-			shouldNotMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Services: []string{"svc1", "svc2"}}),
-			},
-		},
-		{
-			input: `services!:"__unallocated__"`,
-			expected: kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-				kubecost.AllocationFilterAnd{[]kubecost.AllocationFilter{
-					kubecost.AllocationFilterCondition{
-						Field: kubecost.FilterServices,
-						Op:    kubecost.FilterNotContains,
-						Value: kubecost.UnallocatedSuffix,
-					},
-				}},
-			}},
-			shouldMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Services: []string{"svc1", "svc2"}}),
-			},
-			shouldNotMatch: []kubecost.Allocation{
-				allocGenerator(kubecost.AllocationProperties{Services: []string{}}),
-			},
-		},
-	}
-
-	for i, c := range cases {
-		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
-			t.Logf("Query: %s", c.input)
-			result, err := ParseAllocationFilter(c.input)
-			t.Logf("Result: %s", result)
-			if err != nil {
-				t.Fatalf("Unexpected parse error: %s", err)
-			}
-			if !reflect.DeepEqual(result, c.expected) {
-				t.Fatalf("Expected:\n%s\nGot:\n%s", c.expected, result)
-			}
-
-			for _, shouldMatch := range c.shouldMatch {
-				if !result.Matches(&shouldMatch) {
-					t.Errorf("Failed to match %s", shouldMatch.Name)
-				}
-			}
-			for _, shouldNotMatch := range c.shouldNotMatch {
-				if result.Matches(&shouldNotMatch) {
-					t.Errorf("Incorrectly matched %s", shouldNotMatch.Name)
-				}
-			}
-		})
-	}
-}

+ 544 - 0
pkg/util/filterutil/filterutil.go

@@ -0,0 +1,544 @@
+package filterutil
+
+import (
+	"strings"
+
+	"github.com/opencost/opencost/pkg/costmodel/clusters"
+	"github.com/opencost/opencost/pkg/kubecost"
+	"github.com/opencost/opencost/pkg/log"
+	"github.com/opencost/opencost/pkg/prom"
+	"github.com/opencost/opencost/pkg/util/typeutil"
+
+	filter "github.com/opencost/opencost/pkg/filter21"
+	allocationfilter "github.com/opencost/opencost/pkg/filter21/allocation"
+	"github.com/opencost/opencost/pkg/filter21/ast"
+	// cloudfilter "github.com/opencost/opencost/pkg/filter/cloud"
+)
+
+// ============================================================================
+// This file contains:
+// Parsing (HTTP query params -> v2.1 filter) for V1 of query param filters
+//
+// e.g. "filterNamespaces=ku&filterControllers=deployment:kc"
+// ============================================================================
+
+// This is somewhat of a fancy solution, but allows us to "register" DefaultFieldByName funcs
+// funcs by Field type.
+var defaultFieldByType = map[string]any{
+	// typeutil.TypeOf[cloudfilter.CloudAggregationField](): cloudfilter.DefaultFieldByName,
+	typeutil.TypeOf[allocationfilter.AllocationField](): allocationfilter.DefaultFieldByName,
+}
+
+// DefaultFieldByName looks up a specific T field instance by name and returns the default
+// ast.Field value for that type.
+func DefaultFieldByName[T ~string](field T) *ast.Field {
+	lookup, ok := defaultFieldByType[typeutil.TypeOf[T]()]
+	if !ok {
+		log.Errorf("Failed to get default field lookup for: %s", typeutil.TypeOf[T]())
+		return nil
+	}
+
+	defaultLookup, ok := lookup.(func(T) *ast.Field)
+	if !ok {
+		log.Errorf("Failed to cast default field lookup for: %s", typeutil.TypeOf[T]())
+		return nil
+	}
+
+	return defaultLookup(field)
+}
+
+const (
+	ParamFilterClusters        = "filterClusters"
+	ParamFilterNodes           = "filterNodes"
+	ParamFilterNamespaces      = "filterNamespaces"
+	ParamFilterControllerKinds = "filterControllerKinds"
+	ParamFilterControllers     = "filterControllers"
+	ParamFilterPods            = "filterPods"
+	ParamFilterContainers      = "filterContainers"
+
+	ParamFilterDepartments  = "filterDepartments"
+	ParamFilterEnvironments = "filterEnvironments"
+	ParamFilterOwners       = "filterOwners"
+	ParamFilterProducts     = "filterProducts"
+	ParamFilterTeams        = "filterTeams"
+
+	ParamFilterAnnotations = "filterAnnotations"
+	ParamFilterLabels      = "filterLabels"
+	ParamFilterServices    = "filterServices"
+)
+
+// AllocationPropToV1FilterParamKey maps allocation string property
+// representations to v1 filter param keys for legacy filter config support
+// (e.g. reports). Example mapping: "cluster" -> "filterClusters"
+var AllocationPropToV1FilterParamKey = map[string]string{
+	kubecost.AllocationClusterProp:        ParamFilterClusters,
+	kubecost.AllocationNodeProp:           ParamFilterNodes,
+	kubecost.AllocationNamespaceProp:      ParamFilterNamespaces,
+	kubecost.AllocationControllerProp:     ParamFilterControllers,
+	kubecost.AllocationControllerKindProp: ParamFilterControllerKinds,
+	kubecost.AllocationPodProp:            ParamFilterPods,
+	kubecost.AllocationLabelProp:          ParamFilterLabels,
+	kubecost.AllocationServiceProp:        ParamFilterServices,
+	kubecost.AllocationDepartmentProp:     ParamFilterDepartments,
+	kubecost.AllocationEnvironmentProp:    ParamFilterEnvironments,
+	kubecost.AllocationOwnerProp:          ParamFilterOwners,
+	kubecost.AllocationProductProp:        ParamFilterProducts,
+	kubecost.AllocationTeamProp:           ParamFilterTeams,
+}
+
+// AllHTTPParamKeys returns all HTTP GET parameters used for v1 filters. It is
+// intended to help validate HTTP queries in handlers to help avoid e.g.
+// spelling errors.
+func AllHTTPParamKeys() []string {
+	return []string{
+		ParamFilterClusters,
+		ParamFilterNodes,
+		ParamFilterNamespaces,
+		ParamFilterControllerKinds,
+		ParamFilterControllers,
+		ParamFilterPods,
+		ParamFilterContainers,
+
+		ParamFilterDepartments,
+		ParamFilterEnvironments,
+		ParamFilterOwners,
+		ParamFilterProducts,
+		ParamFilterTeams,
+
+		ParamFilterAnnotations,
+		ParamFilterLabels,
+		ParamFilterServices,
+	}
+}
+
+// AllocationFilterFromParamsV1 takes a set of HTTP query parameters and
+// converts them to an AllocationFilter, which is a structured in-Go
+// representation of a set of filters.
+//
+// The HTTP query parameters are the "v1" filters attached to the Allocation
+// API: "filterNamespaces=", "filterNodes=", etc.
+//
+// It takes an optional LabelConfig, which if provided enables "label-mapped"
+// filters like "filterDepartments".
+//
+// It takes an optional ClusterMap, which if provided enables cluster name
+// filtering. This turns all `filterClusters=foo` arguments into the equivalent
+// of `clusterID = "foo" OR clusterName = "foo"`.
+func AllocationFilterFromParamsV1(
+	params AllocationFilterV1,
+	labelConfig *kubecost.LabelConfig,
+	clusterMap clusters.ClusterMap,
+) filter.Filter {
+
+	var filterOps []ast.FilterNode
+
+	// ClusterMap does not provide a cluster name -> cluster ID mapping in the
+	// interface, probably because there could be multiple IDs with the same
+	// name. However, V1 filter logic demands that the parameters to
+	// filterClusters= be checked against both cluster ID AND cluster name.
+	//
+	// To support expected filterClusters= behavior, we construct a mapping
+	// of cluster name -> cluster IDs (could be multiple IDs for the same name)
+	// so that we can create AllocationFilters that use only ClusterIDEquals.
+	//
+	//
+	// AllocationFilter intentionally does not support cluster name filters
+	// because those should be considered presentation-layer only.
+	clusterNameToIDs := map[string][]string{}
+	if clusterMap != nil {
+		cMap := clusterMap.AsMap()
+		for _, info := range cMap {
+			if info == nil {
+				continue
+			}
+
+			if _, ok := clusterNameToIDs[info.Name]; ok {
+				clusterNameToIDs[info.Name] = append(clusterNameToIDs[info.Name], info.ID)
+			} else {
+				clusterNameToIDs[info.Name] = []string{info.ID}
+			}
+		}
+	}
+
+	// The proliferation of > 0 guards in the function is to avoid constructing
+	// empty filter structs. While it is functionally equivalent to add empty
+	// filter structs (they evaluate to true always) there could be overhead
+	// when calling Matches() repeatedly for no purpose.
+
+	if len(params.Clusters) > 0 {
+		var ops []ast.FilterNode
+
+		// filter my cluster identifier
+		ops = push(ops, filterV1SingleValueFromList(params.Clusters, allocationfilter.AllocationFieldClusterID))
+
+		for _, rawFilterValue := range params.Clusters {
+			clusterNameFilter, wildcard := parseWildcardEnd(rawFilterValue)
+
+			clusterIDsToFilter := []string{}
+			for clusterName := range clusterNameToIDs {
+				if wildcard && strings.HasPrefix(clusterName, clusterNameFilter) {
+					clusterIDsToFilter = append(clusterIDsToFilter, clusterNameToIDs[clusterName]...)
+				} else if !wildcard && clusterName == clusterNameFilter {
+					clusterIDsToFilter = append(clusterIDsToFilter, clusterNameToIDs[clusterName]...)
+				}
+			}
+
+			for _, clusterID := range clusterIDsToFilter {
+				ops = append(ops, &ast.EqualOp{
+					Left: ast.Identifier{
+						Field: allocationfilter.DefaultFieldByName(allocationfilter.AllocationFieldClusterID),
+						Key:   "",
+					},
+					Right: clusterID,
+				})
+			}
+		}
+
+		//
+		clustersOp := opsToOr(ops)
+		filterOps = push(filterOps, clustersOp)
+	}
+
+	if len(params.Nodes) > 0 {
+		filterOps = push(filterOps, filterV1SingleValueFromList(params.Nodes, allocationfilter.AllocationFieldNode))
+	}
+
+	if len(params.Namespaces) > 0 {
+		filterOps = push(filterOps, filterV1SingleValueFromList(params.Namespaces, allocationfilter.AllocationFieldNamespace))
+	}
+
+	if len(params.ControllerKinds) > 0 {
+		filterOps = push(filterOps, filterV1SingleValueFromList(params.ControllerKinds, allocationfilter.AllocationFieldControllerKind))
+	}
+
+	// filterControllers= accepts controllerkind:controllername filters, e.g.
+	// "deployment:kubecost-cost-analyzer"
+	//
+	// Thus, we have to make a custom OR filter for this condition.
+	if len(params.Controllers) > 0 {
+		var ops []ast.FilterNode
+
+		for _, rawFilterValue := range params.Controllers {
+			split := strings.Split(rawFilterValue, ":")
+			if len(split) == 1 {
+				filterValue, wildcard := parseWildcardEnd(split[0])
+
+				subFilter := toEqualOp(allocationfilter.AllocationFieldControllerName, "", filterValue, wildcard)
+				ops = append(ops, subFilter)
+			} else if len(split) == 2 {
+				kindFilterVal := split[0]
+				nameFilterVal, wildcard := parseWildcardEnd(split[1])
+
+				kindFilter := toEqualOp(allocationfilter.AllocationFieldControllerKind, "", kindFilterVal, false)
+				nameFilter := toEqualOp(allocationfilter.AllocationFieldControllerName, "", nameFilterVal, wildcard)
+
+				// The controller name AND the controller kind must match
+				ops = append(ops, &ast.AndOp{
+					Operands: []ast.FilterNode{
+						kindFilter,
+						nameFilter,
+					},
+				})
+			} else {
+				log.Warnf("illegal filter for controller: %s", rawFilterValue)
+			}
+		}
+		controllersOp := opsToOr(ops)
+		filterOps = push(filterOps, controllersOp)
+	}
+
+	if len(params.Pods) > 0 {
+		filterOps = push(filterOps, filterV1SingleValueFromList(params.Pods, allocationfilter.AllocationFieldPod))
+	}
+
+	if len(params.Containers) > 0 {
+		filterOps = push(filterOps, filterV1SingleValueFromList(params.Containers, allocationfilter.AllocationFieldContainer))
+	}
+
+	// Label-mapped queries require a label config to be present.
+	if labelConfig != nil {
+		if len(params.Departments) > 0 {
+			filterOps = push(filterOps, filterV1LabelAliasMappedFromList(params.Departments, labelConfig.DepartmentLabel))
+		}
+		if len(params.Environments) > 0 {
+			filterOps = push(filterOps, filterV1LabelAliasMappedFromList(params.Environments, labelConfig.EnvironmentLabel))
+		}
+		if len(params.Owners) > 0 {
+			filterOps = push(filterOps, filterV1LabelAliasMappedFromList(params.Owners, labelConfig.OwnerLabel))
+		}
+		if len(params.Products) > 0 {
+			filterOps = push(filterOps, filterV1LabelAliasMappedFromList(params.Products, labelConfig.ProductLabel))
+		}
+		if len(params.Teams) > 0 {
+			filterOps = push(filterOps, filterV1LabelAliasMappedFromList(params.Teams, labelConfig.TeamLabel))
+		}
+	} else {
+		log.Debugf("No label config is available. Not creating filters for label-mapped 'fields'.")
+	}
+
+	if len(params.Annotations) > 0 {
+		filterOps = push(filterOps, filterV1DoubleValueFromList(params.Annotations, allocationfilter.AllocationFieldAnnotation))
+	}
+
+	if len(params.Labels) > 0 {
+		filterOps = push(filterOps, filterV1DoubleValueFromList(params.Labels, allocationfilter.AllocationFieldLabel))
+	}
+
+	if len(params.Services) > 0 {
+		var ops []ast.FilterNode
+
+		// filterServices= is the only filter that uses the "contains" operator.
+		for _, filterValue := range params.Services {
+			// TODO: wildcard support
+			filterValue, wildcard := parseWildcardEnd(filterValue)
+
+			subFilter := toContainsOp(allocationfilter.AllocationFieldServices, "", filterValue, wildcard)
+			ops = append(ops, subFilter)
+		}
+
+		serviceOps := opsToOr(ops)
+		filterOps = push(filterOps, serviceOps)
+	}
+
+	andFilter := opsToAnd(filterOps)
+	if andFilter == nil {
+		return &ast.VoidOp{} // no filter
+	}
+
+	return andFilter
+}
+
+// filterV1SingleValueFromList creates an OR of equality filters for a given
+// filter field.
+//
+// The v1 query language (e.g. "filterNamespaces=XYZ,ABC") uses OR within
+// a field (e.g. namespace = XYZ OR namespace = ABC)
+func filterV1SingleValueFromList[T ~string](rawFilterValues []string, filterField T) ast.FilterNode {
+	var ops []ast.FilterNode
+
+	for _, filterValue := range rawFilterValues {
+		filterValue = strings.TrimSpace(filterValue)
+		filterValue, wildcard := parseWildcardEnd(filterValue)
+
+		subFilter := toEqualOp(filterField, "", filterValue, wildcard)
+		ops = append(ops, subFilter)
+	}
+
+	return opsToOr(ops)
+}
+
+// filterV1LabelAliasMappedFromList is like filterV1SingleValueFromList but is
+// explicitly for labels and annotations because "label-mapped" filters (like filterTeams=)
+// are actually label filters with a fixed label key.
+func filterV1LabelAliasMappedFromList(rawFilterValues []string, labelName string) ast.FilterNode {
+	var ops []ast.FilterNode
+	labelName = prom.SanitizeLabelName(labelName)
+
+	for _, filterValue := range rawFilterValues {
+		filterValue = strings.TrimSpace(filterValue)
+		filterValue, wildcard := parseWildcardEnd(filterValue)
+
+		subFilter := toAllocationAliasOp(labelName, filterValue, wildcard)
+
+		ops = append(ops, subFilter)
+	}
+
+	return opsToOr(ops)
+}
+
+// filterV1DoubleValueFromList creates an OR of key:value equality filters for
+// colon-split filter values.
+//
+// The v1 query language (e.g. "filterLabels=app:foo,l2:bar") uses OR within
+// a field (e.g. label[app] = foo OR label[l2] = bar)
+func filterV1DoubleValueFromList(rawFilterValuesUnsplit []string, filterField allocationfilter.AllocationField) ast.FilterNode {
+	var ops []ast.FilterNode
+
+	for _, unsplit := range rawFilterValuesUnsplit {
+		if unsplit != "" {
+			split := strings.Split(unsplit, ":")
+			if len(split) != 2 {
+				log.Warnf("illegal key/value filter (ignoring): %s", unsplit)
+				continue
+			}
+			labelName := prom.SanitizeLabelName(strings.TrimSpace(split[0]))
+			val := strings.TrimSpace(split[1])
+			val, wildcard := parseWildcardEnd(val)
+
+			subFilter := toEqualOp(filterField, labelName, val, wildcard)
+			ops = append(ops, subFilter)
+		}
+	}
+
+	return opsToOr(ops)
+}
+
+// parseWildcardEnd checks if the given filter value is wildcarded, meaning
+// it ends in "*". If it does, it removes the suffix and returns the cleaned
+// string and true. Otherwise, it returns the same filter and false.
+//
+// parseWildcardEnd("kube*") = "kube", true
+// parseWildcardEnd("kube") = "kube", false
+func parseWildcardEnd(rawFilterValue string) (string, bool) {
+	return strings.TrimSuffix(rawFilterValue, "*"), strings.HasSuffix(rawFilterValue, "*")
+}
+
+func push(a []ast.FilterNode, item ast.FilterNode) []ast.FilterNode {
+	if item == nil {
+		return a
+	}
+
+	return append(a, item)
+}
+
+func opsToOr(ops []ast.FilterNode) ast.FilterNode {
+	if len(ops) == 0 {
+		return nil
+	}
+
+	if len(ops) == 1 {
+		return ops[0]
+	}
+
+	return &ast.OrOp{
+		Operands: ops,
+	}
+}
+
+func opsToAnd(ops []ast.FilterNode) ast.FilterNode {
+	if len(ops) == 0 {
+		return nil
+	}
+
+	if len(ops) == 1 {
+		return ops[0]
+	}
+
+	return &ast.AndOp{
+		Operands: ops,
+	}
+}
+
+func toEqualOp[T ~string](field T, key string, value string, wildcard bool) ast.FilterNode {
+	left := ast.Identifier{
+		Field: DefaultFieldByName(field),
+		Key:   key,
+	}
+	right := value
+
+	if wildcard {
+		return &ast.ContainsPrefixOp{
+			Left:  left,
+			Right: right,
+		}
+	}
+
+	return &ast.EqualOp{
+		Left:  left,
+		Right: right,
+	}
+}
+
+func toContainsOp[T ~string](field T, key string, value string, wildcard bool) ast.FilterNode {
+	left := ast.Identifier{
+		Field: DefaultFieldByName(field),
+		Key:   key,
+	}
+	right := value
+
+	if wildcard {
+		return &ast.ContainsPrefixOp{
+			Left:  left,
+			Right: right,
+		}
+	}
+
+	return &ast.ContainsOp{
+		Left:  left,
+		Right: right,
+	}
+}
+
+func toAllocationAliasOp(labelName string, filterValue string, wildcard bool) *ast.OrOp {
+	// labels.Contains(labelName)
+	labelContainsKey := &ast.ContainsOp{
+		Left: ast.Identifier{
+			Field: allocationfilter.DefaultFieldByName(allocationfilter.AllocationFieldLabel),
+			Key:   "",
+		},
+		Right: labelName,
+	}
+
+	// annotations.Contains(labelName)
+	annotationContainsKey := &ast.ContainsOp{
+		Left: ast.Identifier{
+			Field: allocationfilter.DefaultFieldByName(allocationfilter.AllocationFieldAnnotation),
+			Key:   "",
+		},
+		Right: labelName,
+	}
+
+	// labels[labelName] equals/startswith filterValue
+	var labelSubFilter ast.FilterNode
+	if wildcard {
+		labelSubFilter = &ast.ContainsPrefixOp{
+			Left: ast.Identifier{
+				Field: allocationfilter.DefaultFieldByName(allocationfilter.AllocationFieldLabel),
+				Key:   labelName,
+			},
+			Right: filterValue,
+		}
+	} else {
+		labelSubFilter = &ast.EqualOp{
+			Left: ast.Identifier{
+				Field: allocationfilter.DefaultFieldByName(allocationfilter.AllocationFieldLabel),
+				Key:   labelName,
+			},
+			Right: filterValue,
+		}
+	}
+
+	// annotations[labelName] equals/startswith filterValue
+	var annotationSubFilter ast.FilterNode
+	if wildcard {
+		annotationSubFilter = &ast.ContainsPrefixOp{
+			Left: ast.Identifier{
+				Field: allocationfilter.DefaultFieldByName(allocationfilter.AllocationFieldAnnotation),
+				Key:   labelName,
+			},
+			Right: filterValue,
+		}
+	} else {
+		annotationSubFilter = &ast.EqualOp{
+			Left: ast.Identifier{
+				Field: allocationfilter.DefaultFieldByName(allocationfilter.AllocationFieldAnnotation),
+				Key:   labelName,
+			},
+			Right: filterValue,
+		}
+	}
+
+	// Logically, this is equivalent to:
+	// (labels.Contains(labelName) && labels[labelName] = filterValue) ||
+	// (!labels.Contains(labelName) && annotations.Contains(labelName) && annotations[labelName] = filterValue)
+
+	return &ast.OrOp{
+		Operands: []ast.FilterNode{
+			&ast.AndOp{
+				Operands: []ast.FilterNode{
+					labelContainsKey,
+					labelSubFilter,
+				},
+			},
+			&ast.AndOp{
+				Operands: []ast.FilterNode{
+					&ast.NotOp{
+						Operand: ast.Clone(labelContainsKey),
+					},
+					annotationContainsKey,
+					annotationSubFilter,
+				},
+			},
+		},
+	}
+}

+ 32 - 211
pkg/util/allocationfilterutil/queryfilters_test.go → pkg/util/filterutil/queryfilters_test.go

@@ -1,4 +1,4 @@
-package allocationfilterutil
+package filterutil
 
 import (
 	"testing"
@@ -8,6 +8,8 @@ import (
 	"github.com/opencost/opencost/pkg/util/mapper"
 )
 
+var allocCompiler = kubecost.NewAllocationMatchCompiler()
+
 type mockClusterMap struct {
 	m map[string]*clusters.ClusterInfo
 }
@@ -384,6 +386,30 @@ func TestFiltersFromParamsV1(t *testing.T) {
 				}),
 			},
 		},
+		{
+			name: "single department, no label, annotation",
+			qp: map[string]string{
+				"filterDepartments": "pa-1",
+			},
+			shouldMatch: []kubecost.Allocation{
+				allocGenerator(kubecost.AllocationProperties{
+					Annotations: map[string]string{
+						"internal_product_umbrella": "pa-1",
+					},
+				}),
+			},
+			// should find labels first and fail
+			shouldNotMatch: []kubecost.Allocation{
+				allocGenerator(kubecost.AllocationProperties{
+					Labels: map[string]string{
+						"internal_product_umbrella": "ps-N",
+					},
+					Annotations: map[string]string{
+						"internal_product_umbrella": "pa-1",
+					},
+				}),
+			},
+		},
 		{
 			name: "wildcard department",
 			qp: map[string]string{
@@ -705,7 +731,11 @@ func TestFiltersFromParamsV1(t *testing.T) {
 				},
 			}
 
-			filter := AllocationFilterFromParamsV1(qpMapper, &labelConfig, clustersMap)
+			filterTree := AllocationFilterFromParamsV1(ConvertFilterQueryParams(qpMapper, &labelConfig), &labelConfig, clustersMap)
+			filter, err := allocCompiler.Compile(filterTree)
+			if err != nil {
+				t.Fatalf("compiling filter: %s", err)
+			}
 			for _, alloc := range c.shouldMatch {
 				if !filter.Matches(&alloc) {
 					t.Errorf("should have matched: %s", alloc.Name)
@@ -719,212 +749,3 @@ func TestFiltersFromParamsV1(t *testing.T) {
 		})
 	}
 }
-
-type FilterV1EqualsTestcase struct {
-	name     string
-	this     FilterV1
-	that     FilterV1
-	expected bool
-}
-
-func TestFilterV1_Equals(t *testing.T) {
-	testCases := []FilterV1EqualsTestcase{
-		{
-			name: "both filters nil",
-			this: FilterV1{
-				Annotations:     nil,
-				Containers:      nil,
-				Controllers:     nil,
-				ControllerKinds: nil,
-				Clusters:        nil,
-				Departments:     nil,
-				Environments:    nil,
-				Labels:          nil,
-				Namespaces:      nil,
-				Nodes:           nil,
-				Owners:          nil,
-				Pods:            nil,
-				Products:        nil,
-				Services:        nil,
-				Teams:           nil,
-			},
-			that: FilterV1{
-				Annotations:     nil,
-				Containers:      nil,
-				Controllers:     nil,
-				ControllerKinds: nil,
-				Clusters:        nil,
-				Departments:     nil,
-				Environments:    nil,
-				Labels:          nil,
-				Namespaces:      nil,
-				Nodes:           nil,
-				Owners:          nil,
-				Pods:            nil,
-				Products:        nil,
-				Services:        nil,
-				Teams:           nil,
-			},
-			expected: true,
-		},
-		{
-			name: "both filters not nil and matching",
-			this: FilterV1{
-				Annotations:     []string{"a1", "b1"},
-				Containers:      []string{"a1", "b1"},
-				Controllers:     []string{"a1", "b1"},
-				ControllerKinds: []string{"a1", "b1"},
-				Clusters:        []string{"a1", "b1"},
-				Departments:     []string{"a1", "b1"},
-				Environments:    []string{"a1", "b1"},
-				Labels:          []string{"a1", "b1"},
-				Namespaces:      []string{"a1", "b1"},
-				Nodes:           []string{"a1", "b1"},
-				Owners:          []string{"a1", "b1"},
-				Pods:            []string{"a1", "b1"},
-				Products:        []string{"a1", "b1"},
-				Services:        []string{"a1", "b1"},
-				Teams:           []string{"a1", "b1"},
-			},
-			that: FilterV1{
-				Annotations:     []string{"a1", "b1"},
-				Containers:      []string{"a1", "b1"},
-				Controllers:     []string{"a1", "b1"},
-				ControllerKinds: []string{"a1", "b1"},
-				Clusters:        []string{"a1", "b1"},
-				Departments:     []string{"a1", "b1"},
-				Environments:    []string{"a1", "b1"},
-				Labels:          []string{"a1", "b1"},
-				Namespaces:      []string{"a1", "b1"},
-				Nodes:           []string{"a1", "b1"},
-				Owners:          []string{"a1", "b1"},
-				Pods:            []string{"a1", "b1"},
-				Products:        []string{"a1", "b1"},
-				Services:        []string{"a1", "b1"},
-				Teams:           []string{"a1", "b1"},
-			},
-			expected: true,
-		},
-		{
-			name: "both filters diff count",
-			this: FilterV1{
-				Annotations:     []string{"a1", "b1", "c1"},
-				Containers:      []string{"a1", "b1"},
-				Controllers:     []string{"a1", "b1"},
-				ControllerKinds: []string{"a1", "b1"},
-				Clusters:        []string{"a1", "b1"},
-				Departments:     []string{"a1", "b1"},
-				Environments:    []string{"a1", "b1"},
-				Labels:          []string{"a1", "b1"},
-				Namespaces:      []string{"a1", "b1"},
-				Nodes:           []string{"a1", "b1"},
-				Owners:          []string{"a1", "b1"},
-				Pods:            []string{"a1", "b1"},
-				Products:        []string{"a1", "b1"},
-				Services:        []string{"a1", "b1"},
-				Teams:           []string{"a1", "b1"},
-			},
-			that: FilterV1{
-				Annotations:     []string{"a1", "b1"},
-				Containers:      []string{"a1", "b1"},
-				Controllers:     []string{"a1", "b1"},
-				ControllerKinds: []string{"a1", "b1"},
-				Clusters:        []string{"a1", "b1"},
-				Departments:     []string{"a1", "b1"},
-				Environments:    []string{"a1", "b1"},
-				Labels:          []string{"a1", "b1"},
-				Namespaces:      []string{"a1", "b1"},
-				Nodes:           []string{"a1", "b1"},
-				Owners:          []string{"a1", "b1"},
-				Pods:            []string{"a1", "b1"},
-				Products:        []string{"a1", "b1"},
-				Services:        []string{"a1", "b1"},
-				Teams:           []string{"a1", "b1"},
-			},
-			expected: false,
-		},
-		{
-			name: "slight mismatch",
-			this: FilterV1{
-				Annotations:     []string{"x1", "b1"},
-				Containers:      []string{"a1", "b1"},
-				Controllers:     []string{"a1", "b1"},
-				ControllerKinds: []string{"a1", "b1"},
-				Clusters:        []string{"a1", "b1"},
-				Departments:     []string{"a1", "b1"},
-				Environments:    []string{"a1", "b1"},
-				Labels:          []string{"a1", "b1"},
-				Namespaces:      []string{"a1", "b1"},
-				Nodes:           []string{"a1", "b1"},
-				Owners:          []string{"a1", "b1"},
-				Pods:            []string{"a1", "b1"},
-				Products:        []string{"a1", "b1"},
-				Services:        []string{"a1", "b1"},
-				Teams:           []string{"a1", "b1"},
-			},
-			that: FilterV1{
-				Annotations:     []string{"a1", "b1"},
-				Containers:      []string{"a1", "b1"},
-				Controllers:     []string{"a1", "b1"},
-				ControllerKinds: []string{"a1", "b1"},
-				Clusters:        []string{"a1", "b1"},
-				Departments:     []string{"a1", "b1"},
-				Environments:    []string{"a1", "b1"},
-				Labels:          []string{"a1", "b1"},
-				Namespaces:      []string{"a1", "b1"},
-				Nodes:           []string{"a1", "b1"},
-				Owners:          []string{"a1", "b1"},
-				Pods:            []string{"a1", "b1"},
-				Products:        []string{"a1", "b1"},
-				Services:        []string{"a1", "b1"},
-				Teams:           []string{"a1", "b1"},
-			},
-			expected: false,
-		},
-		{
-			name: "one nil",
-			this: FilterV1{
-				Annotations:     []string{"x1", "b1"},
-				Containers:      []string{"a1", "b1"},
-				Controllers:     []string{"a1", "b1"},
-				ControllerKinds: []string{"a1", "b1"},
-				Clusters:        []string{"a1", "b1"},
-				Departments:     []string{"a1", "b1"},
-				Environments:    []string{"a1", "b1"},
-				Labels:          []string{"a1", "b1"},
-				Namespaces:      []string{"a1", "b1"},
-				Nodes:           []string{"a1", "b1"},
-				Owners:          []string{"a1", "b1"},
-				Pods:            []string{"a1", "b1"},
-				Products:        []string{"a1", "b1"},
-				Services:        []string{"a1", "b1"},
-				Teams:           []string{"a1", "b1"},
-			},
-			that: FilterV1{
-				Annotations:     nil,
-				Containers:      nil,
-				Controllers:     nil,
-				ControllerKinds: nil,
-				Clusters:        nil,
-				Departments:     nil,
-				Environments:    nil,
-				Labels:          nil,
-				Namespaces:      nil,
-				Nodes:           nil,
-				Owners:          nil,
-				Pods:            nil,
-				Products:        nil,
-				Services:        nil,
-				Teams:           nil,
-			},
-			expected: false,
-		},
-	}
-
-	for _, tc := range testCases {
-		got := tc.this.Equals(tc.that)
-		if got != tc.expected {
-			t.Fatalf("expected %t, got: %t for test case: %s", tc.expected, got, tc.name)
-		}
-	}
-}

+ 53 - 0
pkg/util/filterutil/testhelpers.go

@@ -0,0 +1,53 @@
+package filterutil
+
+import (
+	"sort"
+	"strings"
+
+	"github.com/opencost/opencost/pkg/filter21/ast"
+)
+
+func testingOnlyLess(left, right ast.FilterNode) bool {
+	leftStr := ast.ToPreOrderShortString(left)
+	rightStr := ast.ToPreOrderShortString(right)
+	return strings.Compare(leftStr, rightStr) < 0
+}
+
+func testingOnlySortedOperands(operands []ast.FilterNode) []ast.FilterNode {
+	var copy []ast.FilterNode
+	for _, operand := range operands {
+		copy = append(copy, operand)
+	}
+	sort.SliceStable(copy, func(i, j int) bool {
+		leftSorted := TestingOnlySortNode(copy[i])
+		rightSorted := TestingOnlySortNode(copy[j])
+
+		return testingOnlyLess(leftSorted, rightSorted)
+	})
+	return copy
+}
+
+// TestingOnlySortNode sorts the provided node deterministically, intended only
+// for use in unit tests to ensure that filter parsing steps produce logically-
+// equivalent filters. This is useful only for cases where filters are
+// constructed nondeterministically, like via a map iteration.
+func TestingOnlySortNode(n ast.FilterNode) ast.FilterNode {
+	switch concrete := n.(type) {
+	case *ast.AndOp:
+		return &ast.AndOp{
+			Operands: testingOnlySortedOperands(concrete.Operands),
+		}
+	case *ast.OrOp:
+		return &ast.OrOp{
+			Operands: testingOnlySortedOperands(concrete.Operands),
+		}
+	case *ast.NotOp:
+		return &ast.NotOp{
+			Operand: TestingOnlySortNode(concrete.Operand),
+		}
+	// This isn't great, but non-container ops are mostly safe. We don't need
+	// full deepcopy because this is for testing-only comparison
+	default:
+		return concrete
+	}
+}

+ 72 - 0
pkg/util/filterutil/v1.go

@@ -0,0 +1,72 @@
+package filterutil
+
+import (
+	"reflect"
+
+	"github.com/opencost/opencost/pkg/kubecost"
+	"github.com/opencost/opencost/pkg/log"
+	"github.com/opencost/opencost/pkg/util/mapper"
+)
+
+func ConvertFilterQueryParams(qp mapper.PrimitiveMapReader, labelConfig *kubecost.LabelConfig) AllocationFilterV1 {
+	filter := AllocationFilterV1{
+		Annotations:     qp.GetList(ParamFilterAnnotations, ","),
+		Containers:      qp.GetList(ParamFilterContainers, ","),
+		Controllers:     qp.GetList(ParamFilterControllers, ","),
+		ControllerKinds: qp.GetList(ParamFilterControllerKinds, ","),
+		Clusters:        qp.GetList(ParamFilterClusters, ","),
+		Labels:          qp.GetList(ParamFilterLabels, ","),
+		Namespaces:      qp.GetList(ParamFilterNamespaces, ","),
+		Nodes:           qp.GetList(ParamFilterNodes, ","),
+		Pods:            qp.GetList(ParamFilterPods, ","),
+		Services:        qp.GetList(ParamFilterServices, ","),
+	}
+
+	if labelConfig != nil {
+		filter.Departments = qp.GetList(ParamFilterDepartments, ",")
+		filter.Environments = qp.GetList(ParamFilterEnvironments, ",")
+		filter.Owners = qp.GetList(ParamFilterOwners, ",")
+		filter.Products = qp.GetList(ParamFilterProducts, ",")
+		filter.Teams = qp.GetList(ParamFilterTeams, ",")
+	} else {
+		log.Debugf("No label config is available. Not creating filters for label-mapped 'fields'.")
+	}
+
+	return filter
+}
+
+type AllocationFilterV1 struct {
+	Annotations     []string `json:"annotations,omitempty"`
+	Containers      []string `json:"containers,omitempty"`
+	Controllers     []string `json:"controllers,omitempty"`
+	ControllerKinds []string `json:"controllerKinds,omitempty"`
+	Clusters        []string `json:"clusters,omitempty"`
+	Departments     []string `json:"departments,omitempty"`
+	Environments    []string `json:"environments,omitempty"`
+	Labels          []string `json:"labels,omitempty"`
+	Namespaces      []string `json:"namespaces,omitempty"`
+	Nodes           []string `json:"nodes,omitempty"`
+	Owners          []string `json:"owners,omitempty"`
+	Pods            []string `json:"pods,omitempty"`
+	Products        []string `json:"products,omitempty"`
+	Services        []string `json:"services,omitempty"`
+	Teams           []string `json:"teams,omitempty"`
+}
+
+func (f AllocationFilterV1) Equals(that AllocationFilterV1) bool {
+	return reflect.DeepEqual(f.Annotations, that.Annotations) &&
+		reflect.DeepEqual(f.Containers, that.Containers) &&
+		reflect.DeepEqual(f.Controllers, that.Controllers) &&
+		reflect.DeepEqual(f.ControllerKinds, that.ControllerKinds) &&
+		reflect.DeepEqual(f.Clusters, that.Clusters) &&
+		reflect.DeepEqual(f.Departments, that.Departments) &&
+		reflect.DeepEqual(f.Environments, that.Environments) &&
+		reflect.DeepEqual(f.Labels, that.Labels) &&
+		reflect.DeepEqual(f.Namespaces, that.Namespaces) &&
+		reflect.DeepEqual(f.Nodes, that.Nodes) &&
+		reflect.DeepEqual(f.Owners, that.Owners) &&
+		reflect.DeepEqual(f.Pods, that.Pods) &&
+		reflect.DeepEqual(f.Products, that.Products) &&
+		reflect.DeepEqual(f.Services, that.Services) &&
+		reflect.DeepEqual(f.Teams, that.Teams)
+}

+ 214 - 0
pkg/util/filterutil/v1_test.go

@@ -0,0 +1,214 @@
+package filterutil
+
+import (
+	"testing"
+)
+
+type FilterV1EqualsTestcase struct {
+	name     string
+	this     AllocationFilterV1
+	that     AllocationFilterV1
+	expected bool
+}
+
+func TestFilterV1_Equals(t *testing.T) {
+	testCases := []FilterV1EqualsTestcase{
+		{
+			name: "both filters nil",
+			this: AllocationFilterV1{
+				Annotations:     nil,
+				Containers:      nil,
+				Controllers:     nil,
+				ControllerKinds: nil,
+				Clusters:        nil,
+				Departments:     nil,
+				Environments:    nil,
+				Labels:          nil,
+				Namespaces:      nil,
+				Nodes:           nil,
+				Owners:          nil,
+				Pods:            nil,
+				Products:        nil,
+				Services:        nil,
+				Teams:           nil,
+			},
+			that: AllocationFilterV1{
+				Annotations:     nil,
+				Containers:      nil,
+				Controllers:     nil,
+				ControllerKinds: nil,
+				Clusters:        nil,
+				Departments:     nil,
+				Environments:    nil,
+				Labels:          nil,
+				Namespaces:      nil,
+				Nodes:           nil,
+				Owners:          nil,
+				Pods:            nil,
+				Products:        nil,
+				Services:        nil,
+				Teams:           nil,
+			},
+			expected: true,
+		},
+		{
+			name: "both filters not nil and matching",
+			this: AllocationFilterV1{
+				Annotations:     []string{"a1", "b1"},
+				Containers:      []string{"a1", "b1"},
+				Controllers:     []string{"a1", "b1"},
+				ControllerKinds: []string{"a1", "b1"},
+				Clusters:        []string{"a1", "b1"},
+				Departments:     []string{"a1", "b1"},
+				Environments:    []string{"a1", "b1"},
+				Labels:          []string{"a1", "b1"},
+				Namespaces:      []string{"a1", "b1"},
+				Nodes:           []string{"a1", "b1"},
+				Owners:          []string{"a1", "b1"},
+				Pods:            []string{"a1", "b1"},
+				Products:        []string{"a1", "b1"},
+				Services:        []string{"a1", "b1"},
+				Teams:           []string{"a1", "b1"},
+			},
+			that: AllocationFilterV1{
+				Annotations:     []string{"a1", "b1"},
+				Containers:      []string{"a1", "b1"},
+				Controllers:     []string{"a1", "b1"},
+				ControllerKinds: []string{"a1", "b1"},
+				Clusters:        []string{"a1", "b1"},
+				Departments:     []string{"a1", "b1"},
+				Environments:    []string{"a1", "b1"},
+				Labels:          []string{"a1", "b1"},
+				Namespaces:      []string{"a1", "b1"},
+				Nodes:           []string{"a1", "b1"},
+				Owners:          []string{"a1", "b1"},
+				Pods:            []string{"a1", "b1"},
+				Products:        []string{"a1", "b1"},
+				Services:        []string{"a1", "b1"},
+				Teams:           []string{"a1", "b1"},
+			},
+			expected: true,
+		},
+		{
+			name: "both filters diff count",
+			this: AllocationFilterV1{
+				Annotations:     []string{"a1", "b1", "c1"},
+				Containers:      []string{"a1", "b1"},
+				Controllers:     []string{"a1", "b1"},
+				ControllerKinds: []string{"a1", "b1"},
+				Clusters:        []string{"a1", "b1"},
+				Departments:     []string{"a1", "b1"},
+				Environments:    []string{"a1", "b1"},
+				Labels:          []string{"a1", "b1"},
+				Namespaces:      []string{"a1", "b1"},
+				Nodes:           []string{"a1", "b1"},
+				Owners:          []string{"a1", "b1"},
+				Pods:            []string{"a1", "b1"},
+				Products:        []string{"a1", "b1"},
+				Services:        []string{"a1", "b1"},
+				Teams:           []string{"a1", "b1"},
+			},
+			that: AllocationFilterV1{
+				Annotations:     []string{"a1", "b1"},
+				Containers:      []string{"a1", "b1"},
+				Controllers:     []string{"a1", "b1"},
+				ControllerKinds: []string{"a1", "b1"},
+				Clusters:        []string{"a1", "b1"},
+				Departments:     []string{"a1", "b1"},
+				Environments:    []string{"a1", "b1"},
+				Labels:          []string{"a1", "b1"},
+				Namespaces:      []string{"a1", "b1"},
+				Nodes:           []string{"a1", "b1"},
+				Owners:          []string{"a1", "b1"},
+				Pods:            []string{"a1", "b1"},
+				Products:        []string{"a1", "b1"},
+				Services:        []string{"a1", "b1"},
+				Teams:           []string{"a1", "b1"},
+			},
+			expected: false,
+		},
+		{
+			name: "slight mismatch",
+			this: AllocationFilterV1{
+				Annotations:     []string{"x1", "b1"},
+				Containers:      []string{"a1", "b1"},
+				Controllers:     []string{"a1", "b1"},
+				ControllerKinds: []string{"a1", "b1"},
+				Clusters:        []string{"a1", "b1"},
+				Departments:     []string{"a1", "b1"},
+				Environments:    []string{"a1", "b1"},
+				Labels:          []string{"a1", "b1"},
+				Namespaces:      []string{"a1", "b1"},
+				Nodes:           []string{"a1", "b1"},
+				Owners:          []string{"a1", "b1"},
+				Pods:            []string{"a1", "b1"},
+				Products:        []string{"a1", "b1"},
+				Services:        []string{"a1", "b1"},
+				Teams:           []string{"a1", "b1"},
+			},
+			that: AllocationFilterV1{
+				Annotations:     []string{"a1", "b1"},
+				Containers:      []string{"a1", "b1"},
+				Controllers:     []string{"a1", "b1"},
+				ControllerKinds: []string{"a1", "b1"},
+				Clusters:        []string{"a1", "b1"},
+				Departments:     []string{"a1", "b1"},
+				Environments:    []string{"a1", "b1"},
+				Labels:          []string{"a1", "b1"},
+				Namespaces:      []string{"a1", "b1"},
+				Nodes:           []string{"a1", "b1"},
+				Owners:          []string{"a1", "b1"},
+				Pods:            []string{"a1", "b1"},
+				Products:        []string{"a1", "b1"},
+				Services:        []string{"a1", "b1"},
+				Teams:           []string{"a1", "b1"},
+			},
+			expected: false,
+		},
+		{
+			name: "one nil",
+			this: AllocationFilterV1{
+				Annotations:     []string{"x1", "b1"},
+				Containers:      []string{"a1", "b1"},
+				Controllers:     []string{"a1", "b1"},
+				ControllerKinds: []string{"a1", "b1"},
+				Clusters:        []string{"a1", "b1"},
+				Departments:     []string{"a1", "b1"},
+				Environments:    []string{"a1", "b1"},
+				Labels:          []string{"a1", "b1"},
+				Namespaces:      []string{"a1", "b1"},
+				Nodes:           []string{"a1", "b1"},
+				Owners:          []string{"a1", "b1"},
+				Pods:            []string{"a1", "b1"},
+				Products:        []string{"a1", "b1"},
+				Services:        []string{"a1", "b1"},
+				Teams:           []string{"a1", "b1"},
+			},
+			that: AllocationFilterV1{
+				Annotations:     nil,
+				Containers:      nil,
+				Controllers:     nil,
+				ControllerKinds: nil,
+				Clusters:        nil,
+				Departments:     nil,
+				Environments:    nil,
+				Labels:          nil,
+				Namespaces:      nil,
+				Nodes:           nil,
+				Owners:          nil,
+				Pods:            nil,
+				Products:        nil,
+				Services:        nil,
+				Teams:           nil,
+			},
+			expected: false,
+		},
+	}
+
+	for _, tc := range testCases {
+		got := tc.this.Equals(tc.that)
+		if got != tc.expected {
+			t.Fatalf("expected %t, got: %t for test case: %s", tc.expected, got, tc.name)
+		}
+	}
+}