瀏覽代碼

merge with develop

Signed-off-by: r2k1 <yokree@gmail.com>
r2k1 3 年之前
父節點
當前提交
d379d59e57
共有 42 個文件被更改,包括 3077 次插入3238 次删除
  1. 12 0
      .idea/codeStyles/Project.xml
  2. 5 0
      .idea/codeStyles/codeStyleConfig.xml
  3. 26 28
      CONTRIBUTING.md
  4. 1 1
      README.md
  5. 8 4
      configs/alibaba.json
  6. 1 1
      go.mod
  7. 2 2
      go.sum
  8. 48 8
      pkg/cloud/aliyunprovider.go
  9. 124 0
      pkg/cloud/azurepricesheet/client.go
  10. 300 0
      pkg/cloud/azurepricesheet/downloader.go
  11. 101 0
      pkg/cloud/azurepricesheet/downloader_test.go
  12. 230 113
      pkg/cloud/azureprovider.go
  13. 59 0
      pkg/cloud/azureprovider_test.go
  14. 2 0
      pkg/cloud/provider.go
  15. 42 0
      pkg/cloud/providerconfig.go
  16. 0 1
      pkg/cmd/costmodel/costmodel.go
  17. 4 1
      pkg/costmodel/aggregation.go
  18. 9 4
      pkg/costmodel/allocation.go
  19. 17 14
      pkg/costmodel/allocation_helpers.go
  20. 2 1
      pkg/costmodel/costmodel.go
  21. 4 1
      pkg/costmodel/router.go
  22. 18 0
      pkg/env/costmodelenv.go
  23. 119 0
      pkg/filter/util/cloudcost.go
  24. 0 70
      pkg/filter/util/cloudcostaggregate.go
  25. 44 21
      pkg/kubecost/allocation.go
  26. 2 2
      pkg/kubecost/allocation_json.go
  27. 151 60
      pkg/kubecost/allocation_test.go
  28. 53 4
      pkg/kubecost/allocationprops.go
  29. 130 0
      pkg/kubecost/allocationprops_test.go
  30. 0 29
      pkg/kubecost/asset.go
  31. 7 14
      pkg/kubecost/bingen.go
  32. 550 0
      pkg/kubecost/cloudcost.go
  33. 270 0
      pkg/kubecost/cloudcost_test.go
  34. 0 504
      pkg/kubecost/cloudcostaggregate.go
  35. 0 370
      pkg/kubecost/cloudcostaggregate_test.go
  36. 0 519
      pkg/kubecost/cloudcostitem.go
  37. 0 420
      pkg/kubecost/cloudcostitem_test.go
  38. 214 0
      pkg/kubecost/cloudcostprops.go
  39. 165 0
      pkg/kubecost/cloudcostprops_test.go
  40. 353 994
      pkg/kubecost/kubecost_codecs.go
  41. 0 48
      pkg/kubecost/mock.go
  42. 4 4
      pkg/kubecost/window.go

+ 12 - 0
.idea/codeStyles/Project.xml

@@ -0,0 +1,12 @@
+<component name="ProjectCodeStyleConfiguration">
+  <code_scheme name="Project" version="173">
+    <GoCodeStyleSettings>
+      <option name="ADD_PARENTHESES_FOR_SINGLE_IMPORT" value="true" />
+      <option name="REMOVE_REDUNDANT_IMPORT_ALIASES" value="true" />
+      <option name="MOVE_ALL_IMPORTS_IN_ONE_DECLARATION" value="true" />
+      <option name="MOVE_ALL_STDLIB_IMPORTS_IN_ONE_GROUP" value="true" />
+      <option name="GROUP_STDLIB_IMPORTS" value="true" />
+      <option name="LOCAL_PACKAGE_PREFIXES" />
+    </GoCodeStyleSettings>
+  </code_scheme>
+</component>

+ 5 - 0
.idea/codeStyles/codeStyleConfig.xml

@@ -0,0 +1,5 @@
+<component name="ProjectCodeStyleConfiguration">
+  <state>
+    <option name="USE_PER_PROJECT_SETTINGS" value="true" />
+  </state>
+</component>

+ 26 - 28
CONTRIBUTING.md

@@ -5,9 +5,9 @@ Thanks for your help improving the OpenCost project! There are many ways to cont
 * contributing or providing feedback on the [OpenCost Spec](https://github.com/opencost/opencost/tree/develop/spec)
 * contributing documentation here or to the [OpenCost website](https://github.com/kubecost/opencost-website)
 * joining the discussion in the [CNCF Slack](https://slack.cncf.io/) in the [#opencost](https://cloud-native.slack.com/archives/C03D56FPD4G) channel
-* participating in the fortnightly [OpenCost Working Group](https://calendar.google.com/calendar/u/0/embed?src=c_c0f7q56e5eeod3j89bb320fvjg@group.calendar.google.com&ctz=America/Los_Angeles) meetings ([notes here](https://drive.google.com/drive/folders/1hXlcyFPePB7t3z6lyVzdxmdfrbzeT1Jz))
+* keep up with community events using our [Calendar](https://bit.ly/opencost-calendar)
+* participating in the fortnightly [OpenCost Working Group](https://bit.ly/opencost-calendar) meetings ([notes here](https://bit.ly/opencost-meeting))
 * committing software via the workflow below
-* keep up with community events using our [Calendar](https://calendar.google.com/calendar/u/0/embed?src=c_c0f7q56e5eeod3j89bb320fvjg@group.calendar.google.com&ctz=America/Los_Angeles)
 
 ## Getting Help
 
@@ -22,32 +22,37 @@ This repository's contribution workflow follows a typical open-source model:
 - Work on the forked repository
 - Open a pull request to [merge the fork back into this repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork)
 
-## Building
+## Building OpenCost
 
-Follow these steps to build from source and deploy:
+Follow these steps to build the OpenCost cost-model and UI from source and deploy:
 
-1. `docker build --rm -f "Dockerfile" -t <repo>/kubecost-cost-model:<tag> .`
-2. Edit the [pulled image](https://github.com/opencost/opencost/blob/master/kubernetes/deployment.yaml#L25) in the deployment.yaml to <repo>/kubecost-cost-model:<tag>
-3. Set [this environment variable](https://github.com/opencost/opencost/blob/master/kubernetes/deployment.yaml#L33) to the address of your prometheus server
-4. `kubectl create namespace cost-model`
-5. `kubectl apply -f kubernetes/ --namespace cost-model`
-6. `kubectl port-forward --namespace cost-model service/cost-model 9003`
+1. `docker build --rm -f "Dockerfile" -t <repo>/opencost:<tag> .`
+2. Edit the [pulled image](https://github.com/opencost/opencost/blob/develop/kubernetes/opencost.yaml#L145) in the `kubernetes/opencost.yaml` to `<repo>/opencost:<tag>`
+3. Set [this environment variable](https://github.com/opencost/opencost/blob/develop/kubernetes/opencost.yaml#L155) to the address of your Prometheus server
+4. `cd ui`
+5. `docker build --rm -f "Dockerfile" -t <repo>/opencost-ui:<tag> .`
+6. `cd ..`
+7. Edit the [pulled image](https://github.com/opencost/opencost/blob/develop/kubernetes/opencost.yaml#L162) in the `kubernetes/opencost.yaml` to `<repo>/opencost-ui:<tag>`
+8. `kubectl create namespace opencost`
+9. `kubectl apply -f kubernetes/opencost --namespace opencost`
+10. `kubectl -n opencost port-forward service/opencost 9090 9003`
 
-To test, build the cost-model docker container and then push it to a Kubernetes cluster with a running Prometheus.
+To test, build the OpenCost containers and then push them to a Kubernetes cluster with a running Prometheus.
 
-To confirm that the server is running, you can hit [http://localhost:9003/costDataModel?timeWindow=1d](http://localhost:9003/costDataModel?timeWindow=1d)
+To confirm that the server and UI are running, you can hit [http://localhost:9090](http://localhost:9090) to access the OpenCost UI.
+You can test the server API with `curl http://localhost:9003/allocation/compute -d window=60m -G`.
 
 ## Running locally
 
 To run locally cd into `cmd/costmodel` and `go run main.go`
 
-cost-model requires a connection to Prometheus in order to operate so setting the environment variable `PROMETHEUS_SERVER_ENDPOINT` is required.
-In order to expose Prometheus to cost-model it may be required to port-forward using kubectl to your Prometheus endpoint.
+OpenCost requires a connection to Prometheus in order to operate so setting the environment variable `PROMETHEUS_SERVER_ENDPOINT` is required.
+In order to expose Prometheus to OpenCost it may be required to port-forward using kubectl to your Prometheus endpoint.
 
 For example:
 
 ```bash
-kubectl port-forward svc/kubecost-prometheus-server 9080:80
+kubectl port-forward svc/prometheus-server 9080:80
 ```
 
 This would expose Prometheus on port 9080 and allow setting the environment variable as so:
@@ -56,7 +61,7 @@ This would expose Prometheus on port 9080 and allow setting the environment vari
 PROMETHEUS_SERVER_ENDPOINT="http://127.0.0.1:9080"
 ```
 
-If you want to run with a specific kubeconfig the environment variable `KUBECONFIG` can be used. cost-model will attempt to connect to your Kubernetes cluster in a similar fashion as kubectl so the env is not required. The order of precedence is `KUBECONFIG` > default kubeconfig file location ($HOME/.kube/config) > in cluster config
+If you want to run with a specific kubeconfig the environment variable `KUBECONFIG` can be used. OpenCost will attempt to connect to your Kubernetes cluster in a similar fashion as kubectl so the env is not required. The order of precedence is `KUBECONFIG` > default kubeconfig file location ($HOME/.kube/config) > in cluster config
 
 Example:
 
@@ -64,13 +69,6 @@ Example:
 export KUBECONFIG=~/.kube/config
 ```
 
-There are two more environment variables recommended to run locally. These should be set as the default file location used is `/var/` which usually requires more permissions than kubecost actually needs to run. They do not need to match but keeping everything together can help cleanup when no longer needed.
-
-```bash
-ETL_PATH_PREFIX="/my/cool/path/kubecost/var/config"
-CONFIG_PATH="/my/cool/path/kubecost/var/config"
-```
-
 An example of the full command:
 
 ```bash
@@ -82,20 +80,20 @@ ETL_PATH_PREFIX="/my/cool/path/kubecost/var/config" CONFIG_PATH="/my/cool/path/k
 To run these tests:
 
 - Make sure you have a kubeconfig that can point to your cluster, and have permissions to create/modify a namespace called "test"
-- Connect to your the Prometheus kubecost emits to on localhost:9003:
-  `kubectl port-forward --namespace kubecost service/kubecost-prometheus-server 9003:80`
+- Connect to your the Prometheus OpenCost emits to on localhost:9003:
+  `kubectl port-forward --namespace opencost service/prometheus-server 9003:80`
 - Temporary workaround: Copy the default.json file in this project at cloud/default.json to /models/default.json on the machine your test is running on. TODO: fix this and inject the cloud/default.json path into provider.go.
 - Navigate to cost-model/test
 - Run `go test -timeout 700s` from the testing directory. The tests right now take about 10 minutes (600s) to run because they bring up and down pods and wait for Prometheus to scrape data about them.
 
-## Certification of Origin
+## Certificate of Origin
 
-By contributing to this project, you certify that your contribution was created in whole or in part by you and that you have the right to submit it under the open source license indicated in the project. In other words, please confirm that you, as a contributor, have the legal right to make the contribution.
+By contributing to this project, you certify that your contribution was created in whole or in part by you and that you have the right to submit it under the open source license indicated in the project. In other words, please confirm that you, as a contributor, have the legal right to make the contribution. This is enforced on Pull Requests and requires `Signed-off-by` with the email address for the author in the commit message.
 
 ## Committing
 
 Please write a commit message with Fixes Issue # if there is an outstanding issue that is fixed. It’s okay to submit a PR without a corresponding issue; just please try to be detailed in the description of the problem you’re addressing.
 
-Please run go fmt on the project directory. Lint can be okay (for example, comments on exported functions are nice but not required on the server).
+Please run `go fmt` on the project directory. Lint can be okay (for example, comments on exported functions are nice but not required on the server).
 
 Please email us [opencost@kubecost.com](opencost@kubecost.com) or reach out to us on [CNCF Slack](https://slack.cncf.io/) in the [#opencost](https://cloud-native.slack.com/archives/C03D56FPD4G) channel if you need help or have any questions!

+ 1 - 1
README.md

@@ -37,7 +37,7 @@ and contributing changes.
 
 ## Community
 
-If you need any support or have any questions on contributing to the project, you can reach us on [CNCF Slack](https://slack.cncf.io/) in the [#opencost](https://cloud-native.slack.com/archives/C03D56FPD4G) channel, email at [opencost@kubecost.com](opencost@kubecost.com), or attend the [OpenCost Working Group meetings](https://bit.ly/opencost-meeting) from the [Community Calendar](https://bit.ly/opencost-calendar).
+If you need any support or have any questions on contributing to the project, you can reach us on [CNCF Slack](https://slack.cncf.io/) in the [#opencost](https://cloud-native.slack.com/archives/C03D56FPD4G) channel, email at [opencost@kubecost.com](opencost@kubecost.com), or attend the biweekly [OpenCost Working Group community meeting](https://bit.ly/opencost-meeting) from the [Community Calendar](https://bit.ly/opencost-calendar).
 
 ## FAQ
 

+ 8 - 4
configs/alibaba.json

@@ -1,12 +1,16 @@
 {
     "provider": "Alibaba",
-    "description": "Default prices used to compute allocation between RAM and CPU. Alibaba Cloud pricing API data still used for total node cost.",
-    "alibabaServiceKeyName": "ABC",
-    "alibabaServiceKeySecret": "XYZ",
+    "description": "Default prices used to compute allocation between RAM and CPU. Alibaba Cloud pricing API is used for total node and PV cost.",
+    "alibabaServiceKeyName": "",
+    "alibabaServiceKeySecret": "",
     "CPU": "0.031611",
     "spotCPU": "0.006655",
     "RAM": "0.004237",
     "GPU": "0.95",
     "spotRAM": "0.000892",
-    "storage": "0.00005479452"
+    "storage": "0.00005479452",
+    "zoneNetworkEgress": "0.02",
+    "regionNetworkEgress": "0.08",
+    "internetNetworkEgress": "0.123",
+    "defaultLBPrice": "0.007"
 }

+ 1 - 1
go.mod

@@ -8,7 +8,7 @@ require (
 	cloud.google.com/go/storage v1.28.1
 	github.com/Azure/azure-pipeline-go v0.2.3
 	github.com/Azure/azure-sdk-for-go v65.0.0+incompatible
-	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0
+	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.1-0.20230323231529-14c481f239ec
 	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.2
 	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0
 	github.com/Azure/azure-storage-blob-go v0.15.0

+ 2 - 2
go.sum

@@ -56,8 +56,8 @@ github.com/Azure/azure-pipeline-go v0.2.3 h1:7U9HBg1JFK3jHl5qmo4CTZKFTVgMwdFHMVt
 github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k=
 github.com/Azure/azure-sdk-for-go v65.0.0+incompatible h1:HzKLt3kIwMm4KeJYTdx9EbjRYTySD/t8i1Ee/W5EGXw=
 github.com/Azure/azure-sdk-for-go v65.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0 h1:rTnT/Jrcm+figWlYz4Ixzt0SJVR2cMC8lvZcimipiEY=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.0/go.mod h1:ON4tFdPTwRcgWEaVDrN3584Ef+b7GgSJaXxe5fW9t4M=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.1-0.20230323231529-14c481f239ec h1:S83Dzhd3VLyvN2bgFI7/Lgk1etamk3Pk8QQhn3iXt4s=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.4.1-0.20230323231529-14c481f239ec/go.mod h1:IoxiGSzhL1QHFXa/mlAXCD+sUaP0rxg//yn2w/JH7wg=
 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.2 h1:uqM+VoHjVH6zdlkLF2b6O0ZANcHoj3rO0PoQ3jglUJA=
 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.2.2/go.mod h1:twTKAa1E6hLmSDjLhaCkbTMQKc7p/rNLU40rLxGEOCI=
 github.com/Azure/azure-sdk-for-go/sdk/internal v1.2.0 h1:leh5DwKv6Ihwi+h60uHtn6UWAxBbZ0q8DwQVMzf61zw=

+ 48 - 8
pkg/cloud/aliyunprovider.go

@@ -6,6 +6,7 @@ import (
 	"io"
 	"os"
 	"regexp"
+	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -366,7 +367,10 @@ func (alibaba *Alibaba) GetAlibabaAccessKey() (*credentials.AccessKeyCredential,
 		return nil, fmt.Errorf("failed to get the access key for the current alibaba account")
 	}
 
-	alibaba.accessKey = &credentials.AccessKeyCredential{AccessKeyId: env.GetAlibabaAccessKeyID(), AccessKeySecret: env.GetAlibabaAccessKeySecret()}
+	// At this point either user is using the alibaba key and secret from secret passed in helm config if not he will use the secret that is passed in custom pricing
+	// There's no check at this time for if the custom pricing key and secret is valid and that's on the user else there will be errors recorded.
+	// Key and secret passed in config will supersede key and secret passed while installing Closed source helm chart.
+	alibaba.accessKey = &credentials.AccessKeyCredential{AccessKeyId: config.AlibabaServiceKeyName, AccessKeySecret: config.AlibabaServiceKeySecret}
 
 	return alibaba.accessKey, nil
 }
@@ -544,19 +548,46 @@ func (alibaba *Alibaba) PVPricing(pvk PVKey) (*PV, error) {
 	return pricing.PV, nil
 }
 
-// Stubbed NetworkPricing for Alibaba Cloud. Will look at this in Next PR
+// Inter zone and Inter region network cost are defaulted based on https://www.alibabacloud.com/help/en/cloud-data-transmission/latest/cross-region-data-transfers
+// Internet cost is default based on https://www.alibabacloud.com/help/en/elastic-compute-service/latest/public-bandwidth to $0.123
 func (alibaba *Alibaba) NetworkPricing() (*Network, error) {
+	cpricing, err := alibaba.Config.GetCustomPricingData()
+	if err != nil {
+		return nil, err
+	}
+	znec, err := strconv.ParseFloat(cpricing.ZoneNetworkEgress, 64)
+	if err != nil {
+		return nil, err
+	}
+	rnec, err := strconv.ParseFloat(cpricing.RegionNetworkEgress, 64)
+	if err != nil {
+		return nil, err
+	}
+	inec, err := strconv.ParseFloat(cpricing.InternetNetworkEgress, 64)
+	if err != nil {
+		return nil, err
+	}
+
 	return &Network{
-		ZoneNetworkEgressCost:     0.0,
-		RegionNetworkEgressCost:   0.0,
-		InternetNetworkEgressCost: 0.0,
+		ZoneNetworkEgressCost:     znec,
+		RegionNetworkEgressCost:   rnec,
+		InternetNetworkEgressCost: inec,
 	}, nil
 }
 
-// Stubbed LoadBalancerPricing for Alibaba Cloud. Will look at this in Next PR
+// Alibaba loadbalancer has three different types https://www.alibabacloud.com/product/server-load-balancer,
+// defaulted price to classic load balancer https://www.alibabacloud.com/help/en/server-load-balancer/latest/pay-as-you-go.
 func (alibaba *Alibaba) LoadBalancerPricing() (*LoadBalancer, error) {
+	cpricing, err := alibaba.Config.GetCustomPricingData()
+	if err != nil {
+		return nil, err
+	}
+	lbPricing, err := strconv.ParseFloat(cpricing.DefaultLBPrice, 64)
+	if err != nil {
+		return nil, err
+	}
 	return &LoadBalancer{
-		Cost: 0.0,
+		Cost: lbPricing,
 	}, nil
 }
 
@@ -747,7 +778,16 @@ func (alibaba *Alibaba) CombinedDiscountForNode(string, bool, float64, float64)
 }
 
 func (alibaba *Alibaba) accessKeyisLoaded() bool {
-	return alibaba.accessKey != nil
+	if alibaba.accessKey == nil {
+		return false
+	}
+	if alibaba.accessKey.AccessKeyId == "" {
+		return false
+	}
+	if alibaba.accessKey.AccessKeySecret == "" {
+		return false
+	}
+	return true
 }
 
 type AlibabaNodeKey struct {

+ 124 - 0
pkg/cloud/azurepricesheet/client.go

@@ -0,0 +1,124 @@
+package azurepricesheet
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
+	armruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/runtime"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
+)
+
+const (
+	moduleName    = "armconsumption"
+	moduleVersion = "v1.0.0"
+)
+
+// At the moment the consumption pricesheet download API is not a)
+// documented or b) supported by the SDK. This is an implementation of
+// a client in the style of the Azure go SDK - once the API is
+// supported this will be removed.
+
+// PriceSheetClient contains the methods for the PriceSheet group.
+// Don't use this type directly, use NewPriceSheetClient() instead.
+type PriceSheetClient struct {
+	host             string
+	billingAccountID string
+	pl               runtime.Pipeline
+}
+
+// NewClient creates a new instance of PriceSheetClient with the specified values.
+// billingAccountId - Azure Billing Account ID.
+// credential - used to authorize requests. Usually a credential from azidentity.
+// options - pass nil to accept the default values.
+func NewClient(billingAccountID string, credential azcore.TokenCredential, options *arm.ClientOptions) (*PriceSheetClient, error) {
+	if options == nil {
+		options = &arm.ClientOptions{}
+	}
+	ep := cloud.AzurePublic.Services[cloud.ResourceManager].Endpoint
+	if c, ok := options.Cloud.Services[cloud.ResourceManager]; ok {
+		ep = c.Endpoint
+	}
+	pl, err := armruntime.NewPipeline(moduleName, moduleVersion, credential, runtime.PipelineOptions{}, options)
+	if err != nil {
+		return nil, err
+	}
+	client := &PriceSheetClient{
+		billingAccountID: billingAccountID,
+		host:             ep,
+		pl:               pl,
+	}
+	return client, nil
+}
+
+// BeginDownloadByBillingPeriod - requests a pricesheet for a specific billing period `yyyymm`.
+// Returns a Poller that will provide the download URL when the pricesheet is ready.
+// If the operation fails it returns an *azcore.ResponseError type.
+// Generated from API version 2022-06-01
+// billingPeriodName - Billing Period Name `yyyymm`.
+func (client *PriceSheetClient) BeginDownloadByBillingPeriod(ctx context.Context, billingPeriodName string) (*runtime.Poller[PriceSheetClientDownloadResponse], error) {
+	resp, err := client.downloadByBillingPeriodOperation(ctx, billingPeriodName)
+	if err != nil {
+		return nil, err
+	}
+	return runtime.NewPoller[PriceSheetClientDownloadResponse](resp, client.pl, nil)
+}
+
+type PriceSheetClientDownloadResponse struct {
+	ID         string                             `json:"id"`
+	Name       string                             `json:"name"`
+	StartTime  time.Time                          `json:"startTime"`
+	EndTime    time.Time                          `json:"endTime"`
+	Status     string                             `json:"status"`
+	Properties PriceSheetClientDownloadProperties `json:"properties"`
+}
+
+type PriceSheetClientDownloadProperties struct {
+	DownloadURL string `json:"downloadUrl"`
+	ValidTill   string `json:"validTill"`
+}
+
+func (client *PriceSheetClient) downloadByBillingPeriodOperation(ctx context.Context, billingPeriodName string) (*http.Response, error) {
+	req, err := client.downloadByBillingPeriodCreateRequest(ctx, billingPeriodName)
+	if err != nil {
+		return nil, err
+	}
+	resp, err := client.pl.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusAccepted) {
+		return nil, runtime.NewResponseError(resp)
+	}
+	return resp, nil
+}
+
+const downloadByBillingPeriodTemplate = "/providers/Microsoft.Billing/billingAccounts/%s/billingPeriods/%s/providers/Microsoft.Consumption/pricesheets/download"
+
+// downloadByBillingPeriodCreateRequest creates the DownloadByBillingPeriod request.
+func (client *PriceSheetClient) downloadByBillingPeriodCreateRequest(ctx context.Context, billingPeriodName string) (*policy.Request, error) {
+	if client.billingAccountID == "" {
+		return nil, errors.New("parameter client.billingAccountID cannot be empty")
+	}
+	if billingPeriodName == "" {
+		return nil, errors.New("parameter billingPeriodName cannot be empty")
+	}
+	urlPath := fmt.Sprintf(downloadByBillingPeriodTemplate, url.PathEscape(client.billingAccountID), url.PathEscape(billingPeriodName))
+	req, err := runtime.NewRequest(ctx, http.MethodGet, runtime.JoinPaths(client.host, urlPath))
+	if err != nil {
+		return nil, err
+	}
+	reqQP := req.Raw().URL.Query()
+	reqQP.Set("api-version", "2022-06-01")
+	reqQP.Set("ln", "en")
+	req.Raw().URL.RawQuery = reqQP.Encode()
+	req.Raw().Header["Accept"] = []string{"*/*"}
+	return req, nil
+}

+ 300 - 0
pkg/cloud/azurepricesheet/downloader.go

@@ -0,0 +1,300 @@
+package azurepricesheet
+
+import (
+	"bufio"
+	"context"
+	"encoding/csv"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/Azure/azure-sdk-for-go/profiles/2020-09-01/commerce/mgmt/commerce"
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
+	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
+
+	"github.com/opencost/opencost/pkg/log"
+)
+
+type Downloader[T any] struct {
+	TenantID         string
+	ClientID         string
+	ClientSecret     string
+	BillingAccount   string
+	OfferID          string
+	ConvertMeterInfo func(info commerce.MeterInfo) (map[string]*T, error)
+}
+
+func (d *Downloader[T]) GetPricing(ctx context.Context) (map[string]*T, error) {
+	log.Infof("requesting pricesheet download link")
+	url, err := d.getPricesheetDownloadURL(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting download URL: %w", err)
+	}
+	log.Infof("downloading pricesheet from %q", url)
+	data, err := d.saveData(ctx, url, "pricesheet")
+	if err != nil {
+		return nil, fmt.Errorf("saving pricesheet from %q: %w", url, err)
+	}
+	defer data.Close()
+
+	prices, err := d.readPricesheet(ctx, data)
+	if err != nil {
+		return nil, fmt.Errorf("reading pricesheet: %w", err)
+	}
+	log.Infof("loaded %d pricings from pricesheet", len(prices))
+	return prices, nil
+}
+
+func (d *Downloader[T]) getPricesheetDownloadURL(ctx context.Context) (string, error) {
+	cred, err := azidentity.NewClientSecretCredential(d.TenantID, d.ClientID, d.ClientSecret, nil)
+	if err != nil {
+		return "", fmt.Errorf("creating credential: %w", err)
+	}
+	client, err := NewClient(d.BillingAccount, cred, nil)
+	if err != nil {
+		return "", fmt.Errorf("creating pricesheet client: %w", err)
+	}
+	poller, err := client.BeginDownloadByBillingPeriod(ctx, currentBillingPeriod())
+	if err != nil {
+		return "", fmt.Errorf("beginning pricesheet download: %w", err)
+	}
+	resp, err := poller.PollUntilDone(ctx, &runtime.PollUntilDoneOptions{
+		Frequency: 30 * time.Second,
+	})
+	if err != nil {
+		return "", fmt.Errorf("polling for pricesheet: %w", err)
+	}
+	return resp.Properties.DownloadURL, nil
+}
+
+func (d Downloader[T]) saveData(ctx context.Context, url, tempName string) (io.ReadCloser, error) {
+	// Download file from URL in response.
+	out, err := os.CreateTemp("", tempName)
+	if err != nil {
+		return nil, fmt.Errorf("creating %s temp file: %w", tempName, err)
+	}
+
+	resp, err := http.Get(url)
+	if err != nil {
+		return nil, fmt.Errorf("downloading: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("unexpected HTTP status %d", resp.StatusCode)
+	}
+
+	if _, err := io.Copy(out, resp.Body); err != nil {
+		return nil, fmt.Errorf("reading response: %w", err)
+	}
+
+	_, err = out.Seek(0, io.SeekStart)
+	if err != nil {
+		return nil, fmt.Errorf("seeking to start of file: %w", err)
+	}
+
+	return &removeOnClose{File: out}, nil
+}
+
+type removeOnClose struct {
+	*os.File
+}
+
+func (r *removeOnClose) Close() error {
+	err := r.File.Close()
+	if err != nil {
+		return err
+	}
+	return os.Remove(r.Name())
+}
+
+func (d *Downloader[T]) readPricesheet(ctx context.Context, data io.Reader) (map[string]*T, error) {
+	// Avoid double-buffering.
+	buf, ok := (data).(*bufio.Reader)
+	if !ok {
+		buf = bufio.NewReader(data)
+	}
+
+	// The CSV file starts with two lines before the header without
+	// commas (so different numbers of fields as far as the CSV parser
+	// is concerned). Skip them before making the CSV reader so we
+	// still get the benefit of the row length checks after the
+	// header.
+	for i := 0; i < 2; i++ {
+		_, err := buf.ReadBytes('\n')
+		if err != nil {
+			return nil, fmt.Errorf("skipping preamble line %d: %w", i, err)
+		}
+	}
+	reader := csv.NewReader(buf)
+	reader.ReuseRecord = true
+
+	header, err := reader.Read()
+	if err != nil {
+		return nil, fmt.Errorf("reading header: %w", err)
+	}
+	if err := checkPricesheetHeader(header); err != nil {
+		return nil, err
+	}
+
+	units := make(map[string]bool)
+
+	results := make(map[string]*T)
+	lines := 2
+	for {
+		row, err := reader.Read()
+		if err == io.EOF {
+			break
+		}
+		lines++
+		if err != nil {
+			return nil, fmt.Errorf("reading line %d: %w", lines, err)
+		}
+
+		// Skip savings plan - we should be reporting based on the
+		// consumption price because we don't know whether the user is
+		// using a savings plan or over their threshold.
+		if row[pricesheetPriceType] == "Savings Plan" || row[pricesheetOfferID] != d.OfferID {
+			continue
+		}
+
+		// TODO: Creating a meter info for each record will cause a
+		// lot of GC churn - is it worth reusing one meter info instead?
+		meterInfo, err := makeMeterInfo(row)
+		if err != nil {
+			log.Warnf("making meter info (line %d): %v", lines, err)
+			continue
+		}
+
+		pricings, err := d.ConvertMeterInfo(meterInfo)
+		if err != nil {
+			log.Warnf("converting meter to pricings (line %d): %v", lines, err)
+			continue
+		}
+
+		if pricings != nil {
+			units[*meterInfo.Unit] = true
+		}
+
+		for key, pricing := range pricings {
+			results[key] = pricing
+		}
+	}
+
+	if len(results) == 0 {
+		return nil, fmt.Errorf("no matching pricing from price sheet")
+	}
+
+	// Keep track of units seen so we can detect if there are any that
+	// need handling.
+	allUnits := make([]string, 0, len(units))
+	for unit := range units {
+		allUnits = append(allUnits, unit)
+	}
+	sort.Strings(allUnits)
+	log.Infof("all units in pricesheet: %s", strings.Join(allUnits, ", "))
+
+	return results, nil
+}
+
+func checkPricesheetHeader(header []string) error {
+	if len(header) < len(pricesheetCols) {
+		return fmt.Errorf("too few header columns: got %d, expected %d", len(header), len(pricesheetCols))
+	}
+	for col, name := range pricesheetCols {
+		if !strings.EqualFold(header[col], name) {
+			return fmt.Errorf("unexpected header at col %d %q, expected %q", col, header[col], name)
+		}
+	}
+	return nil
+}
+
+func makeMeterInfo(row []string) (commerce.MeterInfo, error) {
+	price, err := strconv.ParseFloat(row[pricesheetUnitPrice], 64)
+	if err != nil {
+		return commerce.MeterInfo{}, fmt.Errorf("parsing unit price: %w", err)
+	}
+	newPrice, unit := normalisePrice(price, row[pricesheetUnit])
+	return commerce.MeterInfo{
+		MeterName:        ptr(row[pricesheetMeterName]),
+		MeterCategory:    ptr(row[pricesheetMeterCategory]),
+		MeterSubCategory: ptr(row[pricesheetMeterSubCategory]),
+		Unit:             &unit,
+		MeterRegion:      ptr(row[pricesheetMeterRegion]),
+		MeterRates:       map[string]*float64{"0": &newPrice},
+	}, nil
+}
+
+var pricesheetCols = []string{
+	"Meter ID",
+	"Meter name",
+	"Meter category",
+	"Meter sub-category",
+	"Meter region",
+	"Unit",
+	"Unit of measure",
+	"Part number",
+	"Unit price",
+	"Currency code",
+	"Included quantity",
+	"Offer Id",
+	"Term",
+	"Price type",
+}
+
+const (
+	pricesheetMeterID          = 0
+	pricesheetMeterName        = 1
+	pricesheetMeterCategory    = 2
+	pricesheetMeterSubCategory = 3
+	pricesheetMeterRegion      = 4
+	pricesheetUnit             = 5
+	pricesheetUnitPrice        = 8
+	pricesheetCurrencyCode     = 9
+	pricesheetOfferID          = 11
+	pricesheetPriceType        = 13
+)
+
+func currentBillingPeriod() string {
+	return time.Now().Format("200601")
+}
+
+func ptr[T any](v T) *T {
+	return &v
+}
+
+// conversions lists all the units seen from the price sheet for
+// prices we're interested in with factors to the corresponding units
+// in the rate card.
+var conversions = map[string]struct {
+	divisor float64
+	unit    string
+}{
+	"1 /Month":       {divisor: 1, unit: "1 /Month"},
+	"1 Hour":         {divisor: 1, unit: "1 Hour"},
+	"1 PiB/Hour":     {divisor: 1_000_000, unit: "1 GiB/Hour"},
+	"10 /Month":      {divisor: 10, unit: "1 /Month"},
+	"10 Hours":       {divisor: 10, unit: "1 Hour"},
+	"100 /Month":     {divisor: 100, unit: "1 /Month"},
+	"100 GB/Month":   {divisor: 100, unit: "1 GB/Month"},
+	"100 Hours":      {divisor: 100, unit: "1 Hour"},
+	"100 TiB/Hour":   {divisor: 100_000, unit: "1 GiB/Hour"},
+	"1000 Hours":     {divisor: 1000, unit: "1 Hour"},
+	"10000 Hours":    {divisor: 10_000, unit: "1 Hour"},
+	"100000 /Hour":   {divisor: 100_000, unit: "1 /Hour"},
+	"1000000 /Hour":  {divisor: 1_000_000, unit: "1 /Hour"},
+	"10000000 /Hour": {divisor: 10_000_000, unit: "1 /Hour"},
+}
+
+func normalisePrice(price float64, unit string) (float64, string) {
+	if conv, ok := conversions[unit]; ok {
+		return price / conv.divisor, conv.unit
+	}
+
+	return price, unit
+}

+ 101 - 0
pkg/cloud/azurepricesheet/downloader_test.go

@@ -0,0 +1,101 @@
+package azurepricesheet
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"testing"
+
+	"github.com/Azure/azure-sdk-for-go/profiles/2020-09-01/commerce/mgmt/commerce"
+	"github.com/stretchr/testify/require"
+)
+
+func TestDownloader(t *testing.T) {
+	d := Downloader[fakePricing]{
+		TenantID:         "test-tenant-id",
+		ClientID:         "test-client-id",
+		ClientSecret:     "test-client-secret",
+		BillingAccount:   "test-billing-account",
+		OfferID:          "my-offer-id",
+		ConvertMeterInfo: convertMeter,
+	}
+
+	t.Run("read prices", func(t *testing.T) {
+		results, err := d.readPricesheet(context.Background(), strings.NewReader(pricesheetData))
+		require.NoError(t, err)
+
+		// Units and prices are normalised.
+		// Info for saving plans and other offers is skipped.
+		expected := map[string]*fakePricing{
+			"DC96as_v4": {price: "10.505", unit: "1 Hour"},
+			"DC2as_v4":  {price: "0.219", unit: "1 Hour"},
+			"VM1":       {price: "1.0", unit: "1 Hour"},
+			"VM2":       {price: "2.0", unit: "1 Hour"},
+		}
+		require.Equal(t, expected, results)
+	})
+
+	t.Run("bad header", func(t *testing.T) {
+		data := "\n\nMeter ID,Meter name,Meter category,Something else,,,,,,,,,,,,,,\n"
+		_, err := d.readPricesheet(context.Background(), strings.NewReader(data))
+		require.ErrorContains(t, err, `unexpected header at col 3 "Something else", expected "Meter sub-category"`)
+	})
+
+	t.Run("short header", func(t *testing.T) {
+		data := "\n\nMeter ID, Meter name, Meter category, Meter sub-category\n"
+		_, err := d.readPricesheet(context.Background(), strings.NewReader(data))
+		require.ErrorContains(t, err, "too few header columns: got 4, expected 14")
+	})
+
+	t.Run("no matching prices", func(t *testing.T) {
+		d := Downloader[fakePricing]{
+			TenantID:       "test-tenant-id",
+			ClientID:       "test-client-id",
+			ClientSecret:   "test-client-secret",
+			BillingAccount: "test-billing-account",
+			OfferID:        "my-offer-id",
+			ConvertMeterInfo: func(commerce.MeterInfo) (map[string]*fakePricing, error) {
+				return nil, nil
+			},
+		}
+		_, err := d.readPricesheet(context.Background(), strings.NewReader(pricesheetData))
+		require.ErrorContains(t, err, "no matching pricing from price sheet")
+	})
+}
+
+func convertMeter(info commerce.MeterInfo) (map[string]*fakePricing, error) {
+	switch *info.MeterName {
+	case "skip-this":
+		return nil, nil
+	case "multiple-prices":
+		return map[string]*fakePricing{
+			"VM1": {price: "1.0", unit: "1 Hour"},
+			"VM2": {price: "2.0", unit: "1 Hour"},
+		}, nil
+	case "error":
+		return nil, fmt.Errorf("there was an error handling this row!")
+	default:
+		return map[string]*fakePricing{
+			*info.MeterName: {price: fmt.Sprintf("%0.3f", *info.MeterRates["0"]), unit: *info.Unit},
+		}, nil
+	}
+}
+
+type fakePricing struct {
+	price string
+	unit  string
+}
+
+const pricesheetData = `Price Sheet Report for billing period - 202304
+
+Meter ID,Meter name,Meter category,Meter sub-category,Meter region,Unit,Unit of measure,Part number,Unit price,Currency code,Included quantity,Offer Id,Term,Price type
+d4236f8f-3ba6-5a9a-8c6b-14556538c44c,DC96as_v4,Virtual Machines,DCasv4 Series,US East,10 Hours,10 Hours,AAF-70822,105.050000000000000,USD,0.00,my-offer-id,,Consumption
+d4236f8f-3ba6-5a9a-8c6b-14556538c44c,DC96as_v4,Virtual Machines,DCasv4 Series,US East,10 Hours,10 Hours,AAF-70831,60.890000000000000,USD,0.00,other-offer-id,,Consumption
+e47a2c4c-4dc4-55d5-a8d7-ec5b1dcc9c08,DC2as_v4,Virtual Machines,DCasv4 Series,US East,100 Hours,100 Hours,AAF-70890,21.900000000000000,USD,0.000,my-offer-id,,Consumption
+e47a2c4c-4dc4-55d5-a8d7-ec5b1dcc9c08,DC2as_v4,Virtual Machines,DCasv4 Series,US East,100 Hours,100 Hours,AAF-70886,12.700000000000000,USD,0.000,other-offer-id,,Consumption
+cb8d72c0-2b02-5b41-9ac9-2809c04f17ff,DC16as_v4,Virtual Machines,DCasv4 Series,US East,10 Hours,10 Hours,AAF-70911,17.510000000000000,USD,0.00,my-offer-id,,Savings Plan
+cb8d72c0-2b02-5b41-9ac9-2809c04f17ff,DC16as_v4,Virtual Machines,DCasv4 Series,US East,10 Hours,10 Hours,AAF-70910,10.150000000000000,USD,0.00,other-offer-id,,Consumption
+d4236f8f-3ba6-5a9a-8c6b-14556538c44c,skip-this,Virtual Machines,DCasv4 Series,US East,10 Hours,10 Hours,AAF-70822,105.050000000000000,USD,0.00,my-offer-id,,Consumption
+d4236f8f-3ba6-5a9a-8c6b-14556538c44c,multiple-prices,Virtual Machines,DCasv4 Series,US East,10 Hours,10 Hours,AAF-70822,105.050000000000000,USD,0.00,my-offer-id,,Consumption
+d4236f8f-3ba6-5a9a-8c6b-14556538c44c,error,Virtual Machines,DCasv4 Series,US East,10 Hours,10 Hours,AAF-70822,105.050000000000000,USD,0.00,my-offer-id,,Consumption
+`

+ 230 - 113
pkg/cloud/azureprovider.go

@@ -13,8 +13,6 @@ import (
 	"sync"
 	"time"
 
-	"github.com/opencost/opencost/pkg/kubecost"
-
 	"github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2021-11-01/compute"
 	"github.com/Azure/azure-sdk-for-go/services/preview/commerce/mgmt/2015-06-01-preview/commerce"
 	"github.com/Azure/azure-sdk-for-go/services/resources/mgmt/2016-06-01/subscriptions"
@@ -22,13 +20,17 @@ import (
 	"github.com/Azure/go-autorest/autorest"
 	"github.com/Azure/go-autorest/autorest/azure"
 	"github.com/Azure/go-autorest/autorest/azure/auth"
+
+	pricesheet "github.com/opencost/opencost/pkg/cloud/azurepricesheet"
 	"github.com/opencost/opencost/pkg/clustercache"
 	"github.com/opencost/opencost/pkg/env"
+	"github.com/opencost/opencost/pkg/kubecost"
 	"github.com/opencost/opencost/pkg/log"
 	"github.com/opencost/opencost/pkg/util"
 	"github.com/opencost/opencost/pkg/util/fileutil"
 	"github.com/opencost/opencost/pkg/util/json"
 	"github.com/opencost/opencost/pkg/util/timeutil"
+
 	v1 "k8s.io/api/core/v1"
 )
 
@@ -205,7 +207,7 @@ func getRegions(service string, subscriptionsClient subscriptions.Client, provid
 						if loc, ok := allLocations[displName]; ok {
 							supLocations[loc] = displName
 						} else {
-							log.Warnf("unsupported cloud region %s", loc)
+							log.Warnf("unsupported cloud region %q", loc)
 						}
 					}
 					break
@@ -223,7 +225,7 @@ func getRegions(service string, subscriptionsClient subscriptions.Client, provid
 						if loc, ok := allLocations[displName]; ok {
 							supLocations[loc] = displName
 						} else {
-							log.Warnf("unsupported cloud region %s", loc)
+							log.Warnf("unsupported cloud region %q", loc)
 						}
 					}
 					break
@@ -328,7 +330,7 @@ func toRegionID(meterRegion string, regions map[string]string) (string, error) {
 			return regionID, nil
 		}
 	}
-	return "", fmt.Errorf("Couldn't find region")
+	return "", fmt.Errorf("Couldn't find region %q", meterRegion)
 }
 
 // azure has very inconsistent naming standards between display names from the rate card api and display names from the regions api
@@ -395,7 +397,9 @@ type Azure struct {
 	Clientset                      clustercache.ClusterCache
 	Config                         *ProviderConfig
 	serviceAccountChecks           *ServiceAccountChecks
-	RateCardPricingError           error
+	pricingSource                  string
+	rateCardPricingError           error
+	priceSheetPricingError         error
 	clusterAccountID               string
 	clusterRegion                  string
 	loadedAzureSecret              bool
@@ -779,10 +783,19 @@ func (az *Azure) DownloadPricingData() error {
 
 	config, err := az.GetConfig()
 	if err != nil {
-		az.RateCardPricingError = err
+		az.rateCardPricingError = err
 		return err
 	}
 
+	envBillingAccount := env.GetAzureBillingAccount()
+	if envBillingAccount != "" {
+		config.AzureBillingAccount = envBillingAccount
+	}
+	envOfferID := env.GetAzureOfferID()
+	if envOfferID != "" {
+		config.AzureOfferDurableID = envOfferID
+	}
+
 	// Load the service provider keys
 	subscriptionID, clientID, clientSecret, tenantID := az.getAzureRateCardAuth(false, config)
 	config.AzureSubscriptionID = subscriptionID
@@ -798,7 +811,7 @@ func (az *Azure) DownloadPricingData() error {
 		credentialsConfig := NewClientCredentialsConfig(config.AzureClientID, config.AzureClientSecret, config.AzureTenantID, azureEnv)
 		a, err := credentialsConfig.Authorizer()
 		if err != nil {
-			az.RateCardPricingError = err
+			az.rateCardPricingError = err
 			return err
 		}
 		authorizer = a
@@ -810,7 +823,7 @@ func (az *Azure) DownloadPricingData() error {
 		if err != nil {
 			a, err := auth.NewAuthorizerFromFile(azureEnv.ResourceManagerEndpoint)
 			if err != nil {
-				az.RateCardPricingError = err
+				az.rateCardPricingError = err
 				return err
 			}
 			authorizer = a
@@ -832,14 +845,14 @@ func (az *Azure) DownloadPricingData() error {
 	result, err := rcClient.Get(context.TODO(), rateCardFilter)
 	if err != nil {
 		log.Warnf("Error in pricing download query from API")
-		az.RateCardPricingError = err
+		az.rateCardPricingError = err
 		return err
 	}
 
 	regions, err := getRegions("compute", sClient, providersClient, config.AzureSubscriptionID)
 	if err != nil {
 		log.Warnf("Error in pricing download regions from API")
-		az.RateCardPricingError = err
+		az.rateCardPricingError = err
 		return err
 	}
 
@@ -847,107 +860,166 @@ func (az *Azure) DownloadPricingData() error {
 	allPrices := make(map[string]*AzurePricing)
 
 	for _, v := range *result.Meters {
-		meterName := *v.MeterName
-		meterRegion := *v.MeterRegion
-		meterCategory := *v.MeterCategory
-		meterSubCategory := *v.MeterSubCategory
-
-		region, err := toRegionID(meterRegion, regions)
+		pricings, err := convertMeterToPricings(v, regions, baseCPUPrice)
 		if err != nil {
+			log.Warnf("converting meter to pricings: %s", err.Error())
 			continue
 		}
+		for key, pricing := range pricings {
+			allPrices[key] = pricing
+		}
+	}
+	addAzureFilePricing(allPrices, regions)
 
-		if !strings.Contains(meterSubCategory, "Windows") {
-
-			if strings.Contains(meterCategory, "Storage") {
-				if strings.Contains(meterSubCategory, "HDD") || strings.Contains(meterSubCategory, "SSD") || strings.Contains(meterSubCategory, "Premium Files") {
-					var storageClass string = ""
-					if strings.Contains(meterName, "P4 ") {
-						storageClass = AzureDiskPremiumSSDStorageClass
-					} else if strings.Contains(meterName, "E4 ") {
-						storageClass = AzureDiskStandardSSDStorageClass
-					} else if strings.Contains(meterName, "S4 ") {
-						storageClass = AzureDiskStandardStorageClass
-					} else if strings.Contains(meterName, "LRS Provisioned") {
-						storageClass = AzureFilePremiumStorageClass
-					}
-
-					if storageClass != "" {
-						var priceInUsd float64
-
-						if len(v.MeterRates) < 1 {
-							log.Warnf("missing rate info %+v", map[string]interface{}{"MeterSubCategory": *v.MeterSubCategory, "region": region})
-							continue
-						}
-						for _, rate := range v.MeterRates {
-							priceInUsd += *rate
-						}
-						// rate is in disk per month, resolve price per hour, then GB per hour
-						pricePerHour := priceInUsd / 730.0 / 32.0
-						priceStr := fmt.Sprintf("%f", pricePerHour)
-
-						key := region + "," + storageClass
-						log.Debugf("Adding PV.Key: %s, Cost: %s", key, priceStr)
-						allPrices[key] = &AzurePricing{
-							PV: &PV{
-								Cost:   priceStr,
-								Region: region,
-							},
-						}
-					}
-				}
+	az.Pricing = allPrices
+	az.pricingSource = rateCardPricingSource
+	az.rateCardPricingError = nil
+
+	// If we've got a billing account set, kick off downloading the custom pricing data.
+	if config.AzureBillingAccount != "" {
+		downloader := pricesheet.Downloader[AzurePricing]{
+			TenantID:       config.AzureTenantID,
+			ClientID:       config.AzureClientID,
+			ClientSecret:   config.AzureClientSecret,
+			BillingAccount: config.AzureBillingAccount,
+			OfferID:        config.AzureOfferDurableID,
+			ConvertMeterInfo: func(meterInfo commerce.MeterInfo) (map[string]*AzurePricing, error) {
+				return convertMeterToPricings(meterInfo, regions, baseCPUPrice)
+			},
+		}
+		// The price sheet can take 5 minutes to generate, so we don't
+		// want to hang onto the lock while we're waiting for it.
+		go func() {
+			ctx := context.Background()
+			allPrices, err := downloader.GetPricing(ctx)
+
+			az.DownloadPricingDataLock.Lock()
+			defer az.DownloadPricingDataLock.Unlock()
+			if err != nil {
+				log.Errorf("Error downloading Azure price sheet: %s", err)
+				az.priceSheetPricingError = err
+				return
 			}
+			addAzureFilePricing(allPrices, regions)
+			az.Pricing = allPrices
+			az.pricingSource = priceSheetPricingSource
+			az.priceSheetPricingError = nil
+		}()
+	}
 
-			if strings.Contains(meterCategory, "Virtual Machines") {
-
-				usageType := ""
-				if !strings.Contains(meterName, "Low Priority") {
-					usageType = "ondemand"
-				} else {
-					usageType = "preemptible"
-				}
+	return nil
+}
 
-				var instanceTypes []string
-				name := strings.TrimSuffix(meterName, " Low Priority")
-				instanceType := strings.Split(name, "/")
-				for _, it := range instanceType {
-					if strings.Contains(meterSubCategory, "Promo") {
-						it = it + " Promo"
-					}
-					instanceTypes = append(instanceTypes, strings.Replace(it, " ", "_", 1))
-				}
+func convertMeterToPricings(info commerce.MeterInfo, regions map[string]string, baseCPUPrice string) (map[string]*AzurePricing, error) {
+	meterName := *info.MeterName
+	meterRegion := *info.MeterRegion
+	meterCategory := *info.MeterCategory
+	meterSubCategory := *info.MeterSubCategory
 
-				instanceTypes = transformMachineType(meterSubCategory, instanceTypes)
-				if strings.Contains(name, "Expired") {
-					instanceTypes = []string{}
-				}
+	region, err := toRegionID(meterRegion, regions)
+	if err != nil {
+		// Skip this meter if we don't recognize the region.
+		return nil, nil
+	}
+
+	if strings.Contains(meterSubCategory, "Windows") {
+		// This meter doesn't correspond to any pricings.
+		return nil, nil
+	}
+
+	if strings.Contains(meterCategory, "Storage") {
+		if strings.Contains(meterSubCategory, "HDD") || strings.Contains(meterSubCategory, "SSD") || strings.Contains(meterSubCategory, "Premium Files") {
+			var storageClass string = ""
+			if strings.Contains(meterName, "P4 ") {
+				storageClass = AzureDiskPremiumSSDStorageClass
+			} else if strings.Contains(meterName, "E4 ") {
+				storageClass = AzureDiskStandardSSDStorageClass
+			} else if strings.Contains(meterName, "S4 ") {
+				storageClass = AzureDiskStandardStorageClass
+			} else if strings.Contains(meterName, "LRS Provisioned") {
+				storageClass = AzureFilePremiumStorageClass
+			}
 
+			if storageClass != "" {
 				var priceInUsd float64
 
-				if len(v.MeterRates) < 1 {
-					log.Warnf("missing rate info %+v", map[string]interface{}{"MeterSubCategory": *v.MeterSubCategory, "region": region})
-					continue
+				if len(info.MeterRates) < 1 {
+					return nil, fmt.Errorf("missing rate info %+v", map[string]interface{}{"MeterSubCategory": *info.MeterSubCategory, "region": region})
 				}
-				for _, rate := range v.MeterRates {
+				for _, rate := range info.MeterRates {
 					priceInUsd += *rate
 				}
-				priceStr := fmt.Sprintf("%f", priceInUsd)
-				for _, instanceType := range instanceTypes {
-
-					key := fmt.Sprintf("%s,%s,%s", region, instanceType, usageType)
-
-					allPrices[key] = &AzurePricing{
-						Node: &Node{
-							Cost:         priceStr,
-							BaseCPUPrice: baseCPUPrice,
-							UsageType:    usageType,
+				// rate is in disk per month, resolve price per hour, then GB per hour
+				pricePerHour := priceInUsd / 730.0 / 32.0
+				priceStr := fmt.Sprintf("%f", pricePerHour)
+
+				key := region + "," + storageClass
+				log.Debugf("Adding PV.Key: %s, Cost: %s", key, priceStr)
+				return map[string]*AzurePricing{
+					key: {
+						PV: &PV{
+							Cost:   priceStr,
+							Region: region,
 						},
-					}
-				}
+					},
+				}, nil
 			}
 		}
 	}
 
+	if !strings.Contains(meterCategory, "Virtual Machines") {
+		return nil, nil
+	}
+
+	usageType := ""
+	if !strings.Contains(meterName, "Low Priority") {
+		usageType = "ondemand"
+	} else {
+		usageType = "preemptible"
+	}
+
+	var instanceTypes []string
+	name := strings.TrimSuffix(meterName, " Low Priority")
+	instanceType := strings.Split(name, "/")
+	for _, it := range instanceType {
+		if strings.Contains(meterSubCategory, "Promo") {
+			it = it + " Promo"
+		}
+		instanceTypes = append(instanceTypes, strings.Replace(it, " ", "_", 1))
+	}
+
+	instanceTypes = transformMachineType(meterSubCategory, instanceTypes)
+	if strings.Contains(name, "Expired") {
+		instanceTypes = []string{}
+	}
+
+	var priceInUsd float64
+
+	if len(info.MeterRates) < 1 {
+		return nil, fmt.Errorf("missing rate info %+v", map[string]interface{}{"MeterSubCategory": *info.MeterSubCategory, "region": region})
+	}
+	for _, rate := range info.MeterRates {
+		priceInUsd += *rate
+	}
+	priceStr := fmt.Sprintf("%f", priceInUsd)
+	results := make(map[string]*AzurePricing)
+	for _, instanceType := range instanceTypes {
+
+		key := fmt.Sprintf("%s,%s,%s", region, instanceType, usageType)
+		pricing := &AzurePricing{
+			Node: &Node{
+				Cost:         priceStr,
+				BaseCPUPrice: baseCPUPrice,
+				UsageType:    usageType,
+			},
+		}
+		results[key] = pricing
+	}
+	return results, nil
+
+}
+
+func addAzureFilePricing(prices map[string]*AzurePricing, regions map[string]string) {
 	// There is no easy way of supporting Standard Azure-File, because it's billed per used GB
 	// this will set the price to "0" as a workaround to not spam with `Persistent Volume pricing not found for` error
 	// check https://github.com/opencost/opencost/issues/159 for more information (same problem on AWS)
@@ -955,17 +1027,13 @@ func (az *Azure) DownloadPricingData() error {
 	for region := range regions {
 		key := region + "," + AzureFileStandardStorageClass
 		log.Debugf("Adding PV.Key: %s, Cost: %s", key, zeroPrice)
-		allPrices[key] = &AzurePricing{
+		prices[key] = &AzurePricing{
 			PV: &PV{
 				Cost:   zeroPrice,
 				Region: region,
 			},
 		}
 	}
-
-	az.Pricing = allPrices
-	az.RateCardPricingError = nil
-	return nil
 }
 
 // determineCloudByRegion uses region name to pick the correct Cloud Environment for the azure provider to use
@@ -1010,12 +1078,19 @@ func (az *Azure) AllNodePricing() (interface{}, error) {
 func (az *Azure) NodePricing(key Key) (*Node, error) {
 	az.DownloadPricingDataLock.RLock()
 	defer az.DownloadPricingDataLock.RUnlock()
+	pricingDataExists := true
+	if az.Pricing == nil {
+		pricingDataExists = false
+		log.DedupedWarningf(1, "Unable to download Azure pricing data")
+	}
 
 	azKey, ok := key.(*azureKey)
 	if !ok {
 		return nil, fmt.Errorf("azure: NodePricing: key is of type %T", key)
 	}
 	config, _ := az.GetConfig()
+
+	// Spot Node
 	if slv, ok := azKey.Labels[config.SpotLabel]; ok && slv == config.SpotLabelValue && config.SpotLabel != "" && config.SpotLabelValue != "" {
 		features := strings.Split(azKey.Features(), ",")
 		region := features[0]
@@ -1029,7 +1104,6 @@ func (az *Azure) NodePricing(key Key) (*Node, error) {
 			return n.Node, nil
 		}
 		log.Infof("[Info] found spot instance, trying to get retail price for %s: %s, ", spotFeatures, azKey)
-
 		spotCost, err := getRetailPrice(region, instance, config.CurrencyCode, true)
 		if err != nil {
 			log.DedupedWarningf(5, "failed to retrieve spot retail pricing")
@@ -1043,27 +1117,31 @@ func (az *Azure) NodePricing(key Key) (*Node, error) {
 				UsageType: "spot",
 				GPU:       gpu,
 			}
-
 			az.addPricing(spotFeatures, &AzurePricing{
 				Node: spotNode,
 			})
-
 			return spotNode, nil
 		}
 	}
 
-	if n, ok := az.Pricing[azKey.Features()]; ok {
-		log.Debugf("Returning pricing for node %s: %+v from key %s", azKey, n, azKey.Features())
-		if azKey.isValidGPUNode() {
-			n.Node.GPU = azKey.GetGPUCount()
+	// Use the downloaded pricing data if possible. Otherwise, use default
+	// configured pricing data.
+	if pricingDataExists {
+		if n, ok := az.Pricing[azKey.Features()]; ok {
+			log.Debugf("Returning pricing for node %s: %+v from key %s", azKey, n, azKey.Features())
+			if azKey.isValidGPUNode() {
+				n.Node.GPU = azKey.GetGPUCount()
+			}
+			return n.Node, nil
 		}
-		return n.Node, nil
+		log.DedupedWarningf(5, "No pricing data found for node %s from key %s", azKey, azKey.Features())
 	}
-	log.Warnf("no pricing data found for %s: %s", azKey.Features(), azKey)
 	c, err := az.GetConfig()
 	if err != nil {
 		return nil, fmt.Errorf("No default pricing data available")
 	}
+
+	// GPU Node
 	if azKey.isValidGPUNode() {
 		return &Node{
 			VCPUCost:         c.CPU,
@@ -1073,6 +1151,18 @@ func (az *Azure) NodePricing(key Key) (*Node, error) {
 			GPU:              azKey.GetGPUCount(),
 		}, nil
 	}
+
+	// Serverless Node. This is an Azure Container Instance, and no pods can be
+	// scheduled to this node. Azure does not charge for this node. Set costs to
+	// zero.
+	if azKey.Labels["kubernetes.io/hostname"] == "virtual-node-aci-linux" {
+		return &Node{
+			VCPUCost: "0",
+			RAMCost:  "0",
+		}, nil
+	}
+
+	// Regular Node
 	return &Node{
 		VCPUCost:         c.CPU,
 		RAMCost:          c.RAM,
@@ -1205,7 +1295,7 @@ func (az *Azure) getDisks() ([]*compute.Disk, error) {
 		credentialsConfig := NewClientCredentialsConfig(config.AzureClientID, config.AzureClientSecret, config.AzureTenantID, azureEnv)
 		a, err := credentialsConfig.Authorizer()
 		if err != nil {
-			az.RateCardPricingError = err
+			az.rateCardPricingError = err
 			return nil, err
 		}
 		authorizer = a
@@ -1217,7 +1307,7 @@ func (az *Azure) getDisks() ([]*compute.Disk, error) {
 		if err != nil {
 			a, err := auth.NewAuthorizerFromFile(azureEnv.ResourceManagerEndpoint)
 			if err != nil {
-				az.RateCardPricingError = err
+				az.rateCardPricingError = err
 				return nil, err
 			}
 			authorizer = a
@@ -1472,18 +1562,23 @@ func (az *Azure) ServiceAccountStatus() *ServiceAccountStatus {
 	return az.serviceAccountChecks.getStatus()
 }
 
-const rateCardPricingSource = "Rate Card API"
+const (
+	rateCardPricingSource   = "Rate Card API"
+	priceSheetPricingSource = "Price Sheet API"
+)
 
 // PricingSourceStatus returns the status of the rate card api
 func (az *Azure) PricingSourceStatus() map[string]*PricingSource {
+	az.DownloadPricingDataLock.Lock()
+	defer az.DownloadPricingDataLock.Unlock()
 	sources := make(map[string]*PricingSource)
 	errMsg := ""
-	if az.RateCardPricingError != nil {
-		errMsg = az.RateCardPricingError.Error()
+	if az.rateCardPricingError != nil {
+		errMsg = az.rateCardPricingError.Error()
 	}
 	rcps := &PricingSource{
 		Name:    rateCardPricingSource,
-		Enabled: true,
+		Enabled: az.pricingSource == rateCardPricingSource,
 		Error:   errMsg,
 	}
 	if rcps.Error != "" {
@@ -1494,7 +1589,29 @@ func (az *Azure) PricingSourceStatus() map[string]*PricingSource {
 	} else {
 		rcps.Available = true
 	}
+
+	errMsg = ""
+	if az.priceSheetPricingError != nil {
+		errMsg = az.priceSheetPricingError.Error()
+	}
+	psps := &PricingSource{
+		Name:    priceSheetPricingSource,
+		Enabled: az.pricingSource == priceSheetPricingSource,
+		Error:   errMsg,
+	}
+	if psps.Error != "" {
+		psps.Available = false
+	} else if len(az.Pricing) == 0 {
+		psps.Error = "No Pricing Data Available"
+		psps.Available = false
+	} else if env.GetAzureBillingAccount() == "" {
+		psps.Error = "No Azure Billing Account ID"
+		psps.Available = false
+	} else {
+		psps.Available = true
+	}
 	sources[rateCardPricingSource] = rcps
+	sources[priceSheetPricingSource] = psps
 	return sources
 }
 

+ 59 - 0
pkg/cloud/azureprovider_test.go

@@ -2,6 +2,9 @@ package cloud
 
 import (
 	"testing"
+
+	"github.com/Azure/azure-sdk-for-go/services/preview/commerce/mgmt/2015-06-01-preview/commerce"
+	"github.com/stretchr/testify/require"
 )
 
 func TestParseAzureSubscriptionID(t *testing.T) {
@@ -34,3 +37,59 @@ func TestParseAzureSubscriptionID(t *testing.T) {
 		}
 	}
 }
+
+func TestConvertMeterToPricings(t *testing.T) {
+	regions := map[string]string{
+		"useast":             "US East",
+		"japanwest":          "Japan West",
+		"australiasoutheast": "Australia Southeast",
+		"norwaywest":         "Norway West",
+	}
+	baseCPUPrice := "0.30000"
+
+	meterInfo := func(category, subcategory, name, region string, rate float64) commerce.MeterInfo {
+		return commerce.MeterInfo{
+			MeterCategory:    &category,
+			MeterSubCategory: &subcategory,
+			MeterName:        &name,
+			MeterRegion:      &region,
+			MeterRates:       map[string]*float64{"0": &rate},
+		}
+	}
+
+	t.Run("windows", func(t *testing.T) {
+		info := meterInfo("Virtual Machines", "D2 Series Windows", "D2s v3", "AU Southeast", 0.3)
+		results, err := convertMeterToPricings(info, regions, baseCPUPrice)
+		require.NoError(t, err)
+		require.Nil(t, results)
+	})
+
+	t.Run("storage", func(t *testing.T) {
+		info := meterInfo("Storage", "Some SSD type", "P4 are good", "US East", 2000)
+		results, err := convertMeterToPricings(info, regions, baseCPUPrice)
+		require.NoError(t, err)
+
+		expected := map[string]*AzurePricing{
+			"useast,premium_ssd": {
+				PV: &PV{Cost: "0.085616", Region: "useast"},
+			},
+		}
+		require.Equal(t, expected, results)
+	})
+
+	t.Run("virtual machines", func(t *testing.T) {
+		info := meterInfo("Virtual Machines", "Eav4/Easv4 Series", "E96a v4/E96as v4 Low Priority", "JA West", 10)
+		results, err := convertMeterToPricings(info, regions, baseCPUPrice)
+		require.NoError(t, err)
+
+		expected := map[string]*AzurePricing{
+			"japanwest,Standard_E96a_v4,preemptible": {
+				Node: &Node{Cost: "10.000000", BaseCPUPrice: "0.30000", UsageType: "preemptible"},
+			},
+			"japanwest,Standard_E96as_v4,preemptible": {
+				Node: &Node{Cost: "10.000000", BaseCPUPrice: "0.30000", UsageType: "preemptible"},
+			},
+		}
+		require.Equal(t, expected, results)
+	})
+}

+ 2 - 0
pkg/cloud/provider.go

@@ -211,6 +211,7 @@ type CustomPricing struct {
 	AzureClientSecret            string `json:"azureClientSecret"`
 	AzureTenantID                string `json:"azureTenantID"`
 	AzureBillingRegion           string `json:"azureBillingRegion"`
+	AzureBillingAccount          string `json:"azureBillingAccount"`
 	AzureOfferDurableID          string `json:"azureOfferDurableID"`
 	AzureStorageSubscriptionID   string `json:"azureStorageSubscriptionID"`
 	AzureStorageAccount          string `json:"azureStorageAccount"`
@@ -233,6 +234,7 @@ type CustomPricing struct {
 	KubecostToken                string `json:"kubecostToken"`
 	GoogleAnalyticsTag           string `json:"googleAnalyticsTag"`
 	ExcludeProviderID            string `json:"excludeProviderID"`
+	DefaultLBPrice               string `json:"defaultLBPrice"`
 }
 
 // GetSharedOverheadCostPerMonth parses and returns a float64 representation

+ 42 - 0
pkg/cloud/providerconfig.go

@@ -2,6 +2,8 @@ package cloud
 
 import (
 	"fmt"
+	"io/ioutil"
+	"os"
 	gopath "path"
 	"reflect"
 	"strconv"
@@ -14,6 +16,8 @@ import (
 	"github.com/opencost/opencost/pkg/util/json"
 )
 
+const closedSourceConfigMount = "models/"
+
 var sanitizePolicy = bluemonday.UGCPolicy()
 
 // ProviderConfig is a utility class that provides a thread-safe configuration storage/cache for all Provider
@@ -91,6 +95,15 @@ func (pc *ProviderConfig) loadConfig(writeIfNotExists bool) (*CustomPricing, err
 	if !exists {
 		log.Infof("Could not find Custom Pricing file at path '%s'", pc.configFile.Path())
 		pc.customPricing = DefaultPricing()
+		// If config file is not present use the contents from mount models/ as pricing data
+		// in closed source rather than from from  DefaultPricing as first source of truth.
+		// since most images will already have a mount, to avail this facility user needs to delete the
+		// config file manually from configpath else default pricing still holds good.
+		fileName := filenameInConfigPath(pc.configFile.Path())
+		defaultPricing, err := ReturnPricingFromConfigs(fileName)
+		if err == nil {
+			pc.customPricing = defaultPricing
+		}
 
 		// Only write the file if flag enabled
 		if writeIfNotExists {
@@ -273,3 +286,32 @@ func configPathFor(filename string) string {
 	path := env.GetConfigPathWithDefault("/models/")
 	return gopath.Join(path, filename)
 }
+
+// Gives the config file name in a full qualified file name
+func filenameInConfigPath(fqfn string) string {
+	_, fileName := gopath.Split(fqfn)
+	return fileName
+}
+
+// ReturnPricingFromConfigs is a safe function to return pricing from configs of opensource to the closed source
+// before defaulting it with the above function DefaultPricing
+func ReturnPricingFromConfigs(filename string) (*CustomPricing, error) {
+	if _, err := os.Stat(closedSourceConfigMount); os.IsNotExist(err) {
+		return &CustomPricing{}, fmt.Errorf("ReturnPricingFromConfigs: %s likely running in provider config in opencost itself with err: %v", closedSourceConfigMount, err)
+	}
+	providerConfigFile := gopath.Join(closedSourceConfigMount, filename)
+	if _, err := os.Stat(providerConfigFile); err != nil {
+		return &CustomPricing{}, fmt.Errorf("ReturnPricingFromConfigs: unable to find file %s with err: %v", providerConfigFile, err)
+	}
+	configFile, err := ioutil.ReadFile(providerConfigFile)
+	if err != nil {
+		return &CustomPricing{}, fmt.Errorf("ReturnPricingFromConfigs: unable to open file %s with err: %v", providerConfigFile, err)
+	}
+
+	defaultPricing := &CustomPricing{}
+	err = json.Unmarshal(configFile, defaultPricing)
+	if err != nil {
+		return &CustomPricing{}, fmt.Errorf("ReturnPricingFromConfigs: unable to open file %s with err: %v", providerConfigFile, err)
+	}
+	return defaultPricing, nil
+}

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

@@ -52,7 +52,6 @@ func Execute(opts *CostModelOpts) error {
 }
 
 func StartExportWorker(ctx context.Context, model costmodel.AllocationModel) error {
-	// TODO: there should be a better way to load the configuration
 	exportPath := env.GetExportCSVFile()
 	if exportPath == "" {
 		return fmt.Errorf("%s is not set, skipping CSV exporter", env.ExportCSVFile)

+ 4 - 1
pkg/costmodel/aggregation.go

@@ -2250,7 +2250,10 @@ func (a *Accesses) ComputeAllocationHandler(w http.ResponseWriter, r *http.Reque
 	// IncludeProportionalAssetResourceCosts, if true,
 	includeProportionalAssetResourceCosts := qp.GetBool("includeProportionalAssetResourceCosts", false)
 
-	asr, err := a.Model.QueryAllocation(window, resolution, step, aggregateBy, includeIdle, idleByNode, includeProportionalAssetResourceCosts)
+	// include aggregated labels/annotations if true
+	includeAggregatedMetadata := qp.GetBool("includeAggregatedMetadata", true)
+
+	asr, err := a.Model.QueryAllocation(window, resolution, step, aggregateBy, includeIdle, idleByNode, includeProportionalAssetResourceCosts, includeAggregatedMetadata)
 	if err != nil {
 		if strings.Contains(strings.ToLower(err.Error()), "bad request") {
 			WriteError(w, BadRequest(err.Error()))

+ 9 - 4
pkg/costmodel/allocation.go

@@ -40,8 +40,8 @@ const (
 	queryFmtNetRegionCostPerGiB         = `avg(avg_over_time(kubecost_network_region_egress_cost{}[%s])) by (%s)`
 	queryFmtNetInternetGiB              = `sum(increase(kubecost_pod_network_egress_bytes_total{internet="true"}[%s])) by (pod_name, namespace, %s) / 1024 / 1024 / 1024`
 	queryFmtNetInternetCostPerGiB       = `avg(avg_over_time(kubecost_network_internet_egress_cost{}[%s])) by (%s)`
-	queryFmtNetReceiveBytes             = `sum(increase(container_network_receive_bytes_total{pod!="", container="POD"}[%s])) by (pod_name, pod, namespace, %s)`
-	queryFmtNetTransferBytes            = `sum(increase(container_network_transmit_bytes_total{pod!="", container="POD"}[%s])) by (pod_name, pod, namespace, %s)`
+	queryFmtNetReceiveBytes             = `sum(increase(container_network_receive_bytes_total{pod!=""}[%s])) by (pod_name, pod, namespace, %s)`
+	queryFmtNetTransferBytes            = `sum(increase(container_network_transmit_bytes_total{pod!=""}[%s])) by (pod_name, pod, namespace, %s)`
 	queryFmtNodeLabels                  = `avg_over_time(kube_node_labels[%s])`
 	queryFmtNamespaceLabels             = `avg_over_time(kube_namespace_labels[%s])`
 	queryFmtNamespaceAnnotations        = `avg_over_time(kube_namespace_annotations[%s])`
@@ -60,7 +60,6 @@ const (
 	queryFmtOldestSample                = `min_over_time(timestamp(group(node_cpu_hourly_cost))[%s:%s])`
 	queryFmtNewestSample                = `max_over_time(timestamp(group(node_cpu_hourly_cost))[%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
@@ -291,11 +290,17 @@ func (cm *CostModel) DateRange() (time.Time, time.Time, error) {
 	if err != nil {
 		return time.Time{}, time.Time{}, fmt.Errorf("querying oldest sample: %w", err)
 	}
+	if len(resOldest) == 0 || len(resOldest[0].Values) == 0 {
+		return time.Time{}, time.Time{}, fmt.Errorf("querying oldest sample: no results")
+	}
 	oldest := time.Unix(int64(resOldest[0].Values[0].Value), 0)
 
 	resNewest, _, err := ctx.QuerySync(fmt.Sprintf(queryFmtNewestSample, "90d", "1h"))
 	if err != nil {
-		return time.Time{}, time.Time{}, fmt.Errorf("querying oldest sample: %w", err)
+		return time.Time{}, time.Time{}, fmt.Errorf("querying newest sample: %w", err)
+	}
+	if len(resNewest) == 0 || len(resNewest[0].Values) == 0 {
+		return time.Time{}, time.Time{}, fmt.Errorf("querying newest sample: no results")
 	}
 	newest := time.Unix(int64(resNewest[0].Values[0].Value), 0)
 

+ 17 - 14
pkg/costmodel/allocation_helpers.go

@@ -1411,19 +1411,19 @@ func applyNodeCostPerCPUHr(nodeMap map[nodeKey]*nodePricing, resNodeCostPerCPUHr
 
 		node, err := res.GetString("node")
 		if err != nil {
-			log.Warnf("CostModel.ComputeAllocation: Node CPU cost query result missing field: %s", err)
+			log.Warnf("CostModel.ComputeAllocation: Node CPU cost query result missing field: \"%s\" for node \"%s\"", err, node)
 			continue
 		}
 
 		instanceType, err := res.GetString("instance_type")
 		if err != nil {
-			log.Warnf("CostModel.ComputeAllocation: Node CPU cost query result missing field: %s", err)
+			log.Warnf("CostModel.ComputeAllocation: Node CPU cost query result missing field: \"%s\" for node \"%s\"", err, node)
 			continue
 		}
 
 		providerID, err := res.GetString("provider_id")
 		if err != nil {
-			log.Warnf("CostModel.ComputeAllocation: Node CPU cost query result missing field: %s", err)
+			log.Warnf("CostModel.ComputeAllocation: Node CPU cost query result missing field: \"%s\" for node \"%s\"", err, node)
 			continue
 		}
 
@@ -1449,19 +1449,19 @@ func applyNodeCostPerRAMGiBHr(nodeMap map[nodeKey]*nodePricing, resNodeCostPerRA
 
 		node, err := res.GetString("node")
 		if err != nil {
-			log.Warnf("CostModel.ComputeAllocation: Node RAM cost query result missing field: %s", err)
+			log.Warnf("CostModel.ComputeAllocation: Node RAM cost query result missing field: \"%s\" for node \"%s\"", err, node)
 			continue
 		}
 
 		instanceType, err := res.GetString("instance_type")
 		if err != nil {
-			log.Warnf("CostModel.ComputeAllocation: Node RAM cost query result missing field: %s", err)
+			log.Warnf("CostModel.ComputeAllocation: Node RAM cost query result missing field: \"%s\" for node \"%s\"", err, node)
 			continue
 		}
 
 		providerID, err := res.GetString("provider_id")
 		if err != nil {
-			log.Warnf("CostModel.ComputeAllocation: Node RAM cost query result missing field: %s", err)
+			log.Warnf("CostModel.ComputeAllocation: Node RAM cost query result missing field: \"%s\" for node \"%s\"", err, node)
 			continue
 		}
 
@@ -1487,19 +1487,19 @@ func applyNodeCostPerGPUHr(nodeMap map[nodeKey]*nodePricing, resNodeCostPerGPUHr
 
 		node, err := res.GetString("node")
 		if err != nil {
-			log.Warnf("CostModel.ComputeAllocation: Node GPU cost query result missing field: %s", err)
+			log.Warnf("CostModel.ComputeAllocation: Node GPU cost query result missing field: \"%s\" for node \"%s\"", err, node)
 			continue
 		}
 
 		instanceType, err := res.GetString("instance_type")
 		if err != nil {
-			log.Warnf("CostModel.ComputeAllocation: Node GPU cost query result missing field: %s", err)
+			log.Warnf("CostModel.ComputeAllocation: Node GPU cost query result missing field: \"%s\" for node \"%s\"", err, node)
 			continue
 		}
 
 		providerID, err := res.GetString("provider_id")
 		if err != nil {
-			log.Warnf("CostModel.ComputeAllocation: Node GPU cost query result missing field: %s", err)
+			log.Warnf("CostModel.ComputeAllocation: Node GPU cost query result missing field: \"%s\" for node \"%s\"", err, node)
 			continue
 		}
 
@@ -1531,7 +1531,7 @@ func applyNodeSpot(nodeMap map[nodeKey]*nodePricing, resNodeIsSpot []*prom.Query
 
 		key := newNodeKey(cluster, node)
 		if _, ok := nodeMap[key]; !ok {
-			log.Warnf("CostModel.ComputeAllocation: Node spot  query result for missing node: %s", key)
+			log.Warnf("CostModel.ComputeAllocation: Node spot query result for missing node: %s", key)
 			continue
 		}
 
@@ -1588,7 +1588,7 @@ func (cm *CostModel) applyNodesToPod(podMap map[podKey]*pod, nodeMap map[nodeKey
 
 // getCustomNodePricing converts the CostModel's configured custom pricing
 // values into a nodePricing instance.
-func (cm *CostModel) getCustomNodePricing(spot bool) *nodePricing {
+func (cm *CostModel) getCustomNodePricing(spot bool, providerID string) *nodePricing {
 	customPricingConfig, err := cm.Provider.GetConfig()
 	if err != nil {
 		return nil
@@ -1603,7 +1603,10 @@ func (cm *CostModel) getCustomNodePricing(spot bool) *nodePricing {
 		ramCostStr = customPricingConfig.SpotRAM
 	}
 
-	node := &nodePricing{Source: "custom"}
+	node := &nodePricing{
+		Source:     "custom",
+		ProviderID: providerID,
+	}
 
 	costPerCPUHr, err := strconv.ParseFloat(cpuCostStr, 64)
 	if err != nil {
@@ -1639,7 +1642,7 @@ func (cm *CostModel) getNodePricing(nodeMap map[nodeKey]*nodePricing, nodeKey no
 		if nodeKey.Node != "" {
 			log.DedupedWarningf(5, "CostModel: failed to find node for %s", nodeKey)
 		}
-		return cm.getCustomNodePricing(false)
+		return cm.getCustomNodePricing(false, "")
 	}
 
 	// If custom pricing is enabled and can be retrieved, override detected
@@ -1649,7 +1652,7 @@ func (cm *CostModel) getNodePricing(nodeMap map[nodeKey]*nodePricing, nodeKey no
 		log.Warnf("CostModel: failed to load custom pricing: %s", err)
 	}
 	if cloud.CustomPricesEnabled(cm.Provider) && customPricingConfig != nil {
-		return cm.getCustomNodePricing(node.Preemptible)
+		return cm.getCustomNodePricing(node.Preemptible, node.ProviderID)
 	}
 
 	node.Source = "prometheus"

+ 2 - 1
pkg/costmodel/costmodel.go

@@ -2295,7 +2295,7 @@ func measureTimeAsync(start time.Time, threshold time.Duration, name string, ch
 	}
 }
 
-func (cm *CostModel) QueryAllocation(window kubecost.Window, resolution, step time.Duration, aggregate []string, includeIdle, idleByNode, includeProportionalAssetResourceCosts bool) (*kubecost.AllocationSetRange, error) {
+func (cm *CostModel) QueryAllocation(window kubecost.Window, resolution, step time.Duration, aggregate []string, includeIdle, idleByNode, includeProportionalAssetResourceCosts, includeAggregatedMetadata bool) (*kubecost.AllocationSetRange, error) {
 	// Validate window is legal
 	if window.IsOpen() || window.IsNegative() {
 		return nil, fmt.Errorf("illegal window: %s", window)
@@ -2347,6 +2347,7 @@ func (cm *CostModel) QueryAllocation(window kubecost.Window, resolution, step ti
 	opts := &kubecost.AllocationAggregationOptions{
 		IncludeProportionalAssetResourceCosts: includeProportionalAssetResourceCosts,
 		IdleByNode:                            idleByNode,
+		IncludeAggregatedMetadata:             includeAggregatedMetadata,
 	}
 
 	// Aggregate

+ 4 - 1
pkg/costmodel/router.go

@@ -278,11 +278,14 @@ func WrapData(data interface{}, err error) []byte {
 			Data:    data,
 		})
 	} else {
-		resp, _ = json.Marshal(&Response{
+		resp, err = json.Marshal(&Response{
 			Code:   http.StatusOK,
 			Status: "success",
 			Data:   data,
 		})
+		if err != nil {
+			log.Errorf("error marshaling response json: %s", err.Error())
+		}
 	}
 
 	return resp

+ 18 - 0
pkg/env/costmodelenv.go

@@ -18,6 +18,9 @@ const (
 	AlibabaAccessKeyIDEnvVar     = "ALIBABA_ACCESS_KEY_ID"
 	AlibabaAccessKeySecretEnvVar = "ALIBABA_SECRET_ACCESS_KEY"
 
+	AzureOfferIDEnvVar        = "AZURE_OFFER_ID"
+	AzureBillingAccountEnvVar = "AZURE_BILLING_ACCOUNT"
+
 	KubecostNamespaceEnvVar        = "KUBECOST_NAMESPACE"
 	PodNameEnvVar                  = "POD_NAME"
 	ClusterIDEnvVar                = "CLUSTER_ID"
@@ -242,6 +245,21 @@ func GetAlibabaAccessKeySecret() string {
 	return Get(AlibabaAccessKeySecretEnvVar, "")
 }
 
+// GetAzureOfferID returns the environment variable value for AzureOfferIDEnvVar which represents
+// the Azure offer ID for determining prices.
+func GetAzureOfferID() string {
+	return Get(AzureOfferIDEnvVar, "")
+}
+
+// GetAzureBillingAccount returns the environment variable value for
+// AzureBillingAccountEnvVar which represents the Azure billing
+// account for determining prices. If this is specified
+// customer-specific prices will be downloaded from the consumption
+// price sheet API.
+func GetAzureBillingAccount() string {
+	return Get(AzureBillingAccountEnvVar, "")
+}
+
 // GetKubecostNamespace returns the environment variable value for KubecostNamespaceEnvVar which
 // represents the namespace the cost model exists in.
 func GetKubecostNamespace() string {

+ 119 - 0
pkg/filter/util/cloudcost.go

@@ -0,0 +1,119 @@
+package util
+
+import (
+	"strings"
+
+	"github.com/opencost/opencost/pkg/filter"
+	"github.com/opencost/opencost/pkg/kubecost"
+	"github.com/opencost/opencost/pkg/log"
+	"github.com/opencost/opencost/pkg/util/mapper"
+)
+
+func parseWildcardEnd(rawFilterValue string) (string, bool) {
+	return strings.TrimSuffix(rawFilterValue, "*"), strings.HasSuffix(rawFilterValue, "*")
+}
+
+func CloudCostFilterFromParams(pmr mapper.PrimitiveMapReader) filter.Filter[*kubecost.CloudCost] {
+	filter := filter.And[*kubecost.CloudCost]{
+		Filters: []filter.Filter[*kubecost.CloudCost]{},
+	}
+
+	if raw := pmr.GetList("filterInvoiceEntityIDs", ","); len(raw) > 0 {
+		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostInvoiceEntityIDProp))
+	}
+
+	if raw := pmr.GetList("filterAccountIDs", ","); len(raw) > 0 {
+		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostAccountIDProp))
+	}
+
+	if raw := pmr.GetList("filterProviders", ","); len(raw) > 0 {
+		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostProviderProp))
+	}
+
+	if raw := pmr.GetList("filterProviderIDs", ","); len(raw) > 0 {
+		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostProviderIDProp))
+	}
+
+	if raw := pmr.GetList("filterServices", ","); len(raw) > 0 {
+		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostServiceProp))
+	}
+
+	if raw := pmr.GetList("filterCategories", ","); len(raw) > 0 {
+		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostCategoryProp))
+	}
+
+	if raw := pmr.GetList("filterLabels", ","); len(raw) > 0 {
+		filter.Filters = append(filter.Filters, filterV1DoubleValueFromList(raw, kubecost.CloudCostLabelProp))
+	}
+
+	if len(filter.Filters) == 0 {
+		return nil
+	}
+
+	return filter
+}
+
+func filterV1SingleValueFromList(rawFilterValues []string, field string) filter.Filter[*kubecost.CloudCost] {
+	result := filter.Or[*kubecost.CloudCost]{
+		Filters: []filter.Filter[*kubecost.CloudCost]{},
+	}
+
+	for _, filterValue := range rawFilterValues {
+		filterValue = strings.TrimSpace(filterValue)
+		filterValue, wildcard := parseWildcardEnd(filterValue)
+
+		subFilter := filter.StringProperty[*kubecost.CloudCost]{
+			Field: field,
+			Op:    filter.StringEquals,
+			Value: filterValue,
+		}
+
+		if wildcard {
+			subFilter.Op = kubecost.FilterStartsWith
+		}
+
+		result.Filters = append(result.Filters, subFilter)
+	}
+
+	return result
+}
+
+// filterV1DoubleValueFromList creates an OR of key:value equality filters for
+// colon-split filter values.
+//
+// The v1 query language (e.g. "filterLabels=app:foo,l2:bar") uses OR within
+// a field (e.g. label[app] = foo OR label[l2] = bar)
+func filterV1DoubleValueFromList(rawFilterValuesUnsplit []string, filterField string) filter.Filter[*kubecost.CloudCost] {
+	result := filter.Or[*kubecost.CloudCost]{
+		Filters: []filter.Filter[*kubecost.CloudCost]{},
+	}
+
+	for _, unsplit := range rawFilterValuesUnsplit {
+		if unsplit != "" {
+			split := strings.Split(unsplit, ":")
+			if len(split) != 2 {
+				log.Warnf("illegal key/value filter (ignoring): %s", unsplit)
+				continue
+			}
+			labelName := strings.TrimSpace(split[0])
+			val := strings.TrimSpace(split[1])
+			val, wildcard := parseWildcardEnd(val)
+
+			subFilter := filter.StringMapProperty[*kubecost.CloudCost]{
+				Field: filterField,
+				// All v1 filters are equality comparisons
+				Op:    filter.StringMapEquals,
+				Key:   labelName,
+				Value: val,
+			}
+
+			if wildcard {
+				subFilter.Op = filter.StringMapStartsWith
+			}
+
+			result.Filters = append(result.Filters, subFilter)
+		}
+	}
+
+	return result
+}

+ 0 - 70
pkg/filter/util/cloudcostaggregate.go

@@ -1,70 +0,0 @@
-package util
-
-import (
-	"strings"
-
-	"github.com/opencost/opencost/pkg/filter"
-	"github.com/opencost/opencost/pkg/kubecost"
-	"github.com/opencost/opencost/pkg/util/mapper"
-)
-
-func parseWildcardEnd(rawFilterValue string) (string, bool) {
-	return strings.TrimSuffix(rawFilterValue, "*"), strings.HasSuffix(rawFilterValue, "*")
-}
-
-func CloudCostAggregateFilterFromParams(pmr mapper.PrimitiveMapReader) filter.Filter[*kubecost.CloudCostAggregate] {
-	filter := filter.And[*kubecost.CloudCostAggregate]{
-		Filters: []filter.Filter[*kubecost.CloudCostAggregate]{},
-	}
-
-	if raw := pmr.GetList("filterBillingIDs", ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostBillingIDProp))
-	}
-
-	if raw := pmr.GetList("filterWorkGroupIDs", ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostWorkGroupIDProp))
-	}
-
-	if raw := pmr.GetList("filterProviders", ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostProviderProp))
-	}
-
-	if raw := pmr.GetList("filterServices", ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostServiceProp))
-	}
-
-	if raw := pmr.GetList("filterLabelValues", ","); len(raw) > 0 {
-		filter.Filters = append(filter.Filters, filterV1SingleValueFromList(raw, kubecost.CloudCostLabelProp))
-	}
-
-	if len(filter.Filters) == 0 {
-		return nil
-	}
-
-	return filter
-}
-
-func filterV1SingleValueFromList(rawFilterValues []string, field string) filter.Filter[*kubecost.CloudCostAggregate] {
-	result := filter.Or[*kubecost.CloudCostAggregate]{
-		Filters: []filter.Filter[*kubecost.CloudCostAggregate]{},
-	}
-
-	for _, filterValue := range rawFilterValues {
-		filterValue = strings.TrimSpace(filterValue)
-		filterValue, wildcard := parseWildcardEnd(filterValue)
-
-		subFilter := filter.StringProperty[*kubecost.CloudCostAggregate]{
-			Field: field,
-			Op:    filter.StringEquals,
-			Value: filterValue,
-		}
-
-		if wildcard {
-			subFilter.Op = kubecost.FilterStartsWith
-		}
-
-		result.Filters = append(result.Filters, subFilter)
-	}
-
-	return result
-}

+ 44 - 21
pkg/kubecost/allocation.go

@@ -86,7 +86,7 @@ type Allocation struct {
 	// 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"`
+	ProportionalAssetResourceCosts ProportionalAssetResourceCosts `json:"proportionalAssetResourceCosts"` //@bingen:field[ignore]
 }
 
 // RawAllocationOnlyData is information that only belong in "raw" Allocations,
@@ -247,12 +247,13 @@ func (pva *PVAllocation) Equal(that *PVAllocation) bool {
 }
 
 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"`
+	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"`
+	NodeResourceCostPercentage float64 `json:"nodeResourceCostPercentage"`
 }
 
 func (parc ProportionalAssetResourceCost) Key(insertByNode bool) string {
@@ -273,12 +274,13 @@ func (parcs ProportionalAssetResourceCosts) Insert(parc ProportionalAssetResourc
 	}
 	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,
+			Node:                       curr.Node,
+			Cluster:                    curr.Cluster,
+			ProviderID:                 curr.ProviderID,
+			CPUPercentage:              curr.CPUPercentage + parc.CPUPercentage,
+			GPUPercentage:              curr.GPUPercentage + parc.GPUPercentage,
+			RAMPercentage:              curr.RAMPercentage + parc.RAMPercentage,
+			NodeResourceCostPercentage: curr.NodeResourceCostPercentage + parc.NodeResourceCostPercentage,
 		}
 	} else {
 		parcs[parc.Key(insertByNode)] = parc
@@ -921,6 +923,7 @@ type AllocationAggregationOptions struct {
 	ShareSplit                            string
 	SharedHourlyCosts                     map[string]float64
 	SplitIdle                             bool
+	IncludeAggregatedMetadata             bool
 }
 
 // AggregateBy aggregates the Allocations in the given AllocationSet by the given
@@ -1041,6 +1044,10 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 	// them to their respective sets, removing them from the set of allocations
 	// to aggregate.
 	for _, alloc := range as.Allocations {
+		// if the user does not want any aggregated labels/annotations returned
+		// set the properties accordingly
+		alloc.Properties.AggregatedMetadata = options.IncludeAggregatedMetadata
+
 		// External allocations get aggregated post-hoc (see step 6) and do
 		// not necessarily contain complete sets of properties, so they are
 		// moved to a separate AllocationSet.
@@ -1159,7 +1166,7 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 
 			// Attempt to derive proportional asset resource costs from idle
 			// coefficients, and insert them into the set if successful.
-			parc, err := deriveProportionalAssetResourceCostsFromIdleCoefficients(parcCoefficients, alloc, options)
+			parc, err := deriveProportionalAssetResourceCostsFromIdleCoefficients(parcCoefficients, allocatedTotalsMap, 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
@@ -1734,7 +1741,7 @@ func computeIdleCoeffs(options *AllocationAggregationOptions, as *AllocationSet,
 	return coeffs, totals, nil
 }
 
-func deriveProportionalAssetResourceCostsFromIdleCoefficients(idleCoeffs map[string]map[string]map[string]float64, allocation *Allocation, options *AllocationAggregationOptions) (ProportionalAssetResourceCost, error) {
+func deriveProportionalAssetResourceCostsFromIdleCoefficients(idleCoeffs map[string]map[string]map[string]float64, totals 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)
@@ -1752,13 +1759,29 @@ func deriveProportionalAssetResourceCostsFromIdleCoefficients(idleCoeffs map[str
 	gpuPct := idleCoeffs[idleId][allocation.Name]["gpu"]
 	ramPct := idleCoeffs[idleId][allocation.Name]["ram"]
 
+	// compute how much each component (cpu, gpu, ram) contributes to the overall price
+	totalCost := totals[idleId]["ram"] + totals[idleId]["gpu"] + totals[idleId]["cpu"]
+
+	var ramFraction, cpuFraction, gpuFraction float64
+
+	// only compute fraction if totalCost is nonzero, otherwise returns in NaN
+	if totalCost > 0 {
+		ramFraction = totals[idleId]["ram"] / totalCost
+		cpuFraction = totals[idleId]["cpu"] / totalCost
+		gpuFraction = totals[idleId]["gpu"] / totalCost
+	}
+
+	// compute the resource usage percentage based on the weighted fractions
+	nodeResourceCostPercentage := (ramPct * ramFraction) + (cpuPct * cpuFraction) + (gpuPct * gpuFraction)
+
 	return ProportionalAssetResourceCost{
-		Cluster:       allocation.Properties.Cluster,
-		Node:          allocation.Properties.Node,
-		ProviderID:    allocation.Properties.ProviderID,
-		CPUPercentage: cpuPct,
-		GPUPercentage: gpuPct,
-		RAMPercentage: ramPct,
+		Cluster:                    allocation.Properties.Cluster,
+		Node:                       allocation.Properties.Node,
+		ProviderID:                 allocation.Properties.ProviderID,
+		CPUPercentage:              cpuPct,
+		GPUPercentage:              gpuPct,
+		RAMPercentage:              ramPct,
+		NodeResourceCostPercentage: nodeResourceCostPercentage,
 	}, nil
 }
 

+ 2 - 2
pkg/kubecost/allocation_json.go

@@ -53,8 +53,8 @@ type AllocationJSON struct {
 	SharedCost                     *float64                        `json:"sharedCost"`
 	TotalCost                      *float64                        `json:"totalCost"`
 	TotalEfficiency                *float64                        `json:"totalEfficiency"`
-	RawAllocationOnly              *RawAllocationOnlyData          `json:"rawAllocationOnly,omitEmpty"`
-	ProportionalAssetResourceCosts *ProportionalAssetResourceCosts `json:"proportionalAssetResourceCosts,omitEmpty"`
+	RawAllocationOnly              *RawAllocationOnlyData          `json:"rawAllocationOnly,omitempty"`
+	ProportionalAssetResourceCosts *ProportionalAssetResourceCosts `json:"proportionalAssetResourceCosts,omitempty"`
 }
 
 func (aj *AllocationJSON) BuildFromAllocation(a *Allocation) {

+ 151 - 60
pkg/kubecost/allocation_test.go

@@ -4,9 +4,11 @@ import (
 	"fmt"
 	"math"
 	"reflect"
+	"strings"
 	"testing"
 	"time"
 
+	"github.com/davecgh/go-spew/spew"
 	"github.com/opencost/opencost/pkg/util"
 	"github.com/opencost/opencost/pkg/util/json"
 )
@@ -1074,30 +1076,33 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			expectedParcResults: map[string]ProportionalAssetResourceCosts{
 				"namespace1": ProportionalAssetResourceCosts{
 					"cluster1": ProportionalAssetResourceCost{
-						Cluster:       "cluster1",
-						Node:          "",
-						ProviderID:    "",
-						CPUPercentage: 0.5,
-						GPUPercentage: 0.5,
-						RAMPercentage: 0.8125,
+						Cluster:                    "cluster1",
+						Node:                       "",
+						ProviderID:                 "",
+						CPUPercentage:              0.5,
+						GPUPercentage:              0.5,
+						RAMPercentage:              0.8125,
+						NodeResourceCostPercentage: 0.6785714285714285,
 					},
 				},
 				"namespace2": ProportionalAssetResourceCosts{
 					"cluster1": ProportionalAssetResourceCost{
-						Cluster:       "cluster1",
-						Node:          "",
-						ProviderID:    "",
-						CPUPercentage: 0.5,
-						GPUPercentage: 0.5,
-						RAMPercentage: 0.1875,
+						Cluster:                    "cluster1",
+						Node:                       "",
+						ProviderID:                 "",
+						CPUPercentage:              0.5,
+						GPUPercentage:              0.5,
+						RAMPercentage:              0.1875,
+						NodeResourceCostPercentage: 0.3214285714285714,
 					},
 					"cluster2": ProportionalAssetResourceCost{
-						Cluster:       "cluster2",
-						Node:          "",
-						ProviderID:    "",
-						CPUPercentage: 0.5,
-						GPUPercentage: 0.5,
-						RAMPercentage: 0.5,
+						Cluster:                    "cluster2",
+						Node:                       "",
+						ProviderID:                 "",
+						CPUPercentage:              0.5,
+						GPUPercentage:              0.5,
+						RAMPercentage:              0.5,
+						NodeResourceCostPercentage: 0.5,
 					},
 				},
 			},
@@ -1509,64 +1514,71 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			expectedParcResults: map[string]ProportionalAssetResourceCosts{
 				"namespace1": {
 					"cluster1,c1nodes": ProportionalAssetResourceCost{
-						Cluster:       "cluster1",
-						Node:          "c1nodes",
-						ProviderID:    "c1nodes",
-						CPUPercentage: 0.5,
-						GPUPercentage: 0.5,
-						RAMPercentage: 0.8125,
+						Cluster:                    "cluster1",
+						Node:                       "c1nodes",
+						ProviderID:                 "c1nodes",
+						CPUPercentage:              0.5,
+						GPUPercentage:              0.5,
+						RAMPercentage:              0.8125,
+						NodeResourceCostPercentage: 0.6785714285714285,
 					},
 					"cluster2,node2": ProportionalAssetResourceCost{
-						Cluster:       "cluster2",
-						Node:          "node2",
-						ProviderID:    "node2",
-						CPUPercentage: 0.5,
-						GPUPercentage: 0.5,
-						RAMPercentage: 0.5,
+						Cluster:                    "cluster2",
+						Node:                       "node2",
+						ProviderID:                 "node2",
+						CPUPercentage:              0.5,
+						GPUPercentage:              0.5,
+						RAMPercentage:              0.5,
+						NodeResourceCostPercentage: 0.5,
 					},
 				},
 				"namespace2": {
 					"cluster1,c1nodes": ProportionalAssetResourceCost{
-						Cluster:       "cluster1",
-						Node:          "c1nodes",
-						ProviderID:    "c1nodes",
-						CPUPercentage: 0.5,
-						GPUPercentage: 0.5,
-						RAMPercentage: 0.1875,
+						Cluster:                    "cluster1",
+						Node:                       "c1nodes",
+						ProviderID:                 "c1nodes",
+						CPUPercentage:              0.5,
+						GPUPercentage:              0.5,
+						RAMPercentage:              0.1875,
+						NodeResourceCostPercentage: 0.3214285714285714,
 					},
 					"cluster2,node1": ProportionalAssetResourceCost{
-						Cluster:       "cluster2",
-						Node:          "node1",
-						ProviderID:    "node1",
-						CPUPercentage: 1,
-						GPUPercentage: 1,
-						RAMPercentage: 1,
+						Cluster:                    "cluster2",
+						Node:                       "node1",
+						ProviderID:                 "node1",
+						CPUPercentage:              1,
+						GPUPercentage:              1,
+						RAMPercentage:              1,
+						NodeResourceCostPercentage: 1,
 					},
 					"cluster2,node2": ProportionalAssetResourceCost{
-						Cluster:       "cluster2",
-						Node:          "node2",
-						ProviderID:    "node2",
-						CPUPercentage: 0.5,
-						GPUPercentage: 0.5,
-						RAMPercentage: 0.5,
+						Cluster:                    "cluster2",
+						Node:                       "node2",
+						ProviderID:                 "node2",
+						CPUPercentage:              0.5,
+						GPUPercentage:              0.5,
+						RAMPercentage:              0.5,
+						NodeResourceCostPercentage: 0.5,
 					},
 				},
 				"namespace3": {
 					"cluster2,node3": ProportionalAssetResourceCost{
-						Cluster:       "cluster2",
-						Node:          "node3",
-						ProviderID:    "node3",
-						CPUPercentage: 1,
-						GPUPercentage: 1,
-						RAMPercentage: 1,
+						Cluster:                    "cluster2",
+						Node:                       "node3",
+						ProviderID:                 "node3",
+						CPUPercentage:              1,
+						GPUPercentage:              1,
+						RAMPercentage:              1,
+						NodeResourceCostPercentage: 1,
 					},
 					"cluster2,node2": ProportionalAssetResourceCost{
-						Cluster:       "cluster2",
-						Node:          "node2",
-						ProviderID:    "node2",
-						CPUPercentage: 0.5,
-						GPUPercentage: 0.5,
-						RAMPercentage: 0.5,
+						Cluster:                    "cluster2",
+						Node:                       "node2",
+						ProviderID:                 "node2",
+						CPUPercentage:              0.5,
+						GPUPercentage:              0.5,
+						RAMPercentage:              0.5,
+						NodeResourceCostPercentage: 0.5,
 					},
 				},
 			},
@@ -2757,3 +2769,82 @@ func TestAllocationSet_Accumulate_Equals_AllocationSetRange_Accumulate(t *testin
 		}
 	}
 }
+
+func Test_AggregateByService_UnmountedLBs(t *testing.T) {
+	end := time.Now().UTC().Truncate(day)
+	start := end.Add(-day)
+
+	normalProps := &AllocationProperties{
+		Cluster:        "cluster-one",
+		Container:      "nginx-plus-nginx-ingress",
+		Controller:     "nginx-plus-nginx-ingress",
+		ControllerKind: "deployment",
+		Namespace:      "nginx-plus",
+		Pod:            "nginx-plus-nginx-ingress-123a4b5678-ab12c",
+		ProviderID:     "test",
+		Node:           "testnode",
+		Services: []string{
+			"nginx-plus-nginx-ingress",
+		},
+	}
+
+	problematicProps := &AllocationProperties{
+		Cluster:    "cluster-one",
+		Container:  UnmountedSuffix,
+		Namespace:  UnmountedSuffix,
+		Pod:        UnmountedSuffix,
+		ProviderID: "test",
+		Node:       "testnode",
+		Services: []string{
+			"nginx-plus-nginx-ingress",
+			"ingress-nginx-controller",
+			"pacman",
+		},
+	}
+
+	idle := NewMockUnitAllocation(fmt.Sprintf("cluster-one/%s", IdleSuffix), start, day, &AllocationProperties{
+		Cluster: "cluster-one",
+	})
+	// this allocation is the main point of the test; an unmounted LB that has services
+	problematicAllocation := NewMockUnitAllocation("cluster-one//__unmounted__/__unmounted__/__unmounted__", start, day, problematicProps)
+
+	two := NewMockUnitAllocation("cluster-one//nginx-plus/nginx-plus-nginx-ingress-123a4b5678-ab12c/nginx-plus-nginx-ingress", start, day, normalProps)
+	three := NewMockUnitAllocation("cluster-one//nginx-plus/nginx-plus-nginx-ingress-123a4b5678-ab12c/nginx-plus-nginx-ingress", start, day, normalProps)
+	four := NewMockUnitAllocation("cluster-one//nginx-plus/nginx-plus-nginx-ingress-123a4b5678-ab12c/nginx-plus-nginx-ingress", start, day, normalProps)
+
+	problematicAllocation.ExternalCost = 2.35
+	two.ExternalCost = 1.35
+	three.ExternalCost = 2.60
+	four.ExternalCost = 4.30
+	set := NewAllocationSet(start, start.Add(day), problematicAllocation, two, three, four)
+
+	set.Insert(idle)
+
+	set.AggregateBy([]string{AllocationServiceProp}, &AllocationAggregationOptions{
+		Filter: AllocationFilterCondition{Field: FilterServices, Op: FilterContains, Value: "nginx-plus-nginx-ingress"},
+	})
+
+	for _, alloc := range set.Allocations {
+		if !strings.Contains(UnmountedSuffix, alloc.Name) {
+			props := alloc.Properties
+			if props.Cluster == UnmountedSuffix {
+				t.Error("cluster unmounted")
+			}
+			if props.Container == UnmountedSuffix {
+				t.Error("container unmounted")
+			}
+			if props.Namespace == UnmountedSuffix {
+				t.Error("namespace unmounted")
+			}
+			if props.Pod == UnmountedSuffix {
+				t.Error("pod unmounted")
+			}
+			if props.Controller == UnmountedSuffix {
+				t.Error("controller unmounted")
+			}
+		}
+	}
+
+	spew.Config.DisableMethods = true
+	t.Logf("%s", spew.Sdump(set.Allocations))
+}

+ 53 - 4
pkg/kubecost/allocationprops.go

@@ -103,6 +103,9 @@ type AllocationProperties struct {
 	ProviderID     string                `json:"providerID,omitempty"`
 	Labels         AllocationLabels      `json:"labels,omitempty"`
 	Annotations    AllocationAnnotations `json:"annotations,omitempty"`
+	// When set to true, maintain the intersection of all labels + annotations
+	// in the aggregated AllocationProperties object
+	AggregatedMetadata bool `json:"-"`
 }
 
 // AllocationLabels is a schema-free mapping of key/value pairs that can be
@@ -285,10 +288,18 @@ func (p *AllocationProperties) GenerateKey(aggregateBy []string, labelConfig *La
 				// Indicate that allocation has no services
 				names = append(names, UnallocatedSuffix)
 			} else {
-				// This just uses the first service
-				for _, service := range services {
-					names = append(names, service)
-					break
+				// Unmounted load balancers lead to __unmounted__ Allocations whose
+				// services field is populated. If we don't have a special case, the
+				// __unmounted__ Allocation will be transformed into a regular Allocation,
+				// causing issues with AggregateBy and drilldown
+				if p.Pod == UnmountedSuffix || p.Namespace == UnmountedSuffix || p.Container == UnmountedSuffix {
+					names = append(names, UnmountedSuffix)
+				} else {
+					// This just uses the first service
+					for _, service := range services {
+						names = append(names, service)
+						break
+					}
 				}
 			}
 		case strings.HasPrefix(agg, "label:"):
@@ -439,7 +450,31 @@ func (p *AllocationProperties) Intersection(that *AllocationProperties) *Allocat
 		intersectionProps.ControllerKind = p.ControllerKind
 	}
 	if p.Namespace == that.Namespace {
+
 		intersectionProps.Namespace = p.Namespace
+		// ignore the incoming labels from unallocated or unmounted special case pods
+		if p.AggregatedMetadata || that.AggregatedMetadata {
+			intersectionProps.AggregatedMetadata = true
+
+			// When aggregating by metadata, we maintain the intersection of the labels/annotations
+			// of the two AllocationProperties objects being intersected here.
+			// Special case unallocated/unmounted Allocations never have any labels or annotations.
+			// As a result, they have the effect of always clearing out the intersection,
+			// regardless if all the other actual allocations/etc have them.
+			// This logic is designed to effectively ignore the unmounted/unallocated objects
+			// and just copy over the labels from the other object - we only take the intersection
+			// of 'legitimate' allocations.
+			if p.Container == UnmountedSuffix {
+				intersectionProps.Annotations = that.Annotations
+				intersectionProps.Labels = that.Labels
+			} else if that.Container == UnmountedSuffix {
+				intersectionProps.Annotations = p.Annotations
+				intersectionProps.Labels = p.Labels
+			} else {
+				intersectionProps.Annotations = mapIntersection(p.Annotations, that.Annotations)
+				intersectionProps.Labels = mapIntersection(p.Labels, that.Labels)
+			}
+		}
 	}
 	if p.Pod == that.Pod {
 		intersectionProps.Pod = p.Pod
@@ -450,6 +485,20 @@ func (p *AllocationProperties) Intersection(that *AllocationProperties) *Allocat
 	return intersectionProps
 }
 
+func mapIntersection(map1, map2 map[string]string) map[string]string {
+	result := make(map[string]string)
+	for key, value := range map1 {
+		if value2, ok := map2[key]; ok {
+			if value2 == value {
+				result[key] = value
+			}
+		}
+
+	}
+
+	return result
+}
+
 func (p *AllocationProperties) String() string {
 	if p == nil {
 		return "<nil>"

+ 130 - 0
pkg/kubecost/allocationprops_test.go

@@ -5,6 +5,136 @@ import (
 	"testing"
 )
 
+func TestAllocationPropsIntersection(t *testing.T) {
+	cases := map[string]struct {
+		allocationProps1 *AllocationProperties
+		allocationProps2 *AllocationProperties
+		expected         *AllocationProperties
+	}{
+		"intersection two allocation properties with empty labels/annotations": {
+			allocationProps1: &AllocationProperties{
+				Labels:      map[string]string{},
+				Annotations: map[string]string{},
+			},
+			allocationProps2: &AllocationProperties{
+				Labels:      map[string]string{},
+				Annotations: map[string]string{},
+			},
+			expected: &AllocationProperties{
+				Labels:      nil,
+				Annotations: nil,
+			},
+		},
+		"nil intersection": {
+			allocationProps1: nil,
+			allocationProps2: nil,
+			expected:         nil,
+		},
+		"intersection, with labels/annotations, no aggregated metdata": {
+			allocationProps1: &AllocationProperties{
+				AggregatedMetadata: false,
+				Node:               "node1",
+				Labels:             map[string]string{"key1": "val1"},
+				Annotations:        map[string]string{"key2": "val2"},
+			},
+			allocationProps2: &AllocationProperties{
+				AggregatedMetadata: false,
+				Node:               "node1",
+				Labels:             map[string]string{"key3": "val3"},
+				Annotations:        map[string]string{"key4": "val4"},
+			},
+			expected: &AllocationProperties{
+				AggregatedMetadata: false,
+				Node:               "node1",
+				Labels:             nil,
+				Annotations:        nil,
+			},
+		},
+		"intersection, with labels/annotations, with aggregated metdata": {
+			allocationProps1: &AllocationProperties{
+				AggregatedMetadata: false,
+				ControllerKind:     "controller1",
+				Namespace:          "ns1",
+				Labels:             map[string]string{"key1": "val1"},
+				Annotations:        map[string]string{"key2": "val2"},
+			},
+			allocationProps2: &AllocationProperties{
+				AggregatedMetadata: true,
+				ControllerKind:     "controller2",
+				Namespace:          "ns1",
+				Labels:             map[string]string{"key1": "val1"},
+				Annotations:        map[string]string{"key2": "val2"},
+			},
+			expected: &AllocationProperties{
+				AggregatedMetadata: true,
+				Namespace:          "ns1",
+				ControllerKind:     "",
+				Labels:             map[string]string{"key1": "val1"},
+				Annotations:        map[string]string{"key2": "val2"},
+			},
+		},
+		"intersection, with labels/annotations, special case container": {
+			allocationProps1: &AllocationProperties{
+				AggregatedMetadata: false,
+				Container:          UnmountedSuffix,
+				Namespace:          "ns1",
+				Labels:             map[string]string{},
+				Annotations:        map[string]string{},
+			},
+			allocationProps2: &AllocationProperties{
+				AggregatedMetadata: true,
+				Container:          "container3",
+				Namespace:          "ns1",
+				Labels:             map[string]string{"key1": "val1"},
+				Annotations:        map[string]string{"key2": "val2"},
+			},
+			expected: &AllocationProperties{
+				AggregatedMetadata: true,
+				Namespace:          "ns1",
+				ControllerKind:     "",
+				Labels:             map[string]string{"key1": "val1"},
+				Annotations:        map[string]string{"key2": "val2"},
+			},
+		},
+		"test services are nulled when intersecting": {
+			allocationProps1: &AllocationProperties{
+				AggregatedMetadata: false,
+				Container:          UnmountedSuffix,
+				Namespace:          "ns1",
+				Services: []string{
+					"cool",
+				},
+				Labels:      map[string]string{},
+				Annotations: map[string]string{},
+			},
+			allocationProps2: &AllocationProperties{
+				AggregatedMetadata: true,
+				Container:          "container3",
+				Namespace:          "ns1",
+				Labels:             map[string]string{"key1": "val1"},
+				Annotations:        map[string]string{"key2": "val2"},
+			},
+			expected: &AllocationProperties{
+				AggregatedMetadata: true,
+				Namespace:          "ns1",
+				ControllerKind:     "",
+				Labels:             map[string]string{"key1": "val1"},
+				Annotations:        map[string]string{"key2": "val2"},
+			},
+		},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+
+			actual := tc.allocationProps1.Intersection(tc.allocationProps2)
+
+			if !reflect.DeepEqual(actual, tc.expected) {
+				t.Fatalf("test case %s: expected %+v; got %+v", name, tc.expected, actual)
+			}
+		})
+	}
+}
 func TestGenerateKey(t *testing.T) {
 
 	customOwnerLabelConfig := NewLabelConfig()

+ 0 - 29
pkg/kubecost/asset.go

@@ -2158,22 +2158,6 @@ func (n *Node) GPUs() float64 {
 	return n.GPUHours * (60.0 / n.Minutes())
 }
 
-func (n *Node) MonitoringKey() string {
-	nodeProps := n.GetProperties()
-	if nodeProps == nil {
-		return ""
-	}
-	//TO-DO: For Alibaba investigate why cloudCost ProviderID doesnt match Kubecost ProviderID via Kubernetes API
-	if nodeProps.Provider == AlibabaProvider {
-		aliProviderID := strings.Split(nodeProps.ProviderID, ".")
-		if len(aliProviderID) != 2 {
-			return ""
-		}
-		return nodeProps.Provider + "/" + aliProviderID[1]
-	}
-	return nodeProps.Provider + "/" + nodeProps.ProviderID
-}
-
 // LoadBalancer is an Asset representing a single load balancer in a cluster
 // TODO: add GB of ingress processed, numForwardingRules once we start recording those to prometheus metric
 type LoadBalancer struct {
@@ -3180,19 +3164,6 @@ func (as *AssetSet) accumulate(that *AssetSet) (*AssetSet, error) {
 	return acc, nil
 }
 
-func (as *AssetSet) MonitoredNodeForCloudCostItem(cci *CloudCostItem) *Node {
-	for _, node := range as.Nodes {
-		if node.MonitoringKey() == cci.MonitoringKey() {
-			props := node.GetProperties()
-			if props == nil {
-				continue
-			}
-			return node
-		}
-	}
-	return nil
-}
-
 type DiffKind string
 
 const (

+ 7 - 14
pkg/kubecost/bingen.go

@@ -73,20 +73,13 @@ package kubecost
 // @bingen:generate:AuditSetRange
 // @bingen:end
 
-// @bingen:set[name=CloudCostAggregate,version=2]
-// @bingen:generate:CloudCostAggregate
-// @bingen:generate[stringtable]:CloudCostAggregateSet
-// @bingen:generate:CloudCostAggregateSetRange
-// @bingen:generate:CloudCostAggregateProperties
-// @bingen:generate:CloudCostAggregateLabels
-// @bingen:end
-
-// @bingen:set[name=CloudCostItem,version=2]
-// @bingen:generate:CloudCostItem
-// @bingen:generate[stringtable]:CloudCostItemSet
-// @bingen:generate:CloudCostItemSetRange
-// @bingen:generate:CloudCostItemProperties
-// @bingen:generate:CloudCostItemLabels
+// @bingen:set[name=CloudCost,version=1]
+// @bingen:generate:CloudCost
+// @bingen:generate:CostMetric
+// @bingen:generate[stringtable]:CloudCostSet
+// @bingen:generate:CloudCostSetRange
+// @bingen:generate:CloudCostProperties
+// @bingen:generate:CloudCostLabels
 // @bingen:end
 
 //go:generate bingen -package=kubecost -version=17 -buffer=github.com/opencost/opencost/pkg/util

+ 550 - 0
pkg/kubecost/cloudcost.go

@@ -0,0 +1,550 @@
+package kubecost
+
+import (
+	"errors"
+	"fmt"
+	"time"
+
+	"github.com/opencost/opencost/pkg/filter"
+	"github.com/opencost/opencost/pkg/log"
+)
+
+// CloudCost represents a CUR line item, identifying a cloud resource and
+// its cost over some period of time.
+type CloudCost struct {
+	Properties       *CloudCostProperties `json:"properties"`
+	Window           Window               `json:"window"`
+	ListCost         CostMetric           `json:"listCost"`
+	NetCost          CostMetric           `json:"netCost"`
+	AmortizedNetCost CostMetric           `json:"amortizedNetCost"`
+	InvoicedCost     CostMetric           `json:"invoicedCost"`
+}
+
+// NewCloudCost instantiates a new CloudCost
+func NewCloudCost(start, end time.Time, ccProperties *CloudCostProperties, kubernetesPercent, listCost, netCost, amortizedNetCost, invoicedCost float64) *CloudCost {
+	return &CloudCost{
+		Properties: ccProperties,
+		Window:     NewWindow(&start, &end),
+		ListCost: CostMetric{
+			Cost:              listCost,
+			KubernetesPercent: kubernetesPercent,
+		},
+		NetCost: CostMetric{
+			Cost:              netCost,
+			KubernetesPercent: kubernetesPercent,
+		},
+		AmortizedNetCost: CostMetric{
+			Cost:              amortizedNetCost,
+			KubernetesPercent: kubernetesPercent,
+		},
+		InvoicedCost: CostMetric{
+			Cost:              listCost,
+			KubernetesPercent: kubernetesPercent,
+		},
+	}
+}
+
+func (cc *CloudCost) Clone() *CloudCost {
+	return &CloudCost{
+		Properties:       cc.Properties.Clone(),
+		Window:           cc.Window.Clone(),
+		ListCost:         cc.ListCost.Clone(),
+		NetCost:          cc.NetCost.Clone(),
+		AmortizedNetCost: cc.AmortizedNetCost.Clone(),
+		InvoicedCost:     cc.InvoicedCost.Clone(),
+	}
+}
+
+func (cc *CloudCost) Equal(that *CloudCost) bool {
+	if that == nil {
+		return false
+	}
+
+	return cc.Properties.Equal(that.Properties) &&
+		cc.Window.Equal(that.Window) &&
+		cc.ListCost.Equal(that.ListCost) &&
+		cc.NetCost.Equal(that.NetCost) &&
+		cc.AmortizedNetCost.Equal(that.AmortizedNetCost) &&
+		cc.InvoicedCost.Equal(that.InvoicedCost)
+}
+
+func (cc *CloudCost) add(that *CloudCost) {
+	if cc == nil {
+		log.Warnf("cannot add to nil CloudCost")
+		return
+	}
+
+	// Preserve properties of cloud cost  that are matching between the two CloudCost
+	cc.Properties = cc.Properties.Intersection(that.Properties)
+
+	cc.ListCost = cc.ListCost.add(that.ListCost)
+	cc.NetCost = cc.NetCost.add(that.NetCost)
+	cc.AmortizedNetCost = cc.AmortizedNetCost.add(that.AmortizedNetCost)
+	cc.InvoicedCost = cc.InvoicedCost.add(that.InvoicedCost)
+
+	cc.Window = cc.Window.Expand(that.Window)
+}
+
+func (cc *CloudCost) StringProperty(prop string) (string, error) {
+	if cc == nil {
+		return "", nil
+	}
+
+	switch prop {
+	case CloudCostInvoiceEntityIDProp:
+		return cc.Properties.InvoiceEntityID, nil
+	case CloudCostAccountIDProp:
+		return cc.Properties.AccountID, nil
+	case CloudCostProviderProp:
+		return cc.Properties.Provider, nil
+	case CloudCostProviderIDProp:
+		return cc.Properties.ProviderID, nil
+	case CloudCostServiceProp:
+		return cc.Properties.Service, nil
+	case CloudCostCategoryProp:
+		return cc.Properties.Category, nil
+	default:
+		return "", fmt.Errorf("invalid property name: %s", prop)
+	}
+}
+
+func (cc *CloudCost) StringMapProperty(property string) (map[string]string, error) {
+	switch property {
+	case CloudCostLabelProp:
+		if cc.Properties == nil {
+			return nil, nil
+		}
+		return cc.Properties.Labels, nil
+
+	default:
+		return nil, fmt.Errorf("CloudCost: StringMapProperty: invalid property name: %s", property)
+	}
+}
+
+func (cc *CloudCost) GetCostMetric(costMetricName string) (CostMetric, error) {
+	switch costMetricName {
+	case ListCostMetric:
+		return cc.ListCost, nil
+	case NetCostMetric:
+		return cc.NetCost, nil
+	case AmortizedNetCostMetric:
+		return cc.AmortizedNetCost, nil
+	case InvoicedCostMetric:
+		return cc.InvoicedCost, nil
+	}
+	return CostMetric{}, fmt.Errorf("invalid Cost Metric: %s", costMetricName)
+}
+
+// CloudCostSet follows the established set pattern of windowed data types. It has addition metadata types that can be
+// used to preserve data consistency and be used for validation.
+// - Integration is the ID for the integration that a CloudCostSet was sourced from, this value is cleared if when a
+// set is joined with another with a different key
+// - AggregationProperties is set by the Aggregate function and ensures that any additional inserts are keyed correctly
+type CloudCostSet struct {
+	CloudCosts            map[string]*CloudCost `json:"cloudCosts"`
+	Window                Window                `json:"window"`
+	Integration           string                `json:"-"`
+	AggregationProperties []string              `json:"aggregationProperties"`
+}
+
+// NewCloudCostSet instantiates a new CloudCostSet and, optionally, inserts
+// the given list of CloudCosts
+func NewCloudCostSet(start, end time.Time, cloudCosts ...*CloudCost) *CloudCostSet {
+	ccs := &CloudCostSet{
+		CloudCosts: map[string]*CloudCost{},
+		Window:     NewWindow(&start, &end),
+	}
+
+	for _, cc := range cloudCosts {
+		ccs.Insert(cc)
+	}
+
+	return ccs
+}
+
+func (ccs *CloudCostSet) Aggregate(props []string) (*CloudCostSet, error) {
+	if ccs == nil {
+		return nil, errors.New("cannot aggregate a nil CloudCostSet")
+	}
+
+	if ccs.Window.IsOpen() {
+		return nil, fmt.Errorf("cannot aggregate a CloudCostSet with an open window: %s", ccs.Window)
+	}
+
+	// Create a new result set, with the given aggregation property
+	result := ccs.cloneSet()
+	result.AggregationProperties = props
+
+	// Insert clones of each item in the set, keyed by the given property.
+	// The underlying insert logic will add binned items together.
+	for name, cc := range ccs.CloudCosts {
+		ccClone := cc.Clone()
+		err := result.Insert(ccClone)
+		if err != nil {
+			return nil, fmt.Errorf("error aggregating %s by %v: %s", name, props, err)
+		}
+	}
+
+	return result, nil
+}
+
+func (ccs *CloudCostSet) Accumulate(that *CloudCostSet) (*CloudCostSet, error) {
+	if ccs.IsEmpty() {
+		return that.Clone(), nil
+	}
+	acc := ccs.Clone()
+	err := acc.accumulateInto(that)
+	if err == nil {
+		return nil, err
+	}
+	return acc, nil
+}
+
+// accumulateInto accumulates a the arg CloudCostSet Into the receiver
+func (ccs *CloudCostSet) accumulateInto(that *CloudCostSet) error {
+	if ccs == nil {
+		return fmt.Errorf("CloudCost: cannot accumulate into nil set")
+	}
+
+	if that.IsEmpty() {
+		return nil
+	}
+
+	if ccs.Integration != that.Integration {
+		ccs.Integration = ""
+	}
+
+	ccs.Window.Expand(that.Window)
+
+	for _, cc := range that.CloudCosts {
+		err := ccs.Insert(cc)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (ccs *CloudCostSet) Equal(that *CloudCostSet) bool {
+	if ccs.Integration != that.Integration {
+		return false
+	}
+
+	if !ccs.Window.Equal(that.Window) {
+		return false
+	}
+
+	// Check Aggregation Properties, slice order is grounds for inequality
+	if len(ccs.AggregationProperties) != len(that.AggregationProperties) {
+		return false
+	}
+	for i, prop := range ccs.AggregationProperties {
+		if that.AggregationProperties[i] != prop {
+			return false
+		}
+	}
+
+	if len(ccs.CloudCosts) != len(that.CloudCosts) {
+		return false
+	}
+
+	for k, cc := range ccs.CloudCosts {
+		if tcc, ok := that.CloudCosts[k]; !ok || !cc.Equal(tcc) {
+			return false
+		}
+	}
+
+	return true
+}
+
+func (ccs *CloudCostSet) Filter(filters filter.Filter[*CloudCost]) *CloudCostSet {
+	if ccs == nil {
+		return nil
+	}
+
+	if filters == nil {
+		return ccs.Clone()
+	}
+
+	result := ccs.cloneSet()
+
+	for _, cc := range ccs.CloudCosts {
+		if filters.Matches(cc) {
+			result.Insert(cc.Clone())
+		}
+	}
+
+	return result
+}
+
+// Insert adds a CloudCost to a CloudCostSet using its AggregationProperties and LabelConfig
+// to determine the key where it will be inserted
+func (ccs *CloudCostSet) Insert(cc *CloudCost) error {
+	if ccs == nil {
+		return fmt.Errorf("cannot insert into nil CloudCostSet")
+	}
+
+	if cc == nil {
+		return fmt.Errorf("cannot insert nil CloudCost into CloudCostSet")
+	}
+
+	if ccs.CloudCosts == nil {
+		ccs.CloudCosts = map[string]*CloudCost{}
+	}
+
+	ccKey := cc.Properties.GenerateKey(ccs.AggregationProperties)
+
+	// Add the given CloudCost to the existing entry, if there is one;
+	// otherwise just set directly into allocations
+	if _, ok := ccs.CloudCosts[ccKey]; !ok {
+		ccs.CloudCosts[ccKey] = cc.Clone()
+	} else {
+		ccs.CloudCosts[ccKey].add(cc)
+	}
+
+	return nil
+}
+
+func (ccs *CloudCostSet) Clone() *CloudCostSet {
+	cloudCosts := make(map[string]*CloudCost, len(ccs.CloudCosts))
+	for k, v := range ccs.CloudCosts {
+		cloudCosts[k] = v.Clone()
+	}
+
+	cloneCCS := ccs.cloneSet()
+	cloneCCS.CloudCosts = cloudCosts
+
+	return cloneCCS
+}
+
+// cloneSet creates a copy of the receiver without any of its CloudCosts
+func (ccs *CloudCostSet) cloneSet() *CloudCostSet {
+	aggProps := make([]string, len(ccs.AggregationProperties))
+	for i, v := range ccs.AggregationProperties {
+		aggProps[i] = v
+	}
+	return &CloudCostSet{
+		CloudCosts:            make(map[string]*CloudCost),
+		Integration:           ccs.Integration,
+		AggregationProperties: aggProps,
+		Window:                ccs.Window.Clone(),
+	}
+}
+
+func (ccs *CloudCostSet) IsEmpty() bool {
+	if ccs == nil {
+		return true
+	}
+
+	if len(ccs.CloudCosts) == 0 {
+		return true
+	}
+
+	return false
+}
+
+func (ccs *CloudCostSet) Length() int {
+	if ccs == nil {
+		return 0
+	}
+	return len(ccs.CloudCosts)
+}
+
+func (ccs *CloudCostSet) GetWindow() Window {
+	return ccs.Window
+}
+
+func (ccs *CloudCostSet) Merge(that *CloudCostSet) (*CloudCostSet, error) {
+	if ccs == nil {
+		return nil, fmt.Errorf("cannot merge nil CloudCostSets")
+	}
+
+	if that.IsEmpty() {
+		return ccs.Clone(), nil
+	}
+
+	if !ccs.Window.Equal(that.Window) {
+		return nil, fmt.Errorf("cannot merge CloudCostSets with different windows")
+	}
+
+	result := ccs.cloneSet()
+	// clear integration if it is not equal
+	if ccs.Integration != that.Integration {
+		result.Integration = ""
+	}
+
+	for _, cc := range ccs.CloudCosts {
+		result.Insert(cc)
+	}
+
+	for _, cc := range that.CloudCosts {
+		result.Insert(cc)
+	}
+
+	return result, nil
+}
+
+type CloudCostSetRange struct {
+	CloudCostSets []*CloudCostSet `json:"sets"`
+	Window        Window          `json:"window"`
+}
+
+// NewCloudCostSetRange create a CloudCostSetRange containing CloudCostSets with windows of equal duration
+// the duration between start and end must be divisible by the window duration argument
+func NewCloudCostSetRange(start time.Time, end time.Time, window time.Duration, integration string) (*CloudCostSetRange, error) {
+	windows, err := GetWindows(start, end, window)
+	if err != nil {
+		return nil, err
+	}
+
+	// Build slice of CloudCostSet to cover the range
+	cloudCostItemSets := make([]*CloudCostSet, len(windows))
+	for i, w := range windows {
+		ccs := NewCloudCostSet(*w.Start(), *w.End())
+		ccs.Integration = integration
+		cloudCostItemSets[i] = ccs
+	}
+	return &CloudCostSetRange{
+		Window:        NewWindow(&start, &end),
+		CloudCostSets: cloudCostItemSets,
+	}, nil
+}
+
+func (ccsr *CloudCostSetRange) Clone() *CloudCostSetRange {
+	ccsSlice := make([]*CloudCostSet, len(ccsr.CloudCostSets))
+	for i, ccs := range ccsr.CloudCostSets {
+		ccsSlice[i] = ccs.Clone()
+	}
+	return &CloudCostSetRange{
+		Window:        ccsr.Window.Clone(),
+		CloudCostSets: ccsSlice,
+	}
+}
+
+func (ccsr *CloudCostSetRange) IsEmpty() bool {
+	for _, ccs := range ccsr.CloudCostSets {
+		if !ccs.IsEmpty() {
+			return false
+		}
+	}
+	return true
+}
+
+// Accumulate sums each CloudCostSet in the given range, returning a single cumulative
+// CloudCostSet for the entire range.
+func (ccsr *CloudCostSetRange) Accumulate() (*CloudCostSet, error) {
+	var cloudCostSet *CloudCostSet
+	var err error
+
+	for _, ccs := range ccsr.CloudCostSets {
+		if cloudCostSet == nil {
+			cloudCostSet = ccs.Clone()
+			continue
+		}
+		err = cloudCostSet.accumulateInto(ccs)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return cloudCostSet, nil
+}
+
+// LoadCloudCost loads CloudCosts into existing CloudCostSets of the CloudCostSetRange.
+// This function service to aggregate and distribute costs over predefined windows
+// are accumulated here so that the resulting CloudCost with the 1d window has the correct price for the entire day.
+// If all or a portion of the window of the CloudCost is outside of the windows of the existing CloudCostSets,
+// that portion of the CloudCost's cost will not be inserted
+func (ccsr *CloudCostSetRange) LoadCloudCost(cloudCost *CloudCost) {
+	window := cloudCost.Window
+	if window.IsOpen() {
+		log.Errorf("CloudCostSetRange: LoadCloudCost: invalid window %s", window.String())
+		return
+	}
+
+	totalPct := 0.0
+
+	// Distribute cost of the current item across one or more CloudCosts in
+	// across each relevant CloudCostSet. Stop when the end of the current
+	// block reaches the item's end time or the end of the range.
+	for _, ccs := range ccsr.CloudCostSets {
+		setWindow := ccs.Window
+
+		// get percent of item window contained in set window
+		pct := setWindow.GetPercentInWindow(window)
+		if pct == 0 {
+			continue
+		}
+
+		cc := cloudCost
+		// If the current set Window only contains a portion of the CloudCost Window, insert costs relative to that portion
+		if pct < 1.0 {
+			cc = &CloudCost{
+				Properties:       cloudCost.Properties,
+				Window:           window.Contract(setWindow),
+				ListCost:         cloudCost.ListCost.percent(pct),
+				NetCost:          cloudCost.NetCost.percent(pct),
+				AmortizedNetCost: cloudCost.AmortizedNetCost.percent(pct),
+				InvoicedCost:     cloudCost.InvoicedCost.percent(pct),
+			}
+		}
+
+		err := ccs.Insert(cc)
+		if err != nil {
+			log.Errorf("CloudCostSetRange: LoadCloudCost: failed to load CloudCost with window %s: %s", setWindow.String(), err.Error())
+		}
+
+		// If all cost has been inserted, then there is no need to check later days in the range
+		totalPct += pct
+		if totalPct >= 1.0 {
+			return
+		}
+	}
+}
+
+const (
+	ListCostMetric         string = "ListCost"
+	NetCostMetric          string = "NetCost"
+	AmortizedNetCostMetric string = "AmortizedNetCost"
+	InvoicedCostMetric     string = "InvoicedCost"
+)
+
+type CostMetric struct {
+	Cost              float64 `json:"cost"`
+	KubernetesPercent float64 `json:"kubernetesPercent"`
+}
+
+func (cm CostMetric) Equal(that CostMetric) bool {
+	return cm.Cost == that.Cost && cm.KubernetesPercent == that.KubernetesPercent
+}
+
+func (cm CostMetric) Clone() CostMetric {
+	return CostMetric{
+		Cost:              cm.Cost,
+		KubernetesPercent: cm.KubernetesPercent,
+	}
+}
+
+func (cm CostMetric) add(that CostMetric) CostMetric {
+	// Compute KubernetesPercent for sum
+	k8sPct := 0.0
+	sumCost := cm.Cost + that.Cost
+	if sumCost > 0.0 {
+		thisK8sCost := cm.Cost * cm.KubernetesPercent
+		thatK8sCost := that.Cost * that.KubernetesPercent
+		k8sPct = (thisK8sCost + thatK8sCost) / sumCost
+	}
+
+	return CostMetric{
+		Cost:              sumCost,
+		KubernetesPercent: k8sPct,
+	}
+}
+
+// percent returns the product of the given percent and the cost, KubernetesPercent remains the same
+func (cm CostMetric) percent(pct float64) CostMetric {
+	return CostMetric{
+		Cost:              cm.Cost * pct,
+		KubernetesPercent: cm.KubernetesPercent,
+	}
+}

+ 270 - 0
pkg/kubecost/cloudcost_test.go

@@ -0,0 +1,270 @@
+package kubecost
+
+import (
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/pkg/util/timeutil"
+)
+
+var ccProperties1 = &CloudCostProperties{
+	ProviderID:      "providerid1",
+	Provider:        "provider1",
+	AccountID:       "workgroup1",
+	InvoiceEntityID: "billing1",
+	Service:         "service1",
+	Category:        "category1",
+	Labels: map[string]string{
+		"label1": "value1",
+		"label2": "value2",
+	},
+}
+
+// TestCloudCost_LoadCloudCost checks that loaded CloudCosts end up in the correct set in the
+// correct proportions
+func TestCloudCost_LoadCloudCost(t *testing.T) {
+	cc1Key := ccProperties1.GenerateKey(nil)
+	// create values for 3 day Range tests
+	end := RoundBack(time.Now().UTC(), timeutil.Day)
+	start := end.Add(-3 * timeutil.Day)
+	dayWindows, _ := GetWindows(start, end, timeutil.Day)
+	emtpyCCSR, _ := NewCloudCostSetRange(start, end, timeutil.Day, "integration")
+	testCases := map[string]struct {
+		cc       []*CloudCost
+		ccsr     *CloudCostSetRange
+		expected []*CloudCostSet
+	}{
+		"Load Single Day On Grid": {
+			cc: []*CloudCost{
+				{
+					Properties:       ccProperties1,
+					Window:           dayWindows[0],
+					ListCost:         CostMetric{Cost: 100, KubernetesPercent: 1},
+					NetCost:          CostMetric{Cost: 80, KubernetesPercent: 1},
+					AmortizedNetCost: CostMetric{Cost: 90, KubernetesPercent: 1},
+					InvoicedCost:     CostMetric{Cost: 95, KubernetesPercent: 1},
+				},
+			},
+			ccsr: emtpyCCSR.Clone(),
+			expected: []*CloudCostSet{
+				{
+					Integration: "integration",
+					Window:      dayWindows[0],
+					CloudCosts: map[string]*CloudCost{
+						cc1Key: {
+							Properties:       ccProperties1,
+							Window:           dayWindows[0],
+							ListCost:         CostMetric{Cost: 100, KubernetesPercent: 1},
+							NetCost:          CostMetric{Cost: 80, KubernetesPercent: 1},
+							AmortizedNetCost: CostMetric{Cost: 90, KubernetesPercent: 1},
+							InvoicedCost:     CostMetric{Cost: 95, KubernetesPercent: 1},
+						},
+					},
+				},
+				{
+					Integration: "integration",
+					Window:      dayWindows[1],
+					CloudCosts:  map[string]*CloudCost{},
+				},
+				{
+					Integration: "integration",
+					Window:      dayWindows[2],
+					CloudCosts:  map[string]*CloudCost{},
+				},
+			},
+		},
+		"Load Single Day Off Grid": {
+			cc: []*CloudCost{
+				{
+					Properties:       ccProperties1,
+					Window:           NewClosedWindow(start.Add(12*time.Hour), start.Add(36*time.Hour)),
+					ListCost:         CostMetric{Cost: 100, KubernetesPercent: 1},
+					NetCost:          CostMetric{Cost: 80, KubernetesPercent: 1},
+					AmortizedNetCost: CostMetric{Cost: 90, KubernetesPercent: 1},
+					InvoicedCost:     CostMetric{Cost: 95, KubernetesPercent: 1},
+				},
+			},
+			ccsr: emtpyCCSR.Clone(),
+			expected: []*CloudCostSet{
+				{
+					Integration: "integration",
+					Window:      dayWindows[0],
+					CloudCosts: map[string]*CloudCost{
+						cc1Key: {
+							Properties:       ccProperties1,
+							Window:           NewClosedWindow(start.Add(12*time.Hour), start.Add(24*time.Hour)),
+							ListCost:         CostMetric{Cost: 50, KubernetesPercent: 1},
+							NetCost:          CostMetric{Cost: 40, KubernetesPercent: 1},
+							AmortizedNetCost: CostMetric{Cost: 45, KubernetesPercent: 1},
+							InvoicedCost:     CostMetric{Cost: 47.5, KubernetesPercent: 1},
+						},
+					},
+				},
+				{
+					Integration: "integration",
+					Window:      dayWindows[1],
+					CloudCosts: map[string]*CloudCost{
+						cc1Key: {
+							Properties:       ccProperties1,
+							Window:           NewClosedWindow(start.Add(24*time.Hour), start.Add(36*time.Hour)),
+							ListCost:         CostMetric{Cost: 50, KubernetesPercent: 1},
+							NetCost:          CostMetric{Cost: 40, KubernetesPercent: 1},
+							AmortizedNetCost: CostMetric{Cost: 45, KubernetesPercent: 1},
+							InvoicedCost:     CostMetric{Cost: 47.5, KubernetesPercent: 1},
+						},
+					},
+				},
+				{
+					Integration: "integration",
+					Window:      dayWindows[2],
+					CloudCosts:  map[string]*CloudCost{},
+				},
+			},
+		},
+		"Load Single Day Off Grid Before Range Window": {
+			cc: []*CloudCost{
+				{
+					Properties:       ccProperties1,
+					Window:           NewClosedWindow(start.Add(-12*time.Hour), start.Add(12*time.Hour)),
+					ListCost:         CostMetric{Cost: 100, KubernetesPercent: 1},
+					NetCost:          CostMetric{Cost: 80, KubernetesPercent: 1},
+					AmortizedNetCost: CostMetric{Cost: 90, KubernetesPercent: 1},
+					InvoicedCost:     CostMetric{Cost: 95, KubernetesPercent: 1},
+				},
+			},
+			ccsr: emtpyCCSR.Clone(),
+			expected: []*CloudCostSet{
+				{
+					Integration: "integration",
+					Window:      dayWindows[0],
+					CloudCosts: map[string]*CloudCost{
+						cc1Key: {
+							Properties:       ccProperties1,
+							Window:           NewClosedWindow(start, start.Add(12*time.Hour)),
+							ListCost:         CostMetric{Cost: 50, KubernetesPercent: 1},
+							NetCost:          CostMetric{Cost: 40, KubernetesPercent: 1},
+							AmortizedNetCost: CostMetric{Cost: 45, KubernetesPercent: 1},
+							InvoicedCost:     CostMetric{Cost: 47.5, KubernetesPercent: 1},
+						},
+					},
+				},
+				{
+					Integration: "integration",
+					Window:      dayWindows[1],
+					CloudCosts:  map[string]*CloudCost{},
+				},
+				{
+					Integration: "integration",
+					Window:      dayWindows[2],
+					CloudCosts:  map[string]*CloudCost{},
+				},
+			},
+		},
+		"Load Single Day Off Grid After Range Window": {
+			cc: []*CloudCost{
+				{
+					Properties:       ccProperties1,
+					Window:           NewClosedWindow(end.Add(-12*time.Hour), end.Add(12*time.Hour)),
+					ListCost:         CostMetric{Cost: 100, KubernetesPercent: 1},
+					NetCost:          CostMetric{Cost: 80, KubernetesPercent: 1},
+					AmortizedNetCost: CostMetric{Cost: 90, KubernetesPercent: 1},
+					InvoicedCost:     CostMetric{Cost: 95, KubernetesPercent: 1},
+				},
+			},
+			ccsr: emtpyCCSR.Clone(),
+			expected: []*CloudCostSet{
+				{
+					Integration: "integration",
+					Window:      dayWindows[0],
+					CloudCosts:  map[string]*CloudCost{},
+				},
+				{
+					Integration: "integration",
+					Window:      dayWindows[1],
+					CloudCosts:  map[string]*CloudCost{},
+				},
+				{
+					Integration: "integration",
+					Window:      dayWindows[2],
+					CloudCosts: map[string]*CloudCost{
+						cc1Key: {
+							Properties:       ccProperties1,
+							Window:           NewClosedWindow(end.Add(-12*time.Hour), end),
+							ListCost:         CostMetric{Cost: 50, KubernetesPercent: 1},
+							NetCost:          CostMetric{Cost: 40, KubernetesPercent: 1},
+							AmortizedNetCost: CostMetric{Cost: 45, KubernetesPercent: 1},
+							InvoicedCost:     CostMetric{Cost: 47.5, KubernetesPercent: 1},
+						},
+					},
+				},
+			},
+		},
+		"Single Day Kubernetes Percent": {
+			cc: []*CloudCost{
+				{
+					Properties:       ccProperties1,
+					Window:           dayWindows[1],
+					ListCost:         CostMetric{Cost: 75, KubernetesPercent: 1},
+					NetCost:          CostMetric{Cost: 40, KubernetesPercent: 1},
+					AmortizedNetCost: CostMetric{Cost: 60, KubernetesPercent: 1},
+					InvoicedCost:     CostMetric{Cost: 50, KubernetesPercent: 1},
+				},
+				{
+					Properties:       ccProperties1,
+					Window:           dayWindows[1],
+					ListCost:         CostMetric{Cost: 25, KubernetesPercent: 0},
+					NetCost:          CostMetric{Cost: 60, KubernetesPercent: 0},
+					AmortizedNetCost: CostMetric{Cost: 40, KubernetesPercent: 0},
+					InvoicedCost:     CostMetric{Cost: 50, KubernetesPercent: 0},
+				},
+			},
+			ccsr: emtpyCCSR.Clone(),
+			expected: []*CloudCostSet{
+				{
+					Integration: "integration",
+					Window:      dayWindows[0],
+					CloudCosts:  map[string]*CloudCost{},
+				},
+				{
+					Integration: "integration",
+					Window:      dayWindows[1],
+					CloudCosts: map[string]*CloudCost{
+						cc1Key: {
+							Properties:       ccProperties1,
+							Window:           dayWindows[1],
+							ListCost:         CostMetric{Cost: 100, KubernetesPercent: 0.75},
+							NetCost:          CostMetric{Cost: 100, KubernetesPercent: 0.4},
+							AmortizedNetCost: CostMetric{Cost: 100, KubernetesPercent: 0.6},
+							InvoicedCost:     CostMetric{Cost: 100, KubernetesPercent: 0.5},
+						},
+					},
+				},
+				{
+					Integration: "integration",
+					Window:      dayWindows[2],
+					CloudCosts:  map[string]*CloudCost{},
+				},
+			},
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			// load Cloud Costs
+			for _, cc := range tc.cc {
+				tc.ccsr.LoadCloudCost(cc)
+			}
+
+			if len(tc.ccsr.CloudCostSets) != len(tc.expected) {
+				t.Errorf("the CloudCostSetRanges did not have the expected length")
+			}
+
+			for i, ccs := range tc.ccsr.CloudCostSets {
+				if !ccs.Equal(tc.expected[i]) {
+					t.Errorf("CloudCostSet at index: %d did not match expected", i)
+				}
+			}
+		})
+	}
+
+}

+ 0 - 504
pkg/kubecost/cloudcostaggregate.go

@@ -1,504 +0,0 @@
-package kubecost
-
-import (
-	"errors"
-	"fmt"
-	"strings"
-	"time"
-
-	"github.com/opencost/opencost/pkg/filter"
-	"github.com/opencost/opencost/pkg/log"
-)
-
-const (
-	CloudCostBillingIDProp   string = "billingID"
-	CloudCostWorkGroupIDProp string = "workGroupID"
-	CloudCostProviderProp    string = "provider"
-	CloudCostServiceProp     string = "service"
-	CloudCostLabelProp       string = "label"
-)
-
-// CloudCostAggregateProperties unique property set for CloudCostAggregate within a window
-type CloudCostAggregateProperties struct {
-	Provider    string `json:"provider"`
-	WorkGroupID string `json:"workGroupID"`
-	BillingID   string `json:"billingID"`
-	Service     string `json:"service"`
-	LabelValue  string `json:"label"`
-}
-
-func (ccap CloudCostAggregateProperties) Equal(that CloudCostAggregateProperties) bool {
-	return ccap.Provider == that.Provider &&
-		ccap.WorkGroupID == that.WorkGroupID &&
-		ccap.BillingID == that.BillingID &&
-		ccap.Service == that.Service &&
-		ccap.LabelValue == that.LabelValue
-}
-
-// Intersection ensure the values of two CloudCostAggregateProperties are maintain only if they are equal
-func (ccap CloudCostAggregateProperties) Intersection(that CloudCostAggregateProperties) CloudCostAggregateProperties {
-	if ccap.Equal(that) {
-		return ccap
-	}
-	intersectionCCAP := CloudCostAggregateProperties{}
-	if ccap == intersectionCCAP || that == intersectionCCAP {
-		return intersectionCCAP
-	}
-
-	if ccap.Provider == that.Provider {
-		intersectionCCAP.Provider = ccap.Provider
-	}
-	if ccap.WorkGroupID == that.WorkGroupID {
-		intersectionCCAP.WorkGroupID = ccap.WorkGroupID
-	}
-	if ccap.BillingID == that.BillingID {
-		intersectionCCAP.BillingID = ccap.BillingID
-	}
-	if ccap.Service == that.Service {
-		intersectionCCAP.Service = ccap.Service
-	}
-	if ccap.LabelValue == that.LabelValue {
-		intersectionCCAP.LabelValue = ccap.LabelValue
-	}
-	return intersectionCCAP
-}
-func (ccap CloudCostAggregateProperties) Key(props []string) string {
-	if len(props) == 0 {
-		return fmt.Sprintf("%s/%s/%s/%s/%s", ccap.Provider, ccap.BillingID, ccap.WorkGroupID, ccap.Service, ccap.LabelValue)
-	}
-
-	keys := make([]string, len(props))
-	for i, prop := range props {
-		key := UnallocatedSuffix
-
-		switch prop {
-		case CloudCostProviderProp:
-			if ccap.Provider != "" {
-				key = ccap.Provider
-			}
-		case CloudCostBillingIDProp:
-			if ccap.BillingID != "" {
-				key = ccap.BillingID
-			}
-		case CloudCostWorkGroupIDProp:
-			if ccap.WorkGroupID != "" {
-				key = ccap.WorkGroupID
-			}
-		case CloudCostServiceProp:
-			if ccap.Service != "" {
-				key = ccap.Service
-			}
-		case CloudCostLabelProp:
-			if ccap.LabelValue != "" {
-				key = ccap.LabelValue
-			}
-		}
-
-		keys[i] = key
-	}
-
-	return strings.Join(keys, "/")
-}
-
-// CloudCostAggregate represents an aggregation of Billing Integration data on the properties listed
-// - KubernetesPercent is the percent of the CloudCostAggregates cost which was from an item which could be identified
-//   as coming from a kubernetes resources.
-// - Cost is the sum of the cost of each item in the CloudCostAggregate
-// - Credit is the sum of credits applied to each item in the CloudCostAggregate
-
-type CloudCostAggregate struct {
-	Properties        CloudCostAggregateProperties `json:"properties"`
-	KubernetesPercent float64                      `json:"kubernetesPercent"`
-	Cost              float64                      `json:"cost"`
-	NetCost           float64                      `json:"netCost"`
-}
-
-func NewCloudCostAggregate(properties CloudCostAggregateProperties, kubernetesPercent, cost, netCost float64) *CloudCostAggregate {
-	return &CloudCostAggregate{
-		Properties:        properties,
-		KubernetesPercent: kubernetesPercent,
-		Cost:              cost,
-		NetCost:           netCost,
-	}
-}
-
-func (cca *CloudCostAggregate) Clone() *CloudCostAggregate {
-	return &CloudCostAggregate{
-		Properties:        cca.Properties,
-		KubernetesPercent: cca.KubernetesPercent,
-		Cost:              cca.Cost,
-		NetCost:           cca.NetCost,
-	}
-}
-
-func (cca *CloudCostAggregate) Equal(that *CloudCostAggregate) bool {
-	if that == nil {
-		return false
-	}
-
-	return cca.Cost == that.Cost &&
-		cca.NetCost == that.NetCost &&
-		cca.Properties.Equal(that.Properties)
-}
-
-func (cca *CloudCostAggregate) Key(props []string) string {
-	return cca.Properties.Key(props)
-}
-
-func (cca *CloudCostAggregate) StringProperty(prop string) (string, error) {
-	if cca == nil {
-		return "", nil
-	}
-
-	switch prop {
-	case CloudCostBillingIDProp:
-		return cca.Properties.BillingID, nil
-	case CloudCostWorkGroupIDProp:
-		return cca.Properties.WorkGroupID, nil
-	case CloudCostProviderProp:
-		return cca.Properties.Provider, nil
-	case CloudCostServiceProp:
-		return cca.Properties.Service, nil
-	case CloudCostLabelProp:
-		return cca.Properties.LabelValue, nil
-	default:
-		return "", fmt.Errorf("invalid property name: %s", prop)
-	}
-}
-
-func (cca *CloudCostAggregate) add(that *CloudCostAggregate) {
-	if cca == nil {
-		log.Warnf("cannot add to nil CloudCostAggregate")
-		return
-	}
-
-	// Preserve string properties of cloud cost aggregates that are matching between the two CloudCostAggregate
-	cca.Properties = cca.Properties.Intersection(that.Properties)
-
-	// Compute KubernetesPercent for sum
-	k8sPct := 0.0
-	sumCost := cca.Cost + that.Cost
-	if sumCost > 0.0 {
-		thisK8sCost := cca.Cost * cca.KubernetesPercent
-		thatK8sCost := that.Cost * that.KubernetesPercent
-		k8sPct = (thisK8sCost + thatK8sCost) / sumCost
-	}
-
-	cca.Cost = sumCost
-	cca.NetCost += that.NetCost
-	cca.KubernetesPercent = k8sPct
-}
-
-type CloudCostAggregateSet struct {
-	CloudCostAggregates   map[string]*CloudCostAggregate `json:"aggregates"`
-	AggregationProperties []string                       `json:"-"`
-	Integration           string                         `json:"-"`
-	LabelName             string                         `json:"labelName,omitempty"`
-	Window                Window                         `json:"window"`
-}
-
-func NewCloudCostAggregateSet(start, end time.Time, cloudCostAggregates ...*CloudCostAggregate) *CloudCostAggregateSet {
-	ccas := &CloudCostAggregateSet{
-		CloudCostAggregates: map[string]*CloudCostAggregate{},
-		Window:              NewWindow(&start, &end),
-	}
-
-	for _, cca := range cloudCostAggregates {
-		ccas.insertByProperty(cca, nil)
-	}
-
-	return ccas
-}
-
-func (ccas *CloudCostAggregateSet) Aggregate(props []string) (*CloudCostAggregateSet, error) {
-	if ccas == nil {
-		return nil, errors.New("cannot aggregate a nil CloudCostAggregateSet")
-	}
-
-	if ccas.Window.IsOpen() {
-		return nil, fmt.Errorf("cannot aggregate a CloudCostAggregateSet with an open window: %s", ccas.Window)
-	}
-
-	// Create a new result set, with the given aggregation property
-	result := NewCloudCostAggregateSet(*ccas.Window.Start(), *ccas.Window.End())
-	result.AggregationProperties = props
-	result.LabelName = ccas.LabelName
-	result.Integration = ccas.Integration
-
-	// Insert clones of each item in the set, keyed by the given property.
-	// The underlying insert logic will add binned items together.
-	for name, cca := range ccas.CloudCostAggregates {
-		ccaClone := cca.Clone()
-		err := result.insertByProperty(ccaClone, props)
-		if err != nil {
-			return nil, fmt.Errorf("error aggregating %s by %v: %s", name, props, err)
-		}
-	}
-
-	return result, nil
-}
-
-func (ccas *CloudCostAggregateSet) Filter(filters filter.Filter[*CloudCostAggregate]) *CloudCostAggregateSet {
-	if ccas == nil {
-		return nil
-	}
-
-	result := ccas.Clone()
-	result.filter(filters)
-
-	return result
-}
-
-func (ccas *CloudCostAggregateSet) filter(filters filter.Filter[*CloudCostAggregate]) {
-	if ccas == nil {
-		return
-	}
-
-	if filters == nil {
-		return
-	}
-
-	for name, cca := range ccas.CloudCostAggregates {
-		if !filters.Matches(cca) {
-			delete(ccas.CloudCostAggregates, name)
-		}
-	}
-}
-
-func (ccas *CloudCostAggregateSet) Insert(that *CloudCostAggregate) error {
-	// Publicly, only allow Inserting as a basic operation (i.e. without causing
-	// an aggregation on a property).
-	return ccas.insertByProperty(that, nil)
-}
-
-func (ccas *CloudCostAggregateSet) insertByProperty(that *CloudCostAggregate, props []string) error {
-	if ccas == nil {
-		return fmt.Errorf("cannot insert into nil CloudCostAggregateSet")
-	}
-
-	if ccas.CloudCostAggregates == nil {
-		ccas.CloudCostAggregates = map[string]*CloudCostAggregate{}
-	}
-
-	// Add the given CloudCostAggregate to the existing entry, if there is one;
-	// otherwise just set directly into allocations
-	if _, ok := ccas.CloudCostAggregates[that.Key(props)]; !ok {
-		ccas.CloudCostAggregates[that.Key(props)] = that
-	} else {
-		ccas.CloudCostAggregates[that.Key(props)].add(that)
-	}
-
-	return nil
-}
-
-func (ccas *CloudCostAggregateSet) Clone() *CloudCostAggregateSet {
-	aggs := make(map[string]*CloudCostAggregate, len(ccas.CloudCostAggregates))
-	for k, v := range ccas.CloudCostAggregates {
-		aggs[k] = v.Clone()
-	}
-
-	return &CloudCostAggregateSet{
-		CloudCostAggregates: aggs,
-		Integration:         ccas.Integration,
-		LabelName:           ccas.LabelName,
-		Window:              ccas.Window.Clone(),
-	}
-}
-
-func (ccas *CloudCostAggregateSet) Equal(that *CloudCostAggregateSet) bool {
-	if ccas.Integration != that.Integration {
-		return false
-	}
-
-	if ccas.LabelName != that.LabelName {
-		return false
-	}
-
-	if !ccas.Window.Equal(that.Window) {
-		return false
-	}
-
-	if len(ccas.CloudCostAggregates) != len(that.CloudCostAggregates) {
-		return false
-	}
-
-	for k, cca := range ccas.CloudCostAggregates {
-		tcca, ok := that.CloudCostAggregates[k]
-		if !ok {
-			return false
-		}
-		if !cca.Equal(tcca) {
-			return false
-		}
-	}
-
-	return true
-}
-
-func (ccas *CloudCostAggregateSet) IsEmpty() bool {
-	if ccas == nil {
-		return true
-	}
-
-	if len(ccas.CloudCostAggregates) == 0 {
-		return true
-	}
-
-	return false
-}
-
-func (ccas *CloudCostAggregateSet) Length() int {
-	if ccas == nil {
-		return 0
-	}
-	return len(ccas.CloudCostAggregates)
-}
-
-func (ccas *CloudCostAggregateSet) GetWindow() Window {
-	return ccas.Window
-}
-
-func (ccas *CloudCostAggregateSet) Merge(that *CloudCostAggregateSet) (*CloudCostAggregateSet, error) {
-	if ccas == nil || that == nil {
-		return nil, fmt.Errorf("cannot merge nil CloudCostAggregateSets")
-	}
-
-	if that.IsEmpty() {
-		return ccas.Clone(), nil
-	}
-
-	if !ccas.Window.Equal(that.Window) {
-		return nil, fmt.Errorf("cannot merge CloudCostAggregateSets with different windows")
-	}
-
-	if ccas.LabelName != that.LabelName {
-		return nil, fmt.Errorf("cannot merge CloudCostAggregateSets with different label names: '%s' != '%s'", ccas.LabelName, that.LabelName)
-	}
-
-	start, end := *ccas.Window.Start(), *ccas.Window.End()
-	result := NewCloudCostAggregateSet(start, end)
-	result.LabelName = ccas.LabelName
-
-	for _, cca := range ccas.CloudCostAggregates {
-		result.insertByProperty(cca, nil)
-	}
-
-	for _, cca := range that.CloudCostAggregates {
-		result.insertByProperty(cca, nil)
-	}
-
-	return result, nil
-}
-
-type CloudCostAggregateSetRange struct {
-	CloudCostAggregateSets []*CloudCostAggregateSet `json:"sets"`
-	Window                 Window                   `json:"window"`
-}
-
-// NewCloudCostAggregateSetRange create a CloudCostAggregateSetRange containing CloudCostItemSets with windows of equal duration
-// the duration between start and end must be divisible by the window duration argument
-func NewCloudCostAggregateSetRange(start, end time.Time, window time.Duration, integration string, labelName string) (*CloudCostAggregateSetRange, error) {
-	windows, err := GetWindows(start, end, window)
-	if err != nil {
-		return nil, err
-	}
-
-	// Build slice of CloudCostAggregateSet to cover the range
-	cloudCostAggregateSets := make([]*CloudCostAggregateSet, len(windows))
-	for i, w := range windows {
-		ccas := NewCloudCostAggregateSet(*w.Start(), *w.End())
-		ccas.Integration = integration
-		ccas.LabelName = labelName
-		cloudCostAggregateSets[i] = ccas
-	}
-	return &CloudCostAggregateSetRange{
-		Window:                 NewWindow(&start, &end),
-		CloudCostAggregateSets: cloudCostAggregateSets,
-	}, nil
-}
-
-// LoadCloudCostAggregate loads CloudCostAggregates into existing CloudCostAggregateSets of the CloudCostAggregateSetRange.
-// This function service to aggregate and distribute costs over predefined windows
-// If all or a portion of the window of the CloudCostAggregate is outside of the windows of the existing CloudCostAggregateSets,
-// that portion of the CloudCostAggregate's cost will not be inserted
-func (ccasr *CloudCostAggregateSetRange) LoadCloudCostAggregate(window Window, cloudCostAggregate *CloudCostAggregate) {
-	if window.IsOpen() {
-		log.Errorf("CloudCostItemSetRange: LoadCloudCostItem: invalid window %s", window.String())
-		return
-	}
-
-	totalPct := 0.0
-
-	// Distribute cost of the current item across one or more CloudCostAggregates in
-	// across each relevant CloudCostAggregateSet. Stop when the end of the current
-	// block reaches the item's end time or the end of the range.
-	for _, ccas := range ccasr.CloudCostAggregateSets {
-		pct := ccas.GetWindow().GetPercentInWindow(window)
-		if pct == 0 {
-			continue
-		}
-		cca := cloudCostAggregate
-		// If the current set Window only contains a portion of the CloudCostItem Window, insert costs relative to that portion
-		if pct < 1.0 {
-			cca = &CloudCostAggregate{
-				Properties:        cloudCostAggregate.Properties,
-				KubernetesPercent: cloudCostAggregate.KubernetesPercent * pct,
-				Cost:              cloudCostAggregate.Cost * pct,
-				NetCost:           cloudCostAggregate.NetCost * pct,
-			}
-		}
-		err := ccas.insertByProperty(cca, nil)
-		if err != nil {
-			log.Errorf("LoadCloudCostAggregateSets: failed to load CloudCostAggregate with key %s and window %s", cca.Key(nil), ccas.GetWindow().String())
-		}
-
-		// If all cost has been inserted then finish
-		totalPct += pct
-		if totalPct >= 1.0 {
-			return
-		}
-	}
-}
-
-func (ccasr *CloudCostAggregateSetRange) Clone() *CloudCostAggregateSetRange {
-	ccasSlice := make([]*CloudCostAggregateSet, len(ccasr.CloudCostAggregateSets))
-	for i, ccas := range ccasr.CloudCostAggregateSets {
-		ccasSlice[i] = ccas.Clone()
-	}
-	return &CloudCostAggregateSetRange{
-		Window:                 ccasr.Window.Clone(),
-		CloudCostAggregateSets: ccasSlice,
-	}
-}
-
-func (ccasr *CloudCostAggregateSetRange) IsEmpty() bool {
-	for _, ccas := range ccasr.CloudCostAggregateSets {
-		if !ccas.IsEmpty() {
-			return false
-		}
-	}
-	return true
-}
-
-func (ccasr *CloudCostAggregateSetRange) Accumulate() (*CloudCostAggregateSet, error) {
-	if ccasr == nil {
-		return nil, errors.New("cannot accumulate a nil CloudCostAggregateSetRange")
-	}
-
-	if ccasr.Window.IsOpen() {
-		return nil, fmt.Errorf("cannot accumulate a CloudCostAggregateSetRange with an open window: %s", ccasr.Window)
-	}
-
-	result := NewCloudCostAggregateSet(*ccasr.Window.Start(), *ccasr.Window.End())
-
-	for _, ccas := range ccasr.CloudCostAggregateSets {
-		for name, cca := range ccas.CloudCostAggregates {
-			err := result.insertByProperty(cca.Clone(), ccas.AggregationProperties)
-			if err != nil {
-				return nil, fmt.Errorf("error accumulating CloudCostAggregateSetRange[%s][%s]: %s", ccas.Window.String(), name, err)
-			}
-		}
-	}
-
-	return result, nil
-}

+ 0 - 370
pkg/kubecost/cloudcostaggregate_test.go

@@ -1,370 +0,0 @@
-package kubecost
-
-import (
-	"testing"
-	"time"
-
-	"github.com/opencost/opencost/pkg/util/timeutil"
-)
-
-var ccaProperties1 = CloudCostAggregateProperties{
-	Provider:    "provider1",
-	WorkGroupID: "workgroup1",
-	BillingID:   "billing1",
-	Service:     "service1",
-	LabelValue:  "labelValue1",
-}
-
-func TestCloudCostAggregatePropertiesIntersection(t *testing.T) {
-	testCases := map[string]struct {
-		baseCCAP     CloudCostAggregateProperties
-		intCCAP      CloudCostAggregateProperties
-		expectedCCAP CloudCostAggregateProperties
-	}{
-		"When properties match between both CloudCostAggregateProperties": {
-			baseCCAP: CloudCostAggregateProperties{
-				Provider:    "CustomProvider",
-				WorkGroupID: "WorkGroupID1",
-				BillingID:   "BillingID1",
-				Service:     "Service1",
-				LabelValue:  "Label1",
-			},
-			intCCAP: CloudCostAggregateProperties{
-				Provider:    "CustomProvider",
-				WorkGroupID: "WorkGroupID1",
-				BillingID:   "BillingID1",
-				Service:     "Service1",
-				LabelValue:  "Label1",
-			},
-			expectedCCAP: CloudCostAggregateProperties{
-				Provider:    "CustomProvider",
-				WorkGroupID: "WorkGroupID1",
-				BillingID:   "BillingID1",
-				Service:     "Service1",
-				LabelValue:  "Label1",
-			},
-		},
-		"When one of the properties differ in the two CloudCostAggregateProperties": {
-			baseCCAP: CloudCostAggregateProperties{
-				Provider:    "CustomProvider",
-				WorkGroupID: "WorkGroupID1",
-				BillingID:   "BillingID1",
-				Service:     "Service1",
-				LabelValue:  "Label1",
-			},
-			intCCAP: CloudCostAggregateProperties{
-				Provider:    "CustomProvider",
-				WorkGroupID: "WorkGroupID1",
-				BillingID:   "BillingID1",
-				Service:     "Service2",
-				LabelValue:  "Label1",
-			},
-			expectedCCAP: CloudCostAggregateProperties{
-				Provider:    "CustomProvider",
-				WorkGroupID: "WorkGroupID1",
-				BillingID:   "BillingID1",
-				Service:     "",
-				LabelValue:  "Label1",
-			},
-		},
-		"When two of the properties differ in the two CloudCostAggregateProperties": {
-			baseCCAP: CloudCostAggregateProperties{
-				Provider:    "CustomProvider",
-				WorkGroupID: "WorkGroupID1",
-				BillingID:   "BillingID1",
-				Service:     "Service1",
-				LabelValue:  "Label1",
-			},
-			intCCAP: CloudCostAggregateProperties{
-				Provider:    "CustomProvider",
-				WorkGroupID: "WorkGroupID2",
-				BillingID:   "BillingID1",
-				Service:     "Service2",
-				LabelValue:  "Label1",
-			},
-			expectedCCAP: CloudCostAggregateProperties{
-				Provider:    "CustomProvider",
-				WorkGroupID: "",
-				BillingID:   "BillingID1",
-				Service:     "",
-				LabelValue:  "Label1",
-			},
-		},
-	}
-	for name, tc := range testCases {
-		t.Run(name, func(t *testing.T) {
-			actualCCAP := tc.baseCCAP.Intersection(tc.intCCAP)
-			if actualCCAP.Provider != tc.expectedCCAP.Provider {
-				t.Errorf("Case %s: Provider properties dont match with expected CloudCostAggregateProperties: %v actual %v", name, tc.expectedCCAP, actualCCAP)
-			}
-			if actualCCAP.WorkGroupID != tc.expectedCCAP.WorkGroupID {
-				t.Errorf("Case %s: WorkGroupID properties dont match with expected CloudCostAggregateProperties: %v actual %v", name, tc.expectedCCAP, actualCCAP)
-			}
-			if actualCCAP.BillingID != tc.expectedCCAP.BillingID {
-				t.Errorf("Case %s: BillingID properties dont match with expected CloudCostAggregateProperties: %v actual %v", name, tc.expectedCCAP, actualCCAP)
-			}
-			if actualCCAP.Service != tc.expectedCCAP.Service {
-				t.Errorf("Case %s: Service properties dont match with expected CloudCostAggregateProperties: %v actual %v", name, tc.expectedCCAP, actualCCAP)
-			}
-			if actualCCAP.LabelValue != tc.expectedCCAP.LabelValue {
-				t.Errorf("Case %s: LabelValue properties dont match with expected CloudCostAggregateProperties: %v actual %v", name, tc.expectedCCAP, actualCCAP)
-			}
-		})
-	}
-}
-
-// TestCloudCostAggregate_LoadCloudCostAggregate checks that loaded CloudCostAggregates end up in the correct set in the
-// correct proportions
-func TestCloudCostAggregate_LoadCloudCostAggregate(t *testing.T) {
-	// create values for 3 day Range tests
-	end := RoundBack(time.Now().UTC(), timeutil.Day)
-	start := end.Add(-3 * timeutil.Day)
-	dayWindows, _ := GetWindows(start, end, timeutil.Day)
-	emtpyCASSR, _ := NewCloudCostAggregateSetRange(start, end, timeutil.Day, "integration", "label")
-	testCases := map[string]struct {
-		cca      []*CloudCostAggregate
-		windows  []Window
-		ccasr    *CloudCostAggregateSetRange
-		expected []*CloudCostAggregateSet
-	}{
-		"Load Single Day On Grid": {
-			cca: []*CloudCostAggregate{
-				{
-					Properties:        ccaProperties1,
-					KubernetesPercent: 1,
-					Cost:              100,
-					NetCost:           80,
-				},
-			},
-			windows: []Window{
-				dayWindows[0],
-			},
-			ccasr: emtpyCASSR.Clone(),
-			expected: []*CloudCostAggregateSet{
-				{
-					Integration: "integration",
-					LabelName:   "label",
-					Window:      dayWindows[0],
-					CloudCostAggregates: map[string]*CloudCostAggregate{
-						ccaProperties1.Key(nil): {
-							Properties:        ccaProperties1,
-							KubernetesPercent: 1,
-							Cost:              100,
-							NetCost:           80,
-						},
-					},
-				},
-				{
-					Integration:         "integration",
-					LabelName:           "label",
-					Window:              dayWindows[1],
-					CloudCostAggregates: map[string]*CloudCostAggregate{},
-				},
-				{
-					Integration:         "integration",
-					LabelName:           "label",
-					Window:              dayWindows[2],
-					CloudCostAggregates: map[string]*CloudCostAggregate{},
-				},
-			},
-		},
-		"Load Single Day Off Grid": {
-			cca: []*CloudCostAggregate{
-				{
-					Properties:        ccaProperties1,
-					KubernetesPercent: 1,
-					Cost:              100,
-					NetCost:           80,
-				},
-			},
-			windows: []Window{
-				NewClosedWindow(start.Add(12*time.Hour), start.Add(36*time.Hour)),
-			},
-			ccasr: emtpyCASSR.Clone(),
-			expected: []*CloudCostAggregateSet{
-				{
-					Integration: "integration",
-					LabelName:   "label",
-					Window:      dayWindows[0],
-					CloudCostAggregates: map[string]*CloudCostAggregate{
-						ccaProperties1.Key(nil): {
-							Properties:        ccaProperties1,
-							KubernetesPercent: 1,
-							Cost:              50,
-							NetCost:           40,
-						},
-					},
-				},
-				{
-					Integration: "integration",
-					LabelName:   "label",
-					Window:      dayWindows[1],
-					CloudCostAggregates: map[string]*CloudCostAggregate{
-						ccaProperties1.Key(nil): {
-							Properties:        ccaProperties1,
-							KubernetesPercent: 1,
-							Cost:              50,
-							NetCost:           40,
-						},
-					},
-				},
-				{
-					Integration:         "integration",
-					LabelName:           "label",
-					Window:              dayWindows[2],
-					CloudCostAggregates: map[string]*CloudCostAggregate{},
-				},
-			},
-		},
-		"Load Single Day Off Grid Before Range Window": {
-			cca: []*CloudCostAggregate{
-				{
-					Properties:        ccaProperties1,
-					KubernetesPercent: 1,
-					Cost:              100,
-					NetCost:           80,
-				},
-			},
-			windows: []Window{
-				NewClosedWindow(start.Add(-12*time.Hour), start.Add(12*time.Hour)),
-			},
-			ccasr: emtpyCASSR.Clone(),
-			expected: []*CloudCostAggregateSet{
-				{
-					Integration: "integration",
-					LabelName:   "label",
-					Window:      dayWindows[0],
-					CloudCostAggregates: map[string]*CloudCostAggregate{
-						ccaProperties1.Key(nil): {
-							Properties:        ccaProperties1,
-							KubernetesPercent: 1,
-							Cost:              50,
-							NetCost:           40,
-						},
-					},
-				},
-				{
-					Integration:         "integration",
-					LabelName:           "label",
-					Window:              dayWindows[1],
-					CloudCostAggregates: map[string]*CloudCostAggregate{},
-				},
-				{
-					Integration:         "integration",
-					LabelName:           "label",
-					Window:              dayWindows[2],
-					CloudCostAggregates: map[string]*CloudCostAggregate{},
-				},
-			},
-		},
-		"Load Single Day Off Grid After Range Window": {
-			cca: []*CloudCostAggregate{
-				{
-					Properties:        ccaProperties1,
-					KubernetesPercent: 1,
-					Cost:              100,
-					NetCost:           80,
-				},
-			},
-			windows: []Window{
-				NewClosedWindow(end.Add(-12*time.Hour), end.Add(12*time.Hour)),
-			},
-			ccasr: emtpyCASSR.Clone(),
-			expected: []*CloudCostAggregateSet{
-				{
-					Integration:         "integration",
-					LabelName:           "label",
-					Window:              dayWindows[0],
-					CloudCostAggregates: map[string]*CloudCostAggregate{},
-				},
-				{
-					Integration:         "integration",
-					LabelName:           "label",
-					Window:              dayWindows[1],
-					CloudCostAggregates: map[string]*CloudCostAggregate{},
-				},
-				{
-					Integration: "integration",
-					LabelName:   "label",
-					Window:      dayWindows[2],
-					CloudCostAggregates: map[string]*CloudCostAggregate{
-						ccaProperties1.Key(nil): {
-							Properties:        ccaProperties1,
-							KubernetesPercent: 1,
-							Cost:              50,
-							NetCost:           40,
-						},
-					},
-				},
-			},
-		},
-		"Single Day Kubecost Percent": {
-			cca: []*CloudCostAggregate{
-				{
-					Properties:        ccaProperties1,
-					KubernetesPercent: 1,
-					Cost:              75,
-					NetCost:           60,
-				},
-				{
-					Properties:        ccaProperties1,
-					KubernetesPercent: 0,
-					Cost:              25,
-					NetCost:           20,
-				},
-			},
-			windows: []Window{
-				dayWindows[1],
-				dayWindows[1],
-			},
-			ccasr: emtpyCASSR.Clone(),
-			expected: []*CloudCostAggregateSet{
-				{
-					Integration:         "integration",
-					LabelName:           "label",
-					Window:              dayWindows[0],
-					CloudCostAggregates: map[string]*CloudCostAggregate{},
-				},
-				{
-					Integration: "integration",
-					LabelName:   "label",
-					Window:      dayWindows[1],
-					CloudCostAggregates: map[string]*CloudCostAggregate{
-						ccaProperties1.Key(nil): {
-							Properties:        ccaProperties1,
-							KubernetesPercent: 0.75,
-							Cost:              100,
-							NetCost:           80,
-						},
-					},
-				},
-				{
-					Integration:         "integration",
-					LabelName:           "label",
-					Window:              dayWindows[2],
-					CloudCostAggregates: map[string]*CloudCostAggregate{},
-				},
-			},
-		},
-	}
-
-	for name, tc := range testCases {
-		t.Run(name, func(t *testing.T) {
-			// load Cloud Cost Aggregates
-			for i, cca := range tc.cca {
-				tc.ccasr.LoadCloudCostAggregate(tc.windows[i], cca)
-			}
-
-			if len(tc.ccasr.CloudCostAggregateSets) != len(tc.expected) {
-				t.Errorf("the CloudCostAggregateSetRanges did not have the expected length")
-			}
-
-			for i, ccas := range tc.ccasr.CloudCostAggregateSets {
-				if !ccas.Equal(tc.expected[i]) {
-					t.Errorf("CloudCostAggregateSet at index: %d did not match expected", i)
-				}
-			}
-		})
-	}
-
-}

+ 0 - 519
pkg/kubecost/cloudcostitem.go

@@ -1,519 +0,0 @@
-package kubecost
-
-import (
-	"fmt"
-	"strings"
-	"time"
-
-	"github.com/opencost/opencost/pkg/filter"
-	"github.com/opencost/opencost/pkg/log"
-)
-
-// These contain some labels that can be used on Cloud cost
-// item to get the corresponding cluster its associated.
-const (
-	AWSMatchLabel1     = "eks_cluster_name"
-	AWSMatchLabel2     = "alpha_eksctl_io_cluster_name"
-	AlibabaMatchLabel1 = "ack.aliyun.com"
-	GCPMatchLabel1     = "goog-k8s-cluster-name"
-)
-
-type CloudCostItemLabels map[string]string
-
-func (ccil CloudCostItemLabels) Clone() CloudCostItemLabels {
-	result := make(map[string]string, len(ccil))
-	for k, v := range ccil {
-		result[k] = v
-	}
-	return result
-}
-
-func (ccil CloudCostItemLabels) Equal(that CloudCostItemLabels) bool {
-	if len(ccil) != len(that) {
-		return false
-	}
-
-	// Maps are of equal length, so if all keys are in both maps, we don't
-	// have to check the keys of the other map.
-	for k, v := range ccil {
-		if tv, ok := that[k]; !ok || v != tv {
-			return false
-		}
-	}
-
-	return true
-}
-
-type CloudCostItemProperties struct {
-	ProviderID  string              `json:"providerID,omitempty"`
-	Provider    string              `json:"provider,omitempty"`
-	WorkGroupID string              `json:"workGroupID,omitempty"`
-	BillingID   string              `json:"billingID,omitempty"`
-	Service     string              `json:"service,omitempty"`
-	Category    string              `json:"category,omitempty"`
-	Labels      CloudCostItemLabels `json:"labels,omitempty"`
-}
-
-func (ccip CloudCostItemProperties) Equal(that CloudCostItemProperties) bool {
-	return ccip.ProviderID == that.ProviderID &&
-		ccip.Provider == that.Provider &&
-		ccip.WorkGroupID == that.WorkGroupID &&
-		ccip.BillingID == that.BillingID &&
-		ccip.Service == that.Service &&
-		ccip.Category == that.Category &&
-		ccip.Labels.Equal(that.Labels)
-}
-
-func (ccip CloudCostItemProperties) Clone() CloudCostItemProperties {
-	return CloudCostItemProperties{
-		ProviderID:  ccip.ProviderID,
-		Provider:    ccip.Provider,
-		WorkGroupID: ccip.WorkGroupID,
-		BillingID:   ccip.BillingID,
-		Service:     ccip.Service,
-		Category:    ccip.Category,
-		Labels:      ccip.Labels.Clone(),
-	}
-}
-
-func (ccip CloudCostItemProperties) Key() string {
-	return fmt.Sprintf("%s/%s/%s/%s/%s/%s", ccip.Provider, ccip.BillingID, ccip.WorkGroupID, ccip.Category, ccip.Service, ccip.ProviderID)
-}
-
-func (ccip CloudCostItemProperties) MonitoringKey() string {
-	return fmt.Sprintf("%s/%s", ccip.Provider, ccip.ProviderID)
-}
-
-// CloudCostItem represents a CUR line item, identifying a cloud resource and
-// its cost over some period of time.
-type CloudCostItem struct {
-	Properties   CloudCostItemProperties `json:"properties"`
-	IsKubernetes bool                    `json:"isKubernetes"`
-	Window       Window                  `json:"window"`
-	Cost         float64                 `json:"cost"`
-	NetCost      float64                 `json:"netCost"`
-}
-
-// NewCloudCostItem instantiates a new CloudCostItem asset
-func NewCloudCostItem(start, end time.Time, cciProperties CloudCostItemProperties, isKubernetes bool, cost, netcost float64) *CloudCostItem {
-	return &CloudCostItem{
-		Properties:   cciProperties,
-		IsKubernetes: isKubernetes,
-		Window:       NewWindow(&start, &end),
-		Cost:         cost,
-		NetCost:      netcost,
-	}
-}
-
-func (cci *CloudCostItem) Clone() *CloudCostItem {
-	return &CloudCostItem{
-		Properties:   cci.Properties.Clone(),
-		IsKubernetes: cci.IsKubernetes,
-		Window:       cci.Window.Clone(),
-		Cost:         cci.Cost,
-		NetCost:      cci.NetCost,
-	}
-}
-
-func (cci *CloudCostItem) Equal(that *CloudCostItem) bool {
-	if that == nil {
-		return false
-	}
-
-	return cci.Properties.Equal(that.Properties) &&
-		cci.IsKubernetes == that.IsKubernetes &&
-		cci.Window.Equal(that.Window) &&
-		cci.Cost == that.Cost &&
-		cci.NetCost == that.NetCost
-}
-
-func (cci *CloudCostItem) Key() string {
-	return cci.Properties.Key()
-}
-
-func (cci *CloudCostItem) add(that *CloudCostItem) {
-	if cci == nil {
-		log.Warnf("cannot add to nil CloudCostItem")
-		return
-	}
-
-	cci.Cost += that.Cost
-	cci.NetCost += that.NetCost
-	cci.Window = cci.Window.Expand(that.Window)
-}
-
-func (cci *CloudCostItem) MonitoringKey() string {
-	return cci.Properties.MonitoringKey()
-}
-
-// Ony use compute resources to get Cluster names
-func (cci *CloudCostItem) GetCluster() string {
-	switch provider := cci.Properties.Provider; provider {
-	case AWSProvider:
-		return cci.GetAWSCluster()
-	case AzureProvider:
-		return cci.GetAzureCluster()
-	case GCPProvider:
-		return cci.GetGCPCluster()
-	case AlibabaProvider:
-		return cci.GetAlibabaCluster()
-	default:
-		log.Warnf("unsupported CloudCostItem found for a provider: %s", provider)
-		return ""
-	}
-}
-
-// Add any new ways of finding GCP cluster from Cloud cost Item
-func (cci *CloudCostItem) GetGCPCluster() string {
-	// currently from Cloud cost compute unable to get cluster name so returning empty
-	return ""
-}
-
-// Add any new ways of finding AWS cluster from Cloud cost Item
-func (cci *CloudCostItem) GetAWSCluster() string {
-	if cci == nil {
-		return ""
-	}
-
-	// This flag should be removed with filters in the compute query
-	if cci.Properties.Provider != AWSProvider || cci.Properties.Category != ComputeCategory {
-		return ""
-	}
-	// cn be either of these two labels to distinguish cluster name for a given providerID
-	if val, ok := cci.Properties.Labels[AWSMatchLabel1]; ok {
-		return val
-	}
-	if val, ok := cci.Properties.Labels[AWSMatchLabel2]; ok {
-		return val
-	}
-	return ""
-}
-
-// Add any new ways of finding Azure cluster from Cloud cost Item
-func (cci *CloudCostItem) GetAzureCluster() string {
-	if cci == nil {
-		return ""
-	}
-
-	// This flag should be removed with filters in the compute query
-	if cci.Properties.Provider != AzureProvider || cci.Properties.Category != ComputeCategory {
-		return ""
-	}
-
-	providerIDSplit := strings.Split(cci.Properties.ProviderID, "/")
-	// ensure this is actually returnable before return
-	if len(providerIDSplit) < 6 {
-		return ""
-	}
-	return strings.Split(cci.Properties.ProviderID, "/")[6]
-}
-
-// Add any new ways of finding Alibaba cluster from Cloud cost Item
-func (cci *CloudCostItem) GetAlibabaCluster() string {
-	if cci == nil {
-		return ""
-	}
-
-	// This flag should be removed with filters in the compute query
-	if cci.Properties.Provider != AlibabaProvider || cci.Properties.Category != ComputeCategory {
-		return ""
-	}
-	if val, ok := cci.Properties.Labels[AlibabaMatchLabel1]; ok {
-		return val
-	}
-	return ""
-}
-
-type CloudCostItemSet struct {
-	CloudCostItems map[string]*CloudCostItem `json:"items"`
-	Window         Window                    `json:"window"`
-	Integration    string                    `json:"-"`
-}
-
-// NewAssetSet instantiates a new AssetSet and, optionally, inserts
-// the given list of Assets
-func NewCloudCostItemSet(start, end time.Time, cloudCostItems ...*CloudCostItem) *CloudCostItemSet {
-	ccis := &CloudCostItemSet{
-		CloudCostItems: map[string]*CloudCostItem{},
-		Window:         NewWindow(&start, &end),
-	}
-
-	for _, cci := range cloudCostItems {
-		ccis.Insert(cci)
-	}
-
-	return ccis
-}
-
-func (ccis *CloudCostItemSet) Accumulate(that *CloudCostItemSet) (*CloudCostItemSet, error) {
-	if ccis.IsEmpty() {
-		return that.Clone(), nil
-	}
-
-	if that.IsEmpty() {
-		return ccis.Clone(), nil
-	}
-	// Set start, end to min(start), max(end)
-	start := ccis.Window.Start()
-	end := ccis.Window.End()
-	if that.Window.Start().Before(*start) {
-		start = that.Window.Start()
-	}
-	if that.Window.End().After(*end) {
-		end = that.Window.End()
-	}
-
-	acc := NewCloudCostItemSet(*start, *end)
-
-	for _, cci := range ccis.CloudCostItems {
-		err := acc.Insert(cci)
-		if err != nil {
-			return nil, err
-		}
-	}
-
-	for _, cci := range that.CloudCostItems {
-		err := acc.Insert(cci)
-		if err != nil {
-			return nil, err
-		}
-	}
-	return acc, nil
-}
-
-func (ccis *CloudCostItemSet) Equal(that *CloudCostItemSet) bool {
-	if ccis.Integration != that.Integration {
-		return false
-	}
-
-	if !ccis.Window.Equal(that.Window) {
-		return false
-	}
-
-	if len(ccis.CloudCostItems) != len(that.CloudCostItems) {
-		return false
-	}
-
-	for k, cci := range ccis.CloudCostItems {
-		tcci, ok := that.CloudCostItems[k]
-		if !ok {
-			return false
-		}
-		if !cci.Equal(tcci) {
-			return false
-		}
-	}
-
-	return true
-}
-
-func (ccis *CloudCostItemSet) Filter(filters filter.Filter[*CloudCostItem]) *CloudCostItemSet {
-	if ccis == nil {
-		return nil
-	}
-
-	if filters == nil {
-		return ccis.Clone()
-	}
-
-	result := NewCloudCostItemSet(*ccis.Window.start, *ccis.Window.end)
-
-	for _, cci := range ccis.CloudCostItems {
-		if filters.Matches(cci) {
-			result.Insert(cci.Clone())
-		}
-	}
-
-	return result
-}
-
-func (ccis *CloudCostItemSet) Insert(that *CloudCostItem) error {
-	if ccis == nil {
-		return fmt.Errorf("cannot insert into nil CloudCostItemSet")
-	}
-
-	if that == nil {
-		return fmt.Errorf("cannot insert nil CloudCostItem into CloudCostItemSet")
-	}
-
-	if ccis.CloudCostItems == nil {
-		ccis.CloudCostItems = map[string]*CloudCostItem{}
-	}
-
-	// Add the given CloudCostItem to the existing entry, if there is one;
-	// otherwise just set directly into allocations
-	if _, ok := ccis.CloudCostItems[that.Key()]; !ok {
-		ccis.CloudCostItems[that.Key()] = that.Clone()
-	} else {
-		ccis.CloudCostItems[that.Key()].add(that)
-	}
-
-	return nil
-}
-
-func (ccis *CloudCostItemSet) Clone() *CloudCostItemSet {
-	items := make(map[string]*CloudCostItem, len(ccis.CloudCostItems))
-	for k, v := range ccis.CloudCostItems {
-		items[k] = v.Clone()
-	}
-
-	return &CloudCostItemSet{
-		CloudCostItems: items,
-		Integration:    ccis.Integration,
-		Window:         ccis.Window.Clone(),
-	}
-}
-
-func (ccis *CloudCostItemSet) IsEmpty() bool {
-	if ccis == nil {
-		return true
-	}
-
-	if len(ccis.CloudCostItems) == 0 {
-		return true
-	}
-
-	return false
-}
-
-func (ccis *CloudCostItemSet) Length() int {
-	if ccis == nil {
-		return 0
-	}
-	return len(ccis.CloudCostItems)
-}
-
-func (ccis *CloudCostItemSet) GetWindow() Window {
-	return ccis.Window
-}
-
-func (ccis *CloudCostItemSet) Merge(that *CloudCostItemSet) (*CloudCostItemSet, error) {
-	if ccis == nil {
-		return nil, fmt.Errorf("cannot merge nil CloudCostItemSets")
-	}
-
-	if that.IsEmpty() {
-		return ccis.Clone(), nil
-	}
-
-	if !ccis.Window.Equal(that.Window) {
-		return nil, fmt.Errorf("cannot merge CloudCostItemSets with different windows")
-	}
-
-	start, end := *ccis.Window.Start(), *ccis.Window.End()
-	result := NewCloudCostItemSet(start, end)
-
-	for _, cci := range ccis.CloudCostItems {
-		result.Insert(cci)
-	}
-
-	for _, cci := range that.CloudCostItems {
-		result.Insert(cci)
-	}
-
-	return result, nil
-}
-
-type CloudCostItemSetRange struct {
-	CloudCostItemSets []*CloudCostItemSet `json:"sets"`
-	Window            Window              `json:"window"`
-}
-
-// NewCloudCostItemSetRange create a CloudCostItemSetRange containing CloudCostItemSets with windows of equal duration
-// the duration between start and end must be divisible by the window duration argument
-func NewCloudCostItemSetRange(start time.Time, end time.Time, window time.Duration, integration string) (*CloudCostItemSetRange, error) {
-	windows, err := GetWindows(start, end, window)
-	if err != nil {
-		return nil, err
-	}
-
-	// Build slice of CloudCostItemSet to cover the range
-	cloudCostItemSets := make([]*CloudCostItemSet, len(windows))
-	for i, w := range windows {
-		ccis := NewCloudCostItemSet(*w.Start(), *w.End())
-		ccis.Integration = integration
-		cloudCostItemSets[i] = ccis
-	}
-	return &CloudCostItemSetRange{
-		Window:            NewWindow(&start, &end),
-		CloudCostItemSets: cloudCostItemSets,
-	}, nil
-}
-
-func (ccisr *CloudCostItemSetRange) Clone() *CloudCostItemSetRange {
-	ccisSlice := make([]*CloudCostItemSet, len(ccisr.CloudCostItemSets))
-	for i, ccis := range ccisr.CloudCostItemSets {
-		ccisSlice[i] = ccis.Clone()
-	}
-	return &CloudCostItemSetRange{
-		Window:            ccisr.Window.Clone(),
-		CloudCostItemSets: ccisSlice,
-	}
-}
-
-// Accumulate sums each CloudCostItemSet in the given range, returning a single cumulative
-// CloudCostItemSet for the entire range.
-func (ccisr *CloudCostItemSetRange) Accumulate() (*CloudCostItemSet, error) {
-	var cloudCostItemSet *CloudCostItemSet
-	var err error
-
-	for _, ccis := range ccisr.CloudCostItemSets {
-		cloudCostItemSet, err = cloudCostItemSet.Accumulate(ccis)
-		if err != nil {
-			return nil, err
-		}
-	}
-
-	return cloudCostItemSet, nil
-}
-
-// LoadCloudCostItem loads CloudCostItems into existing CloudCostItemSets of the CloudCostItemSetRange.
-// This function service to aggregate and distribute costs over predefined windows
-// are accumulated here so that the resulting CloudCostItem with the 1d window has the correct price for the entire day.
-// If all or a portion of the window of the CloudCostItem is outside of the windows of the existing CloudCostItemSets,
-// that portion of the CloudCostItem's cost will not be inserted
-func (ccisr *CloudCostItemSetRange) LoadCloudCostItem(cloudCostItem *CloudCostItem) {
-	window := cloudCostItem.Window
-	if window.IsOpen() {
-		log.Errorf("CloudCostItemSetRange: LoadCloudCostItem: invalid window %s", window.String())
-		return
-	}
-
-	totalPct := 0.0
-
-	// Distribute cost of the current item across one or more CloudCostItems in
-	// across each relevant CloudCostItemSet. Stop when the end of the current
-	// block reaches the item's end time or the end of the range.
-	for _, ccis := range ccisr.CloudCostItemSets {
-		setWindow := ccis.Window
-
-		// get percent of item window contained in set window
-		pct := setWindow.GetPercentInWindow(window)
-		if pct == 0 {
-			continue
-		}
-
-		cci := cloudCostItem
-		// If the current set Window only contains a portion of the CloudCostItem Window, insert costs relative to that portion
-		if pct < 1.0 {
-			cci = &CloudCostItem{
-				Properties:   cloudCostItem.Properties,
-				IsKubernetes: cloudCostItem.IsKubernetes,
-				Window:       window.Contract(setWindow),
-				Cost:         cloudCostItem.Cost * pct,
-				NetCost:      cloudCostItem.NetCost * pct,
-			}
-		}
-
-		err := ccis.Insert(cci)
-		if err != nil {
-			log.Errorf("CloudCostItemSetRange: LoadCloudCostItem: failed to load CloudCostItem with key %s and window %s: %s", cci.Key(), ccis.GetWindow().String(), err.Error())
-		}
-
-		// If all cost has been inserted then finish
-		totalPct += pct
-		if totalPct >= 1.0 {
-			return
-		}
-	}
-}

+ 0 - 420
pkg/kubecost/cloudcostitem_test.go

@@ -1,420 +0,0 @@
-package kubecost
-
-import (
-	"testing"
-	"time"
-
-	"github.com/opencost/opencost/pkg/util/timeutil"
-)
-
-var cciProperties1 = CloudCostItemProperties{
-	ProviderID:  "providerid1",
-	Provider:    "provider1",
-	WorkGroupID: "workgroup1",
-	BillingID:   "billing1",
-	Service:     "service1",
-	Category:    "category1",
-	Labels: map[string]string{
-		"label1": "value1",
-		"label2": "value2",
-	},
-}
-
-// TestCloudCostItem_LoadCloudCostItem checks that loaded CloudCostItems end up in the correct set in the
-// correct proportions
-func TestCloudCostItem_LoadCloudCostItem(t *testing.T) {
-	// create values for 3 day Range tests
-	end := RoundBack(time.Now().UTC(), timeutil.Day)
-	start := end.Add(-3 * timeutil.Day)
-	dayWindows, _ := GetWindows(start, end, timeutil.Day)
-	emtpyCCISR, _ := NewCloudCostItemSetRange(start, end, timeutil.Day, "integration")
-	testCases := map[string]struct {
-		cci      []*CloudCostItem
-		ccisr    *CloudCostItemSetRange
-		expected []*CloudCostItemSet
-	}{
-		"Load Single Day On Grid": {
-			cci: []*CloudCostItem{
-				{
-					Properties:   cciProperties1,
-					Window:       dayWindows[0],
-					IsKubernetes: true,
-					Cost:         100,
-					NetCost:      80,
-				},
-			},
-			ccisr: emtpyCCISR.Clone(),
-			expected: []*CloudCostItemSet{
-				{
-					Integration: "integration",
-					Window:      dayWindows[0],
-					CloudCostItems: map[string]*CloudCostItem{
-						cciProperties1.Key(): {
-							Properties:   cciProperties1,
-							Window:       dayWindows[0],
-							IsKubernetes: true,
-							Cost:         100,
-							NetCost:      80,
-						},
-					},
-				},
-				{
-					Integration:    "integration",
-					Window:         dayWindows[1],
-					CloudCostItems: map[string]*CloudCostItem{},
-				},
-				{
-					Integration:    "integration",
-					Window:         dayWindows[2],
-					CloudCostItems: map[string]*CloudCostItem{},
-				},
-			},
-		},
-		"Load Single Day Off Grid": {
-			cci: []*CloudCostItem{
-				{
-					Properties:   cciProperties1,
-					Window:       NewClosedWindow(start.Add(12*time.Hour), start.Add(36*time.Hour)),
-					IsKubernetes: true,
-					Cost:         100,
-					NetCost:      80,
-				},
-			},
-			ccisr: emtpyCCISR.Clone(),
-			expected: []*CloudCostItemSet{
-				{
-					Integration: "integration",
-					Window:      dayWindows[0],
-					CloudCostItems: map[string]*CloudCostItem{
-						cciProperties1.Key(): {
-							Properties:   cciProperties1,
-							Window:       NewClosedWindow(start.Add(12*time.Hour), start.Add(24*time.Hour)),
-							IsKubernetes: true,
-							Cost:         50,
-							NetCost:      40,
-						},
-					},
-				},
-				{
-					Integration: "integration",
-					Window:      dayWindows[1],
-					CloudCostItems: map[string]*CloudCostItem{
-						cciProperties1.Key(): {
-							Properties:   cciProperties1,
-							Window:       NewClosedWindow(start.Add(24*time.Hour), start.Add(36*time.Hour)),
-							IsKubernetes: true,
-							Cost:         50,
-							NetCost:      40,
-						},
-					},
-				},
-				{
-					Integration:    "integration",
-					Window:         dayWindows[2],
-					CloudCostItems: map[string]*CloudCostItem{},
-				},
-			},
-		},
-		"Load Single Day Off Grid Before Range Window": {
-			cci: []*CloudCostItem{
-				{
-					Properties:   cciProperties1,
-					Window:       NewClosedWindow(start.Add(-12*time.Hour), start.Add(12*time.Hour)),
-					IsKubernetes: true,
-					Cost:         100,
-					NetCost:      80,
-				},
-			},
-			ccisr: emtpyCCISR.Clone(),
-			expected: []*CloudCostItemSet{
-				{
-					Integration: "integration",
-					Window:      dayWindows[0],
-					CloudCostItems: map[string]*CloudCostItem{
-						cciProperties1.Key(): {
-							Properties:   cciProperties1,
-							Window:       NewClosedWindow(start, start.Add(12*time.Hour)),
-							IsKubernetes: true,
-							Cost:         50,
-							NetCost:      40,
-						},
-					},
-				},
-				{
-					Integration:    "integration",
-					Window:         dayWindows[1],
-					CloudCostItems: map[string]*CloudCostItem{},
-				},
-				{
-					Integration:    "integration",
-					Window:         dayWindows[2],
-					CloudCostItems: map[string]*CloudCostItem{},
-				},
-			},
-		},
-		"Load Single Day Off Grid After Range Window": {
-			cci: []*CloudCostItem{
-				{
-					Properties:   cciProperties1,
-					Window:       NewClosedWindow(end.Add(-12*time.Hour), end.Add(12*time.Hour)),
-					IsKubernetes: true,
-					Cost:         100,
-					NetCost:      80,
-				},
-			},
-			ccisr: emtpyCCISR.Clone(),
-			expected: []*CloudCostItemSet{
-				{
-					Integration:    "integration",
-					Window:         dayWindows[0],
-					CloudCostItems: map[string]*CloudCostItem{},
-				},
-				{
-					Integration:    "integration",
-					Window:         dayWindows[1],
-					CloudCostItems: map[string]*CloudCostItem{},
-				},
-				{
-					Integration: "integration",
-					Window:      dayWindows[2],
-					CloudCostItems: map[string]*CloudCostItem{
-						cciProperties1.Key(): {
-							Properties:   cciProperties1,
-							Window:       NewClosedWindow(end.Add(-12*time.Hour), end),
-							IsKubernetes: true,
-							Cost:         50,
-							NetCost:      40,
-						},
-					},
-				},
-			},
-		},
-		"Single Day Kubecost Percent": {
-			cci: []*CloudCostItem{
-				{
-					Properties:   cciProperties1,
-					Window:       dayWindows[1],
-					IsKubernetes: true,
-					Cost:         75,
-					NetCost:      60,
-				},
-				{
-					Properties:   cciProperties1,
-					Window:       dayWindows[1],
-					IsKubernetes: true,
-					Cost:         25,
-					NetCost:      20,
-				},
-			},
-			ccisr: emtpyCCISR.Clone(),
-			expected: []*CloudCostItemSet{
-				{
-					Integration:    "integration",
-					Window:         dayWindows[0],
-					CloudCostItems: map[string]*CloudCostItem{},
-				},
-				{
-					Integration: "integration",
-					Window:      dayWindows[1],
-					CloudCostItems: map[string]*CloudCostItem{
-						cciProperties1.Key(): {
-							Properties:   cciProperties1,
-							Window:       dayWindows[1],
-							IsKubernetes: true,
-							Cost:         100,
-							NetCost:      80,
-						},
-					},
-				},
-				{
-					Integration:    "integration",
-					Window:         dayWindows[2],
-					CloudCostItems: map[string]*CloudCostItem{},
-				},
-			},
-		},
-	}
-
-	for name, tc := range testCases {
-		t.Run(name, func(t *testing.T) {
-			// load Cloud Cost Items
-			for _, cci := range tc.cci {
-				tc.ccisr.LoadCloudCostItem(cci)
-			}
-
-			if len(tc.ccisr.CloudCostItemSets) != len(tc.expected) {
-				t.Errorf("the CloudCostItemSetRanges did not have the expected length")
-			}
-
-			for i, ccis := range tc.ccisr.CloudCostItemSets {
-				if !ccis.Equal(tc.expected[i]) {
-					t.Errorf("CloudCostItemSet at index: %d did not match expected", i)
-				}
-			}
-		})
-	}
-
-}
-
-func TestGetAWSClusterFromCCI(t *testing.T) {
-	awsCCIWithLabeleksClusterName, eksClusterName := GenerateAWSMockCCIAndPID(1, 1, AWSMatchLabel1, ComputeCategory)
-	awsCCIWithLabeleksCtlClusterName, eksCtlClusterName := GenerateAWSMockCCIAndPID(2, 2, AWSMatchLabel2, ComputeCategory)
-	awsCCIWithLabelWithRandomLabel, _ := GenerateAWSMockCCIAndPID(1, 1, "randomLabel", ComputeCategory)
-	awsCCINetworkCategory, _ := GenerateAWSMockCCIAndPID(1, 1, AWSMatchLabel1, NetworkCategory)
-	alibabaCCI, _ := GenerateAlibabaMockCCIAndPID(4, 4, AlibabaMatchLabel1, ComputeCategory)
-	testCases := map[string]struct {
-		testcci  *CloudCostItem
-		expected string
-	}{
-		"cluster in label eks_cluster_name": {
-			testcci:  awsCCIWithLabeleksClusterName,
-			expected: eksClusterName,
-		},
-		"cluster in label alpha_eksctl_io_cluster_name": {
-			testcci:  awsCCIWithLabeleksCtlClusterName,
-			expected: eksCtlClusterName,
-		},
-		"cluster name in random label either not eks_cluster_name or eks_cluster_name": {
-			testcci:  awsCCIWithLabelWithRandomLabel,
-			expected: "",
-		},
-		"Not a AWS provider": {
-			testcci:  alibabaCCI,
-			expected: "",
-		},
-		"Not a compute resource": {
-			testcci:  awsCCINetworkCategory,
-			expected: "",
-		},
-	}
-	for name, testCase := range testCases {
-		t.Run(name, func(t *testing.T) {
-			actual := testCase.testcci.GetAWSCluster()
-			if actual != testCase.expected {
-				t.Errorf("incorrect result: Actual: '%s', Expected: '%s", actual, testCase.expected)
-			}
-		})
-	}
-}
-
-func TestGetAzureClusterFromCCI(t *testing.T) {
-	testCases := map[string]struct {
-		testcci  *CloudCostItem
-		expected string
-	}{
-		"cluster in ProviderID complete": {
-			testcci: &CloudCostItem{
-				IsKubernetes: true,
-				Window:       Window{},
-				Properties: CloudCostItemProperties{
-					Labels: map[string]string{
-						"randomLabel": "value1",
-					},
-					Provider:   AzureProvider,
-					Category:   ComputeCategory,
-					ProviderID: "azure:///subscriptions/0bd50fdf-c923-4e1e-850c-196dd3dcc5d3/resourceGroups/mc_dev_dev-1_eastus/providers/Microsoft.Compute/virtualMachineScaleSets/aks-devsysz1-24570986-vmss/virtualMachines/0",
-				},
-			},
-			expected: "mc_dev_dev-1_eastus",
-		},
-		"cluster in ProviderID complete but missing some values": {
-			testcci: &CloudCostItem{
-				IsKubernetes: true,
-				Window:       Window{},
-				Properties: CloudCostItemProperties{
-					Labels: map[string]string{
-						"randomLabel": "value1",
-					},
-					Provider:   AzureProvider,
-					Category:   ComputeCategory,
-					ProviderID: "azure:///subscriptions//resourceGroups/mc_dev_dev-1_eastus/providers/Microsoft.Compute/virtualMachineScaleSets/aks-devsysz1-XXXXX-vmss/virtualMachines/0",
-				},
-			},
-			expected: "mc_dev_dev-1_eastus",
-		},
-		"Not having enough split content in providerID": {
-			testcci: &CloudCostItem{
-				IsKubernetes: true,
-				Window:       Window{},
-				Properties: CloudCostItemProperties{
-					Labels: map[string]string{
-						"randomLabel": "value1",
-					},
-					Provider:   AzureProvider,
-					Category:   ComputeCategory,
-					ProviderID: "test1",
-				},
-			},
-			expected: "",
-		},
-		"Not a Azure provider": {
-			testcci: &CloudCostItem{
-				IsKubernetes: true,
-				Window:       Window{},
-				Properties: CloudCostItemProperties{
-					Labels: map[string]string{
-						"randomLabel": "value1",
-					},
-					Provider:   AWSProvider,
-					Category:   ComputeCategory,
-					ProviderID: "test1",
-				},
-			},
-			expected: "",
-		},
-		"Not a compute resource": {
-			testcci: &CloudCostItem{
-				IsKubernetes: true,
-				Window:       Window{},
-				Properties: CloudCostItemProperties{
-					Labels: map[string]string{
-						"randomLabel": "value1",
-					},
-					Provider:   AzureProvider,
-					Category:   StorageCategory,
-					ProviderID: "pvc-xyz",
-				},
-			},
-			expected: "",
-		},
-	}
-	for name, testCase := range testCases {
-		t.Run(name, func(t *testing.T) {
-			actual := testCase.testcci.GetAzureCluster()
-			if actual != testCase.expected {
-				t.Errorf("incorrect result: Actual: '%s', Expected: '%s", actual, testCase.expected)
-			}
-		})
-	}
-}
-
-func TestGetAlibabaClusterFromCCI(t *testing.T) {
-	alibabaCCIWithACKAliyunCom, clusterName1 := GenerateAlibabaMockCCIAndPID(4, 4, AlibabaMatchLabel1, ComputeCategory)
-	awsCCI, _ := GenerateAWSMockCCIAndPID(1, 1, AWSMatchLabel1, ComputeCategory)
-	alibabaCCINetworkCategory, clusterName1 := GenerateAlibabaMockCCIAndPID(4, 4, AlibabaMatchLabel1, NetworkCategory)
-	testCases := map[string]struct {
-		testcci  *CloudCostItem
-		expected string
-	}{
-		"cluster in label ack.aliyun.com": {
-			testcci:  alibabaCCIWithACKAliyunCom,
-			expected: clusterName1,
-		},
-		"Not a Alibaba provider": {
-			testcci:  awsCCI,
-			expected: "",
-		},
-		"Not a compute resource": {
-			testcci:  alibabaCCINetworkCategory,
-			expected: "",
-		},
-	}
-	for name, testCase := range testCases {
-		t.Run(name, func(t *testing.T) {
-			actual := testCase.testcci.GetAlibabaCluster()
-			if actual != testCase.expected {
-				t.Errorf("incorrect result: Actual: '%s', Expected: '%s", actual, testCase.expected)
-			}
-		})
-	}
-}

+ 214 - 0
pkg/kubecost/cloudcostprops.go

@@ -0,0 +1,214 @@
+package kubecost
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/opencost/opencost/pkg/log"
+)
+
+const (
+	CloudCostInvoiceEntityIDProp string = "invoiceEntityID"
+	CloudCostAccountIDProp       string = "accountID"
+	CloudCostProviderProp        string = "provider"
+	CloudCostProviderIDProp      string = "providerID"
+	CloudCostCategoryProp        string = "category"
+	CloudCostServiceProp         string = "service"
+	CloudCostLabelProp           string = "label"
+)
+
+const (
+	// CloudCostClusterManagementCategory describes CloudCost representing Hosted Kubernetes Fees
+	CloudCostClusterManagementCategory string = "Cluster Management"
+
+	// CloudCostDiskCategory describes CloudCost representing Disk usage
+	CloudCostDiskCategory string = "Disk"
+
+	// CloudCostLoadBalancerCategory describes CloudCost representing Load Balancer usage
+	CloudCostLoadBalancerCategory string = "Load Balancer"
+
+	// CloudCostNetworkCategory describes CloudCost representing Network usage
+	CloudCostNetworkCategory string = "Network"
+
+	// CloudCostVirtualMachineCategory describes CloudCost representing VM usage
+	CloudCostVirtualMachineCategory string = "Virtual Machine"
+
+	// CloudCostOtherCategory describes CloudCost that do not belong to a defined category
+	CloudCostOtherCategory string = "Other"
+)
+
+type CloudCostLabels map[string]string
+
+func (ccl CloudCostLabels) Clone() CloudCostLabels {
+	result := make(map[string]string, len(ccl))
+	for k, v := range ccl {
+		result[k] = v
+	}
+	return result
+}
+
+func (ccl CloudCostLabels) Equal(that CloudCostLabels) bool {
+	if len(ccl) != len(that) {
+		return false
+	}
+
+	// Maps are of equal length, so if all keys are in both maps, we don't
+	// have to check the keys of the other map.
+	for k, val := range ccl {
+		if thatVal, ok := that[k]; !ok || val != thatVal {
+			return false
+		}
+	}
+
+	return true
+}
+
+// Intersection returns the set of labels that have the same key and value in the receiver and arg
+func (ccl CloudCostLabels) Intersection(that CloudCostLabels) CloudCostLabels {
+	intersection := make(map[string]string)
+	if len(ccl) == 0 || len(that) == 0 {
+		return intersection
+	}
+
+	// Pick the smaller of the two label sets
+	smallerLabels := ccl
+	largerLabels := that
+	if len(ccl) > len(that) {
+		smallerLabels = that
+		largerLabels = ccl
+	}
+
+	// Loop through the smaller label set
+	for k, sVal := range smallerLabels {
+		if lVal, ok := largerLabels[k]; ok && sVal == lVal {
+			intersection[k] = sVal
+		}
+	}
+	return intersection
+}
+
+type CloudCostProperties struct {
+	ProviderID      string          `json:"providerID,omitempty"`
+	Provider        string          `json:"provider,omitempty"`
+	AccountID       string          `json:"accountID,omitempty"`
+	InvoiceEntityID string          `json:"invoiceEntityID,omitempty"`
+	Service         string          `json:"service,omitempty"`
+	Category        string          `json:"category,omitempty"`
+	Labels          CloudCostLabels `json:"labels,omitempty"`
+}
+
+func (ccp *CloudCostProperties) Equal(that *CloudCostProperties) bool {
+	return ccp.ProviderID == that.ProviderID &&
+		ccp.Provider == that.Provider &&
+		ccp.AccountID == that.AccountID &&
+		ccp.InvoiceEntityID == that.InvoiceEntityID &&
+		ccp.Service == that.Service &&
+		ccp.Category == that.Category &&
+		ccp.Labels.Equal(that.Labels)
+}
+
+func (ccp *CloudCostProperties) Clone() *CloudCostProperties {
+	return &CloudCostProperties{
+		ProviderID:      ccp.ProviderID,
+		Provider:        ccp.Provider,
+		AccountID:       ccp.AccountID,
+		InvoiceEntityID: ccp.InvoiceEntityID,
+		Service:         ccp.Service,
+		Category:        ccp.Category,
+		Labels:          ccp.Labels.Clone(),
+	}
+}
+
+// Intersection ensure the values of two CloudCostAggregateProperties are maintain only if they are equal
+func (ccp *CloudCostProperties) Intersection(that *CloudCostProperties) *CloudCostProperties {
+	if ccp == nil || that == nil {
+		return nil
+	}
+
+	if ccp.Equal(that) {
+		return ccp
+	}
+	intersectionCCP := &CloudCostProperties{}
+	if ccp.Equal(intersectionCCP) || that.Equal(intersectionCCP) {
+		return intersectionCCP
+	}
+
+	if ccp.Provider == that.Provider {
+		intersectionCCP.Provider = ccp.Provider
+	}
+	if ccp.ProviderID == that.ProviderID {
+		intersectionCCP.ProviderID = ccp.ProviderID
+	}
+	if ccp.AccountID == that.AccountID {
+		intersectionCCP.AccountID = ccp.AccountID
+	}
+	if ccp.InvoiceEntityID == that.InvoiceEntityID {
+		intersectionCCP.InvoiceEntityID = ccp.InvoiceEntityID
+	}
+	if ccp.Service == that.Service {
+		intersectionCCP.Service = ccp.Service
+	}
+	if ccp.Category == that.Category {
+		intersectionCCP.Category = ccp.Category
+	}
+	intersectionCCP.Labels = ccp.Labels.Intersection(that.Labels)
+
+	return intersectionCCP
+}
+
+func (ccp *CloudCostProperties) GenerateKey(props []string) string {
+
+	if len(props) == 0 {
+		return fmt.Sprintf("%s/%s/%s/%s/%s/%s", ccp.Provider, ccp.InvoiceEntityID, ccp.AccountID, ccp.Category, ccp.Service, ccp.ProviderID)
+	}
+
+	values := make([]string, len(props))
+	for i, prop := range props {
+		propVal := UnallocatedSuffix
+
+		switch true {
+		case prop == CloudCostProviderProp:
+			if ccp.Provider != "" {
+				propVal = ccp.Provider
+			}
+		case prop == CloudCostProviderIDProp:
+			if ccp.ProviderID != "" {
+				propVal = ccp.ProviderID
+			}
+		case prop == CloudCostCategoryProp:
+			if ccp.Category != "" {
+				propVal = ccp.Category
+			}
+		case prop == CloudCostInvoiceEntityIDProp:
+			if ccp.InvoiceEntityID != "" {
+				propVal = ccp.InvoiceEntityID
+			}
+		case prop == CloudCostAccountIDProp:
+			if ccp.AccountID != "" {
+				propVal = ccp.AccountID
+			}
+		case prop == CloudCostServiceProp:
+			if ccp.Service != "" {
+				propVal = ccp.Service
+			}
+		case strings.HasPrefix(prop, "label:"):
+			labels := ccp.Labels
+			if labels != nil {
+				labelName := strings.TrimPrefix(prop, "label:")
+				if labelValue, ok := labels[labelName]; ok && labelValue != "" {
+					propVal = labelValue
+				}
+			}
+		default:
+			// This case should never be reached, as input up until this point
+			// should be checked and rejected if invalid. But if we do get a
+			// value we don't recognize, log a warning.
+			log.Warnf("CloudCost: GenerateKey: illegal aggregation parameter: %s", prop)
+
+		}
+
+		values[i] = propVal
+	}
+
+	return strings.Join(values, "/")
+}

+ 165 - 0
pkg/kubecost/cloudcostprops_test.go

@@ -0,0 +1,165 @@
+package kubecost
+
+import "testing"
+
+func TestCloudCostPropertiesIntersection(t *testing.T) {
+	testCases := map[string]struct {
+		baseCCP     *CloudCostProperties
+		intCCP      *CloudCostProperties
+		expectedCCP *CloudCostProperties
+	}{
+		"When properties match between both CloudCostProperties": {
+			baseCCP: &CloudCostProperties{
+				Provider:        "CustomProvider",
+				ProviderID:      "ProviderID1",
+				AccountID:       "WorkGroupID1",
+				InvoiceEntityID: "InvoiceEntityID1",
+				Service:         "Service1",
+				Category:        "Category1",
+				Labels: map[string]string{
+					"key1": "value1",
+				},
+			},
+			intCCP: &CloudCostProperties{
+				Provider:        "CustomProvider",
+				ProviderID:      "ProviderID1",
+				AccountID:       "WorkGroupID1",
+				InvoiceEntityID: "InvoiceEntityID1",
+				Service:         "Service1",
+				Category:        "Category1",
+				Labels: map[string]string{
+					"key1": "value1",
+				},
+			},
+			expectedCCP: &CloudCostProperties{
+				Provider:        "CustomProvider",
+				ProviderID:      "ProviderID1",
+				AccountID:       "WorkGroupID1",
+				InvoiceEntityID: "InvoiceEntityID1",
+				Service:         "Service1",
+				Category:        "Category1",
+				Labels: map[string]string{
+					"key1": "value1",
+				},
+			},
+		},
+		"When one of the properties differ in the two CloudCostProperties": {
+			baseCCP: &CloudCostProperties{
+				Provider:        "CustomProvider",
+				ProviderID:      "ProviderID1",
+				AccountID:       "WorkGroupID1",
+				InvoiceEntityID: "InvoiceEntityID1",
+				Service:         "Service1",
+				Category:        "Category1",
+				Labels: map[string]string{
+					"key1": "value1",
+				},
+			},
+			intCCP: &CloudCostProperties{
+				Provider:        "CustomProvider",
+				ProviderID:      "ProviderID1",
+				AccountID:       "WorkGroupID1",
+				InvoiceEntityID: "InvoiceEntityID1",
+				Service:         "Service2",
+				Category:        "Category1",
+				Labels: map[string]string{
+					"key1": "value1",
+				},
+			},
+			expectedCCP: &CloudCostProperties{
+				Provider:        "CustomProvider",
+				ProviderID:      "ProviderID1",
+				AccountID:       "WorkGroupID1",
+				InvoiceEntityID: "InvoiceEntityID1",
+				Service:         "",
+				Category:        "Category1",
+				Labels: map[string]string{
+					"key1": "value1",
+				},
+			},
+		},
+		"When two of the properties differ in the two CloudCostProperties": {
+			baseCCP: &CloudCostProperties{
+				Provider:        "CustomProvider",
+				ProviderID:      "ProviderID1",
+				AccountID:       "WorkGroupID1",
+				InvoiceEntityID: "InvoiceEntityID1",
+				Service:         "Service1",
+				Category:        "Category1",
+				Labels: map[string]string{
+					"key1": "value1",
+				},
+			},
+			intCCP: &CloudCostProperties{
+				Provider:        "CustomProvider",
+				ProviderID:      "ProviderID1",
+				AccountID:       "WorkGroupID2",
+				InvoiceEntityID: "InvoiceEntityID1",
+				Service:         "Service2",
+				Category:        "Category1",
+				Labels: map[string]string{
+					"key1": "value1",
+				},
+			},
+			expectedCCP: &CloudCostProperties{
+				Provider:        "CustomProvider",
+				ProviderID:      "ProviderID1",
+				AccountID:       "",
+				InvoiceEntityID: "InvoiceEntityID1",
+				Service:         "",
+				Category:        "Category1",
+				Labels: map[string]string{
+					"key1": "value1",
+				},
+			},
+		},
+		"When labels differ": {
+			baseCCP: &CloudCostProperties{
+				Provider:        "CustomProvider",
+				ProviderID:      "ProviderID1",
+				AccountID:       "WorkGroupID1",
+				InvoiceEntityID: "InvoiceEntityID1",
+				Service:         "Service1",
+				Category:        "Category1",
+				Labels: map[string]string{
+					"key1": "value1",
+					"key2": "value2",
+					"key3": "value3",
+				},
+			},
+			intCCP: &CloudCostProperties{
+				Provider:        "CustomProvider",
+				ProviderID:      "ProviderID1",
+				AccountID:       "WorkGroupID1",
+				InvoiceEntityID: "InvoiceEntityID1",
+				Service:         "Service1",
+				Category:        "Category1",
+				Labels: map[string]string{
+					"key1": "value2",
+					"key2": "value2",
+					"key4": "value4",
+				},
+			},
+			expectedCCP: &CloudCostProperties{
+				Provider:        "CustomProvider",
+				ProviderID:      "ProviderID1",
+				AccountID:       "WorkGroupID1",
+				InvoiceEntityID: "InvoiceEntityID1",
+				Service:         "Service1",
+				Category:        "Category1",
+				Labels: map[string]string{
+					"key2": "value2",
+				},
+			},
+		},
+	}
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			actualCCP := tc.baseCCP.Intersection(tc.intCCP)
+
+			if !actualCCP.Equal(tc.expectedCCP) {
+				t.Errorf("Case %s: properties dont match with expected CloudCostProperties: %v actual %v", name, tc.expectedCCP, actualCCP)
+			}
+		})
+	}
+}

File diff suppressed because it is too large
+ 353 - 994
pkg/kubecost/kubecost_codecs.go


+ 0 - 48
pkg/kubecost/mock.go

@@ -2,7 +2,6 @@ package kubecost
 
 import (
 	"fmt"
-	"strconv"
 	"time"
 )
 
@@ -699,53 +698,6 @@ func GenerateMockAssetSet(start time.Time, duration time.Duration) *AssetSet {
 	)
 }
 
-func GenerateKubecostNodeAndPID(mockProviderIDInt int, provider string, mockClusterID int, setEndTime time.Time) (*Node, string) {
-	providerID := "PID" + strconv.FormatInt(int64(mockProviderIDInt), 10)
-	return &Node{
-		Properties: &AssetProperties{
-			Provider:   provider,
-			ProviderID: providerID,
-			Cluster:    "cluster" + strconv.FormatInt(int64(mockClusterID), 10),
-		},
-		End: setEndTime,
-	}, providerID
-}
-func GenerateAWSMockCCIAndPID(mockProviderIDInt int, mockCloudIDInt int, labelKey string, resourceCategory string) (*CloudCostItem, string) {
-	return &CloudCostItem{
-		Properties: CloudCostItemProperties{
-			ProviderID: "PID" + strconv.FormatInt(int64(mockProviderIDInt), 10),
-			Provider:   AWSProvider,
-			Category:   resourceCategory,
-			Labels: map[string]string{
-				labelKey: "cluster" + strconv.FormatInt(int64(mockCloudIDInt), 10),
-			},
-		},
-	}, "cluster" + strconv.FormatInt(int64(mockCloudIDInt), 10)
-}
-
-func GenerateAlibabaMockCCIAndPID(mockProviderIDInt int, mockCloudIDInt int, labelKey string, resourceCategory string) (*CloudCostItem, string) {
-	return &CloudCostItem{
-		Properties: CloudCostItemProperties{
-			ProviderID: "PID" + strconv.FormatInt(int64(mockProviderIDInt), 10),
-			Provider:   AlibabaProvider,
-			Category:   resourceCategory,
-			Labels: map[string]string{
-				labelKey: "cluster" + strconv.FormatInt(int64(mockCloudIDInt), 10),
-			},
-		},
-	}, "cluster" + strconv.FormatInt(int64(mockCloudIDInt), 10)
-}
-
-func GenerateGCPMockCCIAndPID(mockProviderIDInt int, mockCloudIDInt int, labelKey string, resourceCategory string) (*CloudCostItem, string) {
-	return &CloudCostItem{
-		Properties: CloudCostItemProperties{
-			ProviderID: "PID" + strconv.FormatInt(int64(mockProviderIDInt), 10),
-			Provider:   GCPProvider,
-			Category:   resourceCategory,
-		},
-	}, ""
-}
-
 // NewMockUnitSummaryAllocation creates an *SummaryAllocation with all of its float64 values set to 1 and generic properties if not provided in arg
 func NewMockUnitSummaryAllocation(name string, start time.Time, resolution time.Duration, props *AllocationProperties) *SummaryAllocation {
 	if name == "" {

+ 4 - 4
pkg/kubecost/window.go

@@ -746,14 +746,14 @@ func (w Window) DurationOffsetStrings() (string, string) {
 // e.g. here are the two possible scenarios as simplidied
 // 10m windows with dashes representing item's time running:
 //
-//  1. item falls entirely within one CloudCostItemSet window
+//  1. item falls entirely within one CloudCostSet window
 //     |     ---- |          |          |
 //     totalMins = 4.0
 //     pct := 4.0 / 4.0 = 1.0 for window 1
 //     pct := 0.0 / 4.0 = 0.0 for window 2
 //     pct := 0.0 / 4.0 = 0.0 for window 3
 //
-//  2. item overlaps multiple CloudCostItemSet windows
+//  2. item overlaps multiple CloudCostSet windows
 //     |      ----|----------|--        |
 //     totalMins = 16.0
 //     pct :=  4.0 / 16.0 = 0.250 for window 1
@@ -810,7 +810,7 @@ func GetWindows(start time.Time, end time.Time, windowSize time.Duration) ([]Win
 		return nil, fmt.Errorf("range timezone doesn't match configured timezone: expected %s; found %ds", env.GetParsedUTCOffset(), sz)
 	}
 
-	// Build array of windows to cover the CloudCostItemSetRange
+	// Build array of windows to cover the CloudCostSetRange
 	windows := []Window{}
 	s, e := start, start.Add(windowSize)
 	for !e.After(end) {
@@ -836,7 +836,7 @@ func GetWindowsForQueryWindow(start time.Time, end time.Time, queryWindow time.D
 		return nil, fmt.Errorf("range timezone doesn't match configured timezone: expected %s; found %ds", env.GetParsedUTCOffset(), sz)
 	}
 
-	// Build array of windows to cover the CloudCostItemSetRange
+	// Build array of windows to cover the CloudCostSetRange
 	windows := []Window{}
 	s, e := start, start.Add(queryWindow)
 	for s.Before(end) {

Some files were not shown because too many files changed in this diff