Niko Kovacevic 5 лет назад
Родитель
Сommit
a82ae43a9f

+ 223 - 53
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,11 +107,14 @@ func (a *Allocation) Clone() *Allocation {
 		RAMCost:         a.RAMCost,
 		RAMEfficiency:   a.RAMEfficiency,
 		SharedCost:      a.SharedCost,
+		ExternalCost:    a.ExternalCost,
 		TotalCost:       a.TotalCost,
 		TotalEfficiency: a.TotalEfficiency,
 	}
 }
 
+// Equal returns true if the values held in the given Allocation precisely
+// match those of the receiving Allocation. nil does not match nil.
 func (a *Allocation) Equal(that *Allocation) bool {
 	if a == nil || that == nil {
 		return false
@@ -167,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
 	}
@@ -201,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) {
@@ -266,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()
@@ -349,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
@@ -434,6 +405,11 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 		return nil
 	}
 
+	fmt.Printf("AllocationSet.AggregateBy\n")
+	as.Each(func(key string, a *Allocation) {
+		fmt.Printf(" > %s: %.2f %s\n", key, a.TotalCost, &(a.Properties))
+	})
+
 	// aggSet will collect the aggregated allocations
 	aggSet := &AllocationSet{
 		// Profiler: as.Profiler,
@@ -542,7 +518,7 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 		idleCoefficients, err = computeIdleCoeffs(properties, options, as)
 		if err != nil {
 			log.Warningf("AllocationSet.AggregateBy: compute idle coeff: %s", err)
-			return err
+			return fmt.Errorf("error computing idle coefficients: %s", err)
 		}
 	}
 
@@ -1066,6 +1042,115 @@ func (as *AllocationSet) Clone() *AllocationSet {
 	}
 }
 
+// ComputeIdleAllocations computes the idle allocations for the AllocationSet,
+// given a set of Assets. Ideally, assetSet should contain only Nodes, but if
+// it contains other Assets, they will be ignored; only CPU, GPU and RAM are
+// considered for idle allocation. One idle allocation per-cluster will be
+// computed and returned, keyed by cluster_id.
+func (as *AllocationSet) ComputeIdleAllocations(assetSet *AssetSet) (map[string]*Allocation, error) {
+	if as == nil {
+		return nil, fmt.Errorf("cannot compute idle allocation for nil AllocationSet")
+	}
+
+	// TODO niko/allocation-etl remove after testing and benchmarking
+	profStart := time.Now()
+	defer log.Profile(profStart, fmt.Sprintf("ComputeIdleAllocations: %s", as.Window))
+
+	if assetSet == nil {
+		return nil, fmt.Errorf("cannot compute idle allocation with nil AssetSet")
+	}
+
+	if !as.Window.Equal(assetSet.Window) {
+		return nil, fmt.Errorf("cannot compute idle allocation for sets with mismatched windows: %s != %s", as.Window, assetSet.Window)
+	}
+
+	window := as.Window
+
+	// Build a map of cumulative cluster asset costs, per resource; i.e.
+	// cluster-to-{cpu|gpu|ram}-to-cost.
+	assetClusterResourceCosts := map[string]map[string]float64{}
+	assetSet.Each(func(key string, a Asset) {
+		if node, ok := a.(*Node); ok {
+			if _, ok := assetClusterResourceCosts[node.Properties().Cluster]; !ok {
+				assetClusterResourceCosts[node.Properties().Cluster] = map[string]float64{}
+			}
+			assetClusterResourceCosts[node.Properties().Cluster]["cpu"] += node.CPUCost * (1.0 - node.Discount)
+			assetClusterResourceCosts[node.Properties().Cluster]["gpu"] += node.GPUCost * (1.0 - node.Discount)
+			assetClusterResourceCosts[node.Properties().Cluster]["ram"] += node.RAMCost * (1.0 - node.Discount)
+		}
+	})
+
+	// Determine start, end on a per-cluster basis
+	clusterStarts := map[string]time.Time{}
+	clusterEnds := map[string]time.Time{}
+
+	// Subtract allocated costs from asset costs, leaving only the remaining
+	// idle costs.
+	as.Each(func(name string, a *Allocation) {
+		cluster, err := a.Properties.GetCluster()
+		if err != nil {
+			// Failed to find allocation's cluster
+			return
+		}
+
+		if _, ok := assetClusterResourceCosts[cluster]; !ok {
+			// Failed to find assets for allocation's cluster
+			return
+		}
+
+		// Set cluster (start, end) if they are either not currently set,
+		// or if the detected (start, end) of the current allocation falls
+		// before or after, respectively, the current values.
+		if s, ok := clusterStarts[cluster]; !ok || a.Start.Before(s) {
+			clusterStarts[cluster] = a.Start
+		}
+		if e, ok := clusterEnds[cluster]; !ok || a.End.Before(e) {
+			clusterEnds[cluster] = a.End
+		}
+
+		assetClusterResourceCosts[cluster]["cpu"] -= a.CPUCost
+		assetClusterResourceCosts[cluster]["gpu"] -= a.GPUCost
+		assetClusterResourceCosts[cluster]["ram"] -= a.RAMCost
+	})
+
+	// Turn remaining un-allocated asset costs into idle allocations
+	idleAllocs := map[string]*Allocation{}
+	for cluster, resources := range assetClusterResourceCosts {
+		// Default start and end to the (start, end) of the given window, but
+		// use the actual, detected (start, end) pair if they are available.
+		start := *window.Start()
+		if s, ok := clusterStarts[cluster]; ok && window.Contains(s) {
+			start = s
+		}
+		end := *window.End()
+		if e, ok := clusterEnds[cluster]; ok && window.Contains(e) {
+			end = e
+		}
+
+		idleAlloc := &Allocation{
+			Name:       fmt.Sprintf("%s/%s", cluster, IdleSuffix),
+			Properties: Properties{ClusterProp: cluster},
+			Start:      start,
+			End:        end,
+			Minutes:    end.Sub(start).Minutes(), // TODO deprecate w/ niko/allocation-minutes
+			CPUCost:    resources["cpu"],
+			GPUCost:    resources["gpu"],
+			RAMCost:    resources["ram"],
+		}
+		idleAlloc.TotalCost = idleAlloc.CPUCost + idleAlloc.GPUCost + idleAlloc.RAMCost
+
+		// Do not continue if multiple idle allocations are computed for a
+		// single cluster.
+		if _, ok := idleAllocs[cluster]; ok {
+			return nil, fmt.Errorf("duplicate idle allocations for cluster %s", cluster)
+		}
+
+		idleAllocs[cluster] = idleAlloc
+	}
+
+	return idleAllocs, nil
+}
+
 // Delete removes the allocation with the given name from the set
 func (as *AllocationSet) Delete(name string) {
 	if as == nil {
@@ -1143,16 +1228,21 @@ func (as *AllocationSet) Insert(that *Allocation) error {
 }
 
 func (as *AllocationSet) insert(that *Allocation, accumulate bool) error {
-	if as.IsEmpty() {
-		as.Lock()
-		as.allocations = map[string]*Allocation{}
-		as.idleKeys = map[string]bool{}
-		as.Unlock()
+	if as == nil {
+		return fmt.Errorf("cannot insert into nil AllocationSet")
 	}
 
 	as.Lock()
 	defer as.Unlock()
 
+	if as.allocations == nil {
+		as.allocations = map[string]*Allocation{}
+	}
+
+	if as.idleKeys == nil {
+		as.idleKeys = map[string]bool{}
+	}
+
 	// Add the given Allocation to the existing entry, if there is one;
 	// otherwise just set directly into allocations
 	if _, ok := as.allocations[that.Name]; !ok {
@@ -1213,6 +1303,8 @@ func (as *AllocationSet) Resolution() time.Duration {
 	return as.Window.Duration()
 }
 
+// Set uses the given Allocation to overwrite the existing entry in the
+// AllocationSet under the Allocation's name.
 func (as *AllocationSet) Set(alloc *Allocation) error {
 	if as.IsEmpty() {
 		as.Lock()
@@ -1272,6 +1364,7 @@ func (as *AllocationSet) TotalCost() float64 {
 	return tc
 }
 
+// UTCOffset returns the AllocationSet's configured UTCOffset.
 func (as *AllocationSet) UTCOffset() time.Duration {
 	_, zone := as.Start().Zone()
 	return time.Duration(zone) * time.Second
@@ -1339,11 +1432,17 @@ func (as *AllocationSet) accumulate(that *AllocationSet) (*AllocationSet, error)
 	return acc, nil
 }
 
+// AllocationSetRange is a thread-safe slice of AllocationSets. It is meant to
+// be used such that the AllocationSets held are consecutive and coherent with
+// respect to using the same aggregation properties, UTC offset, and
+// resolution. However these rules are not necessarily enforced, so use wisely.
 type AllocationSetRange struct {
 	sync.RWMutex
 	allocations []*AllocationSet
 }
 
+// NewAllocationSetRange instantiates a new range composed of the given
+// AllocationSets in the order provided.
 func NewAllocationSetRange(allocs ...*AllocationSet) *AllocationSetRange {
 	return &AllocationSetRange{
 		allocations: allocs,
@@ -1372,9 +1471,13 @@ func (asr *AllocationSetRange) Accumulate() (*AllocationSet, error) {
 // TODO niko/etl accumulate into lower-resolution chunks of the given resolution
 // func (asr *AllocationSetRange) AccumulateBy(resolution time.Duration) *AllocationSetRange
 
+// AggregateBy aggregates each AllocationSet in the range by the given
+// properties and options.
 func (asr *AllocationSetRange) AggregateBy(properties Properties, options *AllocationAggregationOptions) error {
 	aggRange := &AllocationSetRange{allocations: []*AllocationSet{}}
 
+	fmt.Printf("AllocationSetRange.AggregateBy\n")
+
 	asr.Lock()
 	defer asr.Unlock()
 
@@ -1391,6 +1494,8 @@ func (asr *AllocationSetRange) AggregateBy(properties Properties, options *Alloc
 	return nil
 }
 
+// Append appends the given AllocationSet to the end of the range. It does not
+// validate whether or not that violates window continuity.
 func (asr *AllocationSetRange) Append(that *AllocationSet) {
 	asr.Lock()
 	defer asr.Unlock()
@@ -1408,6 +1513,7 @@ func (asr *AllocationSetRange) Each(f func(int, *AllocationSet)) {
 	}
 }
 
+// Get retrieves the AllocationSet at the given index of the range.
 func (asr *AllocationSetRange) Get(i int) (*AllocationSet, error) {
 	if i < 0 || i >= len(asr.allocations) {
 		return nil, fmt.Errorf("AllocationSetRange: index out of range: %d", i)
@@ -1418,6 +1524,64 @@ func (asr *AllocationSetRange) Get(i int) (*AllocationSet, error) {
 	return asr.allocations[i], nil
 }
 
+// InsertRange merges the given AllocationSetRange into the receiving one by
+// lining up sets with matching windows, then inserting each allocation from
+// the given ASR into the respective set in the receiving ASR. If the given
+// ASR contains an AllocationSet from a window that does not exist in the
+// receiving ASR, then an error is returned. However, the given ASR does not
+// need to cover the full range of the receiver.
+func (asr *AllocationSetRange) InsertRange(that *AllocationSetRange) error {
+	if asr == nil {
+		return fmt.Errorf("cannot insert range into nil AllocationSetRange")
+	}
+
+	// keys maps window to index in asr
+	keys := map[string]int{}
+	asr.Each(func(i int, as *AllocationSet) {
+		if as == nil {
+			return
+		}
+		keys[as.Window.String()] = i
+	})
+
+	// Nothing to merge, so simply return
+	if len(keys) == 0 {
+		return nil
+	}
+
+	var err error
+	that.Each(func(j int, thatAS *AllocationSet) {
+		if thatAS == nil || err != nil {
+			return
+		}
+
+		// Find matching AllocationSet in asr
+		i, ok := keys[thatAS.Window.String()]
+		if !ok {
+			err = fmt.Errorf("cannot merge AllocationSet into window that does not exist: %s", thatAS.Window.String())
+			return
+		}
+		as, err := asr.Get(i)
+		if err != nil {
+			err = fmt.Errorf("AllocationSetRange index does not exist: %d", i)
+			return
+		}
+
+		// Insert each Allocation from the given set
+		thatAS.Each(func(k string, alloc *Allocation) {
+			err = as.Insert(alloc)
+			if err != nil {
+				err = fmt.Errorf("error inserting allocation: %s", err)
+				return
+			}
+		})
+	})
+
+	// err might be nil
+	return err
+}
+
+// Length returns the length of the range, which is zero if nil
 func (asr *AllocationSetRange) Length() int {
 	if asr == nil || asr.allocations == nil {
 		return 0
@@ -1428,12 +1592,15 @@ func (asr *AllocationSetRange) Length() int {
 	return len(asr.allocations)
 }
 
+// MarshalJSON JSON-encodes the range
 func (asr *AllocationSetRange) MarshalJSON() ([]byte, error) {
 	asr.RLock()
 	asr.RUnlock()
 	return json.Marshal(asr.allocations)
 }
 
+// Slice copies the underlying slice of AllocationSets, maintaining order,
+// and returns the copied slice.
 func (asr *AllocationSetRange) Slice() []*AllocationSet {
 	if asr == nil || asr.allocations == nil {
 		return nil
@@ -1456,6 +1623,9 @@ func (asr *AllocationSetRange) String() string {
 	return fmt.Sprintf("AllocationSetRange{length: %d}", asr.Length())
 }
 
+// UTCOffset returns the detected UTCOffset of the AllocationSets within the
+// range. Defaults to 0 if the range is nil or empty. Does not warn if there
+// are sets with conflicting UTCOffsets (just returns the first).
 func (asr *AllocationSetRange) UTCOffset() time.Duration {
 	if asr.Length() == 0 {
 		return 0

+ 265 - 100
pkg/kubecost/allocation_test.go

@@ -5,6 +5,8 @@ import (
 	"math"
 	"testing"
 	"time"
+
+	util "github.com/kubecost/cost-model/pkg/util"
 )
 
 const day = 24 * time.Hour
@@ -96,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
 }
@@ -976,6 +878,106 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 // TODO niko/etl
 //func TestAllocationSet_Clone(t *testing.T) {}
 
+func TestAllocationSet_ComputeIdleAllocations(t *testing.T) {
+	var as *AllocationSet
+	var err error
+	var idles map[string]*Allocation
+
+	end := time.Now().UTC().Truncate(day)
+	start := end.Add(-day)
+
+	// Generate AllocationSet and strip out any existing idle allocations
+	as = generateAllocationSet(start)
+	for key := range as.idleKeys {
+		as.Delete(key)
+	}
+
+	// Create an AssetSet representing cluster costs for two clusters (cluster1
+	// and cluster2). Include Nodes and Disks for both, even though only
+	// Nodes will be counted. Whereas in practice, Assets should be aggregated
+	// by type, here we will provide multiple Nodes for one of the clusters to
+	// make sure the function still holds.
+
+	// NOTE: we're re-using generateAllocationSet so this has to line up with
+	// the allocated node costs from that function. See table above.
+
+	// | Hierarchy                               | Cost |  CPU |  RAM |  GPU |
+	// +-----------------------------------------+------+------+------+------+
+	//   cluster1:
+	//     nodes                                  100.00  50.00  40.00  10.00
+	// +-----------------------------------------+------+------+------+------+
+	//   cluster1 subtotal                        100.00  50.00  40.00  10.00
+	// +-----------------------------------------+------+------+------+------+
+	//   cluster1 allocated                        48.00   6.00  16.00   6.00
+	// +-----------------------------------------+------+------+------+------+
+	//   cluster1 idle                             72.00  44.00  24.00   4.00
+	// +-----------------------------------------+------+------+------+------+
+	//   cluster2:
+	//     node1                                   35.00  20.00  15.00   0.00
+	//     node2                                   35.00  20.00  15.00   0.00
+	//     node3                                   30.00  10.00  10.00  10.00
+	//     (disks should not matter for idle)
+	// +-----------------------------------------+------+------+------+------+
+	//   cluster2 subtotal                        100.00  50.00  40.00  10.00
+	// +-----------------------------------------+------+------+------+------+
+	//   cluster2 allocated                        28.00   6.00   6.00   6.00
+	// +-----------------------------------------+------+------+------+------+
+	//   cluster2 idle                             82.00  44.00  34.00   4.00
+	// +-----------------------------------------+------+------+------+------+
+
+	cluster1Nodes := NewNode("", "cluster1", "", start, end, NewWindow(&start, &end))
+	cluster1Nodes.CPUCost = 50.0
+	cluster1Nodes.RAMCost = 40.0
+	cluster1Nodes.GPUCost = 10.0
+
+	cluster2Node1 := NewNode("node1", "cluster2", "node1", start, end, NewWindow(&start, &end))
+	cluster2Node1.CPUCost = 20.0
+	cluster2Node1.RAMCost = 15.0
+	cluster2Node1.GPUCost = 0.0
+
+	cluster2Node2 := NewNode("node2", "cluster2", "node2", start, end, NewWindow(&start, &end))
+	cluster2Node2.CPUCost = 20.0
+	cluster2Node2.RAMCost = 15.0
+	cluster2Node2.GPUCost = 0.0
+
+	cluster2Node3 := NewNode("node3", "cluster2", "node3", start, end, NewWindow(&start, &end))
+	cluster2Node3.CPUCost = 10.0
+	cluster2Node3.RAMCost = 10.0
+	cluster2Node3.GPUCost = 10.0
+
+	cluster2Disk1 := NewDisk("disk1", "cluster2", "disk1", start, end, NewWindow(&start, &end))
+	cluster2Disk1.Cost = 5.0
+
+	assetSet := NewAssetSet(start, end, cluster1Nodes, cluster2Node1, cluster2Node2, cluster2Node3, cluster2Disk1)
+
+	idles, err = as.ComputeIdleAllocations(assetSet)
+	if err != nil {
+		t.Fatalf("unexpected error: %s", err)
+	}
+
+	if len(idles) != 2 {
+		t.Fatalf("idles: expected length %d; got length %d", 2, len(idles))
+	}
+
+	if idle, ok := idles["cluster1"]; !ok {
+		t.Fatalf("expected idle cost for %s", "cluster1")
+	} else {
+		if !util.IsApproximately(idle.TotalCost, 72.0) {
+			t.Fatalf("%s idle: expected total cost %f; got total cost %f", "cluster1", 72.0, idle.TotalCost)
+		}
+	}
+
+	if idle, ok := idles["cluster2"]; !ok {
+		t.Fatalf("expected idle cost for %s", "cluster2")
+	} else {
+		if !util.IsApproximately(idle.TotalCost, 82.0) {
+			t.Fatalf("%s idle: expected total cost %f; got total cost %f", "cluster2", 82.0, idle.TotalCost)
+		}
+	}
+
+	// TODO assert value of each resource cost precisely
+}
+
 // TODO niko/etl
 //func TestAllocationSet_Delete(t *testing.T) {}
 
@@ -1175,6 +1177,169 @@ func TestAllocationSetRange_Accumulate(t *testing.T) {
 // TODO niko/etl
 // func TestAllocationSetRange_Append(t *testing.T) {}
 
+// TODO niko/etl
+// func TestAllocationSetRange_Each(t *testing.T) {}
+
+// TODO niko/etl
+// func TestAllocationSetRange_Get(t *testing.T) {}
+
+func TestAllocationSetRange_InsertRange(t *testing.T) {
+	// Set up
+	ago2d := time.Now().UTC().Truncate(day).Add(-2 * day)
+	yesterday := time.Now().UTC().Truncate(day).Add(-day)
+	today := time.Now().UTC().Truncate(day)
+	tomorrow := time.Now().UTC().Truncate(day).Add(day)
+
+	unit := NewUnitAllocation("", today, day, nil)
+
+	ago2dAS := NewAllocationSet(ago2d, yesterday)
+	ago2dAS.Set(NewUnitAllocation("a", ago2d, day, nil))
+	ago2dAS.Set(NewUnitAllocation("b", ago2d, day, nil))
+	ago2dAS.Set(NewUnitAllocation("c", ago2d, day, nil))
+
+	yesterdayAS := NewAllocationSet(yesterday, today)
+	yesterdayAS.Set(NewUnitAllocation("a", yesterday, day, nil))
+	yesterdayAS.Set(NewUnitAllocation("b", yesterday, day, nil))
+	yesterdayAS.Set(NewUnitAllocation("c", yesterday, day, nil))
+
+	todayAS := NewAllocationSet(today, tomorrow)
+	todayAS.Set(NewUnitAllocation("a", today, day, nil))
+	todayAS.Set(NewUnitAllocation("b", today, day, nil))
+	todayAS.Set(NewUnitAllocation("c", today, day, nil))
+
+	var nilASR *AllocationSetRange
+	thisASR := NewAllocationSetRange(yesterdayAS.Clone(), todayAS.Clone())
+	thatASR := NewAllocationSetRange(yesterdayAS.Clone())
+	longASR := NewAllocationSetRange(ago2dAS.Clone(), yesterdayAS.Clone(), todayAS.Clone())
+	var err error
+
+	// Expect an error calling InsertRange on nil
+	err = nilASR.InsertRange(thatASR)
+	if err == nil {
+		t.Fatalf("expected error, got nil")
+	}
+
+	// Expect nothing to happen calling InsertRange(nil) on non-nil ASR
+	err = thisASR.InsertRange(nil)
+	if err != nil {
+		t.Fatalf("unexpected error: %s", err)
+	}
+	thisASR.Each(func(i int, as *AllocationSet) {
+		as.Each(func(k string, a *Allocation) {
+			if !util.IsApproximately(a.CPUCoreHours, unit.CPUCoreHours) {
+				t.Fatalf("allocation %s: expected %f; got %f", k, unit.CPUCoreHours, a.CPUCoreHours)
+			}
+			if !util.IsApproximately(a.CPUCost, unit.CPUCost) {
+				t.Fatalf("allocation %s: expected %f; got %f", k, unit.CPUCost, a.CPUCost)
+			}
+			if !util.IsApproximately(a.RAMByteHours, unit.RAMByteHours) {
+				t.Fatalf("allocation %s: expected %f; got %f", k, unit.RAMByteHours, a.RAMByteHours)
+			}
+			if !util.IsApproximately(a.RAMCost, unit.RAMCost) {
+				t.Fatalf("allocation %s: expected %f; got %f", k, unit.RAMCost, a.RAMCost)
+			}
+			if !util.IsApproximately(a.GPUHours, unit.GPUHours) {
+				t.Fatalf("allocation %s: expected %f; got %f", k, unit.GPUHours, a.GPUHours)
+			}
+			if !util.IsApproximately(a.GPUCost, unit.GPUCost) {
+				t.Fatalf("allocation %s: expected %f; got %f", k, unit.GPUCost, a.GPUCost)
+			}
+			if !util.IsApproximately(a.PVByteHours, unit.PVByteHours) {
+				t.Fatalf("allocation %s: expected %f; got %f", k, unit.PVByteHours, a.PVByteHours)
+			}
+			if !util.IsApproximately(a.PVCost, unit.PVCost) {
+				t.Fatalf("allocation %s: expected %f; got %f", k, unit.PVCost, a.PVCost)
+			}
+			if !util.IsApproximately(a.NetworkCost, unit.NetworkCost) {
+				t.Fatalf("allocation %s: expected %f; got %f", k, unit.NetworkCost, a.NetworkCost)
+			}
+			if !util.IsApproximately(a.TotalCost, unit.TotalCost) {
+				t.Fatalf("allocation %s: expected %f; got %f", k, unit.TotalCost, a.TotalCost)
+			}
+		})
+	})
+
+	// Expect an error calling InsertRange with a range exceeding the receiver
+	err = thisASR.InsertRange(longASR)
+	if err == nil {
+		t.Fatalf("expected error calling InsertRange with a range exceeding the receiver")
+	}
+
+	// Expect each Allocation in "today" to stay the same, but "yesterday" to
+	// precisely double when inserting a range that only has a duplicate of
+	// "yesterday", but no entry for "today"
+	err = thisASR.InsertRange(thatASR)
+	if err != nil {
+		t.Fatalf("unexpected error: %s", err)
+	}
+	yAS, err := thisASR.Get(0)
+	yAS.Each(func(k string, a *Allocation) {
+		if !util.IsApproximately(a.CPUCoreHours, 2*unit.CPUCoreHours) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.CPUCoreHours, a.CPUCoreHours)
+		}
+		if !util.IsApproximately(a.CPUCost, 2*unit.CPUCost) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.CPUCost, a.CPUCost)
+		}
+		if !util.IsApproximately(a.RAMByteHours, 2*unit.RAMByteHours) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.RAMByteHours, a.RAMByteHours)
+		}
+		if !util.IsApproximately(a.RAMCost, 2*unit.RAMCost) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.RAMCost, a.RAMCost)
+		}
+		if !util.IsApproximately(a.GPUHours, 2*unit.GPUHours) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.GPUHours, a.GPUHours)
+		}
+		if !util.IsApproximately(a.GPUCost, 2*unit.GPUCost) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.GPUCost, a.GPUCost)
+		}
+		if !util.IsApproximately(a.PVByteHours, 2*unit.PVByteHours) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.PVByteHours, a.PVByteHours)
+		}
+		if !util.IsApproximately(a.PVCost, 2*unit.PVCost) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.PVCost, a.PVCost)
+		}
+		if !util.IsApproximately(a.NetworkCost, 2*unit.NetworkCost) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.NetworkCost, a.NetworkCost)
+		}
+		if !util.IsApproximately(a.TotalCost, 2*unit.TotalCost) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.TotalCost, a.TotalCost)
+		}
+	})
+	tAS, err := thisASR.Get(1)
+	tAS.Each(func(k string, a *Allocation) {
+		if !util.IsApproximately(a.CPUCoreHours, unit.CPUCoreHours) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.CPUCoreHours, a.CPUCoreHours)
+		}
+		if !util.IsApproximately(a.CPUCost, unit.CPUCost) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.CPUCost, a.CPUCost)
+		}
+		if !util.IsApproximately(a.RAMByteHours, unit.RAMByteHours) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.RAMByteHours, a.RAMByteHours)
+		}
+		if !util.IsApproximately(a.RAMCost, unit.RAMCost) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.RAMCost, a.RAMCost)
+		}
+		if !util.IsApproximately(a.GPUHours, unit.GPUHours) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.GPUHours, a.GPUHours)
+		}
+		if !util.IsApproximately(a.GPUCost, unit.GPUCost) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.GPUCost, a.GPUCost)
+		}
+		if !util.IsApproximately(a.PVByteHours, unit.PVByteHours) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.PVByteHours, a.PVByteHours)
+		}
+		if !util.IsApproximately(a.PVCost, unit.PVCost) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.PVCost, a.PVCost)
+		}
+		if !util.IsApproximately(a.NetworkCost, unit.NetworkCost) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.NetworkCost, a.NetworkCost)
+		}
+		if !util.IsApproximately(a.TotalCost, unit.TotalCost) {
+			t.Fatalf("allocation %s: expected %f; got %f", k, unit.TotalCost, a.TotalCost)
+		}
+	})
+}
+
 // TODO niko/etl
 // func TestAllocationSetRange_Length(t *testing.T) {}
 

+ 131 - 0
pkg/kubecost/asset.go

@@ -60,6 +60,137 @@ 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
+
+	// props records the relevant Properties to set on the resultant Allocation
+	props := Properties{}
+
+	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
+
+				// Set the corresponding label in props
+				labels, err := props.GetLabels()
+				if err != nil {
+					labels = map[string]string{}
+				}
+				labels[labelName] = value
+				props.SetLabels(labels)
+			} else {
+				names = append(names, value)
+				match = true
+
+				// Set the corresponding property on props
+				switch aggBy {
+				case ClusterProp.String():
+					props.SetCluster(value)
+				case NodeProp.String():
+					props.SetNode(value)
+				case NamespaceProp.String():
+					props.SetNamespace(value)
+				case ControllerKindProp.String():
+					props.SetControllerKind(value)
+				case ControllerProp.String():
+					props.SetController(value)
+				case PodProp.String():
+					props.SetPod(value)
+				case ContainerProp.String():
+					props.SetContainer(value)
+				case ServiceProp.String():
+					// TODO niko/allocation-etl how to do this? multi-service?
+					props.SetServices([]string{value})
+				}
+			}
+		} 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, "/"),
+		Properties:   props,
+		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` `aggregateBy` indicates
 // that all available `AssetProperty` keys should be used. Passing empty `aggregateBy` indicates that

+ 384 - 145
pkg/kubecost/asset_test.go

@@ -6,6 +6,8 @@ import (
 	"math"
 	"testing"
 	"time"
+
+	util "github.com/kubecost/cost-model/pkg/util"
 )
 
 var start1 = time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)
@@ -19,11 +21,150 @@ var windows = []Window{
 	NewWindow(&start3, &start4),
 }
 
-const delta = 0.00001
 const gb = 1024 * 1024 * 1024
 
-func approx(a, b, delta float64) bool {
-	return math.Abs(a-b) < delta
+// 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) {
@@ -186,7 +327,7 @@ func TestDisk_Add(t *testing.T) {
 	if diskT.Bytes() != 160.0*gb {
 		t.Fatalf("Disk.Add: expected %f; got %f", 160.0*gb, diskT.Bytes())
 	}
-	if !approx(diskT.Local, 0.333333, delta) {
+	if !util.IsApproximately(diskT.Local, 0.333333) {
 		t.Fatalf("Disk.Add: expected %f; got %f", 0.333333, diskT.Local)
 	}
 
@@ -381,7 +522,7 @@ func TestNode_Add(t *testing.T) {
 	nodeT := node1.Add(node2).(*Node)
 
 	// Check that the sums and properties are correct
-	if !approx(nodeT.TotalCost(), 15.0, delta) {
+	if !util.IsApproximately(nodeT.TotalCost(), 15.0) {
 		t.Fatalf("Node.Add: expected %f; got %f", 15.0, nodeT.TotalCost())
 	}
 	if nodeT.Adjustment() != 2.6 {
@@ -407,13 +548,13 @@ func TestNode_Add(t *testing.T) {
 	}
 
 	// Check that the original assets are unchanged
-	if !approx(node1.TotalCost(), 10.0, delta) {
+	if !util.IsApproximately(node1.TotalCost(), 10.0) {
 		t.Fatalf("Node.Add: expected %f; got %f", 10.0, node1.TotalCost())
 	}
 	if node1.Adjustment() != 1.6 {
 		t.Fatalf("Node.Add: expected %f; got %f", 1.0, node1.Adjustment())
 	}
-	if !approx(node2.TotalCost(), 5.0, delta) {
+	if !util.IsApproximately(node2.TotalCost(), 5.0) {
 		t.Fatalf("Node.Add: expected %f; got %f", 5.0, node2.TotalCost())
 	}
 	if node2.Adjustment() != 1.0 {
@@ -471,7 +612,7 @@ func TestNode_Add(t *testing.T) {
 	nodeAT := nodeA1.Add(nodeA2).(*Node)
 
 	// Check that the sums and properties are correct
-	if !approx(nodeAT.TotalCost(), 15.0, delta) {
+	if !util.IsApproximately(nodeAT.TotalCost(), 15.0) {
 		t.Fatalf("Node.Add: expected %f; got %f", 15.0, nodeAT.TotalCost())
 	}
 	if nodeAT.Adjustment() != 2.6 {
@@ -497,13 +638,13 @@ func TestNode_Add(t *testing.T) {
 	}
 
 	// Check that the original assets are unchanged
-	if !approx(nodeA1.TotalCost(), 10.0, delta) {
+	if !util.IsApproximately(nodeA1.TotalCost(), 10.0) {
 		t.Fatalf("Node.Add: expected %f; got %f", 10.0, nodeA1.TotalCost())
 	}
 	if nodeA1.Adjustment() != 1.6 {
 		t.Fatalf("Node.Add: expected %f; got %f", 1.0, nodeA1.Adjustment())
 	}
-	if !approx(nodeA2.TotalCost(), 5.0, delta) {
+	if !util.IsApproximately(nodeA2.TotalCost(), 5.0) {
 		t.Fatalf("Node.Add: expected %f; got %f", 5.0, nodeA2.TotalCost())
 	}
 	if nodeA2.Adjustment() != 1.0 {
@@ -881,147 +1022,245 @@ 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 ns, err := alloc.Properties.GetNamespace(); err != nil || ns != "monitoring" {
+		t.Fatalf("expected external allocation with Properties.Namespace '%s'; got '%s' (%s)", "monitoring", ns, err)
+	}
+	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)
-	node1.SetLabels(map[string]string{"test": "test"})
-
-	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
+	// 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 ns, err := alloc.Properties.GetNamespace(); err != nil || ns != "monitoring" {
+		t.Fatalf("expected external allocation with Properties.Namespace '%s'; got '%s' (%s)", "monitoring", ns, err)
+	}
+	if ls, err := alloc.Properties.GetLabels(); err != nil || ls["env"] != "prod" {
+		t.Fatalf("expected external allocation with Properties.Labels[\"env\"] '%s'; got '%s' (%s)", "prod", ls["env"], err)
+	}
+	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)
+	}
 
-	cm2 := NewClusterManagement("gcp", "cluster2", window.Clone())
-	cm2.Cost = 0.0
+	// 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 ns, err := alloc.Properties.GetNamespace(); err != nil || ns != "monitoring" {
+		t.Fatalf("expected external allocation with Properties.Namespace '%s'; got '%s' (%s)", "monitoring", ns, err)
+	}
+	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) no match
+	alloc, err = AssetToExternalAllocation(asset, []string{"cluster"}, apls)
+	if err == nil {
+		t.Fatalf("expected 'no match' error")
+	}
 }
 
-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)
-	})
-}
+// TODO merge conflict had this:
+
+// 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)
+// node1.SetLabels(map[string]string{"test": "test"})
+
+// 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

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

+ 49 - 97
pkg/kubecost/properties.go

@@ -222,120 +222,72 @@ func (p *Properties) Length() int {
 	return len(*p)
 }
 
-// TODO: deprecate
-func (p *Properties) Matches(that Properties) bool {
-	// The only Properties that a nil Properties matches is an empty one
+func (p *Properties) String() string {
 	if p == nil {
-		return that.Length() == 0
+		return "<nil>"
 	}
 
-	// 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
-		}
+	strs := []string{}
+	for key, prop := range *p {
+		strs = append(strs, fmt.Sprintf("%s:%s", key, prop))
 	}
+	return fmt.Sprintf("{%s}", strings.Join(strs, "; "))
+}
 
-	if thatNode, thatErr := that.GetNode(); thatErr == nil {
-		if thisNode, thisErr := p.GetNode(); thisErr != nil || thisNode != thatNode {
-			return false
-		}
+// AggregationStrings converts a Properties object into a slice of strings
+// representing a request to aggregate by certain properties.
+// NOTE: today, the ordering of the properties *has to match the ordering
+// of the allocaiton function generateKey*
+func (p *Properties) AggregationStrings() []string {
+	if p == nil {
+		return []string{}
 	}
 
-	if thatNamespace, thatErr := that.GetNamespace(); thatErr == nil {
-		if thisNamespace, thisErr := p.GetNamespace(); thisErr != nil || thisNamespace != thatNamespace {
-			return false
-		}
+	aggStrs := []string{}
+	if p.HasCluster() {
+		aggStrs = append(aggStrs, ClusterProp.String())
 	}
-
-	if thatController, thatErr := that.GetController(); thatErr == nil {
-		if thisController, thisErr := p.GetController(); thisErr != nil || thisController != thatController {
-			return false
-		}
+	if p.HasNode() {
+		aggStrs = append(aggStrs, NodeProp.String())
 	}
-
-	if thatControllerKind, thatErr := that.GetControllerKind(); thatErr == nil {
-		if thisControllerKind, thisErr := p.GetControllerKind(); thisErr != nil || thisControllerKind != thatControllerKind {
-			return false
-		}
+	if p.HasNamespace() {
+		aggStrs = append(aggStrs, NamespaceProp.String())
 	}
-
-	if thatPod, thatErr := that.GetPod(); thatErr == nil {
-		if thisPod, thisErr := p.GetPod(); thisErr != nil || thisPod != thatPod {
-			return false
-		}
+	if p.HasControllerKind() {
+		aggStrs = append(aggStrs, ControllerKindProp.String())
 	}
-
-	if thatContainer, thatErr := that.GetContainer(); thatErr == nil {
-		if thisContainer, thisErr := p.GetContainer(); thisErr != nil || thisContainer != thatContainer {
-			return false
-		}
+	if p.HasController() {
+		aggStrs = append(aggStrs, ControllerProp.String())
 	}
-
-	// 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
-			}
-		}
+	if p.HasPod() {
+		aggStrs = append(aggStrs, PodProp.String())
 	}
-
-	// 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
+	if p.HasContainer() {
+		aggStrs = append(aggStrs, ContainerProp.String())
+	}
+	if p.HasService() {
+		aggStrs = append(aggStrs, ServiceProp.String())
+	}
+	if p.HasLabel() {
+		// e.g. expect format map[string]string{
+		// 	 "env":""
+		// 	 "app":"",
+		// }
+		// for aggregating by "label:app,label:env"
+		labels, _ := p.GetLabels()
+		labelAggStrs := []string{}
+		for labelName := range labels {
+			labelAggStrs = append(labelAggStrs, fmt.Sprintf("label:%s", labelName))
 		}
-
-		for _, service := range thatServices {
-			match := false
-			for _, s := range thisServices {
-				if s == service {
-					match = true
-					break
-				}
-			}
-			if !match {
-				return false
+		if len(labelAggStrs) > 0 {
+			// Enforce alphabetical ordering, then append to aggStrs
+			sort.Strings(labelAggStrs)
+			for _, labelName := range labelAggStrs {
+				aggStrs = append(aggStrs, labelName)
 			}
 		}
 	}
-
-	return true
-}
-
-func (p *Properties) String() string {
-	if p == nil {
-		return "<nil>"
-	}
-
-	strs := []string{}
-	for key, prop := range *p {
-		strs = append(strs, fmt.Sprintf("%s:%s", key, prop))
-	}
-	return fmt.Sprintf("{%s}", strings.Join(strs, "; "))
+	return aggStrs
 }
 
 func (p *Properties) Get(prop Property) (string, error) {

+ 44 - 170
pkg/kubecost/properties_test.go

@@ -1,8 +1,6 @@
 package kubecost
 
-import (
-	"testing"
-)
+import "testing"
 
 // TODO niko/etl
 // func TestParseProperty(t *testing.T) {}
@@ -10,187 +8,63 @@ import (
 // TODO niko/etl
 // func TestProperty_String(t *testing.T) {}
 
-// TODO niko/etl
-// func TestProperties_Clone(t *testing.T) {}
-
-// 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")
-	}
+func TestProperties_AggregationString(t *testing.T) {
+	var props *Properties
+	var aggStrs []string
 
-	// No match on namespace
-	if p.Matches(Properties{
-		NamespaceProp: "miss",
-	}) {
-		t.Fatalf("Properties.Matches: expect no match on namespace")
+	// nil Properties should produce and empty slice
+	aggStrs = props.AggregationStrings()
+	if aggStrs == nil || len(aggStrs) > 0 {
+		t.Fatalf("expected empty slice; got %v", aggStrs)
 	}
 
-	// Match on controller
-	if !p.Matches(Properties{
-		ControllerProp: "kubecost-deployment",
-	}) {
-		t.Fatalf("Properties.Matches: expect match on controller")
+	// empty Properties should product an empty slice
+	props = &Properties{}
+	aggStrs = props.AggregationStrings()
+	if aggStrs == nil || len(aggStrs) > 0 {
+		t.Fatalf("expected empty slice; got %v", aggStrs)
 	}
 
-	// No match on controller
-	if p.Matches(Properties{
-		ControllerProp: "miss",
-	}) {
-		t.Fatalf("Properties.Matches: expect no match on controller")
+	// Properties with single, simple property set
+	props = &Properties{}
+	props.SetNamespace("")
+	aggStrs = props.AggregationStrings()
+	if len(aggStrs) != 1 || aggStrs[0] != "namespace" {
+		t.Fatalf("expected [\"namespace\"]; got %v", aggStrs)
 	}
 
-	// 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")
+	// Properties with mutiple properties, including labels
+	// Note: order matters!
+	props = &Properties{}
+	props.SetNamespace("")
+	props.SetLabels(map[string]string{
+		"env": "",
+		"app": "",
+	})
+	props.SetCluster("")
+	aggStrs = props.AggregationStrings()
+	if len(aggStrs) != 4 {
+		t.Fatalf("expected length %d; got lenfth %d", 4, len(aggStrs))
 	}
-
-	// Match on container
-	if !p.Matches(Properties{
-		ContainerProp: "kubecost-cost-model",
-	}) {
-		t.Fatalf("Properties.Matches: expect match on container")
+	if aggStrs[0] != "cluster" {
+		t.Fatalf("expected aggStrs[0] == \"%s\"; got \"%s\"", "cluster", aggStrs[0])
 	}
-
-	// No match on container
-	if p.Matches(Properties{
-		ContainerProp: "miss",
-	}) {
-		t.Fatalf("Properties.Matches: expect no match on container")
+	if aggStrs[1] != "namespace" {
+		t.Fatalf("expected aggStrs[1] == \"%s\"; got \"%s\"", "namespace", aggStrs[1])
 	}
-
-	// Match on single service
-	if !p.Matches(Properties{
-		ServiceProp: []string{"kubecost-frontend"},
-	}) {
-		t.Fatalf("Properties.Matches: expect match on service")
+	if aggStrs[2] != "label:app" {
+		t.Fatalf("expected aggStrs[2] == \"%s\"; got \"%s\"", "label:app", aggStrs[2])
 	}
-
-	// 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")
+	if aggStrs[3] != "label:env" {
+		t.Fatalf("expected aggStrs[3] == \"%s\"; got \"%s\"", "label:env", aggStrs[3])
 	}
+}
 
-	// Match on single label
-	if !p.Matches(Properties{
-		LabelProp: map[string]string{
-			"app": "kubecost",
-		},
-	}) {
-		t.Fatalf("Properties.Matches: expect match on label")
-	}
+// TODO niko/etl
+// func TestProperties_Clone(t *testing.T) {}
 
-	// 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_Intersection(t *testing.T) {}
 
 // TODO niko/etl
 // func TestProperties_GetCluster(t *testing.T) {}

+ 15 - 0
pkg/util/math.go

@@ -0,0 +1,15 @@
+package util
+
+import "math"
+
+// IsApproximately returns true is a approximately equals b, within
+// a delta computed as a function of the size of a and b.
+func IsApproximately(a, b float64) bool {
+	delta := 0.000001 * math.Max(math.Abs(a), math.Abs(b))
+	return math.Abs(a-b) < delta
+}
+
+// IsWithin returns true if a and b are within delta of each other
+func IsWithin(a, b, delta float64) bool {
+	return math.Abs(a-b) < delta
+}