Sfoglia il codice sorgente

Implement v2.1 filtering logic for Allocation (#1971)

* Add pkg/filter/allocation from PR#1762

Signed-off-by: Michael Dresser <michaelmdresser@gmail.com>

* Add "builder" ops for programmatic filter build

From https://github.com/kubecost/kubecost-core/commit/261480e312a0a3cf93310c89ba4b9ee168e02525#diff-71dcd2d112624b182f55dc1967ddc8db6287d05df55be4fb85115d22ae5c0826

Signed-off-by: Michael Dresser <michaelmdresser@gmail.com>

* Move typeutil to generic util package

Signed-off-by: Michael Dresser <michaelmdresser@gmail.com>

* Add Matcher implementation for Allocation

Signed-off-by: Michael Dresser <michaelmdresser@gmail.com>

---------

Signed-off-by: Michael Dresser <michaelmdresser@gmail.com>
Michael Dresser 2 anni fa
parent
commit
be9feb279d

+ 37 - 0
pkg/filter21/allocation/fields.go

@@ -0,0 +1,37 @@
+package allocation
+
+// AllocationField is an enum that represents Allocation-specific fields that can be
+// filtered on (namespace, label, etc.)
+type AllocationField string
+
+// If you add a AllocationFilterField, make sure to update field maps to return the correct
+// Allocation value
+// does not enforce exhaustive pattern matching on "enum" types.
+const (
+	AllocationFieldClusterID      AllocationField = "cluster"
+	AllocationFieldNode           AllocationField = "node"
+	AllocationFieldNamespace      AllocationField = "namespace"
+	AllocationFieldControllerKind AllocationField = "controllerKind"
+	AllocationFieldControllerName AllocationField = "controllerName"
+	AllocationFieldPod            AllocationField = "pod"
+	AllocationFieldContainer      AllocationField = "container"
+	AllocationFieldProvider       AllocationField = "provider"
+	AllocationFieldServices       AllocationField = "services"
+	AllocationFieldLabel          AllocationField = "label"
+	AllocationFieldAnnotation     AllocationField = "annotation"
+)
+
+// AllocationAlias represents an alias field type for allocations.
+// 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.
+type AllocationAlias string
+
+const (
+	AllocationAliasDepartment  AllocationAlias = "department"
+	AllocationAliasEnvironment AllocationAlias = "environment"
+	AllocationAliasOwner       AllocationAlias = "owner"
+	AllocationAliasProduct     AllocationAlias = "product"
+	AllocationAliasTeam        AllocationAlias = "team"
+)

+ 51 - 0
pkg/filter21/allocation/parser.go

@@ -0,0 +1,51 @@
+package allocation
+
+import "github.com/opencost/opencost/pkg/filter21/ast"
+
+// a slice of all the allocation field instances the lexer should recognize as
+// valid left-hand comparators
+var allocationFilterFields []*ast.Field = []*ast.Field{
+	ast.NewField(AllocationFieldClusterID),
+	ast.NewField(AllocationFieldNode),
+	ast.NewField(AllocationFieldNamespace),
+	ast.NewField(AllocationFieldControllerName),
+	ast.NewField(AllocationFieldControllerKind),
+	ast.NewField(AllocationFieldContainer),
+	ast.NewField(AllocationFieldPod),
+	ast.NewField(AllocationFieldProvider),
+	ast.NewAliasField(AllocationAliasDepartment),
+	ast.NewAliasField(AllocationAliasEnvironment),
+	ast.NewAliasField(AllocationAliasOwner),
+	ast.NewAliasField(AllocationAliasProduct),
+	ast.NewAliasField(AllocationAliasTeam),
+	ast.NewSliceField(AllocationFieldServices),
+	ast.NewMapField(AllocationFieldLabel),
+	ast.NewMapField(AllocationFieldAnnotation),
+}
+
+// fieldMap is a lazily loaded mapping from AllocationField to ast.Field
+var fieldMap map[AllocationField]*ast.Field
+
+// DefaultFieldByName returns only default allocation filter fields by name.
+func DefaultFieldByName(field AllocationField) *ast.Field {
+	if fieldMap == nil {
+		fieldMap = make(map[AllocationField]*ast.Field, len(allocationFilterFields))
+		for _, f := range allocationFilterFields {
+			ff := *f
+			fieldMap[AllocationField(ff.Name)] = &ff
+		}
+	}
+
+	if af, ok := fieldMap[field]; ok {
+		afcopy := *af
+		return &afcopy
+	}
+
+	return nil
+}
+
+// NewAllocationFilterParser creates a new `ast.FilterParser` implementation
+// which uses allocation specific fields
+func NewAllocationFilterParser() ast.FilterParser {
+	return ast.NewFilterParser(allocationFilterFields)
+}

+ 289 - 0
pkg/filter21/allocation/parser_test.go

@@ -0,0 +1,289 @@
+package allocation
+
+import (
+	"errors"
+	"fmt"
+	"testing"
+
+	"github.com/hashicorp/go-multierror"
+	"github.com/opencost/opencost/pkg/filter21/ast"
+)
+
+var parser ast.FilterParser = NewAllocationFilterParser()
+
+func TestParse(t *testing.T) {
+	cases := []struct {
+		name  string
+		input string
+	}{
+		{
+			name: "Empty",
+			input: `              
+			
+			`,
+		},
+		{
+			name:  "Single",
+			input: `namespace: "kubecost"`,
+		},
+		{
+			name:  "Single Group",
+			input: `(namespace: "kubecost")`,
+		},
+		{
+			name:  "Single Double Group",
+			input: `((namespace: "kubecost"))`,
+		},
+		{
+			name:  "And 2x Expression",
+			input: `(namespace: "kubecost" + services~:"foo")`,
+		},
+		{
+			name:  "And 4x Expression",
+			input: `(namespace: "kubecost" + services~:"foo" + cluster:"cluster-one" + controllerKind:"deployment")`,
+		},
+		{
+			name:  "Nested And Groups",
+			input: `namespace: "kubecost" + services~:"foo" + (cluster:"cluster-one" + controllerKind:"deployment")`,
+		},
+		{
+			name:  "Nested Or Groups",
+			input: `namespace: "kubecost" | services~:"foo" | (cluster:"cluster-one" | controllerKind:"deployment")`,
+		},
+		{
+			name:  "Nested AndOr Groups",
+			input: `namespace: "kubecost" + services~:"foo" + (cluster:"cluster-one" | controllerKind:"deployment")`,
+		},
+		{
+			name:  "Nested OrAnd Groups",
+			input: `namespace: "kubecost" | services~:"foo" | (cluster:"cluster-one" + controllerKind:"deployment")`,
+		},
+		{
+			name:  "Nested OrAndOr Groups",
+			input: `namespace: "kubecost" | services~:"foo" | (cluster:"cluster-one" + controllerKind:"deployment") | namespace:"bar","test"`,
+		},
+		{
+			name:  "Non-uniform Whitespace",
+			input: `node:"node a b c" , "node 12 3"` + string('\n') + "+" + string('\n') + string('\r') + `namespace : "kubecost"`,
+		},
+		{
+			name:  "Group Or Comparison",
+			input: `(namespace:"kubecost" | cluster<~:"cluster-") + services~:"foo"`,
+		},
+		{
+			name:  "Group Or Group",
+			input: `(label~:"foo" + label[foo]:"bar") | (label!~:"foo" + annotation~:"foo" + annotation[foo]:"bar")`,
+		},
+		{
+			name:  "MultiDepth Groups",
+			input: `namespace: "kubecost" | ((services~:"foo" | (cluster:"cluster-one" + controllerKind:"deployment") | namespace:"bar","test") + cluster~:"cluster-")`,
+		},
+		{
+			name: "Long Query",
+			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" + 
+				owner!:"kubecost"
+			`,
+		},
+	}
+
+	for i, c := range cases {
+		t.Run(fmt.Sprintf("%d:%s", i, c.name), func(t *testing.T) {
+			t.Logf("Query: %s", c.input)
+			tree, err := parser.Parse(c.input)
+			if err != nil {
+				t.Fatalf("Unexpected parse error: %s", err)
+			}
+			t.Logf("%s", ast.ToPreOrderString(tree))
+		})
+	}
+}
+
+func TestFailingParses(t *testing.T) {
+	cases := []struct {
+		name   string
+		input  string
+		errors int
+	}{
+		{
+			name:   "Empty Parens",
+			input:  `()`,
+			errors: 1,
+		},
+		{
+			name:   "Invalid Op",
+			input:  `namespace.:"kubecost"`,
+			errors: 1,
+		},
+		{
+			name:   "Extra Closing Paren",
+			input:  `(namespace:"kubecost"))`,
+			errors: 1,
+		},
+		{
+			name:   "Extra Opening Paren",
+			input:  `((namespace:"kubecost")`,
+			errors: 1,
+		},
+		{
+			name:   "Or And Mixing",
+			input:  `namespace:"kubecost" | services~:"foo" + cluster:"bar"`,
+			errors: 1,
+		},
+		{
+			name:   "And Or Mixing",
+			input:  `namespace:"kubecost" + services~:"foo" | cluster:"bar"`,
+			errors: 1,
+		},
+		{
+			name:   "And Or Mixing With Extra Closing Paren",
+			input:  `(namespace:"kubecost" + (services~:"foo" | cluster:"bar") | controllerKind<~:"dep"))`,
+			errors: 2,
+		},
+		// NOTE: This test includes coverage for an extra closing paren _early_, which basically enforces an
+		// NOTE: early return. Scoping errors don't allow the parser to continue collecting errors.
+		{
+			name:   "And Or Mixing With Extra Early Closing Paren",
+			input:  `(namespace:"kubecost" + (services~:"foo" | cluster:"bar")) | controllerKind<~:"dep")`,
+			errors: 1,
+		},
+	}
+
+	for i, c := range cases {
+		t.Run(fmt.Sprintf("%d:%s", i, c.name), func(t *testing.T) {
+			t.Logf("Query: %s", c.input)
+			tree, err := parser.Parse(c.input)
+			if err == nil {
+				t.Fatalf("Expected parsing failure. Instead, got a valid tree: \n%s\n", ast.ToPreOrderString(tree))
+			}
+
+			t.Logf("Errors: %s\n", err)
+
+			mErr := errors.Unwrap(err)
+			totalErrors := len(mErr.(*multierror.Error).Errors)
+			if totalErrors != c.errors {
+				t.Fatalf("Expected %d errors from parsing. Got %d", c.errors, totalErrors)
+			}
+		})
+	}
+}
+
+func TestShortPrint(t *testing.T) {
+	cases := []struct {
+		name  string
+		input string
+	}{
+		{
+			name: "Empty",
+			input: `              
+			
+			`,
+		},
+		{
+			name:  "Single",
+			input: `namespace: "kubecost"`,
+		},
+		{
+			name:  "Single Group",
+			input: `(namespace: "kubecost")`,
+		},
+		{
+			name:  "Single Double Group",
+			input: `((namespace: "kubecost"))`,
+		},
+		{
+			name:  "And 2x Expression",
+			input: `(namespace: "kubecost" + services~:"foo")`,
+		},
+		{
+			name:  "And 4x Expression",
+			input: `(namespace: "kubecost" + services~:"foo" + cluster:"cluster-one" + controllerKind:"deployment")`,
+		},
+		{
+			name:  "Nested And Groups",
+			input: `namespace: "kubecost" + services~:"foo" + (cluster:"cluster-one" + controllerKind:"deployment")`,
+		},
+		{
+			name:  "Nested Or Groups",
+			input: `namespace: "kubecost" | services~:"foo" | (cluster:"cluster-one" | controllerKind:"deployment")`,
+		},
+		{
+			name:  "Nested AndOr Groups",
+			input: `namespace: "kubecost" + services~:"foo" + (cluster:"cluster-one" | controllerKind:"deployment")`,
+		},
+		{
+			name:  "Nested OrAnd Groups",
+			input: `namespace: "kubecost" | services~:"foo" | (cluster:"cluster-one" + controllerKind:"deployment")`,
+		},
+		{
+			name:  "Nested OrAndOr Groups",
+			input: `namespace: "kubecost" | services~:"foo" | (cluster:"cluster-one" + controllerKind:"deployment") | namespace:"bar","test"`,
+		},
+		{
+			name:  "Non-uniform Whitespace",
+			input: `node:"node a b c" , "node 12 3"` + string('\n') + "+" + string('\n') + string('\r') + `namespace : "kubecost"`,
+		},
+		{
+			name:  "Group Or Comparison",
+			input: `(namespace:"kubecost" | cluster<~:"cluster-") + services~:"foo"`,
+		},
+		{
+			name:  "Group Or Group",
+			input: `(label~:"foo" + label[foo]:"bar") | (label!~:"foo" + annotation~:"foo" + annotation[foo]:"bar")`,
+		},
+		{
+			name:  "MultiDepth Groups",
+			input: `namespace: "kubecost" | ((services~:"foo" | (cluster:"cluster-one" + controllerKind:"deployment") | namespace:"bar","test") + cluster~:"cluster-")`,
+		},
+		{
+			name: "Long Query",
+			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" + 
+				owner!:"kubecost"
+			`,
+		},
+	}
+
+	for i, c := range cases {
+		t.Run(fmt.Sprintf("%d:%s", i, c.name), func(t *testing.T) {
+			t.Logf("Query: %s", c.input)
+			tree, err := parser.Parse(c.input)
+			if err != nil {
+				t.Fatalf("Unexpected parse error: %s", err)
+			}
+			t.Logf("%s", ast.ToPreOrderShortString(tree))
+		})
+	}
+}

+ 9 - 0
pkg/filter21/ast/fields.go

@@ -19,6 +19,15 @@ type Field struct {
 	fieldType FieldType
 }
 
+// Field equivalence is determined by name and type.
+func (f *Field) Equal(other *Field) bool {
+	if f == nil || other == nil {
+		return false
+	}
+
+	return f.Name == other.Name && f.fieldType == other.fieldType
+}
+
 // IsSlice returns true if the field is a slice. This instructs the lexer that the field
 // should allow contains operations.
 func (f *Field) IsSlice() bool {

+ 5 - 0
pkg/filter21/ast/tree.go

@@ -23,6 +23,11 @@ type Identifier struct {
 	Key   string
 }
 
+// Equal returns true if the identifiers are equal
+func (id *Identifier) Equal(ident Identifier) bool {
+	return id.Field.Equal(ident.Field) && id.Key == ident.Key
+}
+
 // String returns the string representation for the Identifier
 func (id *Identifier) String() string {
 	s := id.Field.Name

+ 467 - 0
pkg/filter21/matcher/matcher_test.go

@@ -0,0 +1,467 @@
+package matcher_test
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/opencost/opencost/pkg/filter21/allocation"
+	"github.com/opencost/opencost/pkg/filter21/ast"
+	"github.com/opencost/opencost/pkg/filter21/matcher"
+	"github.com/opencost/opencost/pkg/filter21/transform"
+)
+
+// MatcherCompiler for Allocation instances providing functions which map identifers
+// to values within the allocation
+var allocCompiler = matcher.NewMatchCompiler(
+	AllocFieldMap,
+	AllocSliceFieldMap,
+	AllocMapFieldMap,
+	transform.PrometheusKeySanitizePass(),
+	transform.UnallocatedReplacementPass(),
+)
+
+// AST parser for allocation syntax
+var allocParser ast.FilterParser = allocation.NewAllocationFilterParser()
+
+func newAlloc(props *AllocationProperties) *Allocation {
+	a := &Allocation{
+		Properties: props,
+	}
+
+	a.Name = a.Properties.String()
+	return a
+}
+
+func TestCompileAndMatch(t *testing.T) {
+	cases := []struct {
+		input          string
+		shouldMatch    []*Allocation
+		shouldNotMatch []*Allocation
+	}{
+		{
+			input: `namespace:"kubecost"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Namespace: "kubecost"}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Namespace: "kube-system"}),
+			},
+		},
+		{
+			input: `cluster:"cluster-one"+namespace:"kubecost"+controllerKind:"daemonset"+controllerName:"kubecost-network-costs"+container:"kubecost-network-costs"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{
+					Cluster:        "cluster-one",
+					Namespace:      "kubecost",
+					ControllerKind: "daemonset",
+					Controller:     "kubecost-network-costs",
+					Pod:            "kubecost-network-costs-abc123",
+					Container:      "kubecost-network-costs",
+				}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{
+					Cluster:        "cluster-one",
+					Namespace:      "default",
+					ControllerKind: "deployment",
+					Controller:     "workload-abc",
+					Pod:            "workload-abc-123abc",
+					Container:      "abc",
+				}),
+			},
+		},
+		{
+			input: `namespace!:"kubecost","kube-system"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Namespace: "abc"}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Namespace: "kubecost"}),
+				newAlloc(&AllocationProperties{Namespace: "kube-system"}),
+			},
+		},
+		{
+			input: `namespace:"kubecost","kube-system"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Namespace: "kubecost"}),
+				newAlloc(&AllocationProperties{Namespace: "kube-system"}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Namespace: "abc"}),
+			},
+		},
+		{
+			input: `node:"node a b c" , "node 12 3"` + string('\n') + "+" + string('\n') + string('\r') + `namespace : "kubecost"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Namespace: "kubecost", Node: "node a b c"}),
+				newAlloc(&AllocationProperties{Namespace: "kubecost", Node: "node 12 3"}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Namespace: "kubecost"}),
+				newAlloc(&AllocationProperties{Namespace: "kubecost", Node: "nodeabc"}),
+			},
+		},
+		{
+			input: `label[app_abc]:"cost_analyzer"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{
+					Namespace: "kubecost",
+					Labels: map[string]string{
+						"test":    "test123",
+						"app_abc": "cost_analyzer",
+					},
+				}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{
+					Namespace: "kubecost",
+					Labels: map[string]string{
+						"foo": "bar",
+					},
+				}),
+			},
+		},
+		{
+			input: `services~:"123","abc"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{
+					Namespace: "kubecost",
+					Services: []string{
+						"foo",
+						"bar",
+						"123",
+					},
+				}),
+				newAlloc(&AllocationProperties{
+					Namespace: "kubecost",
+					Services: []string{
+						"foo",
+						"abc",
+						"test",
+					},
+				}),
+				newAlloc(&AllocationProperties{
+					Namespace: "kubecost",
+					Services: []string{
+						"123",
+						"abc",
+						"test",
+					},
+				}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{
+					Namespace: "kubecost",
+					Services: []string{
+						"foo",
+						"bar",
+					},
+				}),
+			},
+		},
+		{
+			input: `services!:"123","abc"`,
+		},
+		{
+			input: `label[app-abc]:"cost_analyzer"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{
+					Labels: map[string]string{
+						"app_abc": "cost_analyzer",
+					},
+				}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{
+					Labels: map[string]string{
+						"app-abc": "cost_analyzer",
+					},
+				}),
+			},
+		},
+		{
+			input: `label[app_abc]:"cost_analyzer"+label[foo]:"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"
+`,
+		},
+		{
+			input: `namespace:"__unallocated__"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Namespace: ""}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Namespace: "kube-system"}),
+			},
+		},
+		{
+			input: `namespace!:"__unallocated__"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Namespace: "kubecost"}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Namespace: ""}),
+			},
+		},
+		{
+			input: `controllerKind:"__unallocated__"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{ControllerKind: ""}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{ControllerKind: "deployment"}),
+			},
+		},
+		{
+			input: `controllerKind!:"__unallocated__"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{ControllerKind: "deployment"}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{ControllerKind: ""}),
+			},
+		},
+		{
+			input: `label[app]:"__unallocated__"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Labels: map[string]string{"foo": "bar"}}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Labels: map[string]string{"app": "test"}}),
+			},
+		},
+		{
+			input: `label[app]!:"__unallocated__"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Labels: map[string]string{"app": "test"}}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Labels: map[string]string{"foo": "bar"}}),
+			},
+		},
+		{
+			input: `services:"__unallocated__"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Services: []string{}}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Services: []string{"svc1", "svc2"}}),
+			},
+		},
+		{
+			input: `services!:"__unallocated__"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Services: []string{"svc1", "svc2"}}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{Services: []string{}}),
+			},
+		},
+		{
+			input: `label[cloud.google.com/gke-nodepool]:"gke-nodepool-1"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{
+					Labels: map[string]string{
+						"cloud_google_com_gke_nodepool": "gke-nodepool-1",
+					},
+				}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{
+					Labels: map[string]string{
+						"cloud.google.com/gke-nodepool": "gke-nodepool-1",
+					},
+				}),
+			},
+		},
+		{
+			input: `label:"cloud.google.com/gke-nodepool"`,
+			shouldMatch: []*Allocation{
+				newAlloc(&AllocationProperties{
+					Labels: map[string]string{
+						"cloud_google_com_gke_nodepool": "gke-nodepool-1",
+					},
+				}),
+			},
+			shouldNotMatch: []*Allocation{
+				newAlloc(&AllocationProperties{
+					Labels: map[string]string{
+						"cloud.google.com/gke-nodepool": "gke-nodepool-1",
+					},
+				}),
+			},
+		},
+	}
+
+	for i, c := range cases {
+		t.Run(fmt.Sprintf("%d", i), func(t *testing.T) {
+			t.Logf("Query: %s", c.input)
+			tree, err := allocParser.Parse(c.input)
+			if err != nil {
+				t.Fatalf("Unexpected parse error: %s", err)
+			}
+			t.Logf("%s", ast.ToPreOrderString(tree))
+
+			matcher, err := allocCompiler.Compile(tree)
+			t.Logf("Result: %s", matcher)
+			if err != nil {
+				t.Fatalf("Unexpected parse error: %s", err)
+			}
+			for _, shouldMatch := range c.shouldMatch {
+				if !matcher.Matches(shouldMatch) {
+					t.Errorf("Failed to match %s", shouldMatch.Name)
+				}
+			}
+			for _, shouldNotMatch := range c.shouldNotMatch {
+				if matcher.Matches(shouldNotMatch) {
+					t.Errorf("Incorrectly matched %s", shouldNotMatch.Name)
+				}
+			}
+		})
+	}
+}
+
+// Allocation Mock
+
+// Maps fields from an allocation to a string value based on an identifier
+func AllocFieldMap(a *Allocation, identifier ast.Identifier) (string, error) {
+	switch identifier.Field.Name {
+	case "namespace":
+		return a.Properties.Namespace, nil
+	case "node":
+		return a.Properties.Node, nil
+	case "cluster":
+		return a.Properties.Cluster, nil
+	case "controllerName":
+		return a.Properties.Controller, nil
+	case "controllerKind":
+		return a.Properties.ControllerKind, nil
+	case "pod":
+		return a.Properties.Pod, nil
+	case "container":
+		return a.Properties.Container, nil
+	case "label":
+		return a.Properties.Labels[identifier.Key], nil
+	case "annotation":
+		return a.Properties.Annotations[identifier.Key], nil
+	}
+
+	return "", fmt.Errorf("Failed to find string identifier on Allocation: %s", identifier.Field.Name)
+}
+
+// Maps slice fields from an allocation to a []string value based on an identifier
+func AllocSliceFieldMap(a *Allocation, identifier ast.Identifier) ([]string, error) {
+	switch identifier.Field.Name {
+	case "services":
+		return a.Properties.Services, nil
+	}
+
+	return nil, fmt.Errorf("Failed to find []string identifier on Allocation: %s", identifier.Field.Name)
+}
+
+// Maps map fields from an allocation to a map[string]string value based on an identifier
+func AllocMapFieldMap(a *Allocation, identifier ast.Identifier) (map[string]string, error) {
+	switch identifier.Field.Name {
+	case "label":
+		return a.Properties.Labels, nil
+	case "annotation":
+		return a.Properties.Annotations, nil
+	}
+	return nil, fmt.Errorf("Failed to find map[string]string identifier on Allocation: %s", identifier.Field.Name)
+}
+
+type AllocationProperties struct {
+	Cluster        string            `json:"cluster,omitempty"`
+	Node           string            `json:"node,omitempty"`
+	Container      string            `json:"container,omitempty"`
+	Controller     string            `json:"controller,omitempty"`
+	ControllerKind string            `json:"controllerKind,omitempty"`
+	Namespace      string            `json:"namespace,omitempty"`
+	Pod            string            `json:"pod,omitempty"`
+	Services       []string          `json:"services,omitempty"`
+	ProviderID     string            `json:"providerID,omitempty"`
+	Labels         map[string]string `json:"labels,omitempty"`
+	Annotations    map[string]string `json:"annotations,omitempty"`
+}
+
+func (p *AllocationProperties) String() string {
+	if p == nil {
+		return "<nil>"
+	}
+
+	strs := []string{}
+
+	if p.Cluster != "" {
+		strs = append(strs, "Cluster:"+p.Cluster)
+	}
+
+	if p.Node != "" {
+		strs = append(strs, "Node:"+p.Node)
+	}
+
+	if p.Container != "" {
+		strs = append(strs, "Container:"+p.Container)
+	}
+
+	if p.Controller != "" {
+		strs = append(strs, "Controller:"+p.Controller)
+	}
+
+	if p.ControllerKind != "" {
+		strs = append(strs, "ControllerKind:"+p.ControllerKind)
+	}
+
+	if p.Namespace != "" {
+		strs = append(strs, "Namespace:"+p.Namespace)
+	}
+
+	if p.Pod != "" {
+		strs = append(strs, "Pod:"+p.Pod)
+	}
+
+	if p.ProviderID != "" {
+		strs = append(strs, "ProviderID:"+p.ProviderID)
+	}
+
+	if len(p.Services) > 0 {
+		strs = append(strs, "Services:"+strings.Join(p.Services, ";"))
+	}
+
+	var labelStrs []string
+	for k, prop := range p.Labels {
+		labelStrs = append(labelStrs, fmt.Sprintf("%s:%s", k, prop))
+	}
+	strs = append(strs, fmt.Sprintf("Labels:{%s}", strings.Join(labelStrs, ",")))
+
+	var annotationStrs []string
+	for k, prop := range p.Annotations {
+		annotationStrs = append(annotationStrs, fmt.Sprintf("%s:%s", k, prop))
+	}
+	strs = append(strs, fmt.Sprintf("Annotations:{%s}", strings.Join(annotationStrs, ",")))
+
+	return fmt.Sprintf("{%s}", strings.Join(strs, "; "))
+}
+
+type Allocation struct {
+	Name       string
+	Properties *AllocationProperties
+}

+ 225 - 0
pkg/filter21/ops/ops.go

@@ -0,0 +1,225 @@
+// The ops package provides a set of functions that can be used to
+// build a filter AST programatically using basic functions, versus
+// building a filter AST leveraging all structural components of the
+// tree.
+package ops
+
+import (
+	"fmt"
+	"reflect"
+	"strings"
+
+	"github.com/opencost/opencost/pkg/filter21/allocation"
+	"github.com/opencost/opencost/pkg/filter21/ast"
+	"github.com/opencost/opencost/pkg/util/typeutil"
+)
+
+// keyFieldType is used to extract field, key, and field type
+type keyFieldType interface {
+	Field() string
+	Key() string
+	Type() string
+}
+
+// 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[cloud.CloudAggregationField]():        cloud.DefaultFieldByName,
+	typeutil.TypeOf[allocation.AllocationField](): allocation.DefaultFieldByName,
+	// typeutil.TypeOf[asset.AssetField]():                   asset.DefaultFieldByName,
+	// typeutil.TypeOf[containerstats.ContainerStatsField](): containerstats.DefaultFieldByName,
+}
+
+// asField looks up a specific T field instance by name and returns the default
+// ast.Field value for that type.
+func asField[T ~string](field T) *ast.Field {
+	lookup, ok := defaultFieldByType[typeutil.TypeOf[T]()]
+	if !ok {
+		return nil
+	}
+
+	defaultLookup, ok := lookup.(func(T) *ast.Field)
+	if !ok {
+		return nil
+	}
+
+	return defaultLookup(field)
+}
+
+// asFieldWithType allows for a field to be looked up by name and type.
+func asFieldWithType(field string, typ string) *ast.Field {
+	lookup, ok := defaultFieldByType[typ]
+	if !ok {
+		return nil
+	}
+
+	// This is the sacrifice being made to allow a simple filter
+	// builder style API. In the cases where we have keys, the typical
+	// field type gets wrapped in a KeyedFieldType, which is a string
+	// that holds all the parameterized data, but no way to get back from
+	// string to T-instance.
+
+	// Since we have the type name, we can use that to lookup the specific
+	// func(T) *ast.Field function to be used.
+	funcType := reflect.TypeOf(lookup)
+
+	// Assert that the function has a single parameter (type T)
+	if funcType.NumIn() != 1 {
+		return nil
+	}
+
+	// Get a reference to the first parameter's type (T)
+	inType := funcType.In(0)
+
+	// Create a reflect.Value for the string field, then convert it to
+	// the T type from the function's parameter list. (This has to be
+	// done to ensure we're executing the call with the correct types)
+	fieldParam := reflect.ValueOf(field).Convert(inType)
+
+	// Create a reflect.Value for the lookup function
+	callable := reflect.ValueOf(lookup)
+
+	// Call the function with the fieldParam value, and get the result
+	result := callable.Call([]reflect.Value{fieldParam})
+	if len(result) == 0 {
+		return nil
+	}
+
+	// Lastly, extract the value from the reflect.Value and ensure we can
+	// cast it to *ast.Field
+	resultValue := result[0].Interface()
+	if f, ok := resultValue.(*ast.Field); ok {
+		return f
+	}
+
+	return nil
+}
+
+// KeyedFieldType is a type alias for field is a special field type that can
+// be deconstructed into multiple components.
+type KeyedFieldType string
+
+func (k KeyedFieldType) Field() string {
+	str := string(k)
+	idx := strings.Index(str, "$")
+	if idx == -1 {
+		return ""
+	}
+
+	return str[0:idx]
+}
+
+func (k KeyedFieldType) Key() string {
+	str := string(k)
+	idx := strings.Index(str, "$")
+	if idx == -1 {
+		return ""
+	}
+
+	lastIndex := strings.LastIndex(str, "$")
+	if lastIndex == -1 {
+		return ""
+	}
+
+	return str[idx+1 : lastIndex]
+}
+
+func (k KeyedFieldType) Type() string {
+	str := string(k)
+	lastIndex := strings.LastIndex(str, "$")
+	if lastIndex == -1 {
+		return ""
+	}
+
+	return str[lastIndex+1:]
+}
+
+func WithKey[T ~string](field T, key string) KeyedFieldType {
+	k := fmt.Sprintf("%s$%s$%s", field, key, typeutil.TypeOf[T]())
+
+	return KeyedFieldType(k)
+}
+
+func toFieldAndKey[T ~string](field T) (*ast.Field, string) {
+	var inner any = field
+	if kft, ok := inner.(keyFieldType); ok {
+		return asFieldWithType(kft.Field(), kft.Type()), kft.Key()
+	}
+
+	return asField(field), ""
+}
+
+func identifier[T ~string](field T) ast.Identifier {
+	f, key := toFieldAndKey(field)
+
+	return ast.Identifier{
+		Field: f,
+		Key:   key,
+	}
+}
+
+func And(node, next ast.FilterNode, others ...ast.FilterNode) ast.FilterNode {
+	operands := append([]ast.FilterNode{node, next}, others...)
+
+	return &ast.AndOp{
+		Operands: operands,
+	}
+}
+
+func Or(node, next ast.FilterNode, others ...ast.FilterNode) ast.FilterNode {
+	operands := append([]ast.FilterNode{node, next}, others...)
+
+	return &ast.OrOp{
+		Operands: operands,
+	}
+}
+
+func Not(node ast.FilterNode) ast.FilterNode {
+	return &ast.NotOp{
+		Operand: node,
+	}
+}
+
+func Eq[T ~string](field T, value string) ast.FilterNode {
+	return &ast.EqualOp{
+		Left:  identifier(field),
+		Right: value,
+	}
+}
+
+func NotEq[T ~string](field T, value string) ast.FilterNode {
+	return Not(Eq(field, value))
+}
+
+func Contains[T ~string](field T, value string) ast.FilterNode {
+	return &ast.ContainsOp{
+		Left:  identifier(field),
+		Right: value,
+	}
+}
+
+func NotContains[T ~string](field T, value string) ast.FilterNode {
+	return Not(Contains(field, value))
+}
+
+func ContainsPrefix[T ~string](field T, value string) ast.FilterNode {
+	return &ast.ContainsPrefixOp{
+		Left:  identifier(field),
+		Right: value,
+	}
+}
+
+func NotContainsPrefix[T ~string](field T, value string) ast.FilterNode {
+	return Not(ContainsPrefix(field, value))
+}
+
+func ContainsSuffix[T ~string](field T, value string) ast.FilterNode {
+	return &ast.ContainsSuffixOp{
+		Left:  identifier(field),
+		Right: value,
+	}
+}
+
+func NotContainsSuffix[T ~string](field T, value string) ast.FilterNode {
+	return Not(ContainsSuffix(field, value))
+}

+ 103 - 0
pkg/filter21/ops/ops_test.go

@@ -0,0 +1,103 @@
+package ops_test
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/opencost/opencost/pkg/filter21/allocation"
+	"github.com/opencost/opencost/pkg/filter21/ast"
+	"github.com/opencost/opencost/pkg/filter21/ops"
+)
+
+func TestBasicOpsBuilder(t *testing.T) {
+	parser := allocation.NewAllocationFilterParser()
+
+	filterTree := ops.And(
+		ops.Or(
+			ops.Eq(allocation.AllocationFieldNamespace, "kubecost"),
+			ops.Eq(allocation.AllocationFieldClusterID, "cluster-one"),
+		),
+		ops.NotContains(allocation.AllocationFieldServices, "service-a"),
+		ops.NotEq(ops.WithKey(allocation.AllocationFieldLabel, "app"), "cost-analyzer"),
+		ops.Contains(allocation.AllocationFieldLabel, "foo"),
+	)
+
+	otherTree, err := parser.Parse(`
+		(namespace: "kubecost" | cluster: "cluster-one") +
+		services!~:"service-a" +
+		label[app]!: "cost-analyzer" +
+		label~:"foo"
+	`)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if !cmp.Equal(filterTree, otherTree) {
+		t.Fatalf("Filter Trees are not equal: %s", cmp.Diff(filterTree, otherTree))
+	}
+}
+
+func TestLongFormComparison(t *testing.T) {
+	filterTree := ops.And(
+		ops.Or(
+			ops.Eq(allocation.AllocationFieldNamespace, "kubecost"),
+			ops.Eq(allocation.AllocationFieldClusterID, "cluster-one"),
+		),
+		ops.NotContains(allocation.AllocationFieldServices, "service-a"),
+		ops.NotEq(ops.WithKey(allocation.AllocationFieldLabel, "app"), "cost-analyzer"),
+		ops.Contains(allocation.AllocationFieldLabel, "foo"),
+	)
+
+	comparisonTree := &ast.AndOp{
+		Operands: []ast.FilterNode{
+			&ast.OrOp{
+				Operands: []ast.FilterNode{
+					&ast.EqualOp{
+						Left: ast.Identifier{
+							Field: ast.NewField(allocation.AllocationFieldNamespace),
+							Key:   "",
+						},
+						Right: "kubecost",
+					},
+					&ast.EqualOp{
+						Left: ast.Identifier{
+							Field: ast.NewField(allocation.AllocationFieldClusterID),
+							Key:   "",
+						},
+						Right: "cluster-one",
+					},
+				},
+			},
+			&ast.NotOp{
+				Operand: &ast.ContainsOp{
+					Left: ast.Identifier{
+						Field: ast.NewSliceField(allocation.AllocationFieldServices),
+						Key:   "",
+					},
+					Right: "service-a",
+				},
+			},
+			&ast.NotOp{
+				Operand: &ast.EqualOp{
+					Left: ast.Identifier{
+						Field: ast.NewMapField(allocation.AllocationFieldLabel),
+						Key:   "app",
+					},
+					Right: "cost-analyzer",
+				},
+			},
+			&ast.ContainsOp{
+				Left: ast.Identifier{
+					Field: ast.NewMapField(allocation.AllocationFieldLabel),
+					Key:   "",
+				},
+				Right: "foo",
+			},
+		},
+	}
+
+	if !cmp.Equal(filterTree, comparisonTree) {
+		t.Fatalf("Filter Trees are not equal: %s", cmp.Diff(filterTree, comparisonTree))
+	}
+}

+ 75 - 0
pkg/kubecost/allocationmatcher.go

@@ -0,0 +1,75 @@
+package kubecost
+
+import (
+	"fmt"
+
+	allocationfilter "github.com/opencost/opencost/pkg/filter21/allocation"
+	"github.com/opencost/opencost/pkg/filter21/ast"
+	"github.com/opencost/opencost/pkg/filter21/matcher"
+	"github.com/opencost/opencost/pkg/filter21/transform"
+)
+
+// AllocationMatcher is a matcher implementation for Allocation instances,
+// compiled using the matcher.MatchCompiler for allocations.
+type AllocationMatcher matcher.Matcher[*Allocation]
+
+// NewAllocationMatchCompiler creates a new instance of a matcher.MatchCompiler[*Allocation]
+// which can be used to compile filter.Filter ASTs into matcher.Matcher[*Allocation]
+// implementations.
+func NewAllocationMatchCompiler() *matcher.MatchCompiler[*Allocation] {
+	return matcher.NewMatchCompiler(
+		allocationFieldMap,
+		allocationSliceFieldMap,
+		allocationMapFieldMap,
+		transform.PrometheusKeySanitizePass(),
+		transform.UnallocatedReplacementPass())
+}
+
+// Maps fields from an allocation to a string value based on an identifier
+func allocationFieldMap(a *Allocation, identifier ast.Identifier) (string, error) {
+	switch allocationfilter.AllocationField(identifier.Field.Name) {
+	case allocationfilter.AllocationFieldNamespace:
+		return a.Properties.Namespace, nil
+	case allocationfilter.AllocationFieldNode:
+		return a.Properties.Node, nil
+	case allocationfilter.AllocationFieldClusterID:
+		return a.Properties.Cluster, nil
+	case allocationfilter.AllocationFieldControllerName:
+		return a.Properties.Controller, nil
+	case allocationfilter.AllocationFieldControllerKind:
+		return a.Properties.ControllerKind, nil
+	case allocationfilter.AllocationFieldPod:
+		return a.Properties.Pod, nil
+	case allocationfilter.AllocationFieldContainer:
+		return a.Properties.Container, nil
+	case allocationfilter.AllocationFieldProvider:
+		return a.Properties.ProviderID, nil
+	case allocationfilter.AllocationFieldLabel:
+		return a.Properties.Labels[identifier.Key], nil
+	case allocationfilter.AllocationFieldAnnotation:
+		return a.Properties.Annotations[identifier.Key], nil
+	}
+
+	return "", fmt.Errorf("Failed to find string identifier on Allocation: %s", identifier.Field.Name)
+}
+
+// Maps slice fields from an allocation to a []string value based on an identifier
+func allocationSliceFieldMap(a *Allocation, identifier ast.Identifier) ([]string, error) {
+	switch allocationfilter.AllocationField(identifier.Field.Name) {
+	case allocationfilter.AllocationFieldServices:
+		return a.Properties.Services, nil
+	}
+
+	return nil, fmt.Errorf("Failed to find []string identifier on Allocation: %s", identifier.Field.Name)
+}
+
+// Maps map fields from an allocation to a map[string]string value based on an identifier
+func allocationMapFieldMap(a *Allocation, identifier ast.Identifier) (map[string]string, error) {
+	switch allocationfilter.AllocationField(identifier.Field.Name) {
+	case allocationfilter.AllocationFieldLabel:
+		return a.Properties.Labels, nil
+	case allocationfilter.AllocationFieldAnnotation:
+		return a.Properties.Annotations, nil
+	}
+	return nil, fmt.Errorf("Failed to find map[string]string identifier on Allocation: %s", identifier.Field.Name)
+}

+ 30 - 0
pkg/util/typeutil/typeutil.go

@@ -0,0 +1,30 @@
+package typeutil
+
+import (
+	"fmt"
+	"reflect"
+)
+
+// TypeOf is a utility that can covert a T type to a package + type name for generic types.
+func TypeOf[T any]() string {
+	var inst T
+	var prefix string
+
+	// get a reflect.Type of a variable with type T
+	t := reflect.TypeOf(inst)
+
+	// pointer types do not carry the adequate type information, so we need to extract the
+	// underlying types until we reach the non-pointer type, we prepend a * each depth
+	for t != nil && t.Kind() == reflect.Pointer {
+		prefix += "*"
+		t = t.Elem()
+	}
+
+	// this should not be possible, but in the event that it does, we want to be loud about it
+	if t == nil {
+		panic(fmt.Sprintf("Unable to generate a key for type: %+v", reflect.TypeOf(inst)))
+	}
+
+	// combine the prefix, package path, and the type name
+	return fmt.Sprintf("%s%s/%s", prefix, t.PkgPath(), t.Name())
+}