Преглед изворни кода

Allocation ETL: on-demand external cost

Niko Kovacevic пре 5 година
родитељ
комит
638e2fe575

+ 16 - 47
pkg/kubecost/allocation.go

@@ -53,6 +53,7 @@ type Allocation struct {
 	RAMCost         float64    `json:"ramCost"`
 	RAMEfficiency   float64    `json:"ramEfficiency"`
 	SharedCost      float64    `json:"sharedCost"`
+	ExternalCost    float64    `json:"externalCost"`
 	TotalCost       float64    `json:"totalCost"`
 	TotalEfficiency float64    `json:"totalEfficiency"`
 	// Profiler        *log.Profiler `json:"-"`
@@ -106,6 +107,7 @@ func (a *Allocation) Clone() *Allocation {
 		RAMCost:         a.RAMCost,
 		RAMEfficiency:   a.RAMEfficiency,
 		SharedCost:      a.SharedCost,
+		ExternalCost:    a.ExternalCost,
 		TotalCost:       a.TotalCost,
 		TotalEfficiency: a.TotalEfficiency,
 	}
@@ -169,6 +171,9 @@ func (a *Allocation) Equal(that *Allocation) bool {
 	if a.SharedCost != that.SharedCost {
 		return false
 	}
+	if a.ExternalCost != that.ExternalCost {
+		return false
+	}
 	if a.TotalCost != that.TotalCost {
 		return false
 	}
@@ -203,45 +208,6 @@ func (a *Allocation) IsUnallocated() bool {
 	return strings.Contains(a.Name, UnallocatedSuffix)
 }
 
-// MatchesFilter returns true if the Allocation passes the given AllocationFilter
-func (a *Allocation) MatchesFilter(f AllocationMatchFunc) bool {
-	return f(a)
-}
-
-// MatchesAll takes a variadic list of Properties, returning true iff the
-// Allocation matches each set of Properties.
-func (a *Allocation) MatchesAll(ps ...Properties) bool {
-	// nil Allocation don't match any Properties
-	if a == nil {
-		return false
-	}
-
-	for _, p := range ps {
-		if !a.Properties.Matches(p) {
-			return false
-		}
-	}
-
-	return true
-}
-
-// MatchesOne takes a variadic list of Properties, returning true iff the
-// Allocation matches at least one of the set of Properties.
-func (a *Allocation) MatchesOne(ps ...Properties) bool {
-	// nil Allocation don't match any Properties
-	if a == nil {
-		return false
-	}
-
-	for _, p := range ps {
-		if a.Properties.Matches(p) {
-			return true
-		}
-	}
-
-	return false
-}
-
 // Share works like Add, but converts the entire cost of the given Allocation
 // to SharedCost, rather than adding to the individual resource costs.
 func (a *Allocation) Share(that *Allocation) (*Allocation, error) {
@@ -268,16 +234,18 @@ func (a *Allocation) String() string {
 }
 
 func (a *Allocation) add(that *Allocation, isShared, isAccumulating bool) {
-	if a == nil {
-		a = that
+	// TODO niko/allocation-etl this can't possibly work as it reads
+	// ...right?? (See https://play.golang.org/p/UDZ-GsNJ1rI)
+	// if a == nil {
+	// 	a = that
 
-		// reset properties
-		thatCluster, _ := that.Properties.GetCluster()
-		thatNode, _ := that.Properties.GetNode()
-		a.Properties = Properties{ClusterProp: thatCluster, NodeProp: thatNode}
+	// 	// reset properties
+	// 	thatCluster, _ := that.Properties.GetCluster()
+	// 	thatNode, _ := that.Properties.GetNode()
+	// 	a.Properties = Properties{ClusterProp: thatCluster, NodeProp: thatNode}
 
-		return
-	}
+	// 	return
+	// }
 
 	aCluster, _ := a.Properties.GetCluster()
 	thatCluster, _ := that.Properties.GetCluster()
@@ -351,6 +319,7 @@ func (a *Allocation) add(that *Allocation, isShared, isAccumulating bool) {
 		}
 
 		a.SharedCost += that.SharedCost
+		a.ExternalCost += that.ExternalCost
 		a.CPUCost += that.CPUCost
 		a.GPUCost += that.GPUCost
 		a.NetworkCost += that.NetworkCost

+ 0 - 100
pkg/kubecost/allocation_test.go

@@ -98,106 +98,6 @@ func TestAllocation_Add(t *testing.T) {
 // TODO niko/etl
 // func TestAllocation_IsIdle(t *testing.T) {}
 
-func TestAllocation_MatchesAll(t *testing.T) {
-	var alloc *Allocation
-
-	// nil Allocations never match
-	if alloc.MatchesAll() {
-		t.Fatalf("Allocation.MatchesAll: expected no match on nil allocation")
-	}
-
-	today := time.Now().UTC().Truncate(day)
-	alloc = NewUnitAllocation("", today, day, nil)
-
-	// Matches when no Properties are given
-	if !alloc.MatchesAll() {
-		t.Fatalf("Allocation.MatchesAll: expected match on no conditions")
-	}
-
-	// Matches when all Properties match
-	if !alloc.MatchesAll(Properties{
-		NamespaceProp: "namespace1",
-	}, Properties{
-		ClusterProp:        "cluster1",
-		ControllerKindProp: "deployment",
-	}, Properties{
-		NodeProp: "node1",
-	}) {
-		t.Fatalf("Allocation.MatchesAll: expected match when all Properties are met")
-	}
-
-	// Doesn't match when one Property doesn't match
-	if alloc.MatchesAll(Properties{
-		NamespaceProp: "namespace1",
-		ServiceProp:   []string{"missing"},
-	}, Properties{
-		ClusterProp:        "cluster1",
-		ControllerKindProp: "deployment",
-	}) {
-		t.Fatalf("Allocation.MatchesAll: expected no match when one Properties is not met")
-	}
-
-	// Doesn't match when no Properties are met
-	if alloc.MatchesAll(Properties{
-		NamespaceProp: "namespace1",
-		ServiceProp:   []string{"missing"},
-	}, Properties{
-		ClusterProp:        "cluster2",
-		ControllerKindProp: "deployment",
-	}) {
-		t.Fatalf("Allocation.MatchesAll: expected no match when no Properties are met")
-	}
-}
-
-func TestAllocation_MatchesOne(t *testing.T) {
-	var alloc *Allocation
-
-	// nil Allocations never match
-	if alloc.MatchesOne() {
-		t.Fatalf("Allocation.MatchesOne: expected no match on nil allocation")
-	}
-
-	today := time.Now().UTC().Truncate(day)
-	alloc = NewUnitAllocation("", today, day, nil)
-
-	// Doesn't match when no Properties are given
-	if alloc.MatchesOne() {
-		t.Fatalf("Allocation.MatchesOne: expected no match on no conditions")
-	}
-
-	// Matches when all Properties match
-	if !alloc.MatchesOne(Properties{
-		NamespaceProp: "namespace1",
-	}, Properties{
-		ClusterProp:        "cluster1",
-		ControllerKindProp: "deployment",
-	}) {
-		t.Fatalf("Allocation.MatchesOne: expected match when all Properties are met")
-	}
-
-	// Matches when one Property doesn't match
-	if !alloc.MatchesOne(Properties{
-		NamespaceProp: "namespace1",
-		ServiceProp:   []string{"missing"},
-	}, Properties{
-		ClusterProp:        "cluster1",
-		ControllerKindProp: "deployment",
-	}) {
-		t.Fatalf("Allocation.MatchesOne: expected match when one Properties is met")
-	}
-
-	// Doesn't match when no Properties are met
-	if alloc.MatchesOne(Properties{
-		NamespaceProp: "namespace1",
-		ServiceProp:   []string{"missing"},
-	}, Properties{
-		ClusterProp:        "cluster2",
-		ControllerKindProp: "deployment",
-	}) {
-		t.Fatalf("Allocation.MatchesOne: expected no match when no Properties are met")
-	}
-}
-
 func TestAllocation_String(t *testing.T) {
 	// TODO niko/etl
 }

+ 97 - 0
pkg/kubecost/asset.go

@@ -56,6 +56,103 @@ type Asset interface {
 	fmt.Stringer
 }
 
+// AssetToExternalAllocation converts the given asset to an Allocation, given
+// the properties to use to aggregate, and the mapping from Allocation property
+// to Asset label. For example, consider this asset:
+//
+//   Cloud {
+// 	   TotalCost: 10.00,
+// 	   Labels{
+//       "kubernetes_namespace":"monitoring",
+// 	     "env":"prod"
+// 	   }
+//   }
+//
+// Given the following parameters, we expect to return:
+//
+//   1) single-prop full match
+//   aggregateBy = ["namespace"]
+//   allocationPropertyLabels = {"namespace":"kubernetes_namespace"}
+//   => Allocation{Name: "monitoring", ExternalCost: 10.00, TotalCost: 10.00}, nil
+//
+//   2) multi-prop full match
+//   aggregateBy = ["namespace", "label:env"]
+//   allocationPropertyLabels = {"namespace":"kubernetes_namespace"}
+//   => Allocation{Name: "monitoring/env=prod", ExternalCost: 10.00, TotalCost: 10.00}, nil
+//
+//   3) multi-prop partial match
+//   aggregateBy = ["namespace", "label:foo"]
+//   allocationPropertyLabels = {"namespace":"kubernetes_namespace"}
+//   => Allocation{Name: "monitoring/__unallocated__", ExternalCost: 10.00, TotalCost: 10.00}, nil
+//
+//   4) no match
+//   aggregateBy = ["cluster"]
+//   allocationPropertyLabels = {"namespace":"kubernetes_namespace"}
+//   => nil, err
+//
+// (See asset_test.go for assertions of these examples and more.)
+func AssetToExternalAllocation(asset Asset, aggregateBy []string, allocationPropertyLabels map[string]string) (*Allocation, error) {
+	if asset == nil {
+		return nil, fmt.Errorf("asset is nil")
+	}
+
+	// names will collect the slash-separated names accrued by iterating over
+	// aggregateBy and checking the relevant labels.
+	names := []string{}
+	// match records whether or not a match was found in the Asset labels,
+	// such that is can genuinely be turned into an external Allocation.
+	match := false
+
+	for _, aggBy := range aggregateBy {
+		// labelName should be derived from the mapping of properties to
+		// label names, unless the aggBy is explicitly a label, in which
+		// case we should pull the label name from the aggBy string.
+		labelName := allocationPropertyLabels[aggBy]
+		if strings.HasPrefix(aggBy, "label:") {
+			labelName = strings.TrimPrefix(aggBy, "label:")
+		}
+
+		if labelName == "" {
+			// No matching label has been defined in the cost-analyzer label config
+			// relating to the given aggregateBy property.
+			names = append(names, UnallocatedSuffix)
+			continue
+		}
+
+		if value := asset.Labels()[labelName]; value != "" {
+			// Valid label value was found for one of the aggregation properties,
+			// so add it to the name.
+			if strings.HasPrefix(aggBy, "label:") {
+				// Use naming convention labelName=labelValue for labels
+				// e.g. aggBy="label:env", value="prod" => "env=prod"
+				names = append(names, fmt.Sprintf("%s=%s", strings.TrimPrefix(aggBy, "label:"), value))
+				match = true
+			} else {
+				names = append(names, value)
+				match = true
+			}
+		} else {
+			// No value label value was found on the Asset; consider it
+			// unallocated. Note that this case is only truly relevant if at
+			// least one other property matches (e.g. case 3 in the examples)
+			// because if there are no matches, then an error is returned.
+			names = append(names, UnallocatedSuffix)
+		}
+	}
+
+	if !match {
+		return nil, fmt.Errorf("asset does not qualify as an external allocation")
+	}
+
+	// TODO niko/allocation-etl efficiency?
+	// TODO niko/allocation-etl resource totals?
+	return &Allocation{
+		Name:         strings.Join(names, "/"),
+		ExternalCost: asset.TotalCost(),
+		TotalCost:    asset.TotalCost(),
+	}, nil
+}
+
 // key is used to determine uniqueness of an Asset, for instance during Insert
 // to determine if two Assets should be combined. Passing nil props indicates
 // that all available props should be used. Passing empty props indicates that

+ 250 - 135
pkg/kubecost/asset_test.go

@@ -23,6 +23,150 @@ var windows = []Window{
 
 const gb = 1024 * 1024 * 1024
 
+// generateAssetSet generates the following topology:
+//
+// | Asset                        | Cost |  Adj |
+// +------------------------------+------+------+
+//   cluster1:
+//     node1:                        6.00   1.00
+//     node2:                        4.00   1.50
+//     node3:                        7.00  -0.50
+//     disk1:                        2.50   0.00
+//     disk2:                        1.50   0.00
+//     clusterManagement1:           3.00   0.00
+// +------------------------------+------+------+
+//   cluster1 subtotal              24.00   2.00
+// +------------------------------+------+------+
+//   cluster2:
+//     node4:                       12.00  -1.00
+//     disk3:                        2.50   0.00
+//     disk4:                        1.50   0.00
+//     clusterManagement2:           0.00   0.00
+// +------------------------------+------+------+
+//   cluster2 subtotal              16.00  -1.00
+// +------------------------------+------+------+
+//   cluster3:
+//     node5:                       17.00   2.00
+// +------------------------------+------+------+
+//   cluster3 subtotal              17.00   2.00
+// +------------------------------+------+------+
+//   total                          57.00   3.00
+// +------------------------------+------+------+
+func generateAssetSet(start time.Time) *AssetSet {
+	end := start.Add(day)
+	window := NewWindow(&start, &end)
+
+	hours := window.Duration().Hours()
+
+	node1 := NewNode("node1", "cluster1", "gcp-node1", *window.Clone().start, *window.Clone().end, window.Clone())
+	node1.CPUCost = 4.0
+	node1.RAMCost = 4.0
+	node1.GPUCost = 2.0
+	node1.Discount = 0.5
+	node1.CPUCoreHours = 2.0 * hours
+	node1.RAMByteHours = 4.0 * gb * hours
+	node1.SetAdjustment(1.0)
+
+	node2 := NewNode("node2", "cluster1", "gcp-node2", *window.Clone().start, *window.Clone().end, window.Clone())
+	node2.CPUCost = 4.0
+	node2.RAMCost = 4.0
+	node2.GPUCost = 0.0
+	node2.Discount = 0.5
+	node2.CPUCoreHours = 2.0 * hours
+	node2.RAMByteHours = 4.0 * gb * hours
+	node2.SetAdjustment(1.5)
+
+	node3 := NewNode("node3", "cluster1", "gcp-node3", *window.Clone().start, *window.Clone().end, window.Clone())
+	node3.CPUCost = 4.0
+	node3.RAMCost = 4.0
+	node3.GPUCost = 3.0
+	node3.Discount = 0.5
+	node3.CPUCoreHours = 2.0 * hours
+	node3.RAMByteHours = 4.0 * gb * hours
+	node3.SetAdjustment(-0.5)
+
+	node4 := NewNode("node4", "cluster2", "gcp-node4", *window.Clone().start, *window.Clone().end, window.Clone())
+	node4.CPUCost = 10.0
+	node4.RAMCost = 6.0
+	node4.GPUCost = 0.0
+	node4.Discount = 0.25
+	node4.CPUCoreHours = 4.0 * hours
+	node4.RAMByteHours = 12.0 * gb * hours
+	node4.SetAdjustment(-1.0)
+
+	node5 := NewNode("node5", "cluster3", "aws-node5", *window.Clone().start, *window.Clone().end, window.Clone())
+	node5.CPUCost = 10.0
+	node5.RAMCost = 7.0
+	node5.GPUCost = 0.0
+	node5.Discount = 0.0
+	node5.CPUCoreHours = 8.0 * hours
+	node5.RAMByteHours = 24.0 * gb * hours
+	node5.SetAdjustment(2.0)
+
+	disk1 := NewDisk("disk1", "cluster1", "gcp-disk1", *window.Clone().start, *window.Clone().end, window.Clone())
+	disk1.Cost = 2.5
+	disk1.ByteHours = 100 * gb * hours
+
+	disk2 := NewDisk("disk2", "cluster1", "gcp-disk2", *window.Clone().start, *window.Clone().end, window.Clone())
+	disk2.Cost = 1.5
+	disk2.ByteHours = 60 * gb * hours
+
+	disk3 := NewDisk("disk3", "cluster2", "gcp-disk3", *window.Clone().start, *window.Clone().end, window.Clone())
+	disk3.Cost = 2.5
+	disk3.ByteHours = 100 * gb * hours
+
+	disk4 := NewDisk("disk4", "cluster2", "gcp-disk4", *window.Clone().start, *window.Clone().end, window.Clone())
+	disk4.Cost = 1.5
+	disk4.ByteHours = 100 * gb * hours
+
+	cm1 := NewClusterManagement("gcp", "cluster1", window.Clone())
+	cm1.Cost = 3.0
+
+	cm2 := NewClusterManagement("gcp", "cluster2", window.Clone())
+	cm2.Cost = 0.0
+
+	return NewAssetSet(
+		start, end,
+		// cluster 1
+		node1, node2, node3, disk1, disk2, cm1,
+		// cluster 2
+		node4, disk3, disk4, cm2,
+		// cluster 3
+		node5,
+	)
+}
+
+func assertAssetSet(t *testing.T, as *AssetSet, msg string, window Window, exps map[string]float64, err error) {
+	if err != nil {
+		t.Fatalf("AssetSet.AggregateBy[%s]: unexpected error: %s", msg, err)
+	}
+	if as.Length() != len(exps) {
+		t.Fatalf("AssetSet.AggregateBy[%s]: expected set of length %d, actual %d", msg, len(exps), as.Length())
+	}
+	if !as.Window.Equal(window) {
+		t.Fatalf("AssetSet.AggregateBy[%s]: expected window %s, actual %s", msg, window, as.Window)
+	}
+	as.Each(func(key string, a Asset) {
+		if exp, ok := exps[key]; ok {
+			if math.Round(a.TotalCost()*100) != math.Round(exp*100) {
+				t.Fatalf("AssetSet.AggregateBy[%s]: key %s expected total cost %.2f, actual %.2f", msg, key, exp, a.TotalCost())
+			}
+			if !a.Window().Equal(window) {
+				t.Fatalf("AssetSet.AggregateBy[%s]: key %s expected window %s, actual %s", msg, key, window, as.Window)
+			}
+		} else {
+			t.Fatalf("AssetSet.AggregateBy[%s]: unexpected asset: %s", msg, key)
+		}
+	})
+}
+
+func printAssetSet(msg string, as *AssetSet) {
+	fmt.Printf("--- %s ---\n", msg)
+	as.Each(func(key string, a Asset) {
+		fmt.Printf(" > %s: %s\n", key, a)
+	})
+}
+
 func TestAny_Add(t *testing.T) {
 	any1 := NewAsset(*windows[0].start, *windows[0].end, windows[0])
 	any1.SetProperties(&AssetProperties{
@@ -865,146 +1009,117 @@ func TestAssetSetRange_Accumulate(t *testing.T) {
 	}, nil)
 }
 
-func assertAssetSet(t *testing.T, as *AssetSet, msg string, window Window, exps map[string]float64, err error) {
+func TestAssetToExternalAllocation(t *testing.T) {
+	var asset Asset
+	var alloc *Allocation
+	var err error
+
+	// default allocationPropertyLabels, which should be compatible with result
+	// of LabelConfig.AllocationPropertyLabels()
+	apls := map[string]string{"namespace": "kubernetes_namespace"}
+
+	alloc, err = AssetToExternalAllocation(asset, []string{"namespace"}, apls)
+	if err == nil {
+		t.Fatalf("expected error due to nil asset")
+	}
+
+	// Consider this Asset:
+	//   Cloud {
+	// 	   TotalCost: 10.00,
+	// 	   Labels{
+	//       "kubernetes_namespace":"monitoring",
+	// 	     "env":"prod"
+	// 	   }
+	//   }
+	cloud := NewCloud(ComputeCategory, "abc123", start1, start2, windows[0])
+	cloud.SetLabels(map[string]string{
+		"kubernetes_namespace": "monitoring",
+		"env":                  "prod",
+	})
+	cloud.Cost = 10.00
+	asset = cloud
+
+	// Providing nil params with a non-nil Asset should not panic, but it
+	// should return an error in both cases (no matching is possible).
+	alloc, err = AssetToExternalAllocation(asset, []string{"namespace"}, nil)
+	if err == nil {
+		t.Fatalf("expected error due to nil allocationPropertyLabels")
+	}
+	alloc, err = AssetToExternalAllocation(asset, nil, apls)
+	if err == nil {
+		t.Fatalf("expected error due to nil aggregateBy")
+	}
+
+	// Given the following parameters, we expect to return:
+	//
+	//   1) single-prop full match
+	//   aggregateBy = ["namespace"]
+	//   allocationPropertyLabels = {"namespace":"kubernetes_namespace"}
+	//   => Allocation{Name: "monitoring", ExternalCost: 10.00, TotalCost: 10.00}, nil
+	//
+	//   2) multi-prop full match
+	//   aggregateBy = ["namespace", "label:env"]
+	//   allocationPropertyLabels = {"namespace":"kubernetes_namespace"}
+	//   => Allocation{Name: "monitoring/env=prod", ExternalCost: 10.00, TotalCost: 10.00}, nil
+	//
+	//   3) multi-prop partial match
+	//   aggregateBy = ["namespace", "label:foo"]
+	//   allocationPropertyLabels = {"namespace":"kubernetes_namespace"}
+	//   => Allocation{Name: "monitoring/__unallocated__", ExternalCost: 10.00, TotalCost: 10.00}, nil
+	//
+	//   4) no match
+	//   aggregateBy = ["cluster"]
+	//   allocationPropertyLabels = {"namespace":"kubernetes_namespace"}
+	//   => nil, err
+
+	// 1) single-prop full match
+	alloc, err = AssetToExternalAllocation(asset, []string{"namespace"}, apls)
 	if err != nil {
-		t.Fatalf("AssetSet.AggregateBy[%s]: unexpected error: %s", msg, err)
+		t.Fatalf("unexpected error: %s", err)
 	}
-	if as.Length() != len(exps) {
-		t.Fatalf("AssetSet.AggregateBy[%s]: expected set of length %d, actual %d", msg, len(exps), as.Length())
+	if alloc.Name != "monitoring" {
+		t.Fatalf("expected external allocation with name '%s'; got '%s'", "monitoring", alloc.Name)
 	}
-	if !as.Window.Equal(window) {
-		t.Fatalf("AssetSet.AggregateBy[%s]: expected window %s, actual %s", msg, window, as.Window)
+	if alloc.ExternalCost != 10.00 {
+		t.Fatalf("expected external allocation with ExternalCost %f; got %f", 10.00, alloc.ExternalCost)
+	}
+	if alloc.TotalCost != 10.00 {
+		t.Fatalf("expected external allocation with TotalCost %f; got %f", 10.00, alloc.TotalCost)
 	}
-	as.Each(func(key string, a Asset) {
-		if exp, ok := exps[key]; ok {
-			if math.Round(a.TotalCost()*100) != math.Round(exp*100) {
-				t.Fatalf("AssetSet.AggregateBy[%s]: key %s expected total cost %.2f, actual %.2f", msg, key, exp, a.TotalCost())
-			}
-			if !a.Window().Equal(window) {
-				t.Fatalf("AssetSet.AggregateBy[%s]: key %s expected window %s, actual %s", msg, key, window, as.Window)
-			}
-		} else {
-			t.Fatalf("AssetSet.AggregateBy[%s]: unexpected asset: %s", msg, key)
-		}
-	})
-}
-
-// generateAssetSet generates the following topology:
-//
-// | Asset                        | Cost |  Adj |
-// +------------------------------+------+------+
-//   cluster1:
-//     node1:                        6.00   1.00
-//     node2:                        4.00   1.50
-//     node3:                        7.00  -0.50
-//     disk1:                        2.50   0.00
-//     disk2:                        1.50   0.00
-//     clusterManagement1:           3.00   0.00
-// +------------------------------+------+------+
-//   cluster1 subtotal              24.00   2.00
-// +------------------------------+------+------+
-//   cluster2:
-//     node4:                       12.00  -1.00
-//     disk3:                        2.50   0.00
-//     disk4:                        1.50   0.00
-//     clusterManagement2:           0.00   0.00
-// +------------------------------+------+------+
-//   cluster2 subtotal              16.00  -1.00
-// +------------------------------+------+------+
-//   cluster3:
-//     node5:                       17.00   2.00
-// +------------------------------+------+------+
-//   cluster3 subtotal              17.00   2.00
-// +------------------------------+------+------+
-//   total                          57.00   3.00
-// +------------------------------+------+------+
-func generateAssetSet(start time.Time) *AssetSet {
-	end := start.Add(day)
-	window := NewWindow(&start, &end)
-
-	hours := window.Duration().Hours()
-
-	node1 := NewNode("node1", "cluster1", "gcp-node1", *window.Clone().start, *window.Clone().end, window.Clone())
-	node1.CPUCost = 4.0
-	node1.RAMCost = 4.0
-	node1.GPUCost = 2.0
-	node1.Discount = 0.5
-	node1.CPUCoreHours = 2.0 * hours
-	node1.RAMByteHours = 4.0 * gb * hours
-	node1.SetAdjustment(1.0)
-
-	node2 := NewNode("node2", "cluster1", "gcp-node2", *window.Clone().start, *window.Clone().end, window.Clone())
-	node2.CPUCost = 4.0
-	node2.RAMCost = 4.0
-	node2.GPUCost = 0.0
-	node2.Discount = 0.5
-	node2.CPUCoreHours = 2.0 * hours
-	node2.RAMByteHours = 4.0 * gb * hours
-	node2.SetAdjustment(1.5)
-
-	node3 := NewNode("node3", "cluster1", "gcp-node3", *window.Clone().start, *window.Clone().end, window.Clone())
-	node3.CPUCost = 4.0
-	node3.RAMCost = 4.0
-	node3.GPUCost = 3.0
-	node3.Discount = 0.5
-	node3.CPUCoreHours = 2.0 * hours
-	node3.RAMByteHours = 4.0 * gb * hours
-	node3.SetAdjustment(-0.5)
-
-	node4 := NewNode("node4", "cluster2", "gcp-node4", *window.Clone().start, *window.Clone().end, window.Clone())
-	node4.CPUCost = 10.0
-	node4.RAMCost = 6.0
-	node4.GPUCost = 0.0
-	node4.Discount = 0.25
-	node4.CPUCoreHours = 4.0 * hours
-	node4.RAMByteHours = 12.0 * gb * hours
-	node4.SetAdjustment(-1.0)
-
-	node5 := NewNode("node5", "cluster3", "aws-node5", *window.Clone().start, *window.Clone().end, window.Clone())
-	node5.CPUCost = 10.0
-	node5.RAMCost = 7.0
-	node5.GPUCost = 0.0
-	node5.Discount = 0.0
-	node5.CPUCoreHours = 8.0 * hours
-	node5.RAMByteHours = 24.0 * gb * hours
-	node5.SetAdjustment(2.0)
-
-	disk1 := NewDisk("disk1", "cluster1", "gcp-disk1", *window.Clone().start, *window.Clone().end, window.Clone())
-	disk1.Cost = 2.5
-	disk1.ByteHours = 100 * gb * hours
-
-	disk2 := NewDisk("disk2", "cluster1", "gcp-disk2", *window.Clone().start, *window.Clone().end, window.Clone())
-	disk2.Cost = 1.5
-	disk2.ByteHours = 60 * gb * hours
-
-	disk3 := NewDisk("disk3", "cluster2", "gcp-disk3", *window.Clone().start, *window.Clone().end, window.Clone())
-	disk3.Cost = 2.5
-	disk3.ByteHours = 100 * gb * hours
-
-	disk4 := NewDisk("disk4", "cluster2", "gcp-disk4", *window.Clone().start, *window.Clone().end, window.Clone())
-	disk4.Cost = 1.5
-	disk4.ByteHours = 100 * gb * hours
-
-	cm1 := NewClusterManagement("gcp", "cluster1", window.Clone())
-	cm1.Cost = 3.0
 
-	cm2 := NewClusterManagement("gcp", "cluster2", window.Clone())
-	cm2.Cost = 0.0
+	// 2) multi-prop full match
+	alloc, err = AssetToExternalAllocation(asset, []string{"namespace", "label:env"}, apls)
+	if err != nil {
+		t.Fatalf("unexpected error: %s", err)
+	}
+	if alloc.Name != "monitoring/env=prod" {
+		t.Fatalf("expected external allocation with name '%s'; got '%s'", "monitoring/env=prod", alloc.Name)
+	}
+	if alloc.ExternalCost != 10.00 {
+		t.Fatalf("expected external allocation with ExternalCost %f; got %f", 10.00, alloc.ExternalCost)
+	}
+	if alloc.TotalCost != 10.00 {
+		t.Fatalf("expected external allocation with TotalCost %f; got %f", 10.00, alloc.TotalCost)
+	}
 
-	return NewAssetSet(
-		start, end,
-		// cluster 1
-		node1, node2, node3, disk1, disk2, cm1,
-		// cluster 2
-		node4, disk3, disk4, cm2,
-		// cluster 3
-		node5,
-	)
-}
+	// 3) multi-prop partial match
+	alloc, err = AssetToExternalAllocation(asset, []string{"namespace", "label:foo"}, apls)
+	if err != nil {
+		t.Fatalf("unexpected error: %s", err)
+	}
+	if alloc.Name != "monitoring/__unallocated__" {
+		t.Fatalf("expected external allocation with name '%s'; got '%s'", "monitoring/__unallocated__", alloc.Name)
+	}
+	if alloc.ExternalCost != 10.00 {
+		t.Fatalf("expected external allocation with ExternalCost %f; got %f", 10.00, alloc.ExternalCost)
+	}
+	if alloc.TotalCost != 10.00 {
+		t.Fatalf("expected external allocation with TotalCost %f; got %f", 10.00, alloc.TotalCost)
+	}
 
-func printAssetSet(msg string, as *AssetSet) {
-	fmt.Printf("--- %s ---\n", msg)
-	as.Each(func(key string, a Asset) {
-		fmt.Printf(" > %s: %s\n", key, a)
-	})
+	// 3) no match
+	alloc, err = AssetToExternalAllocation(asset, []string{"cluster"}, apls)
+	if err == nil {
+		t.Fatalf("expected 'no match' error")
+	}
 }

+ 198 - 0
pkg/kubecost/config.go

@@ -0,0 +1,198 @@
+package kubecost
+
+import (
+	"fmt"
+	"strings"
+)
+
+// LabelConfig is a port of type AnalyzerConfig. We need to be more thoughtful
+// about design at some point, but this is a stop-gap measure, which is required
+// because AnalyzerConfig is defined in package main, so it can't be imported.
+type LabelConfig struct {
+	DepartmentLabel          string `json:"department_label"`
+	EnvironmentLabel         string `json:"environment_label"`
+	OwnerLabel               string `json:"owner_label"`
+	ProductLabel             string `json:"product_label"`
+	TeamLabel                string `json:"team_label"`
+	ClusterExternalLabel     string `json:"cluster_external_label"`
+	NamespaceExternalLabel   string `json:"namespace_external_label"`
+	ControllerExternalLabel  string `json:"controller_external_label"`
+	DaemonsetExternalLabel   string `json:"daemonset_external_label"`
+	DeploymentExternalLabel  string `json:"deployment_external_label"`
+	StatefulsetExternalLabel string `json:"statefulset_external_label"`
+	ServiceExternalLabel     string `json:"service_external_label"`
+	PodExternalLabel         string `json:"pod_external_label"`
+	DepartmentExternalLabel  string `json:"department_external_label"`
+	EnvironmentExternalLabel string `json:"environment_external_label"`
+	OwnerExternalLabel       string `json:"owner_external_label"`
+	ProductExternalLabel     string `json:"product_external_label"`
+	TeamExternalLabel        string `json:"team_external_label"`
+}
+
+// Map returns the config as a basic string map, with default values if not set
+func (lc *LabelConfig) Map() map[string]string {
+	// Start with default values
+	m := map[string]string{
+		"department_label":           "department",
+		"environment_label":          "env",
+		"owner_label":                "owner",
+		"product_label":              "app",
+		"team_label":                 "team",
+		"cluster_external_label":     "kubernetes_cluster",
+		"namespace_external_label":   "kubernetes_namespace",
+		"controller_external_label":  "kubernetes_controller",
+		"daemonset_external_label":   "kubernetes_daemonset",
+		"deployment_external_label":  "kubernetes_deployment",
+		"statefulset_external_label": "kubernetes_statefulset",
+		"service_external_label":     "kubernetes_service",
+		"pod_external_label":         "kubernetes_pod",
+		"department_external_label":  "kubernetes_label_department",
+		"environment_external_label": "kubernetes_label_env",
+		"owner_external_label":       "kubernetes_label_owner",
+		"product_external_label":     "kubernetes_label_app",
+		"team_external_label":        "kubernetes_label_team",
+	}
+
+	if lc == nil {
+		return m
+	}
+
+	if lc.DepartmentLabel != "" {
+		m["department_label"] = lc.DepartmentLabel
+	}
+
+	if lc.EnvironmentLabel != "" {
+		m["environment_label"] = lc.EnvironmentLabel
+	}
+
+	if lc.OwnerLabel != "" {
+		m["owner_label"] = lc.OwnerLabel
+	}
+
+	if lc.ProductLabel != "" {
+		m["product_label"] = lc.ProductLabel
+	}
+
+	if lc.TeamLabel != "" {
+		m["team_label"] = lc.TeamLabel
+	}
+
+	if lc.ClusterExternalLabel != "" {
+		m["cluster_external_label"] = lc.ClusterExternalLabel
+	}
+
+	if lc.NamespaceExternalLabel != "" {
+		m["namespace_external_label"] = lc.NamespaceExternalLabel
+	}
+
+	if lc.ControllerExternalLabel != "" {
+		m["controller_external_label"] = lc.ControllerExternalLabel
+	}
+
+	if lc.DaemonsetExternalLabel != "" {
+		m["daemonset_external_label"] = lc.DaemonsetExternalLabel
+	}
+
+	if lc.DeploymentExternalLabel != "" {
+		m["deployment_external_label"] = lc.DeploymentExternalLabel
+	}
+
+	if lc.StatefulsetExternalLabel != "" {
+		m["statefulset_external_label"] = lc.StatefulsetExternalLabel
+	}
+
+	if lc.ServiceExternalLabel != "" {
+		m["service_external_label"] = lc.ServiceExternalLabel
+	}
+
+	if lc.PodExternalLabel != "" {
+		m["pod_external_label"] = lc.PodExternalLabel
+	}
+
+	if lc.DepartmentExternalLabel != "" {
+		m["department_external_label"] = lc.DepartmentExternalLabel
+	} else if lc.DepartmentLabel != "" {
+		m["department_external_label"] = "kubernetes_label_" + lc.DepartmentLabel
+	}
+
+	if lc.EnvironmentExternalLabel != "" {
+		m["environment_external_label"] = lc.EnvironmentExternalLabel
+	} else if lc.EnvironmentLabel != "" {
+		m["environment_external_label"] = "kubernetes_label_" + lc.EnvironmentLabel
+	}
+
+	if lc.OwnerExternalLabel != "" {
+		m["owner_external_label"] = lc.OwnerExternalLabel
+	} else if lc.OwnerLabel != "" {
+		m["owner_external_label"] = "kubernetes_label_" + lc.OwnerLabel
+	}
+
+	if lc.ProductExternalLabel != "" {
+		m["product_external_label"] = lc.ProductExternalLabel
+	} else if lc.ProductLabel != "" {
+		m["product_external_label"] = "kubernetes_label_" + lc.ProductLabel
+	}
+
+	if lc.TeamExternalLabel != "" {
+		m["team_external_label"] = lc.TeamExternalLabel
+	} else if lc.TeamLabel != "" {
+		m["team_external_label"] = "kubernetes_label_" + lc.TeamLabel
+	}
+
+	return m
+}
+
+// ExternalQueryLabels returns the config's external labels as a mapping of the
+// query column to the label it should set;
+// e.g. if the config stores "statefulset_external_label": "kubernetes_sset",
+//      then this would return "kubernetes_sset": "statefulset"
+func (lc *LabelConfig) ExternalQueryLabels() map[string]string {
+	queryLabels := map[string]string{}
+
+	for label, query := range lc.Map() {
+		if strings.HasSuffix(label, "external_label") && query != "" {
+			queryLabels[query] = label
+		}
+	}
+
+	return queryLabels
+}
+
+// AllocationPropertyLabels returns the config's external resource labels
+// as a mapping from k8s resource-to-label name.
+// e.g. if the config stores "statefulset_external_label": "kubernetes_sset",
+//      then this would return "statefulset": "kubernetes_sset"
+// e.g. if the config stores "owner_label": "product_owner",
+//      then this would return "label:product_owner": "product_owner"
+func (lc *LabelConfig) AllocationPropertyLabels() map[string]string {
+	labels := map[string]string{}
+
+	for labelKind, labelName := range lc.Map() {
+		if labelName != "" {
+			switch labelKind {
+			case "namespace_external_label":
+				labels["namespace"] = labelName
+			case "cluster_external_label":
+				labels["cluster"] = labelName
+			case "controller_external_label":
+				labels["controller"] = labelName
+			case "product_external_label":
+				labels["product"] = labelName
+			case "service_external_label":
+				labels["service"] = labelName
+			case "deployment_external_label":
+				labels["deployment"] = labelName
+			case "statefulset_external_label":
+				labels["statefulset"] = labelName
+			case "daemonset_external_label":
+				labels["daemonset"] = labelName
+			case "pod_external_label":
+				labels["pod"] = labelName
+			default:
+				labels[fmt.Sprintf("label:%s", labelName)] = labelName
+			}
+		}
+	}
+
+	return labels
+}

+ 94 - 0
pkg/kubecost/config_test.go

@@ -0,0 +1,94 @@
+package kubecost
+
+import "testing"
+
+func TestLabelConfig_Map(t *testing.T) {
+	var m map[string]string
+	var lc *LabelConfig
+
+	m = lc.Map()
+	if len(m) != 18 {
+		t.Fatalf("Map: expected length %d; got length %d", 18, len(m))
+	}
+	if val, ok := m["deployment_external_label"]; !ok || val != "kubernetes_deployment" {
+		t.Fatalf("Map: expected %s; got %s", "kubernetes_deployment", val)
+	}
+	if val, ok := m["namespace_external_label"]; !ok || val != "kubernetes_namespace" {
+		t.Fatalf("Map: expected %s; got %s", "kubernetes_namespace", val)
+	}
+
+	lc = &LabelConfig{
+		DaemonsetExternalLabel: "kubernetes_ds",
+	}
+	m = lc.Map()
+	if len(m) != 18 {
+		t.Fatalf("Map: expected length %d; got length %d", 18, len(m))
+	}
+	if val, ok := m["daemonset_external_label"]; !ok || val != "kubernetes_ds" {
+		t.Fatalf("Map: expected %s; got %s", "kubernetes_ds", val)
+	}
+	if val, ok := m["namespace_external_label"]; !ok || val != "kubernetes_namespace" {
+		t.Fatalf("Map: expected %s; got %s", "kubernetes_namespace", val)
+	}
+}
+
+func TestLabelConfig_ExternalQueryLabels(t *testing.T) {
+	var qls map[string]string
+	var lc *LabelConfig
+
+	qls = lc.ExternalQueryLabels()
+	if len(qls) != 13 {
+		t.Fatalf("ExternalQueryLabels: expected length %d; got length %d", 13, len(qls))
+	}
+	if val, ok := qls["kubernetes_deployment"]; !ok || val != "deployment_external_label" {
+		t.Fatalf("ExternalQueryLabels: expected %s; got %s", "deployment_external_label", val)
+	}
+	if val, ok := qls["kubernetes_namespace"]; !ok || val != "namespace_external_label" {
+		t.Fatalf("ExternalQueryLabels: expected %s; got %s", "namespace_external_label", val)
+	}
+
+	lc = &LabelConfig{
+		DaemonsetExternalLabel: "kubernetes_ds",
+	}
+	qls = lc.ExternalQueryLabels()
+	if len(qls) != 13 {
+		t.Fatalf("ExternalQueryLabels: expected length %d; got length %d", 13, len(qls))
+	}
+	if val, ok := qls["kubernetes_ds"]; !ok || val != "daemonset_external_label" {
+		t.Fatalf("ExternalQueryLabels: expected %s; got %s", "daemonset_external_label", val)
+	}
+	if val, ok := qls["kubernetes_namespace"]; !ok || val != "namespace_external_label" {
+		t.Fatalf("ExternalQueryLabels: expected %s; got %s", "namespace_external_label", val)
+	}
+}
+
+func TestTestLabelConfig_AllocationPropertyLabels(t *testing.T) {
+	var labels map[string]string
+	var lc *LabelConfig
+
+	labels = lc.AllocationPropertyLabels()
+	if len(labels) != 18 {
+		t.Fatalf("AllocationPropertyLabels: expected length %d; got length %d", 18, len(labels))
+	}
+	if val, ok := labels["namespace"]; !ok || val != "kubernetes_namespace" {
+		t.Fatalf("AllocationPropertyLabels: expected %s; got %s", "kubernetes_namespace", val)
+	}
+	if val, ok := labels["label:env"]; !ok || val != "env" {
+		t.Fatalf("AllocationPropertyLabels: expected %s; got %s", "env", val)
+	}
+
+	lc = &LabelConfig{
+		NamespaceExternalLabel: "kubens",
+		EnvironmentLabel:       "kubeenv",
+	}
+	labels = lc.AllocationPropertyLabels()
+	if len(labels) != 18 {
+		t.Fatalf("AllocationPropertyLabels: expected length %d; got length %d", 18, len(labels))
+	}
+	if val, ok := labels["namespace"]; !ok || val != "kubens" {
+		t.Fatalf("AllocationPropertyLabels: expected %s; got %s", "kubens", val)
+	}
+	if val, ok := labels["label:kubeenv"]; !ok || val != "kubeenv" {
+		t.Fatalf("AllocationPropertyLabels: expected %s; got %s", "kubeenv", val)
+	}
+}

+ 6 - 103
pkg/kubecost/properties.go

@@ -222,109 +222,6 @@ func (p *Properties) Length() int {
 	return len(*p)
 }
 
-func (p *Properties) Matches(that Properties) bool {
-	// The only Properties that a nil Properties matches is an empty one
-	if p == nil {
-		return that.Length() == 0
-	}
-
-	// Matching on cluster, namespace, controller, controller kind, pod,
-	// and container are simple string equality comparisons. By default,
-	// we assume a match. For each Property given to match, we say that the
-	// match fails if we don't have that Property, or if we have it but the
-	// strings are not equal.
-
-	if thatCluster, thatErr := that.GetCluster(); thatErr == nil {
-		if thisCluster, thisErr := p.GetCluster(); thisErr != nil || thisCluster != thatCluster {
-			return false
-		}
-	}
-
-	if thatNode, thatErr := that.GetNode(); thatErr == nil {
-		if thisNode, thisErr := p.GetNode(); thisErr != nil || thisNode != thatNode {
-			return false
-		}
-	}
-
-	if thatNamespace, thatErr := that.GetNamespace(); thatErr == nil {
-		if thisNamespace, thisErr := p.GetNamespace(); thisErr != nil || thisNamespace != thatNamespace {
-			return false
-		}
-	}
-
-	if thatController, thatErr := that.GetController(); thatErr == nil {
-		if thisController, thisErr := p.GetController(); thisErr != nil || thisController != thatController {
-			return false
-		}
-	}
-
-	if thatControllerKind, thatErr := that.GetControllerKind(); thatErr == nil {
-		if thisControllerKind, thisErr := p.GetControllerKind(); thisErr != nil || thisControllerKind != thatControllerKind {
-			return false
-		}
-	}
-
-	if thatPod, thatErr := that.GetPod(); thatErr == nil {
-		if thisPod, thisErr := p.GetPod(); thisErr != nil || thisPod != thatPod {
-			return false
-		}
-	}
-
-	if thatContainer, thatErr := that.GetContainer(); thatErr == nil {
-		if thisContainer, thisErr := p.GetContainer(); thisErr != nil || thisContainer != thatContainer {
-			return false
-		}
-	}
-
-	// Matching on Services only occurs if a non-zero length slice of strings
-	// is given. The comparison fails if there exists a string to match that is
-	// not present in our slice of services.
-	if thatServices, thatErr := that.GetServices(); thatErr == nil && len(thatServices) > 0 {
-		thisServices, thisErr := p.GetServices()
-		if thisErr != nil {
-			return false
-		}
-
-		for _, service := range thatServices {
-			match := false
-			for _, s := range thisServices {
-				if s == service {
-					match = true
-					break
-				}
-			}
-			if !match {
-				return false
-			}
-		}
-	}
-
-	// Matching on Labels only occurs if a non-zero length map of strings is
-	// given. The comparison fails if there exists a key/value pair to match
-	// that is not present in our set of labels.
-	if thatServices, thatErr := that.GetServices(); thatErr == nil && len(thatServices) > 0 {
-		thisServices, thisErr := p.GetServices()
-		if thisErr != nil {
-			return false
-		}
-
-		for _, service := range thatServices {
-			match := false
-			for _, s := range thisServices {
-				if s == service {
-					match = true
-					break
-				}
-			}
-			if !match {
-				return false
-			}
-		}
-	}
-
-	return true
-}
-
 func (p *Properties) String() string {
 	if p == nil {
 		return "<nil>"
@@ -337,6 +234,12 @@ func (p *Properties) String() string {
 	return fmt.Sprintf("{%s}", strings.Join(strs, "; "))
 }
 
+// TODO niko/allocation-etl
+func (p *Properties) AggregationStrings() []string {
+
+	return []string{}
+}
+
 func (p *Properties) Get(prop Property) (string, error) {
 	if raw, ok := (*p)[prop]; ok {
 		if result, ok := raw.(string); ok {

+ 0 - 180
pkg/kubecost/properties_test.go

@@ -1,9 +1,5 @@
 package kubecost
 
-import (
-	"testing"
-)
-
 // TODO niko/etl
 // func TestParseProperty(t *testing.T) {}
 
@@ -16,182 +12,6 @@ import (
 // TODO niko/etl
 // func TestProperties_Intersection(t *testing.T) {}
 
-func TestProperties_Matches(t *testing.T) {
-	// nil Properties should match empty Properties
-	var p *Properties
-	propsEmpty := Properties{}
-
-	if !p.Matches(propsEmpty) {
-		t.Fatalf("Properties.Matches: expect nil to match empty")
-	}
-
-	// Empty Properties should match empty Properties
-	p = &Properties{}
-	if !p.Matches(propsEmpty) {
-		t.Fatalf("Properties.Matches: expect nil to match empty")
-	}
-
-	p.SetCluster("cluster-one")
-	p.SetNamespace("kubecost")
-	p.SetController("kubecost-deployment")
-	p.SetControllerKind("deployment")
-	p.SetPod("kubecost-deployment-abc123")
-	p.SetContainer("kubecost-cost-model")
-	p.SetServices([]string{"kubecost-frontend"})
-	p.SetLabels(map[string]string{
-		"app":  "kubecost",
-		"tier": "frontend",
-	})
-
-	// Non-empty Properties should match empty Properties, but not vice-a-versa
-	if !p.Matches(propsEmpty) {
-		t.Fatalf("Properties.Matches: expect nil to match empty")
-	}
-	if propsEmpty.Matches(*p) {
-		t.Fatalf("Properties.Matches: expect empty to not match non-empty")
-	}
-
-	// Non-empty Properties should match itself
-	if !p.Matches(*p) {
-		t.Fatalf("Properties.Matches: expect non-empty to match itself")
-	}
-
-	// Match on all
-	if !p.Matches(Properties{
-		ClusterProp:        "cluster-one",
-		NamespaceProp:      "kubecost",
-		ControllerProp:     "kubecost-deployment",
-		ControllerKindProp: "deployment",
-		PodProp:            "kubecost-deployment-abc123",
-		ContainerProp:      "kubecost-cost-model",
-		ServiceProp:        []string{"kubecost-frontend"},
-		LabelProp: map[string]string{
-			"app":  "kubecost",
-			"tier": "frontend",
-		},
-	}) {
-		t.Fatalf("Properties.Matches: expect match on all")
-	}
-
-	// Match on cluster
-	if !p.Matches(Properties{
-		ClusterProp: "cluster-one",
-	}) {
-		t.Fatalf("Properties.Matches: expect match on cluster")
-	}
-
-	// No match on cluster
-	if p.Matches(Properties{
-		ClusterProp: "miss",
-	}) {
-		t.Fatalf("Properties.Matches: expect no match on cluster")
-	}
-
-	// Match on namespace
-	if !p.Matches(Properties{
-		NamespaceProp: "kubecost",
-	}) {
-		t.Fatalf("Properties.Matches: expect match on namespace")
-	}
-
-	// No match on namespace
-	if p.Matches(Properties{
-		NamespaceProp: "miss",
-	}) {
-		t.Fatalf("Properties.Matches: expect no match on namespace")
-	}
-
-	// Match on controller
-	if !p.Matches(Properties{
-		ControllerProp: "kubecost-deployment",
-	}) {
-		t.Fatalf("Properties.Matches: expect match on controller")
-	}
-
-	// No match on controller
-	if p.Matches(Properties{
-		ControllerProp: "miss",
-	}) {
-		t.Fatalf("Properties.Matches: expect no match on controller")
-	}
-
-	// Match on controller kind
-	if !p.Matches(Properties{
-		ControllerKindProp: "deployment",
-	}) {
-		t.Fatalf("Properties.Matches: expect match on controller kind")
-	}
-
-	// No match on controller kind
-	if p.Matches(Properties{
-		ControllerKindProp: "miss",
-	}) {
-		t.Fatalf("Properties.Matches: expect no match on controller kind")
-	}
-
-	// Match on pod
-	if !p.Matches(Properties{
-		PodProp: "kubecost-deployment-abc123",
-	}) {
-		t.Fatalf("Properties.Matches: expect match on pod")
-	}
-
-	// No match on pod
-	if p.Matches(Properties{
-		PodProp: "miss",
-	}) {
-		t.Fatalf("Properties.Matches: expect no match on pod")
-	}
-
-	// Match on container
-	if !p.Matches(Properties{
-		ContainerProp: "kubecost-cost-model",
-	}) {
-		t.Fatalf("Properties.Matches: expect match on container")
-	}
-
-	// No match on container
-	if p.Matches(Properties{
-		ContainerProp: "miss",
-	}) {
-		t.Fatalf("Properties.Matches: expect no match on container")
-	}
-
-	// Match on single service
-	if !p.Matches(Properties{
-		ServiceProp: []string{"kubecost-frontend"},
-	}) {
-		t.Fatalf("Properties.Matches: expect match on service")
-	}
-
-	// No match on one missing service
-	if p.Matches(Properties{
-		ServiceProp: []string{"missing-service", "kubecost-frontend"},
-	}) {
-		t.Fatalf("Properties.Matches: expect no match on 1 of 2 services")
-	}
-
-	// Match on single label
-	if !p.Matches(Properties{
-		LabelProp: map[string]string{
-			"app": "kubecost",
-		},
-	}) {
-		t.Fatalf("Properties.Matches: expect match on label")
-	}
-
-	// No match on one missing label
-	if !p.Matches(Properties{
-		LabelProp: map[string]string{
-			"app":   "kubecost",
-			"tier":  "frontend",
-			"label": "missing",
-		},
-	}) {
-		t.Fatalf("Properties.Matches: expect no match on 2 of 3 labels")
-	}
-}
-
 // TODO niko/etl
 // func TestProperties_GetCluster(t *testing.T) {}