Explorar el Código

Merge branch 'develop' into thomaschaaf-patch-1

Matt Ray hace 3 años
padre
commit
9918253ca3

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

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

+ 60 - 74
pkg/cloud/awsprovider.go

@@ -64,7 +64,7 @@ var (
 	// It's of the form aws:///us-east-2a/i-0fea4fd46592d050b and we want i-0fea4fd46592d050b, if it exists
 	// It's of the form aws:///us-east-2a/i-0fea4fd46592d050b and we want i-0fea4fd46592d050b, if it exists
 	provIdRx      = regexp.MustCompile("aws:///([^/]+)/([^/]+)")
 	provIdRx      = regexp.MustCompile("aws:///([^/]+)/([^/]+)")
 	usageTypeRegx = regexp.MustCompile(".*(-|^)(EBS.+)")
 	usageTypeRegx = regexp.MustCompile(".*(-|^)(EBS.+)")
-	versionRx     = regexp.MustCompile("^#Version: (\\d+)\\.\\d+$")
+	versionRx     = regexp.MustCompile(`^#Version: (\\d+)\\.\\d+$`)
 	regionRx      = regexp.MustCompile("([a-z]+-[a-z]+-[0-9])")
 	regionRx      = regexp.MustCompile("([a-z]+-[a-z]+-[0-9])")
 )
 )
 
 
@@ -257,6 +257,7 @@ type AWSPricingTerms struct {
 // AWSOfferTerm is a sku extension used to pay for the node.
 // AWSOfferTerm is a sku extension used to pay for the node.
 type AWSOfferTerm struct {
 type AWSOfferTerm struct {
 	Sku             string                  `json:"sku"`
 	Sku             string                  `json:"sku"`
+	OfferTermCode   string                  `json:"offerTermCode"`
 	PriceDimensions map[string]*AWSRateCode `json:"priceDimensions"`
 	PriceDimensions map[string]*AWSRateCode `json:"priceDimensions"`
 }
 }
 
 
@@ -299,16 +300,20 @@ type AWSProductTerms struct {
 // ClusterIdEnvVar is the environment variable in which one can manually set the ClusterId
 // ClusterIdEnvVar is the environment variable in which one can manually set the ClusterId
 const ClusterIdEnvVar = "AWS_CLUSTER_ID"
 const ClusterIdEnvVar = "AWS_CLUSTER_ID"
 
 
-// OnDemandRateCode is appended to an node sku
-const OnDemandRateCode = ".JRTCKXETXF"
-const OnDemandRateCodeCn = ".99YE2YK9UR"
+// OnDemandRateCodes is are sets of identifiers for offerTermCodes matching 'On Demand' rates
+var OnDemandRateCodes = map[string]struct{}{
+	"JRTCKXETXF": {},
+}
 
 
-// ReservedRateCode is appended to a node sku
-const ReservedRateCode = ".38NPMPTW36"
+var OnDemandRateCodesCn = map[string]struct{}{
+	"99YE2YK9UR": {},
+	"5Y9WH78GDR": {},
+	"KW44MY7SZN": {},
+}
 
 
 // HourlyRateCode is appended to a node sku
 // HourlyRateCode is appended to a node sku
-const HourlyRateCode = ".6YS6EN2CT7"
-const HourlyRateCodeCn = ".Q7UJUT2CE6"
+const HourlyRateCode = "6YS6EN2CT7"
+const HourlyRateCodeCn = "Q7UJUT2CE6"
 
 
 // volTypes are used to map between AWS UsageTypes and
 // volTypes are used to map between AWS UsageTypes and
 // EBS volume types, as they would appear in K8s storage class
 // EBS volume types, as they would appear in K8s storage class
@@ -359,34 +364,6 @@ var locationToRegion = map[string]string{
 	"AWS GovCloud (US-West)":    "us-gov-west-1",
 	"AWS GovCloud (US-West)":    "us-gov-west-1",
 }
 }
 
 
-var regionToBillingRegionCode = map[string]string{
-	"us-east-2":      "USE2",
-	"us-east-1":      "",
-	"us-west-1":      "USW1",
-	"us-west-2":      "USW2",
-	"ap-east-1":      "APE1",
-	"ap-south-1":     "APS3",
-	"ap-northeast-3": "APN3",
-	"ap-northeast-2": "APN2",
-	"ap-southeast-1": "APS1",
-	"ap-southeast-2": "APS2",
-	"ap-northeast-1": "APN1",
-	"ap-southeast-3": "APS4",
-	"ca-central-1":   "CAN1",
-	"cn-north-1":     "",
-	"cn-northwest-1": "",
-	"eu-central-1":   "EUC1",
-	"eu-west-1":      "EU",
-	"eu-west-2":      "EUW2",
-	"eu-west-3":      "EUW3",
-	"eu-north-1":     "EUN1",
-	"eu-south-1":     "EUS1",
-	"sa-east-1":      "SAE1",
-	"af-south-1":     "AFS1",
-	"us-gov-east-1":  "UGE1",
-	"us-gov-west-1":  "UGW1",
-}
-
 var loadedAWSSecret bool = false
 var loadedAWSSecret bool = false
 var awsSecret *AWSAccessKey = nil
 var awsSecret *AWSAccessKey = nil
 
 
@@ -923,19 +900,53 @@ func (aws *AWS) DownloadPricingData() error {
 		}
 		}
 	}
 	}
 
 
-	aws.Pricing = make(map[string]*AWSProductTerms)
 	aws.ValidPricingKeys = make(map[string]bool)
 	aws.ValidPricingKeys = make(map[string]bool)
-	skusToKeys := make(map[string]string)
 
 
 	resp, pricingURL, err := aws.getRegionPricing(nodeList)
 	resp, pricingURL, err := aws.getRegionPricing(nodeList)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
+	err = aws.populatePricing(resp, inputkeys)
+	if err != nil {
+		return err
+	}
+	log.Infof("Finished downloading \"%s\"", pricingURL)
+
+	if !aws.SpotRefreshEnabled() {
+		return nil
+	}
+
+	// Always run spot pricing refresh when performing download
+	aws.refreshSpotPricing(true)
+
+	// Only start a single refresh goroutine
+	if !aws.SpotRefreshRunning {
+		aws.SpotRefreshRunning = true
+
+		go func() {
+			defer errs.HandlePanic()
+
+			for {
+				log.Infof("Spot Pricing Refresh scheduled in %.2f minutes.", SpotRefreshDuration.Minutes())
+				time.Sleep(SpotRefreshDuration)
+
+				// Reoccurring refresh checks update times
+				aws.refreshSpotPricing(false)
+			}
+		}()
+	}
+
+	return nil
+}
+
+func (aws *AWS) populatePricing(resp *http.Response, inputkeys map[string]bool) error {
+	aws.Pricing = make(map[string]*AWSProductTerms)
+	skusToKeys := make(map[string]string)
 	dec := json.NewDecoder(resp.Body)
 	dec := json.NewDecoder(resp.Body)
 	for {
 	for {
 		t, err := dec.Token()
 		t, err := dec.Token()
 		if err == io.EOF {
 		if err == io.EOF {
-			log.Infof("done loading \"%s\"\n", pricingURL)
+			log.Infof("done loading \"%s\"\n", resp.Request.URL.String())
 			break
 			break
 		} else if err != nil {
 		} else if err != nil {
 			log.Errorf("error parsing response json %v", resp.Body)
 			log.Errorf("error parsing response json %v", resp.Body)
@@ -955,7 +966,7 @@ func (aws *AWS) DownloadPricingData() error {
 
 
 				err = dec.Decode(&product)
 				err = dec.Decode(&product)
 				if err != nil {
 				if err != nil {
-					log.Errorf("Error parsing response from \"%s\": %v", pricingURL, err.Error())
+					log.Errorf("Error parsing response from \"%s\": %v", resp.Request.URL.String(), err.Error())
 					break
 					break
 				}
 				}
 
 
@@ -1024,7 +1035,8 @@ func (aws *AWS) DownloadPricingData() error {
 					if err != nil {
 					if err != nil {
 						return err
 						return err
 					}
 					}
-					skuOnDemand, err := dec.Token()
+					// SKUOndemand
+					_, err = dec.Token()
 					if err != nil {
 					if err != nil {
 						return err
 						return err
 					}
 					}
@@ -1040,10 +1052,10 @@ func (aws *AWS) DownloadPricingData() error {
 						aws.Pricing[key].OnDemand = offerTerm
 						aws.Pricing[key].OnDemand = offerTerm
 						aws.Pricing[spotKey].OnDemand = offerTerm
 						aws.Pricing[spotKey].OnDemand = offerTerm
 						var cost string
 						var cost string
-						if sku.(string)+OnDemandRateCode == skuOnDemand {
-							cost = offerTerm.PriceDimensions[sku.(string)+OnDemandRateCode+HourlyRateCode].PricePerUnit.USD
-						} else if sku.(string)+OnDemandRateCodeCn == skuOnDemand {
-							cost = offerTerm.PriceDimensions[sku.(string)+OnDemandRateCodeCn+HourlyRateCodeCn].PricePerUnit.CNY
+						if _, isMatch := OnDemandRateCodes[offerTerm.OfferTermCode]; isMatch {
+							cost = offerTerm.PriceDimensions[strings.Join([]string{sku.(string), offerTerm.OfferTermCode, HourlyRateCode}, ".")].PricePerUnit.USD
+						} else if _, isMatch := OnDemandRateCodesCn[offerTerm.OfferTermCode]; isMatch {
+							cost = offerTerm.PriceDimensions[strings.Join([]string{sku.(string), offerTerm.OfferTermCode, HourlyRateCodeCn}, ".")].PricePerUnit.CNY
 						}
 						}
 						if strings.Contains(key, "EBS:VolumeP-IOPS.piops") {
 						if strings.Contains(key, "EBS:VolumeP-IOPS.piops") {
 							// If the specific UsageType is the per IO cost used on io1 volumes
 							// If the specific UsageType is the per IO cost used on io1 volumes
@@ -1072,32 +1084,6 @@ func (aws *AWS) DownloadPricingData() error {
 			}
 			}
 		}
 		}
 	}
 	}
-	log.Infof("Finished downloading \"%s\"", pricingURL)
-
-	if !aws.SpotRefreshEnabled() {
-		return nil
-	}
-
-	// Always run spot pricing refresh when performing download
-	aws.refreshSpotPricing(true)
-
-	// Only start a single refresh goroutine
-	if !aws.SpotRefreshRunning {
-		aws.SpotRefreshRunning = true
-
-		go func() {
-			defer errs.HandlePanic()
-
-			for {
-				log.Infof("Spot Pricing Refresh scheduled in %.2f minutes.", SpotRefreshDuration.Minutes())
-				time.Sleep(SpotRefreshDuration)
-
-				// Reoccurring refresh checks update times
-				aws.refreshSpotPricing(false)
-			}
-		}()
-	}
-
 	return nil
 	return nil
 }
 }
 
 
@@ -1268,12 +1254,12 @@ func (aws *AWS) createNode(terms *AWSProductTerms, usageType string, k Key) (*No
 
 
 	}
 	}
 	var cost string
 	var cost string
-	c, ok := terms.OnDemand.PriceDimensions[terms.Sku+OnDemandRateCode+HourlyRateCode]
+	c, ok := terms.OnDemand.PriceDimensions[strings.Join([]string{terms.Sku, terms.OnDemand.OfferTermCode, HourlyRateCode}, ".")]
 	if ok {
 	if ok {
 		cost = c.PricePerUnit.USD
 		cost = c.PricePerUnit.USD
 	} else {
 	} else {
 		// Check for Chinese pricing before throwing error
 		// Check for Chinese pricing before throwing error
-		c, ok = terms.OnDemand.PriceDimensions[terms.Sku+OnDemandRateCodeCn+HourlyRateCodeCn]
+		c, ok = terms.OnDemand.PriceDimensions[strings.Join([]string{terms.Sku, terms.OnDemand.OfferTermCode, HourlyRateCodeCn}, ".")]
 		if ok {
 		if ok {
 			cost = c.PricePerUnit.CNY
 			cost = c.PricePerUnit.CNY
 		} else {
 		} else {
@@ -1427,7 +1413,7 @@ func (aws *AWS) getAWSAuth(forceReload bool, cp *CustomPricing) (string, string)
 	}
 	}
 
 
 	// 3. Fall back to env vars
 	// 3. Fall back to env vars
-	if env.GetAWSAccessKeyID() == "" || env.GetAWSAccessKeyID() == "" {
+	if env.GetAWSAccessKeyID() == "" || env.GetAWSAccessKeySecret() == "" {
 		aws.serviceAccountChecks.set("hasKey", &ServiceAccountCheck{
 		aws.serviceAccountChecks.set("hasKey", &ServiceAccountCheck{
 			Message: "AWS ServiceKey exists",
 			Message: "AWS ServiceKey exists",
 			Status:  false,
 			Status:  false,

+ 402 - 1
pkg/cloud/awsprovider_test.go

@@ -1,6 +1,13 @@
 package cloud
 package cloud
 
 
-import "testing"
+import (
+	"bytes"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"reflect"
+	"testing"
+)
 
 
 func Test_awsKey_getUsageType(t *testing.T) {
 func Test_awsKey_getUsageType(t *testing.T) {
 	type fields struct {
 	type fields struct {
@@ -91,3 +98,397 @@ func Test_awsKey_getUsageType(t *testing.T) {
 		})
 		})
 	}
 	}
 }
 }
+
+// Test_populate_pricing
+//
+// Objective: To test core pricing population logic for AWS
+//
+//	Case 0: US endpoints
+//	 Take a portion of json returned from ondemand terms in us endpoints
+//	 load the request into the http response and give it to the function
+//	 inspect the resulting aws object after the function returns and validate fields
+//	Case 1: Chinese endpoints
+//	 Same as above US test case, except using CN PV offer codes
+//	 Validate populated fields in AWS object
+func Test_populate_pricing(t *testing.T) {
+	awsTest := AWS{
+		ValidPricingKeys: map[string]bool{},
+	}
+	inputkeys := map[string]bool{
+		"us-east-2,m5.large,linux": true,
+	}
+	// Case 0
+	awsUSEastString := `
+	{
+		"formatVersion" : "v1.0",
+		"disclaimer" : "This pricing list is for informational purposes only. All prices are subject to the additional terms included in the pricing pages on http://aws.amazon.com. All Free Tier prices are also subject to the terms included at https://aws.amazon.com/free/",
+		"offerCode" : "AmazonEC2",
+		"version" : "20230322145651",
+		"publicationDate" : "2023-03-22T14:56:51Z",
+		"products" : {
+			"8D49XP354UEYTHGM" : {
+				"sku" : "8D49XP354UEYTHGM",
+				"productFamily" : "Compute Instance",
+				"attributes" : {
+				  "servicecode" : "AmazonEC2",
+				  "location" : "US East (Ohio)",
+				  "locationType" : "AWS Region",
+				  "instanceType" : "m5.large",
+				  "currentGeneration" : "Yes",
+				  "instanceFamily" : "General purpose",
+				  "vcpu" : "2",
+				  "physicalProcessor" : "Intel Xeon Platinum 8175",
+				  "clockSpeed" : "3.1 GHz",
+				  "memory" : "8 GiB",
+				  "storage" : "EBS only",
+				  "networkPerformance" : "Up to 10 Gigabit",
+				  "processorArchitecture" : "64-bit",
+				  "tenancy" : "Shared",
+				  "operatingSystem" : "Linux",
+				  "licenseModel" : "No License required",
+				  "usagetype" : "USE2-BoxUsage:m5.large",
+				  "operation" : "RunInstances",
+				  "availabilityzone" : "NA",
+				  "capacitystatus" : "Used",
+				  "classicnetworkingsupport" : "false",
+				  "dedicatedEbsThroughput" : "Up to 2120 Mbps",
+				  "ecu" : "10",
+				  "enhancedNetworkingSupported" : "Yes",
+				  "gpuMemory" : "NA",
+				  "intelAvxAvailable" : "Yes",
+				  "intelAvx2Available" : "Yes",
+				  "intelTurboAvailable" : "Yes",
+				  "marketoption" : "OnDemand",
+				  "normalizationSizeFactor" : "4",
+				  "preInstalledSw" : "NA",
+				  "processorFeatures" : "Intel AVX; Intel AVX2; Intel AVX512; Intel Turbo",
+				  "regionCode" : "us-east-2",
+				  "servicename" : "Amazon Elastic Compute Cloud",
+				  "vpcnetworkingsupport" : "true"
+				}
+			},
+			"9ZEEN7WWWQKAG292" : {
+				"sku" : "9ZEEN7WWWQKAG292",
+				"productFamily" : "Compute Instance",
+				"attributes" : {
+				  "servicecode" : "AmazonEC2",
+				  "location" : "US East (Ohio)",
+				  "locationType" : "AWS Region",
+				  "instanceType" : "p3.8xlarge",
+				  "currentGeneration" : "Yes",
+				  "instanceFamily" : "GPU instance",
+				  "vcpu" : "32",
+				  "physicalProcessor" : "Intel Xeon E5-2686 v4 (Broadwell)",
+				  "clockSpeed" : "2.3 GHz",
+				  "memory" : "244 GiB",
+				  "storage" : "EBS only",
+				  "networkPerformance" : "10 Gigabit",
+				  "processorArchitecture" : "64-bit",
+				  "tenancy" : "Shared",
+				  "operatingSystem" : "Windows",
+				  "licenseModel" : "Bring your own license",
+				  "usagetype" : "USE2-BoxUsage:p3.8xlarge",
+				  "operation" : "RunInstances:0800",
+				  "availabilityzone" : "NA",
+				  "capacitystatus" : "Used",
+				  "classicnetworkingsupport" : "false",
+				  "dedicatedEbsThroughput" : "7000 Mbps",
+				  "ecu" : "97",
+				  "enhancedNetworkingSupported" : "Yes",
+				  "gpu" : "4",
+				  "gpuMemory" : "NA",
+				  "intelAvxAvailable" : "Yes",
+				  "intelAvx2Available" : "Yes",
+				  "intelTurboAvailable" : "Yes",
+				  "marketoption" : "OnDemand",
+				  "normalizationSizeFactor" : "64",
+				  "preInstalledSw" : "NA",
+				  "processorFeatures" : "Intel AVX; Intel AVX2; Intel Turbo",
+				  "regionCode" : "us-east-2",
+				  "servicename" : "Amazon Elastic Compute Cloud",
+				  "vpcnetworkingsupport" : "true"
+				}
+			},
+			"M6UGCCQ3CDJQAA37" : {
+				"sku" : "M6UGCCQ3CDJQAA37",
+				"productFamily" : "Storage",
+				"attributes" : {
+				  "servicecode" : "AmazonEC2",
+				  "location" : "US East (Ohio)",
+				  "locationType" : "AWS Region",
+				  "storageMedia" : "SSD-backed",
+				  "volumeType" : "General Purpose",
+				  "maxVolumeSize" : "16 TiB",
+				  "maxIopsvolume" : "16000",
+				  "maxThroughputvolume" : "1000 MiB/s",
+				  "usagetype" : "USE2-EBS:VolumeUsage.gp3",
+				  "operation" : "",
+				  "regionCode" : "us-east-2",
+				  "servicename" : "Amazon Elastic Compute Cloud",
+				  "volumeApiName" : "gp3"
+				}
+			  }
+		},
+		"terms" : {
+			"OnDemand" : {
+				"M6UGCCQ3CDJQAA37" : {
+					"M6UGCCQ3CDJQAA37.JRTCKXETXF" : {
+					  "offerTermCode" : "JRTCKXETXF",
+					  "sku" : "M6UGCCQ3CDJQAA37",
+					  "effectiveDate" : "2023-03-01T00:00:00Z",
+					  "priceDimensions" : {
+						"M6UGCCQ3CDJQAA37.JRTCKXETXF.6YS6EN2CT7" : {
+						  "rateCode" : "M6UGCCQ3CDJQAA37.JRTCKXETXF.6YS6EN2CT7",
+						  "description" : "$0.08 per GB-month of General Purpose (gp3) provisioned storage - US East (Ohio)",
+						  "beginRange" : "0",
+						  "endRange" : "Inf",
+						  "unit" : "GB-Mo",
+						  "pricePerUnit" : {
+							"USD" : "0.0800000000"
+						  },
+						  "appliesTo" : [ ]
+						}
+					  },
+					  "termAttributes" : { }
+					}
+				},
+				"9ZEEN7WWWQKAG292" : {
+					"9ZEEN7WWWQKAG292.JRTCKXETXF" : {
+					  "offerTermCode" : "JRTCKXETXF",
+					  "sku" : "9ZEEN7WWWQKAG292",
+					  "effectiveDate" : "2023-03-01T00:00:00Z",
+					  "priceDimensions" : {
+						"9ZEEN7WWWQKAG292.JRTCKXETXF.6YS6EN2CT7" : {
+						  "rateCode" : "9ZEEN7WWWQKAG292.JRTCKXETXF.6YS6EN2CT7",
+						  "description" : "$12.24 per On Demand Windows BYOL p3.8xlarge Instance Hour",
+						  "beginRange" : "0",
+						  "endRange" : "Inf",
+						  "unit" : "Hrs",
+						  "pricePerUnit" : {
+							"USD" : "12.2400000000"
+						  },
+						  "appliesTo" : [ ]
+						}
+					  },
+					  "termAttributes" : { }
+					}
+				},
+				"8D49XP354UEYTHGM" : {
+					"8D49XP354UEYTHGM.MZU6U2429S" : {
+					  "offerTermCode" : "MZU6U2429S",
+					  "sku" : "8D49XP354UEYTHGM",
+					  "effectiveDate" : "2019-01-01T00:00:00Z",
+					  "priceDimensions" : {
+						"8D49XP354UEYTHGM.MZU6U2429S.2TG2D8R56U" : {
+						  "rateCode" : "8D49XP354UEYTHGM.MZU6U2429S.2TG2D8R56U",
+						  "description" : "Upfront Fee",
+						  "unit" : "Quantity",
+						  "pricePerUnit" : {
+							"USD" : "1161"
+						  },
+						  "appliesTo" : [ ]
+						},
+					  },
+					  "termAttributes" : {
+						"LeaseContractLength" : "3yr",
+						"OfferingClass" : "convertible",
+						"PurchaseOption" : "All Upfront"
+					  }
+					}
+				}
+			}
+		},
+		"attributesList" : { }
+	}
+	`
+
+	testResponse := http.Response{
+		Body: ioutil.NopCloser(bytes.NewBufferString(awsUSEastString)),
+		Request: &http.Request{
+			URL: &url.URL{
+				Scheme: "https",
+				Host:   "test-aws-http-endpoint:443",
+			},
+		},
+	}
+
+	awsTest.populatePricing(&testResponse, inputkeys)
+
+	expectedProdTermsDisk := &AWSProductTerms{
+		Sku:     "M6UGCCQ3CDJQAA37",
+		Memory:  "",
+		Storage: "",
+		VCpu:    "",
+		GPU:     "",
+		OnDemand: &AWSOfferTerm{
+			Sku:           "M6UGCCQ3CDJQAA37",
+			OfferTermCode: "JRTCKXETXF",
+			PriceDimensions: map[string]*AWSRateCode{
+				"M6UGCCQ3CDJQAA37.JRTCKXETXF.6YS6EN2CT7": &AWSRateCode{
+					Unit: "GB-Mo",
+					PricePerUnit: AWSCurrencyCode{
+						USD: "0.0800000000",
+						CNY: "",
+					},
+				},
+			},
+		},
+		PV: &PV{
+			Cost:       "0.00010958904109589041",
+			CostPerIO:  "",
+			Class:      "gp3",
+			Size:       "",
+			Region:     "us-east-2",
+			ProviderID: "",
+		},
+	}
+
+	expectedProdTermsInstanceOndemand := &AWSProductTerms{
+		Sku:     "8D49XP354UEYTHGM",
+		Memory:  "8 GiB",
+		Storage: "EBS only",
+		VCpu:    "2",
+		GPU:     "",
+		OnDemand: &AWSOfferTerm{
+			Sku:             "",
+			OfferTermCode:   "",
+			PriceDimensions: nil,
+		},
+	}
+
+	expectedProdTermsInstanceSpot := &AWSProductTerms{
+		Sku:     "8D49XP354UEYTHGM",
+		Memory:  "8 GiB",
+		Storage: "EBS only",
+		VCpu:    "2",
+		GPU:     "",
+		OnDemand: &AWSOfferTerm{
+			Sku:             "",
+			OfferTermCode:   "",
+			PriceDimensions: nil,
+		},
+	}
+
+	expectedPricing := map[string]*AWSProductTerms{
+		"us-east-2,EBS:VolumeUsage.gp3":             expectedProdTermsDisk,
+		"us-east-2,EBS:VolumeUsage.gp3,preemptible": expectedProdTermsDisk,
+		"us-east-2,m5.large,linux":                  expectedProdTermsInstanceOndemand,
+		"us-east-2,m5.large,linux,preemptible":      expectedProdTermsInstanceSpot,
+	}
+
+	if !reflect.DeepEqual(expectedPricing, awsTest.Pricing) {
+		t.Fatalf("expected parsed pricing did not match actual parsed result (us-east-1)")
+	}
+
+	// Case 1
+	awsCnString := `
+	{
+		"formatVersion" : "v1.0",
+		"disclaimer" : "This pricing list is for informational purposes only. All prices are subject to the additional terms included in the pricing pages on http://www.amazonaws.cn.",
+		"offerCode" : "AmazonEC2",
+		"version" : "20230314154740",
+		"publicationDate" : "2023-03-14T15:47:40Z",
+		"products" : {
+			"R83VXG9NAPDASEGN" : {
+				"sku" : "R83VXG9NAPDASEGN",
+				"productFamily" : "Storage",
+				"attributes" : {
+				  "servicecode" : "AmazonEC2",
+				  "location" : "China (Ningxia)",
+				  "locationType" : "AWS Region",
+				  "storageMedia" : "SSD-backed",
+				  "volumeType" : "General Purpose",
+				  "maxVolumeSize" : "16 TiB",
+				  "maxIopsvolume" : "16000",
+				  "maxThroughputvolume" : "1000 MiB/s",
+				  "usagetype" : "CNW1-EBS:VolumeUsage.gp3",
+				  "operation" : "",
+				  "regionCode" : "cn-northwest-1",
+				  "servicename" : "Amazon Elastic Compute Cloud",
+				  "volumeApiName" : "gp3"
+				}
+			}
+		},
+		"terms" : {
+			"OnDemand" : {
+			  "R83VXG9NAPDASEGN" : {
+				"R83VXG9NAPDASEGN.5Y9WH78GDR" : {
+				  "offerTermCode" : "5Y9WH78GDR",
+				  "sku" : "R83VXG9NAPDASEGN",
+				  "effectiveDate" : "2023-03-01T00:00:00Z",
+				  "priceDimensions" : {
+					"R83VXG9NAPDASEGN.5Y9WH78GDR.Q7UJUT2CE6" : {
+					  "rateCode" : "R83VXG9NAPDASEGN.5Y9WH78GDR.Q7UJUT2CE6",
+					  "description" : "0.5312 CNY per GB-month of General Purpose (gp3) provisioned storage - China (Ningxia)",
+					  "beginRange" : "0",
+					  "endRange" : "Inf",
+					  "unit" : "GB-Mo",
+					  "pricePerUnit" : {
+						"CNY" : "0.5312000000"
+					  },
+					  "appliesTo" : [ ]
+					}
+				  },
+				  "termAttributes" : { }
+				}
+			  }
+			}
+	    },
+	  "attributesList" : { }
+	}
+	`
+	awsTest = AWS{
+		ValidPricingKeys: map[string]bool{},
+	}
+
+	testResponse = http.Response{
+		Body: ioutil.NopCloser(bytes.NewBufferString(awsCnString)),
+		Request: &http.Request{
+			URL: &url.URL{
+				Scheme: "https",
+				Host:   "test-aws-http-endpoint:443",
+			},
+		},
+	}
+
+	awsTest.populatePricing(&testResponse, inputkeys)
+
+	expectedProdTermsDisk = &AWSProductTerms{
+		Sku:     "R83VXG9NAPDASEGN",
+		Memory:  "",
+		Storage: "",
+		VCpu:    "",
+		GPU:     "",
+		OnDemand: &AWSOfferTerm{
+			Sku:           "R83VXG9NAPDASEGN",
+			OfferTermCode: "5Y9WH78GDR",
+			PriceDimensions: map[string]*AWSRateCode{
+				"R83VXG9NAPDASEGN.5Y9WH78GDR.Q7UJUT2CE6": &AWSRateCode{
+					Unit: "GB-Mo",
+					PricePerUnit: AWSCurrencyCode{
+						USD: "",
+						CNY: "0.5312000000",
+					},
+				},
+			},
+		},
+		PV: &PV{
+			Cost:       "0.0007276712328767123",
+			CostPerIO:  "",
+			Class:      "gp3",
+			Size:       "",
+			Region:     "cn-northwest-1",
+			ProviderID: "",
+		},
+	}
+
+	expectedPricing = map[string]*AWSProductTerms{
+		"cn-northwest-1,EBS:VolumeUsage.gp3":             expectedProdTermsDisk,
+		"cn-northwest-1,EBS:VolumeUsage.gp3,preemptible": expectedProdTermsDisk,
+	}
+
+	if !reflect.DeepEqual(expectedPricing, awsTest.Pricing) {
+		t.Fatalf("expected parsed pricing did not match actual parsed result (cn)")
+	}
+
+}

+ 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 {
 				if err != nil {
 					return nil, "", err
 					return nil, "", err
 				}
 				}
+
 				usageType := strings.ToLower(product.Category.UsageType)
 				usageType := strings.ToLower(product.Category.UsageType)
 				instanceType := strings.ToLower(product.Category.ResourceGroup)
 				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") {
 				if (instanceType == "ram" || instanceType == "cpu") && strings.Contains(strings.ToUpper(product.Description), "COMPUTE OPTIMIZED") {
 					instanceType = "c2standard"
 					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 {
 				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+","+"e2micro"+","+usageType)
 						candidateKeys = append(candidateKeys, region+","+"e2small"+","+usageType)
 						candidateKeys = append(candidateKeys, region+","+"e2small"+","+usageType)
 						candidateKeys = append(candidateKeys, region+","+"e2medium"+","+usageType)
 						candidateKeys = append(candidateKeys, region+","+"e2medium"+","+usageType)
 						candidateKeys = append(candidateKeys, region+","+"e2standard"+","+usageType)
 						candidateKeys = append(candidateKeys, region+","+"e2standard"+","+usageType)
 						candidateKeys = append(candidateKeys, region+","+"e2custom"+","+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
 						candidateKey := region + "," + instanceType + "," + usageType
 						candidateKeys = append(candidateKeys, candidateKey)
 						candidateKeys = append(candidateKeys, candidateKey)
 					}
 					}
@@ -795,12 +804,28 @@ func (gcp *GCP) parsePage(r io.Reader, inputKeys map[string]Key, pvKeys map[stri
 					if gpuType != "" {
 					if gpuType != "" {
 						lastRateIndex := len(product.PricingInfo[0].PricingExpression.TieredRates) - 1
 						lastRateIndex := len(product.PricingInfo[0].PricingExpression.TieredRates) - 1
 						var nanos float64
 						var nanos float64
+						var unitsBaseCurrency int
 						if lastRateIndex > -1 && len(product.PricingInfo) > 0 {
 						if lastRateIndex > -1 && len(product.PricingInfo) > 0 {
 							nanos = product.PricingInfo[0].PricingExpression.TieredRates[lastRateIndex].UnitPrice.Nanos
 							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 {
 						} else {
 							continue
 							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 {
 						for k, key := range inputKeys {
 							if key.GPUType() == gpuType+","+usageType {
 							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 {
 						if ok || ok2 {
 							lastRateIndex := len(product.PricingInfo[0].PricingExpression.TieredRates) - 1
 							lastRateIndex := len(product.PricingInfo[0].PricingExpression.TieredRates) - 1
 							var nanos float64
 							var nanos float64
+							var unitsBaseCurrency int
 							if lastRateIndex > -1 && len(product.PricingInfo) > 0 {
 							if lastRateIndex > -1 && len(product.PricingInfo) > 0 {
 								nanos = product.PricingInfo[0].PricingExpression.TieredRates[lastRateIndex].UnitPrice.Nanos
 								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 {
 							} else {
 								continue
 								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 {
 							if hourlyPrice == 0 {
 								continue
 								continue

+ 253 - 0
pkg/cloud/gcpprovider_test.go

@@ -1,6 +1,9 @@
 package cloud
 package cloud
 
 
 import (
 import (
+	"bytes"
+	"io/ioutil"
+	"reflect"
 	"testing"
 	"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()
 	rootMux := http.NewServeMux()
 	a.Router.GET("/healthz", Healthz)
 	a.Router.GET("/healthz", Healthz)
+	a.Router.GET("/allocation", a.ComputeAllocationHandler)
 	a.Router.GET("/allocation/summary", a.ComputeAllocationHandlerSummary)
 	a.Router.GET("/allocation/summary", a.ComputeAllocationHandlerSummary)
 	rootMux.Handle("/", a.Router)
 	rootMux.Handle("/", a.Router)
 	rootMux.Handle("/metrics", promhttp.Handler())
 	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
 		if durMins%60 != 0 || durMins < 3*60 { // not divisible by 1h or less than 3h
 			resolution = time.Minute
 			resolution = time.Minute
 		}
 		}
-	} else {                    // greater than 1d
+	} else { // greater than 1d
 		if durMins >= 7*24*60 { // greater than (or equal to) 7 days
 		if durMins >= 7*24*60 { // greater than (or equal to) 7 days
 			resolution = 24.0 * time.Hour
 			resolution = 24.0 * time.Hour
 		} else if durMins >= 2*24*60 { // greater than (or equal to) 2 days
 		} 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)
 		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.
 	// Step is an optional parameter that defines the duration per-set, i.e.
 	// the window for an AllocationSet, of the AllocationSetRange to be
 	// the window for an AllocationSet, of the AllocationSetRange to be
 	// computed. Defaults to the window size, making one set.
 	// computed. Defaults to the window size, making one set.
 	step := qp.GetDuration("step", window.Duration())
 	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
 	// Aggregation is an optional comma-separated list of fields by which to
 	// aggregate results. Some fields allow a sub-field, which is distinguished
 	// aggregate results. Some fields allow a sub-field, which is distinguished
 	// with a colon; e.g. "label:app".
 	// 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)
 		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()))
 			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
 		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)`
 	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)`
 	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)`
 	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)`
 	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)`
 	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)`
 	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)`
 	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)`
 	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]`
 	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
 // 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())
 	queryCPUUsageAvg := fmt.Sprintf(queryFmtCPUUsageAvg, durStr, env.GetPromClusterLabel())
 	resChCPUUsageAvg := ctx.QueryAtTime(queryCPUUsageAvg, end)
 	resChCPUUsageAvg := ctx.QueryAtTime(queryCPUUsageAvg, end)
 
 
-	queryCPUUsageMax := fmt.Sprintf(queryFmtCPUUsageMax, durStr, env.GetPromClusterLabel())
+	queryCPUUsageMax := fmt.Sprintf(queryFmtCPUUsageMaxRecordingRule, durStr, env.GetPromClusterLabel())
 	resChCPUUsageMax := ctx.QueryAtTime(queryCPUUsageMax, end)
 	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())
 	queryGPUsRequested := fmt.Sprintf(queryFmtGPUsRequested, durStr, env.GetPromClusterLabel())
 	resChGPUsRequested := ctx.QueryAtTime(queryGPUsRequested, end)
 	resChGPUsRequested := ctx.QueryAtTime(queryGPUsRequested, end)
@@ -449,7 +492,6 @@ func (cm *CostModel) computeAllocation(start, end time.Time, resolution time.Dur
 	resCPUCoresAllocated, _ := resChCPUCoresAllocated.Await()
 	resCPUCoresAllocated, _ := resChCPUCoresAllocated.Await()
 	resCPURequests, _ := resChCPURequests.Await()
 	resCPURequests, _ := resChCPURequests.Await()
 	resCPUUsageAvg, _ := resChCPUUsageAvg.Await()
 	resCPUUsageAvg, _ := resChCPUUsageAvg.Await()
-	resCPUUsageMax, _ := resChCPUUsageMax.Await()
 	resRAMBytesAllocated, _ := resChRAMBytesAllocated.Await()
 	resRAMBytesAllocated, _ := resChRAMBytesAllocated.Await()
 	resRAMRequests, _ := resChRAMRequests.Await()
 	resRAMRequests, _ := resChRAMRequests.Await()
 	resRAMUsageAvg, _ := resChRAMUsageAvg.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
 package costmodel
 
 
 import (
 import (
+	"errors"
 	"fmt"
 	"fmt"
 	"math"
 	"math"
 	"regexp"
 	"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.
 			} else { // add case to use default pricing model when API data fails.
 				log.Debugf("No node price or CPUprice found, falling back to default")
 				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) {
 			if math.IsNaN(nodePrice) {
 				log.Warnf("nodePrice parsed as NaN. Setting to 0.")
 				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))
 		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
 	// 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.
 	// marshalled as null rather than as an object with Go default values.
 	RawAllocationOnly *RawAllocationOnlyData `json:"rawAllocationOnly"`
 	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,
 // 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)
 		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
 // GetWindow returns the window of the struct
 func (a *Allocation) GetWindow() Window {
 func (a *Allocation) GetWindow() Window {
 	return a.Window
 	return a.Window
@@ -715,6 +771,12 @@ func (a *Allocation) add(that *Allocation) {
 	// Preserve string properties that are matching between the two allocations
 	// Preserve string properties that are matching between the two allocations
 	a.Properties = a.Properties.Intersection(that.Properties)
 	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
 	// Overwrite regular intersection logic for the controller name property in the
 	// case that the Allocation keys are the same but the controllers are not.
 	// case that the Allocation keys are the same but the controllers are not.
 	if leftKey == rightKey &&
 	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
 // succeeds, the allocation is marked as a shared resource. ShareIdle is a
 // simple flag for sharing idle resources.
 // simple flag for sharing idle resources.
 type AllocationAggregationOptions struct {
 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
 // 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.
 	//     Also, create the aggSet into which the results will be aggregated.
 	//
 	//
 	//  2. Compute sharing coefficients for idle and shared resources
 	//  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
 	//        idle filtration coefficients for the purpose of only returning the
 	//        portion of idle allocation that would have been shared with the
 	//        portion of idle allocation that would have been shared with the
 	//        unfiltered results. (See unit tests 5.a,b,c)
 	//        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)
 	//        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
 	//  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
 	shouldAggregate := aggregateBy != nil
 	shouldFilter := options.Filter != nil
 	shouldFilter := options.Filter != nil
 	shouldShare := len(options.SharedHourlyCosts) > 0 || len(options.ShareFuncs) > 0
 	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
 		// There is nothing for AggregateBy to do, so simply return nil
 		return nil
 		return nil
 	}
 	}
@@ -958,6 +1025,12 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 		Window: as.Window.Clone(),
 		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
 	// shareSet will be shared among aggSet after initial aggregation
 	// is complete
 	// is complete
 	shareSet := &AllocationSet{
 	shareSet := &AllocationSet{
@@ -991,6 +1064,12 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 				aggSet.Insert(alloc)
 				aggSet.Insert(alloc)
 			}
 			}
 
 
+			// build a parallel set of allocations to only be used
+			// for computing PARCs
+			if options.IncludeProportionalAssetResourceCosts {
+				parcSet.Insert(alloc.Clone())
+			}
+
 			continue
 			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
 	// 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
 	// 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
 	// 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
 	// come after idle coefficients are computed so that allocations generated
 	// by shared overhead do not skew the idle coefficient computation.
 	// by shared overhead do not skew the idle coefficient computation.
 	for name, cost := range options.SharedHourlyCosts {
 	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
 	// after idle coefficients, and are computed for the aggregated allocations
 	// of the main allocation set. See above for details and an example.
 	// of the main allocation set. See above for details and an example.
 	var shareCoefficients map[string]float64
 	var shareCoefficients map[string]float64
@@ -1624,6 +1734,34 @@ func computeIdleCoeffs(options *AllocationAggregationOptions, as *AllocationSet,
 	return coeffs, totals, nil
 	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
 // 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
 // option in the AllocationAggregationOptions and an error if the respective field is missing
 func (a *Allocation) getIdleId(options *AllocationAggregationOptions) (string, error) {
 func (a *Allocation) getIdleId(options *AllocationAggregationOptions) (string, error) {

+ 49 - 45
pkg/kubecost/allocation_json.go

@@ -2,57 +2,59 @@ package kubecost
 
 
 import (
 import (
 	"fmt"
 	"fmt"
-	"github.com/opencost/opencost/pkg/util/json"
 	"math"
 	"math"
 	"time"
 	"time"
+
+	"github.com/opencost/opencost/pkg/util/json"
 )
 )
 
 
 // AllocationJSON  exists because there are expected JSON response fields
 // AllocationJSON  exists because there are expected JSON response fields
 // that are calculated values from methods on an annotation
 // that are calculated values from methods on an annotation
 type AllocationJSON struct {
 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) {
 func (aj *AllocationJSON) BuildFromAllocation(a *Allocation) {
@@ -64,7 +66,7 @@ func (aj *AllocationJSON) BuildFromAllocation(a *Allocation) {
 	aj.Window = a.Window
 	aj.Window = a.Window
 	aj.Start = a.Start.UTC().Format(time.RFC3339)
 	aj.Start = a.Start.UTC().Format(time.RFC3339)
 	aj.End = a.End.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.CPUCores = formatFloat64ForResponse(a.CPUCores())
 	aj.CPUCoreRequestAverage = formatFloat64ForResponse(a.CPUCoreRequestAverage)
 	aj.CPUCoreRequestAverage = formatFloat64ForResponse(a.CPUCoreRequestAverage)
 	aj.CPUCoreUsageAverage = formatFloat64ForResponse(a.CPUCoreUsageAverage)
 	aj.CPUCoreUsageAverage = formatFloat64ForResponse(a.CPUCoreUsageAverage)
@@ -102,6 +104,8 @@ func (aj *AllocationJSON) BuildFromAllocation(a *Allocation) {
 	aj.TotalCost = formatFloat64ForResponse(a.TotalCost())
 	aj.TotalCost = formatFloat64ForResponse(a.TotalCost())
 	aj.TotalEfficiency = formatFloat64ForResponse(a.TotalEfficiency())
 	aj.TotalEfficiency = formatFloat64ForResponse(a.TotalEfficiency())
 	aj.RawAllocationOnly = a.RawAllocationOnly
 	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
 // 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 (
 import (
 	"fmt"
 	"fmt"
 	"math"
 	"math"
+	"reflect"
 	"testing"
 	"testing"
 	"time"
 	"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) {
 func assertAllocationTotals(t *testing.T, as *AllocationSet, msg string, exps map[string]float64) {
 	for _, a := range as.Allocations {
 	for _, a := range as.Allocations {
 		if exp, ok := exps[a.Name]; ok {
 		if exp, ok := exps[a.Name]; ok {
@@ -690,15 +704,16 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 
 
 	// Tests:
 	// Tests:
 	cases := map[string]struct {
 	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
 		// 1  Single-aggregation
 
 
@@ -1042,8 +1057,9 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
 			aggOpts: &AllocationAggregationOptions{
-				ShareFuncs: []AllocationMatchFunc{isNamespace3},
-				ShareSplit: ShareWeighted,
+				ShareFuncs:                            []AllocationMatchFunc{isNamespace3},
+				ShareSplit:                            ShareWeighted,
+				IncludeProportionalAssetResourceCosts: true,
 			},
 			},
 			numResults: numNamespaces,
 			numResults: numNamespaces,
 			totalCost:  activeTotalCost + idleTotalCost,
 			totalCost:  activeTotalCost + idleTotalCost,
@@ -1055,6 +1071,36 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			windowStart: startYesterday,
 			windowStart: startYesterday,
 			windowEnd:   endYesterday,
 			windowEnd:   endYesterday,
 			expMinutes:  1440.0,
 			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
 		// 4c Share label ShareEven
 		// namespace1: 17.3333 = 28.00 - 16.00 + 16.00*(1.0/3.0)
 		// 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,
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
 			aggOpts: &AllocationAggregationOptions{
-				IdleByNode: true,
+				IdleByNode:                            true,
+				IncludeProportionalAssetResourceCosts: true,
 			},
 			},
 			numResults: numNamespaces + numIdle,
 			numResults: numNamespaces + numIdle,
 			totalCost:  activeTotalCost + idleTotalCost,
 			totalCost:  activeTotalCost + idleTotalCost,
@@ -1459,6 +1506,70 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			windowStart: startYesterday,
 			windowStart: startYesterday,
 			windowEnd:   endYesterday,
 			windowEnd:   endYesterday,
 			expMinutes:  1440.0,
 			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 Split Idle, Idle by Node
 		"6k": {
 		"6k": {
@@ -1524,6 +1635,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			err = as.AggregateBy(testcase.aggBy, testcase.aggOpts)
 			err = as.AggregateBy(testcase.aggBy, testcase.aggOpts)
 			assertAllocationSetTotals(t, as, name, err, testcase.numResults, testcase.totalCost)
 			assertAllocationSetTotals(t, as, name, err, testcase.numResults, testcase.totalCost)
 			assertAllocationTotals(t, as, name, testcase.results)
 			assertAllocationTotals(t, as, name, testcase.results)
+			assertParcResults(t, as, name, testcase.expectedParcResults)
 			assertAllocationWindow(t, as, name, testcase.windowStart, testcase.windowEnd, testcase.expMinutes)
 			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,
 			CPUCoreUsageMax:  1,
 			RAMBytesUsageMax: 1,
 			RAMBytesUsageMax: 1,
 		},
 		},
+		ProportionalAssetResourceCosts: nil,
 	}
 	}
 
 
 	// If idle allocation, remove non-idle costs, but maintain total cost
 	// 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
 	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 {
 func (sa *SummaryAllocation) generateKey(aggregateBy []string, labelConfig *LabelConfig) string {
 	if sa == nil {
 	if sa == nil {
 		return ""
 		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.
 // IdleAllocations returns a map of the idle allocations in the AllocationSet.
 func (sas *SummaryAllocationSet) idleAllocations() map[string]*SummaryAllocation {
 func (sas *SummaryAllocationSet) idleAllocations() map[string]*SummaryAllocation {
 	idles := 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
 // Append appends the given AllocationSet to the end of the range. It does not
 // validate whether or not that violates window continuity.
 // validate whether or not that violates window continuity.
-func (sasr *SummaryAllocationSetRange) Append(sas *SummaryAllocationSet) error {
-
+func (sasr *SummaryAllocationSetRange) Append(sas *SummaryAllocationSet) {
 	sasr.Lock()
 	sasr.Lock()
 	defer sasr.Unlock()
 	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())) {
 	if sasr.Window.End() == nil || (sas.Window.End() != nil && sas.Window.End().After(*sasr.Window.End())) {
 		sasr.Window.end = sas.Window.End()
 		sasr.Window.end = sas.Window.End()
 	}
 	}
-
-	return nil
 }
 }
 
 
 // Each invokes the given function for each AllocationSet in the range
 // 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) {
 func (sasr *SummaryAllocationSetRange) accumulateByNone() (*SummaryAllocationSetRange, error) {
-	result, err := sasr.clone()
-	return result, err
+	return sasr.clone(), nil
 }
 }
 
 
 func (sasr *SummaryAllocationSetRange) accumulateByAll() (*SummaryAllocationSetRange, error) {
 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())
 		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) {
 func (sasr *SummaryAllocationSetRange) accumulateByDay() (*SummaryAllocationSetRange, error) {
@@ -1560,10 +1655,7 @@ func (sasr *SummaryAllocationSetRange) accumulateByDay() (*SummaryAllocationSetR
 			as = as.Clone()
 			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()
 		sas, err := toAccumulate.accumulate()
 		if err != nil {
 		if err != nil {
 			return nil, fmt.Errorf("error accumulating result: %s", err)
 			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 {
 			if length := len(toAccumulate.SummaryAllocationSets); length != 1 {
 				return nil, fmt.Errorf("failed accumulation, detected %d sets instead of 1", length)
 				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
 			toAccumulate = nil
 		}
 		}
 	}
 	}
@@ -1601,10 +1690,7 @@ func (sasr *SummaryAllocationSetRange) accumulateByMonth() (*SummaryAllocationSe
 			as = as.Clone()
 			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()
 		sas, err := toAccumulate.accumulate()
 		if err != nil {
 		if err != nil {
@@ -1618,10 +1704,7 @@ func (sasr *SummaryAllocationSetRange) accumulateByMonth() (*SummaryAllocationSe
 			if length := len(toAccumulate.SummaryAllocationSets); length != 1 {
 			if length := len(toAccumulate.SummaryAllocationSets); length != 1 {
 				return nil, fmt.Errorf("failed accumulation, detected %d sets instead of 1", length)
 				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
 			toAccumulate = nil
 		}
 		}
 	}
 	}
@@ -1647,10 +1730,7 @@ func (sasr *SummaryAllocationSetRange) accumulateByWeek() (*SummaryAllocationSet
 			as = as.Clone()
 			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()
 		sas, err := toAccumulate.accumulate()
 		if err != nil {
 		if err != nil {
 			return nil, fmt.Errorf("error accumulating result: %s", err)
 			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 {
 			if length := len(toAccumulate.SummaryAllocationSets); length != 1 {
 				return nil, fmt.Errorf("failed accumulation, detected %d sets instead of 1", length)
 				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
 			toAccumulate = nil
 		}
 		}
 	}
 	}
 	return result, nil
 	return result, nil
 }
 }
 
 
+func (sasr *SummaryAllocationSetRange) Clone() *SummaryAllocationSetRange {
+	return sasr.clone()
+}
+
 // clone returns a new SummaryAllocationSetRange cloned from the existing SASR
 // clone returns a new SummaryAllocationSetRange cloned from the existing SASR
-func (sasr *SummaryAllocationSetRange) clone() (*SummaryAllocationSetRange, error) {
+func (sasr *SummaryAllocationSetRange) clone() *SummaryAllocationSetRange {
 	sasrSource := NewSummaryAllocationSetRange()
 	sasrSource := NewSummaryAllocationSetRange()
 	sasrSource.Window = sasr.Window.Clone()
 	sasrSource.Window = sasr.Window.Clone()
 	sasrSource.Step = sasr.Step
 	sasrSource.Step = sasr.Step
@@ -1685,11 +1766,8 @@ func (sasr *SummaryAllocationSetRange) clone() (*SummaryAllocationSetRange, erro
 			sasClone = sas.Clone()
 			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
 package kubecost
 
 
 import (
 import (
-	"math"
 	"time"
 	"time"
+
+	"github.com/opencost/opencost/pkg/util/formatutil"
 )
 )
 
 
 // SummaryAllocationResponse is a sanitized version of SummaryAllocation, which
 // SummaryAllocationResponse is a sanitized version of SummaryAllocation, which
@@ -36,27 +37,19 @@ func (sa *SummaryAllocation) ToResponse() *SummaryAllocationResponse {
 		Name:                   sa.Name,
 		Name:                   sa.Name,
 		Start:                  sa.Start,
 		Start:                  sa.Start,
 		End:                    sa.End,
 		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,
 // 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
 	// CPUThrottlingDiagnosticMetricID is the identifier for the metric used to determine if CPU throttling is being applied to the
 	// cost-model container.
 	// cost-model container.
 	CPUThrottlingDiagnosticMetricID = "cpuThrottling"
 	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"
 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",
 		Label:       "Kubecost is not CPU throttled",
 		Description: "Kubecost loading slowly? A kubecost component might be 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
 // 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
 	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
+}