Browse Source

Merge branch 'develop' into niko/CORE-129/proportional-asset-cost

Alex Meijer 3 years ago
parent
commit
9c8c54da51

+ 0 - 32
.circleci/config.yml

@@ -1,32 +0,0 @@
-version: 2
-jobs:
-  build:
-    machine: true
-    steps:
-      - checkout
-      - run: |
-          TAG=0.1.$CIRCLE_BUILD_NUM
-          docker login -u $DOCKER_USER -p $DOCKER_PASS
-          docker buildx build -t ajaytripathy/kubecost-cost-model:$TAG .
-          docker push ajaytripathy/kubecost-cost-model:$TAG
-  deploy:
-    machine: true
-    steps:
-      - checkout
-      - run: |
-          docker login -u $DOCKER_USER -p $DOCKER_PASS
-          docker buildx build -t ajaytripathy/kubecost-cost-model:latest .
-          docker push ajaytripathy/kubecost-cost-model:latest
-
-workflows:
-  version: 2
-  build-and-deploy:
-    jobs:
-      - build
-      - deploy:
-          requires:
-            - build
-          filters:
-            branches:
-              only:
-                - master

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

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

+ 6 - 5
ROADMAP.md

@@ -1,13 +1,14 @@
-The following items are outstanding for the open source cost-model:
+The following items are considered the current OpenCost roadmap.
 
-__2022 roadmap__
+__2023 roadmap__
 
 * Improved testing frameworks for backend APIs as well as frontend UI
 * Add conformance tests to confirm implementation meets standards
-* Deeper billing integrations with other cloud providers
+* Deeper billing integrations with other cloud providers, e.g. Alibaba
+* Add external cloud asset cost monitoring ([see the current working group](https://docs.google.com/document/d/1-d-Vvy1VGHW0sXKiTjTplIUEnrElIlnfMU8sUpEehlA/edit#heading=h.vmcygvd1xmbm))
 * More accessible & improved user interface
-* Improved support from open source community helm chart
-* More robust API documentation
+* Continued improvement of the [OpenCost Helm chart](https://github.com/opencost/opencost-helm-chart)
+* More robust [API documentation](https://www.opencost.io/api) and examples.
 * Expose carbon emission ratings
 
 Please contact us at opencost@kubecost.com if you're interest in more detail.

+ 1 - 1
docs/swagger.json

@@ -69,7 +69,7 @@
           {
           "name": "aggregate",
           "in": "query",
-          "description": "Field by which to aggregate the results. Accepts: `cluster`, `namespace`, `controllerKind`, `controller`, `service`, `label:<name>`, and `annotation:<name>`. Also accepts comma-separated lists for multi-aggregation, like `namespace,label:app`.",
+          "description": "Field by which to aggregate the results. Accepts: `cluster`, `node`, `namespace`, `controllerKind`, `controller`, `service`, `pod`, `container`, `label:<name>`, and `annotation:<name>`. Also accepts comma-separated lists for multi-aggregation, like `namespace,label:app`.",
           "required": false,
           "style": "form",
           "explode": true,

+ 3 - 3
kubernetes/opencost.yaml

@@ -1,7 +1,7 @@
 # <https://www.opencost.io/docs/>
 ---
 
-# The namespace opencost will run in
+# The namespace OpenCost will run in
 apiVersion: v1
 kind: Namespace
 metadata:
@@ -15,7 +15,7 @@ metadata:
   name: opencost
 ---
 
-# Cluster role giving opencost to get, list, watch required recources
+# Cluster role giving OpenCost to get, list, watch required resources
 # No write permissions are required
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRole
@@ -173,7 +173,7 @@ spec:
 #
 # Without a Prometheus endpoint configured in the deployment,
 # only opencost/metrics will have useful data as it is intended
-# to be used as just an exporter.
+# to be used as only an exporter.
 kind: Service
 apiVersion: v1
 metadata:

+ 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
 	provIdRx      = regexp.MustCompile("aws:///([^/]+)/([^/]+)")
 	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])")
 )
 
@@ -257,6 +257,7 @@ type AWSPricingTerms struct {
 // AWSOfferTerm is a sku extension used to pay for the node.
 type AWSOfferTerm struct {
 	Sku             string                  `json:"sku"`
+	OfferTermCode   string                  `json:"offerTermCode"`
 	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
 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
-const HourlyRateCode = ".6YS6EN2CT7"
-const HourlyRateCodeCn = ".Q7UJUT2CE6"
+const HourlyRateCode = "6YS6EN2CT7"
+const HourlyRateCodeCn = "Q7UJUT2CE6"
 
 // volTypes are used to map between AWS UsageTypes and
 // 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",
 }
 
-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 awsSecret *AWSAccessKey = nil
 
@@ -923,19 +900,53 @@ func (aws *AWS) DownloadPricingData() error {
 		}
 	}
 
-	aws.Pricing = make(map[string]*AWSProductTerms)
 	aws.ValidPricingKeys = make(map[string]bool)
-	skusToKeys := make(map[string]string)
 
 	resp, pricingURL, err := aws.getRegionPricing(nodeList)
 	if err != nil {
 		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)
 	for {
 		t, err := dec.Token()
 		if err == io.EOF {
-			log.Infof("done loading \"%s\"\n", pricingURL)
+			log.Infof("done loading \"%s\"\n", resp.Request.URL.String())
 			break
 		} else if err != nil {
 			log.Errorf("error parsing response json %v", resp.Body)
@@ -955,7 +966,7 @@ func (aws *AWS) DownloadPricingData() error {
 
 				err = dec.Decode(&product)
 				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
 				}
 
@@ -1024,7 +1035,8 @@ func (aws *AWS) DownloadPricingData() error {
 					if err != nil {
 						return err
 					}
-					skuOnDemand, err := dec.Token()
+					// SKUOndemand
+					_, err = dec.Token()
 					if err != nil {
 						return err
 					}
@@ -1040,10 +1052,10 @@ func (aws *AWS) DownloadPricingData() error {
 						aws.Pricing[key].OnDemand = offerTerm
 						aws.Pricing[spotKey].OnDemand = offerTerm
 						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 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
 }
 
@@ -1268,12 +1254,12 @@ func (aws *AWS) createNode(terms *AWSProductTerms, usageType string, k Key) (*No
 
 	}
 	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 {
 		cost = c.PricePerUnit.USD
 	} else {
 		// 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 {
 			cost = c.PricePerUnit.CNY
 		} else {
@@ -1427,7 +1413,7 @@ func (aws *AWS) getAWSAuth(forceReload bool, cp *CustomPricing) (string, string)
 	}
 
 	// 3. Fall back to env vars
-	if env.GetAWSAccessKeyID() == "" || env.GetAWSAccessKeyID() == "" {
+	if env.GetAWSAccessKeyID() == "" || env.GetAWSAccessKeySecret() == "" {
 		aws.serviceAccountChecks.set("hasKey", &ServiceAccountCheck{
 			Message: "AWS ServiceKey exists",
 			Status:  false,

+ 402 - 1
pkg/cloud/awsprovider_test.go

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

+ 253 - 0
pkg/cloud/gcpprovider_test.go

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

+ 1 - 1
pkg/costmodel/costmodel.go

@@ -1217,7 +1217,7 @@ func (cm *CostModel) GetNodeCost(cp costAnalyzerCloud.Provider) (map[string]*cos
 				}
 			} else { // add case to use default pricing model when API data fails.
 				log.Debugf("No node price or CPUprice found, falling back to default")
-				nodePrice = defaultCPU*cpu + defaultRAM*ram
+				nodePrice = defaultCPU*cpu + defaultRAM*ramGB
 			}
 			if math.IsNaN(nodePrice) {
 				log.Warnf("nodePrice parsed as NaN. Setting to 0.")

+ 5 - 0
pkg/kubecost/allocation.go

@@ -8,6 +8,7 @@ import (
 
 	"github.com/opencost/opencost/pkg/log"
 	"github.com/opencost/opencost/pkg/util"
+	"github.com/opencost/opencost/pkg/util/timeutil"
 )
 
 // TODO Clean-up use of IsEmpty; nil checks should be separated for safety.
@@ -2391,6 +2392,10 @@ func (asr *AllocationSetRange) accumulateByMonth() (*AllocationSetRange, error)
 }
 
 func (asr *AllocationSetRange) accumulateByWeek() (*AllocationSetRange, error) {
+	if len(asr.Allocations) > 0 && asr.Allocations[0].Window.Duration() == timeutil.Week {
+		return asr, nil
+	}
+
 	var toAccumulate *AllocationSetRange
 	result := NewAllocationSetRange()
 	for i, as := range asr.Allocations {

+ 5 - 0
pkg/kubecost/asset.go

@@ -9,6 +9,7 @@ import (
 
 	"github.com/opencost/opencost/pkg/log"
 	"github.com/opencost/opencost/pkg/util/json"
+	"github.com/opencost/opencost/pkg/util/timeutil"
 )
 
 // UndefinedKey is used in composing Asset group keys if the group does not have that property defined.
@@ -3376,6 +3377,10 @@ func (asr *AssetSetRange) accumulateByMonth() (*AssetSetRange, error) {
 }
 
 func (asr *AssetSetRange) accumulateByWeek() (*AssetSetRange, error) {
+	if len(asr.Assets) > 0 && asr.Assets[0].Window.Duration() == timeutil.Week {
+		return asr, nil
+	}
+
 	var toAccumulate *AssetSetRange
 	result := NewAssetSetRange()
 	for i, as := range asr.Assets {

+ 5 - 0
pkg/kubecost/summaryallocation.go

@@ -8,6 +8,7 @@ import (
 	"time"
 
 	"github.com/opencost/opencost/pkg/log"
+	"github.com/opencost/opencost/pkg/util/timeutil"
 )
 
 // SummaryAllocation summarizes an Allocation, keeping only fields necessary
@@ -1628,6 +1629,10 @@ func (sasr *SummaryAllocationSetRange) accumulateByMonth() (*SummaryAllocationSe
 }
 
 func (sasr *SummaryAllocationSetRange) accumulateByWeek() (*SummaryAllocationSetRange, error) {
+	if len(sasr.SummaryAllocationSets) > 0 && sasr.SummaryAllocationSets[0].Window.Duration() == timeutil.Week {
+		return sasr, nil
+	}
+
 	var toAccumulate *SummaryAllocationSetRange
 	result := NewSummaryAllocationSetRange()
 	for i, as := range sasr.SummaryAllocationSets {

+ 15 - 2
pkg/kubecost/window.go

@@ -22,8 +22,8 @@ const (
 )
 
 var (
-	durationRegex       = regexp.MustCompile(`^(\d+)(m|h|d)$`)
-	durationOffsetRegex = regexp.MustCompile(`^(\d+)(m|h|d) offset (\d+)(m|h|d)$`)
+	durationRegex       = regexp.MustCompile(`^(\d+)(m|h|d|w)$`)
+	durationOffsetRegex = regexp.MustCompile(`^(\d+)(m|h|d|w) offset (\d+)(m|h|d|w)$`)
 	offesetRegex        = regexp.MustCompile(`^(\+|-)(\d\d):(\d\d)$`)
 	rfc3339             = `\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\dZ`
 	rfcRegex            = regexp.MustCompile(fmt.Sprintf(`(%s),(%s)`, rfc3339, rfc3339))
@@ -34,6 +34,10 @@ var (
 // in the given time's timezone.
 // e.g. 2020-01-01T12:37:48-0700, 24h = 2020-01-01T00:00:00-0700
 func RoundBack(t time.Time, resolution time.Duration) time.Time {
+	// if the duration is a week - roll back to the following Sunday
+	if resolution == timeutil.Week {
+		return timeutil.RoundToStartOfWeek(t)
+	}
 	_, offSec := t.Zone()
 	return t.Add(time.Duration(offSec) * time.Second).Truncate(resolution).Add(-time.Duration(offSec) * time.Second)
 }
@@ -233,6 +237,9 @@ func parseWindow(window string, now time.Time) (Window, error) {
 		if match[2] == "d" {
 			dur = 24 * time.Hour
 		}
+		if match[2] == "w" {
+			dur = timeutil.Week
+		}
 
 		num, _ := strconv.ParseInt(match[1], 10, 64)
 
@@ -254,6 +261,9 @@ func parseWindow(window string, now time.Time) (Window, error) {
 		if match[4] == "d" {
 			offUnit = 24 * time.Hour
 		}
+		if match[4] == "w" {
+			offUnit = 24 * timeutil.Week
+		}
 
 		offNum, _ := strconv.ParseInt(match[3], 10, 64)
 
@@ -266,6 +276,9 @@ func parseWindow(window string, now time.Time) (Window, error) {
 		if match[2] == "d" {
 			durUnit = 24 * time.Hour
 		}
+		if match[2] == "w" {
+			durUnit = timeutil.Week
+		}
 
 		durNum, _ := strconv.ParseInt(match[1], 10, 64)
 

+ 25 - 0
pkg/kubecost/window_test.go

@@ -8,6 +8,7 @@ import (
 	"time"
 
 	"github.com/google/go-cmp/cmp"
+
 	"github.com/opencost/opencost/pkg/util/timeutil"
 
 	"github.com/opencost/opencost/pkg/env"
@@ -88,6 +89,12 @@ func TestRoundBack(t *testing.T) {
 	if !tb.Equal(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)) {
 		t.Fatalf("RoundBack: expected 2020-01-01T00:00:00Z; actual %s", tb)
 	}
+
+	to = time.Date(2020, time.January, 1, 23, 59, 0, 0, time.UTC)
+	tb = RoundBack(to, timeutil.Week)
+	if !tb.Equal(time.Date(2019, time.December, 29, 0, 0, 0, 0, time.UTC)) {
+		t.Fatalf("RoundForward: expected 2019-12-29T00:00:00Z; actual %s", tb)
+	}
 }
 
 func TestRoundForward(t *testing.T) {
@@ -165,6 +172,24 @@ func TestRoundForward(t *testing.T) {
 	if !tb.Equal(time.Date(2020, time.January, 2, 0, 0, 0, 0, time.UTC)) {
 		t.Fatalf("RoundForward: expected 2020-01-02T00:00:00Z; actual %s", tb)
 	}
+
+	to = time.Date(2020, time.January, 1, 23, 59, 0, 0, time.UTC)
+	tb = RoundForward(to, timeutil.Week)
+	if !tb.Equal(time.Date(2020, time.January, 5, 0, 0, 0, 0, time.UTC)) {
+		t.Fatalf("RoundForward: expected 2020-01-05T00:00:00Z; actual %s", tb)
+	}
+
+	to = time.Date(2020, time.January, 5, 23, 59, 0, 0, time.UTC)
+	tb = RoundForward(to, timeutil.Week)
+	if !tb.Equal(time.Date(2020, time.January, 12, 0, 0, 0, 0, time.UTC)) {
+		t.Fatalf("RoundForward: expected 2020-01-05T00:00:00Z; actual %s", tb)
+	}
+
+	to = time.Date(2020, time.January, 5, 0, 0, 0, 0, time.UTC)
+	tb = RoundForward(to, timeutil.Week)
+	if !tb.Equal(time.Date(2020, time.January, 5, 0, 0, 0, 0, time.UTC)) {
+		t.Fatalf("RoundForward: expected 2020-01-05T00:00:00Z; actual %s", tb)
+	}
 }
 
 func TestParseWindowUTC(t *testing.T) {

+ 18 - 1
pkg/util/timeutil/timeutil.go

@@ -37,6 +37,8 @@ const (
 
 	// Day expresses 24 hours
 	Day = time.Hour * 24.0
+
+	Week = Day * 7.0
 )
 
 // DurationString converts a duration to a Prometheus-compatible string in
@@ -110,7 +112,8 @@ var unitMap = map[string]int64{
 	"s":  int64(time.Second),
 	"m":  int64(time.Minute),
 	"h":  int64(time.Hour),
-	"d":  int64(time.Hour * 24),
+	"d":  int64(Day),
+	"w":  int64(Week),
 }
 
 // goParseDuration is time.ParseDuration lifted from the go std library and enhanced with the ability to
@@ -262,6 +265,20 @@ func FormatDurationStringDaysToHours(param string) (string, error) {
 	return param, nil
 }
 
+// RoundToStartOfWeek creates a new time.Time for the preceding Sunday 00:00 UTC
+func RoundToStartOfWeek(t time.Time) time.Time {
+	date := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
+	daysFromSunday := int(date.Weekday())
+	return date.Add(-1 * time.Duration(daysFromSunday) * Day)
+}
+
+// RoundToStartOfFollowingWeek creates a new time.Time for the following Sunday 00:00 UTC
+func RoundToStartOfFollowingWeek(t time.Time) time.Time {
+	date := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
+	daysFromSunday := 7 - int(date.Weekday())
+	return date.Add(time.Duration(daysFromSunday) * Day)
+}
+
 // JobTicker is a ticker used to synchronize the next run of a repeating
 // process. The designated use-case is for infinitely-looping selects,
 // where a timeout or an exit channel might cancel the process, but otherwise

+ 29 - 0
pkg/util/timeutil/timeutil_test.go

@@ -1,6 +1,7 @@
 package timeutil
 
 import (
+	"fmt"
 	"testing"
 	"time"
 )
@@ -388,3 +389,31 @@ func Test_FormatDurationStringDaysToHours(t *testing.T) {
 		})
 	}
 }
+
+func TestRoundToStartOfWeek(t *testing.T) {
+	sunday := time.Date(2023, 03, 26, 12, 12, 12, 12, time.UTC)
+	roundedFromSunday := RoundToStartOfWeek(sunday)
+	if roundedFromSunday.Day() != 26 || roundedFromSunday.Weekday() == time.Sunday {
+		fmt.Errorf("expected date to be rounded to the same sunday, got: %d, %s", roundedFromSunday.Day(), roundedFromSunday.Weekday().String())
+	}
+
+	tuesday := time.Date(2023, 03, 28, 12, 12, 12, 12, time.UTC)
+	roundedFromTuesday := RoundToStartOfWeek(tuesday)
+	if roundedFromTuesday.Day() != 26 || roundedFromTuesday.Weekday() == time.Sunday {
+		fmt.Errorf("expected date to be rounded to the same sunday, got: %d, %s", roundedFromTuesday.Day(), roundedFromTuesday.Weekday().String())
+	}
+}
+
+func TestRoundToStartOfFollowingWeek(t *testing.T) {
+	sunday := time.Date(2023, 03, 26, 12, 12, 12, 12, time.UTC)
+	roundedFromSunday := RoundToStartOfFollowingWeek(sunday)
+	if roundedFromSunday.Month() != 4 || roundedFromSunday.Day() != 2 || roundedFromSunday.Weekday() == time.Sunday {
+		fmt.Errorf("expected date to be rounded to the same sunday, got: %d, %s", roundedFromSunday.Day(), roundedFromSunday.Weekday().String())
+	}
+
+	tuesday := time.Date(2023, 03, 28, 12, 12, 12, 12, time.UTC)
+	roundedFromTuesday := RoundToStartOfFollowingWeek(tuesday)
+	if roundedFromTuesday.Month() != 4 || roundedFromTuesday.Day() != 2 || roundedFromTuesday.Weekday() == time.Sunday {
+		fmt.Errorf("expected date to be rounded to the same sunday, got: %d, %s", roundedFromTuesday.Day(), roundedFromTuesday.Weekday().String())
+	}
+}