Browse Source

Merge branch 'develop' into ui_containers

Matt Ray 2 years ago
parent
commit
39a62cf674
46 changed files with 4511 additions and 55 deletions
  1. 1 1
      .github/PULL_REQUEST_TEMPLATE.md
  2. 19 1
      pkg/cloud/aws/provider.go
  3. 7 0
      pkg/cloud/provider/provider.go
  4. 6 0
      pkg/cloud/provider/providerconfig.go
  5. 1 1
      pkg/costmodel/aggregation.go
  6. 16 0
      pkg/costmodel/allocation_helpers.go
  7. 7 1
      pkg/env/costmodelenv.go
  8. 37 0
      pkg/filter21/allocation/fields.go
  9. 51 0
      pkg/filter21/allocation/parser.go
  10. 289 0
      pkg/filter21/allocation/parser_test.go
  11. 79 0
      pkg/filter21/ast/fields.go
  12. 342 0
      pkg/filter21/ast/lexer.go
  13. 181 0
      pkg/filter21/ast/lexer_test.go
  14. 182 0
      pkg/filter21/ast/ops.go
  15. 589 0
      pkg/filter21/ast/parser.go
  16. 38 0
      pkg/filter21/ast/tree.go
  17. 311 0
      pkg/filter21/ast/walker.go
  18. 7 0
      pkg/filter21/filter.go
  19. 13 0
      pkg/filter21/matcher/allcut.go
  20. 11 0
      pkg/filter21/matcher/allpass.go
  21. 42 0
      pkg/filter21/matcher/and.go
  22. 177 0
      pkg/filter21/matcher/compiler.go
  23. 17 0
      pkg/filter21/matcher/matcher.go
  24. 467 0
      pkg/filter21/matcher/matcher_test.go
  25. 21 0
      pkg/filter21/matcher/not.go
  26. 42 0
      pkg/filter21/matcher/or.go
  27. 79 0
      pkg/filter21/matcher/stringmapmatcher.go
  28. 73 0
      pkg/filter21/matcher/stringmatcher.go
  29. 91 0
      pkg/filter21/matcher/stringslicematcher.go
  30. 225 0
      pkg/filter21/ops/ops.go
  31. 103 0
      pkg/filter21/ops/ops_test.go
  32. 29 0
      pkg/filter21/transform/pass.go
  33. 67 0
      pkg/filter21/transform/promlabels.go
  34. 42 0
      pkg/filter21/transform/unallocated.go
  35. 63 0
      pkg/filter21/util/stack.go
  36. 73 4
      pkg/kubecost/allocation.go
  37. 199 0
      pkg/kubecost/allocation_test.go
  38. 75 0
      pkg/kubecost/allocationmatcher.go
  39. 88 11
      pkg/kubecost/allocationprops.go
  40. 151 23
      pkg/kubecost/allocationprops_test.go
  41. 1 1
      pkg/kubecost/bingen.go
  42. 147 2
      pkg/kubecost/kubecost_codecs.go
  43. 12 0
      pkg/kubecost/kubecost_codecs_test.go
  44. 8 8
      pkg/prom/diagnostics.go
  45. 2 2
      pkg/storage/s3storage.go
  46. 30 0
      pkg/util/typeutil/typeutil.go

+ 1 - 1
.github/PULL_REQUEST_TEMPLATE.md

@@ -16,5 +16,5 @@
 ## Does this PR require changes to documentation?
 * 
 
-## Have you labeled this PR and its corresponding Issue as "next release" if it should be part of the next Opencost release? If not, why not?
+## Have you labeled this PR and its corresponding Issue as "next release" if it should be part of the next OpenCost release? If not, why not?
 * 

+ 19 - 1
pkg/cloud/aws/provider.go

@@ -534,6 +534,12 @@ func (aws *AWS) UpdateConfig(r io.Reader, updateType string) (*models.CustomPric
 				return err
 			}
 
+			// If the sample nil service key name is set, zero it out so that it is not
+			// misinterpreted as a real service key.
+			if asfi.ServiceKeyName == "AKIXXX" {
+				asfi.ServiceKeyName = ""
+			}
+
 			c.ServiceKeyName = asfi.ServiceKeyName
 			if asfi.ServiceKeySecret != "" {
 				c.ServiceKeySecret = asfi.ServiceKeySecret
@@ -551,6 +557,13 @@ func (aws *AWS) UpdateConfig(r io.Reader, updateType string) (*models.CustomPric
 			if err != nil {
 				return err
 			}
+
+			// If the sample nil service key name is set, zero it out so that it is not
+			// misinterpreted as a real service key.
+			if aai.ServiceKeyName == "AKIXXX" {
+				aai.ServiceKeyName = ""
+			}
+
 			c.AthenaBucketName = aai.AthenaBucketName
 			c.AthenaRegion = aai.AthenaRegion
 			c.AthenaDatabase = aai.AthenaDatabase
@@ -1401,7 +1414,6 @@ func (aws *AWS) ConfigureAuthWith(config *models.CustomPricing) error {
 
 // Gets the aws key id and secret
 func (aws *AWS) getAWSAuth(forceReload bool, cp *models.CustomPricing) (string, string) {
-
 	// 1. Check config values first (set from frontend UI)
 	if cp.ServiceKeyName != "" && cp.ServiceKeySecret != "" {
 		aws.ServiceAccountChecks.Set("hasKey", &models.ServiceAccountCheck{
@@ -1461,6 +1473,12 @@ func (aws *AWS) loadAWSAuthSecret(force bool) (*AWSAccessKey, error) {
 		return nil, err
 	}
 
+	// If the sample nil service key name is set, zero it out so that it is not
+	// misinterpreted as a real service key.
+	if ak.AccessKeyID == "AKIXXX" {
+		ak.AccessKeyID = ""
+	}
+
 	awsSecret = &ak
 	return awsSecret, nil
 }

+ 7 - 0
pkg/cloud/provider/provider.go

@@ -162,6 +162,13 @@ func NewProvider(cache clustercache.ClusterCache, apiKey string, config *config.
 		cp.accountID = providerConfig.customPricing.ClusterAccountID
 	}
 
+	providerConfig.Update(func(cp *models.CustomPricing) error {
+		if cp.ServiceKeyName == "AKIXXX" {
+			cp.ServiceKeyName = ""
+		}
+		return nil
+	})
+
 	switch cp.provider {
 	case kubecost.CSVProvider:
 		log.Infof("Using CSV Provider with CSV at %s", env.GetCSVPath())

+ 6 - 0
pkg/cloud/provider/providerconfig.go

@@ -143,6 +143,12 @@ func (pc *ProviderConfig) loadConfig(writeIfNotExists bool) (*models.CustomPrici
 		pc.customPricing.ShareTenancyCosts = models.DefaultShareTenancyCost
 	}
 
+	// If the sample nil service key name is set, zero it out so that it is not
+	// misinterpreted as a real service key.
+	if pc.customPricing.ServiceKeyName == "AKIXXX" {
+		pc.customPricing.ServiceKeyName = ""
+	}
+
 	return pc.customPricing, nil
 }
 

+ 1 - 1
pkg/costmodel/aggregation.go

@@ -2265,7 +2265,7 @@ func (a *Accesses) ComputeAllocationHandler(w http.ResponseWriter, r *http.Reque
 	includeProportionalAssetResourceCosts := qp.GetBool("includeProportionalAssetResourceCosts", false)
 
 	// include aggregated labels/annotations if true
-	includeAggregatedMetadata := qp.GetBool("includeAggregatedMetadata", true)
+	includeAggregatedMetadata := qp.GetBool("includeAggregatedMetadata", false)
 
 	asr, err := a.Model.QueryAllocation(window, resolution, step, aggregateBy, includeIdle, idleByNode, includeProportionalAssetResourceCosts, includeAggregatedMetadata)
 	if err != nil {

+ 16 - 0
pkg/costmodel/allocation_helpers.go

@@ -926,6 +926,11 @@ func applyLabels(podMap map[podKey]*pod, nodeLabels map[nodeKey]map[string]strin
 				allocLabels = make(map[string]string)
 			}
 
+			nsLabels := alloc.Properties.NamespaceLabels
+			if nsLabels == nil {
+				nsLabels = make(map[string]string)
+			}
+
 			// Apply node labels first, then namespace labels, then pod labels
 			// so that pod labels overwrite namespace labels, which overwrite
 			// node labels.
@@ -943,6 +948,7 @@ func applyLabels(podMap map[podKey]*pod, nodeLabels map[nodeKey]map[string]strin
 			if labels, ok := namespaceLabels[nsKey]; ok {
 				for k, v := range labels {
 					allocLabels[k] = v
+					nsLabels[k] = v
 				}
 			}
 
@@ -953,6 +959,8 @@ func applyLabels(podMap map[podKey]*pod, nodeLabels map[nodeKey]map[string]strin
 			}
 
 			alloc.Properties.Labels = allocLabels
+			alloc.Properties.NamespaceLabels = nsLabels
+			
 		}
 	}
 }
@@ -964,11 +972,18 @@ func applyAnnotations(podMap map[podKey]*pod, namespaceAnnotations map[string]ma
 			if allocAnnotations == nil {
 				allocAnnotations = make(map[string]string)
 			}
+
+			nsAnnotations := alloc.Properties.NamespaceAnnotations
+			if nsAnnotations == nil {
+				nsAnnotations = make(map[string]string)
+			}
+
 			// Apply namespace annotations first, then pod annotations so that
 			// pod labels overwrite namespace labels.
 			if labels, ok := namespaceAnnotations[key.Namespace]; ok {
 				for k, v := range labels {
 					allocAnnotations[k] = v
+					nsAnnotations[k] = v
 				}
 			}
 			if labels, ok := podAnnotations[key]; ok {
@@ -978,6 +993,7 @@ func applyAnnotations(podMap map[podKey]*pod, namespaceAnnotations map[string]ma
 			}
 
 			alloc.Properties.Annotations = allocAnnotations
+			alloc.Properties.NamespaceAnnotations = nsAnnotations
 		}
 	}
 }

+ 7 - 1
pkg/env/costmodelenv.go

@@ -215,7 +215,13 @@ func IsEmitKsmV1MetricsOnly() bool {
 // GetAWSAccessKeyID returns the environment variable value for AWSAccessKeyIDEnvVar which represents
 // the AWS access key for authentication
 func GetAWSAccessKeyID() string {
-	return Get(AWSAccessKeyIDEnvVar, "")
+	awsAccessKeyID := Get(AWSAccessKeyIDEnvVar, "")
+	// If the sample nil service key name is set, zero it out so that it is not
+	// misinterpreted as a real service key.
+	if awsAccessKeyID == "AKIXXX" {
+		awsAccessKeyID = ""
+	}
+	return awsAccessKeyID
 }
 
 // GetAWSAccessKeySecret returns the environment variable value for AWSAccessKeySecretEnvVar which represents

+ 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))
+		})
+	}
+}

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

@@ -0,0 +1,79 @@
+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
+}
+
+// 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 {
+	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,
+	}
+}

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

@@ -0,0 +1,38 @@
+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
+}
+
+// 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
+	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])
+}

+ 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
+}

+ 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
+}

+ 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))
+	}
+}

+ 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
+}

+ 73 - 4
pkg/kubecost/allocation.go

@@ -9,6 +9,7 @@ import (
 	"github.com/opencost/opencost/pkg/log"
 	"github.com/opencost/opencost/pkg/util"
 	"github.com/opencost/opencost/pkg/util/timeutil"
+	"golang.org/x/exp/slices"
 )
 
 // TODO Clean-up use of IsEmpty; nil checks should be separated for safety.
@@ -1045,6 +1046,8 @@ type AllocationAggregationOptions struct {
 	Reconcile                             bool
 	ReconcileNetwork                      bool
 	ShareFuncs                            []AllocationMatchFunc
+	SharedNamespaces                      []string
+	SharedLabels                          map[string][]string
 	ShareIdle                             string
 	ShareSplit                            string
 	SharedHourlyCosts                     map[string]float64
@@ -1171,10 +1174,8 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 	// them to their respective sets, removing them from the set of allocations
 	// to aggregate.
 	for _, alloc := range as.Allocations {
-		// if the user does not want any aggregated labels/annotations returned
-		// set the properties accordingly
-		alloc.Properties.AggregatedMetadata = options.IncludeAggregatedMetadata
 
+		alloc.Properties.AggregatedMetadata = options.IncludeAggregatedMetadata
 		// External allocations get aggregated post-hoc (see step 6) and do
 		// not necessarily contain complete sets of properties, so they are
 		// moved to a separate AllocationSet.
@@ -1539,7 +1540,12 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 					if alloc.SharedCostBreakdown == nil {
 						alloc.SharedCostBreakdown = map[string]SharedCostBreakdown{}
 					}
-					sharedCostName := sharedAlloc.generateKey(aggregateBy, options.LabelConfig)
+
+					sharedCostName, err := sharedAlloc.determineSharingName(options)
+					if err != nil {
+						return fmt.Errorf("failed to group shared costs: %s", err)
+					}
+
 					// check if current allocation is a shared flat overhead cost
 					if strings.Contains(sharedAlloc.Name, SharedSuffix) {
 						sharedCostName = "overheadCost"
@@ -1979,6 +1985,69 @@ func deriveProportionalAssetResourceCosts(options *AllocationAggregationOptions,
 	return nil
 }
 
+func (a *Allocation) determineSharingName(options *AllocationAggregationOptions) (string, error) {
+	if a == nil {
+		return "", fmt.Errorf("determineSharingName called on nil Allocation")
+	} else if options == nil {
+		return "unknown", nil
+	}
+
+	// grab SharedLabels keys and sort them, to keep this function deterministic
+	var labelKeys []string
+	for labelKey, _ := range options.SharedLabels {
+		labelKeys = append(labelKeys, labelKey)
+	}
+	slices.Sort(labelKeys)
+
+	var sharedAggregateBy []string
+	var sharedLabels [][]string
+	for _, labelKey := range labelKeys {
+		sharedAgg := fmt.Sprintf("label:%s", labelKey)
+		if !slices.Contains(sharedAggregateBy, sharedAgg) {
+			sharedAggregateBy = append(sharedAggregateBy, sharedAgg)
+		}
+		sharedLabels = append(sharedLabels, options.SharedLabels[labelKey])
+	}
+	if len(options.SharedNamespaces) > 0 {
+		sharedAggregateBy = append(sharedAggregateBy, "namespace")
+	}
+	sharedCostName := a.generateKey(sharedAggregateBy, options.LabelConfig)
+
+	// get each value in the generated key, then reset the name
+	sharedCostNameValues := strings.Split(sharedCostName, "/")
+	sharedCostName = ""
+
+	// if we don't have as many values as aggregateBys, something went wrong in generateKey
+	if len(sharedCostNameValues) != len(sharedAggregateBy) {
+		log.Warnf("Unable to determine share cost group for allocation \"%s\"", a.Name)
+	} else {
+		// try to match to the first label
+		for i, sharedLabelValues := range sharedLabels {
+			allocLabel := sharedCostNameValues[i]
+			if slices.Contains(sharedLabelValues, allocLabel) {
+				return allocLabel, nil
+			}
+		}
+
+		// if we didn't match to a label, try to match to a namespace
+		if len(options.SharedNamespaces) > 0 {
+			// namespace will always be the last value, if SharedNamespaces is set
+			allocNamespace := sharedCostNameValues[len(sharedCostNameValues)-1]
+			if slices.Contains(options.SharedNamespaces, allocNamespace) {
+				return allocNamespace, nil
+			}
+		}
+
+		// if neither the labels nor the namespaces matched, we log a warning and mark this allocation
+		// as unknown
+		if len(sharedCostName) == 0 {
+			log.Warnf("Failed to determine shared cost grouping for allocation \"%s\"", a.Name)
+		}
+	}
+
+	return "unknown", nil
+}
+
 // getIdleId returns the providerId or cluster of an Allocation depending on the IdleByNode
 // option in the AllocationAggregationOptions and an error if the respective field is missing
 func (a *Allocation) getIdleId(options *AllocationAggregationOptions) (string, error) {

+ 199 - 0
pkg/kubecost/allocation_test.go

@@ -3214,3 +3214,202 @@ func Test_AggregateByService_UnmountedLBs(t *testing.T) {
 	spew.Config.DisableMethods = true
 	t.Logf("%s", spew.Sdump(set.Allocations))
 }
+
+func Test_DetermineSharingName(t *testing.T) {
+	var alloc *Allocation
+	var name string
+	var err error
+
+	// test nil allocation with nil options
+	name, err = alloc.determineSharingName(nil)
+	if err == nil {
+		t.Fatalf("determineSharingName: expected error; actual nil")
+	}
+
+	// test nil with non-nil options
+	name, err = alloc.determineSharingName(&AllocationAggregationOptions{})
+	if err == nil {
+		t.Fatalf("determineSharingName: expected error; actual nil")
+	}
+
+	alloc = &Allocation{}
+	alloc.Properties = &AllocationProperties{
+		Cluster: "cluster1",
+		Labels: map[string]string{
+			"app": "app1",
+			"env": "env1",
+		},
+		Namespace: "namespace1",
+	}
+
+	// test non-nil allocation with nil options
+	name, err = alloc.determineSharingName(nil)
+	if err != nil {
+		t.Fatalf("determineSharingName: expected no error; actual \"%s\"", err)
+	} else if err != nil || name != "unknown" {
+		t.Fatalf("determineSharingName: expected \"unknown\"; actual \"%s\"", name)
+	}
+
+	// test non-nil allocation with empty options
+	options := &AllocationAggregationOptions{}
+	name, err = alloc.determineSharingName(options)
+	if err != nil {
+		t.Fatalf("determineSharingName: expected no error; actual \"%s\"", err)
+	} else if err != nil || name != "unknown" {
+		t.Fatalf("determineSharingName: expected \"unknown\"; actual \"%s\"", name)
+	}
+
+	// test non-nil allocation with matching namespace options
+	options.SharedNamespaces = []string{"namespace1"}
+	name, err = alloc.determineSharingName(options)
+	if err != nil {
+		t.Fatalf("determineSharingName: expected no error; actual \"%s\"", err)
+	} else if err != nil || name != "namespace1" {
+		t.Fatalf("determineSharingName: expected \"namespace1\"; actual \"%s\"", name)
+	}
+
+	// test non-nil allocation with non-matching namespace options
+	options.SharedNamespaces = []string{"namespace2"}
+	name, err = alloc.determineSharingName(options)
+	if err != nil {
+		t.Fatalf("determineSharingName: expected no error; actual \"%s\"", err)
+	} else if err != nil || name != "unknown" {
+		t.Fatalf("determineSharingName: expected \"unknown\"; actual \"%s\"", name)
+	}
+
+	// test non-nil allocation with matching label options
+	options.SharedNamespaces = nil
+	options.SharedLabels = map[string][]string{
+		"app": {"app1"},
+	}
+	name, err = alloc.determineSharingName(options)
+	if err != nil {
+		t.Fatalf("determineSharingName: expected no error; actual \"%s\"", err)
+	} else if err != nil || name != "app1" {
+		t.Fatalf("determineSharingName: expected \"app1\"; actual \"%s\"", name)
+	}
+
+	// test non-nil allocation with partial-matching label options
+	options.SharedLabels = map[string][]string{
+		"app": {"app1", "app2"},
+	}
+	name, err = alloc.determineSharingName(options)
+	if err != nil {
+		t.Fatalf("determineSharingName: expected no error; actual \"%s\"", err)
+	} else if err != nil || name != "app1" {
+		t.Fatalf("determineSharingName: expected \"app1\"; actual \"%s\"", name)
+	}
+
+	// test non-nil allocation with non-matching label options
+	options.SharedLabels = map[string][]string{
+		"app": {"app2"},
+	}
+	name, err = alloc.determineSharingName(options)
+	if err != nil {
+		t.Fatalf("determineSharingName: expected no error; actual \"%s\"", err)
+	} else if err != nil || name != "unknown" {
+		t.Fatalf("determineSharingName: expected \"unknown\"; actual \"%s\"", name)
+	}
+
+	// test non-nil allocation with matching namespace and label options
+	options.SharedNamespaces = []string{"namespace1"}
+	options.SharedLabels = map[string][]string{
+		"app": {"app1"},
+	}
+	name, err = alloc.determineSharingName(options)
+	if err != nil {
+		t.Fatalf("determineSharingName: expected no error; actual \"%s\"", err)
+	} else if err != nil || name != "app1" {
+		t.Fatalf("determineSharingName: expected \"app1\"; actual \"%s\"", name)
+	}
+
+	// test non-nil allocation with non-matching namespace and matching label options
+	options.SharedNamespaces = []string{"namespace2"}
+	options.SharedLabels = map[string][]string{
+		"app": {"app1"},
+	}
+	name, err = alloc.determineSharingName(options)
+	if err != nil {
+		t.Fatalf("determineSharingName: expected no error; actual \"%s\"", err)
+	} else if err != nil || name != "app1" {
+		t.Fatalf("determineSharingName: expected \"app1\"; actual \"%s\"", name)
+	}
+
+	// test non-nil allocation with non-matching namespace and non-matching label options
+	options.SharedNamespaces = []string{"namespace2"}
+	options.SharedLabels = map[string][]string{
+		"app": {"app2"},
+	}
+	name, err = alloc.determineSharingName(options)
+	if err != nil {
+		t.Fatalf("determineSharingName: expected no error; actual \"%s\"", err)
+	} else if err != nil || name != "unknown" {
+		t.Fatalf("determineSharingName: expected \"unknown\"; actual \"%s\"", name)
+	}
+
+	// test non-nil allocation with multiple matching label options
+	alloc.Properties.Labels = map[string]string{
+		"app": "app1",
+		"env": "env1",
+	}
+	options.SharedNamespaces = nil
+	options.SharedLabels = map[string][]string{
+		"app": {"app1"},
+		"env": {"env1"},
+	}
+	name, err = alloc.determineSharingName(options)
+	if err != nil {
+		t.Fatalf("determineSharingName: expected no error; actual \"%s\"", err)
+	} else if err != nil || name != "app1" {
+		t.Fatalf("determineSharingName: expected \"app1\"; actual \"%s\"", name)
+	}
+
+	// test non-nil allocation with one matching label option
+	alloc.Properties.Labels = map[string]string{
+		"app": "app2",
+		"env": "env1",
+	}
+	options.SharedNamespaces = nil
+	options.SharedLabels = map[string][]string{
+		"app": {"app1"},
+		"env": {"env1"},
+	}
+	name, err = alloc.determineSharingName(options)
+	if err != nil {
+		t.Fatalf("determineSharingName: expected no error; actual \"%s\"", err)
+	} else if err != nil || name != "env1" {
+		t.Fatalf("determineSharingName: expected \"env1\"; actual \"%s\"", name)
+	}
+
+	// test non-nil allocation with one matching namespace option
+	alloc.Properties.Namespace = "namespace1"
+	options.SharedNamespaces = []string{"namespace1", "namespace2"}
+	options.SharedLabels = nil
+	name, err = alloc.determineSharingName(options)
+	if err != nil {
+		t.Fatalf("determineSharingName: expected no error; actual \"%s\"", err)
+	} else if err != nil || name != "namespace1" {
+		t.Fatalf("determineSharingName: expected \"namespace1\"; actual \"%s\"", name)
+	}
+
+	// test non-nil allocation with another one matching namespace option
+	alloc.Properties.Namespace = "namespace2"
+	options.SharedNamespaces = []string{"namespace1", "namespace2"}
+	options.SharedLabels = nil
+	name, err = alloc.determineSharingName(options)
+	if err != nil {
+		t.Fatalf("determineSharingName: expected no error; actual \"%s\"", err)
+	} else if err != nil || name != "namespace2" {
+		t.Fatalf("determineSharingName: expected \"namespace2\"; actual \"%s\"", name)
+	}
+
+	// test non-nil allocation with non-matching namespace options
+	alloc.Properties.Namespace = "namespace3"
+	options.SharedNamespaces = []string{"namespace1", "namespace2"}
+	name, err = alloc.determineSharingName(options)
+	if err != nil {
+		t.Fatalf("determineSharingName: expected no error; actual \"%s\"", err)
+	} else if err != nil || name != "unknown" {
+		t.Fatalf("determineSharingName: expected \"unknown\"; actual \"%s\"", name)
+	}
+}

+ 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)
+}

+ 88 - 11
pkg/kubecost/allocationprops.go

@@ -92,17 +92,19 @@ func ParseProperty(text string) (string, error) {
 
 // AllocationProperties describes a set of Kubernetes objects.
 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         AllocationLabels      `json:"labels,omitempty"`
-	Annotations    AllocationAnnotations `json:"annotations,omitempty"`
+	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               AllocationLabels      `json:"labels,omitempty"`
+	Annotations          AllocationAnnotations `json:"annotations,omitempty"`
+	NamespaceLabels      AllocationLabels      `json:"namespaceLabels,omitempty"`      // @bingen:field[version=17]
+	NamespaceAnnotations AllocationAnnotations `json:"namespaceAnnotations,omitempty"` // @bingen:field[version=17]
 	// When set to true, maintain the intersection of all labels + annotations
 	// in the aggregated AllocationProperties object
 	AggregatedMetadata bool `json:"-"` //@bingen:field[ignore]
@@ -141,12 +143,24 @@ func (p *AllocationProperties) Clone() *AllocationProperties {
 	}
 	clone.Labels = labels
 
+	nsLabels := make(map[string]string, len(p.NamespaceLabels))
+	for k, v := range p.NamespaceLabels {
+		nsLabels[k] = v
+	}
+	clone.NamespaceLabels = nsLabels
+
 	annotations := make(map[string]string, len(p.Annotations))
 	for k, v := range p.Annotations {
 		annotations[k] = v
 	}
 	clone.Annotations = annotations
 
+	nsAnnotations := make(map[string]string, len(p.NamespaceAnnotations))
+	for k, v := range p.NamespaceAnnotations {
+		nsAnnotations[k] = v
+	}
+	clone.NamespaceAnnotations = nsAnnotations
+
 	return clone
 }
 
@@ -200,6 +214,19 @@ func (p *AllocationProperties) Equal(that *AllocationProperties) bool {
 		return false
 	}
 
+	pNamespaceLabels := p.NamespaceLabels
+	thatNamespaceLabels := that.NamespaceLabels
+	if len(pNamespaceLabels) == len(thatNamespaceLabels) {
+		for k, pv := range pNamespaceLabels {
+			tv, ok := thatNamespaceLabels[k]
+			if !ok || tv != pv {
+				return false
+			}
+		}
+	} else {
+		return false
+	}
+
 	pAnnotations := p.Annotations
 	thatAnnotations := that.Annotations
 	if len(pAnnotations) == len(thatAnnotations) {
@@ -213,6 +240,19 @@ func (p *AllocationProperties) Equal(that *AllocationProperties) bool {
 		return false
 	}
 
+	pNamespaceAnnotations := p.NamespaceAnnotations
+	thatNamespaceAnnotations := that.NamespaceAnnotations
+	if len(pNamespaceAnnotations) == len(thatNamespaceAnnotations) {
+		for k, pv := range pNamespaceAnnotations {
+			tv, ok := thatNamespaceAnnotations[k]
+			if !ok || tv != pv {
+				return false
+			}
+		}
+	} else {
+		return false
+	}
+
 	pServices := p.Services
 	thatServices := that.Services
 	if len(pServices) == len(thatServices) {
@@ -452,6 +492,20 @@ func (p *AllocationProperties) Intersection(that *AllocationProperties) *Allocat
 	if p.Namespace == that.Namespace {
 
 		intersectionProps.Namespace = p.Namespace
+
+		// CORE-140: In the case that the namespace is the same, also copy over the namespaceLabels and annotations
+		// Note - assume that if the namespace is the same on both, then namespace label/annotation sets
+		// will be the same, so just carry one set over
+		if p.Container == UnmountedSuffix {
+			// This logic is designed to effectively ignore the unmounted/unallocated objects
+			// and just copy over the labels from the other, 'legitimate' allocation.
+			intersectionProps.NamespaceLabels = copyStringMap(that.NamespaceLabels)
+			intersectionProps.NamespaceAnnotations = copyStringMap(that.NamespaceAnnotations)
+		} else {
+			intersectionProps.NamespaceLabels = copyStringMap(p.NamespaceLabels)
+			intersectionProps.NamespaceAnnotations = copyStringMap(p.NamespaceAnnotations)
+		}
+
 		// ignore the incoming labels from unallocated or unmounted special case pods
 		if p.AggregatedMetadata || that.AggregatedMetadata {
 			intersectionProps.AggregatedMetadata = true
@@ -476,15 +530,26 @@ func (p *AllocationProperties) Intersection(that *AllocationProperties) *Allocat
 			}
 		}
 	}
+
 	if p.Pod == that.Pod {
 		intersectionProps.Pod = p.Pod
 	}
 	if p.ProviderID == that.ProviderID {
 		intersectionProps.ProviderID = p.ProviderID
 	}
+
 	return intersectionProps
 }
 
+func copyStringMap(original map[string]string) map[string]string {
+	copy := make(map[string]string)
+	for key, value := range original {
+		copy[key] = value
+	}
+
+	return copy
+}
+
 func mapIntersection(map1, map2 map[string]string) map[string]string {
 	result := make(map[string]string)
 	for key, value := range map1 {
@@ -548,11 +613,23 @@ func (p *AllocationProperties) String() string {
 	}
 	strs = append(strs, fmt.Sprintf("Labels:{%s}", strings.Join(labelStrs, ",")))
 
+	var nsLabelStrs []string
+	for k, prop := range p.NamespaceLabels {
+		nsLabelStrs = append(nsLabelStrs, fmt.Sprintf("%s:%s", k, prop))
+	}
+	strs = append(strs, fmt.Sprintf("NamespaceLabels:{%s}", strings.Join(nsLabelStrs, ",")))
+
 	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, ",")))
 
+	var nsAnnotationStrs []string
+	for k, prop := range p.NamespaceAnnotations {
+		nsAnnotationStrs = append(nsAnnotationStrs, fmt.Sprintf("%s:%s", k, prop))
+	}
+	strs = append(strs, fmt.Sprintf("NamespaceAnnotations:{%s}", strings.Join(nsAnnotationStrs, ",")))
+
 	return fmt.Sprintf("{%s}", strings.Join(strs, "; "))
 }

+ 151 - 23
pkg/kubecost/allocationprops_test.go

@@ -21,8 +21,10 @@ func TestAllocationPropsIntersection(t *testing.T) {
 				Annotations: map[string]string{},
 			},
 			expected: &AllocationProperties{
-				Labels:      nil,
-				Annotations: nil,
+				Labels:               nil,
+				Annotations:          nil,
+				NamespaceLabels:      map[string]string{},
+				NamespaceAnnotations: map[string]string{},
 			},
 		},
 		"nil intersection": {
@@ -30,7 +32,7 @@ func TestAllocationPropsIntersection(t *testing.T) {
 			allocationProps2: nil,
 			expected:         nil,
 		},
-		"intersection, with labels/annotations, no aggregated metdata": {
+		"intersection, with labels/annotations, no aggregated metadata": {
 			allocationProps1: &AllocationProperties{
 				AggregatedMetadata: false,
 				Node:               "node1",
@@ -44,13 +46,15 @@ func TestAllocationPropsIntersection(t *testing.T) {
 				Annotations:        map[string]string{"key4": "val4"},
 			},
 			expected: &AllocationProperties{
-				AggregatedMetadata: false,
-				Node:               "node1",
-				Labels:             nil,
-				Annotations:        nil,
+				AggregatedMetadata:   false,
+				Node:                 "node1",
+				Labels:               nil,
+				Annotations:          nil,
+				NamespaceLabels:      map[string]string{},
+				NamespaceAnnotations: map[string]string{},
 			},
 		},
-		"intersection, with labels/annotations, with aggregated metdata": {
+		"intersection, with labels/annotations, same values": {
 			allocationProps1: &AllocationProperties{
 				AggregatedMetadata: false,
 				ControllerKind:     "controller1",
@@ -66,11 +70,13 @@ func TestAllocationPropsIntersection(t *testing.T) {
 				Annotations:        map[string]string{"key2": "val2"},
 			},
 			expected: &AllocationProperties{
-				AggregatedMetadata: true,
-				Namespace:          "ns1",
-				ControllerKind:     "",
-				Labels:             map[string]string{"key1": "val1"},
-				Annotations:        map[string]string{"key2": "val2"},
+				AggregatedMetadata:   true,
+				Namespace:            "ns1",
+				ControllerKind:       "",
+				Labels:               map[string]string{"key1": "val1"},
+				Annotations:          map[string]string{"key2": "val2"},
+				NamespaceLabels:      map[string]string{},
+				NamespaceAnnotations: map[string]string{},
 			},
 		},
 		"intersection, with labels/annotations, special case container": {
@@ -89,11 +95,13 @@ func TestAllocationPropsIntersection(t *testing.T) {
 				Annotations:        map[string]string{"key2": "val2"},
 			},
 			expected: &AllocationProperties{
-				AggregatedMetadata: true,
-				Namespace:          "ns1",
-				ControllerKind:     "",
-				Labels:             map[string]string{"key1": "val1"},
-				Annotations:        map[string]string{"key2": "val2"},
+				AggregatedMetadata:   true,
+				Namespace:            "ns1",
+				ControllerKind:       "",
+				Labels:               map[string]string{"key1": "val1"},
+				Annotations:          map[string]string{"key2": "val2"},
+				NamespaceLabels:      map[string]string{},
+				NamespaceAnnotations: map[string]string{},
 			},
 		},
 		"test services are nulled when intersecting": {
@@ -115,11 +123,13 @@ func TestAllocationPropsIntersection(t *testing.T) {
 				Annotations:        map[string]string{"key2": "val2"},
 			},
 			expected: &AllocationProperties{
-				AggregatedMetadata: true,
-				Namespace:          "ns1",
-				ControllerKind:     "",
-				Labels:             map[string]string{"key1": "val1"},
-				Annotations:        map[string]string{"key2": "val2"},
+				AggregatedMetadata:   true,
+				Namespace:            "ns1",
+				ControllerKind:       "",
+				Labels:               map[string]string{"key1": "val1"},
+				Annotations:          map[string]string{"key2": "val2"},
+				NamespaceLabels:      map[string]string{},
+				NamespaceAnnotations: map[string]string{},
 			},
 		},
 	}
@@ -237,3 +247,121 @@ func TestGenerateKey(t *testing.T) {
 		})
 	}
 }
+
+func TestIntersection(t *testing.T) {
+
+	propsEmpty := AllocationProperties{}
+
+	propsMedium := AllocationProperties{
+		Cluster:        "cluster1",
+		Node:           "Node1",
+		Container:      "container1",
+		Controller:     "controller1",
+		ControllerKind: "controllerkind1",
+		Namespace:      "ns1",
+		Pod:            "pod1",
+		Services:       []string{"service1"},
+		ProviderID:     "provider1",
+	}
+
+	propsFull := AllocationProperties{
+		Cluster:              "cluster2",
+		Node:                 "Node2",
+		Container:            "container2",
+		Controller:           "controller2",
+		ControllerKind:       "controllerkind2",
+		Namespace:            "ns2",
+		Pod:                  "pod2",
+		Services:             []string{"service2"},
+		ProviderID:           "provider2",
+		NamespaceLabels:      AllocationLabels{"key1": "value1"},
+		NamespaceAnnotations: AllocationAnnotations{"key2": "value2", "key5": "value5"},
+		Labels:               AllocationLabels{"key3": "value3"},
+		Annotations:          AllocationAnnotations{"key4": "value4"},
+	}
+
+	// Case 1: no intersection
+	// expect empty result object
+	testObj1 := AllocationProperties{}
+
+	result := testObj1.Intersection(&propsEmpty)
+
+	if !result.Equal(&propsEmpty) {
+		t.Fatalf("Case 1: expected empty object, no intersection")
+	}
+
+	// Case 2: Only has labels/annotations
+	// expect empty result object
+	testObj2 := AllocationProperties{
+		Labels:      map[string]string{"app": "product-label-light"},
+		Annotations: map[string]string{"app": "product-annotation-light"},
+	}
+
+	result = testObj2.Intersection(&propsMedium)
+
+	if !result.Equal(&propsEmpty) {
+		t.Fatalf("Case 2: expected empty object, no intersection")
+	}
+
+	// Case 3: Has non-label/annotations set
+	// expect all non label/annotation/service string array fields to be unset
+	// different container names should be omitted
+	testObj3 := AllocationProperties{
+		Cluster:        "cluster1",
+		Node:           "Node1",
+		Container:      "container2",
+		Controller:     "controller1",
+		ControllerKind: "controllerkind1",
+		Namespace:      "ns1",
+		Pod:            "pod1",
+		Services:       []string{"service1"},
+		ProviderID:     "provider1",
+	}
+
+	expectedResult := AllocationProperties{
+		Cluster:        "cluster1",
+		Node:           "Node1",
+		Controller:     "controller1",
+		ControllerKind: "controllerkind1",
+		Namespace:      "ns1",
+		Pod:            "pod1",
+		ProviderID:     "provider1",
+	}
+
+	result = testObj3.Intersection(&propsMedium)
+
+	if !result.Equal(&expectedResult) {
+		t.Fatalf("Case 3: expected output %v does not match actual output %v", expectedResult, result)
+	}
+
+	// Case 4: Copy over NamespaceLabels/Annots when namespace is the same
+	testObj4 := AllocationProperties{
+		Cluster:              "cluster2",
+		Node:                 "NodeX",
+		Container:            "containerX",
+		Controller:           "controllerX",
+		ControllerKind:       "controllerkindX",
+		Namespace:            "ns2",
+		Pod:                  "podX",
+		Services:             []string{"serviceX"},
+		ProviderID:           "providerX",
+		NamespaceLabels:      AllocationLabels{"key1": "value1"},
+		NamespaceAnnotations: AllocationAnnotations{"key2": "value2", "key5": "value5"},
+		Labels:               AllocationLabels{"key3": "value3"},
+		Annotations:          AllocationAnnotations{"key4": "value4"},
+	}
+
+	expectedResult = AllocationProperties{
+		Cluster:              "cluster2",
+		Namespace:            "ns2",
+		NamespaceLabels:      AllocationLabels{"key1": "value1"},
+		NamespaceAnnotations: AllocationAnnotations{"key2": "value2", "key5": "value5"},
+	}
+
+	result = testObj4.Intersection(&propsFull)
+
+	if !result.Equal(&expectedResult) {
+		t.Fatalf("Case 4: expected output %v does not match actual output %v", expectedResult, result)
+	}
+
+}

+ 1 - 1
pkg/kubecost/bingen.go

@@ -46,7 +46,7 @@ package kubecost
 // @bingen:end
 
 // Allocation Version Set: Includes Allocation pipeline specific resources
-// @bingen:set[name=Allocation,version=16]
+// @bingen:set[name=Allocation,version=17]
 // @bingen:generate:Allocation
 // @bingen:generate[stringtable]:AllocationSet
 // @bingen:generate:AllocationSetRange

+ 147 - 2
pkg/kubecost/kubecost_codecs.go

@@ -13,11 +13,12 @@ package kubecost
 
 import (
 	"fmt"
-	util "github.com/opencost/opencost/pkg/util"
 	"reflect"
 	"strings"
 	"sync"
 	"time"
+
+	util "github.com/opencost/opencost/pkg/util"
 )
 
 const (
@@ -40,7 +41,7 @@ const (
 	AssetsCodecVersion uint8 = 19
 
 	// AllocationCodecVersion is used for any resources listed in the Allocation version set
-	AllocationCodecVersion uint8 = 16
+	AllocationCodecVersion uint8 = 17
 
 	// AuditCodecVersion is used for any resources listed in the Audit version set
 	AuditCodecVersion uint8 = 1
@@ -1186,6 +1187,60 @@ func (target *AllocationProperties) MarshalBinaryWithContext(ctx *EncodingContex
 	}
 	// --- [end][write][alias](AllocationAnnotations) ---
 
+	// --- [begin][write][alias](AllocationLabels) ---
+	if map[string]string(target.NamespaceLabels) == nil {
+		buff.WriteUInt8(uint8(0)) // write nil byte
+	} else {
+		buff.WriteUInt8(uint8(1)) // write non-nil byte
+
+		// --- [begin][write][map](map[string]string) ---
+		buff.WriteInt(len(map[string]string(target.NamespaceLabels))) // map length
+		for vvv, zzz := range map[string]string(target.NamespaceLabels) {
+			if ctx.IsStringTable() {
+				p := ctx.Table.AddOrGet(vvv)
+				buff.WriteInt(p) // write table index
+			} else {
+				buff.WriteString(vvv) // write string
+			}
+			if ctx.IsStringTable() {
+				q := ctx.Table.AddOrGet(zzz)
+				buff.WriteInt(q) // write table index
+			} else {
+				buff.WriteString(zzz) // write string
+			}
+		}
+		// --- [end][write][map](map[string]string) ---
+
+	}
+	// --- [end][write][alias](AllocationLabels) ---
+
+	// --- [begin][write][alias](AllocationAnnotations) ---
+	if map[string]string(target.NamespaceAnnotations) == nil {
+		buff.WriteUInt8(uint8(0)) // write nil byte
+	} else {
+		buff.WriteUInt8(uint8(1)) // write non-nil byte
+
+		// --- [begin][write][map](map[string]string) ---
+		buff.WriteInt(len(map[string]string(target.NamespaceAnnotations))) // map length
+		for vvvv, zzzz := range map[string]string(target.NamespaceAnnotations) {
+			if ctx.IsStringTable() {
+				r := ctx.Table.AddOrGet(vvvv)
+				buff.WriteInt(r) // write table index
+			} else {
+				buff.WriteString(vvvv) // write string
+			}
+			if ctx.IsStringTable() {
+				s := ctx.Table.AddOrGet(zzzz)
+				buff.WriteInt(s) // write table index
+			} else {
+				buff.WriteString(zzzz) // write string
+			}
+		}
+		// --- [end][write][map](map[string]string) ---
+
+	}
+	// --- [end][write][alias](AllocationAnnotations) ---
+
 	return nil
 }
 
@@ -1427,6 +1482,96 @@ func (target *AllocationProperties) UnmarshalBinaryWithContext(ctx *DecodingCont
 	target.Annotations = AllocationAnnotations(tt)
 	// --- [end][read][alias](AllocationAnnotations) ---
 
+	// field version check
+	if uint8(17) <= version {
+		// --- [begin][read][alias](AllocationLabels) ---
+		var eee map[string]string
+		if buff.ReadUInt8() == uint8(0) {
+			eee = nil
+		} else {
+			// --- [begin][read][map](map[string]string) ---
+			ggg := buff.ReadInt() // map len
+			fff := make(map[string]string, ggg)
+			for jj := 0; jj < ggg; jj++ {
+				var vvv string
+				var kkk string
+				if ctx.IsStringTable() {
+					lll := buff.ReadInt() // read string index
+					kkk = ctx.Table[lll]
+				} else {
+					kkk = buff.ReadString() // read string
+				}
+				hhh := kkk
+				vvv = hhh
+
+				var zzz string
+				var nnn string
+				if ctx.IsStringTable() {
+					ooo := buff.ReadInt() // read string index
+					nnn = ctx.Table[ooo]
+				} else {
+					nnn = buff.ReadString() // read string
+				}
+				mmm := nnn
+				zzz = mmm
+
+				fff[vvv] = zzz
+			}
+			eee = fff
+			// --- [end][read][map](map[string]string) ---
+
+		}
+		target.NamespaceLabels = AllocationLabels(eee)
+		// --- [end][read][alias](AllocationLabels) ---
+
+	} else {
+	}
+
+	// field version check
+	if uint8(17) <= version {
+		// --- [begin][read][alias](AllocationAnnotations) ---
+		var ppp map[string]string
+		if buff.ReadUInt8() == uint8(0) {
+			ppp = nil
+		} else {
+			// --- [begin][read][map](map[string]string) ---
+			rrr := buff.ReadInt() // map len
+			qqq := make(map[string]string, rrr)
+			for iii := 0; iii < rrr; iii++ {
+				var vvvv string
+				var ttt string
+				if ctx.IsStringTable() {
+					uuu := buff.ReadInt() // read string index
+					ttt = ctx.Table[uuu]
+				} else {
+					ttt = buff.ReadString() // read string
+				}
+				sss := ttt
+				vvvv = sss
+
+				var zzzz string
+				var xxx string
+				if ctx.IsStringTable() {
+					yyy := buff.ReadInt() // read string index
+					xxx = ctx.Table[yyy]
+				} else {
+					xxx = buff.ReadString() // read string
+				}
+				www := xxx
+				zzzz = www
+
+				qqq[vvvv] = zzzz
+			}
+			ppp = qqq
+			// --- [end][read][map](map[string]string) ---
+
+		}
+		target.NamespaceAnnotations = AllocationAnnotations(ppp)
+		// --- [end][read][alias](AllocationAnnotations) ---
+
+	} else {
+	}
+
 	return nil
 }
 

+ 12 - 0
pkg/kubecost/kubecost_codecs_test.go

@@ -480,6 +480,14 @@ func TestProperties_BinaryEncoding(t *testing.T) {
 	p0.Controller = "daemonset-abc"
 	p0.ControllerKind = "daemonset"
 	p0.Namespace = "namespace1"
+	p0.NamespaceLabels = map[string]string{
+		"app":                "cost-analyzer-namespace",
+		"kubernetes.io/name": "cost-analyzer",
+	}
+	p0.NamespaceAnnotations = map[string]string{
+		"com.kubernetes.io/managed-by":             "helm",
+		"kubernetes.io/last-applied-configuration": "cost-analyzer",
+	}
 	p0.Node = "node1"
 	p0.Pod = "daemonset-abc-123"
 	p0.Labels = map[string]string{
@@ -508,6 +516,10 @@ func TestProperties_BinaryEncoding(t *testing.T) {
 	p0.Controller = "daemonset-abc"
 	p0.ControllerKind = "daemonset"
 	p0.Namespace = "namespace1"
+	p0.NamespaceAnnotations = map[string]string{
+		"com.kubernetes.io/managed-by":             "helm",
+		"kubernetes.io/last-applied-configuration": "cost-analyzer",
+	}
 	p0.Services = []string{}
 	bs, err = p0.MarshalBinary()
 	if err != nil {

+ 8 - 8
pkg/prom/diagnostics.go

@@ -60,14 +60,14 @@ const DocumentationBaseURL = "https://github.com/kubecost/docs/blob/master/diagn
 var diagnosticDefinitions map[string]*diagnosticDefinition = map[string]*diagnosticDefinition{
 	CAdvisorDiagnosticMetricID: {
 		ID:          CAdvisorDiagnosticMetricID,
-		QueryFmt:    `absent_over_time(container_cpu_usage_seconds_total[5m] %s)`,
+		QueryFmt:    `absent_over_time(container_cpu_usage_seconds_total{%s}[5m] %s)`,
 		Label:       "cAdvisor metrics available",
 		Description: "Determine if cAdvisor metrics are available during last 5 minutes.",
 		DocLink:     fmt.Sprintf("%s#cadvisor-metrics-available", DocumentationBaseURL),
 	},
 	KSMDiagnosticMetricID: {
 		ID:          KSMDiagnosticMetricID,
-		QueryFmt:    `absent_over_time(kube_pod_container_resource_requests{resource="memory", unit="byte"}[5m] %s)`,
+		QueryFmt:    `absent_over_time(kube_pod_container_resource_requests{resource="memory", unit="byte", %s}[5m] %s)`,
 		Label:       "Kube-state-metrics available",
 		Description: "Determine if metrics from kube-state-metrics are available during last 5 minutes.",
 		DocLink:     fmt.Sprintf("%s#kube-state-metrics-metrics-available", DocumentationBaseURL),
@@ -87,7 +87,7 @@ var diagnosticDefinitions map[string]*diagnosticDefinition = map[string]*diagnos
 	},
 	CAdvisorLabelDiagnosticMetricID: {
 		ID:          CAdvisorLabelDiagnosticMetricID,
-		QueryFmt:    `absent_over_time(container_cpu_usage_seconds_total{container!="",pod!="",%s}[5m] %s)`,
+		QueryFmt:    `absent_over_time(container_cpu_usage_seconds_total{container!="",pod!="", %s}[5m] %s)`,
 		Label:       "Expected cAdvisor labels available",
 		Description: "Determine if expected cAdvisor labels are present during last 5 minutes.",
 		DocLink:     fmt.Sprintf("%s#cadvisor-metrics-available", DocumentationBaseURL),
@@ -107,33 +107,33 @@ var diagnosticDefinitions map[string]*diagnosticDefinition = map[string]*diagnos
 	},
 	CPUThrottlingDiagnosticMetricID: {
 		ID: CPUThrottlingDiagnosticMetricID,
-		QueryFmt: `avg(increase(container_cpu_cfs_throttled_periods_total{container="cost-model",%s}[10m] %s)) by (container_name, pod_name, namespace)
+		QueryFmt: `avg(increase(container_cpu_cfs_throttled_periods_total{container="cost-model", %s}[10m] %s)) by (container_name, pod_name, namespace)
 	/ avg(increase(container_cpu_cfs_periods_total{container="cost-model",%s}[10m] %s)) by (container_name, pod_name, namespace) > 0.2`,
 		Label:       "Kubecost is not CPU throttled",
 		Description: "Kubecost loading slowly? A kubecost component might be CPU throttled",
 	},
 	KubecostRecordingRuleCPUUsageID: {
 		ID:          KubecostRecordingRuleCPUUsageID,
-		QueryFmt:    `absent_over_time(kubecost_container_cpu_usage_irate[5m] %s)`,
+		QueryFmt:    `absent_over_time(kubecost_container_cpu_usage_irate{%s}[5m] %s)`,
 		Label:       "Kubecost's CPU usage recording rule is set up",
 		Description: "If the 'kubecost_container_cpu_usage_irate' recording rule is not set up, Allocation pipeline build may put pressure on your Prometheus due to the use of a subquery.",
 		DocLink:     "https://docs.kubecost.com/install-and-configure/install/custom-prom",
 	},
 	CAdvisorWorkingSetBytesMetricID: {
 		ID:          CAdvisorWorkingSetBytesMetricID,
-		QueryFmt:    `absent_over_time(container_memory_working_set_bytes{container="cost-model", container!="POD", instance!=""}[5m] %s)`,
+		QueryFmt:    `absent_over_time(container_memory_working_set_bytes{container="cost-model", container!="POD", instance!="", %s}[5m] %s)`,
 		Label:       "cAdvisor working set bytes metrics available",
 		Description: "Determine if cAdvisor working set bytes metrics are available during last 5 minutes.",
 	},
 	KSMCPUCapacityMetricID: {
 		ID:          KSMCPUCapacityMetricID,
-		QueryFmt:    `absent_over_time(kube_node_status_capacity_cpu_cores[5m] %s)`,
+		QueryFmt:    `absent_over_time(kube_node_status_capacity_cpu_cores{%s}[5m] %s)`,
 		Label:       "KSM had CPU capacity during the last 5 minutes",
 		Description: "Determine if KSM had CPU capacity during the last 5 minutes",
 	},
 	KSMAllocatableCPUCoresMetricID: {
 		ID:          KSMAllocatableCPUCoresMetricID,
-		QueryFmt:    `absent_over_time(kube_node_status_allocatable_cpu_cores[5m] %s)`,
+		QueryFmt:    `absent_over_time(kube_node_status_allocatable_cpu_cores{%s}[5m] %s)`,
 		Label:       "KSM had allocatable CPU cores during the last 5 minutes",
 		Description: "Determine if KSM had allocatable CPU cores during the last 5 minutes",
 	},

+ 2 - 2
pkg/storage/s3storage.go

@@ -16,7 +16,7 @@ import (
 
 	"github.com/opencost/opencost/pkg/log"
 
-	aws "github.com/aws/aws-sdk-go-v2/aws"
+	"github.com/aws/aws-sdk-go-v2/aws"
 	awsconfig "github.com/aws/aws-sdk-go-v2/config"
 
 	"github.com/minio/minio-go/v7"
@@ -565,6 +565,7 @@ func (s3 *S3Storage) getRange(ctx context.Context, name string, off, length int6
 		}
 	}
 	r, err := s3.client.GetObject(ctx, s3.name, name, *opts)
+	defer r.Close()
 	if err != nil {
 		if s3.isObjNotFound(err) {
 			return nil, DoesNotExistError
@@ -575,7 +576,6 @@ func (s3 *S3Storage) getRange(ctx context.Context, name string, off, length int6
 	// NotFoundObject error is revealed only after first Read. This does the initial GetRequest. Prefetch this here
 	// for convenience.
 	if _, err := r.Read(nil); err != nil {
-		r.Close()
 		if s3.isObjNotFound(err) {
 			return nil, DoesNotExistError
 		}

+ 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())
+}