Browse Source

Merge pull request #666 from kubecost/niko/allocation-etl

On-demand idle allocation: supporting functions and tests
Niko Kovacevic 5 years ago
parent
commit
ee51931f5f

+ 390 - 105
pkg/kubecost/allocation.go

@@ -11,6 +11,14 @@ import (
 	"github.com/kubecost/cost-model/pkg/log"
 )
 
+// TODO Clean-up use of IsEmpty; nil checks should be separated for safety.
+
+// TODO Consider making Allocation an interface, which is fulfilled by structs
+// like KubernetesAllocation, IdleAllocation, and ExternalAllocation.
+
+// ExternalSuffix indicates an external allocation
+const ExternalSuffix = "__external__"
+
 // IdleSuffix indicates an idle allocation property
 const IdleSuffix = "__idle__"
 
@@ -53,9 +61,9 @@ 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:"-"`
 }
 
 // AllocationMatchFunc is a function that can be used to match Allocations by
@@ -75,7 +83,6 @@ func (a *Allocation) Add(that *Allocation) (*Allocation, error) {
 	}
 
 	agg := a.Clone()
-	// agg.Profiler = a.Profiler
 	agg.add(that, false, false)
 
 	return agg, nil
@@ -106,11 +113,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 +177,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
 	}
@@ -191,6 +204,11 @@ func (a *Allocation) IsAggregated() bool {
 	return a == nil || a.Properties == nil
 }
 
+// IsExternal is true if the given Allocation represents external costs.
+func (a *Allocation) IsExternal() bool {
+	return strings.Contains(a.Name, ExternalSuffix)
+}
+
 // IsIdle is true if the given Allocation represents idle costs.
 func (a *Allocation) IsIdle() bool {
 	return strings.Contains(a.Name, IdleSuffix)
@@ -201,45 +219,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) {
@@ -267,13 +246,7 @@ func (a *Allocation) String() string {
 
 func (a *Allocation) add(that *Allocation, isShared, isAccumulating bool) {
 	if a == nil {
-		a = that
-
-		// reset properties
-		thatCluster, _ := that.Properties.GetCluster()
-		thatNode, _ := that.Properties.GetNode()
-		a.Properties = Properties{ClusterProp: thatCluster, NodeProp: thatNode}
-
+		log.Warningf("Allocation.AggregateBy: trying to add a nil receiver")
 		return
 	}
 
@@ -349,6 +322,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
@@ -363,20 +337,22 @@ func (a *Allocation) add(that *Allocation, isShared, isAccumulating bool) {
 // a window. An AllocationSet is mutable, so treat it like a threadsafe map.
 type AllocationSet struct {
 	sync.RWMutex
-	// Profiler    *log.Profiler
-	allocations map[string]*Allocation
-	idleKeys    map[string]bool
-	Window      Window
-	Warnings    []string
-	Errors      []string
+	allocations  map[string]*Allocation
+	externalKeys map[string]bool
+	idleKeys     map[string]bool
+	Window       Window
+	Warnings     []string
+	Errors       []string
 }
 
 // NewAllocationSet instantiates a new AllocationSet and, optionally, inserts
 // the given list of Allocations
 func NewAllocationSet(start, end time.Time, allocs ...*Allocation) *AllocationSet {
 	as := &AllocationSet{
-		allocations: map[string]*Allocation{},
-		Window:      NewWindow(&start, &end),
+		allocations:  map[string]*Allocation{},
+		externalKeys: map[string]bool{},
+		idleKeys:     map[string]bool{},
+		Window:       NewWindow(&start, &end),
 	}
 
 	for _, a := range allocs {
@@ -407,20 +383,25 @@ type AllocationAggregationOptions struct {
 // given Property; e.g. Containers can be divided by Namespace, but not vice-a-versa.
 func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationAggregationOptions) error {
 	// The order of operations for aggregating allocations is as follows:
-	// 1. move shared and/or idle allocations to separate sets if options
-	//    indicate that they should be shared
-	// 2. idle coefficients
-	// 2.a) if idle allocation is to be shared, compute idle coefficients
-	//      (do not compute shared coefficients here, see step 5)
-	// 2.b) if idle allocation is NOT shared, but filters are present, compute
-	//      idle filtration coefficients for the purpose of only returning the
-	//      portion of idle allocation that would have been shared with the
-	//      unfiltered results set. (See unit tests 5.a,b,c)
-	// 3. ignore allocation if it fails any of the FilterFuncs
-	// 4. generate aggregation key and insert allocation into the output set
-	// 5. if there are shared allocations, compute sharing coefficients on
+	// 1. Partition external, idle, and shared allocations into separate sets
+	// 2. Compute idle coefficients (if necessary)
+	//    a) if idle allocation is to be shared, compute idle coefficients
+	//       (do not compute shared coefficients here, see step 5)
+	//    b) if idle allocation is NOT shared, but filters are present, compute
+	//       idle filtration coefficients for the purpose of only returning the
+	//       portion of idle allocation that would have been shared with the
+	//       unfiltered results set. (See unit tests 5.a,b,c)
+	// 3. Ignore allocation if it fails any of the FilterFuncs
+	// 4. Distribute idle allocations among remaining non-idle, non-external
+	//    allocations
+	// 5. Generate aggregation key and insert allocation into the output set
+	// 6. Scale un-aggregated idle coefficients by filtration coefficient
+	// 7. If there are shared allocations, compute sharing coefficients on
 	//    the aggregated set, then share allocation accordingly
-	// 6. if the merge idle option is enabled, merge any remaining idle
+	// 8. If there are external allocations that can be aggregated into
+	//    the output (i.e. they can be used to generate a valid key for
+	//    the given properties) then aggregate; otherwise... ignore them?
+	// 9. If the merge idle option is enabled, merge any remaining idle
 	//    allocations into a single idle allocation
 
 	// TODO niko/etl revisit (ShareIdle: ShareEven) case, which is probably wrong
@@ -436,24 +417,27 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 
 	// aggSet will collect the aggregated allocations
 	aggSet := &AllocationSet{
-		// Profiler: as.Profiler,
+		Window: as.Window.Clone(),
+	}
+
+	// externalSet will collect external allocations
+	externalSet := &AllocationSet{
 		Window: as.Window.Clone(),
 	}
 
 	// idleSet will be shared among aggSet after initial aggregation
 	// is complete
 	idleSet := &AllocationSet{
-		// Profiler: as.Profiler,
 		Window: as.Window.Clone(),
 	}
 
 	// shareSet will be shared among aggSet after initial aggregation
 	// is complete
 	shareSet := &AllocationSet{
-		// Profiler: as.Profiler
 		Window: as.Window.Clone(),
 	}
 
+	// Convert SharedHourlyCosts to Allocations in the shareSet
 	for name, cost := range options.SharedHourlyCosts {
 		if cost > 0.0 {
 			hours := as.Resolution().Hours()
@@ -479,21 +463,29 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 	as.Lock()
 	defer as.Unlock()
 
-	// Loop and find all of the idle and shared allocations initially. Add
-	// them to their respective sets, removing them from the set of
-	// allocations to aggregate.
+	// (1) Loop and find all of the external, idle, and shared allocations. Add
+	// them to their respective sets, removing them from the set of allocations
+	// to aggregate.
 	for _, alloc := range as.allocations {
+		// External allocations get aggregated post-hoc (see step 6) and do
+		// not necessarily contain complete sets of properties, so they are
+		// moved to a separate AllocationSet.
+		if alloc.IsExternal() {
+			delete(as.externalKeys, alloc.Name)
+			delete(as.allocations, alloc.Name)
+			externalSet.Insert(alloc)
+			continue
+		}
+
 		cluster, err := alloc.Properties.GetCluster()
 		if err != nil {
 			log.Warningf("AllocationSet.AggregateBy: missing cluster for allocation: %s", alloc.Name)
 			return err
 		}
 
-		// Idle allocation doesn't get aggregated, so it can be passed through,
-		// whether or not it is shared. If it is shared, it is put in idleSet
-		// because shareSet may be split by different rules (even/weighted).
+		// Idle allocations should be separated into idleSet if they are to be
+		// shared later on. If they are not to be shared, then aggregate them.
 		if alloc.IsIdle() {
-			// Can't recursively call Delete() due to lock acquisition
 			delete(as.idleKeys, alloc.Name)
 			delete(as.allocations, alloc.Name)
 
@@ -502,14 +494,15 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 			} else {
 				aggSet.Insert(alloc)
 			}
+
+			continue
 		}
 
-		// If any of the share funcs succeed, share the allocation. Do this
-		// prior to filtering so that shared namespaces, etc do not get
-		// filtered out before we have a chance to share them.
+		// Shared allocations must be identified and separated prior to
+		// aggregation and filtering. That is, if any of the ShareFuncs
+		// return true, then move the allocation to shareSet.
 		for _, sf := range options.ShareFuncs {
 			if sf(alloc) {
-				// Can't recursively call Delete() due to lock acquisition
 				delete(as.idleKeys, alloc.Name)
 				delete(as.allocations, alloc.Name)
 
@@ -520,6 +513,8 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 		}
 	}
 
+	// It's possible that no more un-shared, non-idle, non-external allocations
+	// remain at this point. This always results in an emptySet.
 	if len(as.allocations) == 0 {
 		log.Warningf("ETL: AggregateBy: no allocations to aggregate")
 		emptySet := &AllocationSet{
@@ -529,24 +524,30 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 		return nil
 	}
 
-	// In order to correctly apply idle and shared resource coefficients appropriately,
-	// we need to determine the coefficients for the full set of data. The ensures that
-	// the ratios are maintained through filtering.
+	// (2) In order to correctly apply idle and shared resource coefficients
+	// appropriately, we need to determine the coefficients for the full set
+	// of data. The ensures that the ratios are maintained through filtering.
+
 	// idleCoefficients are organized by [cluster][allocation][resource]=coeff
 	var idleCoefficients map[string]map[string]map[string]float64
+
 	// shareCoefficients are organized by [allocation][resource]=coeff (no cluster)
 	var shareCoefficients map[string]float64
+
 	var err error
 
+	// (2a) If there are idle costs and we intend to share them, compute the
+	// coefficients for sharing the cost among the non-idle, non-aggregated
+	// allocations.
 	if idleSet.Length() > 0 && options.ShareIdle != ShareNone {
 		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)
 		}
 	}
 
-	// If we're not sharing idle and we're filtering, we need to track the
+	// (2b) If we're not sharing idle and we're filtering, we need to track the
 	// amount of each idle allocation to "delete" in order to maintain parity
 	// with the idle-allocated results. That is, we want to return only the
 	// idle cost that would have been shared with the unfiltered portion of
@@ -556,10 +557,11 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 		idleFiltrationCoefficients, 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 filtration coefficients: %s", err)
 		}
 	}
 
+	// (3-5) Filter, distribute idle cost, and aggregate (in that order)
 	for _, alloc := range as.allocations {
 		cluster, err := alloc.Properties.GetCluster()
 		if err != nil {
@@ -569,7 +571,7 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 
 		skip := false
 
-		// If any of the filter funcs fail, immediately skip the allocation.
+		// (3) If any of the filter funcs fail, immediately skip the allocation.
 		for _, ff := range options.FilterFuncs {
 			if !ff(alloc) {
 				skip = true
@@ -590,9 +592,11 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 			continue
 		}
 
-		// Split idle allocations and distribute among aggregated allocations
-		// NOTE: if idle allocation is off (i.e. ShareIdle == ShareNone) then all
-		// idle allocations will be in the aggSet at this point.
+		// (4) Split idle allocations and distribute among remaining
+		// un-aggregated allocations.
+		// NOTE: if idle allocation is off (i.e. ShareIdle == ShareNone) then
+		// all idle allocations will be in the aggSet at this point, so idleSet
+		// will be empty and we won't enter this block.
 		if idleSet.Length() > 0 {
 			// Distribute idle allocations by coefficient per-cluster, per-allocation
 			for _, idleAlloc := range idleSet.allocations {
@@ -630,6 +634,7 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 			}
 		}
 
+		// (5) generate key to use for aggregation-by-key and allocation name
 		key, err := alloc.generateKey(properties)
 		if err != nil {
 			return err
@@ -640,9 +645,15 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 			alloc.Name = UnallocatedSuffix
 		}
 
+		// Inserting the allocation with the generated key for a name will
+		// perform the actual basic aggregation step.
 		aggSet.Insert(alloc)
 	}
 
+	// clusterIdleFiltrationCoeffs is used to track per-resource idle
+	// coefficients on a cluster-by-cluster basis. It is, essentailly, an
+	// aggregation of idleFiltrationCoefficients after they have been
+	// filtered above (in step 3)
 	var clusterIdleFiltrationCoeffs map[string]map[string]float64
 	if idleFiltrationCoefficients != nil {
 		clusterIdleFiltrationCoeffs = map[string]map[string]float64{}
@@ -664,9 +675,10 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 		}
 	}
 
-	// If we have filters, and so have computed coefficients for scaling idle
-	// allocation costs by cluster, then use those coefficients to scale down
-	// each idle coefficient in the aggSet.
+	// (6) If we have both un-shared idle allocations and idle filtration
+	// coefficients (i.e. we have computed coefficients for scaling idle
+	// allocation costs by cluster) then use those coefficients to scale down
+	// each idle allocation.
 	if len(aggSet.idleKeys) > 0 && clusterIdleFiltrationCoeffs != nil {
 		for idleKey := range aggSet.idleKeys {
 			idleAlloc := aggSet.Get(idleKey)
@@ -687,7 +699,7 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 		}
 	}
 
-	// Split shared allocations and distribute among aggregated allocations
+	// (7) Split shared allocations and distribute among aggregated allocations
 	if shareSet.Length() > 0 {
 		shareCoefficients, err = computeShareCoeffs(properties, options, aggSet)
 		if err != nil {
@@ -716,7 +728,21 @@ func (as *AllocationSet) AggregateBy(properties Properties, options *AllocationA
 		}
 	}
 
-	// Combine all idle allocations into a single "__idle__" allocation
+	// (8) Aggregate external allocations into aggregated allocations. This may
+	// not be possible for every external allocation, but attempt to find an
+	// exact key match, given each external allocation's proerties, and
+	// aggregate if an exact match is found.
+	for _, alloc := range externalSet.allocations {
+		key, err := alloc.generateKey(properties)
+		if err != nil {
+			continue
+		}
+
+		alloc.Name = key
+		aggSet.Insert(alloc)
+	}
+
+	// (9) Combine all idle allocations into a single "__idle__" allocation
 	if !options.SplitIdle {
 		for _, idleAlloc := range aggSet.IdleAllocations() {
 			aggSet.Delete(idleAlloc.Name)
@@ -1034,6 +1060,7 @@ func (alloc *Allocation) generateKey(properties Properties) (string, error) {
 	return strings.Join(names, "/"), nil
 }
 
+// TODO clean up
 // Helper function to check for slice membership. Not sure if repeated elsewhere in our codebase.
 func indexOf(v string, arr []string) int {
 	for i, s := range arr {
@@ -1060,12 +1087,133 @@ func (as *AllocationSet) Clone() *AllocationSet {
 		allocs[k] = v.Clone()
 	}
 
+	externalKeys := map[string]bool{}
+	for k, v := range as.externalKeys {
+		externalKeys[k] = v
+	}
+
+	idleKeys := map[string]bool{}
+	for k, v := range as.idleKeys {
+		idleKeys[k] = v
+	}
+
 	return &AllocationSet{
-		allocations: allocs,
-		Window:      as.Window.Clone(),
+		allocations:  allocs,
+		externalKeys: externalKeys,
+		idleKeys:     idleKeys,
+		Window:       as.Window.Clone(),
 	}
 }
 
+// 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: external allocation: 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 {
@@ -1074,6 +1222,7 @@ func (as *AllocationSet) Delete(name string) {
 
 	as.Lock()
 	defer as.Unlock()
+	delete(as.externalKeys, name)
 	delete(as.idleKeys, name)
 	delete(as.allocations, name)
 }
@@ -1114,6 +1263,44 @@ func (as *AllocationSet) Get(key string) *Allocation {
 	return nil
 }
 
+// ExternalAllocations returns a map of the external allocations in the set.
+// Returns clones of the actual Allocations, so mutability is not a problem.
+func (as *AllocationSet) ExternalAllocations() map[string]*Allocation {
+	externals := map[string]*Allocation{}
+
+	if as.IsEmpty() {
+		return externals
+	}
+
+	as.RLock()
+	defer as.RUnlock()
+
+	for key := range as.externalKeys {
+		if alloc, ok := as.allocations[key]; ok {
+			externals[key] = alloc.Clone()
+		}
+	}
+
+	return externals
+}
+
+// ExternalCost returns the total aggregated external costs of the set
+func (as *AllocationSet) ExternalCost() float64 {
+	if as.IsEmpty() {
+		return 0.0
+	}
+
+	as.RLock()
+	defer as.RUnlock()
+
+	externalCost := 0.0
+	for _, alloc := range as.allocations {
+		externalCost += alloc.ExternalCost
+	}
+
+	return externalCost
+}
+
 // IdleAllocations returns a map of the idle allocations in the AllocationSet.
 // Returns clones of the actual Allocations, so mutability is not a problem.
 func (as *AllocationSet) IdleAllocations() map[string]*Allocation {
@@ -1143,16 +1330,25 @@ 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.externalKeys == nil {
+		as.externalKeys = map[string]bool{}
+	}
+
+	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 {
@@ -1161,6 +1357,11 @@ func (as *AllocationSet) insert(that *Allocation, accumulate bool) error {
 		as.allocations[that.Name].add(that, false, accumulate)
 	}
 
+	// If the given Allocation is an external one, record that
+	if that.IsExternal() {
+		as.externalKeys[that.Name] = true
+	}
+
 	// If the given Allocation is an idle one, record that
 	if that.IsIdle() {
 		as.idleKeys[that.Name] = true
@@ -1213,10 +1414,13 @@ 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()
 		as.allocations = map[string]*Allocation{}
+		as.externalKeys = map[string]bool{}
 		as.idleKeys = map[string]bool{}
 		as.Unlock()
 	}
@@ -1226,6 +1430,11 @@ func (as *AllocationSet) Set(alloc *Allocation) error {
 
 	as.allocations[alloc.Name] = alloc
 
+	// If the given Allocation is an external one, record that
+	if alloc.IsExternal() {
+		as.externalKeys[alloc.Name] = true
+	}
+
 	// If the given Allocation is an idle one, record that
 	if alloc.IsIdle() {
 		as.idleKeys[alloc.Name] = true
@@ -1272,6 +1481,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 +1549,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,6 +1588,8 @@ 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{}}
 
@@ -1391,6 +1609,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 +1628,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 +1639,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 +1707,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 +1738,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) {}
 

+ 133 - 0
pkg/kubecost/asset.go

@@ -60,6 +60,139 @@ 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: external allocation: 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")
+	}
+
+	names = append(names, ExternalSuffix)
+
+	// TODO: external allocation: efficiency?
+	// TODO: external allocation: 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

+ 385 - 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,151 @@ 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)
+	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
+
+	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 +328,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 +523,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 +549,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 +613,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 +639,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 +1023,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/__external__" {
+		t.Fatalf("expected external allocation with name '%s'; got '%s'", "monitoring/__external__", 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/__external__" {
+		t.Fatalf("expected external allocation with name '%s'; got '%s'", "monitoring/env=prod/__external__", 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__/__external__" {
+		t.Fatalf("expected external allocation with name '%s'; got '%s'", "monitoring/__unallocated__/__external__", 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
+}