Sean Holcomb 5 роки тому
батько
коміт
742d0f9e09

+ 253 - 94
pkg/kubecost/allocation.go

@@ -693,6 +693,7 @@ func NewAllocationSet(start, end time.Time, allocs ...*Allocation) *AllocationSe
 type AllocationAggregationOptions struct {
 	FilterFuncs       []AllocationMatchFunc
 	SplitIdle         bool
+	IdleByNode        bool
 	MergeUnallocated  bool
 	ShareFuncs        []AllocationMatchFunc
 	ShareIdle         string
@@ -818,7 +819,7 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 
 	// (2) In order to correctly share idle and shared costs, we first compute
 	// sharing coefficients, which represent the proportion of each cost to
-	// share with each allocation. Idle allocations are shared per-cluster,
+	// share with each allocation. Idle allocations are shared per-cluster or per-node,
 	// per-allocation, and per-resource, while shared resources are shared per-
 	// allocation only.
 	//
@@ -880,7 +881,7 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 	//  idle:       20.00
 	//
 	// Note that this can happen for any field, not just cluster, so we again
-	// need to track this on a per-cluster, per-allocation, per-resource basis.
+	// need to track this on a per-cluster or per-node, per-allocation, per-resource basis.
 	var idleFiltrationCoefficients map[string]map[string]map[string]float64
 	if len(options.FilterFuncs) > 0 && options.ShareIdle == ShareNone {
 		idleFiltrationCoefficients, err = computeIdleCoeffs(options, as, shareSet)
@@ -927,10 +928,10 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 
 	// (3-5) Filter, distribute idle cost, and aggregate (in that order)
 	for _, alloc := range as.allocations {
-		cluster := alloc.Properties.Cluster
-		if cluster == "" {
-			log.Warningf("AllocationSet.AggregateBy: missing cluster for allocation: %s", alloc.Name)
-			return fmt.Errorf("ClusterProp is not set")
+		idleKey, err := alloc.getIdleKey(options)
+		if err != nil {
+			log.Warningf("AllocationSet.AggregateBy: missing idleKey for allocation: %s", alloc.Name)
+			return err
 		}
 
 		skip := false
@@ -948,7 +949,7 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 			// entry will result in that proportional amount being removed
 			// from the idle allocation at the end of the process.)
 			if idleFiltrationCoefficients != nil {
-				if ifcc, ok := idleFiltrationCoefficients[cluster]; ok {
+				if ifcc, ok := idleFiltrationCoefficients[idleKey]; ok {
 					delete(ifcc, alloc.Name)
 				}
 			}
@@ -961,35 +962,35 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 		// 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
+			// Distribute idle allocations by coefficient per-idleKey, per-allocation
 			for _, idleAlloc := range idleSet.allocations {
-				// Only share idle if the cluster matches; i.e. the allocation
-				// is from the same cluster as the idle costs
-				idleCluster := idleAlloc.Properties.Cluster
-				if idleCluster == "" {
-					return fmt.Errorf("ClusterProp is not set")
+				// Only share idle if the idleKey matches; i.e. the allocation
+				// is from the same idleKey as the idle costs
+				iaIdleKey, err := idleAlloc.getIdleKey(options)
+				if err != nil {
+					return err
 				}
-				if idleCluster != cluster {
+				if iaIdleKey != idleKey {
 					continue
 				}
 
 				// Make sure idle coefficients exist
-				if _, ok := idleCoefficients[cluster]; !ok {
-					log.Warningf("AllocationSet.AggregateBy: error getting idle coefficient: no cluster '%s' for '%s'", cluster, alloc.Name)
+				if _, ok := idleCoefficients[idleKey]; !ok {
+					log.Warningf("AllocationSet.AggregateBy: error getting idle coefficient: no idleKey '%s' for '%s'", idleKey, alloc.Name)
 					continue
 				}
-				if _, ok := idleCoefficients[cluster][alloc.Name]; !ok {
+				if _, ok := idleCoefficients[idleKey][alloc.Name]; !ok {
 					log.Warningf("AllocationSet.AggregateBy: error getting idle coefficient for '%s'", alloc.Name)
 					continue
 				}
 
-				alloc.CPUCoreHours += idleAlloc.CPUCoreHours * idleCoefficients[cluster][alloc.Name]["cpu"]
-				alloc.GPUHours += idleAlloc.GPUHours * idleCoefficients[cluster][alloc.Name]["gpu"]
-				alloc.RAMByteHours += idleAlloc.RAMByteHours * idleCoefficients[cluster][alloc.Name]["ram"]
+				alloc.CPUCoreHours += idleAlloc.CPUCoreHours * idleCoefficients[idleKey][alloc.Name]["cpu"]
+				alloc.GPUHours += idleAlloc.GPUHours * idleCoefficients[idleKey][alloc.Name]["gpu"]
+				alloc.RAMByteHours += idleAlloc.RAMByteHours * idleCoefficients[idleKey][alloc.Name]["ram"]
 
-				idleCPUCost := idleAlloc.CPUCost * idleCoefficients[cluster][alloc.Name]["cpu"]
-				idleGPUCost := idleAlloc.GPUCost * idleCoefficients[cluster][alloc.Name]["gpu"]
-				idleRAMCost := idleAlloc.RAMCost * idleCoefficients[cluster][alloc.Name]["ram"]
+				idleCPUCost := idleAlloc.CPUCost * idleCoefficients[idleKey][alloc.Name]["cpu"]
+				idleGPUCost := idleAlloc.GPUCost * idleCoefficients[idleKey][alloc.Name]["gpu"]
+				idleRAMCost := idleAlloc.RAMCost * idleCoefficients[idleKey][alloc.Name]["ram"]
 				alloc.CPUCost += idleCPUCost
 				alloc.GPUCost += idleGPUCost
 				alloc.RAMCost += idleRAMCost
@@ -1015,41 +1016,41 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 	// before sharing with the aggregated allocations.
 	if idleSet.Length() > 0 && shareSet.Length() > 0 {
 		for _, alloc := range shareSet.allocations {
-			cluster := alloc.Properties.Cluster
-			if cluster == "" {
-				log.Warningf("AllocationSet.AggregateBy: missing cluster for allocation: %s", alloc.Name)
+			idleKey, err := alloc.getIdleKey(options)
+			if err != nil {
+				log.Warningf("AllocationSet.AggregateBy: missing idleKey for allocation: %s", alloc.Name)
 				return err
 			}
 
-			// Distribute idle allocations by coefficient per-cluster, per-allocation
+			// Distribute idle allocations by coefficient per-idleKey, per-allocation
 			for _, idleAlloc := range idleSet.allocations {
-				// Only share idle if the cluster matches; i.e. the allocation
-				// is from the same cluster as the idle costs
-				idleCluster := idleAlloc.Properties.Cluster
-				if idleCluster == "" {
-					return fmt.Errorf("ClusterProp is not set")
+				// Only share idle if the idleKey matches; i.e. the allocation
+				// is from the same idleKey as the idle costs
+				iaIdleKey, err := idleAlloc.getIdleKey(options)
+				if err != nil {
+					return nil
 				}
-				if idleCluster != cluster {
+				if iaIdleKey != idleKey {
 					continue
 				}
 
 				// Make sure idle coefficients exist
-				if _, ok := idleCoefficients[cluster]; !ok {
-					log.Warningf("AllocationSet.AggregateBy: error getting idle coefficient: no cluster '%s' for '%s'", cluster, alloc.Name)
+				if _, ok := idleCoefficients[idleKey]; !ok {
+					log.Warningf("AllocationSet.AggregateBy: error getting idle coefficient: no idleKey '%s' for '%s'", idleKey, alloc.Name)
 					continue
 				}
-				if _, ok := idleCoefficients[cluster][alloc.Name]; !ok {
+				if _, ok := idleCoefficients[idleKey][alloc.Name]; !ok {
 					log.Warningf("AllocationSet.AggregateBy: error getting idle coefficient for '%s'", alloc.Name)
 					continue
 				}
 
-				alloc.CPUCoreHours += idleAlloc.CPUCoreHours * idleCoefficients[cluster][alloc.Name]["cpu"]
-				alloc.GPUHours += idleAlloc.GPUHours * idleCoefficients[cluster][alloc.Name]["gpu"]
-				alloc.RAMByteHours += idleAlloc.RAMByteHours * idleCoefficients[cluster][alloc.Name]["ram"]
+				alloc.CPUCoreHours += idleAlloc.CPUCoreHours * idleCoefficients[idleKey][alloc.Name]["cpu"]
+				alloc.GPUHours += idleAlloc.GPUHours * idleCoefficients[idleKey][alloc.Name]["gpu"]
+				alloc.RAMByteHours += idleAlloc.RAMByteHours * idleCoefficients[idleKey][alloc.Name]["ram"]
 
-				idleCPUCost := idleAlloc.CPUCost * idleCoefficients[cluster][alloc.Name]["cpu"]
-				idleGPUCost := idleAlloc.GPUCost * idleCoefficients[cluster][alloc.Name]["gpu"]
-				idleRAMCost := idleAlloc.RAMCost * idleCoefficients[cluster][alloc.Name]["ram"]
+				idleCPUCost := idleAlloc.CPUCost * idleCoefficients[idleKey][alloc.Name]["cpu"]
+				idleGPUCost := idleAlloc.GPUCost * idleCoefficients[idleKey][alloc.Name]["gpu"]
+				idleRAMCost := idleAlloc.RAMCost * idleCoefficients[idleKey][alloc.Name]["ram"]
 				alloc.CPUCost += idleCPUCost
 				alloc.GPUCost += idleGPUCost
 				alloc.RAMCost += idleRAMCost
@@ -1057,17 +1058,18 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 		}
 	}
 
-	// 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
+	// groupingIdleFiltrationCoeffs is used to track per-resource idle
+	// coefficients on a cluster-by-cluster or node-by-node basis depending
+	// on the IdleByNode option. It is, essentailly, an aggregation of
+	// idleFiltrationCoefficients after they have been
 	// filtered above (in step 3)
-	var clusterIdleFiltrationCoeffs map[string]map[string]float64
+	var groupingIdleFiltrationCoeffs map[string]map[string]float64
 	if idleFiltrationCoefficients != nil {
-		clusterIdleFiltrationCoeffs = map[string]map[string]float64{}
+		groupingIdleFiltrationCoeffs = map[string]map[string]float64{}
 
-		for cluster, m := range idleFiltrationCoefficients {
-			if _, ok := clusterIdleFiltrationCoeffs[cluster]; !ok {
-				clusterIdleFiltrationCoeffs[cluster] = map[string]float64{
+		for idleKey, m := range idleFiltrationCoefficients {
+			if _, ok := groupingIdleFiltrationCoeffs[idleKey]; !ok {
+				groupingIdleFiltrationCoeffs[idleKey] = map[string]float64{
 					"cpu": 0.0,
 					"gpu": 0.0,
 					"ram": 0.0,
@@ -1076,7 +1078,7 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 
 			for _, n := range m {
 				for resource, val := range n {
-					clusterIdleFiltrationCoeffs[cluster][resource] += val
+					groupingIdleFiltrationCoeffs[idleKey][resource] += val
 				}
 			}
 		}
@@ -1084,17 +1086,17 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 
 	// (7) If we have both un-shared idle allocations and idle filtration
 	// coefficients then apply those. See step (2b) for an example.
-	if len(aggSet.idleKeys) > 0 && clusterIdleFiltrationCoeffs != nil {
+	if len(aggSet.idleKeys) > 0 && groupingIdleFiltrationCoeffs != nil {
 		for idleKey := range aggSet.idleKeys {
 			idleAlloc := aggSet.Get(idleKey)
 
-			cluster := idleAlloc.Properties.Cluster
-			if cluster == "" {
-				log.Warningf("AllocationSet.AggregateBy: idle allocation without cluster: %s", idleAlloc)
+			iaIdleKey, err := idleAlloc.getIdleKey(options)
+			if err != nil {
+				log.Warningf("AllocationSet.AggregateBy: idle allocation without IdleKey: %s", idleAlloc)
 				continue
 			}
 
-			if resourceCoeffs, ok := clusterIdleFiltrationCoeffs[cluster]; ok {
+			if resourceCoeffs, ok := groupingIdleFiltrationCoeffs[iaIdleKey]; ok {
 				idleAlloc.CPUCost *= resourceCoeffs["cpu"]
 				idleAlloc.CPUCoreHours *= resourceCoeffs["cpu"]
 				idleAlloc.RAMCost *= resourceCoeffs["ram"]
@@ -1236,43 +1238,42 @@ func computeIdleCoeffs(options *AllocationAggregationOptions, as *AllocationSet,
 			continue
 		}
 
-		// We need to key the allocations by cluster id
-		clusterID := alloc.Properties.Cluster
-		if clusterID == "" {
-			return nil, fmt.Errorf("ClusterProp is not set")
+		idleKey, err := alloc.getIdleKey(options)
+		if err != nil {
+			return nil, err
 		}
 
 		// get the name key for the allocation
 		name := alloc.Name
 
-		// Create cluster based tables if they don't exist
-		if _, ok := coeffs[clusterID]; !ok {
-			coeffs[clusterID] = map[string]map[string]float64{}
+		// Create key based tables if they don't exist
+		if _, ok := coeffs[idleKey]; !ok {
+			coeffs[idleKey] = map[string]map[string]float64{}
 		}
-		if _, ok := totals[clusterID]; !ok {
-			totals[clusterID] = map[string]float64{}
+		if _, ok := totals[idleKey]; !ok {
+			totals[idleKey] = map[string]float64{}
 		}
 
-		if _, ok := coeffs[clusterID][name]; !ok {
-			coeffs[clusterID][name] = map[string]float64{}
+		if _, ok := coeffs[idleKey][name]; !ok {
+			coeffs[idleKey][name] = map[string]float64{}
 		}
 
 		if shareType == ShareEven {
 			for _, r := range types {
 				// Not additive - hard set to 1.0
-				coeffs[clusterID][name][r] = 1.0
+				coeffs[idleKey][name][r] = 1.0
 
 				// totals are additive
-				totals[clusterID][r] += 1.0
+				totals[idleKey][r] += 1.0
 			}
 		} else {
-			coeffs[clusterID][name]["cpu"] += alloc.CPUTotalCost()
-			coeffs[clusterID][name]["gpu"] += alloc.GPUTotalCost()
-			coeffs[clusterID][name]["ram"] += alloc.RAMTotalCost()
+			coeffs[idleKey][name]["cpu"] += alloc.CPUTotalCost()
+			coeffs[idleKey][name]["gpu"] += alloc.GPUTotalCost()
+			coeffs[idleKey][name]["ram"] += alloc.RAMTotalCost()
 
-			totals[clusterID]["cpu"] += alloc.CPUTotalCost()
-			totals[clusterID]["gpu"] += alloc.GPUTotalCost()
-			totals[clusterID]["ram"] += alloc.RAMTotalCost()
+			totals[idleKey]["cpu"] += alloc.CPUTotalCost()
+			totals[idleKey]["gpu"] += alloc.GPUTotalCost()
+			totals[idleKey]["ram"] += alloc.RAMTotalCost()
 		}
 	}
 
@@ -1283,43 +1284,43 @@ func computeIdleCoeffs(options *AllocationAggregationOptions, as *AllocationSet,
 			continue
 		}
 
-		// We need to key the allocations by cluster id
-		clusterID := alloc.Properties.Cluster
-		if clusterID == "" {
-			return nil, fmt.Errorf("ClusterProp is not set")
+		// idleKey will be providerId or cluster
+		idleKey, err := alloc.getIdleKey(options)
+		if err != nil {
+			return nil, err
 		}
 
 		// get the name key for the allocation
 		name := alloc.Name
 
-		// Create cluster based tables if they don't exist
-		if _, ok := coeffs[clusterID]; !ok {
-			coeffs[clusterID] = map[string]map[string]float64{}
+		// Create idleKey based tables if they don't exist
+		if _, ok := coeffs[idleKey]; !ok {
+			coeffs[idleKey] = map[string]map[string]float64{}
 		}
-		if _, ok := totals[clusterID]; !ok {
-			totals[clusterID] = map[string]float64{}
+		if _, ok := totals[idleKey]; !ok {
+			totals[idleKey] = map[string]float64{}
 		}
 
-		if _, ok := coeffs[clusterID][name]; !ok {
-			coeffs[clusterID][name] = map[string]float64{}
+		if _, ok := coeffs[idleKey][name]; !ok {
+			coeffs[idleKey][name] = map[string]float64{}
 		}
 
 		if shareType == ShareEven {
 			for _, r := range types {
 				// Not additive - hard set to 1.0
-				coeffs[clusterID][name][r] = 1.0
+				coeffs[idleKey][name][r] = 1.0
 
 				// totals are additive
-				totals[clusterID][r] += 1.0
+				totals[idleKey][r] += 1.0
 			}
 		} else {
-			coeffs[clusterID][name]["cpu"] += alloc.CPUTotalCost()
-			coeffs[clusterID][name]["gpu"] += alloc.GPUTotalCost()
-			coeffs[clusterID][name]["ram"] += alloc.RAMTotalCost()
+			coeffs[idleKey][name]["cpu"] += alloc.CPUTotalCost()
+			coeffs[idleKey][name]["gpu"] += alloc.GPUTotalCost()
+			coeffs[idleKey][name]["ram"] += alloc.RAMTotalCost()
 
-			totals[clusterID]["cpu"] += alloc.CPUTotalCost()
-			totals[clusterID]["gpu"] += alloc.GPUTotalCost()
-			totals[clusterID]["ram"] += alloc.RAMTotalCost()
+			totals[idleKey]["cpu"] += alloc.CPUTotalCost()
+			totals[idleKey]["gpu"] += alloc.GPUTotalCost()
+			totals[idleKey]["ram"] += alloc.RAMTotalCost()
 		}
 	}
 
@@ -1337,6 +1338,26 @@ func computeIdleCoeffs(options *AllocationAggregationOptions, as *AllocationSet,
 	return coeffs, nil
 }
 
+// getIdleKey returns the providerId or cluster of an Allocation depending on the IdleByNode
+// option in the AllocationAggregationOptions and an error if the respective field is missing
+func (a *Allocation) getIdleKey(options *AllocationAggregationOptions) (string, error) {
+	var idleKey string
+	if options.IdleByNode {
+		// Key allocations to ProviderId to match against node
+		idleKey = a.Properties.ProviderID
+		if idleKey == "" {
+			return idleKey, fmt.Errorf("ProviderId is not set")
+		}
+	} else {
+		// key the allocations by cluster id
+		idleKey = a.Properties.Cluster
+		if idleKey == "" {
+			return idleKey, fmt.Errorf("ClusterProp is not set")
+		}
+	}
+	return idleKey, nil
+}
+
 func (a *Allocation) generateKey(aggregateBy []string) string {
 	if a == nil {
 		return ""
@@ -1631,6 +1652,144 @@ func (as *AllocationSet) ComputeIdleAllocations(assetSet *AssetSet) (map[string]
 	return idleAllocs, nil
 }
 
+// ComputeIdleAllocationsByNode 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. If the Nodes have adjustments, then apply
+// the adjustments proportionally to each of the resources so that total
+// allocation with idle reflects the adjusted node costs. One idle allocation
+// per-node will be computed and returned, keyed by cluster_id.
+func (as *AllocationSet) ComputeIdleAllocationsByNode(assetSet *AssetSet) (map[string]*Allocation, error) {
+	if as == nil {
+		return nil, fmt.Errorf("cannot compute idle allocation for nil AllocationSet")
+	}
+
+	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.
+	assetNodeResourceCosts := map[string]map[string]float64{}
+	nodesByProviderId := map[string]*Node{}
+	assetSet.Each(func(key string, a Asset) {
+		if node, ok := a.(*Node); ok {
+			if _, ok := assetNodeResourceCosts[node.Properties().ProviderID]; ok || node.Properties().ProviderID == "" {
+				return
+			}
+
+			nodesByProviderId[node.Properties().ProviderID] = node
+			assetNodeResourceCosts[node.Properties().ProviderID] = map[string]float64{}
+
+			// adjustmentRate is used to scale resource costs proportionally
+			// by the adjustment. This is necessary because we only get one
+			// adjustment per Node, not one per-resource-per-Node.
+			//
+			// e.g. total cost = $90, adjustment = -$10 => 0.9
+			// e.g. total cost = $150, adjustment = -$300 => 0.3333
+			// e.g. total cost = $150, adjustment = $50 => 1.5
+			adjustmentRate := 1.0
+			if node.TotalCost()-node.Adjustment() == 0 {
+				// If (totalCost - adjustment) is 0.0 then adjustment cancels
+				// the entire node cost and we should make everything 0
+				// without dividing by 0.
+				adjustmentRate = 0.0
+			} else if node.Adjustment() != 0.0 {
+				// adjustmentRate is the ratio of cost-with-adjustment (i.e. TotalCost)
+				// to cost-without-adjustment (i.e. TotalCost - Adjustment).
+				adjustmentRate = node.TotalCost() / (node.TotalCost() - node.Adjustment())
+			}
+
+			cpuCost := node.CPUCost * (1.0 - node.Discount) * adjustmentRate
+			gpuCost := node.GPUCost * (1.0 - node.Discount) * adjustmentRate
+			ramCost := node.RAMCost * (1.0 - node.Discount) * adjustmentRate
+
+			assetNodeResourceCosts[node.Properties().ProviderID]["cpu"] += cpuCost
+			assetNodeResourceCosts[node.Properties().ProviderID]["gpu"] += gpuCost
+			assetNodeResourceCosts[node.Properties().ProviderID]["ram"] += ramCost
+		}
+	})
+
+	// Determine start, end on a per-cluster basis
+	nodeStarts := map[string]time.Time{}
+	nodeEnds := map[string]time.Time{}
+
+	// Subtract allocated costs from asset costs, leaving only the remaining
+	// idle costs.
+	as.Each(func(name string, a *Allocation) {
+		providerId := a.Properties.ProviderID
+		if providerId == "" {
+			// Failed to find allocation's node
+			return
+		}
+
+		if _, ok := assetNodeResourceCosts[providerId]; !ok {
+			// Failed to find assets for allocation's node
+			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 := nodeStarts[providerId]; !ok || a.Start.Before(s) {
+			nodeStarts[providerId] = a.Start
+		}
+		if e, ok := nodeEnds[providerId]; !ok || a.End.After(e) {
+			nodeEnds[providerId] = a.End
+		}
+
+		assetNodeResourceCosts[providerId]["cpu"] -= a.CPUTotalCost()
+		assetNodeResourceCosts[providerId]["gpu"] -= a.GPUTotalCost()
+		assetNodeResourceCosts[providerId]["ram"] -= a.RAMTotalCost()
+	})
+
+	// Turn remaining un-allocated asset costs into idle allocations
+	idleAllocs := map[string]*Allocation{}
+	for providerId, resources := range assetNodeResourceCosts {
+		// 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 := nodeStarts[providerId]; ok && window.Contains(s) {
+			start = s
+		}
+		end := *window.End()
+		if e, ok := nodeEnds[providerId]; ok && window.Contains(e) {
+			end = e
+		}
+		node := nodesByProviderId[providerId]
+		idleAlloc := &Allocation{
+			Name:   fmt.Sprintf("%s/%s", node.properties.Name, IdleSuffix),
+			Window: window.Clone(),
+			Properties: &AllocationProperties{
+				Cluster:    node.properties.Cluster,
+				Node:       node.properties.Name,
+				ProviderID: providerId,
+			},
+			Start:   start,
+			End:     end,
+			CPUCost: resources["cpu"],
+			GPUCost: resources["gpu"],
+			RAMCost: resources["ram"],
+		}
+
+		// Do not continue if multiple idle allocations are computed for a
+		// single node.
+		if _, ok := idleAllocs[providerId]; ok {
+			return nil, fmt.Errorf("duplicate idle allocations for node Provider ID: %s", providerId)
+		}
+
+		idleAllocs[providerId] = idleAlloc
+	}
+
+	return idleAllocs, nil
+}
+
 // Reconcile calculate the exact cost of Allocation by resource(cpu, ram, gpu etc) based on Asset(s) on which
 // the Allocation depends.
 func (as *AllocationSet) Reconcile(assetSet *AssetSet) error {

+ 322 - 80
pkg/kubecost/allocation_test.go

@@ -559,12 +559,10 @@ func TestNewAllocationSet(t *testing.T) {
 	// TODO niko/etl
 }
 
-func generateAllocationSet(start time.Time) *AllocationSet {
-	// Idle allocations
+func generateAllocationSetClusterIdle(start time.Time) *AllocationSet {
+	// Cluster Idle allocations
 	a1i := NewUnitAllocation(fmt.Sprintf("cluster1/%s", IdleSuffix), start, day, &AllocationProperties{
-		Cluster:    "cluster1",
-		Node:       "node1",
-		ProviderID: "c1nodes",
+		Cluster: "cluster1",
 	})
 	a1i.CPUCost = 5.0
 	a1i.RAMCost = 15.0
@@ -577,6 +575,61 @@ func generateAllocationSet(start time.Time) *AllocationSet {
 	a2i.RAMCost = 5.0
 	a2i.GPUCost = 0.0
 
+	as := generateAllocationSet(start)
+	as.Insert(a1i)
+	as.Insert(a2i)
+	return as
+}
+
+func generateAllocationSetNodeIdle(start time.Time) *AllocationSet {
+	// Node Idle allocations
+	a11i := NewUnitAllocation(fmt.Sprintf("c1nodes/%s", IdleSuffix), start, day, &AllocationProperties{
+		Cluster:    "cluster1",
+		Node:       "c1nodes",
+		ProviderID: "c1nodes",
+	})
+	a11i.CPUCost = 5.0
+	a11i.RAMCost = 15.0
+	a11i.GPUCost = 0.0
+
+	a21i := NewUnitAllocation(fmt.Sprintf("node1/%s", IdleSuffix), start, day, &AllocationProperties{
+		Cluster:    "cluster2",
+		Node:       "node1",
+		ProviderID: "node1",
+	})
+	a21i.CPUCost = 1.666667
+	a21i.RAMCost = 1.666667
+	a21i.GPUCost = 0.0
+
+	a22i := NewUnitAllocation(fmt.Sprintf("node2/%s", IdleSuffix), start, day, &AllocationProperties{
+		Cluster:    "cluster2",
+		Node:       "node2",
+		ProviderID: "node2",
+	})
+	a22i.CPUCost = 1.666667
+	a22i.RAMCost = 1.666667
+	a22i.GPUCost = 0.0
+
+	a23i := NewUnitAllocation(fmt.Sprintf("node3/%s", IdleSuffix), start, day, &AllocationProperties{
+		Cluster:    "cluster2",
+		Node:       "node3",
+		ProviderID: "node3",
+		Namespace: "",
+	})
+	a23i.CPUCost = 1.666667
+	a23i.RAMCost = 1.666667
+	a23i.GPUCost = 0.0
+
+	as := generateAllocationSet(start)
+	as.Insert(a11i)
+	as.Insert(a21i)
+	as.Insert(a22i)
+	as.Insert(a23i)
+	return as
+}
+
+func generateAllocationSet(start time.Time) *AllocationSet {
+
 	// Active allocations
 	a1111 := NewUnitAllocation("cluster1/namespace1/pod1/container1", start, day, &AllocationProperties{
 		Cluster:    "cluster1",
@@ -722,8 +775,6 @@ func generateAllocationSet(start time.Time) *AllocationSet {
 	a22pqr6.Properties.Services = []string{"service1"}
 
 	return NewAllocationSet(start, start.Add(day),
-		// idle
-		a1i, a2i,
 		// cluster 1, namespace1
 		a1111, a11abc2, a11def3,
 		// cluster 1, namespace 2
@@ -771,7 +822,7 @@ func generateAssetSets(start, end time.Time) []*AssetSet {
 	//   cluster2 idle                             82.00  44.00  34.00   4.00        0.00
 	// +-----------------------------------------+------+------+------+------+------------+
 
-	cluster1Nodes := NewNode("", "cluster1", "c1nodes", start, end, NewWindow(&start, &end))
+	cluster1Nodes := NewNode("c1nodes", "cluster1", "c1nodes", start, end, NewWindow(&start, &end))
 	cluster1Nodes.CPUCost = 55.0
 	cluster1Nodes.RAMCost = 44.0
 	cluster1Nodes.GPUCost = 11.0
@@ -1019,6 +1070,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	// 6b Share idle with filters
 	// 6c Share resources with filters
 	// 6d Share idle and share resources
+	// 6e IdleByNode
 
 	// 7  Edge cases and errors
 	// 7a Empty AggregationProperties
@@ -1048,7 +1100,10 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	// There will only ever be one __unallocated__
 	numUnallocated := 1
 	// There are two clusters, so each gets an idle entry when they are split
-	numSplitIdle := 2
+	numSplitIdleCluster := 2
+
+	// There are two clusters, so each gets an idle entry when they are split
+	numSplitIdleNode := 4
 
 	activeTotalCost := 82.0
 	idleTotalCost := 30.0
@@ -1431,9 +1486,9 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 		// namespace2: 45.5000 = 36.00 + 18.00*(1.0/2.0)
 		// idle:       30.0000
 		"4a": {
-			start:      start,
-			aggBy:      []string{AllocationNamespaceProp},
-			aggOpts:    &AllocationAggregationOptions{
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
 				ShareFuncs: []AllocationMatchFunc{isNamespace3},
 				ShareSplit: ShareEven,
 			},
@@ -1453,9 +1508,9 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 		// namespace2: 37.5000 =
 		// idle:       30.0000
 		"4b": {
-			start:      start,
-			aggBy:      []string{AllocationNamespaceProp},
-			aggOpts:    &AllocationAggregationOptions{
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
 				ShareFuncs: []AllocationMatchFunc{isNamespace3},
 				ShareSplit: ShareWeighted,
 			},
@@ -1476,9 +1531,9 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 		// namespace3: 23.3333 = 18.00 + 16.00*(1.0/3.0)
 		// idle:       30.0000
 		"4c": {
-			start:      start,
-			aggBy:      []string{AllocationNamespaceProp},
-			aggOpts:    &AllocationAggregationOptions{
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
 				ShareFuncs: []AllocationMatchFunc{isApp1},
 				ShareSplit: ShareEven,
 			},
@@ -1500,14 +1555,14 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 		// namespace3: 54.878 = 18.00 + (7.0*24.0)*(18.00/82.00)
 		// idle:       30.0000
 		"4d": {
-			start:      start,
-			aggBy:      []string{AllocationNamespaceProp},
-			aggOpts:    &AllocationAggregationOptions{
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
 				SharedHourlyCosts: map[string]float64{"total": sharedOverheadHourlyCost},
 				ShareSplit:        ShareWeighted,
 			},
 			numResults: numNamespaces + numIdle,
-			totalCost:  activeTotalCost+idleTotalCost+(sharedOverheadHourlyCost*24.0),
+			totalCost:  activeTotalCost + idleTotalCost + (sharedOverheadHourlyCost * 24.0),
 			results: map[string]float64{
 				"namespace1": 85.366,
 				"namespace2": 109.756,
@@ -1522,14 +1577,14 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 		// 5a Filter by cluster with separate idle
 		"5a": {
-			start:      start,
-			aggBy:      []string{AllocationClusterProp},
-			aggOpts:    &AllocationAggregationOptions{
+			start: start,
+			aggBy: []string{AllocationClusterProp},
+			aggOpts: &AllocationAggregationOptions{
 				FilterFuncs: []AllocationMatchFunc{isCluster("cluster1")},
 				ShareIdle:   ShareNone,
 			},
 			numResults: 1 + numIdle,
-			totalCost: 66.0,
+			totalCost:  66.0,
 			results: map[string]float64{
 				"cluster1": 46.00,
 				IdleSuffix: 20.00,
@@ -1540,14 +1595,14 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 		},
 		// 5b Filter by cluster with shared idle
 		"5b": {
-			start:      start,
-			aggBy:      []string{AllocationClusterProp},
-			aggOpts:    &AllocationAggregationOptions{
+			start: start,
+			aggBy: []string{AllocationClusterProp},
+			aggOpts: &AllocationAggregationOptions{
 				FilterFuncs: []AllocationMatchFunc{isCluster("cluster1")},
 				ShareIdle:   ShareWeighted,
 			},
 			numResults: 1,
-			totalCost: 66.0,
+			totalCost:  66.0,
 			results: map[string]float64{
 				"cluster1": 66.00,
 			},
@@ -1557,14 +1612,14 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 		},
 		// 5c Filter by cluster, agg by namespace, with separate idle
 		"5c": {
-			start:      start,
-			aggBy:      []string{AllocationNamespaceProp},
-			aggOpts:    &AllocationAggregationOptions{
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
 				FilterFuncs: []AllocationMatchFunc{isCluster("cluster1")},
 				ShareIdle:   ShareNone,
 			},
 			numResults: 2 + numIdle,
-			totalCost: 66.0,
+			totalCost:  66.0,
 			results: map[string]float64{
 				"namespace1": 28.00,
 				"namespace2": 18.00,
@@ -1576,14 +1631,14 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 		},
 		// 5d Filter by namespace, agg by cluster, with separate idle
 		"5d": {
-			start:      start,
-			aggBy:      []string{AllocationClusterProp},
-			aggOpts:    &AllocationAggregationOptions{
+			start: start,
+			aggBy: []string{AllocationClusterProp},
+			aggOpts: &AllocationAggregationOptions{
 				FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
 				ShareIdle:   ShareNone,
 			},
 			numResults: numClusters + numIdle,
-			totalCost: 46.31,
+			totalCost:  46.31,
 			results: map[string]float64{
 				"cluster1": 18.00,
 				"cluster2": 18.00,
@@ -1597,13 +1652,13 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 		// 6a SplitIdle
 		"6a": {
-			start:      start,
-			aggBy:      []string{AllocationNamespaceProp},
-			aggOpts:    &AllocationAggregationOptions{
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
 				SplitIdle: true,
 			},
-			numResults: numNamespaces+numSplitIdle,
-			totalCost: activeTotalCost+idleTotalCost,
+			numResults: numNamespaces + numSplitIdleCluster,
+			totalCost:  activeTotalCost + idleTotalCost,
 			results: map[string]float64{
 				"namespace1":                           28.00,
 				"namespace2":                           36.00,
@@ -1619,14 +1674,14 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 		// Should match values from unfiltered aggregation (3a)
 		// namespace2: 46.3125 = 36.00 + 5.0*(3.0/6.0) + 15.0*(3.0/16.0) + 5.0*(3.0/6.0) + 5.0*(3.0/6.0)
 		"6b": {
-			start:      start,
-			aggBy:      []string{AllocationNamespaceProp},
-			aggOpts:    &AllocationAggregationOptions{
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
 				FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
 				ShareIdle:   ShareWeighted,
 			},
 			numResults: 1,
-			totalCost: 46.31,
+			totalCost:  46.31,
 			results: map[string]float64{
 				"namespace2": 46.31,
 			},
@@ -1638,14 +1693,14 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 		// Should match values from unfiltered aggregation (3b)
 		// namespace2: 51.0000 = 36.00 + 5.0*(1.0/2.0) + 15.0*(1.0/2.0) + 5.0*(1.0/2.0) + 5.0*(1.0/2.0)
 		"6c": {
-			start:      start,
-			aggBy:      []string{AllocationNamespaceProp},
-			aggOpts:    &AllocationAggregationOptions{
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
 				FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
 				ShareIdle:   ShareEven,
 			},
 			numResults: 1,
-			totalCost: 51.00,
+			totalCost:  51.00,
 			results: map[string]float64{
 				"namespace2": 51.00,
 			},
@@ -1660,15 +1715,15 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 		// idle:       30.0000
 		// Then namespace 2 is filtered.
 		"6d": {
-			start:      start,
-			aggBy:      []string{AllocationNamespaceProp},
-			aggOpts:    &AllocationAggregationOptions{
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
 				FilterFuncs:       []AllocationMatchFunc{isNamespace("namespace2")},
 				SharedHourlyCosts: map[string]float64{"total": sharedOverheadHourlyCost},
 				ShareSplit:        ShareWeighted,
 			},
 			numResults: 1 + numIdle,
-			totalCost: 139.756,
+			totalCost:  139.756,
 			results: map[string]float64{
 				"namespace2": 109.756,
 				IdleSuffix:   30.00,
@@ -1686,15 +1741,15 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 		// namespace2: 54.667 = 36.00 + (28.00)*(36.00/54.00)
 		// idle:       30.0000
 		"6e": {
-			start:      start,
-			aggBy:      []string{AllocationNamespaceProp},
-			aggOpts:    &AllocationAggregationOptions{
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
 				FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
 				ShareFuncs:  []AllocationMatchFunc{isNamespace("namespace1")},
 				ShareSplit:  ShareWeighted,
 			},
 			numResults: 1 + numIdle,
-			totalCost: 84.667,
+			totalCost:  84.667,
 			results: map[string]float64{
 				"namespace2": 54.667,
 				IdleSuffix:   30.00,
@@ -1738,15 +1793,15 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 		//   idle cost       5.0000
 		//   shared cost    14.2292 = (42.6875)*(18.0/54.0)
 		"6f": {
-			start:      start,
-			aggBy:      []string{AllocationNamespaceProp},
-			aggOpts:    &AllocationAggregationOptions{
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
 				ShareFuncs: []AllocationMatchFunc{isNamespace("namespace1")},
 				ShareSplit: ShareWeighted,
 				ShareIdle:  ShareWeighted,
 			},
 			numResults: 2,
-			totalCost: activeTotalCost+idleTotalCost,
+			totalCost:  activeTotalCost + idleTotalCost,
 			results: map[string]float64{
 				"namespace2": 74.77,
 				"namespace3": 37.23,
@@ -1792,16 +1847,16 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 		//
 		// Then, filter for namespace2: 74.7708
 		"6g": {
-			start:      start,
-			aggBy:      []string{AllocationNamespaceProp},
-			aggOpts:    &AllocationAggregationOptions{
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
 				FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
 				ShareFuncs:  []AllocationMatchFunc{isNamespace("namespace1")},
 				ShareSplit:  ShareWeighted,
 				ShareIdle:   ShareWeighted,
 			},
 			numResults: 1,
-			totalCost: 74.77,
+			totalCost:  74.77,
 			results: map[string]float64{
 				"namespace2": 74.77,
 			},
@@ -1838,16 +1893,16 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 		//
 		// Then namespace 2 is filtered.
 		"6h": {
-			start:      start,
-			aggBy:      []string{AllocationNamespaceProp},
-			aggOpts:    &AllocationAggregationOptions{
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
 				FilterFuncs:       []AllocationMatchFunc{isNamespace("namespace2")},
 				ShareSplit:        ShareWeighted,
 				ShareIdle:         ShareWeighted,
 				SharedHourlyCosts: map[string]float64{"total": sharedOverheadHourlyCost},
 			},
 			numResults: 1,
-			totalCost: 120.07,
+			totalCost:  120.07,
 			results: map[string]float64{
 				"namespace2": 120.07,
 			},
@@ -1855,6 +1910,89 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			windowEnd:   endYesterday,
 			expMinutes:  1440.0,
 		},
+		// 6i Idle by Node
+		"6i": {
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
+				IdleByNode: true,
+			},
+			numResults: numNamespaces + numIdle,
+			totalCost:  activeTotalCost + idleTotalCost,
+			results: map[string]float64{
+				"namespace1": 28.00,
+				"namespace2": 36.00,
+				"namespace3": 18.00,
+				IdleSuffix:   30.00,
+			},
+			windowStart: startYesterday,
+			windowEnd:   endYesterday,
+			expMinutes:  1440.0,
+		},
+		// 6j Split Idle, Idle by Node
+		"6j": {
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
+				SplitIdle:  true,
+				IdleByNode: true,
+			},
+			numResults: numNamespaces + numSplitIdleNode,
+			totalCost:  activeTotalCost + idleTotalCost,
+			results: map[string]float64{
+				"namespace1":                          28.00,
+				"namespace2":                          36.00,
+				"namespace3":                          18.00,
+				fmt.Sprintf("c1nodes/%s", IdleSuffix): 20.00,
+				fmt.Sprintf("node1/%s", IdleSuffix):   3.333333,
+				fmt.Sprintf("node2/%s", IdleSuffix):   3.333333,
+				fmt.Sprintf("node3/%s", IdleSuffix):   3.333333,
+			},
+			windowStart: startYesterday,
+			windowEnd:   endYesterday,
+			expMinutes:  1440.0,
+		},
+		// 6k Share idle Even Idle by Node
+		// Should match values from unfiltered aggregation (3b)
+		"6k": {
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
+				ShareIdle:   ShareEven,
+				IdleByNode:  true,
+			},
+			numResults: 3,
+			totalCost:  112.00,
+			results: map[string]float64{
+				"namespace1": 38.00,
+				"namespace2": 51.00,
+				"namespace3": 23.00,
+			},
+			windowStart: startYesterday,
+			windowEnd:   endYesterday,
+			expMinutes:  1440.0,
+		},
+		// 6l Share idle weighted with filters, Idle by Node
+		// Should match values from unfiltered aggregation (3a)
+		// namespace2: 46.3125 = 36.00 + 5.0*(3.0/6.0) + 15.0*(3.0/16.0) + 5.0*(3.0/6.0) + 5.0*(3.0/6.0)
+		"6l": {
+			start: start,
+			aggBy: []string{AllocationNamespaceProp},
+			aggOpts: &AllocationAggregationOptions{
+				FilterFuncs: []AllocationMatchFunc{isNamespace("namespace2")},
+				ShareIdle:   ShareWeighted,
+				IdleByNode:  true,
+			},
+			numResults: 1,
+			totalCost:  46.31,
+			results: map[string]float64{
+				"namespace2": 46.31,
+			},
+			windowStart: startYesterday,
+			windowEnd:   endYesterday,
+			expMinutes:  1440.0,
+		},
+
 		// 7  Edge cases and errors
 
 		// 7a Empty AggregationProperties
@@ -1865,7 +2003,11 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	for name, testcase := range cases {
 		t.Run(name, func(t *testing.T) {
-			as = generateAllocationSet(testcase.start)
+			if testcase.aggOpts != nil && testcase.aggOpts.IdleByNode {
+				as = generateAllocationSetNodeIdle(testcase.start)
+			} else {
+				as = generateAllocationSetClusterIdle(testcase.start)
+			}
 			err = as.AggregateBy(testcase.aggBy, testcase.aggOpts)
 			assertAllocationSetTotals(t, as, name, err, testcase.numResults, testcase.totalCost)
 			assertAllocationTotals(t, as, name, testcase.results)
@@ -1885,11 +2027,8 @@ func TestAllocationSet_ComputeIdleAllocations(t *testing.T) {
 	end := time.Now().UTC().Truncate(day)
 	start := end.Add(-day)
 
-	// Generate AllocationSet and strip out any existing idle allocations
+	// Generate AllocationSet without idle allocations
 	as = generateAllocationSet(start)
-	for key := range as.idleKeys {
-		as.Delete(key)
-	}
 
 	assetSets := generateAssetSets(start, end)
 
@@ -1965,18 +2104,121 @@ func TestAllocationSet_ComputeIdleAllocations(t *testing.T) {
 	}
 }
 
-func TestAllocationSet_ReconcileAllocations(t *testing.T) {
+func TestAllocationSet_ComputeIdleAllocationsPerNode(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
+	// Generate AllocationSet without idle allocations
 	as = generateAllocationSet(start)
-	for key := range as.idleKeys {
-		as.Delete(key)
+
+	assetSets := generateAssetSets(start, end)
+
+	cases := map[string]struct {
+		allocationSet *AllocationSet
+		assetSet      *AssetSet
+		nodes         map[string]Allocation
+	}{
+		"1a": {
+			allocationSet: as,
+			assetSet:      assetSets[0],
+			nodes: map[string]Allocation{
+				"c1nodes": {
+					CPUCost: 44.0,
+					RAMCost: 24.0,
+					GPUCost: 4.0,
+				},
+				"node1": {
+					CPUCost: 18.0,
+					RAMCost: 13.0,
+					GPUCost: -2.0,
+				},
+				"node2": {
+					CPUCost: 18.0,
+					RAMCost: 13.0,
+					GPUCost: -2.0,
+				},
+				"node3": {
+					CPUCost: 8.0,
+					RAMCost: 8.0,
+					GPUCost: 8.0,
+				},
+			},
+		},
+		"1b": {
+			allocationSet: as,
+			assetSet:      assetSets[1],
+			nodes: map[string]Allocation{
+				"c1nodes": {
+					CPUCost: 44.0,
+					RAMCost: 24.0,
+					GPUCost: 4.0,
+				},
+				"node1": {
+					CPUCost: 18.0,
+					RAMCost: 13.0,
+					GPUCost: -2.0,
+				},
+				"node2": {
+					CPUCost: 18.0,
+					RAMCost: 13.0,
+					GPUCost: -2.0,
+				},
+				"node3": {
+					CPUCost: 8.0,
+					RAMCost: 8.0,
+					GPUCost: 8.0,
+				},
+			},
+		},
 	}
+
+	for name, testcase := range cases {
+		t.Run(name, func(t *testing.T) {
+			idles, err = as.ComputeIdleAllocationsByNode(testcase.assetSet)
+			if err != nil {
+				t.Fatalf("unexpected error: %s", err)
+			}
+
+			if len(idles) != len(testcase.nodes) {
+				t.Fatalf("idles: expected length %d; got length %d", len(testcase.nodes), len(idles))
+			}
+
+			for nodeName, node := range testcase.nodes {
+				if idle, ok := idles[nodeName]; !ok {
+					t.Fatalf("expected idle cost for %s", nodeName)
+				} else {
+					if !util.IsApproximately(idle.TotalCost(), node.TotalCost()) {
+						t.Fatalf("%s idle: expected total cost %f; got total cost %f", nodeName, node.TotalCost(), idle.TotalCost())
+					}
+				}
+				if !util.IsApproximately(idles[nodeName].CPUCost, node.CPUCost) {
+					t.Fatalf("expected idle CPU cost for %s to be %.2f; got %.2f", nodeName, node.CPUCost, idles[nodeName].CPUCost)
+				}
+				if !util.IsApproximately(idles[nodeName].RAMCost, node.RAMCost) {
+					t.Fatalf("expected idle RAM cost for %s to be %.2f; got %.2f", nodeName, node.RAMCost, idles[nodeName].RAMCost)
+				}
+				if !util.IsApproximately(idles[nodeName].GPUCost, node.GPUCost) {
+					t.Fatalf("expected idle GPU cost for %s to be %.2f; got %.2f", nodeName, node.GPUCost, idles[nodeName].GPUCost)
+				}
+			}
+		})
+	}
+}
+
+func TestAllocationSet_ReconcileAllocations(t *testing.T) {
+	var as *AllocationSet
+	var err error
+
+	end := time.Now().UTC().Truncate(day)
+	start := end.Add(-day)
+
+	// Generate AllocationSet without idle allocations
+	as = generateAllocationSet(start)
+
 	// add reconcilable pvs to pod-mno
 	for _, a := range as.allocations {
 		if a.Properties.Pod == "pod-mno" {

+ 6 - 6
pkg/kubecost/kubecost_codecs_test.go

@@ -25,9 +25,9 @@ func BenchmarkAllocationSetRange_BinaryEncoding(b *testing.B) {
 	var err error
 
 	asr0 = NewAllocationSetRange(
-		generateAllocationSet(startD0),
-		generateAllocationSet(startD1),
-		generateAllocationSet(startD2),
+		generateAllocationSetClusterIdle(startD0),
+		generateAllocationSetClusterIdle(startD1),
+		generateAllocationSetClusterIdle(startD2),
 	)
 
 	for it := 0; it < b.N; it++ {
@@ -90,9 +90,9 @@ func TestAllocationSetRange_BinaryEncoding(t *testing.T) {
 	var err error
 
 	asr0 = NewAllocationSetRange(
-		generateAllocationSet(startD0),
-		generateAllocationSet(startD1),
-		generateAllocationSet(startD2),
+		generateAllocationSetClusterIdle(startD0),
+		generateAllocationSetClusterIdle(startD1),
+		generateAllocationSetClusterIdle(startD2),
 	)
 
 	bs, err = asr0.MarshalBinary()