فهرست منبع

Add filter v2.1 scaffolding (#1970)

This introduces just the scaffolding (AST, compiler, utils, etc.) from
https://github.com/opencost/opencost/pull/1762/files to a new package:
pkg/filter21. Implementation will roughly follow the following plan:

1. Scaffolding
2. Allocation implementation for filter21
3. Switchover of Allocation types/query to filter21 Matcher interface
4. Repeat 2+3 for Asset
5. Repeat 2+3 for CloudCost

Signed-off-by: Michael Dresser <michaelmdresser@gmail.com>
Michael Dresser 2 سال پیش
والد
کامیت
05e5af2133

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

@@ -0,0 +1,70 @@
+package ast
+
+// FieldType is an enumeration of specific types relevant to lexing and
+// parsing a filter.
+type FieldType int
+
+const (
+	FieldTypeDefault FieldType = iota
+	FieldTypeSlice
+	FieldTypeMap
+	FieldTypeAlias
+)
+
+// Field is a Lexer input which acts as a mapping of identifiers used to lex/parse filters.
+type Field struct {
+	// Name contains the name of the specific field as it appears in language.
+	Name string
+
+	fieldType 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 {
+	return f.fieldType == FieldTypeSlice
+}
+
+// IsMap returns true if the field is a map. This instructs the lexer that the field should
+// allow keyed-access operations.
+func (f *Field) IsMap() bool {
+	return f.fieldType == FieldTypeMap
+}
+
+// IsAlias returns true if the field is an alias type. This instructs the lexer that the field
+// is an alias for custom logical resolution by an external compiler.
+func (f *Field) IsAlias() bool {
+	return f.fieldType == FieldTypeAlias
+}
+
+// NewField creates a default string field using the provided name.
+func NewField[T ~string](name T) *Field {
+	return &Field{
+		Name:      string(name),
+		fieldType: FieldTypeDefault,
+	}
+}
+
+// NewSliceField creates a slice field using the provided name.
+func NewSliceField[T ~string](name T) *Field {
+	return &Field{
+		Name:      string(name),
+		fieldType: FieldTypeSlice,
+	}
+}
+
+// NewMapField creates a new map field using the provided name.
+func NewMapField[T ~string](name T) *Field {
+	return &Field{
+		Name:      string(name),
+		fieldType: FieldTypeMap,
+	}
+}
+
+// NewAliasField creates a new alias field using the provided name.
+func NewAliasField[T ~string](name T) *Field {
+	return &Field{
+		Name:      string(name),
+		fieldType: FieldTypeAlias,
+	}
+}

+ 342 - 0
pkg/filter21/ast/lexer.go

@@ -0,0 +1,342 @@
+package ast
+
+import (
+	"fmt"
+
+	multierror "github.com/hashicorp/go-multierror"
+)
+
+// ============================================================================
+// 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                   // '+'
+	or                     // '|'
+
+	bangColon           // '!:'
+	tildeColon          // '~:'
+	bangTildeColon      // '!~:'
+	startTildeColon     // '<~:'
+	bangStartTildeColon // '!<~:'
+	tildeEndColon       // '~>:'
+	bangTildeEndColon   // '!~>:'
+
+	parenOpen  // '('
+	parenClose // ')'
+
+	str // '"foo"'
+
+	filterField // 'namespace', 'cluster'
+	mapField    // 'label', 'annotation'
+	keyedAccess // '[app]', '[foo]', etc.
+	identifier  // K8s valid name + sanitized Prom: 'app', 'abc_label'
+
+	eof
+)
+
+func (tk tokenKind) String() string {
+	switch tk {
+	case colon:
+		return "colon"
+	case comma:
+		return "comma"
+	case plus:
+		return "plus"
+	case or:
+		return "or"
+	case bangColon:
+		return "bangColon"
+	case tildeColon:
+		return "tildeColon"
+	case bangTildeColon:
+		return "bangTildeColon"
+	case startTildeColon:
+		return "startTildeColon"
+	case bangStartTildeColon:
+		return "bangStartTildeColon"
+	case tildeEndColon:
+		return "tildeEndColon"
+	case bangTildeEndColon:
+		return "bangTildeEndColon"
+	case parenOpen:
+		return "parenOpen"
+	case parenClose:
+		return "parenClose"
+	case str:
+		return "str"
+	case filterField:
+		return "filterField1"
+	case mapField:
+		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
+
+	fields    map[string]*Field
+	mapFields map[string]*Field
+
+	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 '|':
+		s.addToken(or)
+	case '!':
+		if s.match(':') {
+			s.addToken(bangColon)
+		} else if s.match('~') {
+			if s.match(':') {
+				s.addToken(bangTildeColon)
+			} else if s.match('>') {
+				if s.match(':') {
+					s.addToken(bangTildeEndColon)
+				} else {
+					s.errors = append(s.errors, fmt.Errorf("Position %d: Unexpected '>'", s.nextByte-1))
+				}
+			} else {
+				s.errors = append(s.errors, fmt.Errorf("Position %d: Unexpected '~'", s.nextByte-1))
+			}
+		} else if s.match('<') {
+			if s.match('~') {
+				if s.match(':') {
+					s.addToken(bangStartTildeColon)
+				} else {
+					s.errors = append(s.errors, fmt.Errorf("Position %d: Unexpected '~'", s.nextByte-1))
+				}
+			} else {
+				s.errors = append(s.errors, fmt.Errorf("Position %d: Unexpected '<'", s.nextByte-1))
+			}
+		} else {
+			s.errors = append(s.errors, fmt.Errorf("Position %d: Unexpected '!'", s.nextByte-1))
+		}
+	case '(':
+		s.addToken(parenOpen)
+	case ')':
+		s.addToken(parenClose)
+	case '<':
+		if s.match('~') {
+			if s.match(':') {
+				s.addToken(startTildeColon)
+			} else {
+				s.errors = append(s.errors, fmt.Errorf("Position %d: Unexpected '~'", s.nextByte-1))
+			}
+		} else {
+			s.errors = append(s.errors, fmt.Errorf("Position %d: Unexpected '<'", s.nextByte-1))
+		}
+	case '~':
+		if s.match(':') {
+			s.addToken(tildeColon)
+		} else if s.match('>') {
+			if s.match(':') {
+				s.addToken(tildeEndColon)
+			} else {
+				s.errors = append(s.errors, fmt.Errorf("Position %d: Unexpected '>'", s.nextByte-1))
+			}
+		} 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 := s.fields[tokenText]; ok {
+		s.addToken(filterField)
+	} else if _, ok := s.mapFields[tokenText]; ok {
+		s.addToken(mapField)
+	} else {
+		s.addToken(identifier)
+	}
+}
+
+// lex will generate a slice of tokens provided a raw string and the filter field definitions
+func lex(raw string, fields map[string]*Field, mapFields map[string]*Field) ([]token, error) {
+	s := scanner{
+		source:    raw,
+		fields:    fields,
+		mapFields: mapFields,
+	}
+	s.scanTokens()
+
+	if len(s.errors) > 0 {
+		return s.tokens, multierror.Append(nil, s.errors...)
+	}
+
+	return s.tokens, nil
+}

+ 181 - 0
pkg/filter21/ast/lexer_test.go

@@ -0,0 +1,181 @@
+package ast
+
+import (
+	"testing"
+)
+
+var allocFields map[string]*Field = map[string]*Field{
+	"cluster":        NewField("cluster"),
+	"node":           NewField("node"),
+	"namespace":      NewField("namespace"),
+	"controllerName": NewField("controllerName"),
+	"controllerKind": NewField("controllerKind"),
+	"container":      NewField("container"),
+	"pod":            NewField("pod"),
+	"services":       NewSliceField("services"),
+}
+
+var allocMapFields map[string]*Field = map[string]*Field{
+	"label":      NewMapField("label"),
+	"annotation": NewMapField("annotation"),
+}
+
+func TestLexerGroup(t *testing.T) {
+	tokens, err := lex(
+		`cluster:"cluster-one"+namespace:"kubecost"+(controllerKind!:"daemonset","deployment")+controllerName:"kubecost-network-costs"+container:"kubecost-network-costs"`,
+		allocFields,
+		allocMapFields)
+
+	if err != nil {
+		t.Errorf("Error: %s", err)
+	}
+
+	for _, token := range tokens {
+		t.Logf("%s", token)
+	}
+}
+
+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:     "or",
+			input:    "|",
+			expected: []token{{kind: or, s: "|"}, {kind: eof}},
+		},
+		{
+			name:     "bangColon",
+			input:    "!:",
+			expected: []token{{kind: bangColon, s: "!:"}, {kind: eof}},
+		},
+		{
+			name:     "tildeColon",
+			input:    "~:",
+			expected: []token{{kind: tildeColon, s: "~:"}, {kind: eof}},
+		},
+		{
+			name:     "bangTildeColon",
+			input:    "!~:",
+			expected: []token{{kind: bangTildeColon, s: "!~:"}, {kind: eof}},
+		},
+		{
+			name:     "startTildeColon",
+			input:    "<~:",
+			expected: []token{{kind: startTildeColon, s: "<~:"}, {kind: eof}},
+		},
+		{
+			name:     "bangStartTildeColon",
+			input:    "!<~:",
+			expected: []token{{kind: bangStartTildeColon, s: "!<~:"}, {kind: eof}},
+		},
+		{
+			name:     "tildeEndColon",
+			input:    "~>:",
+			expected: []token{{kind: tildeEndColon, s: "~>:"}, {kind: eof}},
+		},
+		{
+			name:     "bangTildeEndColon",
+			input:    "!~>:",
+			expected: []token{{kind: bangTildeEndColon, s: "!~>:"}, {kind: eof}},
+		},
+		{
+			name: "multiple symbols",
+			// This is a valid string to parse but not to lex
+			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: filterField, s: "node"},
+				{kind: colon, s: ":"},
+				{kind: str, s: "abc"},
+				{kind: comma, s: ","},
+				{kind: str, s: "def"},
+				{kind: filterField, 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 := lex(c.input, allocFields, allocMapFields)
+			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)
+					}
+				}
+			}
+		})
+	}
+}

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

@@ -0,0 +1,182 @@
+package ast
+
+// 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 (
+	// FilterOpEquals is the equality operator
+	//
+	// "kube-system" FilterOpEquals "kube-system" = true
+	// "kube-syste" FilterOpEquals "kube-system" = false
+	FilterOpEquals FilterOp = "equals"
+
+	// FilterOpNotEquals is the inverse of equals.
+	FilterOpNotEquals = "notequals"
+
+	// FilterOpContains supports string fields, slice fields, and map fields.
+	// For maps, this is equivalent to map.HasKey(x)
+	//
+	// "kube-system" FilterOpContains "e-s" = true
+	// ["a", "b", "c"] FilterOpContains "a" = true
+	// { "namespace": "kubecost", "cluster": "cluster-one" } FilterOpContains "namespace" = true
+	FilterOpContains = "contains"
+
+	// FilterOpNotContains is the inverse of contains.
+	FilterOpNotContains = "notcontains"
+
+	// FilterOpContainsPrefix is like FilterOpContains, but checks against the start of a string.
+	// For maps, this checks to see if any of the keys start with the prefix
+	//
+	// "kube-system" ContainsPrefix "kube" = true
+	// ["kube-system", "abc123"] ContainsPrefix "kube" = true
+	// { "kube-label": "test", "abc": "123" } ContainsPrefix "ab" = true
+	FilterOpContainsPrefix = "containsprefix"
+
+	// FilterOpNotContainsPrefix is the inverse of FilterOpContainsPrefix
+	FilterOpNotContainsPrefix = "notcontainsprefix"
+
+	// FilterOpContainsSuffix is like FilterOpContains, but checks against the end of a string.
+	// For maps, this checks to see if any of the keys end with the suffix
+	//
+	// "kube-system" ContainsSuffix "system" = true
+	// ["kube-system", "abc123"] ContainsSuffix "system" = true
+	// { "kube-label": "test", "abc": "123" } ContainsSuffix "-label" = true
+	FilterOpContainsSuffix = "containssuffix"
+
+	// FilterOpNotContainsSuffix is the inverse of FilterOpContainsSuffix
+	FilterOpNotContainsSuffix = "notcontainssuffix"
+
+	// FilterOpVoid is base-depth operator that is used for an empty filter
+	FilterOpVoid = "void"
+
+	// FilterOpAnd is an operator that succeeds if all parameters succeed.
+	FilterOpAnd = "and"
+
+	// FilterOpOr is an operator that succeeds if any parameter succeeds
+	FilterOpOr = "or"
+
+	// FilterOpNot is an operator that contains a single operand
+	FilterOpNot = "not"
+)
+
+// VoidOp is base-depth operator that is used for an empty filter
+type VoidOp struct{}
+
+// Op returns the FilterOp enumeration value for the operator.
+func (_ *VoidOp) Op() FilterOp {
+	return FilterOpVoid
+}
+
+// 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 {
+	Operands []FilterNode
+}
+
+// Op returns the FilterOp enumeration value for the operator.
+func (_ *AndOp) Op() FilterOp {
+	return FilterOpAnd
+}
+
+// Add appends a filter node to the flat list of operands within the AND operator
+func (ao *AndOp) Add(node FilterNode) {
+	ao.Operands = append(ao.Operands, node)
+}
+
+// OrOp is a filter operation that contains a flat list of nodes which at least one node
+// should resolve to true in order for the result to be true.
+type OrOp struct {
+	Operands []FilterNode
+}
+
+// Op returns the FilterOp enumeration value for the operator.
+func (_ *OrOp) Op() FilterOp {
+	return FilterOpOr
+}
+
+// Add appends a filter node to the flat list of operands within the OR operator
+func (oo *OrOp) Add(node FilterNode) {
+	oo.Operands = append(oo.Operands, node)
+}
+
+// NotOp is a filter operation that logically inverts result of the child operand.
+type NotOp struct {
+	Operand FilterNode
+}
+
+// Op returns the FilterOp enumeration value for the operator.
+func (_ *NotOp) Op() FilterOp {
+	return FilterOpNot
+}
+
+// Add sets the not operand to the parameter
+func (no *NotOp) Add(node FilterNode) {
+	no.Operand = node
+}
+
+// EqualOp is a filter operation that compares a resolvable identifier (Left) to a
+// string value (Right)
+type EqualOp struct {
+	// Left contains a resolvable Identifier (property of an input type) which can be
+	// used to compare against the Right value.
+	Left Identifier
+
+	// Right contains the value which we wish to compare the resolved identifier to.
+	Right string
+}
+
+// Op returns the FilterOp enumeration value for the operator.
+func (_ *EqualOp) Op() FilterOp {
+	return FilterOpEquals
+}
+
+// ContainsOp is a filter operation that checks to see if a resolvable identifier (Left) contains a
+// string value (Right)
+type ContainsOp struct {
+	// Left contains a resolvable Identifier (property of an input type) which can be
+	// used to query against using the Right value.
+	Left Identifier
+
+	// Right contains the value which we use to search the resolved Left identifier with.
+	Right string
+}
+
+// Op returns the FilterOp enumeration value for the operator.
+func (_ *ContainsOp) Op() FilterOp {
+	return FilterOpContains
+}
+
+// ContainsPrefixOp is a filter operation that checks to see if a resolvable identifier (Left) starts with a
+// string value (Right)
+type ContainsPrefixOp struct {
+	// Left contains a resolvable Identifier (property of an input type) which can be
+	// used to query against using the Right value.
+	Left Identifier
+
+	// Right contains the value which we use to search the resolved Left identifier with.
+	Right string
+}
+
+// Op returns the FilterOp enumeration value for the operator.
+func (_ *ContainsPrefixOp) Op() FilterOp {
+	return FilterOpContainsPrefix
+}
+
+// ContainsSuffixOp is a filter operation that checks to see if a resolvable identifier (Left) ends with a
+// string value (Right)
+type ContainsSuffixOp struct {
+	// Left contains a resolvable Identifier (property of an input type) which can be
+	// used to query against using the Right value.
+	Left Identifier
+
+	// Right contains the value which we use to search the resolved Left identifier with.
+	Right string
+}
+
+// Op returns the FilterOp enumeration value for the operator.
+func (_ *ContainsSuffixOp) Op() FilterOp {
+	return FilterOpContainsSuffix
+}

+ 589 - 0
pkg/filter21/ast/parser.go

@@ -0,0 +1,589 @@
+// allocationfilterutil provides functionality for parsing V2 of the Kubecost
+// filter language for Allocation types.
+//
+// e.g. "filter=namespace:kubecost+controllerkind:deployment"
+package ast
+
+import (
+	"fmt"
+
+	"github.com/hashicorp/go-multierror"
+)
+
+// The grammar is approximately as follows:
+//
+// <filter>         ::= <filter-element> (<group-op> <filter-element>)*
+// <filter-element> ::= <comparison> | <group-filter>
+// <group-filter>   ::= '(' <filter> ')'
+// <group-op>       ::= '+' | '|'
+// <comparison>     ::= <filter-key> <filter-op> <filter-value>
+// <filter-key>     ::= <map-field> <keyed-access> | <filter-field>
+// <filter-op>      ::= ':' | '!:' | '~:' | '!~:' | '<~:' | '!<~:' | '~>:' | '!~>:'
+// <filter-value>   ::= '"' <identifier> '"' (',' <filter-value>)*
+// <keyed-access>   ::= '[' <identifier> ']'
+// <map-field>      ::= --- (fields passed into lexer)
+// <filter-field>   ::= --- (fields passed into lexer)
+// <identifier>     ::= --- valid K8s name or Prom-sanitized K8s name
+
+// ============================================================================
+// 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
+
+	fields    map[string]*Field
+	mapFields map[string]*Field
+}
+
+// ----------------------------------------------------------------------------
+// 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 tokenKind, indicating the
+// start of a new (plus, or, or parenClose).
+func (p *parser) synchronize(tokens ...tokenKind) {
+	if len(tokens) == 0 {
+		return
+	}
+
+	for !p.atEnd() {
+		kind := p.peek().kind
+		for _, token := range tokens {
+			if kind == token {
+				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
+// FilterNode tree, reporting parse errors that occurred along the way. The depth
+// parameter is the number of edges from the node to the tree's root node, which
+// is initially 0. As we recurse into the tree, the depth will increase.
+func (p *parser) filter(depth int) (FilterNode, error) {
+	var errs *multierror.Error
+
+	// ----------------------------------------------------------------------------
+	//  Capture Starting Op
+	// ----------------------------------------------------------------------------
+	// Since every valid filter starts with an operand, this is always our first
+	// step. Depending on the _next_ token, we can either stop here or use a grouping
+	// operator (+ or |).
+	var top FilterNode
+
+	// If we determine after parsing the first op that we have a group op, we'll create
+	// the group based on the operator and push the top into the group.
+	var f FilterGroup = nil
+
+	// Special Case: Empty Filter on depth = 0 and first token is eof
+	if depth == 0 && p.peek().kind == eof {
+		return &VoidOp{}, errs.ErrorOrNil()
+	}
+
+	// Open Paren indicates a new filter depth, so we recursively call filter with depth+1.
+	if p.match(parenOpen) {
+		node, err := p.filter(depth + 1)
+		if err != nil {
+			errs = multierror.Append(errs, err)
+		} else {
+			top = node
+		}
+	} else {
+		comparison, err := p.comparison()
+		if err != nil {
+			errs = multierror.Append(errs, err)
+			p.synchronize(plus, or, parenClose)
+		} else {
+			top = comparison
+		}
+	}
+
+	// Handles case `( <comparison> )` with no grouping ops.
+	if p.match(parenClose) {
+		if depth <= 0 {
+			errs = multierror.Append(errs, fmt.Errorf("Found ')' without matching '('"))
+		}
+
+		return top, errs.ErrorOrNil()
+	}
+
+	// ----------------------------------------------------------------------------
+	//  Determine Group Operator
+	// ----------------------------------------------------------------------------
+	// Once we land here, we expect an operator as the next token. This operator will
+	// determine the group for this scope and be used to continue parsing as long as
+	// the operators following the initial are _the same_.
+	//
+	// For instance:
+	// ( <comparison> | <comparison> | <comparison> ) is allowed
+	// ( <comparison> + <comparison> + (<comparison> | <comparison>)) is allowed
+	// ( <comparison> | <comparison> + <comparison> ) is _NOT_ allowed
+
+	// Create the proper grouping operator based on the current token kind,
+	// then use a while to capture each repition of the _same_ operator.
+	selectedOp := p.peek().kind
+	if selectedOp == plus || selectedOp == or {
+		if selectedOp == plus {
+			f = &AndOp{}
+		} else if selectedOp == or {
+			f = &OrOp{}
+		}
+
+		// Once we determine we are using a group operator, it's safe to push
+		// the current top level operand into the group
+		f.Add(top)
+
+		// Capture each repetition
+		for p.match(selectedOp) {
+			if p.match(parenOpen) {
+				node, err := p.filter(depth + 1)
+				if err != nil {
+					errs = multierror.Append(errs, err)
+				} else {
+					f.Add(node)
+				}
+			} else {
+				right, err := p.comparison()
+				if err != nil {
+					errs = multierror.Append(errs, err)
+					p.synchronize(plus, or, parenClose)
+				} else {
+					f.Add(right)
+				}
+			}
+
+			if p.match(parenClose) {
+				if depth <= 0 {
+					errs = multierror.Append(errs, fmt.Errorf("Found ')' without matching '('"))
+				}
+
+				return f, errs.ErrorOrNil()
+			}
+
+			// The following code enforces continued use of a single operator within a scope.
+			// ie: (a | b + c) is disallowed
+			//
+			// In order to continue parsing (to continue to collect parse errors), we need to fast-
+			// forward to the next instance of an operator or scope close.
+			nextOp := p.peek().kind
+			if nextOp != eof && nextOp != selectedOp {
+				errs = multierror.Append(errs, fmt.Errorf("Found \"%s\", Expected \"%s\"", nextOp.String(), selectedOp.String()))
+				// since we were peeking for this check, to correctly synchronize, we must advance at least once
+				p.advance()
+				p.synchronize(plus, or, parenClose)
+
+				// since it's possible to synchronize to a paren close, we need to ensure we correctly pop the
+				// current scope if that's the case.
+				if p.match(parenClose) {
+					return f, errs.ErrorOrNil()
+				}
+			}
+		}
+	}
+
+	// It should not be possible to reach this point on a non-zero depth, so we
+	// must have a () mismatch
+	if depth > 0 {
+		errs = multierror.Append(errs, fmt.Errorf("Found '(' without matching ')'"))
+	}
+
+	// If we didn't have a grouping operator, we simply return the single op
+	if f == nil {
+		return top, errs.ErrorOrNil()
+	}
+
+	return f, errs.ErrorOrNil()
+}
+
+func (p *parser) comparison() (FilterNode, error) {
+	field, key, err := p.filterKey()
+	if err != nil {
+		return nil, err
+	}
+
+	opToken, err := p.filterOp()
+	if err != nil {
+		return nil, err
+	}
+
+	var op FilterOp
+
+	switch opToken.kind {
+	case colon:
+		// for ':' using a slice or key-less map, treat as '~:'
+		if field.IsSlice() || (field.IsMap() && key == "") {
+			op = FilterOpContains
+		} else {
+			op = FilterOpEquals
+		}
+	case bangColon:
+		// for '!:' using a slice or key-less map, treat as '!~:'
+		if field.IsSlice() || (field.IsMap() && key == "") {
+			op = FilterOpNotContains
+		} else {
+			op = FilterOpNotEquals
+		}
+	case tildeColon:
+		op = FilterOpContains
+	case bangTildeColon:
+		op = FilterOpNotContains
+	case startTildeColon:
+		op = FilterOpContainsPrefix
+	case bangStartTildeColon:
+		op = FilterOpNotContainsPrefix
+	case tildeEndColon:
+		op = FilterOpContainsSuffix
+	case bangTildeEndColon:
+		op = FilterOpNotContainsSuffix
+	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, bangTildeColon, bangStartTildeColon, bangTildeEndColon:
+		// Only a single filter value, don't need to wrap in AND
+		if len(values) == 1 {
+			node, err := toFilterNode(field, key, op, values[0])
+			if err != nil {
+				return nil, fmt.Errorf("Parse Error: %s", err)
+			}
+
+			return node, nil
+		}
+
+		// Multiple filter values, wrap in AND
+		baseFilter := &AndOp{}
+		for _, v := range values {
+			node, err := toFilterNode(field, key, op, v)
+			if err != nil {
+				return nil, fmt.Errorf("Parse Error: %s", err)
+			}
+
+			baseFilter.Operands = append(baseFilter.Operands, node)
+		}
+
+		return baseFilter, nil
+
+	default:
+		// Only a single filter value, don't need to wrap in OR
+		if len(values) == 1 {
+			node, err := toFilterNode(field, key, op, values[0])
+			if err != nil {
+				return nil, fmt.Errorf("Parse Error: %s", err)
+			}
+
+			return node, nil
+		}
+
+		// Multiple filter values, wrap in OR
+		baseFilter := &OrOp{}
+		for _, v := range values {
+			node, err := toFilterNode(field, key, op, v)
+			if err != nil {
+				return nil, fmt.Errorf("Parse Error: %s", err)
+			}
+
+			baseFilter.Operands = append(baseFilter.Operands, node)
+		}
+
+		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 *Field, key string, err error) {
+	if p.match(mapField) {
+		rawField := p.previous().s
+		mappedField, ok := p.mapFields[rawField]
+		if !ok {
+			return nil, "", parseError(p.previous(), "expect key-mapped filter field, like 'label' or 'annotation'")
+		}
+
+		// keyed-access is optional after a map field
+		if p.match(keyedAccess) {
+			key = p.previous().s
+		} else {
+			key = ""
+		}
+
+		return mappedField, key, nil
+	}
+
+	_, err = p.consume(filterField, "expect filter field")
+	if err != nil {
+		return nil, "", err
+	}
+
+	rawField := p.previous().s
+	mappedField, ok := p.fields[rawField]
+	if !ok {
+		return nil, "", parseError(p.previous(), "expect known filter field, like 'cluster' or 'namespace'")
+	}
+
+	return mappedField, "", nil
+}
+
+func (p *parser) filterOp() (token, error) {
+	if p.match(colon, bangColon, tildeColon, bangTildeColon, startTildeColon, bangStartTildeColon, tildeEndColon, bangTildeEndColon) {
+		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
+}
+
+func toFilterNode(field *Field, key string, op FilterOp, value string) (FilterNode, error) {
+	switch op {
+	case FilterOpEquals:
+		return &EqualOp{
+			Left: Identifier{
+				Field: field,
+				Key:   key,
+			},
+			Right: value,
+		}, nil
+
+	case FilterOpNotEquals:
+		return &NotOp{
+			Operand: &EqualOp{
+				Left: Identifier{
+					Field: field,
+					Key:   key,
+				},
+				Right: value,
+			},
+		}, nil
+
+	case FilterOpContains:
+		return &ContainsOp{
+			Left: Identifier{
+				Field: field,
+				Key:   key,
+			},
+			Right: value,
+		}, nil
+
+	case FilterOpNotContains:
+		return &NotOp{
+			Operand: &ContainsOp{
+				Left: Identifier{
+					Field: field,
+					Key:   key,
+				},
+				Right: value,
+			},
+		}, nil
+
+	case FilterOpContainsPrefix:
+		return &ContainsPrefixOp{
+			Left: Identifier{
+				Field: field,
+				Key:   key,
+			},
+			Right: value,
+		}, nil
+
+	case FilterOpNotContainsPrefix:
+		return &NotOp{
+			Operand: &ContainsPrefixOp{
+				Left: Identifier{
+					Field: field,
+					Key:   key,
+				},
+				Right: value,
+			},
+		}, nil
+
+	case FilterOpContainsSuffix:
+		return &ContainsSuffixOp{
+			Left: Identifier{
+				Field: field,
+				Key:   key,
+			},
+			Right: value,
+		}, nil
+
+	case FilterOpNotContainsSuffix:
+		return &NotOp{
+			Operand: &ContainsSuffixOp{
+				Left: Identifier{
+					Field: field,
+					Key:   key,
+				},
+				Right: value,
+			},
+		}, nil
+
+	default:
+		return nil, fmt.Errorf("Failed to parse op: %s", op)
+	}
+}
+
+// FilterParser is an object capable of parsing a filter string into a `FilterNode`
+// AST
+type FilterParser interface {
+	// Parse parses a filter string into a FilterNode AST.
+	Parse(filter string) (FilterNode, error)
+}
+
+// default implementation of FilterParser
+type defaultFilterParser struct {
+	fields    map[string]*Field
+	mapFields map[string]*Field
+}
+
+// Parse parses a filter string into a FilterNode AST.
+func (dfp *defaultFilterParser) Parse(filter string) (FilterNode, error) {
+	tokens, err := lex(filter, dfp.fields, dfp.mapFields)
+	if err != nil {
+		return nil, fmt.Errorf("lexing filter: %w", err)
+	}
+
+	p := parser{
+		tokens:    tokens,
+		fields:    dfp.fields,
+		mapFields: dfp.mapFields,
+	}
+
+	parsedFilter, err := p.filter(0)
+	if err != nil {
+		return nil, fmt.Errorf("parsing filter: %w", err)
+	}
+
+	return parsedFilter, nil
+}
+
+// splits a slice of Field instances into a map of fields (key'd by name) that have no key-based
+// access and those that have key-based access.
+func fieldsToMaps(fs []*Field) (fields map[string]*Field, mapFields map[string]*Field) {
+	fields = make(map[string]*Field)
+	mapFields = make(map[string]*Field)
+
+	for _, f := range fs {
+		if f.IsMap() {
+			mapFields[f.Name] = f
+		} else {
+			fields[f.Name] = f
+		}
+	}
+	return
+}
+
+// NewFilterParser creates a new `FilterParser` instance with the provided `Field` definitions to
+// use when lexing and parsing.
+func NewFilterParser(fields []*Field) FilterParser {
+	f, m := fieldsToMaps(fields)
+
+	return &defaultFilterParser{
+		fields:    f,
+		mapFields: m,
+	}
+}

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

@@ -0,0 +1,33 @@
+package ast
+
+// FilterNode is the the base instance of a tree leaf node, which is a conditional operator
+// which contains operands that may also be leaf nodes. A go type-switch should be used to
+// reduce the FilterNode to a concrete type to operate on. If only the type of operator is
+// required, the `Op()` field can be used.
+type FilterNode interface {
+	Op() FilterOp
+}
+
+// FilterGroup is a specialized interface for ops which can collect N operands.
+type FilterGroup interface {
+	FilterNode
+
+	// Adds a new leaf node to the FilterGroup
+	Add(FilterNode)
+}
+
+// Identifier is a struct that contains the data required to resolve a specific operand to a concrete
+// value during operator compilation.
+type Identifier struct {
+	Field *Field
+	Key   string
+}
+
+// String returns the string representation for the Identifier
+func (id *Identifier) String() string {
+	s := id.Field.Name
+	if id.Key != "" {
+		s += "[" + id.Key + "]"
+	}
+	return s
+}

+ 311 - 0
pkg/filter21/ast/walker.go

@@ -0,0 +1,311 @@
+package ast
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/opencost/opencost/pkg/filter21/util"
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
+)
+
+// used to apply a title to pipeline
+var titleCaser cases.Caser = cases.Title(language.Und, cases.NoLower)
+var lowerCaser cases.Caser = cases.Lower(language.Und)
+
+// TraversalState represents the state of the current leaf node in a traversal
+// of the filter  Any grouping ops will include an Enter on their first
+// occurence, and an Exit when leaving the op state.
+type TraversalState int
+
+const (
+	// TraversalStateNone is used whenever a binary op leaf node is traversed.
+	TraversalStateNone TraversalState = iota
+
+	// TraversalStateEnter is used when a group op leaf node is traversed (and, or, not)
+	TraversalStateEnter
+
+	// TraversalStateExit is used wwhen a group op leaf node is popped (and, or, not).
+	TraversalStateExit
+)
+
+// PreOrderTraversal accepts a root `FilterNode` and calls the f callback on each leaf node it traverses.
+// When entering "group" leaf nodes (leaf nodes which contain other leaf nodes), a TraversalStateEnter/Exit
+// will be includes to denote each depth. In short, the callback will be executed twice for each "group" op,
+// once before entering, and once bofore exiting.
+func PreOrderTraversal(node FilterNode, f func(FilterNode, TraversalState)) {
+	if node == nil {
+		return
+	}
+
+	// For group ops, we need to execute the callback with an Enter,
+	// recursively call traverse, then execute the callback with an Exit.
+	switch n := node.(type) {
+	case *NotOp:
+		f(node, TraversalStateEnter)
+		PreOrderTraversal(n.Operand, f)
+		f(node, TraversalStateExit)
+
+	case *AndOp:
+		f(node, TraversalStateEnter)
+		for _, o := range n.Operands {
+			PreOrderTraversal(o, f)
+		}
+		f(node, TraversalStateExit)
+
+	case *OrOp:
+		f(node, TraversalStateEnter)
+		for _, o := range n.Operands {
+			PreOrderTraversal(o, f)
+		}
+		f(node, TraversalStateExit)
+
+	// Otherwise, we just linearly traverse
+	default:
+		f(node, TraversalStateNone)
+	}
+
+}
+
+// ToPreOrderString runs a PreOrderTraversal and generates an indented tree structure string
+// format for the provided tree root.
+func ToPreOrderString(node FilterNode) string {
+	var sb strings.Builder
+	indent := 0
+
+	printNode := func(n FilterNode, action TraversalState) {
+		if action == TraversalStateEnter {
+			sb.WriteString(OpStringFor(n, action, indent))
+			indent++
+		} else if action == TraversalStateExit {
+			indent--
+			sb.WriteString(OpStringFor(n, action, indent))
+		} else {
+			sb.WriteString(OpStringFor(n, action, indent))
+		}
+	}
+
+	PreOrderTraversal(node, printNode)
+
+	return sb.String()
+}
+
+// ToPreOrderShortString runs a PreOrderTraversal and generates a condensed tree structure string
+// format for the provided tree root.
+func ToPreOrderShortString(node FilterNode) string {
+	var sb strings.Builder
+
+	printNode := func(n FilterNode, action TraversalState) {
+		if action == TraversalStateEnter {
+			sb.WriteString(ShortOpStringFor(n, action))
+		} else if action == TraversalStateExit {
+			sb.WriteString(ShortOpStringFor(n, action))
+		} else {
+			sb.WriteString(ShortOpStringFor(n, action))
+		}
+	}
+
+	PreOrderTraversal(node, printNode)
+
+	return sb.String()
+}
+
+// OpStringFor returns a string for the provided leaf node, traversal state, and current
+// depth.
+func OpStringFor(node FilterNode, traversalState TraversalState, depth int) string {
+	prefix := indent(depth)
+
+	if traversalState == TraversalStateExit {
+		return prefix + "}\n"
+	}
+
+	if traversalState == TraversalStateEnter {
+		return prefix + titleCaser.String(string(node.Op())) + " {\n"
+	}
+
+	open := prefix + titleCaser.String(string(node.Op())) + " { "
+
+	switch n := node.(type) {
+	case *VoidOp:
+		open += ")"
+	case *EqualOp:
+		open += fmt.Sprintf("Left: %s, Right: %s }\n", n.Left.String(), n.Right)
+	case *ContainsOp:
+		open += fmt.Sprintf("Left: %s, Right: %s }\n", n.Left.String(), n.Right)
+	case *ContainsPrefixOp:
+		open += fmt.Sprintf("Left: %s, Right: %s }\n", n.Left.String(), n.Right)
+	case *ContainsSuffixOp:
+		open += fmt.Sprintf("Left: %s, Right: %s }\n", n.Left.String(), n.Right)
+	default:
+		open += "}\n"
+	}
+
+	return open
+}
+
+// ShortOpStringFor returns a condensed string for the provided leaf node, traversal state, and current
+// depth.
+func ShortOpStringFor(node FilterNode, traversalState TraversalState) string {
+	if traversalState == TraversalStateExit {
+		return ")"
+	}
+
+	if traversalState == TraversalStateEnter {
+		return lowerCaser.String(string(node.Op())) + "("
+	}
+
+	open := lowerCaser.String(string(node.Op())) + "("
+
+	switch n := node.(type) {
+	case *VoidOp:
+		open += ")"
+	case *EqualOp:
+		open += fmt.Sprintf("%s,%s)", condenseIdent(n.Left), n.Right)
+	case *ContainsOp:
+		open += fmt.Sprintf("%s,%s)", condenseIdent(n.Left), n.Right)
+	case *ContainsPrefixOp:
+		open += fmt.Sprintf("%s,%s)", condenseIdent(n.Left), n.Right)
+	case *ContainsSuffixOp:
+		open += fmt.Sprintf("%s,%s)", condenseIdent(n.Left), n.Right)
+	default:
+		open += ")"
+	}
+
+	return open
+}
+
+// condenses an identifier string
+func condenseIdent(ident Identifier) string {
+	s := condense(ident.Field.Name)
+	if ident.Key != "" {
+		s += "[" + ident.Key + "]"
+	}
+	return s
+}
+
+func condense(s string) string {
+	lc := lowerCaser.String(s)
+	if len(lc) > 2 {
+		return lc[:2]
+	}
+	return lc
+}
+
+// Clone deep copies and returns the AST parameter.
+func Clone(filter FilterNode) FilterNode {
+	var result FilterNode = &VoidOp{}
+	var currentOps *util.Stack[FilterGroup] = util.NewStack[FilterGroup]()
+
+	PreOrderTraversal(filter, func(fn FilterNode, state TraversalState) {
+		switch n := fn.(type) {
+		case *AndOp:
+			if state == TraversalStateEnter {
+				currentOps.Push(&AndOp{})
+			} else if state == TraversalStateExit {
+				if currentOps.Length() > 1 {
+					current := currentOps.Pop()
+					currentOps.Top().Add(current)
+				} else {
+					result = currentOps.Pop()
+				}
+			}
+		case *OrOp:
+			if state == TraversalStateEnter {
+				currentOps.Push(&OrOp{})
+			} else if state == TraversalStateExit {
+				if currentOps.Length() > 1 {
+					current := currentOps.Pop()
+					currentOps.Top().Add(current)
+				} else {
+					result = currentOps.Pop()
+				}
+			}
+
+		case *NotOp:
+			if state == TraversalStateEnter {
+				currentOps.Push(&NotOp{})
+			} else if state == TraversalStateExit {
+				if currentOps.Length() > 1 {
+					current := currentOps.Pop()
+					currentOps.Top().Add(current)
+				} else {
+					result = currentOps.Pop()
+				}
+			}
+
+		case *EqualOp:
+			var field Field = *n.Left.Field
+			sm := &EqualOp{
+				Left: Identifier{
+					Field: &field,
+					Key:   n.Left.Key,
+				},
+				Right: n.Right,
+			}
+
+			if currentOps.Length() == 0 {
+				result = sm
+			} else {
+				currentOps.Top().Add(sm)
+			}
+
+		case *ContainsOp:
+			var field Field = *n.Left.Field
+			sm := &ContainsOp{
+				Left: Identifier{
+					Field: &field,
+					Key:   n.Left.Key,
+				},
+				Right: n.Right,
+			}
+
+			if currentOps.Length() == 0 {
+				result = sm
+			} else {
+				currentOps.Top().Add(sm)
+			}
+
+		case *ContainsPrefixOp:
+			var field Field = *n.Left.Field
+			sm := &ContainsPrefixOp{
+				Left: Identifier{
+					Field: &field,
+					Key:   n.Left.Key,
+				},
+				Right: n.Right,
+			}
+
+			if currentOps.Length() == 0 {
+				result = sm
+			} else {
+				currentOps.Top().Add(sm)
+			}
+
+		case *ContainsSuffixOp:
+			var field Field = *n.Left.Field
+			sm := &ContainsSuffixOp{
+				Left: Identifier{
+					Field: &field,
+					Key:   n.Left.Key,
+				},
+				Right: n.Right,
+			}
+
+			if currentOps.Length() == 0 {
+				result = sm
+			} else {
+				currentOps.Top().Add(sm)
+			}
+		}
+	})
+
+	return result
+}
+
+// returns an 2-space indention for each depth
+func indent(depth int) string {
+	if depth <= 0 {
+		return ""
+	}
+	return strings.Repeat("  ", depth)
+}

+ 7 - 0
pkg/filter21/filter.go

@@ -0,0 +1,7 @@
+package filter
+
+import "github.com/opencost/opencost/pkg/filter21/ast"
+
+// Filter is just the root node of an AST. There are various compiler implementations
+// available to create data source specific filtering from the AST.
+type Filter = ast.FilterNode

+ 13 - 0
pkg/filter21/matcher/allcut.go

@@ -0,0 +1,13 @@
+package matcher
+
+// AllCut is a matcher that matches nothing. This is useful
+// for applications like authorization, where a user/group/role may be disallowed
+// from viewing data entirely.
+type AllCut[T any] struct{}
+
+// String returns the string representation of the matcher instance
+func (ac *AllCut[T]) String() string { return "(AllCut)" }
+
+// Matches is the canonical in-Go function for determining if T
+// matches a specific implementation's rules.
+func (ac *AllCut[T]) Matches(T) bool { return false }

+ 11 - 0
pkg/filter21/matcher/allpass.go

@@ -0,0 +1,11 @@
+package matcher
+
+// AllPass is a filter that matches everything and is the same as no filter. It is implemented here as a guard
+// against universal operations occurring in the absence of filters.
+type AllPass[T any] struct{}
+
+func (n *AllPass[T]) String() string { return "(AllPass)" }
+
+// Matches is the canonical in-Go function for determining if T
+// matches a specific implementation's rules.
+func (n *AllPass[T]) Matches(T) bool { return true }

+ 42 - 0
pkg/filter21/matcher/and.go

@@ -0,0 +1,42 @@
+package matcher
+
+import (
+	"fmt"
+)
+
+// And is a set of filters that should be evaluated as a logical
+// AND.
+type And[T any] struct {
+	Matchers []Matcher[T]
+}
+
+func (a *And[T]) Add(m Matcher[T]) {
+	a.Matchers = append(a.Matchers, m)
+}
+
+func (a *And[T]) String() string {
+	s := "(and"
+	for _, f := range a.Matchers {
+		s += fmt.Sprintf(" %s", f)
+	}
+
+	s += ")"
+	return s
+}
+
+// Matches is the canonical in-Go function for determining if T
+// matches a AND match rules.
+func (a *And[T]) Matches(that T) bool {
+	filters := a.Matchers
+	if len(filters) == 0 {
+		return true
+	}
+
+	for _, filter := range filters {
+		if !filter.Matches(that) {
+			return false
+		}
+	}
+
+	return true
+}

+ 177 - 0
pkg/filter21/matcher/compiler.go

@@ -0,0 +1,177 @@
+package matcher
+
+import (
+	"github.com/opencost/opencost/pkg/filter21/ast"
+	"github.com/opencost/opencost/pkg/filter21/transform"
+	"github.com/opencost/opencost/pkg/filter21/util"
+)
+
+// FieldMapper is the adapter which can fetch actual T instance data of type U
+// leveraging the ast.Identifier definition.
+type FieldMapper[T any, U any] func(T, ast.Identifier) (U, error)
+
+// StringFieldMapper is the adapter which can fetch actual T instance data of type string
+// leveraging the ast.Identifier definition.
+type StringFieldMapper[T any] FieldMapper[T, string]
+
+// SliceFieldMapper is the adapter which can fetch actual T instance data of type []string
+// leveraging the ast.Identifier definition.
+type SliceFieldMapper[T any] FieldMapper[T, []string]
+
+// SliceFieldMapper is the adapter which can fetch actual T instance data of type map[string]string
+// leveraging the ast.Identifier definition.
+type MapFieldMapper[T any] FieldMapper[T, map[string]string]
+
+// MatchCompiler compiles an `ast.FilterNode` into a Matcher[T] implementation.
+type MatchCompiler[T any] struct {
+	stringMatcher *StringMatcherFactory[T]
+	sliceMatcher  *StringSliceMatcherFactory[T]
+	mapMatcher    *StringMapMatcherFactory[T]
+	passes        []transform.CompilerPass
+}
+
+// NewMatchCompiler creates a new MatchCompiler for T instances provided the funcs which
+// can map ast.Identifier instances to a specific T field
+func NewMatchCompiler[T any](
+	stringFieldMapper StringFieldMapper[T],
+	sliceFieldMapper SliceFieldMapper[T],
+	mapFieldMapper MapFieldMapper[T],
+	passes ...transform.CompilerPass,
+) *MatchCompiler[T] {
+	return &MatchCompiler[T]{
+		stringMatcher: NewStringMatcherFactory(stringFieldMapper),
+		sliceMatcher:  NewStringSliceMatcherFactory(sliceFieldMapper),
+		mapMatcher:    NewStringMapMatcherFactory(mapFieldMapper),
+		passes:        passes,
+	}
+}
+
+// Compile accepts anb `ast.FilterNode` tree and compiles it into a `Matcher[T]` implementation
+// which can be used to match T instances dynamically.
+func (mc *MatchCompiler[T]) Compile(filter ast.FilterNode) (Matcher[T], error) {
+	// apply compiler passes on parsed ast
+	filter = transform.ApplyAll(filter, mc.passes)
+
+	// if the root node is a void op, return an allpass
+	if _, ok := filter.(*ast.VoidOp); ok {
+		return &AllPass[T]{}, nil
+	}
+
+	var result Matcher[T]
+	var currentOps *util.Stack[MatcherGroup[T]] = util.NewStack[MatcherGroup[T]]()
+
+	// handle leaf is the ast walker func. group ops get pushed onto a stack on
+	// the Enter state, and popped on the Exit state. Any ops between Enter and
+	// Exit are added to the group. If there are no more groups on the stack after
+	// an Exit state, we set the result to the final group.
+	handleLeaf := func(leaf ast.FilterNode, state ast.TraversalState) {
+		switch n := leaf.(type) {
+		case *ast.AndOp:
+			if state == ast.TraversalStateEnter {
+				currentOps.Push(&And[T]{})
+			} else if state == ast.TraversalStateExit {
+				if currentOps.Length() > 1 {
+					current := currentOps.Pop()
+					currentOps.Top().Add(current)
+				} else {
+					result = currentOps.Pop()
+				}
+			}
+		case *ast.OrOp:
+			if state == ast.TraversalStateEnter {
+				currentOps.Push(&Or[T]{})
+			} else if state == ast.TraversalStateExit {
+				if currentOps.Length() > 1 {
+					current := currentOps.Pop()
+					currentOps.Top().Add(current)
+				} else {
+					result = currentOps.Pop()
+				}
+			}
+
+		case *ast.NotOp:
+			if state == ast.TraversalStateEnter {
+				currentOps.Push(&Not[T]{})
+			} else if state == ast.TraversalStateExit {
+				if currentOps.Length() > 1 {
+					current := currentOps.Pop()
+					currentOps.Top().Add(current)
+				} else {
+					result = currentOps.Pop()
+				}
+			}
+
+		case *ast.EqualOp:
+			sm := mc.stringMatcher.NewStringMatcher(n.Op(), n.Left, n.Right)
+			if currentOps.Length() == 0 {
+				result = sm
+			} else {
+				currentOps.Top().Add(sm)
+			}
+
+		case *ast.ContainsOp:
+			f := n.Left.Field
+			key := n.Left.Key
+
+			var sm Matcher[T]
+			if f.IsSlice() {
+				sm = mc.sliceMatcher.NewStringSliceMatcher(n.Op(), n.Left, n.Right)
+			} else if f.IsMap() && key == "" {
+				sm = mc.mapMatcher.NewStringMapMatcher(n.Op(), n.Left, n.Right)
+			} else {
+				sm = mc.stringMatcher.NewStringMatcher(n.Op(), n.Left, n.Right)
+			}
+
+			if currentOps.Length() == 0 {
+				result = sm
+			} else {
+				currentOps.Top().Add(sm)
+			}
+
+		case *ast.ContainsPrefixOp:
+			f := n.Left.Field
+			key := n.Left.Key
+
+			var sm Matcher[T]
+			if f.IsSlice() {
+				sm = mc.sliceMatcher.NewStringSliceMatcher(n.Op(), n.Left, n.Right)
+			} else if f.IsMap() && key == "" {
+				sm = mc.mapMatcher.NewStringMapMatcher(n.Op(), n.Left, n.Right)
+			} else {
+				sm = mc.stringMatcher.NewStringMatcher(n.Op(), n.Left, n.Right)
+			}
+
+			if currentOps.Length() == 0 {
+				result = sm
+			} else {
+				currentOps.Top().Add(sm)
+			}
+
+		case *ast.ContainsSuffixOp:
+			f := n.Left.Field
+			key := n.Left.Key
+
+			var sm Matcher[T]
+			if f.IsSlice() {
+				sm = mc.sliceMatcher.NewStringSliceMatcher(n.Op(), n.Left, n.Right)
+			} else if f.IsMap() && key == "" {
+				sm = mc.mapMatcher.NewStringMapMatcher(n.Op(), n.Left, n.Right)
+			} else {
+				sm = mc.stringMatcher.NewStringMatcher(n.Op(), n.Left, n.Right)
+			}
+
+			if currentOps.Length() == 0 {
+				result = sm
+			} else {
+				currentOps.Top().Add(sm)
+			}
+		}
+	}
+
+	ast.PreOrderTraversal(filter, handleLeaf)
+	if result == nil {
+		return &AllPass[T]{}, nil
+	}
+
+	return result, nil
+}

+ 17 - 0
pkg/filter21/matcher/matcher.go

@@ -0,0 +1,17 @@
+package matcher
+
+// Matcher represents anything that can be used to match against given generic type T.
+type Matcher[T any] interface {
+	String() string
+
+	// Matches is the canonical in-Go function for determining if T
+	// matches a specific implementation's rules.
+	Matches(T) bool
+}
+
+// MatcherGroup is useful for dynamically creating group based matchers.
+type MatcherGroup[T any] interface {
+	Matcher[T]
+
+	Add(Matcher[T])
+}

+ 21 - 0
pkg/filter21/matcher/not.go

@@ -0,0 +1,21 @@
+package matcher
+
+import "fmt"
+
+// Not negates any filter contained within it
+type Not[T any] struct {
+	Matcher Matcher[T]
+}
+
+func (n *Not[T]) Add(m Matcher[T]) {
+	n.Matcher = m
+}
+
+func (n *Not[T]) String() string {
+	return fmt.Sprintf("(not %s)", n.Matcher.String())
+}
+
+// Matches inverts the result of the child matcher
+func (n *Not[T]) Matches(that T) bool {
+	return !n.Matcher.Matches(that)
+}

+ 42 - 0
pkg/filter21/matcher/or.go

@@ -0,0 +1,42 @@
+package matcher
+
+import (
+	"fmt"
+)
+
+// Or is a set of filters that should be evaluated as a logical
+// OR.
+type Or[T any] struct {
+	Matchers []Matcher[T]
+}
+
+func (o *Or[T]) Add(m Matcher[T]) {
+	o.Matchers = append(o.Matchers, m)
+}
+
+func (o *Or[T]) String() string {
+	s := "(or"
+	for _, f := range o.Matchers {
+		s += fmt.Sprintf(" %s", f)
+	}
+
+	s += ")"
+	return s
+}
+
+// Matches is the canonical in-Go function for determining if T
+// matches OR match rules.
+func (o *Or[T]) Matches(that T) bool {
+	filters := o.Matchers
+	if len(filters) == 0 {
+		return true
+	}
+
+	for _, filter := range filters {
+		if filter.Matches(that) {
+			return true
+		}
+	}
+
+	return false
+}

+ 79 - 0
pkg/filter21/matcher/stringmapmatcher.go

@@ -0,0 +1,79 @@
+package matcher
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/opencost/opencost/pkg/filter21/ast"
+	"github.com/opencost/opencost/pkg/log"
+)
+
+// StringMapMatcherFactory leverages a single MapFieldMapper[T] to generate instances of
+// StringMapMatcher[T].
+type StringMapMatcherFactory[T any] struct {
+	fieldMapper MapFieldMapper[T]
+}
+
+// NewStringMapMatcherFactory creates a new StringMapMatcher factory for a given T type.
+func NewStringMapMatcherFactory[T any](fieldMapper MapFieldMapper[T]) *StringMapMatcherFactory[T] {
+	return &StringMapMatcherFactory[T]{
+		fieldMapper: fieldMapper,
+	}
+}
+
+// NewStringMapMatcher creates a new StringMapMatcher using the provided op, field ident and key for comparison
+func (smmf *StringMapMatcherFactory[T]) NewStringMapMatcher(op ast.FilterOp, ident ast.Identifier, key string) *StringMapMatcher[T] {
+	return &StringMapMatcher[T]{
+		Op:          op,
+		Identifier:  ident,
+		Key:         key,
+		fieldMapper: smmf.fieldMapper,
+	}
+}
+
+// // StringMapMatcher matches properties of a T instance which are map[string]string
+type StringMapMatcher[T any] struct {
+	Op         ast.FilterOp
+	Identifier ast.Identifier
+	Key        string
+
+	fieldMapper MapFieldMapper[T]
+}
+
+func (smm *StringMapMatcher[T]) String() string {
+	return fmt.Sprintf(`(%s %s "%s")`, smm.Op, smm.Identifier.String(), smm.Key)
+}
+
+func (smm *StringMapMatcher[T]) Matches(that T) bool {
+	thatMap, err := smm.fieldMapper(that, smm.Identifier)
+	if err != nil {
+		log.Errorf("Filter: StringMapMatcher: could not retrieve field %s: %s", smm.Identifier.String(), err.Error())
+		return false
+	}
+
+	switch smm.Op {
+	case ast.FilterOpContains:
+		_, exists := thatMap[smm.Key]
+		return exists
+
+	case ast.FilterOpContainsPrefix:
+		for k := range thatMap {
+			if strings.HasPrefix(k, smm.Key) {
+				return true
+			}
+		}
+		return false
+
+	case ast.FilterOpContainsSuffix:
+		for k := range thatMap {
+			if strings.HasSuffix(k, smm.Key) {
+				return true
+			}
+		}
+		return false
+
+	default:
+		log.Errorf("Filter: StringMapMatcher: Unhandled matcher op. This is a filter implementation error and requires immediate patching. Op: %s", smm.Op)
+		return false
+	}
+}

+ 73 - 0
pkg/filter21/matcher/stringmatcher.go

@@ -0,0 +1,73 @@
+package matcher
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/opencost/opencost/pkg/filter21/ast"
+	"github.com/opencost/opencost/pkg/log"
+)
+
+// StringMatcherFactory leverages a single StringFieldMapper[T] to generate instances of
+// StringMatcher[T].
+type StringMatcherFactory[T any] struct {
+	fieldMapper StringFieldMapper[T]
+}
+
+// NewStringMatcherFactory creates a new StringMatcher factory for a given T type.
+func NewStringMatcherFactory[T any](fieldMapper StringFieldMapper[T]) *StringMatcherFactory[T] {
+	return &StringMatcherFactory[T]{
+		fieldMapper: fieldMapper,
+	}
+}
+
+// NewStringMatcher creates a new StringMatcher using the provided op, field ident, and value comparison.
+func (smf *StringMatcherFactory[T]) NewStringMatcher(op ast.FilterOp, ident ast.Identifier, value string) *StringMatcher[T] {
+	return &StringMatcher[T]{
+		Op:          op,
+		Identifier:  ident,
+		Value:       value,
+		fieldMapper: smf.fieldMapper,
+	}
+}
+
+// StringMatcher matches properties of a T instance which are string.
+type StringMatcher[T any] struct {
+	Op         ast.FilterOp
+	Identifier ast.Identifier
+	Value      string
+
+	fieldMapper StringFieldMapper[T]
+}
+
+func (sm *StringMatcher[T]) String() string {
+	return fmt.Sprintf(`(%s %s "%s")`, sm.Op, sm.Identifier.String(), sm.Value)
+}
+
+// Matches is the canonical in-Go function for determining if T
+// matches string property comparison rules.
+func (sm *StringMatcher[T]) Matches(that T) bool {
+	thatString, err := sm.fieldMapper(that, sm.Identifier)
+	if err != nil {
+		log.Errorf("Filter: StringMatcher: could not retrieve field %s: %s", sm.Identifier.String(), err.Error())
+		return false
+	}
+
+	switch sm.Op {
+	case ast.FilterOpEquals:
+		return thatString == sm.Value
+
+	case ast.FilterOpContains:
+		return strings.Contains(thatString, sm.Value)
+
+	case ast.FilterOpContainsPrefix:
+		return strings.HasPrefix(thatString, sm.Value)
+
+	case ast.FilterOpContainsSuffix:
+		return strings.HasSuffix(thatString, sm.Value)
+
+	default:
+		log.Errorf("Filter: StringMatcher: Unhandled filter op. This is a filter implementation error and requires immediate patching. Op: %s", sm.Op)
+		return false
+	}
+}

+ 91 - 0
pkg/filter21/matcher/stringslicematcher.go

@@ -0,0 +1,91 @@
+package matcher
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/opencost/opencost/pkg/filter21/ast"
+	"github.com/opencost/opencost/pkg/log"
+)
+
+// StringMatcherFactory leverages a single StringSliceFieldMapper[T] to generate instances of
+// StringSliceMatcher[T].
+type StringSliceMatcherFactory[T any] struct {
+	fieldMapper SliceFieldMapper[T]
+}
+
+// NewStringSliceMatcherFactory creates a new StringMatcher factory for a given T type.
+func NewStringSliceMatcherFactory[T any](fieldMapper SliceFieldMapper[T]) *StringSliceMatcherFactory[T] {
+	return &StringSliceMatcherFactory[T]{
+		fieldMapper: fieldMapper,
+	}
+}
+
+// NewStringMatcher creates a new StringSliceMatcher using the provided op, field ident, and value comparison.
+func (smf *StringSliceMatcherFactory[T]) NewStringSliceMatcher(op ast.FilterOp, ident ast.Identifier, value string) *StringSliceMatcher[T] {
+	return &StringSliceMatcher[T]{
+		Op:          op,
+		Identifier:  ident,
+		Value:       value,
+		fieldMapper: smf.fieldMapper,
+	}
+}
+
+// StringSliceProperty is the lowest-level type of filter. It represents
+// a filter operation (equality, inequality, etc.) on a property that contains a string slice
+type StringSliceMatcher[T any] struct {
+	Op         ast.FilterOp
+	Identifier ast.Identifier
+	Value      string
+
+	fieldMapper SliceFieldMapper[T]
+}
+
+func (ssp *StringSliceMatcher[T]) String() string {
+	return fmt.Sprintf(`(%s %s "%s")`, ssp.Op, ssp.Identifier.String(), ssp.Value)
+}
+
+func (ssp *StringSliceMatcher[T]) Matches(that T) bool {
+	thatSlice, err := ssp.fieldMapper(that, ssp.Identifier)
+	if err != nil {
+		log.Errorf("Filter: StringSliceMatcher: could not retrieve field %s: %s", ssp.Identifier.String(), err.Error())
+		return false
+	}
+
+	switch ssp.Op {
+
+	case ast.FilterOpContains:
+		if len(thatSlice) == 0 {
+			return ssp.Value == ""
+		}
+
+		for _, s := range thatSlice {
+			if s == ssp.Value {
+				return true
+			}
+		}
+
+	case ast.FilterOpContainsPrefix:
+		for _, s := range thatSlice {
+			if strings.HasPrefix(s, ssp.Value) {
+				return true
+			}
+		}
+
+		return false
+
+	case ast.FilterOpContainsSuffix:
+		for _, s := range thatSlice {
+			if strings.HasSuffix(s, ssp.Value) {
+				return true
+			}
+		}
+		return false
+
+	default:
+		log.Errorf("Filter: StringSliceMatcher: Unhandled filter op. This is a filter implementation error and requires immediate patching. Op: %s", ssp.Op)
+		return false
+	}
+
+	return false
+}

+ 29 - 0
pkg/filter21/transform/pass.go

@@ -0,0 +1,29 @@
+package transform
+
+import "github.com/opencost/opencost/pkg/filter21/ast"
+
+// CompilerPass is an interface which defines an implementation capable of
+// accepting an input AST and making optimizations or changes, and returning
+// a new (or the existing) AST.
+type CompilerPass interface {
+	// Exec executes the pass on the provided AST. This method may either return
+	// a new AST or the existing modified AST. Note that the parameter to this method
+	// may be changed directly.
+	Exec(filter ast.FilterNode) ast.FilterNode
+}
+
+// ApplyAll applies all the compiler passes serially and returns the resulting tree. This
+// method copies the passes AST before executing the compiler passes.
+func ApplyAll(filter ast.FilterNode, passes []CompilerPass) ast.FilterNode {
+	// return the input filter if there are no passes to run
+	if len(passes) == 0 {
+		return filter
+	}
+
+	// Clone the filter first, then apply the passes
+	var f ast.FilterNode = ast.Clone(filter)
+	for _, pass := range passes {
+		f = pass.Exec(f)
+	}
+	return f
+}

+ 67 - 0
pkg/filter21/transform/promlabels.go

@@ -0,0 +1,67 @@
+package transform
+
+import (
+	"regexp"
+
+	"github.com/opencost/opencost/pkg/filter21/ast"
+)
+
+// regex for invalid prometheus label characters
+var invalidKey = regexp.MustCompile(`[^a-zA-Z0-9_]`)
+var promKeyPass CompilerPass = new(promKeySanitizePass)
+
+// PrometheusKeySanitizePass returns a
+func PrometheusKeySanitizePass() CompilerPass {
+	return promKeyPass
+}
+
+type promKeySanitizePass struct{}
+
+// Exec executes the pass on the provided AST. This method may either return
+// a new AST or modify and return the AST parameter. The parameter into this
+// method may be changed directly.
+func (pks *promKeySanitizePass) Exec(filter ast.FilterNode) ast.FilterNode {
+	ast.PreOrderTraversal(filter, func(fn ast.FilterNode, ts ast.TraversalState) {
+		switch n := fn.(type) {
+		case *ast.EqualOp:
+			sanitize(&n.Left)
+		case *ast.ContainsOp:
+			left := &n.Left
+			// if we use a contains operator on a map, we sanitize the value
+			if left.Field.IsMap() && left.Key == "" {
+				n.Right = sanitizeKey(n.Right)
+			} else {
+				sanitize(left)
+			}
+		case *ast.ContainsPrefixOp:
+			left := &n.Left
+			// if we use a contains operator on a map, we sanitize the value
+			if left.Field.IsMap() && left.Key == "" {
+				n.Right = sanitizeKey(n.Right)
+			} else {
+				sanitize(left)
+			}
+		case *ast.ContainsSuffixOp:
+			left := &n.Left
+			// if we use a contains operator on a map, we sanitize the value
+			if left.Field.IsMap() && left.Key == "" {
+				n.Right = sanitizeKey(n.Right)
+			} else {
+				sanitize(left)
+			}
+		}
+	})
+	return filter
+}
+
+// sanitizes the identifier
+func sanitize(left *ast.Identifier) {
+	if left.Key != "" {
+		left.Key = sanitizeKey(left.Key)
+	}
+}
+
+// replaces all invalid characters with underscore
+func sanitizeKey(s string) string {
+	return invalidKey.ReplaceAllString(s, "_")
+}

+ 42 - 0
pkg/filter21/transform/unallocated.go

@@ -0,0 +1,42 @@
+package transform
+
+import "github.com/opencost/opencost/pkg/filter21/ast"
+
+const unallocatedSuffix = "__unallocated__"
+
+var unallocPass CompilerPass = new(unallocReplacePass)
+
+// UnallocatedReplacementPass returns a CompilerPass implementation which replaces all
+// __unallocated__ with empty string
+func UnallocatedReplacementPass() CompilerPass {
+	return unallocPass
+}
+
+type unallocReplacePass struct{}
+
+// Exec executes the pass on the provided AST. This method may either return
+// a new AST or modify and return the AST parameter. The parameter into this
+// method may be changed directly.
+func (pks *unallocReplacePass) Exec(filter ast.FilterNode) ast.FilterNode {
+	ast.PreOrderTraversal(filter, func(fn ast.FilterNode, ts ast.TraversalState) {
+		switch n := fn.(type) {
+		case *ast.EqualOp:
+			n.Right = replaceUnallocated(n.Right)
+		case *ast.ContainsOp:
+			n.Right = replaceUnallocated(n.Right)
+		case *ast.ContainsPrefixOp:
+			n.Right = replaceUnallocated(n.Right)
+		case *ast.ContainsSuffixOp:
+			n.Right = replaceUnallocated(n.Right)
+		}
+	})
+	return filter
+}
+
+// replaces unallocated with empty string if valid
+func replaceUnallocated(s string) string {
+	if s == unallocatedSuffix {
+		return ""
+	}
+	return s
+}

+ 63 - 0
pkg/filter21/util/stack.go

@@ -0,0 +1,63 @@
+package util
+
+type stackNode[T any] struct {
+	value    T
+	previous *stackNode[T]
+}
+
+type Stack[T any] struct {
+	top *stackNode[T]
+
+	length int
+}
+
+// NewStack creates a new Stack[T]
+func NewStack[T any]() *Stack[T] {
+	return &Stack[T]{
+		top:    nil,
+		length: 0,
+	}
+}
+
+// Push adds a value to the top of the stack.
+func (s *Stack[T]) Push(value T) {
+	n := &stackNode[T]{
+		value:    value,
+		previous: s.top,
+	}
+
+	s.top = n
+	s.length++
+}
+
+// Pop the top item of the stack and return it
+func (s *Stack[T]) Pop() T {
+	if s.length == 0 {
+		return defaultFor[T]()
+	}
+
+	n := s.top
+	s.top = n.previous
+	s.length--
+
+	return n.value
+}
+
+// Top returns the item on the top of the stack
+func (s *Stack[T]) Top() T {
+	if s.length == 0 {
+		return defaultFor[T]()
+	}
+
+	return s.top.value
+}
+
+// Length returns the total number of elements on the stack.
+func (s *Stack[T]) Length() int {
+	return s.length
+}
+
+func defaultFor[T any]() T {
+	var t T
+	return t
+}