Forráskód Böngészése

Merge branch 'develop' into alex/opencost-1753-cluster-id

Alex Meijer 3 éve
szülő
commit
752d20a49b

+ 0 - 1
.github/workflows/pr.yaml

@@ -29,5 +29,4 @@ jobs:
         with:
           context: ${{ matrix.location }}/
           file: ${{ matrix.location }}/Dockerfile
-          platforms: linux/amd64,linux/arm64
           push: false

+ 37 - 4
pkg/cloud/gcpprovider.go

@@ -638,6 +638,7 @@ func (gcp *GCP) parsePage(r io.Reader, inputKeys map[string]Key, pvKeys map[stri
 				if err != nil {
 					return nil, "", err
 				}
+
 				usageType := strings.ToLower(product.Category.UsageType)
 				instanceType := strings.ToLower(product.Category.ResourceGroup)
 
@@ -741,6 +742,10 @@ func (gcp *GCP) parsePage(r io.Reader, inputKeys map[string]Key, pvKeys map[stri
 					}
 				}
 
+				if (instanceType == "ram" || instanceType == "cpu") && strings.Contains(strings.ToUpper(product.Description), "A2 INSTANCE") {
+					instanceType = "a2"
+				}
+
 				if (instanceType == "ram" || instanceType == "cpu") && strings.Contains(strings.ToUpper(product.Description), "COMPUTE OPTIMIZED") {
 					instanceType = "c2standard"
 				}
@@ -774,13 +779,17 @@ func (gcp *GCP) parsePage(r io.Reader, inputKeys map[string]Key, pvKeys map[stri
 				}
 
 				for _, region := range product.ServiceRegions {
-					if instanceType == "e2" { // this needs to be done to handle a partial cpu mapping
+					switch instanceType {
+					case "e2":
 						candidateKeys = append(candidateKeys, region+","+"e2micro"+","+usageType)
 						candidateKeys = append(candidateKeys, region+","+"e2small"+","+usageType)
 						candidateKeys = append(candidateKeys, region+","+"e2medium"+","+usageType)
 						candidateKeys = append(candidateKeys, region+","+"e2standard"+","+usageType)
 						candidateKeys = append(candidateKeys, region+","+"e2custom"+","+usageType)
-					} else {
+					case "a2":
+						candidateKeys = append(candidateKeys, region+","+"a2highgpu"+","+usageType)
+						candidateKeys = append(candidateKeys, region+","+"a2megagpu"+","+usageType)
+					default:
 						candidateKey := region + "," + instanceType + "," + usageType
 						candidateKeys = append(candidateKeys, candidateKey)
 					}
@@ -795,12 +804,28 @@ func (gcp *GCP) parsePage(r io.Reader, inputKeys map[string]Key, pvKeys map[stri
 					if gpuType != "" {
 						lastRateIndex := len(product.PricingInfo[0].PricingExpression.TieredRates) - 1
 						var nanos float64
+						var unitsBaseCurrency int
 						if lastRateIndex > -1 && len(product.PricingInfo) > 0 {
 							nanos = product.PricingInfo[0].PricingExpression.TieredRates[lastRateIndex].UnitPrice.Nanos
+							unitsBaseCurrency, err = strconv.Atoi(product.PricingInfo[0].PricingExpression.TieredRates[lastRateIndex].UnitPrice.Units)
+							if err != nil {
+								return nil, "", fmt.Errorf("error parsing base unit price for gpu: %w", err)
+							}
 						} else {
 							continue
 						}
-						hourlyPrice := nanos * math.Pow10(-9)
+
+						// as per https://cloud.google.com/billing/v1/how-tos/catalog-api
+						// the hourly price is the whole currency price + the fractional currency price
+						hourlyPrice := (nanos * math.Pow10(-9)) + float64(unitsBaseCurrency)
+
+						// GPUs with an hourly price of 0 are reserved versions of GPUs
+						// (E.g., SKU "2013-37B4-22EA")
+						// and are excluded from cost computations
+						if hourlyPrice == 0 {
+							log.Infof("Excluding reserved GPU SKU #%s", product.SKUID)
+							continue
+						}
 
 						for k, key := range inputKeys {
 							if key.GPUType() == gpuType+","+usageType {
@@ -830,12 +855,20 @@ func (gcp *GCP) parsePage(r io.Reader, inputKeys map[string]Key, pvKeys map[stri
 						if ok || ok2 {
 							lastRateIndex := len(product.PricingInfo[0].PricingExpression.TieredRates) - 1
 							var nanos float64
+							var unitsBaseCurrency int
 							if lastRateIndex > -1 && len(product.PricingInfo) > 0 {
 								nanos = product.PricingInfo[0].PricingExpression.TieredRates[lastRateIndex].UnitPrice.Nanos
+								unitsBaseCurrency, err = strconv.Atoi(product.PricingInfo[0].PricingExpression.TieredRates[lastRateIndex].UnitPrice.Units)
+								if err != nil {
+									return nil, "", fmt.Errorf("error parsing base unit price for instance: %w", err)
+								}
 							} else {
 								continue
 							}
-							hourlyPrice := nanos * math.Pow10(-9)
+
+							// as per https://cloud.google.com/billing/v1/how-tos/catalog-api
+							// the hourly price is the whole currency price + the fractional currency price
+							hourlyPrice := (nanos * math.Pow10(-9)) + float64(unitsBaseCurrency)
 
 							if hourlyPrice == 0 {
 								continue

+ 253 - 0
pkg/cloud/gcpprovider_test.go

@@ -1,6 +1,9 @@
 package cloud
 
 import (
+	"bytes"
+	"io/ioutil"
+	"reflect"
 	"testing"
 )
 
@@ -112,3 +115,253 @@ func TestGetUsageType(t *testing.T) {
 		}
 	}
 }
+
+// tests basic parsing of GCP pricing API responses
+// Load a reader object on a portion of a GCP api response
+// Confirm that the resting *GCP object contains the correctly parsed pricing info
+func TestParsePage(t *testing.T) {
+
+	gcpSkuString := `
+	{
+		"skus": [
+			{
+				"name": "services/6F81-5844-456A/skus/039F-D0DA-4055",
+				"skuId": "039F-D0DA-4055",
+				"description": "Nvidia Tesla A100 GPU running in Americas",
+				"category": {
+				  "serviceDisplayName": "Compute Engine",
+				  "resourceFamily": "Compute",
+				  "resourceGroup": "GPU",
+				  "usageType": "OnDemand"
+				},
+				"serviceRegions": [
+				  "us-central1",
+				  "us-east1",
+				  "us-west1"
+				],
+				"pricingInfo": [
+				  {
+					"summary": "",
+					"pricingExpression": {
+					  "usageUnit": "h",
+					  "displayQuantity": 1,
+					  "tieredRates": [
+						{
+						  "startUsageAmount": 0,
+						  "unitPrice": {
+							"currencyCode": "USD",
+							"units": "2",
+							"nanos": 933908000
+						  }
+						}
+					  ],
+					  "usageUnitDescription": "hour",
+					  "baseUnit": "s",
+					  "baseUnitDescription": "second",
+					  "baseUnitConversionFactor": 3600
+					},
+					"currencyConversionRate": 1,
+					"effectiveTime": "2023-03-24T10:52:50.681Z"
+				  }
+				],
+				"serviceProviderName": "Google",
+				"geoTaxonomy": {
+				  "type": "MULTI_REGIONAL",
+				  "regions": [
+					"us-central1",
+					"us-east1",
+					"us-west1"
+				  ]
+				}
+			},
+			{
+				"name": "services/6F81-5844-456A/skus/2390-DCAF-DA38",
+				"skuId": "2390-DCAF-DA38",
+				"description": "A2 Instance Ram running in Americas",
+				"category": {
+				  "serviceDisplayName": "Compute Engine",
+				  "resourceFamily": "Compute",
+				  "resourceGroup": "RAM",
+				  "usageType": "OnDemand"
+				},
+				"serviceRegions": [
+				  "us-central1",
+				  "us-east1",
+				  "us-west1"
+				],
+				"pricingInfo": [
+				  {
+					"summary": "",
+					"pricingExpression": {
+					  "usageUnit": "GiBy.h",
+					  "displayQuantity": 1,
+					  "tieredRates": [
+						{
+						  "startUsageAmount": 0,
+						  "unitPrice": {
+							"currencyCode": "USD",
+							"units": "0",
+							"nanos": 4237000
+						  }
+						}
+					  ],
+					  "usageUnitDescription": "gibibyte hour",
+					  "baseUnit": "By.s",
+					  "baseUnitDescription": "byte second",
+					  "baseUnitConversionFactor": 3865470566400
+					},
+					"currencyConversionRate": 1,
+					"effectiveTime": "2023-03-24T10:52:50.681Z"
+				  }
+				],
+				"serviceProviderName": "Google",
+				"geoTaxonomy": {
+				  "type": "MULTI_REGIONAL",
+				  "regions": [
+					"us-central1",
+					"us-east1",
+					"us-west1"
+				  ]
+				}
+			},
+			{
+				"name": "services/6F81-5844-456A/skus/2922-40C5-B19F",
+				"skuId": "2922-40C5-B19F",
+				"description": "A2 Instance Core running in Americas",
+				"category": {
+				  "serviceDisplayName": "Compute Engine",
+				  "resourceFamily": "Compute",
+				  "resourceGroup": "CPU",
+				  "usageType": "OnDemand"
+				},
+				"serviceRegions": [
+				  "us-central1",
+				  "us-east1",
+				  "us-west1"
+				],
+				"pricingInfo": [
+				  {
+					"summary": "",
+					"pricingExpression": {
+					  "usageUnit": "h",
+					  "displayQuantity": 1,
+					  "tieredRates": [
+						{
+						  "startUsageAmount": 0,
+						  "unitPrice": {
+							"currencyCode": "USD",
+							"units": "0",
+							"nanos": 31611000
+						  }
+						}
+					  ],
+					  "usageUnitDescription": "hour",
+					  "baseUnit": "s",
+					  "baseUnitDescription": "second",
+					  "baseUnitConversionFactor": 3600
+					},
+					"currencyConversionRate": 1,
+					"effectiveTime": "2023-03-24T10:52:50.681Z"
+				  }
+				],
+				"serviceProviderName": "Google",
+				"geoTaxonomy": {
+				  "type": "MULTI_REGIONAL",
+				  "regions": [
+					"us-central1",
+					"us-east1",
+					"us-west1"
+				  ]
+				}
+			}
+		],
+			"nextPageToken": "APKCS1HVa0YpwgyTFbqbJ1eGwzKZmsPwLqzMZPTSNia5ck1Hc54Tx_Kz3oBxwSnRIdGVxXoSPdf-XlDpyNBf4QuxKcIEgtrQ1LDLWAgZowI0ns7HjrGta2s="
+		}
+	`
+	reader := ioutil.NopCloser(bytes.NewBufferString(gcpSkuString))
+
+	testGcp := &GCP{}
+
+	inputKeys := map[string]Key{
+		"us-central1,a2highgpu,ondemand,gpu": &gcpKey{
+			Labels: map[string]string{
+				"node.kubernetes.io/instance-type": "a2-highgpu-1g",
+				"cloud.google.com/gke-gpu":         "true",
+				"cloud.google.com/gke-accelerator": "nvidia-tesla-a100",
+				"topology.kubernetes.io/region":    "us-central1",
+			},
+		},
+	}
+
+	pvKeys := map[string]PVKey{}
+
+	actualPrices, token, err := testGcp.parsePage(reader, inputKeys, pvKeys)
+	if err != nil {
+		t.Fatalf("got error parsing page: %v", err)
+	}
+
+	const expectedToken = "APKCS1HVa0YpwgyTFbqbJ1eGwzKZmsPwLqzMZPTSNia5ck1Hc54Tx_Kz3oBxwSnRIdGVxXoSPdf-XlDpyNBf4QuxKcIEgtrQ1LDLWAgZowI0ns7HjrGta2s="
+	if token != expectedToken {
+		t.Fatalf("error parsing GCP next page token, parsed %s but expected %s", token, expectedToken)
+	}
+
+	expectedActualPrices := map[string]*GCPPricing{
+		"us-central1,a2highgpu,ondemand,gpu": &GCPPricing{
+			Name:        "services/6F81-5844-456A/skus/039F-D0DA-4055",
+			SKUID:       "039F-D0DA-4055",
+			Description: "Nvidia Tesla A100 GPU running in Americas",
+			Category: &GCPResourceInfo{
+				ServiceDisplayName: "Compute Engine",
+				ResourceFamily:     "Compute",
+				ResourceGroup:      "GPU",
+				UsageType:          "OnDemand",
+			},
+			ServiceRegions: []string{"us-central1", "us-east1", "us-west1"},
+			PricingInfo: []*PricingInfo{
+				&PricingInfo{
+					Summary: "",
+					PricingExpression: &PricingExpression{
+						UsageUnit:                "h",
+						UsageUnitDescription:     "hour",
+						BaseUnit:                 "s",
+						BaseUnitConversionFactor: 0,
+						DisplayQuantity:          1,
+						TieredRates: []*TieredRates{
+							&TieredRates{
+								StartUsageAmount: 0,
+								UnitPrice: &UnitPriceInfo{
+									CurrencyCode: "USD",
+									Units:        "2",
+									Nanos:        933908000,
+								},
+							},
+						},
+					},
+					CurrencyConversionRate: 1,
+					EffectiveTime:          "2023-03-24T10:52:50.681Z",
+				},
+			},
+			ServiceProviderName: "Google",
+			Node: &Node{
+				VCPUCost:         "0.031611",
+				RAMCost:          "0.004237",
+				UsesBaseCPUPrice: false,
+				GPU:              "1",
+				GPUName:          "nvidia-tesla-a100",
+				GPUCost:          "2.933908",
+			},
+		},
+		"us-central1,a2highgpu,ondemand": &GCPPricing{
+			Node: &Node{
+				VCPUCost:         "0.031611",
+				RAMCost:          "0.004237",
+				UsesBaseCPUPrice: false,
+				UsageType:        "ondemand",
+			},
+		},
+	}
+
+	if !reflect.DeepEqual(actualPrices, expectedActualPrices) {
+		t.Fatalf("error parsing GCP prices. parsed %v but expected %v", actualPrices, expectedActualPrices)
+	}
+}

+ 1 - 0
pkg/cmd/costmodel/costmodel.go

@@ -30,6 +30,7 @@ func Execute(opts *CostModelOpts) error {
 
 	rootMux := http.NewServeMux()
 	a.Router.GET("/healthz", Healthz)
+	a.Router.GET("/allocation", a.ComputeAllocationHandler)
 	a.Router.GET("/allocation/summary", a.ComputeAllocationHandlerSummary)
 	rootMux.Handle("/", a.Router)
 	rootMux.Handle("/metrics", promhttp.Handler())

+ 18 - 47
pkg/costmodel/aggregation.go

@@ -1076,7 +1076,7 @@ func (a *Accesses) ComputeAggregateCostModel(promClient prometheusClient.Client,
 		if durMins%60 != 0 || durMins < 3*60 { // not divisible by 1h or less than 3h
 			resolution = time.Minute
 		}
-	} else {                    // greater than 1d
+	} else { // greater than 1d
 		if durMins >= 7*24*60 { // greater than (or equal to) 7 days
 			resolution = 24.0 * time.Hour
 		} else if durMins >= 2*24*60 { // greater than (or equal to) 2 days
@@ -2221,15 +2221,15 @@ func (a *Accesses) ComputeAllocationHandler(w http.ResponseWriter, r *http.Reque
 		http.Error(w, fmt.Sprintf("Invalid 'window' parameter: %s", err), http.StatusBadRequest)
 	}
 
+	// Resolution is an optional parameter, defaulting to the configured ETL
+	// resolution.
+	resolution := qp.GetDuration("resolution", env.GetETLResolution())
+
 	// Step is an optional parameter that defines the duration per-set, i.e.
 	// the window for an AllocationSet, of the AllocationSetRange to be
 	// computed. Defaults to the window size, making one set.
 	step := qp.GetDuration("step", window.Duration())
 
-	// Resolution is an optional parameter, defaulting to the configured ETL
-	// resolution.
-	resolution := qp.GetDuration("resolution", env.GetETLResolution())
-
 	// Aggregation is an optional comma-separated list of fields by which to
 	// aggregate results. Some fields allow a sub-field, which is distinguished
 	// with a colon; e.g. "label:app".
@@ -2239,54 +2239,25 @@ func (a *Accesses) ComputeAllocationHandler(w http.ResponseWriter, r *http.Reque
 		http.Error(w, fmt.Sprintf("Invalid 'aggregate' parameter: %s", err), http.StatusBadRequest)
 	}
 
-	// Accumulate is an optional parameter, defaulting to false, which if true
-	// sums each Set in the Range, producing one Set.
-	accumulate := qp.GetBool("accumulate", false)
-
-	// Accumulate is an optional parameter that accumulates an AllocationSetRange
-	// by the resolution of the given time duration.
-	// Defaults to 0. If a value is not passed then the parameter is not used.
-	accumulateBy := kubecost.AccumulateOption(qp.Get("accumulateBy", ""))
-
-	// if accumulateBy is not explicitly set, and accumulate is true, ensure result is accumulated
-	if accumulateBy == kubecost.AccumulateOptionNone && accumulate {
-		accumulateBy = kubecost.AccumulateOptionAll
-	}
-
-	// Query for AllocationSets in increments of the given step duration,
-	// appending each to the AllocationSetRange.
-	asr := kubecost.NewAllocationSetRange()
-	stepStart := *window.Start()
-	for window.End().After(stepStart) {
-		stepEnd := stepStart.Add(step)
-		stepWindow := kubecost.NewWindow(&stepStart, &stepEnd)
+	// IncludeIdle, if true, uses Asset data to incorporate Idle Allocation
+	includeIdle := qp.GetBool("includeIdle", false)
 
-		as, err := a.Model.ComputeAllocation(*stepWindow.Start(), *stepWindow.End(), resolution)
-		if err != nil {
-			WriteError(w, InternalServerError(err.Error()))
-			return
-		}
-		asr.Append(as)
+	// IdleByNode, if true, computes idle allocations at the node level.
+	// Otherwise it is computed at the cluster level. (Not relevant if idle
+	// is not included.)
+	idleByNode := qp.GetBool("idleByNode", false)
 
-		stepStart = stepEnd
-	}
+	// IncludeProportionalAssetResourceCosts, if true,
+	includeProportionalAssetResourceCosts := qp.GetBool("includeProportionalAssetResourceCosts", false)
 
-	// Aggregate, if requested
-	if len(aggregateBy) > 0 {
-		err = asr.AggregateBy(aggregateBy, nil)
-		if err != nil {
+	asr, err := a.Model.QueryAllocation(window, resolution, step, aggregateBy, includeIdle, idleByNode, includeProportionalAssetResourceCosts)
+	if err != nil {
+		if strings.Contains(strings.ToLower(err.Error()), "bad request") {
+			WriteError(w, BadRequest(err.Error()))
+		} else {
 			WriteError(w, InternalServerError(err.Error()))
-			return
 		}
-	}
 
-	// Accumulate, if requested
-	if accumulateBy != kubecost.AccumulateOptionNone {
-		asr, err = asr.Accumulate(accumulateBy)
-	}
-
-	if err != nil {
-		WriteError(w, InternalServerError(err.Error()))
 		return
 	}
 

+ 45 - 3
pkg/costmodel/allocation.go

@@ -22,7 +22,6 @@ const (
 	queryFmtCPUCoresAllocated        = `avg(avg_over_time(container_cpu_allocation{container!="", container!="POD", node!=""}[%s])) by (container, pod, namespace, node, %s)`
 	queryFmtCPURequests              = `avg(avg_over_time(kube_pod_container_resource_requests{resource="cpu", unit="core", container!="", container!="POD", node!=""}[%s])) by (container, pod, namespace, node, %s)`
 	queryFmtCPUUsageAvg              = `avg(rate(container_cpu_usage_seconds_total{container!="", container_name!="POD", container!="POD"}[%s])) by (container_name, container, pod_name, pod, namespace, instance, %s)`
-	queryFmtCPUUsageMax              = `max(rate(container_cpu_usage_seconds_total{container!="", container_name!="POD", container!="POD"}[%s])) by (container_name, container, pod_name, pod, namespace, instance, %s)`
 	queryFmtGPUsRequested            = `avg(avg_over_time(kube_pod_container_resource_requests{resource="nvidia_com_gpu", container!="",container!="POD", node!=""}[%s])) by (container, pod, namespace, node, %s)`
 	queryFmtGPUsAllocated            = `avg(avg_over_time(container_gpu_allocation{container!="", container!="POD", node!=""}[%s])) by (container, pod, namespace, node, %s)`
 	queryFmtNodeCostPerCPUHr         = `avg(avg_over_time(node_cpu_hourly_cost[%s])) by (node, %s, instance_type, provider_id)`
@@ -57,6 +56,32 @@ const (
 	queryFmtReplicaSetsWithoutOwners = `avg(avg_over_time(kube_replicaset_owner{owner_kind="<none>", owner_name="<none>"}[%s])) by (replicaset, namespace, %s)`
 	queryFmtLBCostPerHr              = `avg(avg_over_time(kubecost_load_balancer_cost[%s])) by (namespace, service_name, %s)`
 	queryFmtLBActiveMins             = `count(kubecost_load_balancer_cost) by (namespace, service_name, %s)[%s:%s]`
+
+	// Because we use container_cpu_usage_seconds_total to calculate CPU usage
+	// at any given "instant" of time, we need to use an irate or rate. To then
+	// calculate a max (or any aggregation) we have to perform an aggregation
+	// query on top of an instant-by-instant maximum. Prometheus supports this
+	// type of query with a "subquery" [1], however it is reportedly expensive
+	// to make such a query. By default, Kubecost's Prometheus config includes
+	// a recording rule that keeps track of the instant-by-instant irate for CPU
+	// usage. The metric in this query is created by that recording rule.
+	//
+	// [1] https://prometheus.io/blog/2019/01/28/subquery-support/
+	//
+	// If changing the name of the recording rule, make sure to update the
+	// corresponding diagnostic query to avoid confusion.
+	queryFmtCPUUsageMaxRecordingRule = `max(max_over_time(kubecost_container_cpu_usage_irate{}[%s])) by (container_name, container, pod_name, pod, namespace, instance, %s)`
+	// This is the subquery equivalent of the above recording rule query. It is
+	// more expensive, but does not require the recording rule. It should be
+	// used as a fallback query if the recording rule data does not exist.
+	//
+	// The parameter after the colon [:<thisone>] in the subquery affects the
+	// resolution of the subquery.
+	// The parameter after the metric ...{}[<thisone>] should be set to 2x
+	// the resolution, to make sure the irate always has two points to query
+	// in case the Prom scrape duration has been reduced to be equal to the
+	// ETL resolution.
+	queryFmtCPUUsageMaxSubquery = `max(max_over_time(irate(container_cpu_usage_seconds_total{container_name!="POD", container_name!=""}[%s])[%s:%s])) by (container_name, container, pod_name, pod, namespace, instance, %s)`
 )
 
 // Constants for Network Cost Subtype
@@ -338,8 +363,26 @@ func (cm *CostModel) computeAllocation(start, end time.Time, resolution time.Dur
 	queryCPUUsageAvg := fmt.Sprintf(queryFmtCPUUsageAvg, durStr, env.GetPromClusterLabel())
 	resChCPUUsageAvg := ctx.QueryAtTime(queryCPUUsageAvg, end)
 
-	queryCPUUsageMax := fmt.Sprintf(queryFmtCPUUsageMax, durStr, env.GetPromClusterLabel())
+	queryCPUUsageMax := fmt.Sprintf(queryFmtCPUUsageMaxRecordingRule, durStr, env.GetPromClusterLabel())
 	resChCPUUsageMax := ctx.QueryAtTime(queryCPUUsageMax, end)
+	resCPUUsageMax, _ := resChCPUUsageMax.Await()
+	// If the recording rule has no data, try to fall back to the subquery.
+	if len(resCPUUsageMax) == 0 {
+		// The parameter after the metric ...{}[<thisone>] should be set to 2x
+		// the resolution, to make sure the irate always has two points to query
+		// in case the Prom scrape duration has been reduced to be equal to the
+		// resolution.
+		doubleResStr := timeutil.DurationString(2 * resolution)
+		queryCPUUsageMax = fmt.Sprintf(queryFmtCPUUsageMaxSubquery, doubleResStr, durStr, resStr, env.GetPromClusterLabel())
+		resChCPUUsageMax = ctx.QueryAtTime(queryCPUUsageMax, end)
+		resCPUUsageMax, _ = resChCPUUsageMax.Await()
+
+		// This avoids logspam if there is no data for either metric (e.g. if
+		// the Prometheus didn't exist in the queried window of time).
+		if len(resCPUUsageMax) > 0 {
+			log.Debugf("CPU usage recording rule query returned an empty result when queried at %s over %s. Fell back to subquery. Consider setting up Kubecost CPU usage recording role to reduce query load on Prometheus; subqueries are expensive.", end.String(), durStr)
+		}
+	}
 
 	queryGPUsRequested := fmt.Sprintf(queryFmtGPUsRequested, durStr, env.GetPromClusterLabel())
 	resChGPUsRequested := ctx.QueryAtTime(queryGPUsRequested, end)
@@ -449,7 +492,6 @@ func (cm *CostModel) computeAllocation(start, end time.Time, resolution time.Dur
 	resCPUCoresAllocated, _ := resChCPUCoresAllocated.Await()
 	resCPURequests, _ := resChCPURequests.Await()
 	resCPUUsageAvg, _ := resChCPUUsageAvg.Await()
-	resCPUUsageMax, _ := resChCPUUsageMax.Await()
 	resRAMBytesAllocated, _ := resChRAMBytesAllocated.Await()
 	resRAMRequests, _ := resChRAMRequests.Await()
 	resRAMUsageAvg, _ := resChRAMUsageAvg.Await()

+ 170 - 0
pkg/costmodel/assets.go

@@ -0,0 +1,170 @@
+package costmodel
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/opencost/opencost/pkg/kubecost"
+	"github.com/opencost/opencost/pkg/log"
+)
+
+func (cm *CostModel) ComputeAssets(start, end time.Time) (*kubecost.AssetSet, error) {
+	assetSet := kubecost.NewAssetSet(start, end)
+
+	nodeMap, err := cm.ClusterNodes(start, end)
+	if err != nil {
+		return nil, fmt.Errorf("error computing node assets for %s: %w", kubecost.NewClosedWindow(start, end), err)
+
+	}
+
+	lbMap, err := cm.ClusterLoadBalancers(start, end)
+	if err != nil {
+		return nil, fmt.Errorf("error computing load balancer assets for %s: %w", kubecost.NewClosedWindow(start, end), err)
+	}
+
+	diskMap, err := cm.ClusterDisks(start, end)
+	if err != nil {
+		return nil, fmt.Errorf("error computing disk assets for %s: %w", kubecost.NewClosedWindow(start, end), err)
+	}
+
+	for _, d := range diskMap {
+		s := d.Start
+		if s.Before(start) || s.After(end) {
+			log.Debugf("CostModel.ComputeAssets: disk '%s' start outside window: %s not in [%s, %s]", d.Name, s.Format("2006-01-02T15:04:05"), start.Format("2006-01-02T15:04:05"), end.Format("2006-01-02T15:04:05"))
+			s = start
+		}
+
+		e := d.End
+		if e.Before(start) || e.After(end) {
+			log.Debugf("CostModel.ComputeAssets: disk '%s' end outside window: %s not in [%s, %s]", d.Name, e.Format("2006-01-02T15:04:05"), start.Format("2006-01-02T15:04:05"), end.Format("2006-01-02T15:04:05"))
+			e = end
+		}
+
+		hours := e.Sub(s).Hours()
+
+		disk := kubecost.NewDisk(d.Name, d.Cluster, d.ProviderID, s, e, kubecost.NewWindow(&start, &end))
+		cm.propertiesFromCluster(disk.Properties)
+		disk.Cost = d.Cost
+		disk.ByteHours = d.Bytes * hours
+		if d.BytesUsedAvgPtr != nil {
+			byteHours := *d.BytesUsedAvgPtr * hours
+			disk.ByteHoursUsed = &byteHours
+		}
+		if d.BytesUsedMaxPtr != nil {
+			usageMax := *d.BytesUsedMaxPtr
+			disk.ByteUsageMax = &usageMax
+		}
+
+		if d.Local {
+			disk.Local = 1.0
+		}
+		disk.Breakdown = &kubecost.Breakdown{
+			Idle:   d.Breakdown.Idle,
+			System: d.Breakdown.System,
+			User:   d.Breakdown.User,
+			Other:  d.Breakdown.Other,
+		}
+		disk.StorageClass = d.StorageClass
+		disk.VolumeName = d.VolumeName
+		disk.ClaimName = d.ClaimName
+		disk.ClaimNamespace = d.ClaimNamespace
+		assetSet.Insert(disk, nil)
+	}
+
+	for _, lb := range lbMap {
+		s := lb.Start
+		if s.Before(start) || s.After(end) {
+			log.Debugf("CostModel.ComputeAssets: load balancer '%s' start outside window: %s not in [%s, %s]", lb.Name, s.Format("2006-01-02T15:04:05"), start.Format("2006-01-02T15:04:05"), end.Format("2006-01-02T15:04:05"))
+			s = start
+		}
+
+		e := lb.End
+		if e.Before(start) || e.After(end) {
+			log.Debugf("CostModel.ComputeAssets: load balancer '%s' end outside window: %s not in [%s, %s]", lb.Name, e.Format("2006-01-02T15:04:05"), start.Format("2006-01-02T15:04:05"), end.Format("2006-01-02T15:04:05"))
+			e = end
+		}
+
+		loadBalancer := kubecost.NewLoadBalancer(lb.Name, lb.Cluster, lb.ProviderID, s, e, kubecost.NewWindow(&start, &end))
+		cm.propertiesFromCluster(loadBalancer.Properties)
+		loadBalancer.Cost = lb.Cost
+		assetSet.Insert(loadBalancer, nil)
+	}
+
+	for _, n := range nodeMap {
+		s := n.Start
+		if s.Before(start) || s.After(end) {
+			log.Debugf("CostModel.ComputeAssets: node '%s' start outside window: %s not in [%s, %s]", n.Name, s.Format("2006-01-02T15:04:05"), start.Format("2006-01-02T15:04:05"), end.Format("2006-01-02T15:04:05"))
+			s = start
+		}
+
+		e := n.End
+		if e.Before(start) || e.After(end) {
+			log.Debugf("CostModel.ComputeAssets: node '%s' end outside window: %s not in [%s, %s]", n.Name, e.Format("2006-01-02T15:04:05"), start.Format("2006-01-02T15:04:05"), end.Format("2006-01-02T15:04:05"))
+			e = end
+		}
+
+		hours := e.Sub(s).Hours()
+
+		node := kubecost.NewNode(n.Name, n.Cluster, n.ProviderID, s, e, kubecost.NewWindow(&start, &end))
+		cm.propertiesFromCluster(node.Properties)
+		node.NodeType = n.NodeType
+		node.CPUCoreHours = n.CPUCores * hours
+		node.RAMByteHours = n.RAMBytes * hours
+		node.GPUHours = n.GPUCount * hours
+		node.CPUBreakdown = &kubecost.Breakdown{
+			Idle:   n.CPUBreakdown.Idle,
+			System: n.CPUBreakdown.System,
+			User:   n.CPUBreakdown.User,
+			Other:  n.CPUBreakdown.Other,
+		}
+		node.RAMBreakdown = &kubecost.Breakdown{
+			Idle:   n.RAMBreakdown.Idle,
+			System: n.RAMBreakdown.System,
+			User:   n.RAMBreakdown.User,
+			Other:  n.RAMBreakdown.Other,
+		}
+		node.CPUCost = n.CPUCost
+		node.GPUCost = n.GPUCost
+		node.GPUCount = n.GPUCount
+		node.RAMCost = n.RAMCost
+		node.Discount = n.Discount
+		if n.Preemptible {
+			node.Preemptible = 1.0
+		}
+		node.SetLabels(kubecost.AssetLabels(n.Labels))
+		assetSet.Insert(node, nil)
+	}
+
+	return assetSet, nil
+}
+
+func (cm *CostModel) ClusterDisks(start, end time.Time) (map[DiskIdentifier]*Disk, error) {
+	return ClusterDisks(cm.PrometheusClient, cm.Provider, start, end)
+}
+
+func (cm *CostModel) ClusterLoadBalancers(start, end time.Time) (map[LoadBalancerIdentifier]*LoadBalancer, error) {
+	return ClusterLoadBalancers(cm.PrometheusClient, start, end)
+}
+
+func (cm *CostModel) ClusterNodes(start, end time.Time) (map[NodeIdentifier]*Node, error) {
+	return ClusterNodes(cm.Provider, cm.PrometheusClient, start, end)
+}
+
+// propertiesFromCluster populates static cluster properties to individual asset properties
+func (cm *CostModel) propertiesFromCluster(props *kubecost.AssetProperties) {
+	// If properties does not have cluster value, do nothing
+	if props.Cluster == "" {
+		return
+	}
+
+	clusterMap := cm.ClusterMap.AsMap()
+	ci, ok := clusterMap[props.Cluster]
+	if !ok {
+		log.Debugf("CostMode.propertiesFromCluster: cluster '%s' was not found in ClusterMap", props.Cluster)
+		return
+	}
+
+	props.Project = ci.Project
+	props.Account = ci.Account
+	props.Provider = ci.Provider
+}

+ 126 - 1
pkg/costmodel/costmodel.go

@@ -1,6 +1,7 @@
 package costmodel
 
 import (
+	"errors"
 	"fmt"
 	"math"
 	"regexp"
@@ -1216,7 +1217,7 @@ func (cm *CostModel) GetNodeCost(cp costAnalyzerCloud.Provider) (map[string]*cos
 				}
 			} else { // add case to use default pricing model when API data fails.
 				log.Debugf("No node price or CPUprice found, falling back to default")
-				nodePrice = defaultCPU*cpu + defaultRAM*ram
+				nodePrice = defaultCPU*cpu + defaultRAM*ramGB
 			}
 			if math.IsNaN(nodePrice) {
 				log.Warnf("nodePrice parsed as NaN. Setting to 0.")
@@ -2293,3 +2294,127 @@ func measureTimeAsync(start time.Time, threshold time.Duration, name string, ch
 		ch <- fmt.Sprintf("%s took %s", name, time.Since(start))
 	}
 }
+
+func (cm *CostModel) QueryAllocation(window kubecost.Window, resolution, step time.Duration, aggregate []string, includeIdle, idleByNode, includeProportionalAssetResourceCosts bool) (*kubecost.AllocationSetRange, error) {
+	// Validate window is legal
+	if window.IsOpen() || window.IsNegative() {
+		return nil, fmt.Errorf("illegal window: %s", window)
+	}
+
+	// Idle is required for proportional asset costs
+	if includeProportionalAssetResourceCosts {
+		if !includeIdle {
+			return nil, errors.New("bad request - includeIdle must be set true if includeProportionalAssetResourceCosts is true")
+		}
+	}
+
+	// Begin with empty response
+	asr := kubecost.NewAllocationSetRange()
+
+	// Query for AllocationSets in increments of the given step duration,
+	// appending each to the response.
+	stepStart := *window.Start()
+	stepEnd := stepStart.Add(step)
+	for window.End().After(stepStart) {
+		allocSet, err := cm.ComputeAllocation(stepStart, stepEnd, resolution)
+		if err != nil {
+			return nil, fmt.Errorf("error computing allocations for %s: %w", kubecost.NewClosedWindow(stepStart, stepEnd), err)
+		}
+
+		if includeIdle {
+			assetSet, err := cm.ComputeAssets(stepStart, stepEnd)
+			if err != nil {
+				return nil, fmt.Errorf("error computing assets for %s: %w", kubecost.NewClosedWindow(stepStart, stepEnd), err)
+			}
+
+			idleSet, err := computeIdleAllocations(allocSet, assetSet, true)
+			if err != nil {
+				return nil, fmt.Errorf("error computing idle allocations for %s: %w", kubecost.NewClosedWindow(stepStart, stepEnd), err)
+			}
+
+			for _, idleAlloc := range idleSet.Allocations {
+				allocSet.Insert(idleAlloc)
+			}
+		}
+
+		asr.Append(allocSet)
+
+		stepStart = stepEnd
+		stepEnd = stepStart.Add(step)
+	}
+
+	// Set aggregation options and aggregate
+	opts := &kubecost.AllocationAggregationOptions{
+		IncludeProportionalAssetResourceCosts: includeProportionalAssetResourceCosts,
+		IdleByNode:                            idleByNode,
+	}
+
+	// Aggregate
+	err := asr.AggregateBy(aggregate, opts)
+	if err != nil {
+		return nil, fmt.Errorf("error aggregating for %s: %w", window, err)
+	}
+
+	return asr, nil
+}
+
+func computeIdleAllocations(allocSet *kubecost.AllocationSet, assetSet *kubecost.AssetSet, idleByNode bool) (*kubecost.AllocationSet, error) {
+	if !allocSet.Window.Equal(assetSet.Window) {
+		return nil, fmt.Errorf("cannot compute idle allocations for mismatched sets: %s does not equal %s", allocSet.Window, assetSet.Window)
+	}
+
+	var allocTotals map[string]*kubecost.AllocationTotals
+	var assetTotals map[string]*kubecost.AssetTotals
+
+	if idleByNode {
+		allocTotals = kubecost.ComputeAllocationTotals(allocSet, kubecost.AllocationNodeProp)
+		assetTotals = kubecost.ComputeAssetTotals(assetSet, kubecost.AssetNodeProp)
+	} else {
+		allocTotals = kubecost.ComputeAllocationTotals(allocSet, kubecost.AllocationClusterProp)
+		assetTotals = kubecost.ComputeAssetTotals(assetSet, kubecost.AssetClusterProp)
+	}
+
+	start, end := *allocSet.Window.Start(), *allocSet.Window.End()
+	idleSet := kubecost.NewAllocationSet(start, end)
+
+	for key, assetTotal := range assetTotals {
+		allocTotal, ok := allocTotals[key]
+		if !ok {
+			log.Warnf("ETL: did not find allocations for asset key: %s", key)
+
+			// Use a zero-value set of totals. This indicates either (1) an
+			// error computing totals, or (2) that no allocations ran on the
+			// given node for the given window.
+			allocTotal = &kubecost.AllocationTotals{
+				Cluster: assetTotal.Cluster,
+				Node:    assetTotal.Node,
+				Start:   assetTotal.Start,
+				End:     assetTotal.End,
+			}
+		}
+
+		// Insert one idle allocation for each key (whether by node or
+		// by cluster), defined as the difference between the total
+		// asset cost and the allocated cost per-resource.
+		name := fmt.Sprintf("%s/%s", key, kubecost.IdleSuffix)
+		err := idleSet.Insert(&kubecost.Allocation{
+			Name:   name,
+			Window: idleSet.Window.Clone(),
+			Properties: &kubecost.AllocationProperties{
+				Cluster:    assetTotal.Cluster,
+				Node:       assetTotal.Node,
+				ProviderID: assetTotal.Node,
+			},
+			Start:   assetTotal.Start,
+			End:     assetTotal.End,
+			CPUCost: assetTotal.TotalCPUCost() - allocTotal.TotalCPUCost(),
+			GPUCost: assetTotal.TotalGPUCost() - allocTotal.TotalGPUCost(),
+			RAMCost: assetTotal.TotalRAMCost() - allocTotal.TotalRAMCost(),
+		})
+		if err != nil {
+			return nil, fmt.Errorf("failed to insert idle allocation %s: %w", name, err)
+		}
+	}
+
+	return idleSet, nil
+}

+ 158 - 20
pkg/kubecost/allocation.go

@@ -82,6 +82,11 @@ type Allocation struct {
 	// RawAllocationOnly is a pointer so if it is not present it will be
 	// marshalled as null rather than as an object with Go default values.
 	RawAllocationOnly *RawAllocationOnlyData `json:"rawAllocationOnly"`
+	// ProportionalAssetResourceCost represents the per-resource costs of the
+	// allocation as a percentage of the per-resource total cost of the
+	// asset on which the allocation was run. It is optionally computed
+	// and appended to an Allocation, and so by default is is nil.
+	ProportionalAssetResourceCosts ProportionalAssetResourceCosts `json:"proportionalAssetResourceCosts"`
 }
 
 // RawAllocationOnlyData is information that only belong in "raw" Allocations,
@@ -241,6 +246,57 @@ func (pva *PVAllocation) Equal(that *PVAllocation) bool {
 		util.IsApproximately(pva.Cost, that.Cost)
 }
 
+type ProportionalAssetResourceCost struct {
+	Cluster       string  `json:"cluster"`
+	Node          string  `json:"node,omitempty"`
+	ProviderID    string  `json:"providerID,omitempty"`
+	CPUPercentage float64 `json:"cpuPercentage"`
+	GPUPercentage float64 `json:"gpuPercentage"`
+	RAMPercentage float64 `json:"ramPercentage"`
+}
+
+func (parc ProportionalAssetResourceCost) Key(insertByNode bool) string {
+	if insertByNode {
+		return parc.Cluster + "," + parc.Node
+	} else {
+		return parc.Cluster
+	}
+
+}
+
+type ProportionalAssetResourceCosts map[string]ProportionalAssetResourceCost
+
+func (parcs ProportionalAssetResourceCosts) Insert(parc ProportionalAssetResourceCost, insertByNode bool) {
+	if !insertByNode {
+		parc.Node = ""
+		parc.ProviderID = ""
+	}
+	if curr, ok := parcs[parc.Key(insertByNode)]; ok {
+		parcs[parc.Key(insertByNode)] = ProportionalAssetResourceCost{
+			Node:          curr.Node,
+			Cluster:       curr.Cluster,
+			ProviderID:    curr.ProviderID,
+			CPUPercentage: curr.CPUPercentage + parc.CPUPercentage,
+			GPUPercentage: curr.GPUPercentage + parc.GPUPercentage,
+			RAMPercentage: curr.RAMPercentage + parc.RAMPercentage,
+		}
+	} else {
+		parcs[parc.Key(insertByNode)] = parc
+	}
+}
+
+func (parcs ProportionalAssetResourceCosts) Add(that ProportionalAssetResourceCosts) {
+
+	for _, parc := range that {
+		// if node field is empty, we know this is a cluster level PARC aggregation
+		insertByNode := true
+		if parc.Node == "" {
+			insertByNode = false
+		}
+		parcs.Insert(parc, insertByNode)
+	}
+}
+
 // GetWindow returns the window of the struct
 func (a *Allocation) GetWindow() Window {
 	return a.Window
@@ -715,6 +771,12 @@ func (a *Allocation) add(that *Allocation) {
 	// Preserve string properties that are matching between the two allocations
 	a.Properties = a.Properties.Intersection(that.Properties)
 
+	// If both Allocations have ProportionalAssetResourceCosts, then
+	// add those from the given Allocation into the receiver.
+	if a.ProportionalAssetResourceCosts != nil && that.ProportionalAssetResourceCosts != nil {
+		a.ProportionalAssetResourceCosts.Add(that.ProportionalAssetResourceCosts)
+	}
+
 	// Overwrite regular intersection logic for the controller name property in the
 	// case that the Allocation keys are the same but the controllers are not.
 	if leftKey == rightKey &&
@@ -846,18 +908,19 @@ func NewAllocationSet(start, end time.Time, allocs ...*Allocation) *AllocationSe
 // succeeds, the allocation is marked as a shared resource. ShareIdle is a
 // simple flag for sharing idle resources.
 type AllocationAggregationOptions struct {
-	AllocationTotalsStore AllocationTotalsStore
-	Filter                AllocationFilter
-	IdleByNode            bool
-	LabelConfig           *LabelConfig
-	MergeUnallocated      bool
-	Reconcile             bool
-	ReconcileNetwork      bool
-	ShareFuncs            []AllocationMatchFunc
-	ShareIdle             string
-	ShareSplit            string
-	SharedHourlyCosts     map[string]float64
-	SplitIdle             bool
+	AllocationTotalsStore                 AllocationTotalsStore
+	Filter                                AllocationFilter
+	IdleByNode                            bool
+	IncludeProportionalAssetResourceCosts bool
+	LabelConfig                           *LabelConfig
+	MergeUnallocated                      bool
+	Reconcile                             bool
+	ReconcileNetwork                      bool
+	ShareFuncs                            []AllocationMatchFunc
+	ShareIdle                             string
+	ShareSplit                            string
+	SharedHourlyCosts                     map[string]float64
+	SplitIdle                             bool
 }
 
 // AggregateBy aggregates the Allocations in the given AllocationSet by the given
@@ -870,14 +933,18 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 	//     Also, create the aggSet into which the results will be aggregated.
 	//
 	//  2. Compute sharing coefficients for idle and shared resources
-	//     a) if idle allocation is to be shared, compute idle coefficients
-	//     b) if idle allocation is NOT shared, but filters are present, compute
+	//     a) if idle allocation is to be shared, or if proportional asset
+	//        resource costs are to be included, then compute idle coefficients
+	//        (proportional asset resource costs are derived from idle coefficients)
+	//     b) if proportional asset costs are to be included, derive them from
+	//        idle coefficients and add them to the allocations.
+	//     c) 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. (See unit tests 5.a,b,c)
-	//     c) generate shared allocation for them given shared overhead, which
+	//     d) generate shared allocation for them given shared overhead, which
 	//        must happen after (2a) and (2b)
-	//     d) if there are shared resources, compute share coefficients
+	//     e) if there are shared resources, compute share coefficients
 	//
 	//  3. Drop any allocation that fails any of the filters
 	//
@@ -937,7 +1004,7 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 	shouldAggregate := aggregateBy != nil
 	shouldFilter := options.Filter != nil
 	shouldShare := len(options.SharedHourlyCosts) > 0 || len(options.ShareFuncs) > 0
-	if !shouldAggregate && !shouldFilter && !shouldShare && options.ShareIdle == ShareNone {
+	if !shouldAggregate && !shouldFilter && !shouldShare && options.ShareIdle == ShareNone && !options.IncludeProportionalAssetResourceCosts {
 		// There is nothing for AggregateBy to do, so simply return nil
 		return nil
 	}
@@ -958,6 +1025,12 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 		Window: as.Window.Clone(),
 	}
 
+	// parcSet is used to compute proportionalAssetResourceCosts
+	// for surfacing in the API
+	parcSet := &AllocationSet{
+		Window: as.Window.Clone(),
+	}
+
 	// shareSet will be shared among aggSet after initial aggregation
 	// is complete
 	shareSet := &AllocationSet{
@@ -991,6 +1064,12 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 				aggSet.Insert(alloc)
 			}
 
+			// build a parallel set of allocations to only be used
+			// for computing PARCs
+			if options.IncludeProportionalAssetResourceCosts {
+				parcSet.Insert(alloc.Clone())
+			}
+
 			continue
 		}
 
@@ -1058,7 +1137,38 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 		}
 	}
 
-	// (2b) If idle costs are not to be shared, but there are filters, then we
+	// (2b) If proportional asset resource costs are to be included, derive them
+	// from idle coefficients and add them to the allocations.
+	if options.IncludeProportionalAssetResourceCosts {
+		var parcCoefficients map[string]map[string]map[string]float64
+		if parcSet.Length() > 0 {
+			parcCoefficients, allocatedTotalsMap, err = computeIdleCoeffs(options, as, shareSet)
+			if err != nil {
+				log.Warnf("AllocationSet.AggregateBy: compute parc idle coeff: %s", err)
+				return fmt.Errorf("error computing parc coefficients: %s", err)
+			}
+		}
+		if parcCoefficients == nil {
+			return fmt.Errorf("cannot include proportional resource costs because parc coefficients are nil")
+		}
+
+		for _, alloc := range as.Allocations {
+			// Create an empty set of proportional asset resource costs,
+			// regardless of whether or not we're successful in deriving them.
+			alloc.ProportionalAssetResourceCosts = ProportionalAssetResourceCosts{}
+
+			// Attempt to derive proportional asset resource costs from idle
+			// coefficients, and insert them into the set if successful.
+			parc, err := deriveProportionalAssetResourceCostsFromIdleCoefficients(parcCoefficients, alloc, options)
+			if err != nil {
+				log.Debugf("AggregateBy: failed to derive proportional asset resource costs from idle coefficients for %s: %s", alloc.Name, err)
+				continue
+			}
+			alloc.ProportionalAssetResourceCosts.Insert(parc, options.IdleByNode)
+		}
+	}
+
+	// (2c) If idle costs are not to be shared, but there are filters, then we
 	// need to track the amount of each idle allocation to "filter" in order to
 	// maintain parity with the results when idle is shared. That is, we want
 	// to return only the idle costs that would have been shared with the given
@@ -1090,7 +1200,7 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 		}
 	}
 
-	// (2c) Convert SharedHourlyCosts to Allocations in the shareSet. This must
+	// (2d) Convert SharedHourlyCosts to Allocations in the shareSet. This must
 	// come after idle coefficients are computed so that allocations generated
 	// by shared overhead do not skew the idle coefficient computation.
 	for name, cost := range options.SharedHourlyCosts {
@@ -1115,7 +1225,7 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 		}
 	}
 
-	// (2d) Compute share coefficients for shared resources. These are computed
+	// (2e) Compute share coefficients for shared resources. These are computed
 	// after idle coefficients, and are computed for the aggregated allocations
 	// of the main allocation set. See above for details and an example.
 	var shareCoefficients map[string]float64
@@ -1624,6 +1734,34 @@ func computeIdleCoeffs(options *AllocationAggregationOptions, as *AllocationSet,
 	return coeffs, totals, nil
 }
 
+func deriveProportionalAssetResourceCostsFromIdleCoefficients(idleCoeffs map[string]map[string]map[string]float64, allocation *Allocation, options *AllocationAggregationOptions) (ProportionalAssetResourceCost, error) {
+	idleId, err := allocation.getIdleId(options)
+	if err != nil {
+		return ProportionalAssetResourceCost{}, fmt.Errorf("failed to get idle ID for allocation %s", allocation.Name)
+	}
+
+	if _, ok := idleCoeffs[idleId]; !ok {
+		return ProportionalAssetResourceCost{}, fmt.Errorf("failed to find idle coeffs for idle ID %s", idleId)
+	}
+
+	if _, ok := idleCoeffs[idleId][allocation.Name]; !ok {
+		return ProportionalAssetResourceCost{}, fmt.Errorf("failed to find idle coeffs for allocation %s", allocation.Name)
+	}
+
+	cpuPct := idleCoeffs[idleId][allocation.Name]["cpu"]
+	gpuPct := idleCoeffs[idleId][allocation.Name]["gpu"]
+	ramPct := idleCoeffs[idleId][allocation.Name]["ram"]
+
+	return ProportionalAssetResourceCost{
+		Cluster:       allocation.Properties.Cluster,
+		Node:          allocation.Properties.Node,
+		ProviderID:    allocation.Properties.ProviderID,
+		CPUPercentage: cpuPct,
+		GPUPercentage: gpuPct,
+		RAMPercentage: ramPct,
+	}, nil
+}
+
 // getIdleId returns the providerId or cluster of an Allocation depending on the IdleByNode
 // option in the AllocationAggregationOptions and an error if the respective field is missing
 func (a *Allocation) getIdleId(options *AllocationAggregationOptions) (string, error) {

+ 49 - 45
pkg/kubecost/allocation_json.go

@@ -2,57 +2,59 @@ package kubecost
 
 import (
 	"fmt"
-	"github.com/opencost/opencost/pkg/util/json"
 	"math"
 	"time"
+
+	"github.com/opencost/opencost/pkg/util/json"
 )
 
 // AllocationJSON  exists because there are expected JSON response fields
 // that are calculated values from methods on an annotation
 type AllocationJSON struct {
-	Name                       string                 `json:"name"`
-	Properties                 *AllocationProperties  `json:"properties"`
-	Window                     Window                 `json:"window"`
-	Start                      string                 `json:"start"`
-	End                        string                 `json:"end"`
-	Minutes                    *float64               `json:"minutes"`
-	CPUCores                   *float64               `json:"cpuCores"`
-	CPUCoreRequestAverage      *float64               `json:"cpuCoreRequestAverage"`
-	CPUCoreUsageAverage        *float64               `json:"cpuCoreUsageAverage"`
-	CPUCoreHours               *float64               `json:"cpuCoreHours"`
-	CPUCost                    *float64               `json:"cpuCost"`
-	CPUCostAdjustment          *float64               `json:"cpuCostAdjustment"`
-	CPUEfficiency              *float64               `json:"cpuEfficiency"`
-	GPUCount                   *float64               `json:"gpuCount"`
-	GPUHours                   *float64               `json:"gpuHours"`
-	GPUCost                    *float64               `json:"gpuCost"`
-	GPUCostAdjustment          *float64               `json:"gpuCostAdjustment"`
-	NetworkTransferBytes       *float64               `json:"networkTransferBytes"`
-	NetworkReceiveBytes        *float64               `json:"networkReceiveBytes"`
-	NetworkCost                *float64               `json:"networkCost"`
-	NetworkCrossZoneCost       *float64               `json:"networkCrossZoneCost"`
-	NetworkCrossRegionCost     *float64               `json:"networkCrossRegionCost"`
-	NetworkInternetCost        *float64               `json:"networkInternetCost"`
-	NetworkCostAdjustment      *float64               `json:"networkCostAdjustment"`
-	LoadBalancerCost           *float64               `json:"loadBalancerCost"`
-	LoadBalancerCostAdjustment *float64               `json:"loadBalancerCostAdjustment"`
-	PVBytes                    *float64               `json:"pvBytes"`
-	PVByteHours                *float64               `json:"pvByteHours"`
-	PVCost                     *float64               `json:"pvCost"`
-	PVs                        PVAllocations          `json:"pvs"`
-	PVCostAdjustment           *float64               `json:"pvCostAdjustment"`
-	RAMBytes                   *float64               `json:"ramBytes"`
-	RAMByteRequestAverage      *float64               `json:"ramByteRequestAverage"`
-	RAMByteUsageAverage        *float64               `json:"ramByteUsageAverage"`
-	RAMByteHours               *float64               `json:"ramByteHours"`
-	RAMCost                    *float64               `json:"ramCost"`
-	RAMCostAdjustment          *float64               `json:"ramCostAdjustment"`
-	RAMEfficiency              *float64               `json:"ramEfficiency"`
-	ExternalCost               *float64               `json:"externalCost"`
-	SharedCost                 *float64               `json:"sharedCost"`
-	TotalCost                  *float64               `json:"totalCost"`
-	TotalEfficiency            *float64               `json:"totalEfficiency"`
-	RawAllocationOnly          *RawAllocationOnlyData `json:"rawAllocationOnly"`
+	Name                           string                          `json:"name"`
+	Properties                     *AllocationProperties           `json:"properties"`
+	Window                         Window                          `json:"window"`
+	Start                          string                          `json:"start"`
+	End                            string                          `json:"end"`
+	Minutes                        *float64                        `json:"minutes"`
+	CPUCores                       *float64                        `json:"cpuCores"`
+	CPUCoreRequestAverage          *float64                        `json:"cpuCoreRequestAverage"`
+	CPUCoreUsageAverage            *float64                        `json:"cpuCoreUsageAverage"`
+	CPUCoreHours                   *float64                        `json:"cpuCoreHours"`
+	CPUCost                        *float64                        `json:"cpuCost"`
+	CPUCostAdjustment              *float64                        `json:"cpuCostAdjustment"`
+	CPUEfficiency                  *float64                        `json:"cpuEfficiency"`
+	GPUCount                       *float64                        `json:"gpuCount"`
+	GPUHours                       *float64                        `json:"gpuHours"`
+	GPUCost                        *float64                        `json:"gpuCost"`
+	GPUCostAdjustment              *float64                        `json:"gpuCostAdjustment"`
+	NetworkTransferBytes           *float64                        `json:"networkTransferBytes"`
+	NetworkReceiveBytes            *float64                        `json:"networkReceiveBytes"`
+	NetworkCost                    *float64                        `json:"networkCost"`
+	NetworkCrossZoneCost           *float64                        `json:"networkCrossZoneCost"`
+	NetworkCrossRegionCost         *float64                        `json:"networkCrossRegionCost"`
+	NetworkInternetCost            *float64                        `json:"networkInternetCost"`
+	NetworkCostAdjustment          *float64                        `json:"networkCostAdjustment"`
+	LoadBalancerCost               *float64                        `json:"loadBalancerCost"`
+	LoadBalancerCostAdjustment     *float64                        `json:"loadBalancerCostAdjustment"`
+	PVBytes                        *float64                        `json:"pvBytes"`
+	PVByteHours                    *float64                        `json:"pvByteHours"`
+	PVCost                         *float64                        `json:"pvCost"`
+	PVs                            PVAllocations                   `json:"pvs"`
+	PVCostAdjustment               *float64                        `json:"pvCostAdjustment"`
+	RAMBytes                       *float64                        `json:"ramBytes"`
+	RAMByteRequestAverage          *float64                        `json:"ramByteRequestAverage"`
+	RAMByteUsageAverage            *float64                        `json:"ramByteUsageAverage"`
+	RAMByteHours                   *float64                        `json:"ramByteHours"`
+	RAMCost                        *float64                        `json:"ramCost"`
+	RAMCostAdjustment              *float64                        `json:"ramCostAdjustment"`
+	RAMEfficiency                  *float64                        `json:"ramEfficiency"`
+	ExternalCost                   *float64                        `json:"externalCost"`
+	SharedCost                     *float64                        `json:"sharedCost"`
+	TotalCost                      *float64                        `json:"totalCost"`
+	TotalEfficiency                *float64                        `json:"totalEfficiency"`
+	RawAllocationOnly              *RawAllocationOnlyData          `json:"rawAllocationOnly,omitEmpty"`
+	ProportionalAssetResourceCosts *ProportionalAssetResourceCosts `json:"proportionalAssetResourceCosts,omitEmpty"`
 }
 
 func (aj *AllocationJSON) BuildFromAllocation(a *Allocation) {
@@ -64,7 +66,7 @@ func (aj *AllocationJSON) BuildFromAllocation(a *Allocation) {
 	aj.Window = a.Window
 	aj.Start = a.Start.UTC().Format(time.RFC3339)
 	aj.End = a.End.UTC().Format(time.RFC3339)
-  aj.Minutes = formatFloat64ForResponse(a.Minutes())
+	aj.Minutes = formatFloat64ForResponse(a.Minutes())
 	aj.CPUCores = formatFloat64ForResponse(a.CPUCores())
 	aj.CPUCoreRequestAverage = formatFloat64ForResponse(a.CPUCoreRequestAverage)
 	aj.CPUCoreUsageAverage = formatFloat64ForResponse(a.CPUCoreUsageAverage)
@@ -102,6 +104,8 @@ func (aj *AllocationJSON) BuildFromAllocation(a *Allocation) {
 	aj.TotalCost = formatFloat64ForResponse(a.TotalCost())
 	aj.TotalEfficiency = formatFloat64ForResponse(a.TotalEfficiency())
 	aj.RawAllocationOnly = a.RawAllocationOnly
+	aj.ProportionalAssetResourceCosts = &a.ProportionalAssetResourceCosts
+
 }
 
 // formatFloat64ForResponse - take an existing float64, round it to 6 decimal places and return is possible, or return nil if invalid

+ 124 - 12
pkg/kubecost/allocation_test.go

@@ -3,6 +3,7 @@ package kubecost
 import (
 	"fmt"
 	"math"
+	"reflect"
 	"testing"
 	"time"
 
@@ -509,6 +510,19 @@ func assertAllocationSetTotals(t *testing.T, as *AllocationSet, msg string, err
 	}
 }
 
+func assertParcResults(t *testing.T, as *AllocationSet, msg string, exps map[string]ProportionalAssetResourceCosts) {
+	for allocKey, a := range as.Allocations {
+		for key, actualParc := range a.ProportionalAssetResourceCosts {
+			expectedParcs := exps[allocKey]
+
+			if !reflect.DeepEqual(expectedParcs[key], actualParc) {
+				t.Fatalf("actual PARC %v did not match expected PARC %v", actualParc, expectedParcs[key])
+			}
+		}
+
+	}
+}
+
 func assertAllocationTotals(t *testing.T, as *AllocationSet, msg string, exps map[string]float64) {
 	for _, a := range as.Allocations {
 		if exp, ok := exps[a.Name]; ok {
@@ -690,15 +704,16 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 	// Tests:
 	cases := map[string]struct {
-		start       time.Time
-		aggBy       []string
-		aggOpts     *AllocationAggregationOptions
-		numResults  int
-		totalCost   float64
-		results     map[string]float64
-		windowStart time.Time
-		windowEnd   time.Time
-		expMinutes  float64
+		start               time.Time
+		aggBy               []string
+		aggOpts             *AllocationAggregationOptions
+		numResults          int
+		totalCost           float64
+		results             map[string]float64
+		windowStart         time.Time
+		windowEnd           time.Time
+		expMinutes          float64
+		expectedParcResults map[string]ProportionalAssetResourceCosts
 	}{
 		// 1  Single-aggregation
 
@@ -1042,8 +1057,9 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				ShareFuncs: []AllocationMatchFunc{isNamespace3},
-				ShareSplit: ShareWeighted,
+				ShareFuncs:                            []AllocationMatchFunc{isNamespace3},
+				ShareSplit:                            ShareWeighted,
+				IncludeProportionalAssetResourceCosts: true,
 			},
 			numResults: numNamespaces,
 			totalCost:  activeTotalCost + idleTotalCost,
@@ -1055,6 +1071,36 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			windowStart: startYesterday,
 			windowEnd:   endYesterday,
 			expMinutes:  1440.0,
+			expectedParcResults: map[string]ProportionalAssetResourceCosts{
+				"namespace1": ProportionalAssetResourceCosts{
+					"cluster1": ProportionalAssetResourceCost{
+						Cluster:       "cluster1",
+						Node:          "",
+						ProviderID:    "",
+						CPUPercentage: 0.5,
+						GPUPercentage: 0.5,
+						RAMPercentage: 0.8125,
+					},
+				},
+				"namespace2": ProportionalAssetResourceCosts{
+					"cluster1": ProportionalAssetResourceCost{
+						Cluster:       "cluster1",
+						Node:          "",
+						ProviderID:    "",
+						CPUPercentage: 0.5,
+						GPUPercentage: 0.5,
+						RAMPercentage: 0.1875,
+					},
+					"cluster2": ProportionalAssetResourceCost{
+						Cluster:       "cluster2",
+						Node:          "",
+						ProviderID:    "",
+						CPUPercentage: 0.5,
+						GPUPercentage: 0.5,
+						RAMPercentage: 0.5,
+					},
+				},
+			},
 		},
 		// 4c Share label ShareEven
 		// namespace1: 17.3333 = 28.00 - 16.00 + 16.00*(1.0/3.0)
@@ -1446,7 +1492,8 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				IdleByNode: true,
+				IdleByNode:                            true,
+				IncludeProportionalAssetResourceCosts: true,
 			},
 			numResults: numNamespaces + numIdle,
 			totalCost:  activeTotalCost + idleTotalCost,
@@ -1459,6 +1506,70 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			windowStart: startYesterday,
 			windowEnd:   endYesterday,
 			expMinutes:  1440.0,
+			expectedParcResults: map[string]ProportionalAssetResourceCosts{
+				"namespace1": {
+					"cluster1,c1nodes": ProportionalAssetResourceCost{
+						Cluster:       "cluster1",
+						Node:          "c1nodes",
+						ProviderID:    "c1nodes",
+						CPUPercentage: 0.5,
+						GPUPercentage: 0.5,
+						RAMPercentage: 0.8125,
+					},
+					"cluster2,node2": ProportionalAssetResourceCost{
+						Cluster:       "cluster2",
+						Node:          "node2",
+						ProviderID:    "node2",
+						CPUPercentage: 0.5,
+						GPUPercentage: 0.5,
+						RAMPercentage: 0.5,
+					},
+				},
+				"namespace2": {
+					"cluster1,c1nodes": ProportionalAssetResourceCost{
+						Cluster:       "cluster1",
+						Node:          "c1nodes",
+						ProviderID:    "c1nodes",
+						CPUPercentage: 0.5,
+						GPUPercentage: 0.5,
+						RAMPercentage: 0.1875,
+					},
+					"cluster2,node1": ProportionalAssetResourceCost{
+						Cluster:       "cluster2",
+						Node:          "node1",
+						ProviderID:    "node1",
+						CPUPercentage: 1,
+						GPUPercentage: 1,
+						RAMPercentage: 1,
+					},
+					"cluster2,node2": ProportionalAssetResourceCost{
+						Cluster:       "cluster2",
+						Node:          "node2",
+						ProviderID:    "node2",
+						CPUPercentage: 0.5,
+						GPUPercentage: 0.5,
+						RAMPercentage: 0.5,
+					},
+				},
+				"namespace3": {
+					"cluster2,node3": ProportionalAssetResourceCost{
+						Cluster:       "cluster2",
+						Node:          "node3",
+						ProviderID:    "node3",
+						CPUPercentage: 1,
+						GPUPercentage: 1,
+						RAMPercentage: 1,
+					},
+					"cluster2,node2": ProportionalAssetResourceCost{
+						Cluster:       "cluster2",
+						Node:          "node2",
+						ProviderID:    "node2",
+						CPUPercentage: 0.5,
+						GPUPercentage: 0.5,
+						RAMPercentage: 0.5,
+					},
+				},
+			},
 		},
 		// 6k Split Idle, Idle by Node
 		"6k": {
@@ -1524,6 +1635,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			err = as.AggregateBy(testcase.aggBy, testcase.aggOpts)
 			assertAllocationSetTotals(t, as, name, err, testcase.numResults, testcase.totalCost)
 			assertAllocationTotals(t, as, name, testcase.results)
+			assertParcResults(t, as, name, testcase.expectedParcResults)
 			assertAllocationWindow(t, as, name, testcase.windowStart, testcase.windowEnd, testcase.expMinutes)
 		})
 	}

+ 1 - 0
pkg/kubecost/mock.go

@@ -63,6 +63,7 @@ func NewMockUnitAllocation(name string, start time.Time, resolution time.Duratio
 			CPUCoreUsageMax:  1,
 			RAMBytesUsageMax: 1,
 		},
+		ProportionalAssetResourceCosts: nil,
 	}
 
 	// If idle allocation, remove non-idle costs, but maintain total cost

+ 116 - 38
pkg/kubecost/summaryallocation.go

@@ -186,6 +186,74 @@ func (sa *SummaryAllocation) CPUEfficiency() float64 {
 	return 1.0
 }
 
+func (sa *SummaryAllocation) Equal(that *SummaryAllocation) bool {
+	if sa == nil || that == nil {
+		return false
+	}
+
+	if sa.Name != that.Name {
+		return false
+	}
+
+	if sa.Start != that.Start {
+		return false
+	}
+
+	if sa.End != that.End {
+		return false
+	}
+
+	if sa.CPUCoreRequestAverage != that.CPUCoreRequestAverage {
+		return false
+	}
+
+	if sa.CPUCoreUsageAverage != that.CPUCoreUsageAverage {
+		return false
+	}
+
+	if sa.CPUCost != that.CPUCost {
+		return false
+	}
+
+	if sa.GPUCost != that.GPUCost {
+		return false
+	}
+
+	if sa.NetworkCost != that.NetworkCost {
+		return false
+	}
+
+	if sa.LoadBalancerCost != that.LoadBalancerCost {
+		return false
+	}
+
+	if sa.PVCost != that.PVCost {
+		return false
+	}
+
+	if sa.RAMBytesRequestAverage != that.RAMBytesRequestAverage {
+		return false
+	}
+
+	if sa.RAMBytesUsageAverage != that.RAMBytesUsageAverage {
+		return false
+	}
+
+	if sa.RAMCost != that.RAMCost {
+		return false
+	}
+
+	if sa.SharedCost != that.SharedCost {
+		return false
+	}
+
+	if sa.ExternalCost != that.ExternalCost {
+		return false
+	}
+
+	return true
+}
+
 func (sa *SummaryAllocation) generateKey(aggregateBy []string, labelConfig *LabelConfig) string {
 	if sa == nil {
 		return ""
@@ -1084,6 +1152,36 @@ func (sas *SummaryAllocationSet) Each(f func(string, *SummaryAllocation)) {
 	}
 }
 
+func (sas *SummaryAllocationSet) Equal(that *SummaryAllocationSet) bool {
+	if sas == nil || that == nil {
+		return false
+	}
+
+	sas.RLock()
+	defer sas.RUnlock()
+
+	if !sas.Window.Equal(that.Window) {
+		return false
+	}
+
+	if len(sas.SummaryAllocations) != len(that.SummaryAllocations) {
+		return false
+	}
+
+	for name, sa := range sas.SummaryAllocations {
+		thatSA, ok := that.SummaryAllocations[name]
+		if !ok {
+			return false
+		}
+
+		if !sa.Equal(thatSA) {
+			return false
+		}
+	}
+
+	return true
+}
+
 // IdleAllocations returns a map of the idle allocations in the AllocationSet.
 func (sas *SummaryAllocationSet) idleAllocations() map[string]*SummaryAllocation {
 	idles := map[string]*SummaryAllocation{}
@@ -1374,8 +1472,7 @@ func (sasr *SummaryAllocationSetRange) AggregateBy(aggregateBy []string, options
 
 // Append appends the given AllocationSet to the end of the range. It does not
 // validate whether or not that violates window continuity.
-func (sasr *SummaryAllocationSetRange) Append(sas *SummaryAllocationSet) error {
-
+func (sasr *SummaryAllocationSetRange) Append(sas *SummaryAllocationSet) {
 	sasr.Lock()
 	defer sasr.Unlock()
 
@@ -1394,8 +1491,6 @@ func (sasr *SummaryAllocationSetRange) Append(sas *SummaryAllocationSet) error {
 	if sasr.Window.End() == nil || (sas.Window.End() != nil && sas.Window.End().After(*sasr.Window.End())) {
 		sasr.Window.end = sas.Window.End()
 	}
-
-	return nil
 }
 
 // Each invokes the given function for each AllocationSet in the range
@@ -1513,8 +1608,7 @@ func (sasr *SummaryAllocationSetRange) Accumulate(accumulateBy AccumulateOption)
 }
 
 func (sasr *SummaryAllocationSetRange) accumulateByNone() (*SummaryAllocationSetRange, error) {
-	result, err := sasr.clone()
-	return result, err
+	return sasr.clone(), nil
 }
 
 func (sasr *SummaryAllocationSetRange) accumulateByAll() (*SummaryAllocationSetRange, error) {
@@ -1535,8 +1629,9 @@ func (sasr *SummaryAllocationSetRange) accumulateByHour() (*SummaryAllocationSet
 		return nil, fmt.Errorf("window duration must equal 1 hour; got:%s", sasr.SummaryAllocationSets[0].Window.Duration())
 	}
 
-	result, err := sasr.clone()
-	return result, err
+	result := sasr.clone()
+
+	return result, nil
 }
 
 func (sasr *SummaryAllocationSetRange) accumulateByDay() (*SummaryAllocationSetRange, error) {
@@ -1560,10 +1655,7 @@ func (sasr *SummaryAllocationSetRange) accumulateByDay() (*SummaryAllocationSetR
 			as = as.Clone()
 		}
 
-		err := toAccumulate.Append(as)
-		if err != nil {
-			return nil, fmt.Errorf("error building accumulation: %s", err)
-		}
+		toAccumulate.Append(as)
 		sas, err := toAccumulate.accumulate()
 		if err != nil {
 			return nil, fmt.Errorf("error accumulating result: %s", err)
@@ -1574,10 +1666,7 @@ func (sasr *SummaryAllocationSetRange) accumulateByDay() (*SummaryAllocationSetR
 			if length := len(toAccumulate.SummaryAllocationSets); length != 1 {
 				return nil, fmt.Errorf("failed accumulation, detected %d sets instead of 1", length)
 			}
-			err = result.Append(toAccumulate.SummaryAllocationSets[0])
-			if err != nil {
-				return nil, fmt.Errorf("error building result accumulation: %s", err)
-			}
+			result.Append(toAccumulate.SummaryAllocationSets[0])
 			toAccumulate = nil
 		}
 	}
@@ -1601,10 +1690,7 @@ func (sasr *SummaryAllocationSetRange) accumulateByMonth() (*SummaryAllocationSe
 			as = as.Clone()
 		}
 
-		err := toAccumulate.Append(as)
-		if err != nil {
-			return nil, fmt.Errorf("error building monthly accumulation: %s", err)
-		}
+		toAccumulate.Append(as)
 
 		sas, err := toAccumulate.accumulate()
 		if err != nil {
@@ -1618,10 +1704,7 @@ func (sasr *SummaryAllocationSetRange) accumulateByMonth() (*SummaryAllocationSe
 			if length := len(toAccumulate.SummaryAllocationSets); length != 1 {
 				return nil, fmt.Errorf("failed accumulation, detected %d sets instead of 1", length)
 			}
-			err = result.Append(toAccumulate.SummaryAllocationSets[0])
-			if err != nil {
-				return nil, fmt.Errorf("error building result accumulation: %s", err)
-			}
+			result.Append(toAccumulate.SummaryAllocationSets[0])
 			toAccumulate = nil
 		}
 	}
@@ -1647,10 +1730,7 @@ func (sasr *SummaryAllocationSetRange) accumulateByWeek() (*SummaryAllocationSet
 			as = as.Clone()
 		}
 
-		err := toAccumulate.Append(as)
-		if err != nil {
-			return nil, fmt.Errorf("error building accumulation: %s", err)
-		}
+		toAccumulate.Append(as)
 		sas, err := toAccumulate.accumulate()
 		if err != nil {
 			return nil, fmt.Errorf("error accumulating result: %s", err)
@@ -1662,18 +1742,19 @@ func (sasr *SummaryAllocationSetRange) accumulateByWeek() (*SummaryAllocationSet
 			if length := len(toAccumulate.SummaryAllocationSets); length != 1 {
 				return nil, fmt.Errorf("failed accumulation, detected %d sets instead of 1", length)
 			}
-			err = result.Append(toAccumulate.SummaryAllocationSets[0])
-			if err != nil {
-				return nil, fmt.Errorf("error building result accumulation: %s", err)
-			}
+			result.Append(toAccumulate.SummaryAllocationSets[0])
 			toAccumulate = nil
 		}
 	}
 	return result, nil
 }
 
+func (sasr *SummaryAllocationSetRange) Clone() *SummaryAllocationSetRange {
+	return sasr.clone()
+}
+
 // clone returns a new SummaryAllocationSetRange cloned from the existing SASR
-func (sasr *SummaryAllocationSetRange) clone() (*SummaryAllocationSetRange, error) {
+func (sasr *SummaryAllocationSetRange) clone() *SummaryAllocationSetRange {
 	sasrSource := NewSummaryAllocationSetRange()
 	sasrSource.Window = sasr.Window.Clone()
 	sasrSource.Step = sasr.Step
@@ -1685,11 +1766,8 @@ func (sasr *SummaryAllocationSetRange) clone() (*SummaryAllocationSetRange, erro
 			sasClone = sas.Clone()
 		}
 
-		err := sasrSource.Append(sasClone)
-		if err != nil {
-			return nil, err
-		}
+		sasrSource.Append(sasClone)
 	}
-	return sasrSource, nil
 
+	return sasrSource
 }

+ 14 - 21
pkg/kubecost/summaryallocation_json.go

@@ -1,8 +1,9 @@
 package kubecost
 
 import (
-	"math"
 	"time"
+
+	"github.com/opencost/opencost/pkg/util/formatutil"
 )
 
 // SummaryAllocationResponse is a sanitized version of SummaryAllocation, which
@@ -36,27 +37,19 @@ func (sa *SummaryAllocation) ToResponse() *SummaryAllocationResponse {
 		Name:                   sa.Name,
 		Start:                  sa.Start,
 		End:                    sa.End,
-		CPUCoreRequestAverage:  float64ToResponse(sa.CPUCoreRequestAverage),
-		CPUCoreUsageAverage:    float64ToResponse(sa.CPUCoreUsageAverage),
-		CPUCost:                float64ToResponse(sa.CPUCost),
-		GPUCost:                float64ToResponse(sa.GPUCost),
-		NetworkCost:            float64ToResponse(sa.NetworkCost),
-		LoadBalancerCost:       float64ToResponse(sa.LoadBalancerCost),
-		PVCost:                 float64ToResponse(sa.PVCost),
-		RAMBytesRequestAverage: float64ToResponse(sa.RAMBytesRequestAverage),
-		RAMBytesUsageAverage:   float64ToResponse(sa.RAMBytesUsageAverage),
-		RAMCost:                float64ToResponse(sa.RAMCost),
-		SharedCost:             float64ToResponse(sa.SharedCost),
-		ExternalCost:           float64ToResponse(sa.ExternalCost),
-	}
-}
-
-func float64ToResponse(f float64) *float64 {
-	if math.IsNaN(f) || math.IsInf(f, 0) {
-		return nil
+		CPUCoreRequestAverage:  formatutil.Float64ToResponse(sa.CPUCoreRequestAverage),
+		CPUCoreUsageAverage:    formatutil.Float64ToResponse(sa.CPUCoreUsageAverage),
+		CPUCost:                formatutil.Float64ToResponse(sa.CPUCost),
+		GPUCost:                formatutil.Float64ToResponse(sa.GPUCost),
+		NetworkCost:            formatutil.Float64ToResponse(sa.NetworkCost),
+		LoadBalancerCost:       formatutil.Float64ToResponse(sa.LoadBalancerCost),
+		PVCost:                 formatutil.Float64ToResponse(sa.PVCost),
+		RAMBytesRequestAverage: formatutil.Float64ToResponse(sa.RAMBytesRequestAverage),
+		RAMBytesUsageAverage:   formatutil.Float64ToResponse(sa.RAMBytesUsageAverage),
+		RAMCost:                formatutil.Float64ToResponse(sa.RAMCost),
+		SharedCost:             formatutil.Float64ToResponse(sa.SharedCost),
+		ExternalCost:           formatutil.Float64ToResponse(sa.ExternalCost),
 	}
-
-	return &f
 }
 
 // SummaryAllocationSetResponse is a sanitized version of SummaryAllocationSet,

+ 11 - 0
pkg/prom/diagnostics.go

@@ -36,6 +36,10 @@ const (
 	// CPUThrottlingDiagnosticMetricID is the identifier for the metric used to determine if CPU throttling is being applied to the
 	// cost-model container.
 	CPUThrottlingDiagnosticMetricID = "cpuThrottling"
+
+	// KubecostRecordingRuleCPUUsageID is the identifier for the query used to
+	// determine of the CPU usage recording rule is set up correctly.
+	KubecostRecordingRuleCPUUsageID = "kubecostRecordingRuleCPUUsage"
 )
 
 const DocumentationBaseURL = "https://github.com/kubecost/docs/blob/master/diagnostics.md"
@@ -96,6 +100,13 @@ var diagnosticDefinitions map[string]*diagnosticDefinition = map[string]*diagnos
 		Label:       "Kubecost is not CPU throttled",
 		Description: "Kubecost loading slowly? A kubecost component might be CPU throttled",
 	},
+	KubecostRecordingRuleCPUUsageID: {
+		ID:          KubecostRecordingRuleCPUUsageID,
+		QueryFmt:    `absent_over_time(kubecost_container_cpu_usage_irate[5m] %s)`,
+		Label:       "Kubecost's CPU usage recording rule is set up",
+		Description: "If the 'kubecost_container_cpu_usage_irate' recording rule is not set up, Allocation pipeline build may put pressure on your Prometheus due to the use of a subquery.",
+		DocLink:     "https://docs.kubecost.com/install-and-configure/install/custom-prom",
+	},
 }
 
 // QueuedPromRequest is a representation of a request waiting to be sent by the prometheus

+ 11 - 0
pkg/util/formatutil/formatutil.go

@@ -0,0 +1,11 @@
+package formatutil
+
+import "math"
+
+func Float64ToResponse(f float64) *float64 {
+	if math.IsNaN(f) || math.IsInf(f, 0) {
+		return nil
+	}
+
+	return &f
+}

+ 16 - 0
pkg/util/timeutil/timeutil.go

@@ -453,3 +453,19 @@ func leadingInt(s string) (x int64, rem string, err error) {
 	}
 	return x, s[i:], nil
 }
+
+// EarlierOf returns the second time passed in if both are equal
+func EarlierOf(timeOne, timeTwo time.Time) time.Time {
+	if timeOne.Before(timeTwo) {
+		return timeOne
+	}
+	return timeTwo
+}
+
+// LaterOf returns the second time passed in if both are equal
+func LaterOf(timeOne, timeTwo time.Time) time.Time {
+	if timeOne.After(timeTwo) {
+		return timeOne
+	}
+	return timeTwo
+}