Browse Source

Merge branch 'develop' into ioutil

Matt Ray 2 years ago
parent
commit
2063f52a5e
100 changed files with 7195 additions and 977 deletions
  1. 10 1
      .github/workflows/build-test.yaml
  2. 45 0
      .github/workflows/label-comments.yml
  3. 51 0
      .github/workflows/sonar.yaml
  4. 6 6
      .github/workflows/stale.yml
  5. 8 0
      .gitignore
  6. 1 0
      ADOPTERS.MD
  7. 4 10
      CODE_OF_CONDUCT.md
  8. 5 5
      CONTRIBUTING.md
  9. 21 0
      Dockerfile.debug
  10. 1 6
      MAINTAINERS.md
  11. 1 1
      NOTICE
  12. 16 13
      README.md
  13. 1 1
      ROADMAP.md
  14. 130 0
      Tiltfile
  15. 22 18
      go.mod
  16. 44 33
      go.sum
  17. 1 1
      justfile
  18. 14 0
      kubernetes/exporter/opencost-exporter.yaml
  19. 6 6
      pkg/cloud/alibaba/authorizer.go
  20. 12 7
      pkg/cloud/alibaba/boaconfiguration.go
  21. 2 2
      pkg/cloud/alibaba/boaconfiguration_test.go
  22. 6 4
      pkg/cloud/alibaba/boaquerier.go
  23. 1 1
      pkg/cloud/authorizer.go
  24. 31 24
      pkg/cloud/aws/athenaconfiguration.go
  25. 2 2
      pkg/cloud/aws/athenaconfiguration_test.go
  26. 105 182
      pkg/cloud/aws/athenaintegration.go
  27. 11 8
      pkg/cloud/aws/athenaquerier.go
  28. 14 14
      pkg/cloud/aws/authorizer.go
  29. 3 3
      pkg/cloud/aws/authorizer_test.go
  30. 43 2
      pkg/cloud/aws/provider.go
  31. 67 0
      pkg/cloud/aws/provider_test.go
  32. 12 7
      pkg/cloud/aws/s3configuration.go
  33. 5 2
      pkg/cloud/aws/s3connection.go
  34. 2 2
      pkg/cloud/aws/s3connection_test.go
  35. 37 45
      pkg/cloud/aws/s3selectintegration.go
  36. 1 2
      pkg/cloud/aws/s3selectquerier.go
  37. 6 6
      pkg/cloud/azure/authorizer.go
  38. 6 11
      pkg/cloud/azure/azurestorageintegration.go
  39. 10 8
      pkg/cloud/azure/billingexportparser.go
  40. 22 6
      pkg/cloud/azure/provider.go
  41. 20 9
      pkg/cloud/azure/storagebillingparser.go
  42. 16 11
      pkg/cloud/azure/storageconfiguration.go
  43. 2 2
      pkg/cloud/azure/storageconfiguration_test.go
  44. 5 2
      pkg/cloud/azure/storageconnection.go
  45. 0 12
      pkg/cloud/cloudcostintegration.go
  46. 2 1
      pkg/cloud/config.go
  47. 291 0
      pkg/cloud/config/configurations.go
  48. 290 0
      pkg/cloud/config/configurations_test.go
  49. 305 0
      pkg/cloud/config/controller.go
  50. 160 0
      pkg/cloud/config/controller_handlers.go
  51. 871 0
      pkg/cloud/config/controller_test.go
  52. 95 0
      pkg/cloud/config/mock.go
  53. 14 0
      pkg/cloud/config/observer.go
  54. 351 0
      pkg/cloud/config/watcher.go
  55. 9 9
      pkg/cloud/gcp/authorizer.go
  56. 13 8
      pkg/cloud/gcp/bigqueryconfiguration.go
  57. 2 2
      pkg/cloud/gcp/bigqueryconfiguration_test.go
  58. 116 247
      pkg/cloud/gcp/bigqueryintegration.go
  59. 310 0
      pkg/cloud/gcp/bigqueryintegration_types.go
  60. 19 3
      pkg/cloud/gcp/bigqueryquerier.go
  61. 100 0
      pkg/cloud/gcp/cloudcost.go
  62. 15 3
      pkg/cloud/gcp/provider.go
  63. 28 0
      pkg/cloud/provider/providerconfig.go
  64. 1 1
      pkg/cloud/scaleway/provider.go
  65. 207 0
      pkg/cloudcost/ingestionmanager.go
  66. 342 0
      pkg/cloudcost/ingestor.go
  67. 96 0
      pkg/cloudcost/integration.go
  68. 103 0
      pkg/cloudcost/memoryrepository.go
  69. 358 0
      pkg/cloudcost/memoryrepository_test.go
  70. 90 0
      pkg/cloudcost/mock.go
  71. 194 0
      pkg/cloudcost/pipelineservice.go
  72. 89 0
      pkg/cloudcost/querier.go
  73. 118 0
      pkg/cloudcost/querier_test.go
  74. 207 0
      pkg/cloudcost/queryservice.go
  75. 170 0
      pkg/cloudcost/queryservice_helper.go
  76. 136 0
      pkg/cloudcost/queryservice_helper_test.go
  77. 16 0
      pkg/cloudcost/repository.go
  78. 236 0
      pkg/cloudcost/repositoryquerier.go
  79. 24 0
      pkg/cloudcost/status.go
  80. 107 0
      pkg/cloudcost/view.go
  81. 1 1
      pkg/cmd/agent/agent.go
  82. 18 0
      pkg/cmd/costmodel/costmodel.go
  83. 1 1
      pkg/costmodel/aggregation.go
  84. 3 2
      pkg/costmodel/allocation.go
  85. 9 5
      pkg/costmodel/cluster.go
  86. 27 37
      pkg/costmodel/costmodel.go
  87. 48 36
      pkg/costmodel/router.go
  88. 37 0
      pkg/env/costmodelenv.go
  89. 44 0
      pkg/env/costmodelenv_test.go
  90. 40 0
      pkg/filter21/ast/walker.go
  91. 100 0
      pkg/filter21/ast/walker_test.go
  92. 70 24
      pkg/kubecost/allocation.go
  93. 46 36
      pkg/kubecost/allocation_test.go
  94. 68 15
      pkg/kubecost/asset.go
  95. 74 3
      pkg/kubecost/asset_test.go
  96. 5 1
      pkg/kubecost/assetprops.go
  97. 199 63
      pkg/kubecost/cloudcost.go
  98. 1 1
      pkg/kubecost/cloudcost_test.go
  99. 14 3
      pkg/kubecost/cloudcostprops.go
  100. 77 0
      pkg/kubecost/costmetric.go

+ 10 - 1
.github/workflows/pr.yaml → .github/workflows/build-test.yaml

@@ -1,6 +1,10 @@
-name: Develop PR - build test
+name: Build/Test
 
 on:
+  push:
+    branches:
+      - develop
+
   pull_request:
     branches:
       - develop
@@ -41,6 +45,11 @@ jobs:
         name: Build
         run: |
           just build-local
+      - name: Upload code coverage
+        uses: actions/upload-artifact@v3
+        with:
+          name: oc-code-coverage
+          path: coverage.out
 
   frontend:
     runs-on: ubuntu-latest

+ 45 - 0
.github/workflows/label-comments.yml

@@ -0,0 +1,45 @@
+name: needs-follow-up-label
+
+on:
+  issue_comment:
+    types: [created]
+  issues:
+    types: [opened, reopened, closed]
+
+jobs:
+  set-follow-up-label:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Check comment actor org membership
+        id: response
+        run: |
+          echo "::set-output name=MEMBER_RESPONSE::$(curl -I -H 'Accept: application/vnd.github+json' -H 'Authorization: token ${{ github.token }}' 'https://api.github.com/orgs/kubecost/members/${{ github.actor }}')"
+
+      - name: "Check for non-4XX response"
+        id: membership
+        run: |
+          echo '${{ steps.response.outputs.MEMBER_RESPONSE }}' && echo "::set-output name=IS_MEMBER::$(grep 'HTTP/2 [2]' <<< '${{ steps.response.outputs.MEMBER_RESPONSE }}')"
+
+      - name: Apply needs-follow-up label if this is a new or reopened issue by user not in the org
+        if: ${{ steps.membership.outputs.IS_MEMBER == '' && github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'reopened') }}
+        uses: actions-ecosystem/action-add-labels@v1
+        with:
+          labels: needs-follow-up
+
+      - name: Apply needs-follow-up label if comment by a user not in the org
+        if: ${{ steps.membership.outputs.IS_MEMBER == '' && github.event_name == 'issue_comment' }}
+        uses: actions-ecosystem/action-add-labels@v1
+        with:
+          labels: needs-follow-up
+
+      - name: Remove needs-follow-up label if the issue has been closed
+        if: ${{ github.event_name == 'issues' && github.event.action == 'closed' }}
+        uses: actions-ecosystem/action-remove-labels@v1
+        with:
+          labels: needs-follow-up
+
+      - name: Remove needs-follow-up label if comment by a user in the org
+        if: ${{ steps.membership.outputs.IS_MEMBER != '' && github.event_name == 'issue_comment' }}
+        uses: actions-ecosystem/action-remove-labels@v1
+        with:
+          labels: needs-follow-up

+ 51 - 0
.github/workflows/sonar.yaml

@@ -0,0 +1,51 @@
+name: Sonar Code Coverage Upload
+on:
+  workflow_run:
+    workflows: ["Build/Test"]
+    types: [completed]
+jobs:
+  sonar:
+    name: Sonar
+    runs-on: ubuntu-latest
+    if: github.event.workflow_run.conclusion == 'success'
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          repository: ${{ github.event.workflow_run.head_repository.full_name }}
+          ref: ${{ github.event.workflow_run.head_branch }}
+          fetch-depth: 0
+      - name: 'Download code coverage'
+        uses: actions/github-script@v6
+        with:
+          script: |
+            let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
+               owner: context.repo.owner,
+               repo: context.repo.repo,
+               run_id: context.payload.workflow_run.id,
+            });
+            let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
+              return artifact.name == "oc-code-coverage"
+            })[0];
+            let download = await github.rest.actions.downloadArtifact({
+               owner: context.repo.owner,
+               repo: context.repo.repo,
+               artifact_id: matchArtifact.id,
+               archive_format: 'zip',
+            });
+            let fs = require('fs');
+            fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/oc-code-coverage.zip`, Buffer.from(download.data));
+      - name: 'Unzip code coverage'
+        run: unzip oc-code-coverage.zip -d coverage
+      - name: SonarCloud Scan
+        uses: sonarsource/sonarcloud-github-action@master
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+        with:
+          args: >
+            -Dsonar.scm.revision=${{ github.event.workflow_run.head_sha }}
+            -Dsonar.pullrequest.key=${{ github.event.workflow_run.pull_requests[0].number }}
+            -Dsonar.pullrequest.branch=${{ github.event.workflow_run.pull_requests[0].head.ref }}
+            -Dsonar.pullrequest.base=${{ github.event.workflow_run.pull_requests[0].base.ref }}
+            -Dsonar.projectKey=opencost_opencost
+            -Dsonar.organization=opencost

+ 6 - 6
.github/workflows/stale.yml

@@ -9,11 +9,11 @@ jobs:
     steps:
       - uses: actions/stale@v8
         with:
-          stale-issue-message: 'This issue has been marked as stale because it has been open for 180 days with no activity. Please remove the stale label or comment or this issue will be closed in 5 days.'
-          close-issue-message: 'This issue was closed because it has been inactive for 185 days with no activity.'
-          stale-pr-message: 'This pull request has been marked as stale because it has been open for 60 days with no activity. Please remove the stale label or comment or this pull request will be closed in 5 days.'
-          close-pr-message: 'This pull request was closed because it has been inactive for 65 days with no activity.'
-          days-before-issue-stale: 180
+          stale-issue-message: 'This issue has been marked as stale because it has been open for 360 days with no activity. Please remove the stale label or comment or this issue will be closed in 5 days.'
+          close-issue-message: 'This issue was closed because it has been inactive for 365 days with no activity.'
+          stale-pr-message: 'This pull request has been marked as stale because it has been open for 90 days with no activity. Please remove the stale label or comment or this pull request will be closed in 5 days.'
+          close-pr-message: 'This pull request was closed because it has been inactive for 95 days with no activity.'
+          days-before-issue-stale: 360
           days-before-issue-close: 5
-          days-before-pr-stale: 60
+          days-before-pr-stale: 90
           days-before-pr-close: 5

+ 8 - 0
.gitignore

@@ -9,4 +9,12 @@ ui/node_modules/
 cmd/costmodel/costmodel
 cmd/costmodel/costmodel-amd64
 cmd/costmodel/costmodel-arm64
+cmd/costmodel/costmodel-tilt
+
 pkg/cloud/azureorphan_test.go
+
+# VS Code
+.vscode
+
+#Apple
+*.DS_Store

+ 1 - 0
ADOPTERS.MD

@@ -14,3 +14,4 @@ If you would like to be included in this table, please submit a PR to this file
 | Grafana Labs                               | *                                 | end user               | [How Grafana Labs uses and contributes to OpenCost](https://grafana.com/blog/2023/02/02/how-grafana-labs-uses-and-contributes-to-opencost-the-open-source-project-for-real-time-cost-monitoring-in-kubernetes/) |
 | Microsoft                                  | *                                 | Service Provider       | [Leverage OpenCost on Azure Kubernetes Service](http://aka.ms/aks/OpenCost-AKS) |
 | mindcurv group                             | *                                 | Consultancy            | [mindcurv group](https://mindcurv.com/en/) |
+| Zendesk                                    | *                                 | end user               | [Zendesk](https://www.zendesk.com/) |

+ 4 - 10
CODE_OF_CONDUCT.md

@@ -23,22 +23,16 @@ Examples of unacceptable behavior include:
 * Trolling, insulting or derogatory comments, and personal or political attacks
 * Public or private harassment
 * Publishing others' private information, such as a physical or email address, without their explicit permission
-* No solicitation. This is a community to help foster innovation, education, and professional & personal networking. 
+* No solicitation. This is a community to help foster innovation, education, and professional & personal networking.
 * Other conduct which could reasonably be considered inappropriate in a professional setting
 
-Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. 
-By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. 
+Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct.
+By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project.
 Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team.
 
 # Reporting
 
-For incidents occurring in the OpenCost community, contact the OpenCost Code of Conduct Committee via conduct@kubecost.com. You can expect a response within two business days.
-For other projects, please contact the OpenCost staff via conduct@kubecost.com. You can expect a response within three business days.
-
-# Enforcement
-
-The OpenCost project's Code of Conduct Committee enforces code of conduct issues. For all other projects, the OpenCost enforces code of conduct issues.
-Both bodies try to resolve incidents without punishment, but may remove people from the project or OpenCost communities at their discretion.
+Instances of abusive, harassing, or otherwise unacceptable behavior in the OpenCost community may be reported by contacting the project maintainer(s) who will try to resolve incidents without punishment, but may remove people from the project or OpenCost community at their discretion.
 
 # Acknowledgements
 This Code of Conduct is adapted from the Contributor Covenant (http://contributor-covenant.org), version 2.0 available at http://contributor-covenant.org/version/2/0/code_of_conduct/

+ 5 - 5
CONTRIBUTING.md

@@ -3,7 +3,7 @@
 Thanks for your help improving the OpenCost project! There are many ways to contribute to the project, including the following:
 
 * 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)
+* contributing documentation here or to the [OpenCost website](https://github.com/opencost/opencost-website)
 * joining the discussion in the [CNCF Slack](https://slack.cncf.io/) in the [#opencost](https://cloud-native.slack.com/archives/C03D56FPD4G) channel
 * 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))
@@ -12,7 +12,7 @@ Thanks for your help improving the OpenCost project! There are many ways to cont
 ## Getting Help
 
 If you have a question about OpenCost or have encountered problems using it,
-you can start by asking a question on [CNCF Slack](https://slack.cncf.io/) in the [#opencost](https://cloud-native.slack.com/archives/C03D56FPD4G) channel or via email at [opencost@kubecost.com](opencost@kubecost.com)
+you can start by asking a question on [CNCF Slack](https://slack.cncf.io/) in the [#opencost](https://cloud-native.slack.com/archives/C03D56FPD4G) channel or attend the biweekly [OpenCost Working Group community meeting](https://bit.ly/opencost-meeting) from the [Community Calendar](https://bit.ly/opencost-calendar) to discuss OpenCost development.
 
 ## Workflow
 
@@ -30,7 +30,7 @@ will run on both AMD64 and ARM64 clusters).
 
 Dependencies:
 1. Docker (with `buildx`)
-2. [just](https://github.com/casey/just) (if you don't want to install Just, read the `justfile` and run the commands manually)
+2. [just](https://github.com/casey/just) (if you don't want to install it , Just read the `justfile` and run the commands manually)
 3. Multi-arch `buildx` builders set up via https://github.com/tonistiigi/binfmt
 4. `npm` (if you want to build the UI)
 
@@ -85,7 +85,7 @@ export KUBECONFIG=~/.kube/config
 An example of the full command:
 
 ```bash
-ETL_PATH_PREFIX="/my/cool/path/kubecost/var/config" CONFIG_PATH="/my/cool/path/kubecost/var/config" PROMETHEUS_SERVER_ENDPOINT="http://127.0.0.1:9090" go run main.go
+PROMETHEUS_SERVER_ENDPOINT="http://127.0.0.1:9090" go run main.go
 ```
 
 ## Running the integration tests
@@ -109,4 +109,4 @@ Please write a commit message with Fixes Issue # if there is an outstanding issu
 
 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!
+Please reach us on [CNCF Slack](https://slack.cncf.io/) in the [#opencost](https://cloud-native.slack.com/archives/C03D56FPD4G) channel or attend the biweekly [OpenCost Working Group community meeting](https://bit.ly/opencost-meeting) from the [Community Calendar](https://bit.ly/opencost-calendar) to discuss OpenCost development.

+ 21 - 0
Dockerfile.debug

@@ -0,0 +1,21 @@
+# This dockerfile is for development purposes only; do not use this for production deployments
+FROM golang:alpine
+# The prebuilt binary path. This Dockerfile assumes the binary will be built
+# outside of Docker.
+ARG binary_path
+
+WORKDIR /app
+RUN apk add --update --no-cache ca-certificates
+RUN go install github.com/go-delve/delve/cmd/dlv@latest
+
+ADD --chmod=644 ./configs/default.json /models/default.json
+ADD --chmod=644 ./configs/azure.json /models/azure.json
+ADD --chmod=644 ./configs/aws.json /models/aws.json
+ADD --chmod=644 ./configs/gcp.json /models/gcp.json
+ADD --chmod=644 ./configs/alibaba.json /models/alibaba.json
+
+RUN echo "binary_path"
+COPY ${binary_path} main
+
+ENTRYPOINT ["/go/bin/dlv exec --listen=:40000 --api-version=2 --headless=true --accept-multiclient --log --continue /app/main"]
+EXPOSE 9003 40000

+ 1 - 6
MAINTAINERS.md

@@ -1,11 +1,6 @@
 # OpenCost Committers and Maintainers
 
-Official list of OpenCost Committers and Maintainers. This is managed as documented in the [GOVERNANCE.md](GOVERNANCE.md).
-
-## Committers
-
-| Committer | GitHub ID | Affiliation | Email |
-| --------------- | --------- | ----------- | ----------- |
+Official list of [OpenCost Maintainers](https://github.com/orgs/opencost/teams/opencost-maintainers). [OpenCost Committers](https://github.com/orgs/opencost/teams/opencost-committers) are granted Triage permissions for the OpenCost repositories. The [GOVERNANCE.md](https://github.com/opencost/opencost/blob/develop/GOVERNANCE.md) describes the process for becoming a committer and maintainer of the project.
 
 ## Maintainers
 

+ 1 - 1
NOTICE

@@ -1,5 +1,5 @@
 OpenCost
-Copyright 2022 Cloud Native Computing Foundation
+Copyright 2022 - 2023 Cloud Native Computing Foundation
 
 This product includes software developed at
 The Cloud Native Computing Foundation (http://www.cncf.io).

+ 16 - 13
README.md

@@ -1,43 +1,46 @@
 <img src="./opencost-header.png"/>
 
-# OpenCost — your favorite open source cost monitoring tool for Kubernetes
+# OpenCost — your favorite open source cost monitoring tool for Kubernetes and cloud spend
 
-OpenCost models give teams visibility into current and historical Kubernetes spend and resource allocation. These models provide cost transparency in Kubernetes environments that support multiple applications, teams, departments, etc.
+OpenCost give teams visibility into current and historical Kubernetes and cloud spend and resource allocation.
+These models provide cost transparency in Kubernetes environments that support multiple applications, teams, departments, etc.
+It also provides visibility into the cloud costs across multiple providers.
 
 OpenCost was originally developed and open sourced by [Kubecost](https://kubecost.com). This project combines a [specification](/spec/) as well as a Golang implementation of these detailed requirements.
 
-![OpenCost allocation UI](./ui/src/opencost-ui.png)
+[![OpenCost UI Walkthrough](./ui/src/thumbnail.png)](https://youtu.be/lCP4Ci9Kcdg)
+*OpenCost UI Walkthrough*
 
 To see the full functionality of OpenCost you can view [OpenCost features](https://opencost.io). Here is a summary of features enabled:
 
 - Real-time cost allocation by Kubernetes cluster, node, namespace, controller kind, controller, service, or pod
-- Dynamic onDemand asset pricing enabled by integrations with AWS, Azure, and GCP billing APIs
+- Multi-cloud cost monitoring for all cloud services on AWS, Azure, GCP
+- Dynamic on-demand k8s asset pricing enabled by integrations with AWS, Azure, and GCP billing APIs
 - Supports on-prem k8s clusters with custom CSV pricing
-- Allocation for in-cluster resources like CPU, GPU, memory, and persistent volumes.
-- Easily export pricing data to Prometheus with /metrics endpoint ([learn more](PROMETHEUS.md))
-- Free and open source distribution (Apache2 license)
+- Allocation for in-cluster K8s resources like CPU, GPU, memory, and persistent volumes
+- Easily export pricing data to Prometheus with /metrics endpoint ([learn more](https://www.opencost.io/docs/installation/prometheus))
+- Free and open source distribution ([Apache2 license](LICENSE))
 
 ## Getting Started
 
-You can deploy OpenCost on any Kubernetes 1.8+ cluster in a matter of minutes, if not seconds!
+You can deploy OpenCost on any Kubernetes 1.20+ cluster in a matter of minutes, if not seconds!
 
-Visit the full documentation for [recommended install options](https://www.opencost.io/docs/installation/install).
+Visit the full documentation for [recommended installation options](https://www.opencost.io/docs/installation/install).
 
 ## Usage
 
 - [Cost APIs](https://www.opencost.io/docs/integrations/api)
 - [CLI / kubectl cost](https://www.opencost.io/docs/integrations/kubectl-cost)
 - [Prometheus Metrics](https://www.opencost.io/docs/integrations/prometheus)
-- Reference [User Interface](https://github.com/opencost/opencost/tree/develop/ui)
+- [User Interface](https://www.opencost.io/docs/installation/ui)
 
 ## Contributing
 
-We :heart: pull requests! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for information on building the project from source
-and contributing changes.
+We :heart: pull requests! See [`CONTRIBUTING.md`](CONTRIBUTING.md) for information on building the project from source 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 biweekly [OpenCost Working Group community meeting](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 or attend the biweekly [OpenCost Working Group community meeting](https://bit.ly/opencost-meeting) from the [Community Calendar](https://bit.ly/opencost-calendar) to discuss OpenCost development.
 
 ## FAQ
 

+ 1 - 1
ROADMAP.md

@@ -11,4 +11,4 @@ __2023 roadmap__
 * More robust [API documentation](https://www.opencost.io/docs/integrations/api) and examples.
 * Expose carbon emission ratings
 
-Please contact us at opencost@kubecost.com if you're interest in more detail.
+Please reach us on [CNCF Slack](https://slack.cncf.io/) in the [#opencost](https://cloud-native.slack.com/archives/C03D56FPD4G) channel or attend the biweekly [OpenCost Working Group community meeting](https://bit.ly/opencost-meeting) from the [Community Calendar](https://bit.ly/opencost-calendar) to discuss OpenCost development.

+ 130 - 0
Tiltfile

@@ -0,0 +1,130 @@
+load('ext://helm_resource', 'helm_resource', 'helm_repo')
+load('ext://restart_process', 'docker_build_with_restart')
+
+# WARNING: this allows any k8s context for deployment
+#allow_k8s_contexts(k8s_context())
+# To allow a specific context for deployment:
+# allow_k8s_contexts('kubectl-context')
+# See https://docs.tilt.dev/api.html#api.allow_k8s_contexts for default allowed contexts
+
+config.define_string('arch', args=False, usage='amd64')
+config.define_string('docker-repo', args=False, usage='')
+cfg = config.parse()
+
+arch = cfg.get('arch')
+
+docker_platform = "linux/amd64"
+go_arch = "amd64"
+if arch == "arm64":
+    docker_platform = "linux/aarch64"
+    go_arch = "arm64"
+
+docker_repo = cfg.get('docker-repo')
+if docker_repo == None:
+    docker_repo = ''
+else:
+    docker_repo = docker_repo + "/"
+
+# Build and update opencost back end binary when code changes
+local_resource(
+    name='build-costmodel',
+    dir='.',
+    cmd='CGO_ENABLED=0 GOOS=linux GOARCH='+go_arch+' go build -o ./cmd/costmodel/costmodel-tilt ./cmd/costmodel/main.go',
+    deps=[
+        './cmd/costmodel/main.go',
+        './pkg',
+    ],
+    allow_parallel=True,
+    resource_deps=['build-go-mod-download'],
+)
+
+# Build back end docker container
+# If the binary is updated, update the running container and restart binary in dlv
+docker_build_with_restart(
+    ref=docker_repo+'opencost-costmodel',
+    context='.',
+    # remove --continue flag to make dlv wait until debugger is attached to start
+    entrypoint='/go/bin/dlv exec --listen=:40000 --api-version=2 --headless=true --accept-multiclient --log --continue /app/main',
+    dockerfile='Dockerfile.debug',
+    platform=docker_platform,
+
+    build_args={'binary_path':'./cmd/costmodel/costmodel-tilt'},
+    only=[
+        'cmd/costmodel/costmodel-tilt',
+        'configs',
+    ],
+    live_update=[
+       sync('./cmd/costmodel/costmodel-tilt', '/app/main'),
+    ],
+)
+
+# npm install if package.json changes
+local_resource(
+    name='build-npm-install',
+    dir='./ui',
+    cmd='npm install',
+    deps=[
+        './ui/package.json',
+    ],
+    allow_parallel=True,
+)
+
+# Build FE locally when code changes
+local_resource(
+    name='build-ui',
+    dir='./ui',
+    cmd='npx parcel build src/index.html',
+    deps=[
+        './ui/src',
+        './ui/package.json',
+    ],
+    allow_parallel=True,
+    resource_deps=['build-npm-install'],
+)
+
+# update container when relevant files change
+docker_build(
+    ref=docker_repo+'opencost-ui',
+    context='./ui',
+    dockerfile='./ui/Dockerfile.cross',
+    only=[
+        'dist',
+        'nginx.conf',
+        'default.nginx.conf',
+        'docker-entrypoint.sh',
+    ],
+    live_update=[
+       sync('./ui/dist', '/var/www'),
+    ],
+)
+
+# build yaml for deployment to k8s
+yaml = helm(
+    '../opencost-helm-chart/charts/opencost',
+    name='opencost',
+    values=['./tilt-values.yaml'],
+    # configuring opencost to also use the kubecost prometheus server below
+    set=[
+        'opencost.ui.image.fullImageName='+docker_repo+'opencost-ui',
+        'opencost.exporter.image.fullImageName='+docker_repo+'opencost-costmodel',
+        'opencost.prometheus.internal.namespaceName='+k8s_namespace(),
+    ]
+)
+k8s_yaml(yaml) # put resulting yaml into k8s
+k8s_resource(workload='opencost', port_forwards=['9003:9003','9090:9090','40000:40000'])
+
+helm_resource(
+    name='prometheus',
+    chart='prometheus-community/prometheus')
+k8s_resource(workload='prometheus', port_forwards=['9080:9090'])
+
+local_resource(
+    name='costmodel-test',
+    dir='.',
+    cmd='go test ./...',
+    deps=[
+        './pkg',
+    ],
+    allow_parallel=True,
+    resource_deps=['opencost'], # run tests after build to speed up deployment
+)

+ 22 - 18
go.mod

@@ -3,9 +3,9 @@ module github.com/opencost/opencost
 replace github.com/golang/lint => golang.org/x/lint v0.0.0-20180702182130-06c8688daad7
 
 require (
-	cloud.google.com/go/bigquery v1.48.0
+	cloud.google.com/go/bigquery v1.50.0
 	cloud.google.com/go/compute/metadata v0.2.3
-	cloud.google.com/go/storage v1.28.1
+	cloud.google.com/go/storage v1.29.0
 	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.5.0
@@ -25,6 +25,7 @@ require (
 	github.com/aws/aws-sdk-go-v2/service/ec2 v1.29.0
 	github.com/aws/aws-sdk-go-v2/service/s3 v1.31.0
 	github.com/aws/aws-sdk-go-v2/service/sts v1.14.0
+	github.com/aws/smithy-go v1.13.5
 	github.com/davecgh/go-spew v1.1.1
 	github.com/getsentry/sentry-go v0.6.1
 	github.com/goccy/go-json v0.9.11
@@ -48,13 +49,15 @@ require (
 	github.com/scaleway/scaleway-sdk-go v1.0.0-beta.9
 	github.com/spf13/cobra v1.2.1
 	github.com/spf13/viper v1.8.1
-	github.com/stretchr/testify v1.8.1
+	github.com/stretchr/testify v1.8.4
 	go.etcd.io/bbolt v1.3.5
+	go.opentelemetry.io/otel v1.19.0
 	golang.org/x/exp v0.0.0-20221031165847-c99f073a8326
-	golang.org/x/oauth2 v0.6.0
+	golang.org/x/oauth2 v0.7.0
 	golang.org/x/sync v0.1.0
-	golang.org/x/text v0.8.0
+	golang.org/x/text v0.13.0
 	google.golang.org/api v0.114.0
+	google.golang.org/protobuf v1.30.0
 	gopkg.in/yaml.v2 v2.4.0
 	k8s.io/api v0.25.3
 	k8s.io/apimachinery v0.25.3
@@ -64,8 +67,8 @@ require (
 
 require (
 	cloud.google.com/go v0.110.0 // indirect
-	cloud.google.com/go/compute v1.18.0 // indirect
-	cloud.google.com/go/iam v0.12.0 // indirect
+	cloud.google.com/go/compute v1.19.1 // indirect
+	cloud.google.com/go/iam v0.13.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect
 	github.com/Azure/go-autorest v14.2.0+incompatible // indirect
 	github.com/Azure/go-autorest/autorest/azure/cli v0.4.5 // indirect
@@ -78,7 +81,7 @@ require (
 	github.com/PuerkitoBio/purell v1.1.1 // indirect
 	github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
 	github.com/andybalholm/brotli v1.0.4 // indirect
-	github.com/apache/arrow/go/v10 v10.0.1 // indirect
+	github.com/apache/arrow/go/v11 v11.0.0 // indirect
 	github.com/apache/thrift v0.16.0 // indirect
 	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect
 	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0 // indirect
@@ -91,7 +94,6 @@ require (
 	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.25 // indirect
 	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.14.0 // indirect
 	github.com/aws/aws-sdk-go-v2/service/sso v1.9.0 // indirect
-	github.com/aws/smithy-go v1.13.5 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
@@ -99,7 +101,8 @@ require (
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/emicklei/go-restful/v3 v3.10.2 // indirect
 	github.com/fsnotify/fsnotify v1.6.0 // indirect
-	github.com/go-logr/logr v1.2.3 // indirect
+	github.com/go-logr/logr v1.2.4 // indirect
+	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-openapi/jsonpointer v0.19.5 // indirect
 	github.com/go-openapi/jsonreference v0.19.6 // indirect
 	github.com/go-openapi/swag v0.21.1 // indirect
@@ -107,7 +110,7 @@ require (
 	github.com/gogo/protobuf v1.3.2 // indirect
 	github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
-	github.com/golang/protobuf v1.5.2 // indirect
+	github.com/golang/protobuf v1.5.3 // indirect
 	github.com/golang/snappy v0.0.4 // indirect
 	github.com/google/flatbuffers v2.0.8+incompatible // indirect
 	github.com/google/gnostic v0.5.7-v3refs // indirect
@@ -154,19 +157,20 @@ require (
 	github.com/subosito/gotenv v1.2.0 // indirect
 	github.com/zeebo/xxh3 v1.0.2 // indirect
 	go.opencensus.io v0.24.0 // indirect
+	go.opentelemetry.io/otel/metric v1.19.0 // indirect
+	go.opentelemetry.io/otel/trace v1.19.0 // indirect
 	go.uber.org/atomic v1.10.0 // indirect
-	golang.org/x/crypto v0.6.0 // indirect
+	golang.org/x/crypto v0.14.0 // indirect
 	golang.org/x/mod v0.8.0 // indirect
-	golang.org/x/net v0.8.0 // indirect
-	golang.org/x/sys v0.6.0 // indirect
-	golang.org/x/term v0.6.0 // indirect
+	golang.org/x/net v0.17.0 // indirect
+	golang.org/x/sys v0.13.0 // indirect
+	golang.org/x/term v0.13.0 // indirect
 	golang.org/x/time v0.1.0 // indirect
 	golang.org/x/tools v0.6.0 // indirect
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20230320184635-7606e756e683 // indirect
-	google.golang.org/grpc v1.53.0 // indirect
-	google.golang.org/protobuf v1.29.1 // indirect
+	google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
+	google.golang.org/grpc v1.56.3 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/ini.v1 v1.67.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect

+ 44 - 33
go.sum

@@ -26,18 +26,18 @@ cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvf
 cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
 cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
 cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
-cloud.google.com/go/bigquery v1.48.0 h1:u+fhS1jJOkPO9vdM84M8HO5VznTfVUicBeoXNKD26ho=
-cloud.google.com/go/bigquery v1.48.0/go.mod h1:QAwSz+ipNgfL5jxiaK7weyOhzdoAy1zFm0Nf1fysJac=
-cloud.google.com/go/compute v1.18.0 h1:FEigFqoDbys2cvFkZ9Fjq4gnHBP55anJ0yQyau2f9oY=
-cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs=
+cloud.google.com/go/bigquery v1.50.0 h1:RscMV6LbnAmhAzD893Lv9nXXy2WCaJmbxYPWDLbGqNQ=
+cloud.google.com/go/bigquery v1.50.0/go.mod h1:YrleYEh2pSEbgTBZYMJ5SuSr0ML3ypjRB1zgf7pvQLU=
+cloud.google.com/go/compute v1.19.1 h1:am86mquDUgjGNWxiGn+5PGLbmgiWXlE/yNWpIpNvuXY=
+cloud.google.com/go/compute v1.19.1/go.mod h1:6ylj3a05WF8leseCdIf77NK0g1ey+nj5IKd5/kvShxE=
 cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
 cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
-cloud.google.com/go/datacatalog v1.12.0 h1:3uaYULZRLByPdbuUvacGeqneudztEM4xqKQsBcxbDnY=
+cloud.google.com/go/datacatalog v1.13.0 h1:4H5IJiyUE0X6ShQBqgFFZvGGcrwGVndTwUSLP4c52gw=
 cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
 cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
 cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
-cloud.google.com/go/iam v0.12.0 h1:DRtTY29b75ciH6Ov1PHb4/iat2CLCvrOm40Q0a6DFpE=
-cloud.google.com/go/iam v0.12.0/go.mod h1:knyHGviacl11zrtZUoDuYpDgLjvr28sLQaG0YB2GYAY=
+cloud.google.com/go/iam v0.13.0 h1:+CmB+K0J/33d0zSQ9SlFWUeCCEn5XJA0ZMZ3pHE9u8k=
+cloud.google.com/go/iam v0.13.0/go.mod h1:ljOg+rcNfzZ5d6f1nAUJ8ZIxOaZUVoS14bKCtaLZ/D0=
 cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM=
 cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
 cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
@@ -48,8 +48,8 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
 cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
 cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
 cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
-cloud.google.com/go/storage v1.28.1 h1:F5QDG5ChchaAVQhINh24U99OWHURqrW8OmQcGKXcbgI=
-cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y=
+cloud.google.com/go/storage v1.29.0 h1:6weCgzRvMg7lzuUurI4697AqIRPU1SvzHhynwpW31jI=
+cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
 github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
 github.com/Azure/azure-pipeline-go v0.2.3 h1:7U9HBg1JFK3jHl5qmo4CTZKFTVgMwdFHMVtCdfBE21U=
@@ -117,8 +117,8 @@ github.com/aliyun/alibaba-cloud-sdk-go v1.62.3/go.mod h1:Api2AkmMgGaSUAhmk76oaFO
 github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
 github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
-github.com/apache/arrow/go/v10 v10.0.1 h1:n9dERvixoC/1JjDmBcs9FPaEryoANa2sCgVFo6ez9cI=
-github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0=
+github.com/apache/arrow/go/v11 v11.0.0 h1:hqauxvFQxww+0mEU/2XHG6LT7eZternCZq+A5Yly2uM=
+github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI=
 github.com/apache/thrift v0.16.0 h1:qEy6UW60iVOlUy+b9ZR0d5WzUWYGOo4HfopoyBaNmoY=
 github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU=
 github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
@@ -261,8 +261,11 @@ github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG
 github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
 github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
 github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
-github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
+github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
 github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
 github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
@@ -319,8 +322,9 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
 github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
 github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
-github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
+github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
 github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
 github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
@@ -660,8 +664,9 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
 github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o=
@@ -709,6 +714,12 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
 go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
 go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
 go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
+go.opentelemetry.io/otel v1.19.0 h1:MuS/TNf4/j4IXsZuJegVzI1cwut7Qc00344rgH7p8bs=
+go.opentelemetry.io/otel v1.19.0/go.mod h1:i0QyjOq3UPoTzff0PJB2N66fb4S0+rSbSB15/oyH9fY=
+go.opentelemetry.io/otel/metric v1.19.0 h1:aTzpGtV0ar9wlV4Sna9sdJyII5jTVJEvKETPiOKwvpE=
+go.opentelemetry.io/otel/metric v1.19.0/go.mod h1:L5rUsV9kM1IxCj1MmSdS+JQAcVm319EUrDVLrt7jqt8=
+go.opentelemetry.io/otel/trace v1.19.0 h1:DFVQmlVbfVeOuBRrwdtaehRrWiL1JoVs9CPIQ1Dzxpg=
+go.opentelemetry.io/otel/trace v1.19.0/go.mod h1:mfaSyvGyEJEI0nyV2I4qhNQnbBOUUmYZpYojqMnX2vo=
 go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
 go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
 go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
@@ -731,8 +742,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
 golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
-golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
+golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
+golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -826,8 +837,8 @@ golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
 golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
-golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
-golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
+golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
+golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -842,8 +853,8 @@ golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ
 golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
-golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw=
-golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
+golang.org/x/oauth2 v0.7.0 h1:qe6s0zUXlPX80/dITx3440hWZ7GwMwgDDyrSGTPJG/g=
+golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -927,14 +938,14 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
+golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
-golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
-golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
+golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
+golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -946,8 +957,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
-golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
-golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1091,8 +1102,8 @@ google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6D
 google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
 google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
-google.golang.org/genproto v0.0.0-20230320184635-7606e756e683 h1:khxVcsk/FhnzxMKOyD+TDGwjbEOpcPuIpmafPGFmhMA=
-google.golang.org/genproto v0.0.0-20230320184635-7606e756e683/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s=
+google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A=
+google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -1113,8 +1124,8 @@ google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAG
 google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
 google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
 google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
-google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc=
-google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
+google.golang.org/grpc v1.56.3 h1:8I4C0Yq1EjstUzUJzpcRVbuYA2mODtEmpWiQoN/b2nc=
+google.golang.org/grpc v1.56.3/go.mod h1:I9bI3vqKfayGqPUAwGdOSu7kt6oIJLixfffKrpXqQ9s=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -1127,8 +1138,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
 google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.29.1 h1:7QBf+IK2gx70Ap/hDsOmam3GE0v9HicjfEdAxE62UoM=
-google.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
+google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

+ 1 - 1
justfile

@@ -8,7 +8,7 @@ default:
 
 # Run unit tests
 test:
-    {{commonenv}} go test ./...
+    {{commonenv}} go test ./... -coverprofile=coverage.out
 
 # Compile a local binary
 build-local:

+ 14 - 0
kubernetes/exporter/opencost-exporter.yaml

@@ -155,7 +155,21 @@ spec:
               value: "AIzaSyD29bGxmHAVEOBYtgd8sYM2gM2ekfxQX4U" # The GCP Pricing API requires a key. This is supplied just for evaluation.
             - name: CLUSTER_ID
               value: "cluster-one" # Default cluster ID to use if cluster_id is not set in Prometheus metrics.
+            - name: EXPORT_CSV_FILE
+              value: "s3://path/to/csv"
+            - name: AWS_ACCESS_KEY_ID  
+              value: "XXXXXXXXXXXXXXX" ## AWS Access KeyID
+            - name: AWS_SECRET_ACCESS_KEY
+              value: "XXXXXXXXXXXXXXX" ## AWS Secret Access Key
+            - name: AWS_REGION
+              value: "us-west-2" ## AWS Region where bucket is hosted
           imagePullPolicy: Always
+          volumeMounts:
+          - name: tmp-volume
+            mountPath: /tmp
+      volumes:
+      - name: tmp-volume
+        emptyDir: {}
 ---
 
 # Expose the cost model with a service

+ 6 - 6
pkg/cloud/alibaba/authorizer.go

@@ -5,7 +5,7 @@ import (
 
 	"github.com/aliyun/alibaba-cloud-sdk-go/sdk/auth"
 	"github.com/aliyun/alibaba-cloud-sdk-go/sdk/auth/credentials"
-	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/cloud"
 	"github.com/opencost/opencost/pkg/util/json"
 )
 
@@ -13,7 +13,7 @@ const AccessKeyAuthorizerType = "AlibabaAccessKey"
 
 // Authorizer provide *bssopenapi.Client for Alibaba cloud BOS for Billing related SDK calls
 type Authorizer interface {
-	config.Authorizer
+	cloud.Authorizer
 	GetCredentials() (auth.Credential, error)
 }
 
@@ -36,7 +36,7 @@ type AccessKey struct {
 // MarshalJSON custom json marshalling functions, sets properties as tagged in struct and sets the authorizer type property
 func (ak *AccessKey) MarshalJSON() ([]byte, error) {
 	fmap := make(map[string]any, 3)
-	fmap[config.AuthorizerTypeProperty] = AccessKeyAuthorizerType
+	fmap[cloud.AuthorizerTypeProperty] = AccessKeyAuthorizerType
 	fmap["accessKeyID"] = ak.AccessKeyID
 	fmap["accessKeySecret"] = ak.AccessKeySecret
 	return json.Marshal(fmap)
@@ -52,7 +52,7 @@ func (ak *AccessKey) Validate() error {
 	return nil
 }
 
-func (ak *AccessKey) Equals(config config.Config) bool {
+func (ak *AccessKey) Equals(config cloud.Config) bool {
 	if config == nil {
 		return false
 	}
@@ -70,10 +70,10 @@ func (ak *AccessKey) Equals(config config.Config) bool {
 	return true
 }
 
-func (ak *AccessKey) Sanitize() config.Config {
+func (ak *AccessKey) Sanitize() cloud.Config {
 	return &AccessKey{
 		AccessKeyID:     ak.AccessKeyID,
-		AccessKeySecret: config.Redacted,
+		AccessKeySecret: cloud.Redacted,
 	}
 }
 

+ 12 - 7
pkg/cloud/alibaba/boaconfiguration.go

@@ -3,7 +3,8 @@ package alibaba
 import (
 	"fmt"
 
-	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/kubecost"
 	"github.com/opencost/opencost/pkg/util/json"
 )
 
@@ -36,7 +37,7 @@ func (bc *BOAConfiguration) Validate() error {
 	return nil
 }
 
-func (bc *BOAConfiguration) Equals(config config.Config) bool {
+func (bc *BOAConfiguration) Equals(config cloud.Config) bool {
 	if config == nil {
 		return false
 	}
@@ -65,7 +66,7 @@ func (bc *BOAConfiguration) Equals(config config.Config) bool {
 	return true
 }
 
-func (bc *BOAConfiguration) Sanitize() config.Config {
+func (bc *BOAConfiguration) Sanitize() cloud.Config {
 	return &BOAConfiguration{
 		Account:    bc.Account,
 		Region:     bc.Region,
@@ -77,6 +78,10 @@ func (bc *BOAConfiguration) Key() string {
 	return fmt.Sprintf("%s/%s", bc.Account, bc.Region)
 }
 
+func (bc *BOAConfiguration) Provider() string {
+	return kubecost.AlibabaProvider
+}
+
 func (bc *BOAConfiguration) UnmarshalJSON(b []byte) error {
 	var f interface{}
 	err := json.Unmarshal(b, &f)
@@ -86,13 +91,13 @@ func (bc *BOAConfiguration) UnmarshalJSON(b []byte) error {
 
 	fmap := f.(map[string]interface{})
 
-	account, err := config.GetInterfaceValue[string](fmap, "account")
+	account, err := cloud.GetInterfaceValue[string](fmap, "account")
 	if err != nil {
 		return fmt.Errorf("BOAConfiguration: UnmarshalJSON: %s", err.Error())
 	}
 	bc.Account = account
 
-	region, err := config.GetInterfaceValue[string](fmap, "region")
+	region, err := cloud.GetInterfaceValue[string](fmap, "region")
 	if err != nil {
 		return fmt.Errorf("BOAConfiguration: UnmarshalJSON: %s", err.Error())
 	}
@@ -102,7 +107,7 @@ func (bc *BOAConfiguration) UnmarshalJSON(b []byte) error {
 	if !ok {
 		return fmt.Errorf("BOAConfiguration: UnmarshalJSON: missing authorizer")
 	}
-	authorizer, err := config.AuthorizerFromInterface(authAny, SelectAuthorizerByType)
+	authorizer, err := cloud.AuthorizerFromInterface(authAny, SelectAuthorizerByType)
 	if err != nil {
 		return fmt.Errorf("BOAConfiguration: UnmarshalJSON: %s", err.Error())
 	}
@@ -111,7 +116,7 @@ func (bc *BOAConfiguration) UnmarshalJSON(b []byte) error {
 	return nil
 }
 
-func ConvertAlibabaInfoToConfig(acc AlibabaInfo) config.KeyedConfig {
+func ConvertAlibabaInfoToConfig(acc AlibabaInfo) cloud.KeyedConfig {
 	if acc.IsEmpty() {
 		return nil
 	}

+ 2 - 2
pkg/cloud/alibaba/boaconfiguration_test.go

@@ -4,7 +4,7 @@ import (
 	"fmt"
 	"testing"
 
-	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/cloud"
 	"github.com/opencost/opencost/pkg/log"
 	"github.com/opencost/opencost/pkg/util/json"
 )
@@ -97,7 +97,7 @@ func TestBoaConfiguration_Validate(t *testing.T) {
 func TestBOAConfiguration_Equals(t *testing.T) {
 	testCases := map[string]struct {
 		left     BOAConfiguration
-		right    config.Config
+		right    cloud.Config
 		expected bool
 	}{
 		"matching config": {

+ 6 - 4
pkg/cloud/alibaba/boaquerier.go

@@ -4,11 +4,9 @@ import (
 	"fmt"
 	"strings"
 
-	"github.com/opencost/opencost/pkg/cloud"
-	cloudconfig "github.com/opencost/opencost/pkg/cloud/config"
-
 	"github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests"
 	"github.com/aliyun/alibaba-cloud-sdk-go/services/bssopenapi"
+	"github.com/opencost/opencost/pkg/cloud"
 	"github.com/opencost/opencost/pkg/kubecost"
 	"github.com/opencost/opencost/pkg/log"
 )
@@ -25,10 +23,14 @@ type BoaQuerier struct {
 }
 
 func (bq *BoaQuerier) GetStatus() cloud.ConnectionStatus {
+	// initialize status if it has not done so; this can happen if the integration is inactive
+	if bq.ConnectionStatus.String() == "" {
+		bq.ConnectionStatus = cloud.InitialStatus
+	}
 	return bq.ConnectionStatus
 }
 
-func (bq *BoaQuerier) Equals(config cloudconfig.Config) bool {
+func (bq *BoaQuerier) Equals(config cloud.Config) bool {
 	thatConfig, ok := config.(*BoaQuerier)
 	if !ok {
 		return false

+ 1 - 1
pkg/cloud/config/authorizer.go → pkg/cloud/authorizer.go

@@ -1,4 +1,4 @@
-package config
+package cloud
 
 import (
 	"fmt"

+ 31 - 24
pkg/cloud/aws/athenaconfiguration.go

@@ -3,7 +3,8 @@ package aws
 import (
 	"fmt"
 
-	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/kubecost"
 	"github.com/opencost/opencost/pkg/util/json"
 )
 
@@ -12,7 +13,7 @@ type AthenaConfiguration struct {
 	Bucket     string     `json:"bucket"`
 	Region     string     `json:"region"`
 	Database   string     `json:"database"`
-	Catalog    string     `json:"catalog""`
+	Catalog    string     `json:"catalog"`
 	Table      string     `json:"table"`
 	Workgroup  string     `json:"workgroup"`
 	Account    string     `json:"account"`
@@ -55,7 +56,7 @@ func (ac *AthenaConfiguration) Validate() error {
 	return nil
 }
 
-func (ac *AthenaConfiguration) Equals(config config.Config) bool {
+func (ac *AthenaConfiguration) Equals(config cloud.Config) bool {
 	if config == nil {
 		return false
 	}
@@ -105,7 +106,7 @@ func (ac *AthenaConfiguration) Equals(config config.Config) bool {
 	return true
 }
 
-func (ac *AthenaConfiguration) Sanitize() config.Config {
+func (ac *AthenaConfiguration) Sanitize() cloud.Config {
 	return &AthenaConfiguration{
 		Bucket:     ac.Bucket,
 		Region:     ac.Region,
@@ -122,6 +123,10 @@ func (ac *AthenaConfiguration) Key() string {
 	return fmt.Sprintf("%s/%s", ac.Account, ac.Bucket)
 }
 
+func (ac *AthenaConfiguration) Provider() string {
+	return kubecost.AWSProvider
+}
+
 func (ac *AthenaConfiguration) UnmarshalJSON(b []byte) error {
 	var f interface{}
 	err := json.Unmarshal(b, &f)
@@ -131,45 +136,47 @@ func (ac *AthenaConfiguration) UnmarshalJSON(b []byte) error {
 
 	fmap := f.(map[string]interface{})
 
-	bucket, err := config.GetInterfaceValue[string](fmap, "bucket")
+	bucket, err := cloud.GetInterfaceValue[string](fmap, "bucket")
 	if err != nil {
-		return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: %s", err.Error())
+		return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: %w", err)
 	}
 	ac.Bucket = bucket
 
-	region, err := config.GetInterfaceValue[string](fmap, "region")
+	region, err := cloud.GetInterfaceValue[string](fmap, "region")
 	if err != nil {
-		return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: %s", err.Error())
+		return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: %w", err)
 	}
 	ac.Region = region
 
-	database, err := config.GetInterfaceValue[string](fmap, "database")
+	database, err := cloud.GetInterfaceValue[string](fmap, "database")
 	if err != nil {
-		return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: %s", err.Error())
+		return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: %w", err)
 	}
 	ac.Database = database
 
-	catalog, err := config.GetInterfaceValue[string](fmap, "catalog")
-	if err != nil {
-		return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: %s", err.Error())
+	if _, ok := fmap["catalog"]; ok {
+		catalog, err := cloud.GetInterfaceValue[string](fmap, "catalog")
+		if err != nil {
+			return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: %w", err)
+		}
+		ac.Catalog = catalog
 	}
-	ac.Catalog = catalog
 
-	table, err := config.GetInterfaceValue[string](fmap, "table")
+	table, err := cloud.GetInterfaceValue[string](fmap, "table")
 	if err != nil {
-		return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: %s", err.Error())
+		return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: %w", err)
 	}
 	ac.Table = table
 
-	workgroup, err := config.GetInterfaceValue[string](fmap, "workgroup")
+	workgroup, err := cloud.GetInterfaceValue[string](fmap, "workgroup")
 	if err != nil {
-		return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: %s", err.Error())
+		return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: %w", err)
 	}
 	ac.Workgroup = workgroup
 
-	account, err := config.GetInterfaceValue[string](fmap, "account")
+	account, err := cloud.GetInterfaceValue[string](fmap, "account")
 	if err != nil {
-		return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: %s", err.Error())
+		return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: %w", err)
 	}
 	ac.Account = account
 
@@ -177,9 +184,9 @@ func (ac *AthenaConfiguration) UnmarshalJSON(b []byte) error {
 	if !ok {
 		return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: missing authorizer")
 	}
-	authorizer, err := config.AuthorizerFromInterface(authAny, SelectAuthorizerByType)
+	authorizer, err := cloud.AuthorizerFromInterface(authAny, SelectAuthorizerByType)
 	if err != nil {
-		return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: %s", err.Error())
+		return fmt.Errorf("AthenaConfiguration: UnmarshalJSON: %w", err)
 	}
 	ac.Authorizer = authorizer
 
@@ -188,7 +195,7 @@ func (ac *AthenaConfiguration) UnmarshalJSON(b []byte) error {
 
 // ConvertAwsAthenaInfoToConfig takes a legacy config and generates a Config based on the presence of properties to match
 // legacy behavior
-func ConvertAwsAthenaInfoToConfig(aai AwsAthenaInfo) config.KeyedConfig {
+func ConvertAwsAthenaInfoToConfig(aai AwsAthenaInfo) cloud.KeyedConfig {
 	if aai.IsEmpty() {
 		return nil
 	}
@@ -211,7 +218,7 @@ func ConvertAwsAthenaInfoToConfig(aai AwsAthenaInfo) config.KeyedConfig {
 		}
 	}
 
-	var config config.KeyedConfig
+	var config cloud.KeyedConfig
 	if aai.AthenaTable != "" || aai.AthenaDatabase != "" {
 		config = &AthenaConfiguration{
 			Bucket:     aai.AthenaBucketName,

+ 2 - 2
pkg/cloud/aws/athenaconfiguration_test.go

@@ -4,7 +4,7 @@ import (
 	"fmt"
 	"testing"
 
-	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/cloud"
 	"github.com/opencost/opencost/pkg/log"
 	"github.com/opencost/opencost/pkg/util/json"
 )
@@ -184,7 +184,7 @@ func TestAthenaConfiguration_Validate(t *testing.T) {
 func TestAthenaConfiguration_Equals(t *testing.T) {
 	testCases := map[string]struct {
 		left     AthenaConfiguration
-		right    config.Config
+		right    cloud.Config
 		expected bool
 	}{
 		"matching config": {

+ 105 - 182
pkg/cloud/aws/athenaintegration.go

@@ -11,7 +11,6 @@ import (
 	"github.com/opencost/opencost/pkg/cloud"
 	"github.com/opencost/opencost/pkg/kubecost"
 	"github.com/opencost/opencost/pkg/log"
-	"github.com/opencost/opencost/pkg/util/timeutil"
 )
 
 const LabelColumnPrefix = "resource_tags_user_"
@@ -29,14 +28,16 @@ const AthenaSPPricingColumn = "savings_plan_savings_plan_effective_cost"
 // Net Cost Columns
 const AthenaNetPricingColumn = "line_item_net_unblended_cost"
 
+var AthenaNetPricingCoalesce = fmt.Sprintf("COALESCE(%s, %s, 0)", AthenaNetPricingColumn, AthenaPricingColumn)
+
 // Amortized Net Cost Columns
 const AthenaNetRIPricingColumn = "reservation_net_effective_cost"
+
+var AthenaNetRIPricingCoalesce = fmt.Sprintf("COALESCE(%s, %s, 0)", AthenaNetRIPricingColumn, AthenaRIPricingColumn)
+
 const AthenaNetSPPricingColumn = "savings_plan_net_savings_plan_effective_cost"
 
-// Category Columns
-const AthenaIsNode = "SUBSTRING(line_item_resource_id,1,2) = 'i-'"
-const AthenaIsVol = "SUBSTRING(line_item_resource_id, 1, 4) = 'vol-'"
-const AthenaIsNetwork = "line_item_usage_type LIKE '%Bytes'"
+var AthenaNetSPPricingCoalesce = fmt.Sprintf("COALESCE(%s, %s, 0)", AthenaNetSPPricingColumn, AthenaSPPricingColumn)
 
 // athenaDateTruncColumn Aggregates line items from the hourly level to daily. "line_item_usage_start_date" is used because at
 // all time values 00:00-23:00 it will truncate to the correct date.
@@ -48,19 +49,14 @@ const AthenaWhereUsage = "(line_item_line_item_type = 'Usage' OR line_item_line_
 
 // AthenaQueryIndexes is a struct for holding the context of a query
 type AthenaQueryIndexes struct {
-	Query                     string
-	ColumnIndexes             map[string]int
-	TagColumns                []string
-	ListCostColumn            string
-	ListK8sCostColumn         string
-	NetCostColumn             string
-	NetK8sCostColumn          string
-	AmortizedNetCostColumn    string
-	AmortizedNetK8sCostColumn string
-	AmortizedCostColumn       string
-	AmortizedK8sCostColumn    string
-	InvoicedCostColumn        string
-	InvoicedK8sCostColumn     string
+	Query                  string
+	ColumnIndexes          map[string]int
+	TagColumns             []string
+	ListCostColumn         string
+	NetCostColumn          string
+	AmortizedNetCostColumn string
+	AmortizedCostColumn    string
+	IsK8sColumn            string
 }
 
 type AthenaIntegration struct {
@@ -84,14 +80,16 @@ func (ai *AthenaIntegration) GetCloudCost(start, end time.Time) (*kubecost.Cloud
 		"line_item_usage_account_id",
 		"line_item_product_code",
 		"line_item_usage_type",
-		AthenaIsNode,
-		AthenaIsVol,
-		AthenaIsNetwork,
 	}
 
 	// Create query indices
 	aqi := AthenaQueryIndexes{}
 
+	// Add is k8s column
+	isK8sColumn := ai.GetIsKubernetesColumn(allColumns)
+	groupByColumns = append(groupByColumns, isK8sColumn)
+	aqi.IsK8sColumn = isK8sColumn
+
 	// Determine which columns are user-defined tags and add those to the list
 	// of columns to query.
 	for column := range allColumns {
@@ -109,69 +107,39 @@ func (ai *AthenaIntegration) GetCloudCost(start, end time.Time) (*kubecost.Cloud
 	ai.RemoveColumnAliases(groupByColumns)
 
 	// Build list cost column and add it to the select columns
-	listCostColumn := fmt.Sprintf("SUM(%s) as list_cost", ai.GetListCostColumn())
+	listCostColumn := ai.GetListCostColumn()
 	selectColumns = append(selectColumns, listCostColumn)
 	aqi.ListCostColumn = listCostColumn
-	listK8sCostColumn := fmt.Sprintf(
-		"SUM(%s) as list_kubernetes_cost",
-		ai.GetKubernetesCostColumn(allColumns, ai.GetListCostColumn()),
-	)
-	selectColumns = append(selectColumns, listK8sCostColumn)
-	aqi.ListK8sCostColumn = listK8sCostColumn
 
 	// Build net cost column and add it to select columns
-	netCostColumn := fmt.Sprintf("SUM(%s) as net_cost", ai.GetNetCostColumn(allColumns))
+	netCostColumn := ai.GetNetCostColumn(allColumns)
 	selectColumns = append(selectColumns, netCostColumn)
 	aqi.NetCostColumn = netCostColumn
-	netK8sCostColumn := fmt.Sprintf(
-		"SUM(%s) as net_kubernetes_cost",
-		ai.GetKubernetesCostColumn(allColumns, ai.GetNetCostColumn(allColumns)),
-	)
-	selectColumns = append(selectColumns, netK8sCostColumn)
-	aqi.NetK8sCostColumn = netK8sCostColumn
 
 	// Build amortized net cost column and add it to select columns
-	amortizedNetCostColumn := fmt.Sprintf("SUM(%s) as amortized_net_cost", ai.GetAmortizedNetCostColumn(allColumns))
+	amortizedNetCostColumn := ai.GetAmortizedNetCostColumn(allColumns)
 	selectColumns = append(selectColumns, amortizedNetCostColumn)
 	aqi.AmortizedNetCostColumn = amortizedNetCostColumn
-	amortizedNetK8sCostColumn := fmt.Sprintf(
-		"SUM(%s) as amortized_net_kubernetes_cost",
-		ai.GetKubernetesCostColumn(allColumns, ai.GetNetCostColumn(allColumns)),
-	)
-	selectColumns = append(selectColumns, amortizedNetK8sCostColumn)
-	aqi.AmortizedNetK8sCostColumn = amortizedNetK8sCostColumn
 
 	// Build Amortized cost column and add it to select columns
-	amortizedCostColumn := fmt.Sprintf("SUM(%s) as amortized_cost", ai.GetAmortizedCostCase(allColumns))
+	amortizedCostColumn := ai.GetAmortizedCostColumn(allColumns)
 	selectColumns = append(selectColumns, amortizedCostColumn)
 	aqi.AmortizedCostColumn = amortizedCostColumn
-	amortizedK8sCostColumn := fmt.Sprintf(
-		"SUM(%s) as amortized_kubernetes_cost",
-		ai.GetKubernetesCostColumn(allColumns, ai.GetAmortizedCostCase(allColumns)),
-	)
-	selectColumns = append(selectColumns, amortizedK8sCostColumn)
-	aqi.AmortizedK8sCostColumn = amortizedK8sCostColumn
-
-	// We are using Net Cost for Invoiced Cost for now as it is the closest approximation
-	invoicedCostColumn := netCostColumn
-	selectColumns = append(selectColumns, invoicedCostColumn)
-	aqi.InvoicedCostColumn = invoicedCostColumn
-	invoicedK8sCostColumn := netK8sCostColumn
-	selectColumns = append(selectColumns, invoicedK8sCostColumn)
-	aqi.InvoicedK8sCostColumn = invoicedK8sCostColumn
 
 	// Build map of query columns to use for parsing query
 	aqi.ColumnIndexes = map[string]int{}
 	for i, column := range selectColumns {
 		aqi.ColumnIndexes[column] = i
 	}
-	athenaWhereDate := fmt.Sprintf(AthenaWhereDateFmt, start.Format("2006-01-02"), end.Format("2006-01-02"))
+	whereDate := fmt.Sprintf(AthenaWhereDateFmt, start.Format("2006-01-02"), end.Format("2006-01-02"))
+	wherePartitions := ai.GetPartitionWhere(start, end)
 
 	// Query for all line items with a resource_id or from AWS Marketplace, which did not end before
 	// the range or start after it. This captures all costs with any amount of
 	// overlap with the range, for which we will only extract the relevant costs
 	whereConjuncts := []string{
-		athenaWhereDate,
+		wherePartitions,
+		whereDate,
 		AthenaWhereUsage,
 	}
 	columnStr := strings.Join(selectColumns, ", ")
@@ -179,13 +147,13 @@ func (ai *AthenaIntegration) GetCloudCost(start, end time.Time) (*kubecost.Cloud
 	groupByStr := strings.Join(groupByColumns, ", ")
 	queryStr := `
 		SELECT %s
-		FROM %s
+		FROM "%s"
 		WHERE %s
 		GROUP BY %s
 	`
 	aqi.Query = fmt.Sprintf(queryStr, columnStr, ai.Table, whereClause, groupByStr)
 
-	ccsr, err := kubecost.NewCloudCostSetRange(start, end, timeutil.Day, ai.Key())
+	ccsr, err := kubecost.NewCloudCostSetRange(start, end, kubecost.AccumulateOptionDay, ai.Key())
 	if err != nil {
 		return nil, err
 	}
@@ -204,10 +172,8 @@ func (ai *AthenaIntegration) GetCloudCost(start, end time.Time) (*kubecost.Cloud
 		return nil, err
 	}
 
-	for _, ccs := range ccsr.CloudCostSets {
-		log.Debugf("AthenaIntegration[%s]: GetCloudCost: writing compute items for window %s: %d", ai.Key(), ccs.Window, len(ccs.CloudCosts))
-		ai.ConnectionStatus = ai.GetConnectionStatusFromResult(ccs, ai.ConnectionStatus)
-	}
+	ai.ConnectionStatus = ai.GetConnectionStatusFromResult(ccsr, ai.ConnectionStatus)
+
 	return ccsr, nil
 
 }
@@ -220,17 +186,22 @@ func (ai *AthenaIntegration) GetListCostColumn() string {
 	listCostBuilder.WriteString(" ELSE ")
 	listCostBuilder.WriteString(AthenaPricingColumn)
 	listCostBuilder.WriteString(" END")
-	return listCostBuilder.String()
+	return fmt.Sprintf("SUM(%s) as list_cost", listCostBuilder.String())
 }
 
 func (ai *AthenaIntegration) GetNetCostColumn(allColumns map[string]bool) string {
 	netCostColumn := ""
 	if allColumns[AthenaNetPricingColumn] { // if Net pricing exists
-		netCostColumn = AthenaNetPricingColumn
+		netCostColumn = AthenaNetPricingCoalesce
 	} else { // Non-net for if there's no net pricing.
 		netCostColumn = AthenaPricingColumn
 	}
-	return netCostColumn
+	return fmt.Sprintf("SUM(%s) as net_cost", netCostColumn)
+}
+
+func (ai *AthenaIntegration) GetAmortizedCostColumn(allColumns map[string]bool) string {
+	amortizedCostCase := ai.GetAmortizedCostCase(allColumns)
+	return fmt.Sprintf("SUM(%s) as amortized_cost", amortizedCostCase)
 }
 
 func (ai *AthenaIntegration) GetAmortizedNetCostColumn(allColumns map[string]bool) string {
@@ -240,43 +211,7 @@ func (ai *AthenaIntegration) GetAmortizedNetCostColumn(allColumns map[string]boo
 	} else { // Non-net for if there's no net pricing.
 		amortizedNetCostCase = ai.GetAmortizedCostCase(allColumns)
 	}
-	return amortizedNetCostCase
-}
-
-// getIsKubernetesColumn generates a boolean column which determines whether a line item is from kubernetes
-func (ai *AthenaIntegration) GetIsKubernetesColumn(allColumns map[string]bool) string {
-	return ai.GetIsKubernetesCase(allColumns)
-}
-
-// getKubernetesCostColumn generates a double column which determines the cost of k8s items in an aggregate
-func (ai *AthenaIntegration) GetKubernetesCostColumn(allColumns map[string]bool, pricingCase string) string {
-	k8sCase := ai.GetIsKubernetesCase(allColumns)
-	return fmt.Sprintf("CAST((%s) as double) * (%s)", k8sCase, pricingCase)
-
-}
-
-func (ai *AthenaIntegration) RemoveColumnAliases(columns []string) {
-	for i, column := range columns {
-		if strings.Contains(column, " as ") {
-			columnValues := strings.Split(column, " as ")
-			columns[i] = columnValues[0]
-		}
-	}
-}
-
-func (ai *AthenaIntegration) ConvertLabelToAWSTag(label string) string {
-	// if the label already has the column prefix assume that it is in the correct format
-	if strings.HasPrefix(label, LabelColumnPrefix) {
-		return label
-	}
-	// replace characters with underscore
-	tag := label
-	tag = strings.ReplaceAll(tag, ".", "_")
-	tag = strings.ReplaceAll(tag, "/", "_")
-	tag = strings.ReplaceAll(tag, ":", "_")
-	tag = strings.ReplaceAll(tag, "-", "_")
-	// add prefix and return
-	return LabelColumnPrefix + tag
+	return fmt.Sprintf("SUM(%s) as amortized_net_cost", amortizedNetCostCase)
 }
 
 func (ai *AthenaIntegration) GetAmortizedCostCase(allColumns map[string]bool) string {
@@ -306,32 +241,58 @@ func (ai *AthenaIntegration) GetAmortizedCostCase(allColumns map[string]bool) st
 func (ai *AthenaIntegration) GetAmortizedNetCostCase(allColumns map[string]bool) string {
 	// Use net unblended costs if Reserved Instances/Savings Plans aren't in use
 	if !allColumns[AthenaNetRIPricingColumn] && !allColumns[AthenaNetSPPricingColumn] {
-		return AthenaNetPricingColumn
+		return AthenaNetPricingCoalesce
 	}
 
 	var costBuilder strings.Builder
 	costBuilder.WriteString("CASE line_item_line_item_type")
 	if allColumns[AthenaNetRIPricingColumn] {
 		costBuilder.WriteString(" WHEN 'DiscountedUsage' THEN ")
-		costBuilder.WriteString(AthenaNetRIPricingColumn)
+		costBuilder.WriteString(AthenaNetRIPricingCoalesce)
 	}
 
 	if allColumns[AthenaNetSPPricingColumn] {
 		costBuilder.WriteString(" WHEN 'SavingsPlanCoveredUsage' THEN ")
-		costBuilder.WriteString(AthenaNetSPPricingColumn)
+		costBuilder.WriteString(AthenaNetSPPricingCoalesce)
 	}
 
 	costBuilder.WriteString(" ELSE ")
-	costBuilder.WriteString(AthenaNetPricingColumn)
+	costBuilder.WriteString(AthenaNetPricingCoalesce)
 	costBuilder.WriteString(" END")
 	return costBuilder.String()
 }
 
-// GetIsKubernetesCase builds a "CASE" clause which attempts to determine if a line item is kubernetes based on labels
-// that may be available in the CUR
-func (ai *AthenaIntegration) GetIsKubernetesCase(allColumns map[string]bool) string {
-	// k8sColumns is a list of columns where the presence of a value indicates that a resource is part of a kubernetes cluster
-	k8sColumns := []string{
+func (ai *AthenaIntegration) RemoveColumnAliases(columns []string) {
+	for i, column := range columns {
+		if strings.Contains(column, " as ") {
+			columnValues := strings.Split(column, " as ")
+			columns[i] = columnValues[0]
+		}
+	}
+}
+
+func (ai *AthenaIntegration) ConvertLabelToAWSTag(label string) string {
+	// if the label already has the column prefix assume that it is in the correct format
+	if strings.HasPrefix(label, LabelColumnPrefix) {
+		return label
+	}
+	// replace characters with underscore
+	tag := label
+	tag = strings.ReplaceAll(tag, ".", "_")
+	tag = strings.ReplaceAll(tag, "/", "_")
+	tag = strings.ReplaceAll(tag, ":", "_")
+	tag = strings.ReplaceAll(tag, "-", "_")
+	// add prefix and return
+	return LabelColumnPrefix + tag
+}
+
+// GetIsKubernetesColumn builds a column that determines if a row represents kubernetes spend
+func (ai *AthenaIntegration) GetIsKubernetesColumn(allColumns map[string]bool) string {
+	disjuncts := []string{
+		"line_item_product_code = 'AmazonEKS'", // EKS is always kubernetes
+	}
+	// tagColumns is a list of columns where the presence of a value indicates that a resource is part of a kubernetes cluster
+	tagColumns := []string{
 		"resource_tags_aws_eks_cluster_name",
 		"resource_tags_user_eks_cluster_name",
 		"resource_tags_user_alpha_eksctl_io_cluster_name",
@@ -339,21 +300,28 @@ func (ai *AthenaIntegration) GetIsKubernetesCase(allColumns map[string]bool) str
 		"resource_tags_user_kubernetes_io_created_for_pvc_name",
 		"resource_tags_user_kubernetes_io_created_for_pv_name",
 	}
-	var k8sBuilder strings.Builder
 
-	k8sBuilder.WriteString("CASE ")
-	// EKS is always kubernetes
-	k8sBuilder.WriteString("WHEN line_item_product_code = 'AmazonEKS' THEN TRUE ")
-	for _, k8sColumn := range k8sColumns {
-		if _, ok := allColumns[k8sColumn]; ok {
-			k8sBuilder.WriteString("WHEN ")
-			k8sBuilder.WriteString(k8sColumn)
-			k8sBuilder.WriteString(" <> '' THEN TRUE ")
+	for _, tagColumn := range tagColumns {
+		// if tag column is present in the CUR check for it
+		if _, ok := allColumns[tagColumn]; ok {
+			disjunctStr := fmt.Sprintf("%s <> ''", tagColumn)
+			disjuncts = append(disjuncts, disjunctStr)
 		}
 	}
 
-	k8sBuilder.WriteString("ELSE FALSE END")
-	return k8sBuilder.String()
+	return fmt.Sprintf("(%s) as is_kubernetes", strings.Join(disjuncts, " OR "))
+}
+
+func (ai *AthenaIntegration) GetPartitionWhere(start, end time.Time) string {
+	month := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC)
+	endMonth := time.Date(end.Year(), end.Month(), 1, 0, 0, 0, 0, time.UTC)
+	var disjuncts []string
+	for !month.After(endMonth) {
+		disjuncts = append(disjuncts, fmt.Sprintf("(year = '%d' AND month = '%d')", month.Year(), month.Month()))
+		month = month.AddDate(0, 1, 0)
+	}
+	str := fmt.Sprintf("(%s)", strings.Join(disjuncts, " OR "))
+	return str
 }
 
 func (ai *AthenaIntegration) RowToCloudCost(row types.Row, aqi AthenaQueryIndexes, ccsr *kubecost.CloudCostSetRange) error {
@@ -380,16 +348,13 @@ func (ai *AthenaIntegration) RowToCloudCost(row types.Row, aqi AthenaQueryIndexe
 	providerID := GetAthenaRowValue(row, aqi.ColumnIndexes, "line_item_resource_id")
 	productCode := GetAthenaRowValue(row, aqi.ColumnIndexes, "line_item_product_code")
 	usageType := GetAthenaRowValue(row, aqi.ColumnIndexes, "line_item_usage_type")
-	isNode, _ := strconv.ParseBool(GetAthenaRowValue(row, aqi.ColumnIndexes, AthenaIsNode))
-	isVol, _ := strconv.ParseBool(GetAthenaRowValue(row, aqi.ColumnIndexes, AthenaIsVol))
-	isNetwork, _ := strconv.ParseBool(GetAthenaRowValue(row, aqi.ColumnIndexes, AthenaIsNetwork))
-
-	listCost, err := GetAthenaRowValueFloat(row, aqi.ColumnIndexes, aqi.ListCostColumn)
-	if err != nil {
-		return err
+	isK8s, _ := strconv.ParseBool(GetAthenaRowValue(row, aqi.ColumnIndexes, aqi.IsK8sColumn))
+	k8sPct := 0.0
+	if isK8s {
+		k8sPct = 1.0
 	}
 
-	listK8sCost, err := GetAthenaRowValueFloat(row, aqi.ColumnIndexes, aqi.ListK8sCostColumn)
+	listCost, err := GetAthenaRowValueFloat(row, aqi.ColumnIndexes, aqi.ListCostColumn)
 	if err != nil {
 		return err
 	}
@@ -399,42 +364,18 @@ func (ai *AthenaIntegration) RowToCloudCost(row types.Row, aqi AthenaQueryIndexe
 		return err
 	}
 
-	netK8sCost, err := GetAthenaRowValueFloat(row, aqi.ColumnIndexes, aqi.NetK8sCostColumn)
-	if err != nil {
-		return err
-	}
-
 	amortizedNetCost, err := GetAthenaRowValueFloat(row, aqi.ColumnIndexes, aqi.AmortizedNetCostColumn)
 	if err != nil {
 		return err
 	}
 
-	amortizedNetK8sCost, err := GetAthenaRowValueFloat(row, aqi.ColumnIndexes, aqi.AmortizedNetK8sCostColumn)
-	if err != nil {
-		return err
-	}
 	amortizedCost, err := GetAthenaRowValueFloat(row, aqi.ColumnIndexes, aqi.AmortizedCostColumn)
 	if err != nil {
 		return err
 	}
 
-	amortizedK8sCost, err := GetAthenaRowValueFloat(row, aqi.ColumnIndexes, aqi.AmortizedK8sCostColumn)
-	if err != nil {
-		return err
-	}
-
-	invoicedCost, err := GetAthenaRowValueFloat(row, aqi.ColumnIndexes, aqi.InvoicedCostColumn)
-	if err != nil {
-		return err
-	}
-
-	invoicedK8sCost, err := GetAthenaRowValueFloat(row, aqi.ColumnIndexes, aqi.InvoicedK8sCostColumn)
-	if err != nil {
-		return err
-	}
-
 	// Identify resource category in the CUR
-	category := SelectAWSCategory(isNode, isVol, isNetwork, providerID, productCode)
+	category := SelectAWSCategory(providerID, usageType, productCode)
 
 	// Retrieve final stanza of product code for ProviderID
 	if productCode == "AWSELB" || productCode == "AmazonFSx" {
@@ -470,23 +411,23 @@ func (ai *AthenaIntegration) RowToCloudCost(row types.Row, aqi AthenaQueryIndexe
 		Window:     kubecost.NewWindow(&start, &end),
 		ListCost: kubecost.CostMetric{
 			Cost:              listCost,
-			KubernetesPercent: ai.CalculateK8sPercent(listCost, listK8sCost),
+			KubernetesPercent: k8sPct,
 		},
 		NetCost: kubecost.CostMetric{
 			Cost:              netCost,
-			KubernetesPercent: ai.CalculateK8sPercent(netCost, netK8sCost),
+			KubernetesPercent: k8sPct,
 		},
 		AmortizedNetCost: kubecost.CostMetric{
 			Cost:              amortizedNetCost,
-			KubernetesPercent: ai.CalculateK8sPercent(amortizedNetCost, amortizedNetK8sCost),
+			KubernetesPercent: k8sPct,
 		},
 		AmortizedCost: kubecost.CostMetric{
 			Cost:              amortizedCost,
-			KubernetesPercent: ai.CalculateK8sPercent(amortizedCost, amortizedK8sCost),
+			KubernetesPercent: k8sPct,
 		},
 		InvoicedCost: kubecost.CostMetric{
-			Cost:              invoicedCost,
-			KubernetesPercent: ai.CalculateK8sPercent(invoicedCost, invoicedK8sCost),
+			Cost:              netCost, // We are using Net Cost for Invoiced Cost for now as it is the closest approximation
+			KubernetesPercent: k8sPct,
 		},
 	}
 
@@ -494,27 +435,9 @@ func (ai *AthenaIntegration) RowToCloudCost(row types.Row, aqi AthenaQueryIndexe
 	return nil
 }
 
-func (ai *AthenaIntegration) CalculateK8sPercent(cost, k8sCost float64) float64 {
-	// Calculate percent of cost that is k8s with the k8sCost
-	k8sPercent := 0.0
-	if k8sCost != 0.0 && cost != 0.0 {
-		k8sPercent = k8sCost / cost
-	}
-	return k8sPercent
-}
-
 func (ai *AthenaIntegration) GetConnectionStatusFromResult(result cloud.EmptyChecker, currentStatus cloud.ConnectionStatus) cloud.ConnectionStatus {
 	if result.IsEmpty() && currentStatus != cloud.SuccessfulConnection {
 		return cloud.MissingData
 	}
 	return cloud.SuccessfulConnection
 }
-
-func (ai *AthenaIntegration) GetConnectionStatus() string {
-	// initialize status if it has not done so; this can happen if the integration is inactive
-	if ai.ConnectionStatus.String() == "" {
-		ai.ConnectionStatus = cloud.InitialStatus
-	}
-
-	return ai.ConnectionStatus.String()
-}

+ 11 - 8
pkg/cloud/aws/athenaquerier.go

@@ -8,12 +8,10 @@ import (
 	"strings"
 	"time"
 
-	"github.com/opencost/opencost/pkg/cloud"
-	cloudconfig "github.com/opencost/opencost/pkg/cloud/config"
-
 	"github.com/aws/aws-sdk-go-v2/aws"
 	"github.com/aws/aws-sdk-go-v2/service/athena"
 	"github.com/aws/aws-sdk-go-v2/service/athena/types"
+	"github.com/opencost/opencost/pkg/cloud"
 	"github.com/opencost/opencost/pkg/kubecost"
 	"github.com/opencost/opencost/pkg/log"
 	"github.com/opencost/opencost/pkg/util/stringutil"
@@ -25,10 +23,14 @@ type AthenaQuerier struct {
 }
 
 func (aq *AthenaQuerier) GetStatus() cloud.ConnectionStatus {
+	// initialize status if it has not done so; this can happen if the integration is inactive
+	if aq.ConnectionStatus.String() == "" {
+		aq.ConnectionStatus = cloud.InitialStatus
+	}
 	return aq.ConnectionStatus
 }
 
-func (aq *AthenaQuerier) Equals(config cloudconfig.Config) bool {
+func (aq *AthenaQuerier) Equals(config cloud.Config) bool {
 	thatConfig, ok := config.(*AthenaQuerier)
 	if !ok {
 		return false
@@ -128,6 +130,7 @@ func (aq *AthenaQuerier) queryAthenaPaginated(ctx context.Context, query string,
 	}
 	queryResultsInput := &athena.GetQueryResultsInput{
 		QueryExecutionId: startQueryExecutionOutput.QueryExecutionId,
+		MaxResults:       aws.Int32(1000), // this is the default value
 	}
 	getQueryResultsPaginator := athena.NewGetQueryResultsPaginator(cli, queryResultsInput)
 	for getQueryResultsPaginator.HasMorePages() {
@@ -197,18 +200,18 @@ func GetAthenaRowValueFloat(row types.Row, queryColumnIndexes map[string]int, co
 	return cost, nil
 }
 
-func SelectAWSCategory(isNode, isVol, isNetwork bool, providerID, service string) string {
+func SelectAWSCategory(providerID, usageType, service string) string {
 	// Network has the highest priority and is based on the usage type ending in "Bytes"
-	if isNetwork {
+	if strings.HasSuffix(usageType, "Bytes") {
 		return kubecost.NetworkCategory
 	}
 	// The node and volume conditions are mutually exclusive.
 	// Provider ID has prefix "i-"
-	if isNode {
+	if strings.HasPrefix(providerID, "i-") {
 		return kubecost.ComputeCategory
 	}
 	// Provider ID has prefix "vol-"
-	if isVol {
+	if strings.HasPrefix(providerID, "vol-") {
 		return kubecost.StorageCategory
 	}
 

+ 14 - 14
pkg/cloud/aws/authorizer.go

@@ -8,7 +8,7 @@ import (
 	awsconfig "github.com/aws/aws-sdk-go-v2/config"
 	"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
 	"github.com/aws/aws-sdk-go-v2/service/sts"
-	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/cloud"
 	"github.com/opencost/opencost/pkg/util/json"
 )
 
@@ -18,7 +18,7 @@ const AssumeRoleAuthorizerType = "AWSAssumeRole"
 
 // Authorizer implementations provide aws.Config for AWS SDK calls
 type Authorizer interface {
-	config.Authorizer
+	cloud.Authorizer
 	CreateAWSConfig(string) (aws.Config, error)
 }
 
@@ -45,7 +45,7 @@ type AccessKey struct {
 // MarshalJSON custom json marshalling functions, sets properties as tagged in struct and sets the authorizer type property
 func (ak *AccessKey) MarshalJSON() ([]byte, error) {
 	fmap := make(map[string]any, 3)
-	fmap[config.AuthorizerTypeProperty] = AccessKeyAuthorizerType
+	fmap[cloud.AuthorizerTypeProperty] = AccessKeyAuthorizerType
 	fmap["id"] = ak.ID
 	fmap["secret"] = ak.Secret
 	return json.Marshal(fmap)
@@ -70,7 +70,7 @@ func (ak *AccessKey) Validate() error {
 	return nil
 }
 
-func (ak *AccessKey) Equals(config config.Config) bool {
+func (ak *AccessKey) Equals(config cloud.Config) bool {
 	if config == nil {
 		return false
 	}
@@ -88,10 +88,10 @@ func (ak *AccessKey) Equals(config config.Config) bool {
 	return true
 }
 
-func (ak *AccessKey) Sanitize() config.Config {
+func (ak *AccessKey) Sanitize() cloud.Config {
 	return &AccessKey{
 		ID:     ak.ID,
-		Secret: config.Redacted,
+		Secret: cloud.Redacted,
 	}
 }
 
@@ -115,7 +115,7 @@ type ServiceAccount struct{}
 // MarshalJSON custom json marshalling functions, sets properties as tagged in struct and sets the authorizer type property
 func (sa *ServiceAccount) MarshalJSON() ([]byte, error) {
 	fmap := make(map[string]any, 1)
-	fmap[config.AuthorizerTypeProperty] = ServiceAccountAuthorizerType
+	fmap[cloud.AuthorizerTypeProperty] = ServiceAccountAuthorizerType
 	return json.Marshal(fmap)
 }
 
@@ -124,7 +124,7 @@ func (sa *ServiceAccount) Validate() error {
 	return nil
 }
 
-func (sa *ServiceAccount) Equals(config config.Config) bool {
+func (sa *ServiceAccount) Equals(config cloud.Config) bool {
 	if config == nil {
 		return false
 	}
@@ -136,7 +136,7 @@ func (sa *ServiceAccount) Equals(config config.Config) bool {
 	return true
 }
 
-func (sa *ServiceAccount) Sanitize() config.Config {
+func (sa *ServiceAccount) Sanitize() cloud.Config {
 	return &ServiceAccount{}
 }
 
@@ -157,7 +157,7 @@ type AssumeRole struct {
 // MarshalJSON custom json marshalling functions, sets properties as tagged in struct and sets the authorizer type property
 func (ara *AssumeRole) MarshalJSON() ([]byte, error) {
 	fmap := make(map[string]any, 3)
-	fmap[config.AuthorizerTypeProperty] = AssumeRoleAuthorizerType
+	fmap[cloud.AuthorizerTypeProperty] = AssumeRoleAuthorizerType
 	fmap["roleARN"] = ara.RoleARN
 	fmap["authorizer"] = ara.Authorizer
 	return json.Marshal(fmap)
@@ -173,7 +173,7 @@ func (ara *AssumeRole) UnmarshalJSON(b []byte) error {
 
 	fmap := f.(map[string]interface{})
 
-	roleARN, err := config.GetInterfaceValue[string](fmap, "roleARN")
+	roleARN, err := cloud.GetInterfaceValue[string](fmap, "roleARN")
 	if err != nil {
 		return fmt.Errorf("StorageConfiguration: UnmarshalJSON: %s", err.Error())
 	}
@@ -183,7 +183,7 @@ func (ara *AssumeRole) UnmarshalJSON(b []byte) error {
 	if !ok {
 		return fmt.Errorf("AssumeRole: UnmarshalJSON: missing Authorizer")
 	}
-	authorizer, err := config.AuthorizerFromInterface(authAny, SelectAuthorizerByType)
+	authorizer, err := cloud.AuthorizerFromInterface(authAny, SelectAuthorizerByType)
 	if err != nil {
 		return fmt.Errorf("AssumeRole: UnmarshalJSON: %s", err.Error())
 	}
@@ -218,7 +218,7 @@ func (ara *AssumeRole) Validate() error {
 	return nil
 }
 
-func (ara *AssumeRole) Equals(config config.Config) bool {
+func (ara *AssumeRole) Equals(config cloud.Config) bool {
 	if config == nil {
 		return false
 	}
@@ -243,7 +243,7 @@ func (ara *AssumeRole) Equals(config config.Config) bool {
 	return true
 }
 
-func (ara *AssumeRole) Sanitize() config.Config {
+func (ara *AssumeRole) Sanitize() cloud.Config {
 	return &AssumeRole{
 		Authorizer: ara.Authorizer.Sanitize().(Authorizer),
 		RoleARN:    ara.RoleARN,

+ 3 - 3
pkg/cloud/aws/authorizer_test.go

@@ -3,7 +3,7 @@ package aws
 import (
 	"testing"
 
-	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/cloud"
 )
 
 func TestAuthorizerJSON_Sanitize(t *testing.T) {
@@ -19,7 +19,7 @@ func TestAuthorizerJSON_Sanitize(t *testing.T) {
 			},
 			expected: &AccessKey{
 				ID:     "ID",
-				Secret: config.Redacted,
+				Secret: cloud.Redacted,
 			},
 		},
 		"Service Account": {
@@ -37,7 +37,7 @@ func TestAuthorizerJSON_Sanitize(t *testing.T) {
 			expected: &AssumeRole{
 				Authorizer: &AccessKey{
 					ID:     "ID",
-					Secret: config.Redacted,
+					Secret: cloud.Redacted,
 				},
 				RoleARN: "role arn",
 			},

+ 43 - 2
pkg/cloud/aws/provider.go

@@ -70,6 +70,13 @@ var (
 	usageTypeRegx = regexp.MustCompile(".*(-|^)(EBS.+)")
 	versionRx     = regexp.MustCompile(`^#Version: (\\d+)\\.\\d+$`)
 	regionRx      = regexp.MustCompile("([a-z]+-[a-z]+-[0-9])")
+
+	// StorageClassProvisionerDefaults specifies the default storage class types depending upon the provisioner
+	StorageClassProvisionerDefaults = map[string]string{
+		"kubernetes.io/aws-ebs": "gp2",
+		"ebs.csi.aws.com":       "gp3",
+		// TODO: add efs provisioner
+	}
 )
 
 func (aws *AWS) PricingSourceStatus() map[string]*models.PricingSource {
@@ -666,6 +673,9 @@ func (k *awsKey) Features() string {
 // If the instance is a spot instance, it will return PreemptibleType
 // Otherwise returns an empty string
 func (k *awsKey) getUsageType(labels map[string]string) string {
+	if kLabel, ok := labels[k.SpotLabelName]; ok && kLabel == k.SpotLabelValue {
+		return PreemptibleType
+	}
 	if eksLabel, ok := labels[EKSCapacityTypeLabel]; ok && eksLabel == EKSCapacitySpotTypeValue {
 		// We currently write out spot instances as "preemptible" in the pricing data, so these need to match
 		return PreemptibleType
@@ -720,7 +730,12 @@ func (key *awsPVKey) GetStorageClass() string {
 }
 
 func (key *awsPVKey) Features() string {
-	storageClass := key.StorageClassParameters["type"]
+	storageClass, ok := key.StorageClassParameters["type"]
+	if !ok {
+		log.Debugf("storage class %s doesn't have a 'type' parameter", key.Name)
+		storageClass = getStorageClassTypeFrom(key.StorageClassParameters["provisioner"])
+	}
+
 	if storageClass == "standard" {
 		storageClass = "gp2"
 	}
@@ -738,6 +753,22 @@ func (key *awsPVKey) Features() string {
 	return region + "," + class
 }
 
+// getStorageClassTypeFrom returns the default ebs volume type for a provider provisioner
+func getStorageClassTypeFrom(provisioner string) string {
+	// if there isn't any provided provisioner, return empty volume type
+	if provisioner == "" {
+		return ""
+	}
+
+	scType, ok := StorageClassProvisionerDefaults[provisioner]
+	if ok {
+		log.Debugf("using default voltype %s for provisioner %s", scType, provisioner)
+		return scType
+	}
+
+	return ""
+}
+
 // GetKey maps node labels to information needed to retrieve pricing data
 func (aws *AWS) GetKey(labels map[string]string, n *v1.Node) models.Key {
 	return &awsKey{
@@ -862,6 +893,9 @@ func (aws *AWS) DownloadPricingData() error {
 	storageClassMap := make(map[string]map[string]string)
 	for _, storageClass := range storageClasses {
 		params := storageClass.Parameters
+		if params != nil {
+			params["provisioner"] = storageClass.Provisioner
+		}
 		storageClassMap[storageClass.ObjectMeta.Name] = params
 		if storageClass.GetAnnotations()["storageclass.kubernetes.io/is-default-class"] == "true" || storageClass.GetAnnotations()["storageclass.beta.kubernetes.io/is-default-class"] == "true" {
 			storageClassMap["default"] = params
@@ -1814,6 +1848,12 @@ func (aws *AWS) GetOrphanedResources() ([]models.OrphanedResource, error) {
 				url = "https://console.aws.amazon.com/ec2/home?#Volumes:sort=desc:createTime"
 			}
 
+			// output tags as desc
+			tags := map[string]string{}
+			for _, tag := range volume.Tags {
+				tags[*tag.Key] = *tag.Value
+			}
+
 			or := models.OrphanedResource{
 				Kind:        "disk",
 				Region:      zone,
@@ -1821,6 +1861,7 @@ func (aws *AWS) GetOrphanedResources() ([]models.OrphanedResource, error) {
 				DiskName:    *volume.VolumeId,
 				Url:         url,
 				MonthlyCost: cost,
+				Description: tags,
 			}
 
 			orphanedResources = append(orphanedResources, or)
@@ -1868,7 +1909,7 @@ func (aws *AWS) findCostForDisk(disk *ec2Types.Volume) (*float64, error) {
 
 	class := volTypes[string(disk.VolumeType)]
 
-	key := "us-east-2" + "," + class
+	key := aws.ClusterRegion + "," + class
 
 	pricing, ok := aws.Pricing[key]
 	if !ok {

+ 67 - 0
pkg/cloud/aws/provider_test.go

@@ -9,6 +9,7 @@ import (
 	"testing"
 
 	"github.com/opencost/opencost/pkg/cloud/models"
+	v1 "k8s.io/api/core/v1"
 )
 
 func Test_awsKey_getUsageType(t *testing.T) {
@@ -492,5 +493,71 @@ func Test_populate_pricing(t *testing.T) {
 	if !reflect.DeepEqual(expectedPricing, awsTest.Pricing) {
 		t.Fatalf("expected parsed pricing did not match actual parsed result (cn)")
 	}
+}
+
+func TestFeatures(t *testing.T) {
+	testCases := map[string]struct {
+		aws      awsKey
+		expected string
+	}{
+		"Spot from custom labels": {
+			aws: awsKey{
+				SpotLabelName:  "node-type",
+				SpotLabelValue: "node-spot",
+				Labels: map[string]string{
+					"node-type":                "node-spot",
+					v1.LabelOSStable:           "linux",
+					v1.LabelHostname:           "my-hostname",
+					v1.LabelTopologyRegion:     "us-west-2",
+					v1.LabelTopologyZone:       "us-west-2b",
+					v1.LabelInstanceTypeStable: "m5.large",
+				},
+			},
+			expected: "us-west-2,m5.large,linux,preemptible",
+		},
+	}
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			features := tc.aws.Features()
+			if features != tc.expected {
+				t.Errorf("expected %s, got %s", tc.expected, features)
+			}
+		})
+	}
+}
 
+func Test_getStorageClassTypeFrom(t *testing.T) {
+	tests := []struct {
+		name        string
+		provisioner string
+		want        string
+	}{
+		{
+			name:        "empty-provisioner",
+			provisioner: "",
+			want:        "",
+		},
+		{
+			name:        "ebs-default-provisioner",
+			provisioner: "kubernetes.io/aws-ebs",
+			want:        "gp2",
+		},
+		{
+			name:        "ebs-csi-provisioner",
+			provisioner: "ebs.csi.aws.com",
+			want:        "gp3",
+		},
+		{
+			name:        "unknown-provisioner",
+			provisioner: "unknown",
+			want:        "",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := getStorageClassTypeFrom(tt.provisioner); got != tt.want {
+				t.Errorf("getStorageClassTypeFrom() = %v, want %v", got, tt.want)
+			}
+		})
+	}
 }

+ 12 - 7
pkg/cloud/aws/s3configuration.go

@@ -4,7 +4,8 @@ import (
 	"fmt"
 
 	"github.com/aws/aws-sdk-go-v2/aws"
-	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/kubecost"
 	"github.com/opencost/opencost/pkg/util/json"
 )
 
@@ -42,7 +43,7 @@ func (s3c *S3Configuration) Validate() error {
 	return nil
 }
 
-func (s3c *S3Configuration) Equals(config config.Config) bool {
+func (s3c *S3Configuration) Equals(config cloud.Config) bool {
 	if config == nil {
 		return false
 	}
@@ -76,7 +77,7 @@ func (s3c *S3Configuration) Equals(config config.Config) bool {
 	return true
 }
 
-func (s3c *S3Configuration) Sanitize() config.Config {
+func (s3c *S3Configuration) Sanitize() cloud.Config {
 	return &S3Configuration{
 		Bucket:     s3c.Bucket,
 		Region:     s3c.Region,
@@ -89,6 +90,10 @@ func (s3c *S3Configuration) Key() string {
 	return fmt.Sprintf("%s/%s", s3c.Account, s3c.Bucket)
 }
 
+func (s3c *S3Configuration) Provider() string {
+	return kubecost.AWSProvider
+}
+
 func (s3c *S3Configuration) UnmarshalJSON(b []byte) error {
 	var f interface{}
 	err := json.Unmarshal(b, &f)
@@ -98,19 +103,19 @@ func (s3c *S3Configuration) UnmarshalJSON(b []byte) error {
 
 	fmap := f.(map[string]interface{})
 
-	bucket, err := config.GetInterfaceValue[string](fmap, "bucket")
+	bucket, err := cloud.GetInterfaceValue[string](fmap, "bucket")
 	if err != nil {
 		return fmt.Errorf("S3Configuration: UnmarshalJSON: %s", err.Error())
 	}
 	s3c.Bucket = bucket
 
-	region, err := config.GetInterfaceValue[string](fmap, "region")
+	region, err := cloud.GetInterfaceValue[string](fmap, "region")
 	if err != nil {
 		return fmt.Errorf("S3Configuration: UnmarshalJSON: %s", err.Error())
 	}
 	s3c.Region = region
 
-	account, err := config.GetInterfaceValue[string](fmap, "account")
+	account, err := cloud.GetInterfaceValue[string](fmap, "account")
 	if err != nil {
 		return fmt.Errorf("S3Configuration: UnmarshalJSON: %s", err.Error())
 	}
@@ -120,7 +125,7 @@ func (s3c *S3Configuration) UnmarshalJSON(b []byte) error {
 	if !ok {
 		return fmt.Errorf("S3Configuration: UnmarshalJSON: missing authorizer")
 	}
-	authorizer, err := config.AuthorizerFromInterface(authAny, SelectAuthorizerByType)
+	authorizer, err := cloud.AuthorizerFromInterface(authAny, SelectAuthorizerByType)
 	if err != nil {
 		return fmt.Errorf("S3Configuration: UnmarshalJSON: %s", err.Error())
 	}

+ 5 - 2
pkg/cloud/aws/s3connection.go

@@ -6,7 +6,6 @@ import (
 	"github.com/aws/aws-sdk-go-v2/aws"
 	"github.com/aws/aws-sdk-go-v2/service/s3"
 	"github.com/opencost/opencost/pkg/cloud"
-	"github.com/opencost/opencost/pkg/cloud/config"
 )
 
 type S3Connection struct {
@@ -15,10 +14,14 @@ type S3Connection struct {
 }
 
 func (s3c *S3Connection) GetStatus() cloud.ConnectionStatus {
+	// initialize status if it has not done so; this can happen if the integration is inactive
+	if s3c.ConnectionStatus.String() == "" {
+		s3c.ConnectionStatus = cloud.InitialStatus
+	}
 	return s3c.ConnectionStatus
 }
 
-func (s3c *S3Connection) Equals(config config.Config) bool {
+func (s3c *S3Connection) Equals(config cloud.Config) bool {
 	thatConfig, ok := config.(*S3Connection)
 	if !ok {
 		return false

+ 2 - 2
pkg/cloud/aws/s3connection_test.go

@@ -4,7 +4,7 @@ import (
 	"fmt"
 	"testing"
 
-	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/cloud"
 	"github.com/opencost/opencost/pkg/log"
 	"github.com/opencost/opencost/pkg/util/json"
 )
@@ -105,7 +105,7 @@ func TestS3Configuration_Validate(t *testing.T) {
 func TestS3Configuration_Equals(t *testing.T) {
 	testCases := map[string]struct {
 		left     S3Configuration
-		right    config.Config
+		right    cloud.Config
 		expected bool
 	}{
 		"matching config": {

+ 37 - 45
pkg/cloud/aws/s3selectintegration.go

@@ -4,36 +4,31 @@ import (
 	"encoding/csv"
 	"fmt"
 	"io"
-	"strconv"
 	"strings"
 	"time"
 
 	"github.com/aws/aws-sdk-go-v2/service/s3"
 	"github.com/opencost/opencost/pkg/kubecost"
 	"github.com/opencost/opencost/pkg/log"
-	"github.com/opencost/opencost/pkg/util/timeutil"
 )
 
-const s3SelectDateLayout = "2006-01-02T15:04:05Z"
+const S3SelectDateLayout = "2006-01-02T15:04:05Z"
 
 // S3Object is aliased as "s" in queries
-const s3SelectAccountID = `s."bill/PayerAccountId"`
+const S3SelectAccountID = `s."bill/PayerAccountId"`
 
-const s3SelectItemType = `s."lineItem/LineItemType"`
-const s3SelectStartDate = `s."lineItem/UsageStartDate"`
-const s3SelectProductCode = `s."lineItem/ProductCode"`
-const s3SelectResourceID = `s."lineItem/ResourceId"`
+const S3SelectItemType = `s."lineItem/LineItemType"`
+const S3SelectStartDate = `s."lineItem/UsageStartDate"`
+const S3SelectProductCode = `s."lineItem/ProductCode"`
+const S3SelectResourceID = `s."lineItem/ResourceId"`
+const S3SelectUsageType = `s."lineItem/UsageType"`
 
-const s3SelectIsNode = `SUBSTRING(s."lineItem/ResourceId",1,2) = 'i-'`
-const s3SelectIsVol = `SUBSTRING(s."lineItem/ResourceId", 1, 4) = 'vol-'`
-const s3SelectIsNetwork = `s."lineItem/UsageType" LIKE '%Bytes'`
-
-const s3SelectListCost = `s."lineItem/UnblendedCost"`
-const s3SelectNetCost = `s."lineItem/NetUnblendedCost"`
+const S3SelectListCost = `s."lineItem/UnblendedCost"`
+const S3SelectNetCost = `s."lineItem/NetUnblendedCost"`
 
 // These two may be used for Amortized<Net>Cost
-const s3SelectRICost = `s."reservation/EffectiveCost"`
-const s3SelectSPCost = `s."savingsPlan/SavingsPlanEffectiveCost"`
+const S3SelectRICost = `s."reservation/EffectiveCost"`
+const S3SelectSPCost = `s."savingsPlan/SavingsPlanEffectiveCost"`
 
 type S3SelectIntegration struct {
 	S3SelectQuerier
@@ -62,7 +57,7 @@ func (s3si *S3SelectIntegration) GetCloudCost(
 	ccsr, err := kubecost.NewCloudCostSetRange(
 		start,
 		end,
-		timeutil.Day,
+		kubecost.AccumulateOptionDay,
 		s3si.Key(),
 	)
 	if err != nil {
@@ -93,27 +88,25 @@ func (s3si *S3SelectIntegration) GetCloudCost(
 	formattedStart := start.Format("2006-01-02")
 	formattedEnd := end.Format("2006-01-02")
 	selectColumns := []string{
-		s3SelectStartDate,
-		s3SelectAccountID,
-		s3SelectResourceID,
-		s3SelectItemType,
-		s3SelectProductCode,
-		s3SelectIsNode,
-		s3SelectIsVol,
-		s3SelectIsNetwork,
-		s3SelectListCost,
+		S3SelectStartDate,
+		S3SelectAccountID,
+		S3SelectResourceID,
+		S3SelectItemType,
+		S3SelectProductCode,
+		S3SelectUsageType,
+		S3SelectListCost,
 	}
 	// OC equivalent to KCM env flags relevant at all?
 	// Check for Reservation columns in CUR and query if available
-	checkReservations := allColumns[s3SelectRICost]
+	checkReservations := allColumns[S3SelectRICost]
 	if checkReservations {
-		selectColumns = append(selectColumns, s3SelectRICost)
+		selectColumns = append(selectColumns, S3SelectRICost)
 	}
 
 	// Check for Savings Plan Columns in CUR and query if available
-	checkSavingsPlan := allColumns[s3SelectSPCost]
+	checkSavingsPlan := allColumns[S3SelectSPCost]
 	if checkSavingsPlan {
-		selectColumns = append(selectColumns, s3SelectSPCost)
+		selectColumns = append(selectColumns, S3SelectSPCost)
 	}
 
 	// Build map of query columns to use for parsing query
@@ -149,39 +142,38 @@ func (s3si *S3SelectIntegration) GetCloudCost(
 				return nil
 			}
 
-			startStr := GetCSVRowValue(row, columnIndexes, s3SelectStartDate)
-			itemAccountID := GetCSVRowValue(row, columnIndexes, s3SelectAccountID)
-			itemProviderID := GetCSVRowValue(row, columnIndexes, s3SelectResourceID)
-			lineItemType := GetCSVRowValue(row, columnIndexes, s3SelectItemType)
-			itemProductCode := GetCSVRowValue(row, columnIndexes, s3SelectProductCode)
-			isNode, _ := strconv.ParseBool(GetCSVRowValue(row, columnIndexes, s3SelectIsNode))
-			isVol, _ := strconv.ParseBool(GetCSVRowValue(row, columnIndexes, s3SelectIsVol))
-			isNetwork, _ := strconv.ParseBool(GetCSVRowValue(row, columnIndexes, s3SelectIsNetwork))
+			startStr := GetCSVRowValue(row, columnIndexes, S3SelectStartDate)
+			itemAccountID := GetCSVRowValue(row, columnIndexes, S3SelectAccountID)
+			itemProviderID := GetCSVRowValue(row, columnIndexes, S3SelectResourceID)
+			lineItemType := GetCSVRowValue(row, columnIndexes, S3SelectItemType)
+			itemProductCode := GetCSVRowValue(row, columnIndexes, S3SelectProductCode)
+			usageType := GetCSVRowValue(row, columnIndexes, S3SelectUsageType)
+
 			var (
 				amortizedCost float64
 				listCost      float64
 				netCost       float64
 			)
 			// Get list and net costs
-			listCost, err = GetCSVRowValueFloat(row, columnIndexes, s3SelectListCost)
+			listCost, err = GetCSVRowValueFloat(row, columnIndexes, S3SelectListCost)
 			if err != nil {
 				return err
 			}
-			netCost, err = GetCSVRowValueFloat(row, columnIndexes, s3SelectNetCost)
+			netCost, err = GetCSVRowValueFloat(row, columnIndexes, S3SelectNetCost)
 			if err != nil {
 				return err
 			}
 
 			// If there is a reservation_reservation_a_r_n on the line item use the awsRIPricingSUMColumn as cost
 			if checkReservations && lineItemType == "DiscountedUsage" {
-				amortizedCost, err = GetCSVRowValueFloat(row, columnIndexes, s3SelectRICost)
+				amortizedCost, err = GetCSVRowValueFloat(row, columnIndexes, S3SelectRICost)
 				if err != nil {
 					log.Errorf(err.Error())
 					continue
 				}
 				// If there is a lineItemType of SavingsPlanCoveredUsage use the awsSPPricingSUMColumn
 			} else if checkSavingsPlan && lineItemType == "SavingsPlanCoveredUsage" {
-				amortizedCost, err = GetCSVRowValueFloat(row, columnIndexes, s3SelectSPCost)
+				amortizedCost, err = GetCSVRowValueFloat(row, columnIndexes, S3SelectSPCost)
 				if err != nil {
 					log.Errorf(err.Error())
 					continue
@@ -190,7 +182,7 @@ func (s3si *S3SelectIntegration) GetCloudCost(
 				// Default to listCost
 				amortizedCost = listCost
 			}
-			category := SelectAWSCategory(isNode, isVol, isNetwork, itemProductCode, "")
+			category := SelectAWSCategory(itemProviderID, usageType, itemProductCode)
 			// Retrieve final stanza of product code for ProviderID
 			if itemProductCode == "AWSELB" || itemProductCode == "AmazonFSx" {
 				itemProviderID = ParseARN(itemProviderID)
@@ -203,11 +195,11 @@ func (s3si *S3SelectIntegration) GetCloudCost(
 			properties.Service = itemProductCode
 			properties.ProviderID = itemProviderID
 
-			itemStart, err := time.Parse(s3SelectDateLayout, startStr)
+			itemStart, err := time.Parse(S3SelectDateLayout, startStr)
 			if err != nil {
 				log.Infof(
 					"Unable to parse '%s': '%s'",
-					s3SelectStartDate,
+					S3SelectStartDate,
 					err.Error(),
 				)
 				itemStart = time.Now()

+ 1 - 2
pkg/cloud/aws/s3selectquerier.go

@@ -13,7 +13,6 @@ import (
 	"github.com/aws/aws-sdk-go-v2/service/s3"
 	s3Types "github.com/aws/aws-sdk-go-v2/service/s3/types"
 	"github.com/opencost/opencost/pkg/cloud"
-	"github.com/opencost/opencost/pkg/cloud/config"
 	"github.com/opencost/opencost/pkg/util/stringutil"
 )
 
@@ -22,7 +21,7 @@ type S3SelectQuerier struct {
 	connectionStatus cloud.ConnectionStatus
 }
 
-func (s3sq *S3SelectQuerier) Equals(config config.Config) bool {
+func (s3sq *S3SelectQuerier) Equals(config cloud.Config) bool {
 	thatConfig, ok := config.(*S3SelectQuerier)
 	if !ok {
 		return false

+ 6 - 6
pkg/cloud/azure/authorizer.go

@@ -5,13 +5,13 @@ import (
 	"fmt"
 
 	"github.com/Azure/azure-storage-blob-go/azblob"
-	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/cloud"
 )
 
 const AccessKeyAuthorizerType = "AzureAccessKey"
 
 type Authorizer interface {
-	config.Authorizer
+	cloud.Authorizer
 	GetBlobCredentials() (azblob.Credential, error)
 }
 
@@ -32,7 +32,7 @@ type AccessKey struct {
 
 func (ak *AccessKey) MarshalJSON() ([]byte, error) {
 	fmap := make(map[string]any, 3)
-	fmap[config.AuthorizerTypeProperty] = AccessKeyAuthorizerType
+	fmap[cloud.AuthorizerTypeProperty] = AccessKeyAuthorizerType
 	fmap["accessKey"] = ak.AccessKey
 	fmap["account"] = ak.Account
 	return json.Marshal(fmap)
@@ -48,7 +48,7 @@ func (ak *AccessKey) Validate() error {
 	return nil
 }
 
-func (ak *AccessKey) Equals(config config.Config) bool {
+func (ak *AccessKey) Equals(config cloud.Config) bool {
 	if config == nil {
 		return false
 	}
@@ -67,9 +67,9 @@ func (ak *AccessKey) Equals(config config.Config) bool {
 	return true
 }
 
-func (ak *AccessKey) Sanitize() config.Config {
+func (ak *AccessKey) Sanitize() cloud.Config {
 	return &AccessKey{
-		AccessKey: config.Redacted,
+		AccessKey: cloud.Redacted,
 		Account:   ak.Account,
 	}
 }

+ 6 - 11
pkg/cloud/azure/azurestorageintegration.go

@@ -4,23 +4,21 @@ import (
 	"strings"
 	"time"
 
-	"github.com/opencost/opencost/pkg/cloud"
 	"github.com/opencost/opencost/pkg/kubecost"
 	"github.com/opencost/opencost/pkg/util/timeutil"
 )
 
 type AzureStorageIntegration struct {
 	AzureStorageBillingParser
-	ConnectionStatus cloud.ConnectionStatus
 }
 
 func (asi *AzureStorageIntegration) GetCloudCost(start, end time.Time) (*kubecost.CloudCostSetRange, error) {
-	ccsr, err := kubecost.NewCloudCostSetRange(start, end, timeutil.Day, asi.Key())
+	ccsr, err := kubecost.NewCloudCostSetRange(start, end, kubecost.AccumulateOptionDay, asi.Key())
 	if err != nil {
 		return nil, err
 	}
 
-	status, err := asi.ParseBillingData(start, end, func(abv *BillingRowValues) error {
+	err = asi.ParseBillingData(start, end, func(abv *BillingRowValues) error {
 		s := abv.Date
 		e := abv.Date.Add(timeutil.Day)
 		window := kubecost.NewWindow(&s, &e)
@@ -30,12 +28,13 @@ func (asi *AzureStorageIntegration) GetCloudCost(start, end time.Time) (*kubecos
 			k8sPtc = 1.0
 		}
 
+		providerID, _ := AzureSetProviderID(abv)
 		// Create CloudCost
 		// Using the NetCost as a 'placeholder' for Invoiced and Amortized Net costs now,
 		// until we can revisit and spend the time to do the calculations correctly
 		cc := &kubecost.CloudCost{
 			Properties: &kubecost.CloudCostProperties{
-				ProviderID:      AzureSetProviderID(abv),
+				ProviderID:      providerID,
 				Provider:        kubecost.AzureProvider,
 				AccountID:       abv.SubscriptionID,
 				InvoiceEntityID: abv.InvoiceEntityID,
@@ -68,15 +67,11 @@ func (asi *AzureStorageIntegration) GetCloudCost(start, end time.Time) (*kubecos
 			},
 		}
 
-		// Check if Item
-		if abv.IsCompute(cc.Properties.Category) {
-			// TODO: Will need to split VMSS for other features
-			ccsr.LoadCloudCost(cc)
-		}
+		ccsr.LoadCloudCost(cc)
+
 		return nil
 	})
 	if err != nil {
-		asi.ConnectionStatus = status
 		return nil, err
 	}
 	return ccsr, nil

+ 10 - 8
pkg/cloud/azure/billingexportparser.go

@@ -258,28 +258,30 @@ func encloseInBrackets(jsonString string) string {
 	return fmt.Sprintf("{%s}", jsonString)
 }
 
-func AzureSetProviderID(abv *BillingRowValues) string {
+// isVMSSShared represents a bool that lets you know while setting providerID we were
+// able to get the actual VMName associated with a VM of a group of VMs in VMSS.
+func AzureSetProviderID(abv *BillingRowValues) (providerID string, isVMSSShared bool) {
 	category := SelectAzureCategory(abv.MeterCategory)
 	if value, ok := abv.AdditionalInfo["VMName"]; ok {
-		return "azure://" + resourceGroupToLowerCase(abv.InstanceID) + getVMNumberForVMSS(fmt.Sprintf("%v", value))
+		return "azure://" + resourceGroupToLowerCase(abv.InstanceID) + getVMNumberForVMSS(fmt.Sprintf("%v", value)), false
 	} else if value, ok := abv.AdditionalInfo["VmName"]; ok {
-		return "azure://" + resourceGroupToLowerCase(abv.InstanceID) + getVMNumberForVMSS(fmt.Sprintf("%v", value))
+		return "azure://" + resourceGroupToLowerCase(abv.InstanceID) + getVMNumberForVMSS(fmt.Sprintf("%v", value)), false
 	} else if value2, ook := abv.AdditionalInfo["IpAddress"]; ook && abv.MeterCategory == "Virtual Network" {
-		return fmt.Sprintf("%v", value2)
+		return fmt.Sprintf("%v", value2), false
 	}
 
 	if category == kubecost.StorageCategory || (category == kubecost.NetworkCategory && abv.MeterCategory == "Bandwidth") {
 		if value2, ok2 := abv.Tags["creationSource"]; ok2 {
 			creationSource := fmt.Sprintf("%v", value2)
-			return strings.TrimPrefix(creationSource, "aks-")
+			return strings.TrimPrefix(creationSource, "aks-"), true
 		} else if value2, ok2 := abv.Tags["aks-managed-creationSource"]; ok2 {
 			creationSource := fmt.Sprintf("%v", value2)
-			return strings.TrimPrefix(creationSource, "vmssclient-")
+			return strings.TrimPrefix(creationSource, "vmssclient-"), true
 		} else {
-			return getSubStringAfterFinalSlash(abv.InstanceID)
+			return getSubStringAfterFinalSlash(abv.InstanceID), true
 		}
 	}
-	return "azure://" + resourceGroupToLowerCase(abv.InstanceID)
+	return "azure://" + resourceGroupToLowerCase(abv.InstanceID), true
 }
 
 func SelectAzureCategory(meterCategory string) string {

+ 22 - 6
pkg/cloud/azure/provider.go

@@ -1097,7 +1097,9 @@ func (az *Azure) NodePricing(key models.Key) (*models.Node, models.PricingMetada
 	config, _ := az.GetConfig()
 
 	// Spot Node
-	if slv, ok := azKey.Labels[config.SpotLabel]; ok && slv == config.SpotLabelValue && config.SpotLabel != "" && config.SpotLabelValue != "" {
+	slv, ok := azKey.Labels[config.SpotLabel]
+	isSpot := ok && slv == config.SpotLabelValue && config.SpotLabel != "" && config.SpotLabelValue != ""
+	if isSpot {
 		features := strings.Split(azKey.Features(), ",")
 		region := features[0]
 		instance := features[1]
@@ -1147,13 +1149,27 @@ func (az *Azure) NodePricing(key models.Key) (*models.Node, models.PricingMetada
 		return nil, meta, fmt.Errorf("No default pricing data available")
 	}
 
+	var vcpuCost string
+	var ramCost string
+	var gpuCost string
+
+	if isSpot {
+		vcpuCost = c.SpotCPU
+		ramCost = c.SpotRAM
+		gpuCost = c.SpotGPU
+	} else {
+		vcpuCost = c.CPU
+		ramCost = c.RAM
+		gpuCost = c.GPU
+	}
+
 	// GPU Node
 	if azKey.isValidGPUNode() {
 		return &models.Node{
-			VCPUCost:         c.CPU,
-			RAMCost:          c.RAM,
+			VCPUCost:         vcpuCost,
+			RAMCost:          ramCost,
 			UsesBaseCPUPrice: true,
-			GPUCost:          c.GPU,
+			GPUCost:          gpuCost,
 			GPU:              azKey.GetGPUCount(),
 		}, meta, nil
 	}
@@ -1170,8 +1186,8 @@ func (az *Azure) NodePricing(key models.Key) (*models.Node, models.PricingMetada
 
 	// Regular Node
 	return &models.Node{
-		VCPUCost:         c.CPU,
-		RAMCost:          c.RAM,
+		VCPUCost:         vcpuCost,
+		RAMCost:          ramCost,
 		UsesBaseCPUPrice: true,
 	}, meta, nil
 }

+ 20 - 9
pkg/cloud/azure/storagebillingparser.go

@@ -11,7 +11,6 @@ import (
 
 	"github.com/Azure/azure-storage-blob-go/azblob"
 	"github.com/opencost/opencost/pkg/cloud"
-	cloudconfig "github.com/opencost/opencost/pkg/cloud/config"
 	"github.com/opencost/opencost/pkg/log"
 )
 
@@ -20,7 +19,7 @@ type AzureStorageBillingParser struct {
 	StorageConnection
 }
 
-func (asbp *AzureStorageBillingParser) Equals(config cloudconfig.Config) bool {
+func (asbp *AzureStorageBillingParser) Equals(config cloud.Config) bool {
 	thatConfig, ok := config.(*AzureStorageBillingParser)
 	if !ok {
 		return false
@@ -30,33 +29,45 @@ func (asbp *AzureStorageBillingParser) Equals(config cloudconfig.Config) bool {
 
 type AzureBillingResultFunc func(*BillingRowValues) error
 
-func (asbp *AzureStorageBillingParser) ParseBillingData(start, end time.Time, resultFn AzureBillingResultFunc) (cloud.ConnectionStatus, error) {
+func (asbp *AzureStorageBillingParser) ParseBillingData(start, end time.Time, resultFn AzureBillingResultFunc) error {
 	err := asbp.Validate()
 	if err != nil {
-		return cloud.InvalidConfiguration, err
+		asbp.ConnectionStatus = cloud.InvalidConfiguration
+		return err
 	}
 
 	containerURL, err := asbp.getContainer()
 	if err != nil {
-		return cloud.FailedConnection, err
+		asbp.ConnectionStatus = cloud.FailedConnection
+		return err
 	}
 	ctx := context.Background()
 	blobNames, err := asbp.getMostRecentBlobs(start, end, containerURL, ctx)
 	if err != nil {
-		return cloud.FailedConnection, err
+		asbp.ConnectionStatus = cloud.FailedConnection
+		return err
 	}
+
+	if len(blobNames) == 0 && asbp.ConnectionStatus != cloud.SuccessfulConnection {
+		asbp.ConnectionStatus = cloud.MissingData
+		return nil
+	}
+
 	for _, blobName := range blobNames {
 		blobBytes, err2 := asbp.DownloadBlob(blobName, containerURL, ctx)
 		if err2 != nil {
-			return cloud.FailedConnection, err2
+			asbp.ConnectionStatus = cloud.FailedConnection
+			return err2
 		}
 		err2 = asbp.parseCSV(start, end, csv.NewReader(bytes.NewReader(blobBytes)), resultFn)
 		if err2 != nil {
-			return cloud.ParseError, err2
+			asbp.ConnectionStatus = cloud.ParseError
+			return err2
 		}
 
 	}
-	return cloud.SuccessfulConnection, nil
+	asbp.ConnectionStatus = cloud.SuccessfulConnection
+	return nil
 }
 
 func (asbp *AzureStorageBillingParser) parseCSV(start, end time.Time, reader *csv.Reader, resultFn AzureBillingResultFunc) error {

+ 16 - 11
pkg/cloud/azure/storageconfiguration.go

@@ -3,7 +3,8 @@ package azure
 import (
 	"fmt"
 
-	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/kubecost"
 	"github.com/opencost/opencost/pkg/util/json"
 )
 
@@ -43,7 +44,7 @@ func (sc *StorageConfiguration) Validate() error {
 	return nil
 }
 
-func (sc *StorageConfiguration) Equals(config config.Config) bool {
+func (sc *StorageConfiguration) Equals(config cloud.Config) bool {
 	if config == nil {
 		return false
 	}
@@ -85,7 +86,7 @@ func (sc *StorageConfiguration) Equals(config config.Config) bool {
 	return true
 }
 
-func (sc *StorageConfiguration) Sanitize() config.Config {
+func (sc *StorageConfiguration) Sanitize() cloud.Config {
 	return &StorageConfiguration{
 		SubscriptionID: sc.SubscriptionID,
 		Account:        sc.Account,
@@ -105,6 +106,10 @@ func (sc *StorageConfiguration) Key() string {
 	return key
 }
 
+func (sc *StorageConfiguration) Provider() string {
+	return kubecost.AzureProvider
+}
+
 func (sc *StorageConfiguration) UnmarshalJSON(b []byte) error {
 	var f interface{}
 	err := json.Unmarshal(b, &f)
@@ -114,41 +119,41 @@ func (sc *StorageConfiguration) UnmarshalJSON(b []byte) error {
 
 	fmap := f.(map[string]interface{})
 
-	subscriptionID, err := config.GetInterfaceValue[string](fmap, "subscriptionID")
+	subscriptionID, err := cloud.GetInterfaceValue[string](fmap, "subscriptionID")
 	if err != nil {
 		return fmt.Errorf("StorageConfiguration: UnmarshalJSON: %s", err.Error())
 	}
 	sc.SubscriptionID = subscriptionID
 
-	account, err := config.GetInterfaceValue[string](fmap, "account")
+	account, err := cloud.GetInterfaceValue[string](fmap, "account")
 	if err != nil {
 		return fmt.Errorf("StorageConfiguration: UnmarshalJSON: %s", err.Error())
 	}
 	sc.Account = account
 
-	container, err := config.GetInterfaceValue[string](fmap, "container")
+	container, err := cloud.GetInterfaceValue[string](fmap, "container")
 	if err != nil {
 		return fmt.Errorf("StorageConfiguration: UnmarshalJSON: %s", err.Error())
 	}
 	sc.Container = container
 
-	path, err := config.GetInterfaceValue[string](fmap, "path")
+	path, err := cloud.GetInterfaceValue[string](fmap, "path")
 	if err != nil {
 		return fmt.Errorf("StorageConfiguration: UnmarshalJSON: %s", err.Error())
 	}
 	sc.Path = path
 
-	cloud, err := config.GetInterfaceValue[string](fmap, "cloud")
+	cloudValue, err := cloud.GetInterfaceValue[string](fmap, "cloud")
 	if err != nil {
 		return fmt.Errorf("StorageConfiguration: UnmarshalJSON: %s", err.Error())
 	}
-	sc.Cloud = cloud
+	sc.Cloud = cloudValue
 
 	authAny, ok := fmap["authorizer"]
 	if !ok {
 		return fmt.Errorf("StorageConfiguration: UnmarshalJSON: missing authorizer")
 	}
-	authorizer, err := config.AuthorizerFromInterface(authAny, SelectAuthorizerByType)
+	authorizer, err := cloud.AuthorizerFromInterface(authAny, SelectAuthorizerByType)
 	if err != nil {
 		return fmt.Errorf("StorageConfiguration: UnmarshalJSON: %s", err.Error())
 	}
@@ -157,7 +162,7 @@ func (sc *StorageConfiguration) UnmarshalJSON(b []byte) error {
 	return nil
 }
 
-func ConvertAzureStorageConfigToConfig(asc AzureStorageConfig) config.KeyedConfig {
+func ConvertAzureStorageConfigToConfig(asc AzureStorageConfig) cloud.KeyedConfig {
 	if asc.IsEmpty() {
 		return nil
 	}

+ 2 - 2
pkg/cloud/azure/storageconfiguration_test.go

@@ -4,7 +4,7 @@ import (
 	"fmt"
 	"testing"
 
-	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/cloud"
 	"github.com/opencost/opencost/pkg/log"
 	"github.com/opencost/opencost/pkg/util/json"
 )
@@ -145,7 +145,7 @@ func TestStorageConfiguration_Validate(t *testing.T) {
 func TestStorageConfiguration_Equals(t *testing.T) {
 	testCases := map[string]struct {
 		left     StorageConfiguration
-		right    config.Config
+		right    cloud.Config
 		expected bool
 	}{
 		"matching config": {

+ 5 - 2
pkg/cloud/azure/storageconnection.go

@@ -9,7 +9,6 @@ import (
 
 	"github.com/Azure/azure-storage-blob-go/azblob"
 	"github.com/opencost/opencost/pkg/cloud"
-	cloudconfig "github.com/opencost/opencost/pkg/cloud/config"
 	"github.com/opencost/opencost/pkg/log"
 )
 
@@ -20,10 +19,14 @@ type StorageConnection struct {
 }
 
 func (sc *StorageConnection) GetStatus() cloud.ConnectionStatus {
+	// initialize status if it has not done so; this can happen if the integration is inactive
+	if sc.ConnectionStatus.String() == "" {
+		sc.ConnectionStatus = cloud.InitialStatus
+	}
 	return sc.ConnectionStatus
 }
 
-func (sc *StorageConnection) Equals(config cloudconfig.Config) bool {
+func (sc *StorageConnection) Equals(config cloud.Config) bool {
 	thatConfig, ok := config.(*StorageConnection)
 	if !ok {
 		return false

+ 0 - 12
pkg/cloud/cloudcostintegration.go

@@ -1,12 +0,0 @@
-package cloud
-
-import (
-	"time"
-
-	"github.com/opencost/opencost/pkg/kubecost"
-)
-
-// CloudCostIntegration is an interface for retrieving daily granularity CloudCost data for a given range
-type CloudCostIntegration interface {
-	GetCloudCost(time.Time, time.Time) (*kubecost.CloudCostSetRange, error)
-}

+ 2 - 1
pkg/cloud/config/config.go → pkg/cloud/config.go

@@ -1,4 +1,4 @@
-package config
+package cloud
 
 import (
 	"fmt"
@@ -17,6 +17,7 @@ type Config interface {
 type KeyedConfig interface {
 	Config
 	Key() string
+	Provider() string
 }
 
 type KeyedConfigWatcher interface {

+ 291 - 0
pkg/cloud/config/configurations.go

@@ -0,0 +1,291 @@
+package config
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/cloud/alibaba"
+	"github.com/opencost/opencost/pkg/cloud/aws"
+	"github.com/opencost/opencost/pkg/cloud/azure"
+	"github.com/opencost/opencost/pkg/cloud/gcp"
+	"github.com/opencost/opencost/pkg/log"
+)
+
+// MultiCloudConfig struct is used to unmarshal cloud configs for each provider out of cloud-integration file
+// Deprecated: v1.104 use Configurations
+type MultiCloudConfig struct {
+	AzureConfigs   []azure.AzureStorageConfig `json:"azure"`
+	GCPConfigs     []gcp.BigQueryConfig       `json:"gcp"`
+	AWSConfigs     []aws.AwsAthenaInfo        `json:"aws"`
+	AlibabaConfigs []alibaba.AlibabaInfo      `json:"alibaba"`
+}
+
+func (mcc MultiCloudConfig) loadConfigurations(configs *Configurations) {
+	// Load AWS configs
+	for _, awsConfig := range mcc.AWSConfigs {
+		kc := aws.ConvertAwsAthenaInfoToConfig(awsConfig)
+		err := configs.Insert(kc)
+		if err != nil {
+			log.Errorf("MultiCloudConfig: error converting AWS config %s", err.Error())
+		}
+
+	}
+
+	// Load GCP configs
+	for _, gcpConfig := range mcc.GCPConfigs {
+		kc := gcp.ConvertBigQueryConfigToConfig(gcpConfig)
+		err := configs.Insert(kc)
+		if err != nil {
+			log.Errorf("MultiCloudConfig: error converting GCP config %s", err.Error())
+		}
+	}
+
+	// Load Azure configs
+	for _, azureConfig := range mcc.AzureConfigs {
+		kc := azure.ConvertAzureStorageConfigToConfig(azureConfig)
+		err := configs.Insert(kc)
+		if err != nil {
+			log.Errorf("MultiCloudConfig: error converting Azure config %s", err.Error())
+		}
+	}
+
+	// Load Alibaba Cloud Configs
+	for _, aliCloudConfig := range mcc.AlibabaConfigs {
+		kc := alibaba.ConvertAlibabaInfoToConfig(aliCloudConfig)
+		err := configs.Insert(kc)
+		if err != nil {
+			log.Errorf("MultiCloudConfig: error converting Alibaba config %s", err.Error())
+		}
+	}
+}
+
+// Configurations is a general use container for all configuration types
+type Configurations struct {
+	AWS     *AWSConfigs     `json:"aws,omitempty"`
+	GCP     *GCPConfigs     `json:"gcp,omitempty"`
+	Azure   *AzureConfigs   `json:"azure,omitempty"`
+	Alibaba *AlibabaConfigs `json:"alibaba,omitempty"`
+}
+
+// UnmarshalJSON custom json unmarshalling to maintain support for MultiCloudConfig format
+func (c *Configurations) UnmarshalJSON(bytes []byte) error {
+	// Attempt to unmarshal into old config object
+	multiConfig := &MultiCloudConfig{}
+	err := json.Unmarshal(bytes, multiConfig)
+	// If unmarshal is successful, move values into config and return
+	if err == nil {
+		multiConfig.loadConfigurations(c)
+		return nil
+	}
+	// Create inline type to gain access to default Unmarshalling
+	type ConfUnmarshaller *Configurations
+	var conf ConfUnmarshaller = c
+	return json.Unmarshal(bytes, conf)
+}
+
+func (c *Configurations) Equals(that *Configurations) bool {
+	if c == nil && that == nil {
+		return true
+	}
+	if c == nil || that == nil {
+		return false
+	}
+
+	if !c.AWS.Equals(that.AWS) {
+		return false
+	}
+
+	if !c.GCP.Equals(that.GCP) {
+		return false
+	}
+
+	if !c.Azure.Equals(that.Azure) {
+		return false
+	}
+
+	if !c.Alibaba.Equals(that.Alibaba) {
+		return false
+	}
+
+	return true
+}
+
+func (c *Configurations) Insert(keyedConfig cloud.Config) error {
+	switch keyedConfig.(type) {
+	case *aws.AthenaConfiguration:
+		if c.AWS == nil {
+			c.AWS = &AWSConfigs{}
+		}
+		c.AWS.Athena = append(c.AWS.Athena, keyedConfig.(*aws.AthenaConfiguration))
+	case *aws.S3Configuration:
+		if c.AWS == nil {
+			c.AWS = &AWSConfigs{}
+		}
+		c.AWS.S3 = append(c.AWS.S3, keyedConfig.(*aws.S3Configuration))
+	case *gcp.BigQueryConfiguration:
+		if c.GCP == nil {
+			c.GCP = &GCPConfigs{}
+		}
+		c.GCP.BigQuery = append(c.GCP.BigQuery, keyedConfig.(*gcp.BigQueryConfiguration))
+	case *azure.StorageConfiguration:
+		if c.Azure == nil {
+			c.Azure = &AzureConfigs{}
+		}
+		c.Azure.Storage = append(c.Azure.Storage, keyedConfig.(*azure.StorageConfiguration))
+	case *alibaba.BOAConfiguration:
+		if c.Alibaba == nil {
+			c.Alibaba = &AlibabaConfigs{}
+		}
+		c.Alibaba.BOA = append(c.Alibaba.BOA, keyedConfig.(*alibaba.BOAConfiguration))
+	default:
+		return fmt.Errorf("Configurations: Insert: failed to insert config of type: %T", keyedConfig)
+	}
+	return nil
+}
+
+func (c *Configurations) ToSlice() []cloud.KeyedConfig {
+	var keyedConfigs []cloud.KeyedConfig
+	if c.AWS != nil {
+		for _, athenaConfig := range c.AWS.Athena {
+			keyedConfigs = append(keyedConfigs, athenaConfig)
+		}
+
+		for _, s3Config := range c.AWS.S3 {
+			keyedConfigs = append(keyedConfigs, s3Config)
+		}
+	}
+
+	if c.GCP != nil {
+		for _, bigQueryConfig := range c.GCP.BigQuery {
+			keyedConfigs = append(keyedConfigs, bigQueryConfig)
+		}
+	}
+
+	if c.Azure != nil {
+		for _, azureStorageConfig := range c.Azure.Storage {
+			keyedConfigs = append(keyedConfigs, azureStorageConfig)
+		}
+	}
+
+	if c.Alibaba != nil {
+		for _, boaConfig := range c.Alibaba.BOA {
+			keyedConfigs = append(keyedConfigs, boaConfig)
+		}
+	}
+
+	return keyedConfigs
+
+}
+
+type AWSConfigs struct {
+	Athena []*aws.AthenaConfiguration `json:"athena,omitempty"`
+	S3     []*aws.S3Configuration     `json:"s3,omitempty"`
+}
+
+func (ac *AWSConfigs) Equals(that *AWSConfigs) bool {
+	if ac == nil && that == nil {
+		return true
+	}
+	if ac == nil || that == nil {
+		return false
+	}
+	// Check Athena
+	if len(ac.Athena) != len(that.Athena) {
+		return false
+	}
+	for i, thisAthena := range ac.Athena {
+		thatAthena := that.Athena[i]
+		if !thisAthena.Equals(thatAthena) {
+			return false
+		}
+	}
+
+	// Check S3
+	if len(ac.S3) != len(that.S3) {
+		return false
+	}
+	for i, thisS3 := range ac.S3 {
+		thatS3 := that.S3[i]
+		if !thisS3.Equals(thatS3) {
+			return false
+		}
+	}
+
+	return true
+}
+
+type GCPConfigs struct {
+	BigQuery []*gcp.BigQueryConfiguration `json:"bigQuery,omitempty"`
+}
+
+func (gc *GCPConfigs) Equals(that *GCPConfigs) bool {
+	if gc == nil && that == nil {
+		return true
+	}
+	if gc == nil || that == nil {
+		return false
+	}
+	// Check BigQuery
+	if len(gc.BigQuery) != len(that.BigQuery) {
+		return false
+	}
+	for i, thisBigQuery := range gc.BigQuery {
+		thatBigQuery := that.BigQuery[i]
+		if !thisBigQuery.Equals(thatBigQuery) {
+			return false
+		}
+	}
+
+	return true
+}
+
+type AzureConfigs struct {
+	Storage []*azure.StorageConfiguration `json:"storage,omitempty"`
+}
+
+func (ac *AzureConfigs) Equals(that *AzureConfigs) bool {
+	if ac == nil && that == nil {
+		return true
+	}
+	if ac == nil || that == nil {
+		return false
+	}
+	// Check Storage
+	if len(ac.Storage) != len(that.Storage) {
+		return false
+	}
+	for i, thisStorage := range ac.Storage {
+		thatStorage := that.Storage[i]
+		if !thisStorage.Equals(thatStorage) {
+			return false
+		}
+	}
+
+	return true
+}
+
+type AlibabaConfigs struct {
+	BOA []*alibaba.BOAConfiguration `json:"boa,omitempty"`
+}
+
+func (ac *AlibabaConfigs) Equals(that *AlibabaConfigs) bool {
+	if ac == nil && that == nil {
+		return true
+	}
+	if ac == nil || that == nil {
+		return false
+	}
+	// Check BOA
+	if len(ac.BOA) != len(that.BOA) {
+		return false
+	}
+	for i, thisBOA := range ac.BOA {
+		thatBOA := that.BOA[i]
+		if !thisBOA.Equals(thatBOA) {
+			return false
+		}
+	}
+
+	return true
+}

+ 290 - 0
pkg/cloud/config/configurations_test.go

@@ -0,0 +1,290 @@
+package config
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/opencost/opencost/pkg/cloud/aws"
+	"github.com/opencost/opencost/pkg/cloud/azure"
+	"github.com/opencost/opencost/pkg/cloud/gcp"
+)
+
+var (
+	azureMultiCloudConf = MultiCloudConfig{
+		AzureConfigs: []azure.AzureStorageConfig{
+			{
+				SubscriptionId: "subscriptionID",
+				AccountName:    "accountName",
+				AccessKey:      "accessKey",
+				ContainerName:  "containerName",
+				ContainerPath:  "containerPath",
+				AzureCloud:     "azureCloud",
+			},
+		},
+	}
+	azureConfiguration = &Configurations{
+		Azure: &AzureConfigs{
+			Storage: []*azure.StorageConfiguration{
+				{
+					SubscriptionID: "subscriptionID",
+					Account:        "accountName",
+					Container:      "containerName",
+					Path:           "containerPath",
+					Cloud:          "azureCloud",
+					Authorizer: &azure.AccessKey{
+						AccessKey: "accessKey",
+						Account:   "accountName",
+					},
+				},
+			},
+		},
+	}
+
+	GCPKeyMultiCloudConf = MultiCloudConfig{
+		GCPConfigs: []gcp.BigQueryConfig{
+			{
+				ProjectID:          "projectID",
+				BillingDataDataset: "dataset.table",
+				Key: map[string]string{
+					"key": "value",
+				},
+			},
+		},
+	}
+
+	GCPKeyConfigurations = Configurations{
+		GCP: &GCPConfigs{BigQuery: []*gcp.BigQueryConfiguration{{
+			ProjectID: "projectID",
+			Dataset:   "dataset",
+			Table:     "table",
+			Authorizer: &gcp.ServiceAccountKey{
+				Key: map[string]string{
+					"key": "value",
+				},
+			},
+		},
+		}},
+	}
+
+	GCPWIMultiCloudConf = MultiCloudConfig{
+		GCPConfigs: []gcp.BigQueryConfig{
+			{
+				ProjectID:          "projectID",
+				BillingDataDataset: "dataset.table",
+				Key:                nil,
+			},
+		},
+	}
+
+	GCPWIConfigurations = Configurations{
+		GCP: &GCPConfigs{BigQuery: []*gcp.BigQueryConfiguration{{
+			ProjectID:  "projectID",
+			Dataset:    "dataset",
+			Table:      "table",
+			Authorizer: &gcp.WorkloadIdentity{},
+		},
+		}},
+	}
+
+	AWSAthenaKeyMultiCloudConfig = MultiCloudConfig{
+		AWSConfigs: []aws.AwsAthenaInfo{
+			{
+				AthenaBucketName: "bucket",
+				AthenaRegion:     "region",
+				AthenaDatabase:   "database",
+				AthenaTable:      "table",
+				AthenaWorkgroup:  "workgroup",
+				ServiceKeyName:   "id",
+				ServiceKeySecret: "secret",
+				AccountID:        "account",
+				MasterPayerARN:   "",
+			},
+		},
+	}
+
+	AWSAthenaKeyConfigurations = &Configurations{
+		AWS: &AWSConfigs{
+			Athena: []*aws.AthenaConfiguration{
+				{
+					Bucket:    "bucket",
+					Region:    "region",
+					Database:  "database",
+					Table:     "table",
+					Workgroup: "workgroup",
+					Account:   "account",
+					Authorizer: &aws.AccessKey{
+						ID:     "id",
+						Secret: "secret",
+					},
+				},
+			},
+		},
+	}
+
+	AWSAthenaAssumeRoleServiceAccountMultiCloudConfig = MultiCloudConfig{
+		AWSConfigs: []aws.AwsAthenaInfo{
+			{
+				AthenaBucketName: "bucket",
+				AthenaRegion:     "region",
+				AthenaDatabase:   "database",
+				AthenaTable:      "table",
+				AthenaWorkgroup:  "workgroup",
+				AccountID:        "account",
+				MasterPayerARN:   "roleArn",
+			},
+		},
+	}
+
+	AWSAthenaAssumeRoleServiceAccountConfigurations = &Configurations{
+		AWS: &AWSConfigs{
+			Athena: []*aws.AthenaConfiguration{
+				{
+					Bucket:    "bucket",
+					Region:    "region",
+					Database:  "database",
+					Table:     "table",
+					Workgroup: "workgroup",
+					Account:   "account",
+					Authorizer: &aws.AssumeRole{
+						Authorizer: &aws.ServiceAccount{},
+						RoleARN:    "roleArn",
+					},
+				},
+			},
+		},
+	}
+	AWSS3ServiceAccountMultiCloudConfig = MultiCloudConfig{
+		AWSConfigs: []aws.AwsAthenaInfo{
+			{
+				AthenaBucketName: "bucket",
+				AthenaRegion:     "region",
+				AccountID:        "account",
+				MasterPayerARN:   "",
+			},
+		},
+	}
+
+	AWSS3ServiceAccountConfigurations = &Configurations{
+		AWS: &AWSConfigs{
+			S3: []*aws.S3Configuration{
+				{
+					Bucket:     "bucket",
+					Region:     "region",
+					Account:    "account",
+					Authorizer: &aws.ServiceAccount{},
+				},
+			},
+		},
+	}
+
+	AWSS3AssumeRoleAccessKeyMultiCloudConfig = MultiCloudConfig{
+		AWSConfigs: []aws.AwsAthenaInfo{
+			{
+				AthenaBucketName: "bucket",
+				AthenaRegion:     "region",
+				AccountID:        "account",
+				ServiceKeyName:   "id",
+				ServiceKeySecret: "secret",
+				MasterPayerARN:   "roleARN",
+			},
+		},
+	}
+	AWSS3AssumeRoleAccessKeyConfigurations = &Configurations{
+		AWS: &AWSConfigs{
+			S3: []*aws.S3Configuration{
+				{
+					Bucket:  "bucket",
+					Region:  "region",
+					Account: "account",
+					Authorizer: &aws.AssumeRole{
+						Authorizer: &aws.AccessKey{
+							ID:     "id",
+							Secret: "secret",
+						},
+						RoleARN: "roleARN",
+					},
+				},
+			},
+		},
+	}
+)
+
+func TestConfigurations_UnmarshalJSON(t *testing.T) {
+	tests := map[string]struct {
+		input    any
+		expected *Configurations
+	}{
+		"Azure Storage AccessKey": {
+			input:    azureConfiguration,
+			expected: azureConfiguration,
+		},
+		"Azure Storage AccessKey Conversion": {
+			input:    azureMultiCloudConf,
+			expected: azureConfiguration,
+		},
+		"GCP BigQuery ServiceAccountKey": {
+			input:    GCPKeyConfigurations,
+			expected: &GCPKeyConfigurations,
+		},
+		"GCP BigQuery ServiceAccountKey Conversion": {
+			input:    GCPKeyMultiCloudConf,
+			expected: &GCPKeyConfigurations,
+		},
+		"GCP BigQuery Workload Identity ": {
+			input:    &GCPWIConfigurations,
+			expected: &GCPWIConfigurations,
+		},
+		"GCP BigQuery Workload Identity Conversion": {
+			input:    GCPWIMultiCloudConf,
+			expected: &GCPWIConfigurations,
+		},
+		"AWS Athena Access Key": {
+			input:    AWSAthenaKeyConfigurations,
+			expected: AWSAthenaKeyConfigurations,
+		},
+		"AWS Athena Access Key Conversion": {
+			input:    AWSAthenaKeyMultiCloudConfig,
+			expected: AWSAthenaKeyConfigurations,
+		},
+		"AWS Athena Assume Role Service Account": {
+			input:    AWSAthenaAssumeRoleServiceAccountConfigurations,
+			expected: AWSAthenaAssumeRoleServiceAccountConfigurations,
+		},
+		"AWS Athena Assume Role Service Account Conversion": {
+			input:    AWSAthenaAssumeRoleServiceAccountMultiCloudConfig,
+			expected: AWSAthenaAssumeRoleServiceAccountConfigurations,
+		},
+		"AWS S3 Service Account": {
+			input:    AWSS3ServiceAccountConfigurations,
+			expected: AWSS3ServiceAccountConfigurations,
+		},
+		"AWS S3 Service Account Conversion": {
+			input:    AWSS3ServiceAccountMultiCloudConfig,
+			expected: AWSS3ServiceAccountConfigurations,
+		},
+		"AWS S3 Assume Role Access Key": {
+			input:    AWSS3AssumeRoleAccessKeyConfigurations,
+			expected: AWSS3AssumeRoleAccessKeyConfigurations,
+		},
+		"AWS S3 Assume Role Service Access Key": {
+			input:    AWSS3AssumeRoleAccessKeyMultiCloudConfig,
+			expected: AWSS3AssumeRoleAccessKeyConfigurations,
+		},
+	}
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			b, err := json.Marshal(tt.input)
+			if err != nil {
+				t.Fatalf("failed to marshal input")
+			}
+			actual := &Configurations{}
+			err = json.Unmarshal(b, actual)
+			if err != nil && tt.expected != nil {
+				t.Fatalf("Unmarshal failed with error %s", err.Error())
+			}
+			if !tt.expected.Equals(actual) {
+				t.Fatalf("actual Configuration did not match expected")
+			}
+		})
+	}
+}

+ 305 - 0
pkg/cloud/config/controller.go

@@ -0,0 +1,305 @@
+package config
+
+import (
+	"fmt"
+	"sync"
+	"time"
+
+	"github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/cloud/models"
+	"github.com/opencost/opencost/pkg/cloud/provider"
+	"github.com/opencost/opencost/pkg/util/timeutil"
+)
+
+// configID identifies the source and the ID of a configuration to handle duplicate configs from multiple sources
+type configID struct {
+	source ConfigSource
+	key    string
+}
+
+func (cid configID) Equals(that configID) bool {
+	return cid.source == that.source && cid.key == that.key
+}
+
+func newConfigID(source, key string) configID {
+	return configID{
+		source: GetConfigSource(source),
+		key:    key,
+	}
+}
+
+type Status struct {
+	Source ConfigSource
+	Key    string
+	Active bool
+	Valid  bool
+	Config cloud.KeyedConfig
+}
+
+// Controller manages the cloud.Config using config Watcher(s) to track various configuration
+// methods. To do this it has a map of config watchers mapped on configuration source and a list Observers that it updates
+// upon any change detected from the config watchers.
+type Controller struct {
+	statuses  map[configID]*Status
+	observers []Observer
+	watchers  map[ConfigSource]cloud.KeyedConfigWatcher
+}
+
+// NewController initializes an Config Controller
+func NewController(cp models.Provider) *Controller {
+	providerConfig := provider.ExtractConfigFromProviders(cp)
+	watchers := GetCloudBillingWatchers(providerConfig)
+	ic := &Controller{
+		statuses: make(map[configID]*Status),
+		watchers: watchers,
+	}
+
+	ic.load()
+	ic.pullWatchers()
+
+	go func() {
+		ticker := timeutil.NewJobTicker()
+		defer ticker.Close()
+
+		for {
+			ticker.TickIn(10 * time.Second)
+
+			<-ticker.Ch
+
+			ic.pullWatchers()
+		}
+	}()
+
+	return ic
+}
+
+func (c *Controller) EnableConfig(key, source string) error {
+	cID := newConfigID(source, key)
+	cs, ok := c.statuses[cID]
+	if !ok {
+		return fmt.Errorf("Controller: EnableConfig: config with key %s from source %s does not exist", key, source)
+	}
+	if cs.Active {
+		return fmt.Errorf("Controller: EnableConfig: config with key %s from source %s is already active", key, source)
+	}
+
+	// check for configurations with the same configuration key that are already active.
+	for confID, confStat := range c.statuses {
+		if confID.key != key || confID.source == cID.source {
+			continue
+		}
+
+		// if active disable
+		if confStat.Active == true {
+			confStat.Active = false
+		}
+	}
+
+	cs.Active = true
+	c.putConfig(cs.Config)
+	c.save()
+	return nil
+}
+
+// DisableConfig updates an config status if it was enabled
+func (c *Controller) DisableConfig(key, source string) error {
+	iID := newConfigID(source, key)
+	is, ok := c.statuses[iID]
+	if !ok {
+		return fmt.Errorf("Controller: DisableConfig: config with key %s from source %s does not exist", key, source)
+	}
+	if !is.Active {
+		return fmt.Errorf("Controller: DisableConfig: config with key %s from source %s is already disabled", key, source)
+	}
+
+	is.Active = false
+	c.deleteConfig(iID.key)
+	c.save()
+	return nil
+}
+
+// DeleteConfig removes an config from the statuses and deletes the config on all observers if it was active
+func (c *Controller) DeleteConfig(key, source string) error {
+	id := newConfigID(source, key)
+	is, ok := c.statuses[id]
+	if !ok {
+		return fmt.Errorf("Controller: DisableConfig: config with key %s from source %s does not exist", key, source)
+	}
+
+	// delete config on observers if active
+	if is.Active {
+		c.deleteConfig(id.key)
+	}
+	delete(c.statuses, id)
+	c.save()
+	return nil
+}
+
+// pullWatchers retrieve configs from watchers and update configs according to priority of sources
+func (c *Controller) pullWatchers() {
+
+	for source, watcher := range c.watchers {
+		for _, conf := range watcher.GetConfigs() {
+			key := conf.Key()
+			cID := configID{
+				source: source,
+				key:    key,
+			}
+
+			err := conf.Validate()
+			valid := err == nil
+
+			status := Status{
+				Key:    key,
+				Source: source,
+				Active: valid, // active if valid, for now
+				Valid:  valid,
+				Config: conf,
+			}
+
+			// Check existing configs for matching key and source
+			if existingStatus, ok := c.statuses[cID]; ok {
+				// if config has not changed continue
+				if existingStatus.Config.Equals(conf) {
+					continue
+				}
+				// if existing CS is active then it should be replaced by the updated config
+				if existingStatus.Active {
+					if status.Valid {
+						c.putConfig(conf)
+					} else {
+						// if active config is being overwritten by an invalid one, delete the config, as it will not be active
+						c.deleteConfig(key)
+					}
+					c.statuses[cID] = &status
+					continue
+				}
+			}
+
+			// At this point we know that the config from this watcher has changed
+
+			// handle an config with a new unique key for a source or an update config from a source which was inactive before
+			if valid {
+				for matchID, matchCS := range c.statuses {
+					// skip matching configs
+					if matchID.Equals(cID) {
+						continue
+					}
+
+					if matchCS.Active {
+						// if source is non-multi-cloud disable all other non-multi-cloud sourced configs
+						if cID.source == HelmSource || cID.source == ConfigFileSource {
+							if matchID.source == HelmSource || matchID.source == ConfigFileSource {
+								matchCS.Active = false
+								c.deleteConfig(matchID.key)
+							}
+						}
+
+						// check for configs with the same key that are active
+						if matchID.key == key {
+							// If source has higher priority disable other active configs
+							matchCS.Active = false
+							c.deleteConfig(matchID.key)
+						}
+					}
+				}
+			}
+
+			// update config and put to observers if active
+			c.statuses[cID] = &status
+			if status.Active {
+				c.putConfig(conf)
+			}
+		}
+	}
+}
+
+// todo implement when building config api and persistence is necessary
+func (c *Controller) load() {}
+
+// todo implement when building config api and persistence is necessary
+func (c *Controller) save() {}
+
+func (c *Controller) ExportConfigs(key string) (*Configurations, error) {
+	configs := new(Configurations)
+
+	activeConfigs := make(map[string]cloud.Config)
+	for iID, cs := range c.statuses {
+		if cs.Active {
+			activeConfigs[iID.key] = cs.Config
+		}
+	}
+	if key != "" {
+		conf, ok := activeConfigs[key]
+		if !ok {
+			return nil, fmt.Errorf("Config with key %s does not exist or is inactive", key)
+		}
+		sanitizedConfig := conf.Sanitize()
+		err := configs.Insert(sanitizedConfig)
+		if err != nil {
+			return nil, fmt.Errorf("failed to insert config: %w", err)
+		}
+		return configs, nil
+	}
+
+	for _, conf := range activeConfigs {
+		sanitizedConfig := conf.Sanitize()
+		err := configs.Insert(sanitizedConfig)
+		if err != nil {
+			return nil, fmt.Errorf("failed to insert config: %w", err)
+		}
+	}
+	return configs, nil
+}
+
+func (c *Controller) getActiveConfigs() map[string]cloud.KeyedConfig {
+	bi := make(map[string]cloud.KeyedConfig)
+	for iID, cs := range c.statuses {
+		if cs.Active {
+			bi[iID.key] = cs.Config
+		}
+	}
+	return bi
+}
+
+// deleteConfig ask observers to remove and stop all processes related to a configuration with a given key
+func (c *Controller) deleteConfig(key string) {
+	var wg sync.WaitGroup
+	for _, obs := range c.observers {
+		observer := obs
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			observer.DeleteConfig(key)
+		}()
+	}
+	wg.Wait()
+}
+
+// RegisterObserver gives out the current active list configs and adds the observer to the push list
+func (c *Controller) RegisterObserver(obs Observer) {
+	obs.SetConfigs(c.getActiveConfigs())
+	c.observers = append(c.observers, obs)
+}
+
+func (c *Controller) GetStatus() []Status {
+	var status []Status
+	for _, intStat := range c.statuses {
+		status = append(status, *intStat)
+	}
+	return status
+}
+
+// putConfig gives observers a new config to handle
+func (c *Controller) putConfig(conf cloud.KeyedConfig) {
+	var wg sync.WaitGroup
+	for _, obs := range c.observers {
+		observer := obs
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			observer.PutConfig(conf)
+		}()
+	}
+	wg.Wait()
+}

+ 160 - 0
pkg/cloud/config/controller_handlers.go

@@ -0,0 +1,160 @@
+package config
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/julienschmidt/httprouter"
+	"github.com/opencost/opencost/pkg/env"
+	"github.com/opencost/opencost/pkg/proto"
+)
+
+var protocol = proto.HTTP()
+
+func (c *Controller) cloudCostChecks() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	// If Pipeline is nil, always return 503
+	if c == nil {
+		return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+			http.Error(w, "ConfigController: is nil", http.StatusServiceUnavailable)
+		}
+	}
+
+	if !env.IsCloudCostEnabled() {
+		return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+			http.Error(w, "ConfigController: is not enabled", http.StatusServiceUnavailable)
+		}
+	}
+
+	return nil
+}
+
+// GetEnableConfigHandler creates a handler from a http request which enables an integration via the integrationController
+func (c *Controller) GetExportConfigHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	// perform basic checks to ensure that the pipeline can be accessed
+	fn := c.cloudCostChecks()
+	if fn != nil {
+		return fn
+	}
+
+	// Return valid handler func
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		w.Header().Set("Content-Type", "application/json")
+
+		integrationKey := r.URL.Query().Get("integrationKey")
+
+		configs, err := c.ExportConfigs(integrationKey)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		protocol.WriteDataWithMessage(w, configs, "Configurations have been sanitized to protect secrets")
+	}
+}
+
+// GetEnableConfigHandler creates a handler from a http request which enables an integration via the integrationController
+func (c *Controller) GetEnableConfigHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	// perform basic checks to ensure that the pipeline can be accessed
+	fn := c.cloudCostChecks()
+	if fn != nil {
+		return fn
+	}
+
+	// Return valid handler func
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		w.Header().Set("Content-Type", "application/json")
+
+		integrationKey := r.URL.Query().Get("integrationKey")
+		if integrationKey == "" {
+			http.Error(w, "required parameter 'integrationKey' is missing", http.StatusBadRequest)
+			return
+		}
+
+		source := r.URL.Query().Get("source")
+		if source == "" {
+			http.Error(w, "required parameter 'source' is missing", http.StatusBadRequest)
+			return
+		}
+
+		err := c.EnableConfig(integrationKey, source)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		protocol.WriteData(w, fmt.Sprintf("Successfully enabled integration with key %s from source %s", integrationKey, source))
+	}
+}
+
+// GetDisableConfigHandler creates a handler from a http request which disables an integration via the integrationController
+func (c *Controller) GetDisableConfigHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	// perform basic checks to ensure that the pipeline can be accessed
+	fn := c.cloudCostChecks()
+	if fn != nil {
+		return fn
+	}
+
+	// Return valid handler func
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		w.Header().Set("Content-Type", "application/json")
+
+		integrationKey := r.URL.Query().Get("integrationKey")
+		if integrationKey == "" {
+			http.Error(w, "required parameter 'integrationKey' is missing", http.StatusBadRequest)
+			return
+		}
+
+		source := r.URL.Query().Get("source")
+		if source == "" {
+			http.Error(w, "required parameter 'source' is missing", http.StatusBadRequest)
+			return
+		}
+
+		err := c.DisableConfig(integrationKey, source)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		protocol.WriteData(w, fmt.Sprintf("Successfully disabled integration with key %s from source %s", integrationKey, source))
+	}
+}
+
+// GetDeleteConfigHandler creates a handler from a http request which deletes an integration via the integrationController
+// if there are no other integrations with the given integration key, it also clears the data.
+func (c *Controller) GetDeleteConfigHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	// perform basic checks to ensure that the pipeline can be accessed
+	fn := c.cloudCostChecks()
+	if fn != nil {
+		return fn
+	}
+
+	// Return valid handler func
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		w.Header().Set("Content-Type", "application/json")
+
+		integrationKey := r.URL.Query().Get("integrationKey")
+		if integrationKey == "" {
+			http.Error(w, "required parameter 'integrationKey' is missing", http.StatusBadRequest)
+			return
+		}
+
+		source := r.URL.Query().Get("source")
+		if source == "" {
+			http.Error(w, "required parameter 'source' is missing", http.StatusBadRequest)
+			return
+		}
+
+		err := c.DeleteConfig(integrationKey, source)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		protocol.WriteData(w, fmt.Sprintf("Successfully deleted integration with key %s from source %s", integrationKey, source))
+
+		for _, intStat := range c.GetStatus() {
+			if intStat.Key == integrationKey {
+				protocol.WriteData(w, fmt.Sprintf("Found addition integration with integration key %s from source %s. If you wish to delete this data do so manually or delete all integrations with matching keys", integrationKey, intStat.Source))
+				return
+			}
+		}
+		protocol.WriteData(w, fmt.Sprintf("Successfully deleted cloud cost data with key %s", integrationKey))
+	}
+}

+ 871 - 0
pkg/cloud/config/controller_test.go

@@ -0,0 +1,871 @@
+package config
+
+import (
+	"testing"
+
+	cloudconfig "github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/cloud/aws"
+	"github.com/opencost/opencost/pkg/cloud/gcp"
+)
+
+// Baseline valid config
+var validAthenaConf = &aws.AthenaConfiguration{
+	Bucket:     "bucket",
+	Region:     "region",
+	Database:   "database",
+	Table:      "table",
+	Workgroup:  "workgroup",
+	Account:    "account",
+	Authorizer: &aws.ServiceAccount{},
+}
+
+// Config with the same key as the baseline but is not equal to it because of the change in the non-keyed property Workgroup
+var validAthenaConfModifiedProperty = &aws.AthenaConfiguration{
+	Bucket:     "bucket",
+	Region:     "region",
+	Database:   "database",
+	Table:      "table",
+	Workgroup:  "workgroup1",
+	Account:    "account",
+	Authorizer: &aws.ServiceAccount{},
+}
+
+// Config with the same key as baseline but is invalid due to missing Authorizer
+var invalidAthenaConf = &aws.AthenaConfiguration{
+	Bucket:     "bucket",
+	Region:     "region",
+	Database:   "database",
+	Table:      "table",
+	Workgroup:  "workgroup",
+	Account:    "account",
+	Authorizer: nil,
+}
+
+// A valid config with a different key from the baseline
+var validBigQueryConf = &gcp.BigQueryConfiguration{
+	ProjectID:  "projectID",
+	Dataset:    "dataset",
+	Table:      "table",
+	Authorizer: &gcp.WorkloadIdentity{},
+}
+
+func TestIntegrationController_pullWatchers(t *testing.T) {
+	testCases := map[string]struct {
+		initialStatuses  []*Status
+		configWatchers   map[ConfigSource]cloudconfig.KeyedConfigWatcher
+		expectedStatuses []*Status
+	}{
+		// Helm Source
+		"Helm Source init": {
+			initialStatuses: []*Status{},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				HelmSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validAthenaConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+		},
+		"Helm Source No Change": {
+			initialStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				HelmSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validAthenaConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+		},
+		"Helm Source Update Config": {
+			initialStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    validAthenaConfModifiedProperty.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConfModifiedProperty,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				HelmSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validAthenaConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+		},
+		"Helm Source Update Config Invalid": {
+			initialStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				HelmSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						invalidAthenaConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    invalidAthenaConf.Key(),
+					Active: false,
+					Valid:  false,
+					Config: invalidAthenaConf,
+				},
+			},
+		},
+		"Helm Source New Config": {
+			initialStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				HelmSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validBigQueryConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    validAthenaConf.Key(),
+					Active: false, // this value changed
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+				{
+					Source: HelmSource,
+					Key:    validBigQueryConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+			},
+		},
+		// Config File
+		"Config File Source init": {
+			initialStatuses: []*Status{},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				ConfigFileSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validAthenaConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+		},
+		"Config File No Change": {
+			initialStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				ConfigFileSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validAthenaConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+		},
+		"Config File Update Config": {
+			initialStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				ConfigFileSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validAthenaConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+		},
+		"Config File Update Config Invalid": {
+			initialStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				ConfigFileSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						invalidAthenaConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    invalidAthenaConf.Key(),
+					Active: false,
+					Valid:  false,
+					Config: invalidAthenaConf,
+				},
+			},
+		},
+		"Config File New Config": {
+			initialStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				ConfigFileSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validBigQueryConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    validAthenaConf.Key(),
+					Active: false, // this value changed
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+				{
+					Source: ConfigFileSource,
+					Key:    validBigQueryConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+			},
+		},
+		// Multi Cloud
+		"Multi Cloud Source init": {
+			initialStatuses: []*Status{},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				MultiCloudSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validAthenaConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: MultiCloudSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+		},
+		"Multi Cloud No Change": {
+			initialStatuses: []*Status{
+				{
+					Source: MultiCloudSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				MultiCloudSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validAthenaConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: MultiCloudSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+		},
+		"Multi Cloud Update Config": {
+			initialStatuses: []*Status{
+				{
+					Source: MultiCloudSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				MultiCloudSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validAthenaConfModifiedProperty,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: MultiCloudSource,
+					Key:    validAthenaConfModifiedProperty.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConfModifiedProperty,
+				},
+			},
+		},
+		"Multi Cloud Update Config Invalid": {
+			initialStatuses: []*Status{
+				{
+					Source: MultiCloudSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				MultiCloudSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						invalidAthenaConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: MultiCloudSource,
+					Key:    invalidAthenaConf.Key(),
+					Active: false,
+					Valid:  false,
+					Config: invalidAthenaConf,
+				},
+			},
+		},
+		"Multi Cloud New Config": {
+			initialStatuses: []*Status{
+				{
+					Source: MultiCloudSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				MultiCloudSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validBigQueryConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: MultiCloudSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+				{
+					Source: MultiCloudSource,
+					Key:    validBigQueryConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+			},
+		},
+		// Watch Interaction
+		"New Helm, Existing Config File": {
+			initialStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				ConfigFileSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validAthenaConf,
+					},
+				},
+				HelmSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validBigQueryConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    validAthenaConf.Key(),
+					Active: false,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+				{
+					Source: HelmSource,
+					Key:    validBigQueryConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+			},
+		},
+		"Update Helm, Existing Config File": {
+			initialStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    validAthenaConf.Key(),
+					Active: false,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+				{
+					Source: ConfigFileSource,
+					Key:    validBigQueryConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				ConfigFileSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validBigQueryConf,
+					},
+				},
+				HelmSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validAthenaConfModifiedProperty,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    validAthenaConfModifiedProperty.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConfModifiedProperty,
+				},
+				{
+					Source: ConfigFileSource,
+					Key:    validBigQueryConf.Key(),
+					Active: false,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+			},
+		},
+		"New Helm Invalid, Existing Config File": {
+			initialStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    validBigQueryConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				ConfigFileSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validBigQueryConf,
+					},
+				},
+				HelmSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						invalidAthenaConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    validBigQueryConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+				{
+					Source: HelmSource,
+					Key:    invalidAthenaConf.Key(),
+					Active: false,
+					Valid:  false,
+					Config: invalidAthenaConf,
+				},
+			},
+		},
+		"Update Helm Invalid, Existing Config File": {
+			initialStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    validBigQueryConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+				{
+					Source: HelmSource,
+					Key:    validAthenaConf.Key(),
+					Active: false,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				ConfigFileSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validBigQueryConf,
+					},
+				},
+				HelmSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						invalidAthenaConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    validBigQueryConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+				{
+					Source: HelmSource,
+					Key:    invalidAthenaConf.Key(),
+					Active: false,
+					Valid:  false,
+					Config: invalidAthenaConf,
+				},
+			},
+		},
+		"New Config File, Existing Helm": {
+			initialStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    validAthenaConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				HelmSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validAthenaConf,
+					},
+				},
+				ConfigFileSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validBigQueryConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    validAthenaConf.Key(),
+					Active: false,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+				{
+					Source: ConfigFileSource,
+					Key:    validBigQueryConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+			},
+		},
+		"Update Config File, Existing Helm": {
+			initialStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    validAthenaConf.Key(),
+					Active: false,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+				{
+					Source: HelmSource,
+					Key:    validBigQueryConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				HelmSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{},
+				},
+				ConfigFileSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validAthenaConfModifiedProperty,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: ConfigFileSource,
+					Key:    validAthenaConfModifiedProperty.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validAthenaConfModifiedProperty,
+				},
+				{
+					Source: HelmSource,
+					Key:    validBigQueryConf.Key(),
+					Active: false,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+			},
+		},
+		"New Config File Invalid, Existing Helm": {
+			initialStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    validBigQueryConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				HelmSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validBigQueryConf,
+					},
+				},
+				ConfigFileSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						invalidAthenaConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    validBigQueryConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+				{
+					Source: ConfigFileSource,
+					Key:    invalidAthenaConf.Key(),
+					Active: false,
+					Valid:  false,
+					Config: invalidAthenaConf,
+				},
+			},
+		},
+		"Update Config File Invalid, Existing Helm": {
+			initialStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    validBigQueryConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+				{
+					Source: ConfigFileSource,
+					Key:    validAthenaConf.Key(),
+					Active: false,
+					Valid:  true,
+					Config: validAthenaConf,
+				},
+			},
+			configWatchers: map[ConfigSource]cloudconfig.KeyedConfigWatcher{
+				HelmSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						validBigQueryConf,
+					},
+				},
+				ConfigFileSource: &MockKeyedConfigWatcher{
+					Integrations: []cloudconfig.KeyedConfig{
+						invalidAthenaConf,
+					},
+				},
+			},
+			expectedStatuses: []*Status{
+				{
+					Source: HelmSource,
+					Key:    validBigQueryConf.Key(),
+					Active: true,
+					Valid:  true,
+					Config: validBigQueryConf,
+				},
+				{
+					Source: ConfigFileSource,
+					Key:    invalidAthenaConf.Key(),
+					Active: false,
+					Valid:  false,
+					Config: invalidAthenaConf,
+				},
+			},
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			// Test set up and validation
+			initialStatuses := make(map[configID]*Status)
+			for _, status := range tc.initialStatuses {
+				iID := configID{
+					source: status.Source,
+					key:    status.Key,
+				}
+				if _, ok := initialStatuses[iID]; ok {
+					t.Errorf("invalid test, duplicate initial status with key: %s source: %s", iID.key, iID.source.String())
+				}
+				initialStatuses[iID] = status
+			}
+
+			expectedStatuses := make(map[configID]*Status)
+			for _, status := range tc.expectedStatuses {
+				iID := configID{
+					source: status.Source,
+					key:    status.Key,
+				}
+				if _, ok := expectedStatuses[iID]; ok {
+					t.Errorf("invalid test, duplicate expected status with key: %s source: %s", iID.key, iID.source.String())
+				}
+				expectedStatuses[iID] = status
+			}
+
+			// Initialize controller
+			icd := &Controller{
+				statuses: initialStatuses,
+				watchers: tc.configWatchers,
+			}
+			icd.pullWatchers()
+			if len(icd.statuses) != len(tc.expectedStatuses) {
+				t.Errorf("integration statueses did not have the correct length actaul: %d, expected: %d", len(icd.statuses), len(tc.expectedStatuses))
+			}
+
+			for iID, actualStatus := range icd.statuses {
+				expectedStatus, ok := expectedStatuses[iID]
+				if !ok {
+					t.Errorf("expected integration statuses is missing with integration ID: %v", iID)
+				}
+
+				// failure here indicates an issue with the configID
+				if actualStatus.Key != expectedStatus.Key {
+					t.Errorf("integration status does not have the correct Key values actual: %s, expected: %s", actualStatus.Key, expectedStatus.Key)
+				}
+
+				// failure here indicates an issue with the configID
+				if actualStatus.Key != expectedStatus.Key {
+					t.Errorf("integration status does not have the correct Source values actual: %s, expected: %s", actualStatus.Source, expectedStatus.Source)
+				}
+
+				if actualStatus.Active != expectedStatus.Active {
+					t.Errorf("integration status does not have the correct Active values actual: %v, expected: %v", actualStatus.Active, expectedStatus.Active)
+				}
+
+				if actualStatus.Valid != expectedStatus.Valid {
+					t.Errorf("integration status does not have the correct Valid values actual: %v, expected: %v", actualStatus.Valid, expectedStatus.Valid)
+				}
+
+				if !actualStatus.Config.Equals(expectedStatus.Config) {
+					t.Errorf("integration status does not have the correct config values actual: %v, expected: %v", actualStatus.Config, expectedStatus.Config)
+				}
+			}
+		})
+	}
+}

+ 95 - 0
pkg/cloud/config/mock.go

@@ -0,0 +1,95 @@
+package config
+
+import (
+	"fmt"
+
+	"github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/kubecost"
+)
+
+type MockConfig struct {
+}
+
+func (mc *MockConfig) Validate() error {
+	return nil
+}
+
+func (mc *MockConfig) Equals(config cloud.Config) bool {
+	_, ok := config.(*MockConfig)
+	return ok
+}
+
+func (mc *MockConfig) Sanitize() cloud.Config {
+	return &MockConfig{}
+}
+
+// MockKeyedConfig implements KeyedConfig it only requires a key to be valid, there is an additional property allowing
+// MockKeyedConfig with the same key to not be equal
+type MockKeyedConfig struct {
+	key      string
+	property string
+	valid    bool
+}
+
+func NewMockKeyedConfig(key, property string, valid bool) cloud.KeyedConfig {
+	return &MockKeyedConfig{
+		key:      key,
+		property: property,
+		valid:    valid,
+	}
+}
+
+func (mkc *MockKeyedConfig) Validate() error {
+	if !mkc.valid {
+		return fmt.Errorf("MockKeyedConfig: set to invalid")
+	}
+	if mkc.key == "" {
+		return fmt.Errorf("MockKeyedConfig: missing key")
+	}
+	return nil
+}
+
+func (mkc *MockKeyedConfig) Equals(config cloud.Config) bool {
+	that, ok := config.(*MockKeyedConfig)
+	if !ok {
+		return false
+	}
+
+	if mkc.key != that.key {
+		return false
+	}
+
+	if mkc.property != that.property {
+		return false
+	}
+
+	if mkc.valid != that.valid {
+		return false
+	}
+
+	return true
+}
+
+func (mkc *MockKeyedConfig) Sanitize() cloud.Config {
+	return &MockKeyedConfig{
+		key:      mkc.key,
+		property: mkc.property,
+		valid:    mkc.valid,
+	}
+}
+
+func (mkc *MockKeyedConfig) Key() string {
+	return mkc.key
+}
+
+func (mkc *MockKeyedConfig) Provider() string {
+	return kubecost.CustomProvider
+}
+
+type MockKeyedConfigWatcher struct {
+	Integrations []cloud.KeyedConfig
+}
+
+func (mkcw *MockKeyedConfigWatcher) GetConfigs() []cloud.KeyedConfig {
+	return mkcw.Integrations
+}

+ 14 - 0
pkg/cloud/config/observer.go

@@ -0,0 +1,14 @@
+package config
+
+import (
+	"github.com/opencost/opencost/pkg/cloud"
+)
+
+// Observer should be implemented by any struct which need access to the up-to-date list of active configs
+// that the Config.Controller provides. Any cloud billing Integration in the application that is used in the application
+// should pass through this interface, and be revoked if it is not included in a Delete call.
+type Observer interface {
+	PutConfig(cloud.KeyedConfig)
+	DeleteConfig(string)
+	SetConfigs(map[string]cloud.KeyedConfig)
+}

+ 351 - 0
pkg/cloud/config/watcher.go

@@ -0,0 +1,351 @@
+package config
+
+import (
+	"fmt"
+	"io/ioutil"
+	"path"
+
+	"github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/cloud/alibaba"
+	"github.com/opencost/opencost/pkg/cloud/aws"
+	"github.com/opencost/opencost/pkg/cloud/azure"
+	"github.com/opencost/opencost/pkg/cloud/gcp"
+	"github.com/opencost/opencost/pkg/cloud/models"
+
+	"github.com/opencost/opencost/pkg/env"
+	"github.com/opencost/opencost/pkg/log"
+	"github.com/opencost/opencost/pkg/util/fileutil"
+	"github.com/opencost/opencost/pkg/util/json"
+)
+
+const authSecretPath = "/var/secrets/service-key.json"
+const storageConfigSecretPath = "/var/azure-storage-config/azure-storage-config.json"
+const cloudIntegrationSecretPath = "/cloud-integration/cloud-integration.json"
+
+type HelmWatcher struct {
+	providerConfig models.ProviderConfig
+}
+
+// GetConfigs checks secret files and config map set via the helm chart for Cloud Billing integrations. Returns
+// only one billing integration due to values being shared by different configuration types.
+func (hw *HelmWatcher) GetConfigs() []cloud.KeyedConfig {
+	var configs []cloud.KeyedConfig
+
+	customPricing, _ := hw.providerConfig.GetCustomPricingData()
+
+	// check for Azure Storage config in secret file
+	exists, err := fileutil.FileExists(storageConfigSecretPath)
+	if err != nil {
+		log.Errorf("HelmWatcher: AzureStorage: error checking file at '%s': %s", storageConfigSecretPath, err.Error())
+	}
+
+	// If file does not exist implies that this configuration method was not used
+	if exists {
+		result, err2 := ioutil.ReadFile(storageConfigSecretPath)
+		if err2 != nil {
+			log.Errorf("HelmWatcher: AzureStorage: Error reading file: %s", err2.Error())
+			return nil
+		}
+
+		asc := &azure.AzureStorageConfig{}
+		err2 = json.Unmarshal(result, asc)
+		if err2 != nil {
+			log.Errorf("HelmWatcher: AzureStorage: Error reading json: %s", err2.Error())
+			return nil
+		}
+		if asc != nil && !asc.IsEmpty() {
+			// If subscription id is not set it may be present in the rate card API
+			if asc.SubscriptionId == "" {
+				ask := &azure.AzureServiceKey{}
+				err3 := loadFile(authSecretPath, ask)
+				if err3 != nil {
+					log.Errorf("HelmWatcher: AzureStorage: AzureRateCard: %s", err3)
+				}
+				if ask != nil {
+					asc.SubscriptionId = ask.SubscriptionID
+				}
+			}
+			// If SubscriptionID is still empty check the customPricing
+			if asc.SubscriptionId == "" {
+				asc.SubscriptionId = customPricing.AzureSubscriptionID
+			}
+			kc := azure.ConvertAzureStorageConfigToConfig(*asc)
+			configs = append(configs, kc)
+			return configs
+		}
+
+	}
+
+	exists, err = fileutil.FileExists(authSecretPath)
+	if err != nil {
+		log.Errorf("HelmWatcher:  error checking file at '%s': %s", authSecretPath, err.Error())
+	}
+
+	// If the Auth Secret is not set then the config file watch will be responsible for providing the configurer for the
+	// config values present in the CustomPricing object
+	if exists {
+		if customPricing.BillingDataDataset != "" {
+			// Big Query Configuration
+			bqc := gcp.BigQueryConfig{
+				ProjectID:          customPricing.ProjectID,
+				BillingDataDataset: customPricing.BillingDataDataset,
+			}
+
+			key := make(map[string]string)
+			err2 := loadFile(authSecretPath, &key)
+			if err2 != nil {
+				log.Errorf("HelmWatcher: GCP: %s", err2)
+			}
+			if key != nil && len(key) != 0 {
+				bqc.Key = key
+			}
+
+			kc := gcp.ConvertBigQueryConfigToConfig(bqc)
+			configs = append(configs, kc)
+			return configs
+		}
+
+		if customPricing.AthenaBucketName != "" {
+			aai := aws.AwsAthenaInfo{
+				AthenaBucketName: customPricing.AthenaBucketName,
+				AthenaRegion:     customPricing.AthenaRegion,
+				AthenaDatabase:   customPricing.AthenaDatabase,
+				AthenaTable:      customPricing.AthenaTable,
+				AthenaWorkgroup:  customPricing.AthenaWorkgroup,
+				AccountID:        customPricing.AthenaProjectID,
+				MasterPayerARN:   customPricing.MasterPayerARN,
+			}
+
+			// If Account ID is blank check ProjectID
+			if aai.AccountID == "" {
+				aai.AccountID = customPricing.ProjectID
+			}
+
+			var accessKey aws.AWSAccessKey
+			err2 := loadFile(authSecretPath, &accessKey)
+			if err2 != nil {
+				log.Errorf("HelmWatcher: AWS: %s", err2)
+			}
+
+			aai.ServiceKeyName = accessKey.AccessKeyID
+			aai.ServiceKeySecret = accessKey.SecretAccessKey
+
+			kc := aws.ConvertAwsAthenaInfoToConfig(aai)
+			configs = append(configs, kc)
+			return configs
+
+		}
+	}
+
+	return configs
+}
+
+type ConfigFileWatcher struct {
+	providerConfig models.ProviderConfig
+}
+
+// GetConfigs checks secret files and config map set via the helm chart for Cloud Billing integrations. Returns
+// only one billing integration due to values being shared by different configuration types.
+func (cfw *ConfigFileWatcher) GetConfigs() []cloud.KeyedConfig {
+	var configs []cloud.KeyedConfig
+
+	customPricing, _ := cfw.providerConfig.GetCustomPricingData()
+
+	// Detect Azure Storage configuration
+	if customPricing.AzureSubscriptionID != "" {
+		asc := azure.AzureStorageConfig{
+			SubscriptionId: customPricing.AzureSubscriptionID,
+			AccountName:    customPricing.AzureStorageAccount,
+			AccessKey:      customPricing.AzureStorageAccessKey,
+			ContainerName:  customPricing.AzureStorageContainer,
+			ContainerPath:  customPricing.AzureContainerPath,
+			AzureCloud:     customPricing.AzureCloud,
+		}
+		kc := azure.ConvertAzureStorageConfigToConfig(asc)
+		configs = append(configs, kc)
+		return configs
+
+	}
+
+	// Detect Big Query Configuration
+	if customPricing.BillingDataDataset != "" {
+		bqc := gcp.BigQueryConfig{
+			ProjectID:          customPricing.ProjectID,
+			BillingDataDataset: customPricing.BillingDataDataset,
+		}
+
+		var key map[string]string
+		err2 := loadFile(env.GetConfigPathWithDefault("/models/")+"key.json", &key)
+		if err2 != nil {
+			log.Errorf("ConfigFileWatcher: GCP: %s", err2)
+		}
+		if key != nil && len(key) != 0 {
+			bqc.Key = key
+		}
+
+		kc := gcp.ConvertBigQueryConfigToConfig(bqc)
+		configs = append(configs, kc)
+		return configs
+	}
+
+	// Detect AWS configuration
+	if customPricing.AthenaBucketName != "" {
+		aai := aws.AwsAthenaInfo{
+			AthenaBucketName: customPricing.AthenaBucketName,
+			AthenaRegion:     customPricing.AthenaRegion,
+			AthenaDatabase:   customPricing.AthenaDatabase,
+			AthenaTable:      customPricing.AthenaTable,
+			AthenaWorkgroup:  customPricing.AthenaWorkgroup,
+			ServiceKeyName:   customPricing.ServiceKeyName,
+			ServiceKeySecret: customPricing.ServiceKeySecret,
+			AccountID:        customPricing.AthenaProjectID,
+			MasterPayerARN:   customPricing.MasterPayerARN,
+		}
+
+		// If Account ID is blank check ProjectID
+		if aai.AccountID == "" {
+			aai.AccountID = customPricing.ProjectID
+		}
+
+		// If the sample nil service key name is set, zero it out so that it is not
+		// misinterpreted as a real service key.
+		if aai.ServiceKeyName == "AKIXXX" {
+			aai.ServiceKeyName = ""
+		}
+
+		kc := aws.ConvertAwsAthenaInfoToConfig(aai)
+		configs = append(configs, kc)
+		return configs
+	}
+
+	//detect Alibaba Configuration
+
+	if customPricing.AlibabaClusterRegion != "" {
+		aliCloudInfo := alibaba.AlibabaInfo{
+			AlibabaClusterRegion:    customPricing.AlibabaClusterRegion,
+			AlibabaServiceKeyName:   customPricing.AlibabaServiceKeyName,
+			AlibabaServiceKeySecret: customPricing.AlibabaServiceKeySecret,
+			AlibabaAccountID:        customPricing.ProjectID,
+		}
+		kc := alibaba.ConvertAlibabaInfoToConfig(aliCloudInfo)
+		configs = append(configs, kc)
+		return configs
+	}
+	return configs
+}
+
+// MultiCloudWatcher ingests values a MultiCloudConfig from the file pulled in from the secret by the helm chart
+type MultiCloudWatcher struct {
+}
+
+func (mcw *MultiCloudWatcher) GetConfigs() []cloud.KeyedConfig {
+	multiConfigPath := path.Join(env.GetConfigPathWithDefault("/var/configs"), cloudIntegrationSecretPath)
+	exists, err := fileutil.FileExists(multiConfigPath)
+	if err != nil {
+		log.Errorf("MultiCloudWatcher:  error checking file at '%s': %s", multiConfigPath, err.Error())
+	}
+
+	// If config does not exist implies that this configuration method was not used
+	if !exists {
+		// check the original location of secret mount
+		multiConfigPath = path.Join("/var", cloudIntegrationSecretPath)
+		exists, err = fileutil.FileExists(multiConfigPath)
+		if err != nil {
+			log.Errorf("MultiCloudWatcher:  error checking file at '%s': %s", multiConfigPath, err.Error())
+		}
+
+		// If config does not exist implies that this configuration method was not used
+		if !exists {
+			return nil
+		}
+	}
+
+	configurations := &Configurations{}
+	err = loadFile(multiConfigPath, configurations)
+	if err != nil {
+		log.Errorf("MultiCloudWatcher: Error getting file '%s': %s", multiConfigPath, err.Error())
+		return nil
+	}
+
+	return configurations.ToSlice()
+}
+
+func GetCloudBillingWatchers(providerConfig models.ProviderConfig) map[ConfigSource]cloud.KeyedConfigWatcher {
+	watchers := make(map[ConfigSource]cloud.KeyedConfigWatcher, 3)
+	watchers[MultiCloudSource] = &MultiCloudWatcher{}
+	if providerConfig != nil {
+		watchers[HelmSource] = &HelmWatcher{providerConfig: providerConfig}
+		watchers[ConfigFileSource] = &ConfigFileWatcher{providerConfig: providerConfig}
+	}
+
+	return watchers
+}
+
+// loadFile unmarshals the json content of a file into the provided object
+// an empty return with no error indicates that the file did not exist.
+func loadFile[T any](path string, content T) error {
+	exists, err := fileutil.FileExists(path)
+	if err != nil {
+		return fmt.Errorf("loadFile: error checking file at '%s': %s", path, err.Error())
+	}
+
+	// If file does not exist implies that this configuration method was not used
+	if !exists {
+		return nil
+	}
+
+	result, err := ioutil.ReadFile(path)
+	if err != nil {
+		return fmt.Errorf("loadFile: Error reading file: %s", err.Error())
+	}
+
+	err = json.Unmarshal(result, content)
+	if err != nil {
+		return fmt.Errorf("loadFile: Error reading json: %s", err.Error())
+	}
+
+	return nil
+}
+
+// ConfigSource is an Enum of the sources int value of the Source determines its priority
+type ConfigSource int
+
+const (
+	UnknownSource ConfigSource = iota
+	ConfigControllerSource
+	MultiCloudSource
+	ConfigFileSource
+	HelmSource
+)
+
+func GetConfigSource(str string) ConfigSource {
+	switch str {
+	case "configController":
+		return ConfigControllerSource
+	case "configfile":
+		return ConfigFileSource
+	case "helm":
+		return HelmSource
+	case "multicloud":
+		return MultiCloudSource
+	default:
+		return UnknownSource
+	}
+}
+
+func (cs ConfigSource) String() string {
+	switch cs {
+	case ConfigControllerSource:
+		return "configController"
+	case ConfigFileSource:
+		return "configfile"
+	case HelmSource:
+		return "helm"
+	case MultiCloudSource:
+		return "multicloud"
+	case UnknownSource:
+		return "unknown"
+	default:
+		return "unknown"
+	}
+}

+ 9 - 9
pkg/cloud/gcp/authorizer.go

@@ -4,7 +4,7 @@ import (
 	"encoding/json"
 	"fmt"
 
-	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/cloud"
 	"google.golang.org/api/option"
 )
 
@@ -13,7 +13,7 @@ const WorkloadIdentityAuthorizerType = "GCPWorkloadIdentity"
 
 // Authorizer provide a []option.ClientOption which is used in when creating clients in the GCP SDK
 type Authorizer interface {
-	config.Authorizer
+	cloud.Authorizer
 	CreateGCPClientOptions() ([]option.ClientOption, error)
 }
 
@@ -36,7 +36,7 @@ type ServiceAccountKey struct {
 // MarshalJSON custom json marshalling functions, sets properties as tagged in struct and sets the authorizer type property
 func (gkc *ServiceAccountKey) MarshalJSON() ([]byte, error) {
 	fmap := make(map[string]any, 2)
-	fmap[config.AuthorizerTypeProperty] = ServiceAccountKeyAuthorizerType
+	fmap[cloud.AuthorizerTypeProperty] = ServiceAccountKeyAuthorizerType
 	fmap["key"] = gkc.Key
 	return json.Marshal(fmap)
 }
@@ -49,7 +49,7 @@ func (gkc *ServiceAccountKey) Validate() error {
 	return nil
 }
 
-func (gkc *ServiceAccountKey) Equals(config config.Config) bool {
+func (gkc *ServiceAccountKey) Equals(config cloud.Config) bool {
 	if config == nil {
 		return false
 	}
@@ -71,10 +71,10 @@ func (gkc *ServiceAccountKey) Equals(config config.Config) bool {
 	return true
 }
 
-func (gkc *ServiceAccountKey) Sanitize() config.Config {
+func (gkc *ServiceAccountKey) Sanitize() cloud.Config {
 	redactedMap := make(map[string]string, len(gkc.Key))
 	for key, _ := range gkc.Key {
-		redactedMap[key] = config.Redacted
+		redactedMap[key] = cloud.Redacted
 	}
 	return &ServiceAccountKey{
 		Key: redactedMap,
@@ -103,7 +103,7 @@ type WorkloadIdentity struct{}
 // MarshalJSON custom json marshalling functions, sets properties as tagged in struct and sets the authorizer type property
 func (wi *WorkloadIdentity) MarshalJSON() ([]byte, error) {
 	fmap := make(map[string]any, 1)
-	fmap[config.AuthorizerTypeProperty] = WorkloadIdentityAuthorizerType
+	fmap[cloud.AuthorizerTypeProperty] = WorkloadIdentityAuthorizerType
 	return json.Marshal(fmap)
 }
 
@@ -111,7 +111,7 @@ func (wi *WorkloadIdentity) Validate() error {
 	return nil
 }
 
-func (wi *WorkloadIdentity) Equals(config config.Config) bool {
+func (wi *WorkloadIdentity) Equals(config cloud.Config) bool {
 	if config == nil {
 		return false
 	}
@@ -123,7 +123,7 @@ func (wi *WorkloadIdentity) Equals(config config.Config) bool {
 	return true
 }
 
-func (wi *WorkloadIdentity) Sanitize() config.Config {
+func (wi *WorkloadIdentity) Sanitize() cloud.Config {
 	return &WorkloadIdentity{}
 }
 

+ 13 - 8
pkg/cloud/gcp/bigqueryconfiguration.go

@@ -6,7 +6,8 @@ import (
 	"strings"
 
 	"cloud.google.com/go/bigquery"
-	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/kubecost"
 	"github.com/opencost/opencost/pkg/util/json"
 )
 
@@ -43,7 +44,7 @@ func (bqc *BigQueryConfiguration) Validate() error {
 	return nil
 }
 
-func (bqc *BigQueryConfiguration) Equals(config config.Config) bool {
+func (bqc *BigQueryConfiguration) Equals(config cloud.Config) bool {
 	if config == nil {
 		return false
 	}
@@ -77,7 +78,7 @@ func (bqc *BigQueryConfiguration) Equals(config config.Config) bool {
 	return true
 }
 
-func (bqc *BigQueryConfiguration) Sanitize() config.Config {
+func (bqc *BigQueryConfiguration) Sanitize() cloud.Config {
 	return &BigQueryConfiguration{
 		ProjectID:  bqc.ProjectID,
 		Dataset:    bqc.Dataset,
@@ -91,6 +92,10 @@ func (bqc *BigQueryConfiguration) Key() string {
 	return fmt.Sprintf("%s/%s", bqc.ProjectID, bqc.GetBillingDataDataset())
 }
 
+func (bqc *BigQueryConfiguration) Provider() string {
+	return kubecost.GCPProvider
+}
+
 func (bqc *BigQueryConfiguration) GetBillingDataDataset() string {
 	return fmt.Sprintf("%s.%s", bqc.Dataset, bqc.Table)
 }
@@ -113,19 +118,19 @@ func (bqc *BigQueryConfiguration) UnmarshalJSON(b []byte) error {
 
 	fmap := f.(map[string]interface{})
 
-	projectID, err := config.GetInterfaceValue[string](fmap, "projectID")
+	projectID, err := cloud.GetInterfaceValue[string](fmap, "projectID")
 	if err != nil {
 		return fmt.Errorf("BigQueryConfiguration: FromInterface: %s", err.Error())
 	}
 	bqc.ProjectID = projectID
 
-	dataset, err := config.GetInterfaceValue[string](fmap, "dataset")
+	dataset, err := cloud.GetInterfaceValue[string](fmap, "dataset")
 	if err != nil {
 		return fmt.Errorf("BigQueryConfiguration: FromInterface: %s", err.Error())
 	}
 	bqc.Dataset = dataset
 
-	table, err := config.GetInterfaceValue[string](fmap, "table")
+	table, err := cloud.GetInterfaceValue[string](fmap, "table")
 	if err != nil {
 		return fmt.Errorf("BigQueryConfiguration: FromInterface: %s", err.Error())
 	}
@@ -135,7 +140,7 @@ func (bqc *BigQueryConfiguration) UnmarshalJSON(b []byte) error {
 	if !ok {
 		return fmt.Errorf("StorageConfiguration: UnmarshalJSON: missing authorizer")
 	}
-	authorizer, err := config.AuthorizerFromInterface(authAny, SelectAuthorizerByType)
+	authorizer, err := cloud.AuthorizerFromInterface(authAny, SelectAuthorizerByType)
 	if err != nil {
 		return fmt.Errorf("StorageConfiguration: UnmarshalJSON: %s", err.Error())
 	}
@@ -143,7 +148,7 @@ func (bqc *BigQueryConfiguration) UnmarshalJSON(b []byte) error {
 	return nil
 }
 
-func ConvertBigQueryConfigToConfig(bqc BigQueryConfig) config.KeyedConfig {
+func ConvertBigQueryConfigToConfig(bqc BigQueryConfig) cloud.KeyedConfig {
 	if bqc.IsEmpty() {
 		return nil
 	}

+ 2 - 2
pkg/cloud/gcp/bigqueryconfiguration_test.go

@@ -4,7 +4,7 @@ import (
 	"fmt"
 	"testing"
 
-	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/cloud"
 	"github.com/opencost/opencost/pkg/log"
 	"github.com/opencost/opencost/pkg/util/json"
 )
@@ -122,7 +122,7 @@ func TestBigQueryConfiguration_Validate(t *testing.T) {
 func TestBigQueryConfiguration_Equals(t *testing.T) {
 	testCases := map[string]struct {
 		left     BigQueryConfiguration
-		right    config.Config
+		right    cloud.Config
 		expected bool
 	}{
 		"matching config": {

+ 116 - 247
pkg/cloud/gcp/bigqueryintegration.go

@@ -2,16 +2,13 @@ package gcp
 
 import (
 	"context"
-	"encoding/json"
+	"errors"
 	"fmt"
-	"regexp"
 	"strings"
 	"time"
 
-	"cloud.google.com/go/bigquery"
 	"github.com/opencost/opencost/pkg/kubecost"
 	"github.com/opencost/opencost/pkg/log"
-	"github.com/opencost/opencost/pkg/util/timeutil"
 	"google.golang.org/api/iterator"
 )
 
@@ -28,6 +25,7 @@ const (
 	LabelsColumnName             = "labels"
 	ResourceNameColumnName       = "resource"
 	CostColumnName               = "cost"
+	ListCostColumnName           = "list_cost"
 	CreditsColumnName            = "credits"
 )
 
@@ -35,8 +33,12 @@ const BiqQueryWherePartitionFmt = `DATE(_PARTITIONTIME) >= "%s" AND DATE(_PARTIT
 const BiqQueryWhereDateFmt = `usage_start_time >= "%s" AND usage_start_time < "%s"`
 
 func (bqi *BigQueryIntegration) GetCloudCost(start time.Time, end time.Time) (*kubecost.CloudCostSetRange, error) {
-	// Build Query
+	cudRates, err := bqi.GetFlexibleCUDRates(start, end)
+	if err != nil {
+		return nil, fmt.Errorf("error retrieving CUD rates: %w", err)
+	}
 
+	// Build Query
 	selectColumns := []string{
 		fmt.Sprintf("TIMESTAMP_TRUNC(usage_start_time, day) as %s", UsageDateColumnName),
 		fmt.Sprintf("billing_account_id as %s", BillingAccountIDColumnName),
@@ -46,7 +48,8 @@ func (bqi *BigQueryIntegration) GetCloudCost(start time.Time, end time.Time) (*k
 		fmt.Sprintf("resource.name as %s", ResourceNameColumnName),
 		fmt.Sprintf("TO_JSON_STRING(labels) as %s", LabelsColumnName),
 		fmt.Sprintf("SUM(cost) as %s", CostColumnName),
-		fmt.Sprintf("IFNULL(SUM((Select SUM(amount) FROM bd.credits)),0) as %s", CreditsColumnName),
+		fmt.Sprintf("SUM(cost_at_list) as %s", ListCostColumnName),
+		fmt.Sprintf("ARRAY_CONCAT_AGG(credits) as %s", CreditsColumnName),
 	}
 
 	groupByColumns := []string{
@@ -59,15 +62,7 @@ func (bqi *BigQueryIntegration) GetCloudCost(start time.Time, end time.Time) (*k
 		ResourceNameColumnName,
 	}
 
-	partitionStart := start
-	partitionEnd := end.AddDate(0, 0, 2)
-	wherePartition := fmt.Sprintf(BiqQueryWherePartitionFmt, partitionStart.Format("2006-01-02"), partitionEnd.Format("2006-01-02"))
-	whereDate := fmt.Sprintf(BiqQueryWhereDateFmt, start.Format("2006-01-02"), end.Format("2006-01-02"))
-
-	whereConjuncts := []string{
-		wherePartition,
-		whereDate,
-	}
+	whereConjuncts := GetWhereConjuncts(start, end)
 
 	columnStr := strings.Join(selectColumns, ", ")
 	table := fmt.Sprintf(" `%s` bd ", bqi.GetBillingDataDataset())
@@ -84,7 +79,7 @@ func (bqi *BigQueryIntegration) GetCloudCost(start time.Time, end time.Time) (*k
 
 	// Perform Query and parse values
 
-	ccsr, err := kubecost.NewCloudCostSetRange(start, end, timeutil.Day, bqi.Key())
+	ccsr, err := kubecost.NewCloudCostSetRange(start, end, kubecost.AccumulateOptionDay, bqi.Key())
 	if err != nil {
 		return ccsr, fmt.Errorf("error creating new CloudCostSetRange: %s", err)
 	}
@@ -95,8 +90,11 @@ func (bqi *BigQueryIntegration) GetCloudCost(start time.Time, end time.Time) (*k
 	}
 
 	// Parse query into CloudCostSetRange
+
 	for {
-		var ccl CloudCostLoader
+		ccl := CloudCostLoader{
+			FlexibleCUDRates: cudRates,
+		}
 		err = iter.Next(&ccl)
 		if err == iterator.Done {
 			break
@@ -110,260 +108,131 @@ func (bqi *BigQueryIntegration) GetCloudCost(start time.Time, end time.Time) (*k
 		ccsr.LoadCloudCost(ccl.CloudCost)
 
 	}
+
 	return ccsr, nil
 
 }
 
-type CloudCostLoader struct {
-	CloudCost *kubecost.CloudCost
+// GetWhereConjuncts creates a list of Where filter statements that filter for usage start date and partition time
+// additional filters can be added before combining into the final where clause
+func GetWhereConjuncts(start time.Time, end time.Time) []string {
+	partitionStart := start
+	partitionEnd := end.AddDate(0, 0, 2)
+	wherePartition := fmt.Sprintf(BiqQueryWherePartitionFmt, partitionStart.Format("2006-01-02"), partitionEnd.Format("2006-01-02"))
+	whereDate := fmt.Sprintf(BiqQueryWhereDateFmt, start.Format("2006-01-02"), end.Format("2006-01-02"))
+	return []string{wherePartition, whereDate}
 }
 
-// Load populates the fields of a CloudCostValues with bigquery.Value from provided slice
-func (ccl *CloudCostLoader) Load(values []bigquery.Value, schema bigquery.Schema) error {
-
-	// Create Cloud Cost Properties
-	properties := kubecost.CloudCostProperties{
-		Provider: kubecost.GCPProvider,
-	}
-	var window kubecost.Window
-	var description string
-	var listCost float64
-	var credits float64
-
-	for i, field := range schema {
-		if field == nil {
-			log.DedupedErrorf(5, "GCP: BigQuery: found nil field in schema")
-			continue
-		}
+// FlexibleCUDRates are the total amount paid / total amount credited per day for all Flexible CUDs. Since credited will be a negative value
+// this will be a negative ratio. This can then be multiplied with the credits from Flexible CUDs on specific line items to determine
+// the amount paid for the credit it received. This allows us to amortize the Flexible CUD costs which are not associated with resources
+// in the billing export. AmountPayed itself may have some credits on it so a Rate and a NetRate are created.
+// Having both allow us to populate AmortizedCost and AmortizedNetCost respectively.
+type FlexibleCUDRates struct {
+	NetRate float64
+	Rate    float64
+}
 
-		switch field.Name {
-		case UsageDateColumnName:
-			usageDate, ok := values[i].(time.Time)
-			if !ok {
-				// It would be very surprising if an unparsable time came back from the API, so it should be ok to return here.
-				return fmt.Errorf("error parsing usage date: %v", values[0])
-			}
-			// start and end will be the day that the usage occurred on
-			s := usageDate
-			e := s.Add(timeutil.Day)
-			window = kubecost.NewWindow(&s, &e)
-		case BillingAccountIDColumnName:
-			invoiceEntityID, ok := values[i].(string)
-			if !ok {
-				log.DedupedErrorf(5, "error parsing GCP CloudCost %s: %v", BillingAccountIDColumnName, values[i])
-				invoiceEntityID = ""
-			}
-			properties.InvoiceEntityID = invoiceEntityID
-		case ProjectIDColumnName:
-			accountID, ok := values[i].(string)
-			if !ok {
-				log.DedupedErrorf(5, "error parsing GCP CloudCost %s: %v", ProjectIDColumnName, values[i])
-				accountID = ""
-			}
-			properties.AccountID = accountID
-		case ServiceDescriptionColumnName:
-			service, ok := values[i].(string)
-			if !ok {
-				log.DedupedErrorf(5, "error parsing GCP CloudCost %s: %v", ServiceDescriptionColumnName, values[i])
-				service = ""
-			}
-			properties.Service = service
-		case SKUDescriptionColumnName:
-			d, ok := values[i].(string)
-			if !ok {
-				log.DedupedErrorf(5, "error parsing GCP CloudCost %s: %v", SKUDescriptionColumnName, values[i])
-				d = ""
-			}
-			description = d
-		case LabelsColumnName:
-			labelJSON, ok := values[i].(string)
-			if !ok {
-				log.DedupedErrorf(5, "error parsing GCP CloudCost %s: %v", LabelsColumnName, values[i])
-			}
-			labelList := []map[string]string{}
-			err := json.Unmarshal([]byte(labelJSON), &labelList)
-			if err != nil {
-				log.Warnf("GCP Cloud Assets: error unmarshaling GCP CloudCost labels: %s", err)
-			}
-			labels := map[string]string{}
-			for _, pair := range labelList {
-				key := pair["key"]
-				value := pair["value"]
-				labels[key] = value
-			}
-			properties.Labels = labels
-		case ResourceNameColumnName:
-			resouceNameValue := values[i]
-			if resouceNameValue == nil {
-				properties.ProviderID = ""
-				continue
-			}
-			resource, ok := resouceNameValue.(string)
-			if !ok {
-				log.DedupedErrorf(5, "error parsing GCP CloudCost %s: %v", ResourceNameColumnName, values[i])
-				properties.ProviderID = ""
-				continue
-			}
-
-			properties.ProviderID = ParseProviderID(resource)
-		case CostColumnName:
-			cost, ok := values[i].(float64)
-			if !ok {
-				log.DedupedErrorf(5, "error parsing GCP CloudCost %s: %v", CostColumnName, values[i])
-				cost = 0.0
-			}
-			listCost = cost
-		case CreditsColumnName:
-			creditSum, ok := values[i].(float64)
-			if !ok {
-				log.DedupedErrorf(5, "error parsing GCP CloudCost %s: %v", CreditsColumnName, values[i])
-				creditSum = 0.0
-			}
-			credits = creditSum
-		default:
-			log.DedupedErrorf(5, "GCP: BigQuery: found unrecognized column name %s", field.Name)
-		}
+// GetFlexibleCUDRates returns a map of FlexibleCUDRates keyed on the start time of the day which those
+// FlexibleCUDRates were derived from.
+func (bqi *BigQueryIntegration) GetFlexibleCUDRates(start time.Time, end time.Time) (map[time.Time]FlexibleCUDRates, error) {
+	costsByDate, err := bqi.queryFlexibleCUDTotalCosts(start, end)
+	if err != nil {
+		return nil, fmt.Errorf("GetFlexibleCUDRates: %w", err)
 	}
 
-	// Check required Fields
-	if window.IsOpen() {
-		return fmt.Errorf("GCP: BigQuery: error parsing, item had invalid window")
+	creditsByDate, err := bqi.queryFlexibleCUDTotalCredits(start, end)
+	if err != nil {
+		return nil, fmt.Errorf("GetFlexibleCUDRates: %w", err)
 	}
 
-	// Determine Category
-	properties.Category = SelectCategory(properties.Service, description)
-
-	// sum credit and cost for NetCost
-	netCost := listCost + credits
-
-	// Using the NetCost as a 'placeholder' for these costs now, until we can revisit and spend the time to do
-	// the calculations correctly
-	amortizedCost := netCost
-	amortizedNetCost := netCost
-	invoicedCost := netCost
+	results := map[time.Time]FlexibleCUDRates{}
+	for date, amountCredited := range creditsByDate {
+		// Protection against divide by zero
+		if amountCredited == 0 {
+			log.Warnf("GetFlexibleCUDRates: 0 value total credit for Flexible CUDs for date %s", date.Format(time.RFC3339))
+			continue
+		}
+		amountPayed, ok := costsByDate[date]
+		if !ok {
+			log.Warnf("GetFlexibleCUDRates: could not find Flexible CUD payments for date %s", date.Format(time.RFC3339))
+			continue
+		}
 
-	// percent k8s is determined by the presence of labels
-	k8sPercent := 0.0
-	if IsK8s(properties.Labels) {
-		k8sPercent = 1.0
-	}
+		// amountPayed itself may have some credits on it so a Rate and a NetRate are created.
+		// Having both allow us to populate AmortizedCost and AmortizedNetCost respectively.
+		results[date] = FlexibleCUDRates{
+			NetRate: (amountPayed.cost + amountPayed.credits) / amountCredited,
+			Rate:    amountPayed.cost / amountCredited,
+		}
 
-	ccl.CloudCost = &kubecost.CloudCost{
-		Properties: &properties,
-		Window:     window,
-		ListCost: kubecost.CostMetric{
-			Cost:              listCost,
-			KubernetesPercent: k8sPercent,
-		},
-		AmortizedCost: kubecost.CostMetric{
-			Cost:              amortizedCost,
-			KubernetesPercent: k8sPercent,
-		},
-		AmortizedNetCost: kubecost.CostMetric{
-			Cost:              amortizedNetCost,
-			KubernetesPercent: k8sPercent,
-		},
-		InvoicedCost: kubecost.CostMetric{
-			Cost:              invoicedCost,
-			KubernetesPercent: k8sPercent,
-		},
-		NetCost: kubecost.CostMetric{
-			Cost:              netCost,
-			KubernetesPercent: k8sPercent,
-		},
 	}
-
-	return nil
+	return results, nil
 }
 
-func IsK8s(labels map[string]string) bool {
-	if _, ok := labels["goog-gke-volume"]; ok {
-		return true
-	}
+func (bqi *BigQueryIntegration) queryFlexibleCUDTotalCosts(start time.Time, end time.Time) (map[time.Time]flexibleCUDCostTotals, error) {
+	queryFmt := `
+		SELECT
+		  TIMESTAMP_TRUNC(usage_start_time, day) as usage_date, 
+		  sum(cost), 
+		  IFNULL(SUM((Select SUM(amount) FROM bd.credits)),0),
+		FROM %s
+		WHERE %s
+		GROUP BY usage_date, sku.description
+	`
 
-	if _, ok := labels["goog-gke-node"]; ok {
-		return true
-	}
+	table := fmt.Sprintf(" `%s` bd ", bqi.GetBillingDataDataset())
+	whereConjuncts := GetWhereConjuncts(start, end)
+	whereConjuncts = append(whereConjuncts, "sku.description like 'Commitment - dollar based v1:%'")
+	whereClause := strings.Join(whereConjuncts, " AND ")
+	query := fmt.Sprintf(queryFmt, table, whereClause)
 
-	if _, ok := labels["goog-k8s-cluster-name"]; ok {
-		return true
+	iter, err := bqi.Query(context.Background(), query)
+	if err != nil {
+		return nil, fmt.Errorf("queryCUDAmountPayed: query error %w", err)
 	}
-
-	return false
-}
-
-var parseProviderIDRx = regexp.MustCompile("^.+\\/(.+)?") // Capture "gke-cluster-3-default-pool-xxxx-yy" from "projects/###/instances/gke-cluster-3-default-pool-xxxx-yy"
-
-func ParseProviderID(id string) string {
-	match := parseProviderIDRx.FindStringSubmatch(id)
-	if len(match) == 0 {
-		return id
+	var loader FlexibleCUDCostTotalsLoader
+	for {
+		err = iter.Next(&loader)
+		if errors.Is(err, iterator.Done) {
+			break
+		}
+		if err != nil {
+			return nil, fmt.Errorf("queryCUDAmountPayed: load error %w", err)
+		}
 	}
-	return match[len(match)-1]
+	return loader.values, nil
 }
 
-func SelectCategory(service, description string) string {
-	s := strings.ToLower(service)
-	d := strings.ToLower(description)
-
-	// Network descriptions
-	if strings.Contains(d, "download") {
-		return kubecost.NetworkCategory
-	}
-	if strings.Contains(d, "network") {
-		return kubecost.NetworkCategory
-	}
-	if strings.Contains(d, "ingress") {
-		return kubecost.NetworkCategory
-	}
-	if strings.Contains(d, "egress") {
-		return kubecost.NetworkCategory
-	}
-	if strings.Contains(d, "static ip") {
-		return kubecost.NetworkCategory
-	}
-	if strings.Contains(d, "external ip") {
-		return kubecost.NetworkCategory
-	}
-	if strings.Contains(d, "load balanced") {
-		return kubecost.NetworkCategory
-	}
-	if strings.Contains(d, "licensing fee") {
-		return kubecost.OtherCategory
-	}
+func (bqi *BigQueryIntegration) queryFlexibleCUDTotalCredits(start time.Time, end time.Time) (map[time.Time]float64, error) {
+	queryFmt := `SELECT
+	TIMESTAMP_TRUNC(usage_start_time, day) as usage_date,
+	sum(credits.amount)
+	FROM %s
+	CROSS JOIN UNNEST(bd.credits) AS credits
+	WHERE %s
+	GROUP BY usage_date, credits.id
+	`
 
-	// Storage Descriptions
-	if strings.Contains(d, "storage") {
-		return kubecost.StorageCategory
-	}
-	if strings.Contains(d, "pd capacity") {
-		return kubecost.StorageCategory
-	}
-	if strings.Contains(d, "pd iops") {
-		return kubecost.StorageCategory
-	}
-	if strings.Contains(d, "pd snapshot") {
-		return kubecost.StorageCategory
-	}
+	table := fmt.Sprintf(" `%s` bd ", bqi.GetBillingDataDataset())
+	whereConjuncts := GetWhereConjuncts(start, end)
+	whereConjuncts = append(whereConjuncts, "credits.type = 'COMMITTED_USAGE_DISCOUNT_DOLLAR_BASE'")
+	whereClause := strings.Join(whereConjuncts, " AND ")
+	query := fmt.Sprintf(queryFmt, table, whereClause)
 
-	// Service Defaults
-	if strings.Contains(s, "storage") {
-		return kubecost.StorageCategory
-	}
-	if strings.Contains(s, "compute") {
-		return kubecost.ComputeCategory
-	}
-	if strings.Contains(s, "sql") {
-		return kubecost.StorageCategory
-	}
-	if strings.Contains(s, "bigquery") {
-		return kubecost.StorageCategory
+	iter, err := bqi.Query(context.Background(), query)
+	if err != nil {
+		return nil, fmt.Errorf("queryFlexibleCUDTotalCredits: query error %w", err)
 	}
-	if strings.Contains(s, "kubernetes") {
-		return kubecost.ManagementCategory
-	} else if strings.Contains(s, "pub/sub") {
-		return kubecost.NetworkCategory
+	var loader FlexibleCUDCreditTotalsLoader
+	for {
+		err = iter.Next(&loader)
+		if errors.Is(err, iterator.Done) {
+			break
+		}
+		if err != nil {
+			return nil, fmt.Errorf("queryFlexibleCUDTotalCredits: load error %w", err)
+		}
 	}
-
-	return kubecost.OtherCategory
+	return loader.values, nil
 }

+ 310 - 0
pkg/cloud/gcp/bigqueryintegration_types.go

@@ -0,0 +1,310 @@
+package gcp
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"cloud.google.com/go/bigquery"
+	"github.com/opencost/opencost/pkg/kubecost"
+	"github.com/opencost/opencost/pkg/log"
+	"github.com/opencost/opencost/pkg/util/json"
+	"github.com/opencost/opencost/pkg/util/timeutil"
+)
+
+type CloudCostLoader struct {
+	CloudCost        *kubecost.CloudCost
+	FlexibleCUDRates map[time.Time]FlexibleCUDRates
+}
+
+// Load populates the fields of a CloudCostValues with bigquery.Value from provided slice
+func (ccl *CloudCostLoader) Load(values []bigquery.Value, schema bigquery.Schema) error {
+
+	// Create Cloud Cost Properties
+	properties := kubecost.CloudCostProperties{
+		Provider: kubecost.GCPProvider,
+	}
+	var window kubecost.Window
+	var description string
+	var cost float64
+	var listCost float64
+	var creditAmount float64
+	var cudCreditAmount float64
+	var flexibleCUDCreditAmount float64
+
+	for i, field := range schema {
+		if field == nil {
+			log.DedupedErrorf(5, "GCP: BigQuery: found nil field in schema")
+			continue
+		}
+
+		// ignore nil values
+		if values[i] == nil {
+			continue
+		}
+
+		switch field.Name {
+		case UsageDateColumnName:
+			usageDate, ok := values[i].(time.Time)
+			if !ok {
+				// It would be very surprising if an unparsable time came back from the API, so it should be ok to return here.
+				return fmt.Errorf("error parsing usage date: %v", values[0])
+			}
+			// start and end will be the day that the usage occurred on
+			s := usageDate
+			e := s.Add(timeutil.Day)
+			window = kubecost.NewClosedWindow(s, e)
+		case BillingAccountIDColumnName:
+			invoiceEntityID, ok := values[i].(string)
+			if !ok {
+				log.DedupedErrorf(5, "error parsing GCP CloudCost %s: %v", BillingAccountIDColumnName, values[i])
+				invoiceEntityID = ""
+			}
+			properties.InvoiceEntityID = invoiceEntityID
+		case ProjectIDColumnName:
+			accountID, ok := values[i].(string)
+			if !ok {
+				log.DedupedErrorf(5, "error parsing GCP CloudCost %s: %v", ProjectIDColumnName, values[i])
+				accountID = ""
+			}
+			properties.AccountID = accountID
+		case ServiceDescriptionColumnName:
+			service, ok := values[i].(string)
+			if !ok {
+				log.DedupedErrorf(5, "error parsing GCP CloudCost %s: %v", ServiceDescriptionColumnName, values[i])
+				service = ""
+			}
+			properties.Service = service
+		case SKUDescriptionColumnName:
+			d, ok := values[i].(string)
+			if !ok {
+				log.DedupedErrorf(5, "error parsing GCP CloudCost %s: %v", SKUDescriptionColumnName, values[i])
+				d = ""
+			}
+			description = d
+		case LabelsColumnName:
+			labelJSON, ok := values[i].(string)
+			if !ok {
+				log.DedupedErrorf(5, "error parsing GCP CloudCost %s: %v", LabelsColumnName, values[i])
+			}
+			labelList := []map[string]string{}
+			err := json.Unmarshal([]byte(labelJSON), &labelList)
+			if err != nil {
+				log.Warnf("GCP Cloud Assets: error unmarshaling GCP CloudCost labels: %s", err)
+			}
+			labels := map[string]string{}
+			for _, pair := range labelList {
+				key := pair["key"]
+				value := pair["value"]
+				labels[key] = value
+			}
+			properties.Labels = labels
+		case ResourceNameColumnName:
+			resouceNameValue := values[i]
+			if resouceNameValue == nil {
+				properties.ProviderID = ""
+				continue
+			}
+			resource, ok := resouceNameValue.(string)
+			if !ok {
+				log.DedupedErrorf(5, "error parsing GCP CloudCost %s: %v", ResourceNameColumnName, values[i])
+				properties.ProviderID = ""
+				continue
+			}
+
+			properties.ProviderID = ParseProviderID(resource)
+		case CostColumnName:
+			costValue, ok := values[i].(float64)
+			if !ok {
+				log.DedupedErrorf(5, "error parsing GCP CloudCost %s: %v", CostColumnName, values[i])
+				costValue = 0.0
+			}
+			cost = costValue
+		case ListCostColumnName:
+			listCostValue, ok := values[i].(float64)
+			if !ok {
+				log.Errorf("error parsing GCP CloudCost %s: %v", ListCostColumnName, values[i])
+				listCostValue = 0
+			}
+			listCost = listCostValue
+		case CreditsColumnName:
+			creditSlice, ok := values[i].([]bigquery.Value)
+			if !ok {
+				log.DedupedErrorf(5, "error parsing GCP CloudCost %s: %v", CreditsColumnName, values[i])
+			}
+			for _, credit := range creditSlice {
+				creditValues, ok := credit.([]bigquery.Value)
+				if !ok {
+					log.DedupedErrorf(5, "error parsing GCP CloudCost credit values: %v", creditValues)
+					continue
+				}
+				amount, ok := creditValues[1].(float64)
+				if !ok {
+					log.DedupedErrorf(5, "error parsing GCP CloudCost credit amount: %v", creditValues[1])
+					continue
+				}
+				creditType, ok := creditValues[4].(string)
+				if !ok {
+					log.DedupedErrorf(5, "error parsing GCP CloudCost credit type: %v", creditValues[4])
+					continue
+				}
+				switch creditType {
+				case "COMMITTED_USAGE_DISCOUNT":
+					cudCreditAmount += amount
+				case "COMMITTED_USAGE_DISCOUNT_DOLLAR_BASE":
+					flexibleCUDCreditAmount += amount
+				default:
+					creditAmount += amount
+				}
+			}
+		default:
+			log.DedupedErrorf(5, "GCP: BigQuery: found unrecognized column name %s", field.Name)
+		}
+	}
+
+	// Check required Fields
+	if window.IsOpen() {
+		return fmt.Errorf("GCP: BigQuery: error parsing, item had invalid window")
+	}
+
+	// Determine amount paid for credit received from Global CUD
+	var flexibleCUDPayedAmount float64
+	var flexibleCUDNetPayedAmount float64
+	if ccl.FlexibleCUDRates != nil {
+		if rates, ok := ccl.FlexibleCUDRates[*window.Start()]; ok {
+			flexibleCUDNetPayedAmount = flexibleCUDCreditAmount * rates.NetRate
+			flexibleCUDPayedAmount = flexibleCUDCreditAmount * rates.Rate
+		}
+	}
+
+	// Determine Category
+	properties.Category = SelectCategory(properties.Service, description)
+
+	// price_at_list is a new column in the billing export which may be nil
+	if listCost == 0.0 {
+		listCost = cost
+	}
+
+	// Net Cost is cost with all credit amounts applied
+	netCost := cost + creditAmount + cudCreditAmount + flexibleCUDCreditAmount
+
+	// Amortized Cost is Cost plus CUD credits and amortized CUD payments
+	amortizedCost := cost + cudCreditAmount + flexibleCUDCreditAmount + flexibleCUDPayedAmount
+
+	// Amortized Net Cost is Cost with all credits and amortized CUD payments
+	amortizedNetCost := cost + creditAmount + cudCreditAmount + flexibleCUDCreditAmount + flexibleCUDNetPayedAmount
+
+	// Using the NetCost as a 'placeholder' for these costs now, until we can revisit and spend the time to do
+	// the calculations correctly
+	invoicedCost := netCost
+
+	// Update Cost for Commitments that will have matching resource id's and should not their non-amortized costs rolled
+	// into values
+	if strings.HasPrefix(description, "Commitment v1") {
+		listCost = 0
+		netCost = 0
+	}
+
+	// Update Cost for Global CUDs to prevent double counting values, which are added in during amortization
+	if strings.HasPrefix(description, "Commitment - dollar based v1:") {
+		amortizedCost = 0
+		amortizedNetCost = 0
+	}
+
+	// percent k8s is determined by the presence of labels
+	k8sPercent := 0.0
+	if IsK8s(properties.Labels) {
+		k8sPercent = 1.0
+	}
+
+	ccl.CloudCost = &kubecost.CloudCost{
+		Properties: &properties,
+		Window:     window,
+		ListCost: kubecost.CostMetric{
+			Cost:              listCost,
+			KubernetesPercent: k8sPercent,
+		},
+		AmortizedCost: kubecost.CostMetric{
+			Cost:              amortizedCost,
+			KubernetesPercent: k8sPercent,
+		},
+		AmortizedNetCost: kubecost.CostMetric{
+			Cost:              amortizedNetCost,
+			KubernetesPercent: k8sPercent,
+		},
+		InvoicedCost: kubecost.CostMetric{
+			Cost:              invoicedCost,
+			KubernetesPercent: k8sPercent,
+		},
+		NetCost: kubecost.CostMetric{
+			Cost:              netCost,
+			KubernetesPercent: k8sPercent,
+		},
+	}
+
+	return nil
+}
+
+type FlexibleCUDCreditTotalsLoader struct {
+	values map[time.Time]float64
+}
+
+func (ctl *FlexibleCUDCreditTotalsLoader) Load(values []bigquery.Value, schema bigquery.Schema) error {
+
+	usageDate, ok := values[0].(time.Time)
+	if !ok {
+		// It would be very surprising if an unparsable time came back from the API, so it should be ok to return here.
+		return fmt.Errorf("error parsing usage date: %v", values[0])
+	}
+
+	amount, ok := values[1].(float64)
+	if !ok {
+		return fmt.Errorf("error parsing amount: %v", values[1])
+	}
+
+	if ctl.values == nil {
+		ctl.values = map[time.Time]float64{}
+	}
+
+	ctl.values[usageDate] = amount
+
+	return nil
+}
+
+type flexibleCUDCostTotals struct {
+	cost    float64
+	credits float64
+}
+
+type FlexibleCUDCostTotalsLoader struct {
+	values map[time.Time]flexibleCUDCostTotals
+}
+
+func (ctl *FlexibleCUDCostTotalsLoader) Load(values []bigquery.Value, schema bigquery.Schema) error {
+	usageDate, ok := values[0].(time.Time)
+	if !ok {
+		// It would be very surprising if an unparsable time came back from the API, so it should be ok to return here.
+		return fmt.Errorf("error parsing usage date: %v", values[0])
+	}
+
+	cost, ok := values[1].(float64)
+	if !ok {
+		return fmt.Errorf("error parsing cost: %v", values[1])
+	}
+
+	credits, ok := values[2].(float64)
+	if !ok {
+		return fmt.Errorf("error parsing credits: %v", values[2])
+	}
+
+	if ctl.values == nil {
+		ctl.values = map[time.Time]flexibleCUDCostTotals{}
+	}
+
+	ctl.values[usageDate] = flexibleCUDCostTotals{
+		cost:    cost,
+		credits: credits,
+	}
+
+	return nil
+}

+ 19 - 3
pkg/cloud/gcp/bigqueryquerier.go

@@ -2,10 +2,10 @@ package gcp
 
 import (
 	"context"
+	"fmt"
 
 	"cloud.google.com/go/bigquery"
 	"github.com/opencost/opencost/pkg/cloud"
-	cloudconfig "github.com/opencost/opencost/pkg/cloud/config"
 )
 
 type BigQueryQuerier struct {
@@ -14,10 +14,14 @@ type BigQueryQuerier struct {
 }
 
 func (bqq *BigQueryQuerier) GetStatus() cloud.ConnectionStatus {
+	// initialize status if it has not done so; this can happen if the integration is inactive
+	if bqq.ConnectionStatus.String() == "" {
+		bqq.ConnectionStatus = cloud.InitialStatus
+	}
 	return bqq.ConnectionStatus
 }
 
-func (bqq *BigQueryQuerier) Equals(config cloudconfig.Config) bool {
+func (bqq *BigQueryQuerier) Equals(config cloud.Config) bool {
 	thatConfig, ok := config.(*BigQueryQuerier)
 	if !ok {
 		return false
@@ -41,5 +45,17 @@ func (bqq *BigQueryQuerier) Query(ctx context.Context, queryStr string) (*bigque
 	}
 
 	query := client.Query(queryStr)
-	return query.Read(ctx)
+	iter, err := query.Read(ctx)
+
+	// If result is empty and connection status is not already successful update status to missing data
+	if iter == nil && bqq.ConnectionStatus != cloud.SuccessfulConnection {
+		bqq.ConnectionStatus = cloud.MissingData
+	} else {
+		bqq.ConnectionStatus = cloud.SuccessfulConnection
+	}
+
+	if err != nil {
+		return iter, fmt.Errorf("BigQueryQuerier: Query: error reading query results: %w", err)
+	}
+	return iter, nil
 }

+ 100 - 0
pkg/cloud/gcp/cloudcost.go

@@ -0,0 +1,100 @@
+package gcp
+
+import (
+	"regexp"
+	"strings"
+
+	"github.com/opencost/opencost/pkg/kubecost"
+)
+
+func IsK8s(labels map[string]string) bool {
+	if _, ok := labels["goog-gke-volume"]; ok {
+		return true
+	}
+
+	if _, ok := labels["goog-gke-node"]; ok {
+		return true
+	}
+
+	if _, ok := labels["goog-k8s-cluster-name"]; ok {
+		return true
+	}
+
+	return false
+}
+
+var parseProviderIDRx = regexp.MustCompile("^.+\\/(.+)?") // Capture "gke-cluster-3-default-pool-xxxx-yy" from "projects/###/instances/gke-cluster-3-default-pool-xxxx-yy"
+
+func ParseProviderID(id string) string {
+	match := parseProviderIDRx.FindStringSubmatch(id)
+	if len(match) == 0 {
+		return id
+	}
+	return match[len(match)-1]
+}
+
+func SelectCategory(service, description string) string {
+	s := strings.ToLower(service)
+	d := strings.ToLower(description)
+
+	// Network descriptions
+	if strings.Contains(d, "download") {
+		return kubecost.NetworkCategory
+	}
+	if strings.Contains(d, "network") {
+		return kubecost.NetworkCategory
+	}
+	if strings.Contains(d, "ingress") {
+		return kubecost.NetworkCategory
+	}
+	if strings.Contains(d, "egress") {
+		return kubecost.NetworkCategory
+	}
+	if strings.Contains(d, "static ip") {
+		return kubecost.NetworkCategory
+	}
+	if strings.Contains(d, "external ip") {
+		return kubecost.NetworkCategory
+	}
+	if strings.Contains(d, "load balanced") {
+		return kubecost.NetworkCategory
+	}
+	if strings.Contains(d, "licensing fee") {
+		return kubecost.OtherCategory
+	}
+
+	// Storage Descriptions
+	if strings.Contains(d, "storage") {
+		return kubecost.StorageCategory
+	}
+	if strings.Contains(d, "pd capacity") {
+		return kubecost.StorageCategory
+	}
+	if strings.Contains(d, "pd iops") {
+		return kubecost.StorageCategory
+	}
+	if strings.Contains(d, "pd snapshot") {
+		return kubecost.StorageCategory
+	}
+
+	// Service Defaults
+	if strings.Contains(s, "storage") {
+		return kubecost.StorageCategory
+	}
+	if strings.Contains(s, "compute") {
+		return kubecost.ComputeCategory
+	}
+	if strings.Contains(s, "sql") {
+		return kubecost.StorageCategory
+	}
+	if strings.Contains(s, "bigquery") {
+		return kubecost.StorageCategory
+	}
+	if strings.Contains(s, "kubernetes") {
+		return kubecost.ManagementCategory
+	} else if strings.Contains(s, "pub/sub") {
+		return kubecost.NetworkCategory
+	}
+
+	return kubecost.OtherCategory
+}

+ 15 - 3
pkg/cloud/gcp/provider.go

@@ -72,6 +72,7 @@ var gcpRegions = []string{
 	"europe-west3",
 	"europe-west4",
 	"europe-west6",
+	"europe-west9",
 	"northamerica-northeast1",
 	"northamerica-northeast2",
 	"southamerica-east1",
@@ -85,7 +86,8 @@ var gcpRegions = []string{
 }
 
 var (
-	nvidiaGPURegex = regexp.MustCompile("(Nvidia Tesla [^ ]+) ")
+	nvidiaTeslaGPURegex = regexp.MustCompile("(Nvidia Tesla [^ ]+) ")
+	nvidiaGPURegex      = regexp.MustCompile("(Nvidia [^ ]+) ")
 	// gce://guestbook-12345/...
 	//  => guestbook-12345
 	gceRegex = regexp.MustCompile("gce://([^/]*)/*")
@@ -771,13 +773,23 @@ func (gcp *GCP) parsePage(r io.Reader, inputKeys map[string]models.Key, pvKeys m
 				}
 
 				var gpuType string
-				for matchnum, group := range nvidiaGPURegex.FindStringSubmatch(product.Description) {
+				for matchnum, group := range nvidiaTeslaGPURegex.FindStringSubmatch(product.Description) {
 					if matchnum == 1 {
 						gpuType = strings.ToLower(strings.Join(strings.Split(group, " "), "-"))
 						log.Debugf("GCP Billing API: GPU type found: '%s'", gpuType)
 					}
 				}
 
+				// If a 'Nvidia Tesla' is not found, try 'Nvidia'
+				if gpuType == "" {
+					for matchnum, group := range nvidiaGPURegex.FindStringSubmatch(product.Description) {
+						if matchnum == 1 {
+							gpuType = strings.ToLower(strings.Join(strings.Split(group, " "), "-"))
+							log.Debugf("GCP Billing API: GPU type found: '%s'", gpuType)
+						}
+					}
+				}
+
 				candidateKeys := []string{}
 				if gcp.ValidPricingKeys == nil {
 					gcp.ValidPricingKeys = make(map[string]bool)
@@ -794,6 +806,7 @@ func (gcp *GCP) parsePage(r io.Reader, inputKeys map[string]models.Key, pvKeys m
 					case "a2":
 						candidateKeys = append(candidateKeys, region+","+"a2highgpu"+","+usageType)
 						candidateKeys = append(candidateKeys, region+","+"a2megagpu"+","+usageType)
+						candidateKeys = append(candidateKeys, region+","+"a2ultragpu"+","+usageType)
 					default:
 						candidateKey := region + "," + instanceType + "," + usageType
 						candidateKeys = append(candidateKeys, candidateKey)
@@ -983,7 +996,6 @@ func (gcp *GCP) parsePages(inputKeys map[string]models.Key, pvKeys map[string]mo
 
 	url := gcp.getBillingAPIURL(gcp.APIKey, c.CurrencyCode)
 
-	log.Infof("Fetch GCP Billing Data from URL: %s", url)
 	var parsePagesHelper func(string) error
 	parsePagesHelper = func(pageToken string) error {
 		if pageToken == "done" {

+ 28 - 0
pkg/cloud/provider/providerconfig.go

@@ -7,6 +7,10 @@ import (
 	"strconv"
 	"sync"
 
+	"github.com/opencost/opencost/pkg/cloud/alibaba"
+	"github.com/opencost/opencost/pkg/cloud/aws"
+	"github.com/opencost/opencost/pkg/cloud/azure"
+	"github.com/opencost/opencost/pkg/cloud/gcp"
 	"github.com/opencost/opencost/pkg/cloud/models"
 	"github.com/opencost/opencost/pkg/cloud/utils"
 	"github.com/opencost/opencost/pkg/config"
@@ -294,3 +298,27 @@ func ReturnPricingFromConfigs(filename string) (*models.CustomPricing, error) {
 	}
 	return defaultPricing, nil
 }
+
+func ExtractConfigFromProviders(prov models.Provider) models.ProviderConfig {
+	if prov == nil {
+		log.Errorf("cannot extract config from nil provider")
+		return nil
+	}
+	switch p := prov.(type) {
+	case *CSVProvider:
+		return ExtractConfigFromProviders(p.CustomProvider)
+	case *CustomProvider:
+		return p.Config
+	case *gcp.GCP:
+		return p.Config
+	case *aws.AWS:
+		return p.Config
+	case *azure.Azure:
+		return p.Config
+	case *alibaba.Alibaba:
+		return p.Config
+	default:
+		log.Errorf("failed to extract config from provider")
+		return nil
+	}
+}

+ 1 - 1
pkg/cloud/scaleway/provider.go

@@ -149,7 +149,7 @@ func (c *Scaleway) NodePricing(key models.Key) (*models.Node, models.PricingMeta
 				RAM:         fmt.Sprintf("%d", info.RAM),
 				// This is tricky, as instances can have local volumes or not
 				Storage:      fmt.Sprintf("%d", info.PerVolumeConstraint.LSSD.MinSize),
-				GPU:          fmt.Sprintf("%d", info.Gpu),
+				GPU:          fmt.Sprintf("%d", *info.Gpu),
 				InstanceType: split[1],
 				Region:       split[0],
 				GPUName:      key.GPUType(),

+ 207 - 0
pkg/cloudcost/ingestionmanager.go

@@ -0,0 +1,207 @@
+package cloudcost
+
+import (
+	"fmt"
+	"sync"
+	"time"
+
+	"github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/kubecost"
+	"github.com/opencost/opencost/pkg/log"
+)
+
+// IngestionManager is a config.Observer which creates Ingestor instances based on the signals that it receives from the
+// config.Controller
+type IngestionManager struct {
+	lock      sync.Mutex
+	ingestors map[string]*ingestor
+	config    IngestorConfig
+	repo      Repository
+}
+
+// NewIngestionManager creates a new IngestionManager and registers it with the provided integration controller
+func NewIngestionManager(controller *config.Controller, repo Repository, ingConf IngestorConfig) *IngestionManager {
+	// return empty ingestion manager if store or integration controller are nil
+	if controller == nil || repo == nil {
+		return &IngestionManager{
+			ingestors: map[string]*ingestor{},
+		}
+	}
+
+	im := &IngestionManager{
+		ingestors: map[string]*ingestor{},
+		repo:      repo,
+		config:    ingConf,
+	}
+	controller.RegisterObserver(im)
+
+	return im
+}
+
+// PutConfig is an imperative function which puts an ingestor for the provided Integration
+func (im *IngestionManager) PutConfig(kc cloud.KeyedConfig) {
+	im.lock.Lock()
+	defer im.lock.Unlock()
+	err := im.createIngestor(kc)
+	if err != nil {
+		log.Errorf("IngestionManager: PutConfig failed to create billing integration: %s", err.Error())
+	}
+}
+
+// DeleteConfig is an imperative function which removes an ingestor with a matching key
+func (im *IngestionManager) DeleteConfig(key string) {
+	im.lock.Lock()
+	defer im.lock.Unlock()
+	im.deleteIngestor(key)
+}
+
+// SetConfigs is a declarative function for setting which BillingIntegrations IngestionManager should have ingestors for
+func (im *IngestionManager) SetConfigs(configs map[string]cloud.KeyedConfig) {
+	im.lock.Lock()
+	defer im.lock.Unlock()
+	// delete any exiting ingestors
+	for key, _ := range im.ingestors {
+		im.deleteIngestor(key)
+	}
+	// create  ingestors for provided
+	for _, conf := range configs {
+		err := im.createIngestor(conf)
+		if err != nil {
+			log.Errorf("IngestionManager: error creating ingestor: %s", err.Error())
+		}
+	}
+}
+
+func (im *IngestionManager) StartAll() {
+	im.lock.Lock()
+	defer im.lock.Unlock()
+	var wg sync.WaitGroup
+	wg.Add(len(im.ingestors))
+	for key := range im.ingestors {
+		ing := im.ingestors[key]
+		go func() {
+			defer wg.Done()
+			ing.Start(false)
+
+		}()
+	}
+	wg.Wait()
+}
+
+func (im *IngestionManager) StopAll() {
+	im.lock.Lock()
+	defer im.lock.Unlock()
+	var wg sync.WaitGroup
+	wg.Add(len(im.ingestors))
+	for key := range im.ingestors {
+		ing := im.ingestors[key]
+		go func() {
+			defer wg.Done()
+			ing.Stop()
+		}()
+	}
+	wg.Wait()
+}
+
+func (im *IngestionManager) RebuildAll() {
+	im.lock.Lock()
+	defer im.lock.Unlock()
+	var wg sync.WaitGroup
+	wg.Add(len(im.ingestors))
+	for key := range im.ingestors {
+		go func(ing *ingestor) {
+			defer wg.Done()
+			ing.Stop()
+			ing.Start(true)
+
+		}(im.ingestors[key])
+	}
+	wg.Wait()
+}
+
+func (im *IngestionManager) Rebuild(integrationKey string) error {
+	im.lock.Lock()
+	defer im.lock.Unlock()
+	ing, ok := im.ingestors[integrationKey]
+	if !ok {
+		return fmt.Errorf("CloudCost: IngestionManager: Rebuild: failed to rebuild, integration with key does not exist: %s", integrationKey)
+	}
+	ing.Stop()
+	ing.Start(true)
+	return nil
+}
+
+func (im *IngestionManager) RepairAll(start, end time.Time) error {
+	im.lock.Lock()
+	defer im.lock.Unlock()
+	s := kubecost.RoundForward(start, im.config.Resolution)
+	e := kubecost.RoundForward(end, im.config.Resolution)
+	windows, err := kubecost.GetWindowsForQueryWindow(s, e, im.config.QueryWindow)
+	if err != nil {
+		return fmt.Errorf("CloudCost: IngestionManager: Repair could not retrieve windows: %s", err.Error())
+	}
+
+	for key := range im.ingestors {
+		go func(ing *ingestor) {
+			for _, window := range windows {
+				ing.BuildWindow(*window.Start(), *window.End())
+			}
+		}(im.ingestors[key])
+	}
+
+	return nil
+}
+
+func (im *IngestionManager) Repair(integrationKey string, start, end time.Time) error {
+	im.lock.Lock()
+	defer im.lock.Unlock()
+	s := kubecost.RoundForward(start, im.config.Resolution)
+	e := kubecost.RoundForward(end, im.config.Resolution)
+	windows, err := kubecost.GetWindowsForQueryWindow(s, e, im.config.QueryWindow)
+	if err != nil {
+		return fmt.Errorf("CloudCost: IngestionManager: Repair could not retrieve windows: %s", err.Error())
+	}
+	ing, ok := im.ingestors[integrationKey]
+	if !ok {
+		return fmt.Errorf("CloudCost: IngestionManager: Repair: failed to rebuild, integration with key does not exist: %s", integrationKey)
+	}
+	go func(ing *ingestor) {
+		for _, window := range windows {
+			ing.BuildWindow(*window.Start(), *window.End())
+		}
+	}(ing)
+	return nil
+}
+
+// deleteIngestor stops then removes an ingestor from the map of ingestors
+func (im *IngestionManager) deleteIngestor(integrationKey string) {
+	ing, ok := im.ingestors[integrationKey]
+	if !ok {
+		return
+	}
+	log.Infof("CloudCost: IngestionManager: deleting integration with key: %s", integrationKey)
+	ing.Stop()
+
+	delete(im.ingestors, integrationKey)
+}
+
+// createIngestor stops existing ingestor with matching key then creates and starts and new ingestor
+func (im *IngestionManager) createIngestor(config cloud.KeyedConfig) error {
+	if config == nil {
+		return fmt.Errorf("cannot create ingestor from nil integration")
+	}
+	// delete ingestor with matching key if it exists
+	im.deleteIngestor(config.Key())
+	log.Infof("CloudCost: IngestionManager: creating integration with key: %s", config.Key())
+	ing, err := NewIngestor(im.config, im.repo, config)
+	if err != nil {
+		return fmt.Errorf("IngestionManager: createIngestor: %w", err)
+	}
+
+	ing.Start(false)
+
+	im.ingestors[config.Key()] = ing
+
+	return nil
+}

+ 342 - 0
pkg/cloudcost/ingestor.go

@@ -0,0 +1,342 @@
+package cloudcost
+
+import (
+	"fmt"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/env"
+	"github.com/opencost/opencost/pkg/errors"
+	"github.com/opencost/opencost/pkg/kubecost"
+	"github.com/opencost/opencost/pkg/log"
+	"github.com/opencost/opencost/pkg/util/stringutil"
+	"github.com/opencost/opencost/pkg/util/timeutil"
+)
+
+// IngestorStatus includes diagnostic values for a given Ingestor
+type IngestorStatus struct {
+	Created          time.Time
+	LastRun          time.Time
+	NextRun          time.Time
+	Runs             int
+	Coverage         kubecost.Window
+	ConnectionStatus cloud.ConnectionStatus
+}
+
+// IngestorConfig is a configuration struct for an Ingestor
+type IngestorConfig struct {
+	MonthToDateRunInterval int
+	RefreshRate            time.Duration
+	Resolution             time.Duration
+	Duration               time.Duration
+	QueryWindow            time.Duration
+	RunWindow              time.Duration
+}
+
+// DefaultIngestorConfiguration retrieves an IngestorConfig from env variables
+func DefaultIngestorConfiguration() IngestorConfig {
+	return IngestorConfig{
+		Resolution:             timeutil.Day,
+		Duration:               timeutil.Day * time.Duration(env.GetDataRetentionDailyResolutionDays()),
+		MonthToDateRunInterval: env.GetCloudCostMonthToDateInterval(),
+		RefreshRate:            time.Hour * time.Duration(env.GetCloudCostRefreshRateHours()),
+		QueryWindow:            timeutil.Day * time.Duration(env.GetCloudCostQueryWindowDays()),
+		RunWindow:              timeutil.Day * time.Duration(env.GetCloudCostRunWindowDays()),
+	}
+}
+
+// ingestor runs the process for ingesting CloudCost from its CloudCostIntegration and store it in a Repository
+type ingestor struct {
+	key          string
+	integration  CloudCostIntegration
+	config       IngestorConfig
+	repo         Repository
+	runID        string
+	lastRun      time.Time
+	runs         int
+	creationTime time.Time
+	coverage     kubecost.Window
+	coverageLock sync.Mutex
+	isRunning    atomic.Bool
+	isStopping   atomic.Bool
+	exitBuildCh  chan string
+	exitRunCh    chan string
+}
+
+// NewIngestor is an initializer for ingestor
+func NewIngestor(ingestorConfig IngestorConfig, repo Repository, config cloud.KeyedConfig) (*ingestor, error) {
+	if repo == nil {
+		return nil, fmt.Errorf("CloudCost: NewIngestor: repository connot be nil")
+	}
+	if config == nil {
+		return nil, fmt.Errorf("CloudCost: NewIngestor: integration connot be nil")
+	}
+	cci := GetIntegrationFromConfig(config)
+	if cci == nil {
+		return nil, fmt.Errorf("CloudCost: NewIngestor: provider integration config was not a valid type: %T", config)
+	}
+	now := time.Now().UTC()
+	midnight := kubecost.RoundForward(now, timeutil.Day)
+	return &ingestor{
+		config:       ingestorConfig,
+		repo:         repo,
+		key:          config.Key(),
+		integration:  cci,
+		creationTime: now,
+		lastRun:      now,
+		coverage:     kubecost.NewClosedWindow(midnight, midnight),
+	}, nil
+}
+
+func (ing *ingestor) LoadWindow(start, end time.Time) {
+	windows, err := kubecost.GetWindows(start, end, timeutil.Day)
+	if err != nil {
+		log.Errorf("CloudCost[%s]: ingestor: invalid window %s", ing.key, kubecost.NewWindow(&start, &end))
+		return
+	}
+
+	for _, window := range windows {
+		has, err2 := ing.repo.Has(*window.Start(), ing.key)
+		if err2 != nil {
+			log.Errorf("CloudCost[%s]: ingestor: error when loading window: %s", ing.key, err2.Error())
+		}
+		if !has {
+			ing.BuildWindow(start, end)
+			return
+		}
+		ing.expandCoverage(window)
+		log.Debugf("CloudCost[%s]: ingestor: skipping build for window %s, coverage already exists", ing.key, window.String())
+	}
+
+}
+
+func (ing *ingestor) BuildWindow(start, end time.Time) {
+	log.Infof("CloudCost[%s]: ingestor: building window %s", ing.key, kubecost.NewWindow(&start, &end))
+	ccsr, err := ing.integration.GetCloudCost(start, end)
+	if err != nil {
+		log.Errorf("CloudCost[%s]: ingestor: build failed for window %s: %s", ing.key, kubecost.NewWindow(&start, &end), err.Error())
+		return
+	}
+	for _, ccs := range ccsr.CloudCostSets {
+		log.Debugf("BuildWindow[%s]: GetCloudCost: writing cloud costs for window %s: %d", ccs.Integration, ccs.Window, len(ccs.CloudCosts))
+		err2 := ing.repo.Put(ccs)
+		if err2 != nil {
+			log.Errorf("CloudCost[%s]: ingestor: failed to save Cloud Cost Set with window %s: %s", ing.key, ccs.GetWindow().String(), err2.Error())
+		}
+		ing.expandCoverage(ccs.Window)
+	}
+}
+
+func (ing *ingestor) Start(rebuild bool) {
+
+	// If already running, log that and return.
+	if !ing.isRunning.CompareAndSwap(false, true) {
+		log.Infof("CloudCost: ingestor: is already running")
+		return
+	}
+
+	ing.runID = stringutil.RandSeq(5)
+
+	ing.exitBuildCh = make(chan string)
+	ing.exitRunCh = make(chan string)
+
+	// Build the store once, advancing backward in time from the earliest
+	// point of coverage.
+	go ing.build(rebuild)
+
+	go ing.run()
+}
+
+func (ing *ingestor) Stop() {
+	// If already stopping, log that and return.
+	if !ing.isStopping.CompareAndSwap(false, true) {
+		log.Infof("CloudCost: ingestor: is already stopping")
+		return
+	}
+
+	msg := "Stopping"
+
+	// If the processes are running (and thus there are channels available for
+	// stopping them) then stop all sub-processes (i.e. build and run)
+	var wg sync.WaitGroup
+
+	if ing.exitBuildCh != nil {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			ing.exitBuildCh <- msg
+		}()
+	}
+
+	if ing.exitRunCh != nil {
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			ing.exitRunCh <- msg
+		}()
+	}
+
+	wg.Wait()
+
+	// Declare that the store is officially no longer running. This allows
+	// Start to be called again, restarting the store from scratch.
+	ing.isRunning.Store(false)
+	ing.isStopping.Store(false)
+}
+
+// Status returns an IngestorStatus that describes the current state of the ingestor
+func (ing *ingestor) Status() IngestorStatus {
+	return IngestorStatus{
+		Created:          ing.creationTime,
+		LastRun:          ing.lastRun,
+		NextRun:          ing.lastRun.Add(ing.config.RefreshRate).UTC(),
+		Runs:             ing.runs,
+		Coverage:         ing.coverage,
+		ConnectionStatus: ing.integration.GetStatus(),
+	}
+}
+
+func (ing *ingestor) build(rebuild bool) {
+	defer errors.HandlePanic()
+
+	// Profile the full Duration of the build time
+	buildStart := time.Now()
+
+	// Build as far back as the configures build Duration
+	limit := kubecost.RoundBack(time.Now().UTC().Add(-ing.config.Duration), ing.config.Resolution)
+
+	queryWindowStr := timeutil.FormatStoreResolution(ing.config.QueryWindow)
+	log.Infof("CloudCost[%s]: ingestor: build[%s]: Starting build back to %s in blocks of %s", ing.key, ing.runID, limit.String(), queryWindowStr)
+
+	// Start with a window of the configured Duration and ending on the given
+	// start time. Build windows repeating until the window reaches the
+	// given limit time
+
+	// Round end times back to nearest Resolution points in the past,
+	// querying for exactly one interval
+	e := kubecost.RoundBack(time.Now().UTC(), ing.config.Resolution)
+	s := e.Add(-ing.config.QueryWindow)
+
+	// Continue until limit is reached
+	for limit.Before(e) {
+		// If exit instruction is received, log and return
+		select {
+		case <-ing.exitBuildCh:
+			log.Debugf("CloudCost[%s]: ingestor: build[%s]: exiting", ing.key, ing.runID)
+			return
+		default:
+		}
+
+		// Profile the current build step
+		stepStart := time.Now()
+
+		// if rebuild is not specified then check for existing coverage on window
+		if rebuild {
+			ing.BuildWindow(s, e)
+		} else {
+			ing.LoadWindow(s, e)
+		}
+
+		log.Infof("CloudCost[%s]: ingestor: build[%s]:  %s in %v", ing.key, ing.runID, kubecost.NewClosedWindow(s, e), time.Since(stepStart))
+
+		// Shift to next QueryWindow
+		s = s.Add(-ing.config.QueryWindow)
+		if s.Before(limit) {
+			s = limit
+		}
+		e = e.Add(-ing.config.QueryWindow)
+	}
+
+	log.Infof(fmt.Sprintf("CloudCost[%s]: ingestor: build[%s]: completed in %v", ing.key, ing.runID, time.Since(buildStart)))
+
+	// In order to be able to Stop, we have to wait on an exit message
+	// here
+	<-ing.exitBuildCh
+
+}
+
+func (ing *ingestor) run() {
+	defer errors.HandlePanic()
+
+	ticker := timeutil.NewJobTicker()
+	defer ticker.Close()
+	ticker.TickIn(0)
+
+	for {
+		// If an exit instruction is received, break the run loop
+		select {
+		case <-ing.exitRunCh:
+			log.Debugf("CloudCost[%s]: ingestor: Run[%s] exiting", ing.key, ing.runID)
+			return
+		case <-ticker.Ch:
+			// Wait for next tick
+		}
+
+		// Start from the last covered time, minus the RunWindow
+		start := ing.lastRun
+		start = start.Add(-ing.config.RunWindow)
+
+		// Every Nth (determined by the MonthToDateRunInterval) run should be a month to date run. Where the start is
+		// truncated to the beginning of its current month this can mean that early in a new month we will build all of
+		// last month and the first few days of the current month.
+		if ing.runs%ing.config.MonthToDateRunInterval == 0 {
+			start = time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC)
+			log.Infof("CloudCost[%s]: ingestor: Run[%s]: running month-to-date update starting at %s", ing.key, ing.runID, start.String())
+		}
+
+		// Round start time back to the nearest Resolution point in the past from the
+		// last update to the QueryWindow
+		s := kubecost.RoundBack(start.UTC(), ing.config.Resolution)
+		e := s.Add(ing.config.QueryWindow)
+
+		// Start with a window of the configured Duration and starting on the given
+		// start time. Do the following, repeating until the window reaches the
+		// current time:
+		// 1. Instruct builder to build window
+		// 2. Move window forward one Resolution
+		for time.Now().After(s) {
+			profStart := time.Now()
+			ing.BuildWindow(s, e)
+
+			log.Debugf("CloudCost[%s]: ingestor: Run[%s]: completed %s in %v", ing.key, ing.runID, kubecost.NewWindow(&s, &e), time.Since(profStart))
+
+			s = s.Add(ing.config.QueryWindow)
+			e = e.Add(ing.config.QueryWindow)
+			// prevent builds into the future
+			if e.After(time.Now().UTC()) {
+				e = kubecost.RoundForward(time.Now().UTC(), ing.config.Resolution)
+			}
+
+		}
+		ing.lastRun = time.Now().UTC()
+
+		limit := kubecost.RoundBack(time.Now().UTC(), ing.config.Resolution).Add(-ing.config.Duration)
+		err := ing.repo.Expire(limit)
+		if err != nil {
+			log.Errorf("CloudCost: Ingestor: failed to expire Data: %s", err)
+		}
+
+		ing.coverageLock.Lock()
+		ing.coverage = ing.coverage.ContractStart(limit)
+		ing.coverageLock.Unlock()
+
+		ing.runs++
+
+		ticker.TickIn(ing.config.RefreshRate)
+	}
+}
+
+func (ing *ingestor) expandCoverage(window kubecost.Window) {
+	if window.IsOpen() {
+		return
+	}
+	ing.coverageLock.Lock()
+	defer ing.coverageLock.Unlock()
+
+	coverage := ing.coverage.ExpandStart(*window.Start())
+	coverage = coverage.ExpandEnd(*window.End())
+
+	ing.coverage = coverage
+}

+ 96 - 0
pkg/cloudcost/integration.go

@@ -0,0 +1,96 @@
+package cloudcost
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/cloud/alibaba"
+	"github.com/opencost/opencost/pkg/cloud/aws"
+	"github.com/opencost/opencost/pkg/cloud/azure"
+	"github.com/opencost/opencost/pkg/cloud/gcp"
+	"github.com/opencost/opencost/pkg/kubecost"
+)
+
+// CloudCostIntegration is an interface for retrieving daily granularity CloudCost data for a given range
+type CloudCostIntegration interface {
+	GetCloudCost(time.Time, time.Time) (*kubecost.CloudCostSetRange, error)
+	GetStatus() cloud.ConnectionStatus
+}
+
+// GetIntegrationFromConfig coverts any valid KeyedConfig into the appropriate BillingIntegration if possible
+func GetIntegrationFromConfig(kc cloud.KeyedConfig) CloudCostIntegration {
+	switch keyedConfig := kc.(type) {
+	// AthenaIntegration
+	case *aws.AthenaConfiguration:
+		return &aws.AthenaIntegration{
+			AthenaQuerier: aws.AthenaQuerier{
+				AthenaConfiguration: *keyedConfig,
+			},
+		}
+	case *aws.AthenaQuerier:
+		return &aws.AthenaIntegration{
+			AthenaQuerier: *keyedConfig,
+		}
+	case *aws.AthenaIntegration:
+		return keyedConfig
+	// BigQueryIntegration
+	case *gcp.BigQueryConfiguration:
+		return &gcp.BigQueryIntegration{
+			BigQueryQuerier: gcp.BigQueryQuerier{
+				BigQueryConfiguration: *keyedConfig,
+			},
+		}
+	case *gcp.BigQueryQuerier:
+		return &gcp.BigQueryIntegration{
+			BigQueryQuerier: *keyedConfig,
+		}
+	case *gcp.BigQueryIntegration:
+		return keyedConfig
+	// AzureStorageIntegration
+	case *azure.StorageConfiguration:
+		return &azure.AzureStorageIntegration{
+			AzureStorageBillingParser: azure.AzureStorageBillingParser{
+				StorageConnection: azure.StorageConnection{
+					StorageConfiguration: *keyedConfig},
+			},
+		}
+	case *azure.StorageConnection:
+		return &azure.AzureStorageIntegration{
+			AzureStorageBillingParser: azure.AzureStorageBillingParser{
+				StorageConnection: *keyedConfig,
+			},
+		}
+	case *azure.AzureStorageBillingParser:
+		return &azure.AzureStorageIntegration{
+			AzureStorageBillingParser: *keyedConfig,
+		}
+	case *azure.AzureStorageIntegration:
+		return keyedConfig
+	// S3SelectIntegration
+	case *aws.S3Configuration:
+		return &aws.S3SelectIntegration{
+			S3SelectQuerier: aws.S3SelectQuerier{
+				S3Connection: aws.S3Connection{
+					S3Configuration: *keyedConfig,
+				},
+			},
+		}
+	case *aws.S3Connection:
+		return &aws.S3SelectIntegration{
+			S3SelectQuerier: aws.S3SelectQuerier{
+				S3Connection: *keyedConfig,
+			},
+		}
+	case *aws.S3SelectQuerier:
+		return &aws.S3SelectIntegration{
+			S3SelectQuerier: *keyedConfig,
+		}
+	case *aws.S3SelectIntegration:
+		return keyedConfig
+	// Alibaba BOA Integration
+	case *alibaba.BOAConfiguration:
+		return nil
+	default:
+		return nil
+	}
+}

+ 103 - 0
pkg/cloudcost/memoryrepository.go

@@ -0,0 +1,103 @@
+package cloudcost
+
+import (
+	"fmt"
+	"sync"
+	"time"
+
+	"github.com/opencost/opencost/pkg/kubecost"
+	"golang.org/x/exp/maps"
+)
+
+// MemoryRepository is an implementation of Repository that uses a map keyed on config key and window start along with a
+// RWMutex to make it threadsafe
+type MemoryRepository struct {
+	rwLock sync.RWMutex
+	data   map[string]map[time.Time]*kubecost.CloudCostSet
+}
+
+func NewMemoryRepository() *MemoryRepository {
+	return &MemoryRepository{
+		data: make(map[string]map[time.Time]*kubecost.CloudCostSet),
+	}
+}
+
+func (m *MemoryRepository) Has(startTime time.Time, billingIntegration string) (bool, error) {
+	m.rwLock.RLock()
+	defer m.rwLock.RUnlock()
+
+	billingIntegrationData, ok := m.data[billingIntegration]
+	if !ok {
+		return false, nil
+	}
+
+	_, ook := billingIntegrationData[startTime.UTC()]
+	return ook, nil
+}
+
+func (m *MemoryRepository) Get(startTime time.Time, billingIntegration string) (*kubecost.CloudCostSet, error) {
+	m.rwLock.RLock()
+	defer m.rwLock.RUnlock()
+
+	billingIntegrationData, ok := m.data[billingIntegration]
+	if !ok {
+		return nil, nil
+	}
+
+	ccs, ook := billingIntegrationData[startTime.UTC()]
+	if !ook {
+		return nil, nil
+	}
+	return ccs.Clone(), nil
+}
+
+func (m *MemoryRepository) Keys() ([]string, error) {
+	m.rwLock.RLock()
+	defer m.rwLock.RUnlock()
+
+	keys := maps.Keys(m.data)
+	return keys, nil
+}
+
+func (m *MemoryRepository) Put(ccs *kubecost.CloudCostSet) error {
+	m.rwLock.Lock()
+	defer m.rwLock.Unlock()
+
+	if ccs == nil {
+		return fmt.Errorf("MemoryRepository: Put: cannot save nil")
+	}
+
+	if ccs.Window.IsOpen() {
+		return fmt.Errorf("MemoryRepository: Put: cloud cost set has invalid window %s", ccs.Window.String())
+	}
+
+	if ccs.Integration == "" {
+		return fmt.Errorf("MemoryRepository: Put: cloud cost set does not have an integration value")
+	}
+
+	if _, ok := m.data[ccs.Integration]; !ok {
+		m.data[ccs.Integration] = make(map[time.Time]*kubecost.CloudCostSet)
+	}
+
+	m.data[ccs.Integration][ccs.Window.Start().UTC()] = ccs
+	return nil
+}
+
+// Expire deletes all items in the map with a start time before the given limit
+func (m *MemoryRepository) Expire(limit time.Time) error {
+	m.rwLock.Lock()
+	defer m.rwLock.Unlock()
+
+	for key, integration := range m.data {
+		for startTime := range integration {
+			if startTime.Before(limit) {
+				delete(integration, startTime)
+			}
+		}
+		// remove integration if it is now empty
+		if len(integration) == 0 {
+			delete(m.data, key)
+		}
+	}
+	return nil
+}

+ 358 - 0
pkg/cloudcost/memoryrepository_test.go

@@ -0,0 +1,358 @@
+package cloudcost
+
+import (
+	"reflect"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/pkg/kubecost"
+	"github.com/opencost/opencost/pkg/util/timeutil"
+)
+
+func TestMemoryRepository_Get(t *testing.T) {
+	defaultStart := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
+	defaultEnd := defaultStart.Add(timeutil.Day)
+	defaultData := map[string]map[time.Time]*kubecost.CloudCostSet{
+		"key-1": {
+			defaultStart: DefaultMockCloudCostSet(defaultStart, defaultEnd, "aws", "key-1"),
+		},
+	}
+	tests := map[string]struct {
+		data      map[string]map[time.Time]*kubecost.CloudCostSet
+		startTime time.Time
+		key       string
+		want      *kubecost.CloudCostSet
+		wantErr   bool
+	}{
+		"No Data": {
+			data:      map[string]map[time.Time]*kubecost.CloudCostSet{},
+			startTime: defaultStart,
+			key:       "key-1",
+			want:      nil,
+			wantErr:   false,
+		},
+		"has data": {
+			data:      defaultData,
+			startTime: defaultStart,
+			key:       "key-1",
+			want:      DefaultMockCloudCostSet(defaultStart, defaultEnd, "aws", "key-1"),
+			wantErr:   false,
+		},
+		"wrong key": {
+			data:      defaultData,
+			startTime: defaultStart,
+			key:       "key-2",
+			want:      nil,
+			wantErr:   false,
+		},
+		"wrong time": {
+			data:      defaultData,
+			startTime: defaultEnd,
+			key:       "key-1",
+			want:      nil,
+			wantErr:   false,
+		},
+	}
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			m := &MemoryRepository{
+				data: tt.data,
+			}
+			got, err := m.Get(tt.startTime, tt.key)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("Get() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("Get() got = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestMemoryRepository_Has(t *testing.T) {
+	defaultStart := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
+	defaultEnd := defaultStart.Add(timeutil.Day)
+	defaultData := map[string]map[time.Time]*kubecost.CloudCostSet{
+		"key-1": {
+			defaultStart: DefaultMockCloudCostSet(defaultStart, defaultEnd, "aws", "key-1"),
+		},
+	}
+	tests := map[string]struct {
+		data      map[string]map[time.Time]*kubecost.CloudCostSet
+		startTime time.Time
+		key       string
+		want      bool
+		wantErr   bool
+	}{
+		"No Data": {
+			data:      map[string]map[time.Time]*kubecost.CloudCostSet{},
+			startTime: defaultStart,
+			key:       "key-1",
+			want:      false,
+			wantErr:   false,
+		},
+		"has data": {
+			data:      defaultData,
+			startTime: defaultStart,
+			key:       "key-1",
+			want:      true,
+			wantErr:   false,
+		},
+		"wrong key": {
+			data:      defaultData,
+			startTime: defaultStart,
+			key:       "key-2",
+			want:      false,
+			wantErr:   false,
+		},
+		"wrong time": {
+			data:      defaultData,
+			startTime: defaultEnd,
+			key:       "key-1",
+			want:      false,
+			wantErr:   false,
+		},
+	}
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			m := &MemoryRepository{
+				data: tt.data,
+			}
+			got, err := m.Has(tt.startTime, tt.key)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("Has() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if got != tt.want {
+				t.Errorf("Has() got = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestMemoryRepository_Keys(t *testing.T) {
+
+	tests := map[string]struct {
+		data    map[string]map[time.Time]*kubecost.CloudCostSet
+		want    []string
+		wantErr bool
+	}{
+		"empty": {
+			data:    map[string]map[time.Time]*kubecost.CloudCostSet{},
+			want:    []string{},
+			wantErr: false,
+		},
+		"one-key": {
+			data: map[string]map[time.Time]*kubecost.CloudCostSet{
+				"key-1": nil,
+			},
+			want:    []string{"key-1"},
+			wantErr: false,
+		},
+		"two-key": {
+			data: map[string]map[time.Time]*kubecost.CloudCostSet{
+				"key-1": nil,
+				"key-2": {
+					time.Now():        nil,
+					time.Now().Add(1): nil,
+				},
+			},
+			want:    []string{"key-1", "key-2"},
+			wantErr: false,
+		},
+	}
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			m := &MemoryRepository{
+				data: tt.data,
+			}
+			got, err := m.Keys()
+			if (err != nil) != tt.wantErr {
+				t.Errorf("Keys() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("Keys() got = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestMemoryRepository_Put(t *testing.T) {
+	defaultStart := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
+	defaultEnd := defaultStart.Add(timeutil.Day)
+
+	tests := map[string]struct {
+		data    map[string]map[time.Time]*kubecost.CloudCostSet
+		input   *kubecost.CloudCostSet
+		want    map[string]map[time.Time]*kubecost.CloudCostSet
+		wantErr bool
+	}{
+
+		"nil set": {
+			data:    map[string]map[time.Time]*kubecost.CloudCostSet{},
+			input:   nil,
+			want:    map[string]map[time.Time]*kubecost.CloudCostSet{},
+			wantErr: true,
+		},
+		"invalid window": {
+			data: map[string]map[time.Time]*kubecost.CloudCostSet{},
+			input: &kubecost.CloudCostSet{
+				CloudCosts:  nil,
+				Window:      kubecost.Window{},
+				Integration: "key-1",
+			},
+			want:    map[string]map[time.Time]*kubecost.CloudCostSet{},
+			wantErr: true,
+		},
+		"invalid key": {
+			data: map[string]map[time.Time]*kubecost.CloudCostSet{},
+			input: &kubecost.CloudCostSet{
+				CloudCosts:  nil,
+				Window:      kubecost.NewClosedWindow(defaultStart, defaultEnd),
+				Integration: "",
+			},
+			want:    map[string]map[time.Time]*kubecost.CloudCostSet{},
+			wantErr: true,
+		},
+		"valid input": {
+			data:  map[string]map[time.Time]*kubecost.CloudCostSet{},
+			input: DefaultMockCloudCostSet(defaultStart, defaultEnd, "aws", "key-1"),
+			want: map[string]map[time.Time]*kubecost.CloudCostSet{
+				"key-1": {
+					defaultStart: DefaultMockCloudCostSet(defaultStart, defaultEnd, "aws", "key-1"),
+				},
+			},
+			wantErr: false,
+		},
+		"overwrite": {
+			data: map[string]map[time.Time]*kubecost.CloudCostSet{
+				"key-1": {
+					defaultStart: DefaultMockCloudCostSet(defaultStart, defaultEnd, "gcp", "key-1"),
+				},
+			},
+			input: DefaultMockCloudCostSet(defaultStart, defaultEnd, "aws", "key-1"),
+			want: map[string]map[time.Time]*kubecost.CloudCostSet{
+				"key-1": {
+					defaultStart: DefaultMockCloudCostSet(defaultStart, defaultEnd, "aws", "key-1"),
+				},
+			},
+			wantErr: false,
+		},
+		"invalid overwrite": {
+			data: map[string]map[time.Time]*kubecost.CloudCostSet{
+				"key-1": {
+					defaultStart: DefaultMockCloudCostSet(defaultStart, defaultEnd, "gcp", "key-1"),
+				},
+			},
+			input: &kubecost.CloudCostSet{
+				Window:      kubecost.NewWindow(&defaultStart, nil),
+				Integration: "key-1",
+			},
+			want: map[string]map[time.Time]*kubecost.CloudCostSet{
+				"key-1": {
+					defaultStart: DefaultMockCloudCostSet(defaultStart, defaultEnd, "gcp", "key-1"),
+				},
+			},
+			wantErr: true,
+		},
+	}
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			m := &MemoryRepository{data: tt.data}
+
+			if err := m.Put(tt.input); (err != nil) != tt.wantErr {
+				t.Errorf("Put() error = %v, wantErr %v", err, tt.wantErr)
+			}
+
+			if !reflect.DeepEqual(m.data, tt.want) {
+				t.Errorf("Put() got = %v, want %v", m.data, tt.want)
+			}
+		})
+	}
+}
+
+func TestMemoryRepository_Expire(t *testing.T) {
+	dayOne := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
+	dayTwo := time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC)
+	dayThree := time.Date(2023, 1, 3, 0, 0, 0, 0, time.UTC)
+	tests := map[string]struct {
+		data    map[string]map[time.Time]*kubecost.CloudCostSet
+		limit   time.Time
+		want    map[string]map[time.Time]*kubecost.CloudCostSet
+		wantErr bool
+	}{
+		"no expire": {
+			data: map[string]map[time.Time]*kubecost.CloudCostSet{
+				"key-1": {
+					dayTwo: nil,
+				},
+			},
+			limit: dayOne,
+			want: map[string]map[time.Time]*kubecost.CloudCostSet{
+				"key-1": {
+					dayTwo: nil,
+				},
+			},
+			wantErr: false,
+		},
+		"limit match": {
+			data: map[string]map[time.Time]*kubecost.CloudCostSet{
+				"key-1": {
+					dayTwo: nil,
+				},
+			},
+			limit: dayTwo,
+			want: map[string]map[time.Time]*kubecost.CloudCostSet{
+				"key-1": {
+					dayTwo: nil,
+				},
+			},
+			wantErr: false,
+		},
+		"single expire": {
+			data: map[string]map[time.Time]*kubecost.CloudCostSet{
+				"key-1": {
+					dayTwo: nil,
+				},
+			},
+			limit:   dayThree,
+			want:    map[string]map[time.Time]*kubecost.CloudCostSet{},
+			wantErr: false,
+		},
+		"one key expire": {
+			data: map[string]map[time.Time]*kubecost.CloudCostSet{
+				"key-1": {
+					dayOne: nil,
+					dayTwo: nil,
+				},
+				"key-2": {
+					dayOne: nil,
+				},
+			},
+			limit: dayTwo,
+			want: map[string]map[time.Time]*kubecost.CloudCostSet{
+				"key-1": {
+					dayTwo: nil,
+				},
+			},
+			wantErr: false,
+		},
+	}
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			m := &MemoryRepository{
+				data: tt.data,
+			}
+			if err := m.Expire(tt.limit); (err != nil) != tt.wantErr {
+				t.Errorf("Expire() error = %v, wantErr %v", err, tt.wantErr)
+			}
+
+			if !reflect.DeepEqual(m.data, tt.want) {
+				t.Errorf("Expire() got = %v, want %v", m.data, tt.want)
+			}
+
+		})
+	}
+}

+ 90 - 0
pkg/cloudcost/mock.go

@@ -0,0 +1,90 @@
+package cloudcost
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/pkg/kubecost"
+)
+
+func DefaultMockCloudCostSet(start, end time.Time, provider, integration string) *kubecost.CloudCostSet {
+	ccs := kubecost.NewCloudCostSet(start, end)
+
+	ccs.Integration = integration
+
+	ccs.Insert(&kubecost.CloudCost{
+		Window: ccs.Window,
+		Properties: &kubecost.CloudCostProperties{
+			Provider:        provider,
+			AccountID:       "account1",
+			InvoiceEntityID: "invoiceEntity1",
+			Service:         provider + "-storage",
+			Category:        kubecost.StorageCategory,
+			Labels: kubecost.CloudCostLabels{
+				"label1": "value1",
+				"label2": "value2",
+				"label3": "value3",
+			},
+			ProviderID: "id1",
+		},
+		ListCost: kubecost.CostMetric{
+			Cost:              100,
+			KubernetesPercent: 0,
+		},
+		NetCost: kubecost.CostMetric{
+			Cost:              100,
+			KubernetesPercent: 0,
+		},
+	})
+
+	ccs.Insert(&kubecost.CloudCost{
+		Window: ccs.Window,
+		Properties: &kubecost.CloudCostProperties{
+			Provider:        provider,
+			AccountID:       "account1",
+			InvoiceEntityID: "invoiceEntity1",
+			Service:         provider + "-compute",
+			Category:        kubecost.ComputeCategory,
+			Labels: kubecost.CloudCostLabels{
+				"label1": "value1",
+				"label2": "value2",
+				"label3": "value3",
+			},
+			ProviderID: "id2",
+		},
+		ListCost: kubecost.CostMetric{
+			Cost:              2000,
+			KubernetesPercent: 1,
+		},
+		NetCost: kubecost.CostMetric{
+			Cost:              1800,
+			KubernetesPercent: 1,
+		},
+	})
+
+	ccs.Insert(&kubecost.CloudCost{
+		Window: ccs.Window,
+		Properties: &kubecost.CloudCostProperties{
+			Provider:        provider,
+			AccountID:       "account2",
+			InvoiceEntityID: "invoiceEntity2",
+			Service:         provider + "-compute",
+			Category:        kubecost.ComputeCategory,
+			Labels: kubecost.CloudCostLabels{
+				"label1": "value1",
+				"label2": "value2",
+				"label3": "value3",
+			},
+			ProviderID: "id3",
+		},
+		ListCost: kubecost.CostMetric{
+			Cost:              8000,
+			KubernetesPercent: 1,
+		},
+		NetCost: kubecost.CostMetric{
+			Cost:              8000,
+			KubernetesPercent: 1,
+		},
+	})
+
+	return ccs
+}

+ 194 - 0
pkg/cloudcost/pipelineservice.go

@@ -0,0 +1,194 @@
+package cloudcost
+
+import (
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/julienschmidt/httprouter"
+	cloudconfig "github.com/opencost/opencost/pkg/cloud"
+	"github.com/opencost/opencost/pkg/cloud/config"
+	"github.com/opencost/opencost/pkg/env"
+	"github.com/opencost/opencost/pkg/kubecost"
+	"github.com/opencost/opencost/pkg/proto"
+)
+
+var protocol = proto.HTTP()
+
+// PipelineService exposes CloudCost pipeline controls and diagnostics endpoints
+type PipelineService struct {
+	ingestionManager *IngestionManager
+	store            Repository
+	configController *config.Controller
+}
+
+// NewPipelineService is a constructor for a PipelineService
+func NewPipelineService(repo Repository, ic *config.Controller, ingConf IngestorConfig) *PipelineService {
+	im := NewIngestionManager(ic, repo, ingConf)
+	return &PipelineService{
+		ingestionManager: im,
+		store:            repo,
+		configController: ic,
+	}
+}
+
+// Status merges status values from the config.Controller and the IngestionManager to give a combined view of that state
+// of configs and their ingestion status
+func (dp *PipelineService) Status() []Status {
+	var statuses []Status
+	// Pull config status from the config controller
+	confStatuses := dp.configController.GetStatus()
+	refreshRate := time.Hour * time.Duration(env.GetCloudCostRefreshRateHours())
+	for _, confStat := range confStatuses {
+		var conf cloudconfig.Config
+		var provider string
+		if confStat.Config != nil {
+			conf = confStat.Config.Sanitize()
+			provider = confStat.Config.Provider()
+		}
+
+		var ingestorStatus IngestorStatus
+		if ing, ok := dp.ingestionManager.ingestors[confStat.Key]; ok {
+			ingestorStatus = ing.Status()
+		}
+
+		// These are the statuses
+		status := Status{
+			Key:              confStat.Key,
+			Source:           confStat.Source.String(),
+			Active:           confStat.Active,
+			Valid:            confStat.Valid,
+			Config:           conf,
+			Provider:         provider,
+			ConnectionStatus: ingestorStatus.ConnectionStatus.String(),
+			LastRun:          ingestorStatus.LastRun,
+			NextRun:          ingestorStatus.NextRun,
+			Runs:             ingestorStatus.Runs,
+			Created:          ingestorStatus.Created,
+			Coverage:         ingestorStatus.Coverage.String(),
+			RefreshRate:      refreshRate.String(),
+		}
+		statuses = append(statuses, status)
+	}
+
+	return statuses
+}
+
+// GetCloudCostRebuildHandler creates a handler from a http request which initiates a rebuild of cloud cost pipeline, if an
+// integrationKey is provided then it only rebuilds the specified billing integration
+func (s *PipelineService) GetCloudCostRebuildHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	// If Reporting Service is nil, always return 501
+	if s == nil {
+		return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+			http.Error(w, "Cloud Cost Pipeline Service is nil", http.StatusNotImplemented)
+		}
+	}
+	if s.ingestionManager == nil {
+		return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+			http.Error(w, "Cloud Cost Pipeline Service Ingestion Manager is nil", http.StatusNotImplemented)
+		}
+	}
+	// Return valid handler func
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		w.Header().Set("Content-Type", "application/json")
+
+		commit := r.URL.Query().Get("commit") == "true" || r.URL.Query().Get("commit") == "1"
+
+		if !commit {
+			protocol.WriteData(w, "Pass parameter 'commit=true' to confirm Cloud Cost rebuild")
+			return
+		}
+
+		integrationKey := r.URL.Query().Get("integrationKey")
+
+		// If no providerKey argument was provider, restart all Cloud Asset Pipelines
+		if integrationKey == "" {
+			s.ingestionManager.RebuildAll()
+			protocol.WriteData(w, "Rebuilding Cloud Usage For All Providers")
+			return
+		} else {
+			err := s.ingestionManager.Rebuild(integrationKey)
+			if err != nil {
+				http.Error(w, err.Error(), http.StatusBadRequest)
+				return
+			}
+			protocol.WriteData(w, fmt.Sprintf("Rebuilding Cloud Usage For Provider %s", integrationKey))
+			return
+		}
+	}
+}
+
+// GetCloudCostRepairHandler creates a handler from a http request which initiates a repair of cloud cost for a given window, if an
+// integrationKey is provided then it only repairs the specified integration
+func (s *PipelineService) GetCloudCostRepairHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	// If Reporting Service is nil, always return 501
+	if s == nil {
+		return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+			http.Error(w, "Reporting Service is nil", http.StatusNotImplemented)
+		}
+	}
+	if s.ingestionManager == nil {
+		return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+			http.Error(w, "Cloud Cost Pipeline Service Ingestion Manager is nil", http.StatusNotImplemented)
+		}
+	}
+	// Return valid handler func
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		w.Header().Set("Content-Type", "application/json")
+
+		windowStr := r.URL.Query().Get("window")
+
+		var window kubecost.Window
+		if windowStr != "" {
+			win, err := kubecost.ParseWindowWithOffset(windowStr, env.GetParsedUTCOffset())
+			if err != nil {
+				http.Error(w, fmt.Sprintf("Invalid parameter: %s", err), http.StatusBadRequest)
+				return
+			}
+			window = win
+		}
+
+		integrationKey := r.URL.Query().Get("integrationKey")
+
+		// If no providerKey argument was provider, restart all Cloud Asset Pipelines
+		if integrationKey == "" {
+			err := s.ingestionManager.RepairAll(*window.Start(), *window.End())
+			if err != nil {
+				http.Error(w, err.Error(), http.StatusBadRequest)
+				return
+			}
+			protocol.WriteData(w, "Rebuilding Cloud Usage For All Providers")
+			return
+		} else {
+			err := s.ingestionManager.Repair(integrationKey, *window.Start(), *window.End())
+			if err != nil {
+				http.Error(w, err.Error(), http.StatusBadRequest)
+				return
+			}
+			protocol.WriteData(w, fmt.Sprintf("Rebuilding Cloud Usage For Provider %s", integrationKey))
+			return
+		}
+	}
+}
+
+// GetCloudCostStatusHandler creates a handler from a http request which returns a list of the billing integration status
+func (s *PipelineService) GetCloudCostStatusHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	// If Reporting Service is nil, always return 501
+	if s == nil {
+		return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+			http.Error(w, "Reporting Service is nil", http.StatusNotImplemented)
+		}
+	}
+	if s.ingestionManager == nil {
+		return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+			http.Error(w, "Cloud Cost Pipeline Service Ingestion Manager is nil", http.StatusNotImplemented)
+		}
+	}
+
+	// Return valid handler func
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		w.Header().Set("Content-Type", "application/json")
+
+		protocol.WriteData(w, s.Status())
+	}
+}

+ 89 - 0
pkg/cloudcost/querier.go

@@ -0,0 +1,89 @@
+package cloudcost
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	filter "github.com/opencost/opencost/pkg/filter21"
+	"github.com/opencost/opencost/pkg/kubecost"
+)
+
+// Querier allows for querying ranges of CloudCost data
+type Querier interface {
+	Query(QueryRequest, context.Context) (*kubecost.CloudCostSetRange, error)
+}
+
+type QueryRequest struct {
+	Start       time.Time
+	End         time.Time
+	AggregateBy []string
+	Accumulate  kubecost.AccumulateOption
+	Filter      filter.Filter
+}
+
+// DefaultChartItemsLength the default max number of items for a ViewGraphDataSet
+const DefaultChartItemsLength int = 10
+
+// ViewQuerier defines a contract for return View types to the QueryService to service the View Api
+type ViewQuerier interface {
+	QueryViewGraph(ViewQueryRequest, context.Context) (ViewGraphData, error)
+	QueryViewTotals(ViewQueryRequest, context.Context) (*ViewTableRow, int, error)
+	QueryViewTable(ViewQueryRequest, context.Context) (ViewTableRows, error)
+}
+
+type ViewQueryRequest struct {
+	QueryRequest
+	CostMetricName   kubecost.CostMetricName
+	ChartItemsLength int
+	Offset           int
+	Limit            int
+	SortDirection    SortDirection
+	SortColumn       SortField
+}
+
+// SortDirection a string type that acts as an enumeration of possible request options
+type SortDirection string
+
+const (
+	SortDirectionNone       SortDirection = ""
+	SortDirectionAscending  SortDirection = "asc"
+	SortDirectionDescending SortDirection = "desc"
+)
+
+// ParseSortDirection provides a resilient way to parse one of the enumerated SortDirection types from a string
+// or throws an error if it is not able to.
+func ParseSortDirection(sortDirection string) (SortDirection, error) {
+	switch strings.ToLower(sortDirection) {
+	case strings.ToLower(string(SortDirectionAscending)):
+		return SortDirectionAscending, nil
+	case strings.ToLower(string(SortDirectionDescending)):
+		return SortDirectionDescending, nil
+	}
+	return SortDirectionNone, fmt.Errorf("failed to parse a valid CostMetricName from '%s'", sortDirection)
+}
+
+// SortField a string type that acts as an enumeration of possible request options
+type SortField string
+
+const (
+	SortFieldNone              SortField = ""
+	SortFieldName              SortField = "name"
+	SortFieldCost              SortField = "cost"
+	SortFieldKubernetesPercent SortField = "kubernetesPercent"
+)
+
+// ParseSortField provides a resilient way to parse one of the enumerated SortField types from a string
+// or throws an error if it is not able to.
+func ParseSortField(sortColumn string) (SortField, error) {
+	switch strings.ToLower(sortColumn) {
+	case strings.ToLower(string(SortFieldName)):
+		return SortFieldName, nil
+	case strings.ToLower(string(SortFieldCost)):
+		return SortFieldCost, nil
+	case strings.ToLower(string(SortFieldKubernetesPercent)):
+		return SortFieldKubernetesPercent, nil
+	}
+	return SortFieldNone, fmt.Errorf("failed to parse a valid CostMetricName from '%s'", sortColumn)
+}

+ 118 - 0
pkg/cloudcost/querier_test.go

@@ -0,0 +1,118 @@
+package cloudcost
+
+import (
+	"testing"
+)
+
+func TestParseSortDirection(t *testing.T) {
+	tests := map[string]struct {
+		input   string
+		want    SortDirection
+		wantErr bool
+	}{
+		"Empty String": {
+			input:   "",
+			want:    SortDirectionNone,
+			wantErr: true,
+		},
+		"invalid input": {
+			input:   "invalid",
+			want:    SortDirectionNone,
+			wantErr: true,
+		},
+		"upper case ascending": {
+			input:   "ASC",
+			want:    SortDirectionAscending,
+			wantErr: false,
+		},
+		"lower case ascending": {
+			input:   "asc",
+			want:    SortDirectionAscending,
+			wantErr: false,
+		},
+		"upper case descending": {
+			input:   "DESC",
+			want:    SortDirectionDescending,
+			wantErr: false,
+		},
+		"lower case descending": {
+			input:   "desc",
+			want:    SortDirectionDescending,
+			wantErr: false,
+		},
+	}
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			got, err := ParseSortDirection(tt.input)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("ParseSortDirection() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if got != tt.want {
+				t.Errorf("ParseSortDirection() got = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestParseSortField(t *testing.T) {
+
+	tests := map[string]struct {
+		input   string
+		want    SortField
+		wantErr bool
+	}{
+		"Empty String": {
+			input:   "",
+			want:    SortFieldNone,
+			wantErr: true,
+		},
+		"invalid input": {
+			input:   "invalid",
+			want:    SortFieldNone,
+			wantErr: true,
+		},
+		"upper case cost": {
+			input:   "Cost",
+			want:    SortFieldCost,
+			wantErr: false,
+		},
+		"lower case cost": {
+			input:   "cost",
+			want:    SortFieldCost,
+			wantErr: false,
+		},
+		"upper case k8s %": {
+			input:   "KubernetesPercent",
+			want:    SortFieldKubernetesPercent,
+			wantErr: false,
+		},
+		"lower case k8s %": {
+			input:   "kubernetesPercent",
+			want:    SortFieldKubernetesPercent,
+			wantErr: false,
+		},
+		"upper case name": {
+			input:   "Name",
+			want:    SortFieldName,
+			wantErr: false,
+		},
+		"lower case Name": {
+			input:   "name",
+			want:    SortFieldName,
+			wantErr: false,
+		},
+	}
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			got, err := ParseSortField(tt.input)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("ParseSortField() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if got != tt.want {
+				t.Errorf("ParseSortField() got = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}

+ 207 - 0
pkg/cloudcost/queryservice.go

@@ -0,0 +1,207 @@
+package cloudcost
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/julienschmidt/httprouter"
+	"github.com/opencost/opencost/pkg/kubecost"
+	"github.com/opencost/opencost/pkg/util/httputil"
+	"go.opentelemetry.io/otel"
+)
+
+const tracerName = "github.com/opencost/ooencost/pkg/cloudcost"
+
+const (
+	csvFormat = "csv"
+)
+
+// QueryService surfaces endpoints for accessing CloudCost data in raw form or for display in views
+type QueryService struct {
+	Querier     Querier
+	ViewQuerier ViewQuerier
+}
+
+func NewQueryService(querier Querier, viewQuerier ViewQuerier) *QueryService {
+	return &QueryService{
+		Querier:     querier,
+		ViewQuerier: viewQuerier,
+	}
+}
+
+func (s *QueryService) GetCloudCostHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	// Return valid handler func
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		tracer := otel.Tracer(tracerName)
+		ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostHandler")
+		defer span.End()
+
+		// If Query Service is nil, always return 501
+		if s == nil {
+			http.Error(w, "Query Service is nil", http.StatusNotImplemented)
+			return
+		}
+
+		if s.Querier == nil {
+			http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
+			return
+		}
+
+		qp := httputil.NewQueryParams(r.URL.Query())
+		request, err := ParseCloudCostRequest(qp)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		resp, err := s.Querier.Query(*request, ctx)
+		if err != nil {
+			http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
+			return
+		}
+
+		_, spanResp := tracer.Start(ctx, "write response")
+		w.Header().Set("Content-Type", "application/json")
+		protocol.WriteData(w, resp)
+		spanResp.End()
+	}
+}
+
+func (s *QueryService) GetCloudCostViewGraphHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	// Return valid handler func
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		tracer := otel.Tracer(tracerName)
+		ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostViewGraphHandler")
+		defer span.End()
+
+		// If Query Service is nil, always return 501
+		if s == nil {
+			http.Error(w, "Query Service is nil", http.StatusNotImplemented)
+			return
+		}
+
+		if s.ViewQuerier == nil {
+			http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
+			return
+		}
+
+		qp := httputil.NewQueryParams(r.URL.Query())
+		request, err := parseCloudCostViewRequest(qp)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		resp, err := s.ViewQuerier.QueryViewGraph(*request, ctx)
+		if err != nil {
+			http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
+			return
+		}
+
+		_, spanResp := tracer.Start(ctx, "write response")
+		w.Header().Set("Content-Type", "application/json")
+		protocol.WriteData(w, resp)
+		spanResp.End()
+	}
+}
+
+type CloudCostViewTotalsResponse struct {
+	NumResults int           `json:"numResults"`
+	Combined   *ViewTableRow `json:"combined"`
+}
+
+func (s *QueryService) GetCloudCostViewTotalsHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	// Return valid handler func
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		tracer := otel.Tracer(tracerName)
+		ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostViewTotalsHandler")
+		defer span.End()
+
+		// If Query Service is nil, always return 501
+		if s == nil {
+			http.Error(w, "Query Service is nil", http.StatusNotImplemented)
+			return
+		}
+
+		if s.ViewQuerier == nil {
+			http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
+			return
+		}
+
+		qp := httputil.NewQueryParams(r.URL.Query())
+		request, err := parseCloudCostViewRequest(qp)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		totals, count, err := s.ViewQuerier.QueryViewTotals(*request, ctx)
+		if err != nil {
+			http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
+			return
+		}
+
+		resp := CloudCostViewTotalsResponse{
+			NumResults: count,
+			Combined:   totals,
+		}
+
+		_, spanResp := tracer.Start(ctx, "write response")
+		w.Header().Set("Content-Type", "application/json")
+		protocol.WriteData(w, resp)
+		spanResp.End()
+	}
+}
+
+func (s *QueryService) GetCloudCostViewTableHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	// Return valid handler func
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		tracer := otel.Tracer(tracerName)
+		ctx, span := tracer.Start(r.Context(), "Service.GetCloudCostViewTableHandler")
+		defer span.End()
+
+		// If Query Service is nil, always return 501
+		if s == nil {
+			http.Error(w, "Query Service is nil", http.StatusNotImplemented)
+			return
+		}
+
+		if s.ViewQuerier == nil {
+			http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
+			return
+		}
+
+		qp := httputil.NewQueryParams(r.URL.Query())
+		request, err := parseCloudCostViewRequest(qp)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		format := qp.Get("format", "json")
+		if strings.HasPrefix(format, csvFormat) {
+			w.Header().Set("Content-Type", "text/csv")
+			w.Header().Set("Transfer-Encoding", "chunked")
+		} else {
+			// By default, send JSON
+			w.Header().Set("Content-Type", "application/json")
+		}
+
+		resp, err := s.ViewQuerier.QueryViewTable(*request, ctx)
+		if err != nil {
+			http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
+			return
+		}
+
+		_, spanResp := tracer.Start(ctx, "write response")
+		defer spanResp.End()
+		if format == csvFormat {
+			window := kubecost.NewClosedWindow(request.Start, request.End)
+			writeCloudCostViewTableRowsAsCSV(w, resp, window.String())
+			return
+		}
+		w.Header().Set("Content-Type", "application/json")
+		protocol.WriteData(w, resp)
+	}
+}

+ 170 - 0
pkg/cloudcost/queryservice_helper.go

@@ -0,0 +1,170 @@
+package cloudcost
+
+import (
+	"encoding/csv"
+	"fmt"
+	"net/http"
+	"strings"
+
+	filter21 "github.com/opencost/opencost/pkg/filter21"
+	"github.com/opencost/opencost/pkg/filter21/cloudcost"
+	"github.com/opencost/opencost/pkg/kubecost"
+	"github.com/opencost/opencost/pkg/prom"
+	"github.com/opencost/opencost/pkg/util/httputil"
+)
+
+func ParseCloudCostRequest(qp httputil.QueryParams) (*QueryRequest, error) {
+
+	windowStr := qp.Get("window", "")
+	if windowStr == "" {
+		return nil, fmt.Errorf("missing require window param")
+	}
+
+	window, err := kubecost.ParseWindowUTC(windowStr)
+	if err != nil {
+		return nil, fmt.Errorf("invalid window parameter: %w", err)
+	}
+	if window.IsOpen() {
+		return nil, fmt.Errorf("invalid window parameter: %s", window.String())
+	}
+
+	aggregateByRaw := qp.GetList("aggregate", ",")
+	var aggregateBy []string
+	for _, aggBy := range aggregateByRaw {
+		prop, err := ParseCloudCostProperty(aggBy)
+		if err != nil {
+			return nil, fmt.Errorf("error parsing aggregate by %v", err)
+		}
+		aggregateBy = append(aggregateBy, prop)
+	}
+
+	accumulate := kubecost.ParseAccumulate(qp.Get("accumulate", ""))
+
+	var filter filter21.Filter
+	filterString := qp.Get("filter", "")
+	if filterString != "" {
+		parser := cloudcost.NewCloudCostFilterParser()
+		filter, err = parser.Parse(filterString)
+		if err != nil {
+			return nil, fmt.Errorf("Parsing 'filter' parameter: %s", err)
+		}
+	}
+
+	opts := &QueryRequest{
+		Start:       *window.Start(),
+		End:         *window.End(),
+		AggregateBy: aggregateBy,
+		Accumulate:  accumulate,
+		Filter:      filter,
+	}
+
+	return opts, nil
+}
+
+func ParseCloudCostProperty(text string) (string, error) {
+	switch strings.TrimSpace(strings.ToLower(text)) {
+	case strings.ToLower(kubecost.CloudCostInvoiceEntityIDProp):
+		return kubecost.CloudCostInvoiceEntityIDProp, nil
+	case strings.ToLower(kubecost.CloudCostAccountIDProp):
+		return kubecost.CloudCostAccountIDProp, nil
+	case strings.ToLower(kubecost.CloudCostProviderProp):
+		return kubecost.CloudCostProviderProp, nil
+	case strings.ToLower(kubecost.CloudCostProviderIDProp):
+		return kubecost.CloudCostProviderIDProp, nil
+	case strings.ToLower(kubecost.CloudCostCategoryProp):
+		return kubecost.CloudCostCategoryProp, nil
+	case strings.ToLower(kubecost.CloudCostServiceProp):
+		return kubecost.CloudCostServiceProp, nil
+	}
+
+	if strings.HasPrefix(text, "label:") {
+		label := prom.SanitizeLabelName(strings.TrimSpace(strings.TrimPrefix(text, "label:")))
+		return fmt.Sprintf("label:%s", label), nil
+	}
+
+	return "", fmt.Errorf("invalid cloud cost property: %s", text)
+}
+
+func parseCloudCostViewRequest(qp httputil.QueryParams) (*ViewQueryRequest, error) {
+	qr, err := ParseCloudCostRequest(qp)
+	if err != nil {
+		return nil, err
+	}
+
+	// parse cost metric
+	costMetricName, err := kubecost.ParseCostMetricName(qp.Get("costMetric", string(kubecost.CostMetricAmortizedNetCost)))
+	if err != nil {
+		return nil, fmt.Errorf("error parsing 'costMetric': %w", err)
+	}
+
+	limit := qp.GetInt("limit", 0)
+	if limit < 0 {
+		return nil, fmt.Errorf("invalid value for limit %d", limit)
+	}
+	offset := qp.GetInt("offset", 0)
+	if offset < 0 {
+		return nil, fmt.Errorf("invalid value for offset %d", offset)
+	}
+
+	// parse order
+	order, err := ParseSortDirection(qp.Get("sortByOrder", "desc"))
+	if err != nil {
+		return nil, fmt.Errorf("error parsing 'sortByOrder: %w", err)
+	}
+
+	sortColumn, err := ParseSortField(qp.Get("sortBy", "cost"))
+	if err != nil {
+		return nil, fmt.Errorf("error parsing 'sortBy': %w", err)
+	}
+
+	return &ViewQueryRequest{
+		QueryRequest:     *qr,
+		CostMetricName:   costMetricName,
+		ChartItemsLength: DefaultChartItemsLength,
+		Limit:            limit,
+		Offset:           offset,
+		SortDirection:    order,
+		SortColumn:       sortColumn,
+	}, nil
+}
+
+// CloudCostViewTableRowsToCSV takes the csv writer and writes the ViewTableRows into the writer.
+func CloudCostViewTableRowsToCSV(writer *csv.Writer, ctr ViewTableRows, window string) error {
+	defer writer.Flush()
+	// Write the column headers
+	headers := []string{
+		"Name",
+		"K8s Utilization",
+		"Total",
+		"Window",
+	}
+	err := writer.Write(headers)
+	if err != nil {
+		return fmt.Errorf("CloudCostViewTableRowsToCSV: failed to convert ViewTableRows to csv with error: %w", err)
+	}
+
+	// Write one row per entry in the ViewTableRows
+	for _, row := range ctr {
+		err = writer.Write([]string{
+			row.Name,
+			fmt.Sprintf("%.3f", row.KubernetesPercent),
+			fmt.Sprintf("%.3f", row.Cost),
+			window,
+		})
+		if err != nil {
+			return fmt.Errorf("CloudCostViewTableRowsToCSV: failed to convert ViewTableRows to csv with error: %w", err)
+		}
+	}
+
+	return nil
+}
+
+func writeCloudCostViewTableRowsAsCSV(w http.ResponseWriter, ctr ViewTableRows, window string) {
+	writer := csv.NewWriter(w)
+
+	err := CloudCostViewTableRowsToCSV(writer, ctr, window)
+	if err != nil {
+		protocol.WriteError(w, protocol.InternalServerError(err.Error()))
+		return
+	}
+}

+ 136 - 0
pkg/cloudcost/queryservice_helper_test.go

@@ -0,0 +1,136 @@
+package cloudcost
+
+import (
+	"reflect"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/pkg/filter21/cloudcost"
+	"github.com/opencost/opencost/pkg/kubecost"
+	"github.com/opencost/opencost/pkg/util/httputil"
+)
+
+func TestParseCloudCostRequest(t *testing.T) {
+	windowStr := "2023-01-01T00:00:00Z,2023-01-02T00:00:00Z"
+	start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC)
+	validFilterStr := `service:"AmazonEC2"`
+	parser := cloudcost.NewCloudCostFilterParser()
+	validFilter, _ := parser.Parse(validFilterStr)
+	tests := map[string]struct {
+		values  map[string][]string
+		want    *QueryRequest
+		wantErr bool
+	}{
+		"missing window": {
+			values:  map[string][]string{},
+			want:    nil,
+			wantErr: true,
+		},
+		"invalid window": {
+			values: map[string][]string{
+				"window": {"invalid"},
+			},
+			want:    nil,
+			wantErr: true,
+		},
+		"valid window": {
+			values: map[string][]string{
+				"window": {windowStr},
+			},
+			want: &QueryRequest{
+				Start:       start,
+				End:         end,
+				AggregateBy: nil,
+				Accumulate:  "",
+				Filter:      nil,
+			},
+			wantErr: false,
+		},
+		"valid aggregate": {
+			values: map[string][]string{
+				"window":    {windowStr},
+				"aggregate": {"invoiceEntityID,accountID,label:app"},
+			},
+			want: &QueryRequest{
+				Start:       start,
+				End:         end,
+				AggregateBy: []string{kubecost.CloudCostInvoiceEntityIDProp, kubecost.CloudCostAccountIDProp, "label:app"},
+				Accumulate:  "",
+				Filter:      nil,
+			},
+			wantErr: false,
+		},
+		"invalid aggregate": {
+			values: map[string][]string{
+				"window":    {windowStr},
+				"aggregate": {"invalid"},
+			},
+			want:    nil,
+			wantErr: true,
+		},
+		"valid accumulate": {
+			values: map[string][]string{
+				"window":     {windowStr},
+				"accumulate": {"week"},
+			},
+			want: &QueryRequest{
+				Start:       start,
+				End:         end,
+				AggregateBy: nil,
+				Accumulate:  kubecost.AccumulateOptionWeek,
+				Filter:      nil,
+			},
+			wantErr: false,
+		},
+		"invalid accumulate": {
+			values: map[string][]string{
+				"window":     {windowStr},
+				"accumulate": {"invalid"},
+			},
+			want: &QueryRequest{
+				Start:       start,
+				End:         end,
+				AggregateBy: nil,
+				Accumulate:  kubecost.AccumulateOptionNone,
+				Filter:      nil,
+			},
+			wantErr: false,
+		},
+		"valid filter": {
+			values: map[string][]string{
+				"window": {windowStr},
+				"filter": {validFilterStr},
+			},
+			want: &QueryRequest{
+				Start:       start,
+				End:         end,
+				AggregateBy: nil,
+				Accumulate:  kubecost.AccumulateOptionNone,
+				Filter:      validFilter,
+			},
+			wantErr: false,
+		},
+		"invalid filter": {
+			values: map[string][]string{
+				"window": {windowStr},
+				"filter": {"invalid"},
+			},
+			want:    nil,
+			wantErr: true,
+		},
+	}
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			qp := httputil.NewQueryParams(tt.values)
+			got, err := ParseCloudCostRequest(qp)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("ParseCloudCostRequest() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !reflect.DeepEqual(got, tt.want) {
+				t.Errorf("ParseCloudCostRequest() got = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}

+ 16 - 0
pkg/cloudcost/repository.go

@@ -0,0 +1,16 @@
+package cloudcost
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/pkg/kubecost"
+)
+
+// Repository is an interface for storing and retrieving CloudCost data
+type Repository interface {
+	Has(time.Time, string) (bool, error)
+	Get(time.Time, string) (*kubecost.CloudCostSet, error)
+	Keys() ([]string, error)
+	Put(*kubecost.CloudCostSet) error
+	Expire(time.Time) error
+}

+ 236 - 0
pkg/cloudcost/repositoryquerier.go

@@ -0,0 +1,236 @@
+package cloudcost
+
+import (
+	"context"
+	"fmt"
+	"sort"
+
+	"github.com/opencost/opencost/pkg/kubecost"
+	"github.com/opencost/opencost/pkg/log"
+)
+
+// RepositoryQuerier is an implementation of Querier and ViewQuerier which pulls directly from a Repository
+type RepositoryQuerier struct {
+	repo Repository
+}
+
+func NewRepositoryQuerier(repo Repository) *RepositoryQuerier {
+	return &RepositoryQuerier{repo: repo}
+}
+
+func (rq *RepositoryQuerier) Query(request QueryRequest, ctx context.Context) (*kubecost.CloudCostSetRange, error) {
+	repoKeys, err := rq.repo.Keys()
+	if err != nil {
+		return nil, fmt.Errorf("RepositoryQuerier: Query: failed to get list of keys from repository: %w", err)
+	}
+
+	// create filter
+	compiler := kubecost.NewCloudCostMatchCompiler()
+	matcher, err := compiler.Compile(request.Filter)
+	if err != nil {
+		return nil, fmt.Errorf("RepositoryQuerier: Query: failed to compile filters: %w", err)
+	}
+
+	// Create a Cloud Cost Set Range in the resolution of the repository
+	ccsr, err := kubecost.NewCloudCostSetRange(request.Start, request.End, kubecost.AccumulateOptionDay, "")
+	if err != nil {
+		return nil, fmt.Errorf("RepositoryQuerier: Query: failed to create Cloud Cost Set Range: %w", err)
+	}
+	for _, cloudCostSet := range ccsr.CloudCostSets {
+		// Setting this values creates
+		cloudCostSet.AggregationProperties = request.AggregateBy
+		for _, key := range repoKeys {
+			ccs, err := rq.repo.Get(cloudCostSet.Window.Start().UTC(), key)
+			if err != nil {
+				log.Errorf("RepositoryQuerier: Query: %s", err.Error())
+				continue
+			}
+			if ccs == nil {
+				continue
+			}
+
+			for _, cc := range ccs.CloudCosts {
+				if matcher.Matches(cc) {
+					cloudCostSet.Insert(cc)
+				}
+			}
+		}
+	}
+
+	if request.Accumulate != kubecost.AccumulateOptionNone {
+		ccsr, err = ccsr.Accumulate(request.Accumulate)
+		if err != nil {
+			return nil, fmt.Errorf("RepositoryQuerier: Query: error accumulating: %w", err)
+		}
+	}
+
+	return ccsr, nil
+}
+
+func (rq *RepositoryQuerier) QueryViewGraph(request ViewQueryRequest, ctx context.Context) (ViewGraphData, error) {
+	ccasr, err := rq.Query(request.QueryRequest, ctx)
+	if err != nil {
+		return nil, fmt.Errorf("QueryViewGraph: query failed: %w", err)
+	}
+
+	if ccasr.IsEmpty() {
+		return make([]*ViewGraphDataSet, 0), nil
+	}
+	var sets ViewGraphData
+	for _, ccas := range ccasr.CloudCostSets {
+		items := make([]ViewGraphDataSetItem, 0)
+
+		for key, cc := range ccas.CloudCosts {
+			costMetric, err := cc.GetCostMetric(request.CostMetricName)
+			if err != nil {
+				return nil, fmt.Errorf("QueryViewGraph: failed to get cost metric: %w", err)
+			}
+			items = append(items, ViewGraphDataSetItem{
+				Name:  key,
+				Value: costMetric.Cost,
+			})
+		}
+		sort.SliceStable(items, func(i, j int) bool {
+			return items[i].Value > items[j].Value
+		})
+
+		if len(items) > request.ChartItemsLength {
+			otherItems := items[request.ChartItemsLength:]
+			newItems := items[:request.ChartItemsLength]
+			// Rename last item other and add all other values into it
+			newItems[request.ChartItemsLength-1].Name = "Other"
+			for _, item := range otherItems {
+				newItems[request.ChartItemsLength-1].Value += item.Value
+			}
+			items = newItems
+		}
+
+		sets = append(sets, &ViewGraphDataSet{
+			Start: *ccas.Window.Start(),
+			End:   *ccas.Window.End(),
+			Items: items,
+		})
+	}
+	return sets, nil
+}
+
+func (rq *RepositoryQuerier) QueryViewTotals(request ViewQueryRequest, ctx context.Context) (*ViewTableRow, int, error) {
+	ccasr, err := rq.Query(request.QueryRequest, ctx)
+	if err != nil {
+		return nil, -1, fmt.Errorf("QueryViewTotals: query failed: %w", err)
+	}
+	acc, err := ccasr.AccumulateAll()
+	if err != nil {
+		return nil, -1, fmt.Errorf("QueryViewTotals: accumulate failed: %w", err)
+	}
+	if acc.IsEmpty() {
+		return nil, 0, nil
+	}
+	count := len(acc.CloudCosts)
+
+	total, err := acc.Aggregate([]string{})
+	if err != nil {
+		return nil, -1, fmt.Errorf("QueryViewTotals: aggregate total failed: %w", err)
+	}
+
+	if total.IsEmpty() {
+		return nil, -1, fmt.Errorf("QueryViewTotals: missing total: %w", err)
+	}
+
+	if len(total.CloudCosts) != 1 {
+		return nil, -1, fmt.Errorf("QueryViewTotals: total did not aggregate: %w", err)
+	}
+
+	cm, err := total.CloudCosts[""].GetCostMetric(request.CostMetricName)
+	if err != nil {
+		return nil, -1, fmt.Errorf("QueryViewTotals: failed to retrieve cost metric: %w", err)
+	}
+	return &ViewTableRow{
+		Name:              "Totals",
+		KubernetesPercent: cm.KubernetesPercent,
+		Cost:              cm.Cost,
+	}, count, nil
+}
+
+func (rq *RepositoryQuerier) QueryViewTable(request ViewQueryRequest, ctx context.Context) (ViewTableRows, error) {
+	ccasr, err := rq.Query(request.QueryRequest, ctx)
+	if err != nil {
+		return nil, fmt.Errorf("QueryViewTable: query failed: %w", err)
+	}
+	acc, err := ccasr.AccumulateAll()
+	if err != nil {
+		return nil, fmt.Errorf("QueryViewTable: accumulate failed: %w", err)
+	}
+
+	var rows ViewTableRows
+	for key, cloudCost := range acc.CloudCosts {
+		costMetric, err2 := cloudCost.GetCostMetric(request.CostMetricName)
+		if err2 != nil {
+			return nil, fmt.Errorf("QueryViewTable: failed to retrieve cost metric: %w", err)
+		}
+		vtr := &ViewTableRow{
+			Name:              key,
+			KubernetesPercent: costMetric.KubernetesPercent,
+			Cost:              costMetric.Cost,
+		}
+		rows = append(rows, vtr)
+	}
+	// Sort Results
+
+	// Sort by Name to ensure consistent return
+	sort.SliceStable(rows, func(i, j int) bool {
+		return rows[i].Name > rows[j].Name
+	})
+
+	switch request.SortColumn {
+	case SortFieldName:
+		if request.SortDirection == SortDirectionAscending {
+			sort.SliceStable(rows, func(i, j int) bool {
+				return rows[i].Name < rows[j].Name
+			})
+		}
+
+	case SortFieldCost:
+		if request.SortDirection == SortDirectionAscending {
+			sort.SliceStable(rows, func(i, j int) bool {
+				return rows[i].Cost < rows[j].Cost
+			})
+		} else {
+			sort.SliceStable(rows, func(i, j int) bool {
+				return rows[i].Cost > rows[j].Cost
+			})
+		}
+	case SortFieldKubernetesPercent:
+		if request.SortDirection == SortDirectionAscending {
+			sort.SliceStable(rows, func(i, j int) bool {
+				return rows[i].KubernetesPercent < rows[j].KubernetesPercent
+			})
+		} else {
+			sort.SliceStable(rows, func(i, j int) bool {
+				return rows[i].KubernetesPercent > rows[j].KubernetesPercent
+			})
+		}
+
+	default:
+		return nil, fmt.Errorf("invalid sort field '%s'", string(request.SortColumn))
+	}
+
+	// paginate sorted results
+	if request.Offset > len(rows) {
+		return make([]*ViewTableRow, 0), nil
+	}
+
+	if request.Limit > 0 {
+		limit := request.Offset + request.Limit
+		if limit > len(rows) {
+			return rows[request.Offset:], nil
+		}
+		return rows[request.Offset:limit], nil
+	}
+
+	if request.Offset > 0 {
+		return rows[request.Offset:], nil
+	}
+
+	return rows, nil
+}

+ 24 - 0
pkg/cloudcost/status.go

@@ -0,0 +1,24 @@
+package cloudcost
+
+import (
+	"time"
+
+	cloudconfig "github.com/opencost/opencost/pkg/cloud"
+)
+
+// Status gives the details and metadata of a CloudCost integration
+type Status struct {
+	Key              string             `json:"key"`
+	Source           string             `json:"source"`
+	Provider         string             `json:"provider"`
+	Active           bool               `json:"active"`
+	Valid            bool               `json:"valid"`
+	LastRun          time.Time          `json:"lastRun"`
+	NextRun          time.Time          `json:"nextRun"`
+	RefreshRate      string             `json:"RefreshRate"`
+	Created          time.Time          `json:"created"`
+	Runs             int                `json:"runs"`
+	Coverage         string             `json:"coverage"`
+	ConnectionStatus string             `json:"connectionStatus"`
+	Config           cloudconfig.Config `json:"config"`
+}

+ 107 - 0
pkg/cloudcost/view.go

@@ -0,0 +1,107 @@
+package cloudcost
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/pkg/util/mathutil"
+)
+
+// View serves data to the Cloud Cost front end, in the
+// structure it requires (i.e. a graph and a table).
+type View struct {
+	GraphData  ViewGraphData `json:"graphData"`
+	TableTotal *ViewTableRow `json:"tableTotal"`
+	TableRows  ViewTableRows `json:"tableRows"`
+}
+
+type ViewTableRows []*ViewTableRow
+
+func (vtrs ViewTableRows) Equal(that ViewTableRows) bool {
+	if len(vtrs) != len(that) {
+		return false
+	}
+
+	for i := 0; i < len(vtrs); i++ {
+		if !vtrs[i].Equal(that[i]) {
+			return false
+		}
+	}
+
+	return true
+}
+
+type ViewTableRow struct {
+	Name              string  `json:"name"`
+	KubernetesPercent float64 `json:"kubernetesPercent"`
+	Cost              float64 `json:"cost"`
+}
+
+func (vtr *ViewTableRow) Equal(that *ViewTableRow) bool {
+	if vtr.Name != that.Name {
+		return false
+	}
+
+	if !mathutil.Approximately(vtr.KubernetesPercent, that.KubernetesPercent) {
+		return false
+	}
+
+	if !mathutil.Approximately(vtr.Cost, that.Cost) {
+		return false
+	}
+
+	return true
+}
+
+type ViewGraphData []*ViewGraphDataSet
+
+func (vgd ViewGraphData) Equal(that ViewGraphData) bool {
+	if len(vgd) != len(that) {
+		return false
+	}
+
+	for i := 0; i < len(vgd); i++ {
+		if !vgd[i].Equal(that[i]) {
+			return false
+		}
+	}
+
+	return true
+}
+
+type ViewGraphDataSet struct {
+	Start time.Time              `json:"start"`
+	End   time.Time              `json:"end"`
+	Items []ViewGraphDataSetItem `json:"items"`
+}
+
+// NOTE: does not compare start and end times, just that the items are equal
+func (vgds *ViewGraphDataSet) Equal(that *ViewGraphDataSet) bool {
+	if len(vgds.Items) != len(that.Items) {
+		return false
+	}
+
+	for i := 0; i < len(vgds.Items); i++ {
+		if !vgds.Items[i].Equal(that.Items[i]) {
+			return false
+		}
+	}
+
+	return true
+}
+
+type ViewGraphDataSetItem struct {
+	Name  string  `json:"name"`
+	Value float64 `json:"value"`
+}
+
+func (vgdsi ViewGraphDataSetItem) Equal(that ViewGraphDataSetItem) bool {
+	if vgdsi.Name != that.Name {
+		return false
+	}
+
+	if !mathutil.Approximately(vgdsi.Value, that.Value) {
+		return false
+	}
+
+	return true
+}

+ 1 - 1
pkg/cmd/agent/agent.go

@@ -117,7 +117,7 @@ func newPrometheusClient() (prometheus.Client, error) {
 	api := prometheusAPI.NewAPI(promCli)
 	_, err = api.Config(context.Background())
 	if err != nil {
-		log.Infof("No valid prometheus config file at %s. Error: %s . Troubleshooting help available at: %s. Ignore if using cortex/thanos here.", address, err.Error(), prom.PrometheusTroubleshootingURL)
+		log.Infof("No valid prometheus config file at %s. Error: %s . Troubleshooting help available at: %s. Ignore if using cortex/mimir/thanos here.", address, err.Error(), prom.PrometheusTroubleshootingURL)
 	} else {
 		log.Infof("Retrieved a prometheus config file from: %s", address)
 	}

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

@@ -7,6 +7,7 @@ import (
 	"time"
 
 	"github.com/julienschmidt/httprouter"
+	"github.com/opencost/opencost/pkg/cloudcost"
 	"github.com/prometheus/client_golang/prometheus/promhttp"
 	"github.com/rs/cors"
 
@@ -39,11 +40,28 @@ func Execute(opts *CostModelOpts) error {
 		log.Errorf("couldn't start CSV export worker: %v", err)
 	}
 
+	if env.IsCloudCostEnabled() {
+		repo := cloudcost.NewMemoryRepository()
+		a.CloudCostPipelineService = cloudcost.NewPipelineService(repo, a.CloudConfigController, cloudcost.DefaultIngestorConfiguration())
+		repoQuerier := cloudcost.NewRepositoryQuerier(repo)
+		a.CloudCostQueryService = cloudcost.NewQueryService(repoQuerier, repoQuerier)
+	}
+
 	rootMux := http.NewServeMux()
 	a.Router.GET("/healthz", Healthz)
 	a.Router.GET("/allocation", a.ComputeAllocationHandler)
 	a.Router.GET("/allocation/summary", a.ComputeAllocationHandlerSummary)
 	a.Router.GET("/assets", a.ComputeAssetsHandler)
+
+	a.Router.GET("/cloudCost", a.CloudCostQueryService.GetCloudCostHandler())
+	a.Router.GET("/cloudCost/view/graph", a.CloudCostQueryService.GetCloudCostViewGraphHandler())
+	a.Router.GET("/cloudCost/view/totals", a.CloudCostQueryService.GetCloudCostViewTotalsHandler())
+	a.Router.GET("/cloudCost/view/table", a.CloudCostQueryService.GetCloudCostViewTableHandler())
+
+	a.Router.GET("/cloudCost/status", a.CloudCostPipelineService.GetCloudCostStatusHandler())
+	a.Router.GET("/cloudCost/rebuild", a.CloudCostPipelineService.GetCloudCostRebuildHandler())
+	a.Router.GET("/cloudCost/repair", a.CloudCostPipelineService.GetCloudCostRepairHandler())
+
 	rootMux.Handle("/", a.Router)
 	rootMux.Handle("/metrics", promhttp.Handler())
 	telemetryHandler := metrics.ResponseMetricMiddleware(rootMux)

+ 1 - 1
pkg/costmodel/aggregation.go

@@ -2215,7 +2215,7 @@ func (a *Accesses) ComputeAllocationHandlerSummary(w http.ResponseWriter, r *htt
 
 	sasl := []*kubecost.SummaryAllocationSet{}
 	for _, as := range asr.Slice() {
-		sas := kubecost.NewSummaryAllocationSet(as, nil, []kubecost.AllocationMatchFunc{}, false, false)
+		sas := kubecost.NewSummaryAllocationSet(as, nil, nil, false, false)
 		sasl = append(sasl, sas)
 	}
 	sasr := kubecost.NewSummaryAllocationSetRange(sasl...)

+ 3 - 2
pkg/costmodel/allocation.go

@@ -295,8 +295,9 @@ func (cm *CostModel) ComputeAllocation(start, end time.Time, resolution time.Dur
 // it supposed to be a good indicator of available allocation data
 func (cm *CostModel) DateRange() (time.Time, time.Time, error) {
 	ctx := prom.NewNamedContext(cm.PrometheusClient, prom.AllocationContextName)
+	exportCsvDaysFmt := fmt.Sprintf("%dd", env.GetExportCSVMaxDays())
 
-	resOldest, _, err := ctx.QuerySync(fmt.Sprintf(queryFmtOldestSample, env.GetPromClusterFilter(), "90d", "1h"))
+	resOldest, _, err := ctx.QuerySync(fmt.Sprintf(queryFmtOldestSample, env.GetPromClusterFilter(), exportCsvDaysFmt, "1h"))
 	if err != nil {
 		return time.Time{}, time.Time{}, fmt.Errorf("querying oldest sample: %w", err)
 	}
@@ -305,7 +306,7 @@ func (cm *CostModel) DateRange() (time.Time, time.Time, error) {
 	}
 	oldest := time.Unix(int64(resOldest[0].Values[0].Value), 0)
 
-	resNewest, _, err := ctx.QuerySync(fmt.Sprintf(queryFmtNewestSample, env.GetPromClusterFilter(), "90d", "1h"))
+	resNewest, _, err := ctx.QuerySync(fmt.Sprintf(queryFmtNewestSample, env.GetPromClusterFilter(), exportCsvDaysFmt, "1h"))
 	if err != nil {
 		return time.Time{}, time.Time{}, fmt.Errorf("querying newest sample: %w", err)
 	}

+ 9 - 5
pkg/costmodel/cluster.go

@@ -179,8 +179,8 @@ func ClusterDisks(client prometheus.Client, provider models.Provider, start, end
 	queryPVCInfo := fmt.Sprintf(`avg(avg_over_time(kube_persistentvolumeclaim_info{%s}[%s])) by (%s, volumename, persistentvolumeclaim, namespace)`, env.GetPromClusterFilter(), durStr, env.GetPromClusterLabel())
 	queryLocalStorageCost := fmt.Sprintf(`sum_over_time(sum(container_fs_limit_bytes{device!="tmpfs", id="/", %s}) by (instance, %s)[%s:%dm]) / 1024 / 1024 / 1024 * %f * %f`, env.GetPromClusterFilter(), env.GetPromClusterLabel(), durStr, minsPerResolution, hourlyToCumulative, costPerGBHr)
 	queryLocalStorageUsedCost := fmt.Sprintf(`sum_over_time(sum(container_fs_usage_bytes{device!="tmpfs", id="/", %s}) by (instance, %s)[%s:%dm]) / 1024 / 1024 / 1024 * %f * %f`, env.GetPromClusterFilter(), env.GetPromClusterLabel(), durStr, minsPerResolution, hourlyToCumulative, costPerGBHr)
-	queryLocalStorageUsedAvg := fmt.Sprintf(`avg(avg_over_time(container_fs_usage_bytes{device!="tmpfs", id="/", %s}[%s])) by (instance, %s)`, env.GetPromClusterFilter(), durStr, env.GetPromClusterLabel())
-	queryLocalStorageUsedMax := fmt.Sprintf(`max(max_over_time(container_fs_usage_bytes{device!="tmpfs", id="/", %s}[%s])) by (instance, %s)`, env.GetPromClusterFilter(), durStr, env.GetPromClusterLabel())
+	queryLocalStorageUsedAvg := fmt.Sprintf(`avg(sum(avg_over_time(container_fs_usage_bytes{device!="tmpfs", id="/", %s}[%s])) by (instance, %s, job)) by (instance, %s)`, env.GetPromClusterFilter(), durStr, env.GetPromClusterLabel(), env.GetPromClusterLabel())
+	queryLocalStorageUsedMax := fmt.Sprintf(`max(sum(max_over_time(container_fs_usage_bytes{device!="tmpfs", id="/", %s}[%s])) by (instance, %s, job)) by (instance, %s)`, env.GetPromClusterFilter(), durStr, env.GetPromClusterLabel(), env.GetPromClusterLabel())
 	queryLocalStorageBytes := fmt.Sprintf(`avg_over_time(sum(container_fs_limit_bytes{device!="tmpfs", id="/", %s}) by (instance, %s)[%s:%dm])`, env.GetPromClusterFilter(), env.GetPromClusterLabel(), durStr, minsPerResolution)
 	queryLocalActiveMins := fmt.Sprintf(`count(node_total_hourly_cost{%s}) by (%s, node)[%s:%dm]`, env.GetPromClusterFilter(), env.GetPromClusterLabel(), durStr, minsPerResolution)
 
@@ -848,10 +848,14 @@ func ClusterLoadBalancers(client prometheus.Client, start, end time.Time) (map[L
 
 			// interpolate any missing data
 			resultMins := lb.Minutes
-			scaleFactor := (resultMins + resolution.Minutes()) / resultMins
+			if resultMins > 0 {
+				scaleFactor := (resultMins + resolution.Minutes()) / resultMins
 
-			hrs := (lb.Minutes * scaleFactor) / 60.0
-			lb.Cost += lbPricePerHr * hrs
+				hrs := (lb.Minutes * scaleFactor) / 60.0
+				lb.Cost += lbPricePerHr * hrs
+			} else {
+				log.DedupedWarningf(20, "ClusterLoadBalancers: found zero minutes for key: %v", key)
+			}
 
 			if lb.Ip != "" && lb.Ip != providerID {
 				log.DedupedWarningf(5, "ClusterLoadBalancers: multiple IPs per load balancer not supported, using most recent IP")

+ 27 - 37
pkg/costmodel/costmodel.go

@@ -144,39 +144,25 @@ const (
 		label_replace(
 			label_replace(
 				avg(
-					count_over_time(kube_pod_container_resource_requests{resource="memory", unit="byte", container!="",container!="POD", node!="", %s}[%s] %s)
-					*
-					avg_over_time(kube_pod_container_resource_requests{resource="memory", unit="byte", container!="",container!="POD", node!="", %s}[%s] %s)
+					sum_over_time(kube_pod_container_resource_requests{resource="memory", unit="byte", container!="",container!="POD", node!="", %s}[%s] %s)
 				) by (namespace,container,pod,node,%s) , "container_name","$1","container","(.+)"
 			), "pod_name","$1","pod","(.+)"
 		)
 	) by (namespace,container_name,pod_name,node,%s)`
-	queryRAMUsageStr = `sort_desc(
-		avg(
-			label_replace(
-				label_replace(
-					label_replace(
-						count_over_time(container_memory_working_set_bytes{container!="", container!="POD", instance!="", %s}[%s] %s), "node", "$1", "instance", "(.+)"
-					), "container_name", "$1", "container", "(.+)"
-				), "pod_name", "$1", "pod", "(.+)"
-			)
-			*
+	queryRAMUsageStr = `avg(
+		label_replace(
 			label_replace(
 				label_replace(
-					label_replace(
-						avg_over_time(container_memory_working_set_bytes{container!="", container!="POD", instance!="", %s}[%s] %s), "node", "$1", "instance", "(.+)"
-					), "container_name", "$1", "container", "(.+)"
-				), "pod_name", "$1", "pod", "(.+)"
-			)
-		) by (namespace, container_name, pod_name, node, %s)
-	)`
+					sum_over_time(container_memory_working_set_bytes{container!="", container!="POD", instance!="", %s}[%s] %s), "node", "$1", "instance", "(.+)"
+				), "container_name", "$1", "container", "(.+)"
+			), "pod_name", "$1", "pod", "(.+)"
+		)
+	) by (namespace, container_name, pod_name, node, %s)`
 	queryCPURequestsStr = `avg(
 		label_replace(
 			label_replace(
 				avg(
-					count_over_time(kube_pod_container_resource_requests{resource="cpu", unit="core", container!="",container!="POD", node!="", %s}[%s] %s)
-					*
-					avg_over_time(kube_pod_container_resource_requests{resource="cpu", unit="core", container!="",container!="POD", node!="", %s}[%s] %s)
+					sum_over_time(kube_pod_container_resource_requests{resource="cpu", unit="core", container!="",container!="POD", node!="", %s}[%s] %s)
 				) by (namespace,container,pod,node,%s) , "container_name","$1","container","(.+)"
 			), "pod_name","$1","pod","(.+)"
 		)
@@ -196,9 +182,7 @@ const (
 		label_replace(
 			label_replace(
 				avg(
-					count_over_time(kube_pod_container_resource_requests{resource="nvidia_com_gpu", container!="",container!="POD", node!="", %s}[%s] %s)
-					*
-					avg_over_time(kube_pod_container_resource_requests{resource="nvidia_com_gpu", container!="",container!="POD", node!="", %s}[%s] %s)
+					sum_over_time(kube_pod_container_resource_requests{resource="nvidia_com_gpu", container!="",container!="POD", node!="", %s}[%s] %s)
 					* %f
 				) by (namespace,container,pod,node,%s) , "container_name","$1","container","(.+)"
 			), "pod_name","$1","pod","(.+)"
@@ -253,7 +237,7 @@ const (
 )
 
 func (cm *CostModel) ComputeCostData(cli prometheusClient.Client, cp costAnalyzerCloud.Provider, window string, offset string, filterNamespace string) (map[string]*CostData, error) {
-	queryRAMUsage := fmt.Sprintf(queryRAMUsageStr, env.GetPromClusterFilter(), window, offset, env.GetPromClusterFilter(), window, offset, env.GetPromClusterLabel())
+	queryRAMUsage := fmt.Sprintf(queryRAMUsageStr, env.GetPromClusterFilter(), window, offset, env.GetPromClusterLabel())
 	queryCPUUsage := fmt.Sprintf(queryCPUUsageStr, env.GetPromClusterFilter(), window, offset, env.GetPromClusterLabel())
 	queryNetZoneRequests := fmt.Sprintf(queryZoneNetworkUsage, env.GetPromClusterFilter(), window, "", env.GetPromClusterLabel())
 	queryNetRegionRequests := fmt.Sprintf(queryRegionNetworkUsage, env.GetPromClusterFilter(), window, "", env.GetPromClusterLabel())
@@ -333,6 +317,9 @@ func (cm *CostModel) ComputeCostData(cli prometheusClient.Client, cp costAnalyze
 	// Determine if there are vgpus configured and if so get the total allocatable number
 	// If there are no vgpus, the coefficient is set to 1.0
 	vgpuCount, err := getAllocatableVGPUs(cm.Cache)
+	if err != nil {
+		log.Warnf("getAllocatableVGCPUs error: %s", err.Error())
+	}
 	vgpuCoeff := 10.0
 	if vgpuCount > 0.0 {
 		vgpuCoeff = vgpuCount
@@ -1019,6 +1006,9 @@ func (cm *CostModel) GetNodeCost(cp costAnalyzerCloud.Provider) (map[string]*cos
 	nodes := make(map[string]*costAnalyzerCloud.Node)
 
 	vgpuCount, err := getAllocatableVGPUs(cm.Cache)
+	if err != nil {
+		return nil, err
+	}
 	vgpuCoeff := 10.0
 	if vgpuCount > 0.0 {
 		vgpuCoeff = vgpuCount
@@ -1161,14 +1151,14 @@ func (cm *CostModel) GetNodeCost(cp costAnalyzerCloud.Provider) (map[string]*cos
 
 			cpuToRAMRatio := defaultCPU / defaultRAM
 			if math.IsNaN(cpuToRAMRatio) {
-				log.Warnf("cpuToRAMRatio[defaultCPU: %f / defaultRAM: %f] is NaN. Setting to 0.", defaultCPU, defaultRAM)
-				cpuToRAMRatio = 0
+				log.Warnf("cpuToRAMRatio[defaultCPU: %f / defaultRAM: %f] is NaN. Setting to 10.", defaultCPU, defaultRAM)
+				cpuToRAMRatio = 10
 			}
 
 			gpuToRAMRatio := defaultGPU / defaultRAM
 			if math.IsNaN(gpuToRAMRatio) {
-				log.Warnf("gpuToRAMRatio is NaN. Setting to 0.")
-				gpuToRAMRatio = 0
+				log.Warnf("gpuToRAMRatio is NaN. Setting to 100.")
+				gpuToRAMRatio = 100
 			}
 
 			ramGB := ram / 1024 / 1024 / 1024
@@ -1244,8 +1234,8 @@ func (cm *CostModel) GetNodeCost(cp costAnalyzerCloud.Provider) (map[string]*cos
 
 			cpuToRAMRatio := defaultCPU / defaultRAM
 			if math.IsNaN(cpuToRAMRatio) {
-				log.Warnf("cpuToRAMRatio[defaultCPU: %f / defaultRAM: %f] is NaN. Setting to 0.", defaultCPU, defaultRAM)
-				cpuToRAMRatio = 0
+				log.Warnf("cpuToRAMRatio[defaultCPU: %f / defaultRAM: %f] is NaN. Setting to 10.", defaultCPU, defaultRAM)
+				cpuToRAMRatio = 10
 			}
 
 			ramGB := ram / 1024 / 1024 / 1024
@@ -1693,11 +1683,11 @@ func (cm *CostModel) costDataRange(cli prometheusClient.Client, cp costAnalyzerC
 
 	queryRAMAlloc := fmt.Sprintf(queryRAMAllocationByteHours, env.GetPromClusterFilter(), resStr, env.GetPromClusterLabel(), scrapeIntervalSeconds)
 	queryCPUAlloc := fmt.Sprintf(queryCPUAllocationVCPUHours, env.GetPromClusterFilter(), resStr, env.GetPromClusterLabel(), scrapeIntervalSeconds)
-	queryRAMRequests := fmt.Sprintf(queryRAMRequestsStr, env.GetPromClusterFilter(), resStr, "", env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel(), env.GetPromClusterLabel())
-	queryRAMUsage := fmt.Sprintf(queryRAMUsageStr, env.GetPromClusterFilter(), resStr, "", env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel())
-	queryCPURequests := fmt.Sprintf(queryCPURequestsStr, env.GetPromClusterFilter(), resStr, "", env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel(), env.GetPromClusterLabel())
+	queryRAMRequests := fmt.Sprintf(queryRAMRequestsStr, env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel(), env.GetPromClusterLabel())
+	queryRAMUsage := fmt.Sprintf(queryRAMUsageStr, env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel())
+	queryCPURequests := fmt.Sprintf(queryCPURequestsStr, env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel(), env.GetPromClusterLabel())
 	queryCPUUsage := fmt.Sprintf(queryCPUUsageStr, env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel())
-	queryGPURequests := fmt.Sprintf(queryGPURequestsStr, env.GetPromClusterFilter(), resStr, "", env.GetPromClusterFilter(), resStr, "", resolution.Hours(), env.GetPromClusterLabel(), env.GetPromClusterLabel(), env.GetPromClusterLabel(), env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel())
+	queryGPURequests := fmt.Sprintf(queryGPURequestsStr, env.GetPromClusterFilter(), resStr, "", resolution.Hours(), env.GetPromClusterLabel(), env.GetPromClusterLabel(), env.GetPromClusterLabel(), env.GetPromClusterFilter(), resStr, "", env.GetPromClusterLabel())
 	queryPVRequests := fmt.Sprintf(queryPVRequestsStr, env.GetPromClusterFilter(), env.GetPromClusterLabel(), env.GetPromClusterLabel(), env.GetPromClusterFilter(), env.GetPromClusterLabel(), env.GetPromClusterLabel())
 	queryPVCAllocation := fmt.Sprintf(queryPVCAllocationFmt, env.GetPromClusterFilter(), resStr, env.GetPromClusterLabel(), scrapeIntervalSeconds)
 	queryPVHourlyCost := fmt.Sprintf(queryPVHourlyCostFmt, env.GetPromClusterFilter(), resStr)

+ 48 - 36
pkg/costmodel/router.go

@@ -17,8 +17,10 @@ import (
 
 	"github.com/microcosm-cc/bluemonday"
 	"github.com/opencost/opencost/pkg/cloud/aws"
+	cloudconfig "github.com/opencost/opencost/pkg/cloud/config"
 	"github.com/opencost/opencost/pkg/cloud/gcp"
 	"github.com/opencost/opencost/pkg/cloud/provider"
+	"github.com/opencost/opencost/pkg/cloudcost"
 	"github.com/opencost/opencost/pkg/config"
 	"github.com/opencost/opencost/pkg/kubeconfig"
 	"github.com/opencost/opencost/pkg/metrics"
@@ -82,23 +84,26 @@ var (
 // Accesses defines a singleton application instance, providing access to
 // Prometheus, Kubernetes, the cloud provider, and caches.
 type Accesses struct {
-	Router              *httprouter.Router
-	PrometheusClient    prometheus.Client
-	ThanosClient        prometheus.Client
-	KubeClientSet       kubernetes.Interface
-	ClusterCache        clustercache.ClusterCache
-	ClusterMap          clusters.ClusterMap
-	CloudProvider       models.Provider
-	ConfigFileManager   *config.ConfigFileManager
-	ClusterInfoProvider clusters.ClusterInfoProvider
-	Model               *CostModel
-	MetricsEmitter      *CostModelMetricsEmitter
-	OutOfClusterCache   *cache.Cache
-	AggregateCache      *cache.Cache
-	CostDataCache       *cache.Cache
-	ClusterCostsCache   *cache.Cache
-	CacheExpiration     map[time.Duration]time.Duration
-	AggAPI              Aggregator
+	Router                   *httprouter.Router
+	PrometheusClient         prometheus.Client
+	ThanosClient             prometheus.Client
+	KubeClientSet            kubernetes.Interface
+	ClusterCache             clustercache.ClusterCache
+	ClusterMap               clusters.ClusterMap
+	CloudProvider            models.Provider
+	ConfigFileManager        *config.ConfigFileManager
+	CloudConfigController    *cloudconfig.Controller
+	CloudCostPipelineService *cloudcost.PipelineService
+	CloudCostQueryService    *cloudcost.QueryService
+	ClusterInfoProvider      clusters.ClusterInfoProvider
+	Model                    *CostModel
+	MetricsEmitter           *CostModelMetricsEmitter
+	OutOfClusterCache        *cache.Cache
+	AggregateCache           *cache.Cache
+	CostDataCache            *cache.Cache
+	ClusterCostsCache        *cache.Cache
+	CacheExpiration          map[time.Duration]time.Duration
+	AggAPI                   Aggregator
 	// SettingsCache stores current state of app settings
 	SettingsCache *cache.Cache
 	// settingsSubscribers tracks channels through which changes to different
@@ -1555,7 +1560,7 @@ func Initialize(additionalConfigWatchers ...*watcher.ConfigMapWatcher) *Accesses
 	api := prometheusAPI.NewAPI(promCli)
 	_, err = api.Config(context.Background())
 	if err != nil {
-		log.Infof("No valid prometheus config file at %s. Error: %s . Troubleshooting help available at: %s. Ignore if using cortex/thanos here.", address, err.Error(), prom.PrometheusTroubleshootingURL)
+		log.Infof("No valid prometheus config file at %s. Error: %s . Troubleshooting help available at: %s. Ignore if using cortex/mimir/thanos here.", address, err.Error(), prom.PrometheusTroubleshootingURL)
 	} else {
 		log.Infof("Retrieved a prometheus config file from: %s", address)
 	}
@@ -1714,25 +1719,27 @@ func Initialize(additionalConfigWatchers ...*watcher.ConfigMapWatcher) *Accesses
 	metricsEmitter := NewCostModelMetricsEmitter(promCli, k8sCache, cloudProvider, clusterInfoProvider, costModel)
 
 	a := &Accesses{
-		Router:              httprouter.New(),
-		PrometheusClient:    promCli,
-		ThanosClient:        thanosClient,
-		KubeClientSet:       kubeClientset,
-		ClusterCache:        k8sCache,
-		ClusterMap:          clusterMap,
-		CloudProvider:       cloudProvider,
-		ConfigFileManager:   confManager,
-		ClusterInfoProvider: clusterInfoProvider,
-		Model:               costModel,
-		MetricsEmitter:      metricsEmitter,
-		AggregateCache:      aggregateCache,
-		CostDataCache:       costDataCache,
-		ClusterCostsCache:   clusterCostsCache,
-		OutOfClusterCache:   outOfClusterCache,
-		SettingsCache:       settingsCache,
-		CacheExpiration:     cacheExpiration,
-		httpServices:        services.NewCostModelServices(),
+		Router:                httprouter.New(),
+		PrometheusClient:      promCli,
+		ThanosClient:          thanosClient,
+		KubeClientSet:         kubeClientset,
+		ClusterCache:          k8sCache,
+		ClusterMap:            clusterMap,
+		CloudProvider:         cloudProvider,
+		CloudConfigController: cloudconfig.NewController(cloudProvider),
+		ConfigFileManager:     confManager,
+		ClusterInfoProvider:   clusterInfoProvider,
+		Model:                 costModel,
+		MetricsEmitter:        metricsEmitter,
+		AggregateCache:        aggregateCache,
+		CostDataCache:         costDataCache,
+		ClusterCostsCache:     clusterCostsCache,
+		OutOfClusterCache:     outOfClusterCache,
+		SettingsCache:         settingsCache,
+		CacheExpiration:       cacheExpiration,
+		httpServices:          services.NewCostModelServices(),
 	}
+
 	// Use the Accesses instance, itself, as the CostModelAggregator. This is
 	// confusing and unconventional, but necessary so that we can swap it
 	// out for the ETL-adapted version elsewhere.
@@ -1811,6 +1818,11 @@ func Initialize(additionalConfigWatchers ...*watcher.ConfigMapWatcher) *Accesses
 	a.Router.GET("/logs/level", a.GetLogLevel)
 	a.Router.POST("/logs/level", a.SetLogLevel)
 
+	a.Router.GET("/cloud/config/export", a.CloudConfigController.GetExportConfigHandler())
+	a.Router.GET("/cloud/config/enable", a.CloudConfigController.GetEnableConfigHandler())
+	a.Router.GET("/cloud/config/disable", a.CloudConfigController.GetDisableConfigHandler())
+	a.Router.GET("/cloud/config/delete", a.CloudConfigController.GetDeleteConfigHandler())
+
 	a.httpServices.RegisterAll(a.Router)
 
 	return a

+ 37 - 0
pkg/env/costmodelenv.go

@@ -106,6 +106,15 @@ const (
 	ExportCSVFile       = "EXPORT_CSV_FILE"
 	ExportCSVLabelsList = "EXPORT_CSV_LABELS_LIST"
 	ExportCSVLabelsAll  = "EXPORT_CSV_LABELS_ALL"
+	ExportCSVMaxDays    = "EXPORT_CSV_MAX_DAYS"
+
+	DataRetentionDailyResolutionDaysEnvVar = "DATA_RETENTION_DAILY_RESOLUTION_DAYS"
+
+	CloudCostEnabledEnvVar          = "CLOUD_COST_ENABLED"
+	CloudCostMonthToDateIntervalVar = "CLOUD_COST_MONTH_TO_DATE_INTERVAL"
+	CloudCostRefreshRateHoursEnvVar = "CLOUD_COST_REFRESH_RATE_HOURS"
+	CloudCostQueryWindowDaysEnvVar  = "CLOUD_COST_QUERY_WINDOW_DAYS"
+	CloudCostRunWindowDaysEnvVar    = "CLOUD_COST_RUN_WINDOW_DAYS"
 )
 
 const DefaultConfigMountPath = "/var/configs"
@@ -128,6 +137,10 @@ func GetExportCSVLabelsList() []string {
 	return GetList(ExportCSVLabelsList, ",")
 }
 
+func GetExportCSVMaxDays() int {
+	return GetInt(ExportCSVMaxDays, 90)
+}
+
 // GetKubecostConfigBucket returns a file location for a mounted bucket configuration which is used to store
 // a subset of kubecost configurations that require sharing via remote storage.
 func GetKubecostConfigBucket() string {
@@ -603,3 +616,27 @@ func GetRegionOverrideList() []string {
 
 	return regionList
 }
+
+func GetDataRetentionDailyResolutionDays() int64 {
+	return GetInt64(DataRetentionDailyResolutionDaysEnvVar, 15)
+}
+
+func IsCloudCostEnabled() bool {
+	return GetBool(CloudCostEnabledEnvVar, false)
+}
+
+func GetCloudCostMonthToDateInterval() int {
+	return GetInt(CloudCostMonthToDateIntervalVar, 6)
+}
+
+func GetCloudCostRefreshRateHours() int64 {
+	return GetInt64(CloudCostRefreshRateHoursEnvVar, 6)
+}
+
+func GetCloudCostQueryWindowDays() int64 {
+	return GetInt64(CloudCostQueryWindowDaysEnvVar, 7)
+}
+
+func GetCloudCostRunWindowDays() int64 {
+	return GetInt64(CloudCostRunWindowDaysEnvVar, 3)
+}

+ 44 - 0
pkg/env/costmodelenv_test.go

@@ -41,3 +41,47 @@ func TestIsCacheDisabled(t *testing.T) {
 		})
 	}
 }
+
+func TestGetExportCSVMaxDays(t *testing.T) {
+	tests := []struct {
+		name string
+		want int
+		pre  func()
+	}{
+		{
+			name: "Ensure the default value is 90d",
+			want: 90,
+		},
+		{
+			name: "Ensure the value is 30 when EXPORT_CSV_MAX_DAYS is set to 30",
+			want: 30,
+			pre: func() {
+				os.Setenv("EXPORT_CSV_MAX_DAYS", "30")
+			},
+		},
+		{
+			name: "Ensure the value is 90 when EXPORT_CSV_MAX_DAYS is set to empty string",
+			want: 90,
+			pre: func() {
+				os.Setenv("EXPORT_CSV_MAX_DAYS", "")
+			},
+		},
+		{
+			name: "Ensure the value is 90 when EXPORT_CSV_MAX_DAYS is set to invalid value",
+			want: 90,
+			pre: func() {
+				os.Setenv("EXPORT_CSV_MAX_DAYS", "foo")
+			},
+		},
+	}
+	for _, tt := range tests {
+		if tt.pre != nil {
+			tt.pre()
+		}
+		t.Run(tt.name, func(t *testing.T) {
+			if got := GetExportCSVMaxDays(); got != tt.want {
+				t.Errorf("GetExportCSVMaxDays() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}

+ 40 - 0
pkg/filter21/ast/walker.go

@@ -2,6 +2,7 @@ package ast
 
 import (
 	"fmt"
+	"sort"
 	"strings"
 
 	"github.com/opencost/opencost/pkg/filter21/util"
@@ -367,3 +368,42 @@ func indent(depth int) string {
 	}
 	return strings.Repeat("  ", depth)
 }
+
+func Fields(filter FilterNode) []Field {
+	fields := map[Field]bool{}
+
+	PreOrderTraversal(filter, func(fn FilterNode, state TraversalState) {
+		if fn == nil {
+			return
+		}
+		switch n := fn.(type) {
+		case *EqualOp:
+			if n.Left.Field != nil {
+				fields[*n.Left.Field] = true
+			}
+		case *ContainsOp:
+			if n.Left.Field != nil {
+				fields[*n.Left.Field] = true
+			}
+		case *ContainsPrefixOp:
+			if n.Left.Field != nil {
+				fields[*n.Left.Field] = true
+			}
+		case *ContainsSuffixOp:
+			if n.Left.Field != nil {
+				fields[*n.Left.Field] = true
+			}
+		}
+	})
+
+	response := make([]Field, 0, len(fields))
+	for field := range fields {
+		response = append(response, field)
+	}
+
+	sort.Slice(response, func(i, j int) bool {
+		return response[i].Name < response[j].Name
+	})
+
+	return response
+}

+ 100 - 0
pkg/filter21/ast/walker_test.go

@@ -2,6 +2,8 @@ package ast
 
 import (
 	"fmt"
+	"reflect"
+	"testing"
 )
 
 func ExampleTransformLeaves() {
@@ -50,3 +52,101 @@ func ExampleTransformLeaves() {
 	//   }
 	// }
 }
+
+func TestFields(t *testing.T) {
+	type testCase struct {
+		name   string
+		filter FilterNode
+		exp    []Field
+	}
+
+	fieldNamespace := *NewField("namespace")
+	fieldCluster := *NewField("cluster")
+	fieldControllerKind := *NewField("controllerKind")
+
+	testCases := []testCase{
+		{
+			name:   ``,
+			filter: &VoidOp{},
+			exp:    []Field{},
+		},
+		{
+			name: `namespace:"kubecost"`,
+			filter: &EqualOp{
+				Left: Identifier{
+					Field: NewField("namespace"),
+					Key:   "",
+				},
+				Right: "kubecost",
+			},
+			exp: []Field{fieldNamespace},
+		},
+		{
+			name: `namespace: "kubecost" | cluster:"cluster-one" | controllerKind:"deployment"`,
+			filter: &OrOp{
+				Operands: []FilterNode{
+					&EqualOp{
+						Left: Identifier{
+							Field: NewField("namespace"),
+							Key:   "",
+						},
+						Right: "kubecost",
+					},
+					&EqualOp{
+						Left: Identifier{
+							Field: NewField("cluster"),
+							Key:   "",
+						},
+						Right: "cluster-one",
+					},
+					&EqualOp{
+						Left: Identifier{
+							Field: NewField("controllerKind"),
+							Key:   "",
+						},
+						Right: "deployment",
+					},
+				},
+			},
+			exp: []Field{fieldCluster, fieldControllerKind, fieldNamespace},
+		},
+		{
+			name: `namespace: "kubecost" | namespace:"kube-system" | namespace:"default"`,
+			filter: &OrOp{
+				Operands: []FilterNode{
+					&EqualOp{
+						Left: Identifier{
+							Field: NewField("namespace"),
+							Key:   "",
+						},
+						Right: "kubecost",
+					},
+					&EqualOp{
+						Left: Identifier{
+							Field: NewField("namespace"),
+							Key:   "",
+						},
+						Right: "kube-system",
+					},
+					&EqualOp{
+						Left: Identifier{
+							Field: NewField("namespace"),
+							Key:   "",
+						},
+						Right: "default",
+					},
+				},
+			},
+			exp: []Field{fieldNamespace},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			act := Fields(tc.filter)
+			if !reflect.DeepEqual(tc.exp, act) {
+				t.Errorf("fields do not match; expected %v; got %v", tc.exp, act)
+			}
+		})
+	}
+}

+ 70 - 24
pkg/kubecost/allocation.go

@@ -94,7 +94,10 @@ type Allocation struct {
 	ProportionalAssetResourceCosts ProportionalAssetResourceCosts `json:"proportionalAssetResourceCosts"` //@bingen:field[ignore]
 	SharedCostBreakdown            SharedCostBreakdowns           `json:"sharedCostBreakdown"`            //@bingen:field[ignore]
 	LoadBalancers                  LbAllocations                  `json:"LoadBalancers"`                  // @bingen:field[version=18]
-
+	// UnmountedPVCost is used to track how much of the cost in PVs is for an
+	// unmounted PV. It is not additive of PVCost() and need not be sent in API
+	// responses.
+	UnmountedPVCost float64 `json:"-"`
 }
 
 type LbAllocations map[string]*LbAllocation
@@ -682,6 +685,7 @@ func (a *Allocation) Clone() *Allocation {
 		ProportionalAssetResourceCosts: a.ProportionalAssetResourceCosts.Clone(),
 		SharedCostBreakdown:            a.SharedCostBreakdown.Clone(),
 		LoadBalancers:                  a.LoadBalancers.Clone(),
+		UnmountedPVCost:                a.UnmountedPVCost,
 	}
 }
 
@@ -781,6 +785,10 @@ func (a *Allocation) Equal(that *Allocation) bool {
 		return false
 	}
 
+	if !util.IsApproximately(a.UnmountedPVCost, that.UnmountedPVCost) {
+		return false
+	}
+
 	return true
 }
 
@@ -1027,21 +1035,12 @@ func (a *Allocation) IsUnallocated() bool {
 }
 
 // IsUnmounted is true if the given Allocation represents unmounted volume costs.
-// Note: Due to change in https://github.com/opencost/opencost/pull/1477 made to include Unmounted
-// PVC cost inside namespace we need to check unmounted suffix across all the three major properties
-// to actually classify it as unmounted.
 func (a *Allocation) IsUnmounted() bool {
 	if a == nil {
 		return false
 	}
 
-	props := a.Properties
-	if props != nil {
-		if props.Container == UnmountedSuffix && props.Namespace == UnmountedSuffix && props.Pod == UnmountedSuffix {
-			return true
-		}
-	}
-	return false
+	return strings.Contains(a.Name, UnmountedSuffix)
 }
 
 // Minutes returns the number of minutes the Allocation represents, as defined
@@ -1054,6 +1053,21 @@ func (a *Allocation) Minutes() float64 {
 	return a.End.Sub(a.Start).Minutes()
 }
 
+// SetUnmountedPVCost determines if the Allocation is unmounted and, if so, it
+// sets the UnmountedPVCost field appropriately.
+func (a *Allocation) SetUnmountedPVCost() float64 {
+	if a == nil {
+		return 0.0
+	}
+
+	if a.IsUnmounted() {
+		a.UnmountedPVCost = a.PVTotalCost()
+		return a.UnmountedPVCost
+	}
+
+	return 0.0
+}
+
 // Share adds the TotalCost of the given Allocation to the SharedCost of the
 // receiving Allocation. No Start, End, Window, or AllocationProperties are considered.
 // Neither Allocation is mutated; a new Allocation is always returned.
@@ -1201,6 +1215,7 @@ func (a *Allocation) add(that *Allocation) {
 	a.LoadBalancerCost += that.LoadBalancerCost
 	a.SharedCost += that.SharedCost
 	a.ExternalCost += that.ExternalCost
+	a.UnmountedPVCost += that.UnmountedPVCost
 
 	// Sum PVAllocations
 	a.PVs = a.PVs.Add(that.PVs)
@@ -1295,7 +1310,7 @@ type AllocationAggregationOptions struct {
 	MergeUnallocated                      bool
 	Reconcile                             bool
 	ReconcileNetwork                      bool
-	ShareFuncs                            []AllocationMatchFunc
+	Share                                 filter21.Filter
 	SharedNamespaces                      []string
 	SharedLabels                          map[string][]string
 	ShareIdle                             string
@@ -1395,6 +1410,16 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 		return fmt.Errorf("unexpected nil filter")
 	}
 
+	var sharer AllocationMatcher
+	if options.Share != nil {
+		compiler := NewAllocationMatchCompiler(options.LabelConfig)
+		var err error
+		sharer, err = compiler.Compile(options.Share)
+		if err != nil {
+			return fmt.Errorf("compiling sharer '%s': %w", ast.ToPreOrderShortString(options.Filter), err)
+		}
+	}
+
 	var allocatedTotalsMap map[string]map[string]float64
 
 	// If aggregateBy is nil, we don't aggregate anything. On the other hand,
@@ -1402,7 +1427,7 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 	// generateKey for why that makes sense.
 	shouldAggregate := aggregateBy != nil
 	shouldFilter := !isFilterEmpty(filter)
-	shouldShare := len(options.SharedHourlyCosts) > 0 || len(options.ShareFuncs) > 0
+	shouldShare := len(options.SharedHourlyCosts) > 0 || sharer != nil
 	if !shouldAggregate && !shouldFilter && !shouldShare && options.ShareIdle == ShareNone && !options.IncludeProportionalAssetResourceCosts {
 		// There is nothing for AggregateBy to do, so simply return nil
 		// before returning, set aggregated metadata inclusion in properties
@@ -1446,7 +1471,6 @@ 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 {
-
 		alloc.Properties.AggregatedMetadata = options.IncludeAggregatedMetadata
 		// build a parallel set of allocations to only be used
 		// for computing PARCs
@@ -1483,13 +1507,11 @@ func (as *AllocationSet) AggregateBy(aggregateBy []string, options *AllocationAg
 		// Shared allocations must be identified and separated prior to
 		// aggregation and filtering. That is, if any of the ShareFuncs return
 		// true for the allocation, then move it to shareSet.
-		for _, sf := range options.ShareFuncs {
-			if sf(alloc) {
-				delete(as.IdleKeys, alloc.Name)
-				delete(as.Allocations, alloc.Name)
-				shareSet.Insert(alloc)
-				break
-			}
+		if sharer != nil && sharer.Matches(alloc) {
+			delete(as.IdleKeys, alloc.Name)
+			delete(as.Allocations, alloc.Name)
+			shareSet.Insert(alloc)
+			continue
 		}
 	}
 
@@ -2032,8 +2054,8 @@ func computeShareCoeffs(aggregateBy []string, options *AllocationAggregationOpti
 		} else {
 			// Both are additive for weighted distribution, where each
 			// cumulative coefficient will be divided by the total.
-			coeffs[name] += alloc.TotalCost() - alloc.SharedCost
-			total += alloc.TotalCost() - alloc.SharedCost
+			coeffs[name] += alloc.TotalCost() - alloc.SharedCost - alloc.UnmountedPVCost
+			total += alloc.TotalCost() - alloc.SharedCost - alloc.UnmountedPVCost
 		}
 	}
 
@@ -2314,7 +2336,7 @@ func (a *Allocation) determineSharingName(options *AllocationAggregationOptions)
 
 	// grab SharedLabels keys and sort them, to keep this function deterministic
 	var labelKeys []string
-	for labelKey, _ := range options.SharedLabels {
+	for labelKey := range options.SharedLabels {
 		labelKeys = append(labelKeys, labelKey)
 	}
 	slices.Sort(labelKeys)
@@ -2825,6 +2847,30 @@ func (as *AllocationSet) Set(alloc *Allocation) error {
 	return nil
 }
 
+// GetUnmountedPVCost returns the sum of all UnmountedPVCost fields across all
+// allocations in the set.
+func (as *AllocationSet) GetUnmountedPVCost() float64 {
+	upvc := 0.0
+
+	for _, a := range as.Allocations {
+		upvc += a.UnmountedPVCost
+	}
+
+	return upvc
+}
+
+// SetUnmountedPVCost sets the UnmountedPVCost field for all allocations in the
+// set.
+func (as *AllocationSet) SetUnmountedPVCost() float64 {
+	upvc := 0.0
+
+	for _, a := range as.Allocations {
+		upvc += a.SetUnmountedPVCost()
+	}
+
+	return upvc
+}
+
 // Start returns the Start time of the AllocationSet window
 func (as *AllocationSet) Start() time.Time {
 	if as == nil {

+ 46 - 36
pkg/kubecost/allocation_test.go

@@ -10,7 +10,9 @@ import (
 
 	"github.com/davecgh/go-spew/spew"
 	filter21 "github.com/opencost/opencost/pkg/filter21"
+	"github.com/opencost/opencost/pkg/filter21/allocation"
 	afilter "github.com/opencost/opencost/pkg/filter21/allocation"
+	"github.com/opencost/opencost/pkg/filter21/ast"
 	"github.com/opencost/opencost/pkg/filter21/ops"
 	"github.com/opencost/opencost/pkg/log"
 	"github.com/opencost/opencost/pkg/util"
@@ -718,25 +720,27 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	idleTotalCost := 30.0
 	sharedOverheadHourlyCost := 7.0
 
-	// Match Functions
-	isNamespace3 := func(a *Allocation) bool {
-		ns := a.Properties.Namespace
-		return ns == "namespace3"
-	}
+	// Match filters
 
-	isApp1 := func(a *Allocation) bool {
-		ls := a.Properties.Labels
-		if app, ok := ls["app"]; ok && app == "app1" {
-			return true
+	// This is ugly, but required because cannot import filterutil due to import cycle
+	namespaceEquals := func(ns string) *ast.EqualOp {
+		return &ast.EqualOp{
+			Left: ast.Identifier{
+				Field: ast.NewField(allocation.FieldNamespace),
+				Key:   "",
+			},
+			Right: ns,
 		}
-		return false
 	}
 
-	// Filters
-	isNamespace := func(matchNamespace string) func(*Allocation) bool {
-		return func(a *Allocation) bool {
-			namespace := a.Properties.Namespace
-			return namespace == matchNamespace
+	// This is ugly, but required because cannot import filterutil due to import cycle
+	labelEquals := func(name, value string) *ast.EqualOp {
+		return &ast.EqualOp{
+			Left: ast.Identifier{
+				Field: ast.NewField(allocation.FieldLabel),
+				Key:   name,
+			},
+			Right: value,
 		}
 	}
 
@@ -1216,7 +1220,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				ShareFuncs: []AllocationMatchFunc{isNamespace3},
+				Share:      namespaceEquals("namespace3"),
 				ShareSplit: ShareEven,
 			},
 			numResults: numNamespaces,
@@ -1238,7 +1242,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				ShareFuncs:                            []AllocationMatchFunc{isNamespace3},
+				Share:                                 namespaceEquals("namespace3"),
 				ShareSplit:                            ShareWeighted,
 				IncludeProportionalAssetResourceCosts: true,
 			},
@@ -1298,7 +1302,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				ShareFuncs: []AllocationMatchFunc{isApp1},
+				Share:      labelEquals("app", "app1"),
 				ShareSplit: ShareEven,
 			},
 			numResults: numNamespaces + numIdle,
@@ -1486,7 +1490,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
 				Filter:     mustParseFilter(`namespace:"namespace2"`),
-				ShareFuncs: []AllocationMatchFunc{isNamespace("namespace1")},
+				Share:      namespaceEquals("namespace1"),
 				ShareSplit: ShareWeighted,
 			},
 			numResults: 1 + numIdle,
@@ -1505,7 +1509,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
 				Filter:     mustParseFilter(`namespace:"namespace2"`),
-				ShareFuncs: []AllocationMatchFunc{isNamespace("namespace1")},
+				Share:      namespaceEquals("namespace1"),
 				ShareSplit: ShareWeighted,
 				ShareIdle:  ShareWeighted,
 			},
@@ -1556,7 +1560,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			start: start,
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
-				ShareFuncs: []AllocationMatchFunc{isNamespace("namespace1")},
+				Share:      namespaceEquals("namespace1"),
 				ShareSplit: ShareWeighted,
 				ShareIdle:  ShareWeighted,
 			},
@@ -1611,7 +1615,7 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 			aggBy: []string{AllocationNamespaceProp},
 			aggOpts: &AllocationAggregationOptions{
 				Filter:     mustParseFilter(`namespace:"namespace2"`),
-				ShareFuncs: []AllocationMatchFunc{isNamespace("namespace1")},
+				Share:      namespaceEquals("namespace1"),
 				ShareSplit: ShareWeighted,
 				ShareIdle:  ShareWeighted,
 			},
@@ -1888,6 +1892,9 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 	}
 
 	for name, testcase := range cases {
+		if name != "4a" {
+			continue
+		}
 		t.Run(name, func(t *testing.T) {
 			if testcase.aggOpts != nil && testcase.aggOpts.IdleByNode {
 				as = GenerateMockAllocationSetNodeIdle(testcase.start)
@@ -1895,6 +1902,12 @@ func TestAllocationSet_AggregateBy(t *testing.T) {
 				as = GenerateMockAllocationSetClusterIdle(testcase.start)
 			}
 			err = as.AggregateBy(testcase.aggBy, testcase.aggOpts)
+
+			log.Infof("RESULTS")
+			for name, alloc := range as.Allocations {
+				log.Infof("  %s = %f", name, alloc.TotalCost())
+			}
+
 			assertAllocationSetTotals(t, as, name, err, testcase.numResults, testcase.totalCost)
 			assertAllocationTotals(t, as, name, testcase.results)
 			assertParcResults(t, as, name, testcase.expectedParcResults)
@@ -1947,14 +1960,15 @@ func TestAllocationSet_AggregateBy_SharedCostBreakdown(t *testing.T) {
 	end := time.Now().UTC().Truncate(day)
 	start := end.Add(-day)
 
-	isNamespace1 := func(a *Allocation) bool {
-		ns := a.Properties.Namespace
-		return ns == "namespace1"
-	}
-
-	isNamespace3 := func(a *Allocation) bool {
-		ns := a.Properties.Namespace
-		return ns == "namespace3"
+	// This is ugly, but required because cannot import filterutil due to import cycle
+	namespaceEquals := func(ns string) *ast.EqualOp {
+		return &ast.EqualOp{
+			Left: ast.Identifier{
+				Field: ast.NewField(allocation.FieldNamespace),
+				Key:   "",
+			},
+			Right: ns,
+		}
 	}
 
 	cases := map[string]struct {
@@ -1974,9 +1988,7 @@ func TestAllocationSet_AggregateBy_SharedCostBreakdown(t *testing.T) {
 			start: start,
 			aggBy: []string{"namespace"},
 			aggOpts: &AllocationAggregationOptions{
-				ShareFuncs: []AllocationMatchFunc{
-					isNamespace1,
-				},
+				Share:                      namespaceEquals("namespace1"),
 				IncludeSharedCostBreakdown: true,
 			},
 		},
@@ -1984,9 +1996,7 @@ func TestAllocationSet_AggregateBy_SharedCostBreakdown(t *testing.T) {
 			start: start,
 			aggBy: []string{"namespace"},
 			aggOpts: &AllocationAggregationOptions{
-				ShareFuncs: []AllocationMatchFunc{
-					isNamespace3,
-				},
+				Share:                      namespaceEquals("namespace3"),
 				IncludeSharedCostBreakdown: true,
 			},
 		},

+ 68 - 15
pkg/kubecost/asset.go

@@ -1589,6 +1589,10 @@ func (b *Breakdown) Clone() *Breakdown {
 
 // Equal returns true if the two Breakdowns are exact matches
 func (b *Breakdown) Equal(that *Breakdown) bool {
+	if b == nil && that == nil {
+		return true
+	}
+
 	if b == nil || that == nil {
 		return false
 	}
@@ -1889,6 +1893,32 @@ func (n *NodeOverhead) SanitizeNaN() {
 	}
 }
 
+func (n *NodeOverhead) Equal(other *NodeOverhead) bool {
+	if n == nil && other != nil {
+		return false
+	}
+	if n != nil && other == nil {
+		return false
+	}
+	if n == nil && other == nil {
+		return true
+	}
+
+	// This is okay because everything in NodeOverhead is a value type.
+	return *n == *other
+}
+
+func (n *NodeOverhead) Clone() *NodeOverhead {
+	if n == nil {
+		return nil
+	}
+	return &NodeOverhead{
+		CpuOverheadFraction:  n.CpuOverheadFraction,
+		RamOverheadFraction:  n.RamOverheadFraction,
+		OverheadCostFraction: n.OverheadCostFraction,
+	}
+}
+
 // Node is an Asset representing a single node in a cluster
 type Node struct {
 	Properties   *AssetProperties
@@ -2130,6 +2160,15 @@ func (n *Node) add(that *Node) {
 		n.RAMBreakdown.User = (n.RAMBreakdown.User*n.RAMCost + that.RAMBreakdown.User*that.RAMCost) / totalRAMCost
 	}
 
+	// These calculations have to happen before the mutable fields of n they
+	// depend on (cpu cost, ram cost) are mutated with post-add totals.
+	if n.Overhead != nil && that.Overhead != nil {
+		n.Overhead.RamOverheadFraction = (n.Overhead.RamOverheadFraction*n.RAMCost + that.Overhead.RamOverheadFraction*that.RAMCost) / totalRAMCost
+		n.Overhead.CpuOverheadFraction = (n.Overhead.CpuOverheadFraction*n.CPUCost + that.Overhead.CpuOverheadFraction*that.CPUCost) / totalCPUCost
+	} else {
+		n.Overhead = nil
+	}
+
 	n.CPUCoreHours += that.CPUCoreHours
 	n.RAMByteHours += that.RAMByteHours
 	n.GPUHours += that.GPUHours
@@ -2139,10 +2178,9 @@ func (n *Node) add(that *Node) {
 	n.RAMCost += that.RAMCost
 	n.Adjustment += that.Adjustment
 
-	if n.Overhead != nil && that.Overhead != nil {
-
-		n.Overhead.RamOverheadFraction = (n.Overhead.RamOverheadFraction*n.RAMCost + that.Overhead.RamOverheadFraction*that.RAMCost) / totalRAMCost
-		n.Overhead.CpuOverheadFraction = (n.Overhead.CpuOverheadFraction*n.CPUCost + that.Overhead.CpuOverheadFraction*that.CPUCost) / totalCPUCost
+	// The cost-weighted overhead is calculated after the node is totaled
+	// because the cost-weighted overhead is based on post-add data.
+	if n.Overhead != nil {
 		n.Overhead.OverheadCostFraction = ((n.Overhead.CpuOverheadFraction * n.CPUCost) + (n.Overhead.RamOverheadFraction * n.RAMCost)) / n.TotalCost()
 	}
 }
@@ -2171,6 +2209,7 @@ func (n *Node) Clone() Asset {
 		GPUCount:     n.GPUCount,
 		RAMCost:      n.RAMCost,
 		Preemptible:  n.Preemptible,
+		Overhead:     n.Overhead.Clone(),
 		Discount:     n.Discount,
 	}
 }
@@ -2233,6 +2272,9 @@ func (n *Node) Equal(a Asset) bool {
 	if n.Preemptible != that.Preemptible {
 		return false
 	}
+	if !n.Overhead.Equal(that.Overhead) {
+		return false
+	}
 
 	return true
 }
@@ -2345,9 +2387,16 @@ func (n *Node) SanitizeNaN() {
 		n.Preemptible = 0
 	}
 
-	n.CPUBreakdown.SanitizeNaN()
-	n.RAMBreakdown.SanitizeNaN()
-	n.Overhead.SanitizeNaN()
+	if n.CPUBreakdown != nil {
+		n.CPUBreakdown.SanitizeNaN()
+	}
+	if n.RAMBreakdown != nil {
+		n.RAMBreakdown.SanitizeNaN()
+	}
+
+	if n.Overhead != nil {
+		n.Overhead.SanitizeNaN()
+	}
 }
 
 // LoadBalancer is an Asset representing a single load balancer in a cluster
@@ -3211,24 +3260,27 @@ func (as *AssetSet) ReconciliationMatchMap() map[string]map[string]Asset {
 			continue
 		}
 
-		if _, ok := matchMap[props.ProviderID]; !ok {
-			matchMap[props.ProviderID] = make(map[string]Asset)
+		// we can't guarantee case in providerID for Azure provider to have map working for all providers,
+		// lower casing providerID  while creating reconciliation map
+		providerID := strings.ToLower(props.ProviderID)
+		if _, ok := matchMap[providerID]; !ok {
+			matchMap[providerID] = make(map[string]Asset)
 		}
 
 		// Check if a match is already in the map
-		if duplicateAsset, ok := matchMap[props.ProviderID][props.Category]; ok {
+		if duplicateAsset, ok := matchMap[providerID][props.Category]; ok {
 			log.DedupedWarningf(5, "duplicate asset found when reconciling for %s", props.ProviderID)
 			// if one asset already has adjustment use that one
 			if duplicateAsset.GetAdjustment() == 0 && asset.GetAdjustment() != 0 {
-				matchMap[props.ProviderID][props.Category] = asset
+				matchMap[providerID][props.Category] = asset
 			} else if duplicateAsset.GetAdjustment() != 0 && asset.GetAdjustment() == 0 {
-				matchMap[props.ProviderID][props.Category] = duplicateAsset
+				matchMap[providerID][props.Category] = duplicateAsset
 				// otherwise use the one with the higher cost
 			} else if duplicateAsset.TotalCost() < asset.TotalCost() {
-				matchMap[props.ProviderID][props.Category] = asset
+				matchMap[providerID][props.Category] = asset
 			}
 		} else {
-			matchMap[props.ProviderID][props.Category] = asset
+			matchMap[providerID][props.Category] = asset
 		}
 
 	}
@@ -3789,6 +3841,7 @@ func (asr *AssetSetRange) InsertRange(that *AssetSetRange) error {
 	}
 
 	var err error
+	var as *AssetSet
 	for _, thatAS := range that.Assets {
 		if thatAS == nil || err != nil {
 			continue
@@ -3800,7 +3853,7 @@ func (asr *AssetSetRange) InsertRange(that *AssetSetRange) error {
 			err = fmt.Errorf("cannot merge AssetSet into window that does not exist: %s", thatAS.Window.String())
 			continue
 		}
-		as, err := asr.Get(i)
+		as, err = asr.Get(i)
 		if err != nil {
 			err = fmt.Errorf("AssetSetRange index does not exist: %d", i)
 			continue

+ 74 - 3
pkg/kubecost/asset_test.go

@@ -384,6 +384,11 @@ func TestNode_Add(t *testing.T) {
 		Other:  0.0,
 	}
 	node1.SetAdjustment(1.6)
+	node1.Overhead = &NodeOverhead{
+		CpuOverheadFraction:  1,
+		RamOverheadFraction:  1,
+		OverheadCostFraction: 1,
+	}
 
 	node2 := NewNode("node2", "cluster1", "node2", *windows[0].start, *windows[0].end, windows[0])
 	node2.CPUCoreHours = 1.0 * hours
@@ -406,6 +411,11 @@ func TestNode_Add(t *testing.T) {
 		Other:  0.05,
 	}
 	node2.SetAdjustment(1.0)
+	node2.Overhead = &NodeOverhead{
+		CpuOverheadFraction:  0.6,
+		RamOverheadFraction:  0.75,
+		OverheadCostFraction: 0.7,
+	}
 
 	nodeT := node1.Add(node2).(*Node)
 
@@ -434,6 +444,19 @@ func TestNode_Add(t *testing.T) {
 	if nodeT.RAMBytes() != 4.0*gb {
 		t.Fatalf("Node.Add: expected %f; got %f", 4.0*gb, nodeT.RAMBytes())
 	}
+	if o := nodeT.Overhead; o == nil {
+		t.Errorf("Node.Add (1 + 2): expected overhead to be non-nil")
+	} else {
+		if o.CpuOverheadFraction < 0 || o.CpuOverheadFraction > 1 {
+			t.Errorf("CPU overhead must be within [0, 1], is: %f", o.CpuOverheadFraction)
+		}
+		if o.RamOverheadFraction < 0 || o.RamOverheadFraction > 1 {
+			t.Errorf("RAM overhead must be within [0, 1], is: %f", o.RamOverheadFraction)
+		}
+		if o.OverheadCostFraction < 0 || o.OverheadCostFraction > 1 {
+			t.Errorf("Cost-weighted overhead must be within [0, 1], is: %f", o.OverheadCostFraction)
+		}
+	}
 
 	// Check that the original assets are unchanged
 	if !util.IsApproximately(node1.TotalCost(), 10.0) {
@@ -459,6 +482,11 @@ func TestNode_Add(t *testing.T) {
 	node3.RAMCost = 0.0
 	node3.Discount = 0.3
 	node3.SetAdjustment(0.0)
+	node3.Overhead = &NodeOverhead{
+		CpuOverheadFraction:  0.6,
+		RamOverheadFraction:  0.75,
+		OverheadCostFraction: 0.7,
+	}
 
 	node4 := NewNode("node4", "cluster1", "node4", *windows[0].start, *windows[0].end, windows[0])
 	node4.CPUCoreHours = 0 * hours
@@ -469,6 +497,7 @@ func TestNode_Add(t *testing.T) {
 	node4.RAMCost = 0.0
 	node4.Discount = 0.1
 	node4.SetAdjustment(0.0)
+	node4.Overhead = nil
 
 	nodeT = node3.Add(node4).(*Node)
 
@@ -479,6 +508,9 @@ func TestNode_Add(t *testing.T) {
 	if nodeT.Discount != 0.2 {
 		t.Fatalf("Node.Add: expected %f; got %f", 0.2, nodeT.Discount)
 	}
+	if nodeT.Overhead != nil {
+		t.Errorf("Node.Add: adding a node with nil overhead should nil the resulting overhead")
+	}
 
 	// Accumulate: one nodes, two window
 	nodeA1 := NewNode("nodeA1", "cluster1", "nodeA1", *windows[0].start, *windows[0].end, windows[0])
@@ -548,7 +580,37 @@ func TestNode_Add(t *testing.T) {
 }
 
 func TestNode_Clone(t *testing.T) {
-	// TODO
+	cases := []struct {
+		name string
+
+		input *Node
+	}{
+		{
+			name: "overhead nil",
+			input: &Node{
+				Overhead: nil,
+			},
+		},
+		{
+			name: "overhead non-nil",
+			input: &Node{
+				Overhead: &NodeOverhead{
+					CpuOverheadFraction:  3,
+					RamOverheadFraction:  7,
+					OverheadCostFraction: 6,
+				},
+			},
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			result := c.input.Clone()
+			if !result.Equal(c.input) {
+				t.Errorf("clone result doesn't equal input")
+			}
+		})
+	}
 }
 
 func TestNode_MarshalJSON(t *testing.T) {
@@ -897,10 +959,13 @@ func TestAssetSetRange_AccumulateToAssetSet(t *testing.T) {
 		GenerateMockAssetSet(startD2, day),
 	)
 	err = asr.AggregateBy(nil, nil)
-	as, err = asr.AccumulateToAssetSet()
 	if err != nil {
 		t.Fatalf("AssetSetRange.AggregateBy: unexpected error: %s", err)
 	}
+	as, err = asr.AccumulateToAssetSet()
+	if err != nil {
+		t.Fatalf("AssetSetRange.AccumulateToAssetSet: unexpected error: %s", err)
+	}
 	assertAssetSet(t, as, "1a", window, map[string]float64{
 		"__undefined__/__undefined__/__undefined__/Compute/cluster1/Node/Kubernetes/gcp-node1/node1":                   21.00,
 		"__undefined__/__undefined__/__undefined__/Compute/cluster1/Node/Kubernetes/gcp-node2/node2":                   16.50,
@@ -921,10 +986,13 @@ func TestAssetSetRange_AccumulateToAssetSet(t *testing.T) {
 		GenerateMockAssetSet(startD2, day),
 	)
 	err = asr.AggregateBy([]string{}, nil)
-	as, err = asr.AccumulateToAssetSet()
 	if err != nil {
 		t.Fatalf("AssetSetRange.AggregateBy: unexpected error: %s", err)
 	}
+	as, err = asr.AccumulateToAssetSet()
+	if err != nil {
+		t.Fatalf("AssetSetRange.AccumulateToAssetSet: unexpected error: %s", err)
+	}
 	assertAssetSet(t, as, "1b", window, map[string]float64{
 		"": 180.00,
 	}, nil)
@@ -976,6 +1044,9 @@ func TestAssetSetRange_AccumulateToAssetSet(t *testing.T) {
 	)
 
 	err = asr.AggregateBy([]string{string(AssetTypeProp)}, nil)
+	if err != nil {
+		t.Fatalf("AssetSetRange.AggregateBy: unexpected error: %s", err)
+	}
 	as, err = asr.AccumulateToAssetSet()
 	if err != nil {
 		t.Fatalf("AssetSetRange.AggregateBy: unexpected error: %s", err)

+ 5 - 1
pkg/kubecost/assetprops.go

@@ -199,8 +199,12 @@ func (ap *AssetProperties) Clone() *AssetProperties {
 	return clone
 }
 
-// Equal returns true only if both AssetProperties are non-nil exact matches
+// Equal returns true only if both AssetProperties are matches
 func (ap *AssetProperties) Equal(that *AssetProperties) bool {
+	if ap == nil && that == nil {
+		return true
+	}
+
 	if ap == nil || that == nil {
 		return false
 	}

+ 199 - 63
pkg/kubecost/cloudcost.go

@@ -9,6 +9,7 @@ import (
 	filter21 "github.com/opencost/opencost/pkg/filter21"
 	"github.com/opencost/opencost/pkg/filter21/ast"
 	"github.com/opencost/opencost/pkg/log"
+	"github.com/opencost/opencost/pkg/util/timeutil"
 )
 
 // CloudCost represents a CUR line item, identifying a cloud resource and
@@ -131,22 +132,31 @@ func (cc *CloudCost) StringMapProperty(property string) (map[string]string, erro
 	}
 }
 
-func (cc *CloudCost) GetCostMetric(costMetricName string) (CostMetric, error) {
+func (cc *CloudCost) GetCostMetric(costMetricName CostMetricName) (CostMetric, error) {
 	switch costMetricName {
-	case ListCostMetric:
+	case CostMetricListCost:
 		return cc.ListCost, nil
-	case NetCostMetric:
+	case CostMetricNetCost:
 		return cc.NetCost, nil
-	case AmortizedNetCostMetric:
+	case CostMetricAmortizedNetCost:
 		return cc.AmortizedNetCost, nil
-	case InvoicedCostMetric:
+	case CostMetricInvoicedCost:
 		return cc.InvoicedCost, nil
-	case AmortizedCostMetric:
+	case CostMetricAmortizedCost:
 		return cc.AmortizedCost, nil
 	}
 	return CostMetric{}, fmt.Errorf("invalid Cost Metric: %s", costMetricName)
 }
 
+// WeightCostMetrics weights all the cost metrics with the given weightedAverage
+func (cc *CloudCost) WeightCostMetrics(weightedAverge float64) {
+	cc.ListCost.Cost *= weightedAverge
+	cc.NetCost.Cost *= weightedAverge
+	cc.AmortizedNetCost.Cost *= weightedAverge
+	cc.InvoicedCost.Cost *= weightedAverge
+	cc.AmortizedCost.Cost *= weightedAverge
+}
+
 // 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
@@ -362,9 +372,12 @@ func (ccs *CloudCostSet) Clone() *CloudCostSet {
 
 // 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
+	var aggProps []string
+	if ccs.AggregationProperties != nil {
+		aggProps = make([]string, len(ccs.AggregationProperties))
+		for i, v := range ccs.AggregationProperties {
+			aggProps[i] = v
+		}
 	}
 	return &CloudCostSet{
 		CloudCosts:            make(map[string]*CloudCost),
@@ -434,8 +447,8 @@ type CloudCostSetRange struct {
 
 // 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)
+func NewCloudCostSetRange(start time.Time, end time.Time, accumOpt AccumulateOption, integration string) (*CloudCostSetRange, error) {
+	windows, err := NewClosedWindow(start.UTC(), end.UTC()).GetAccumulateWindows(accumOpt)
 	if err != nil {
 		return nil, err
 	}
@@ -448,7 +461,6 @@ func NewCloudCostSetRange(start time.Time, end time.Time, window time.Duration,
 		cloudCostItemSets[i] = ccs
 	}
 	return &CloudCostSetRange{
-		Window:        NewWindow(&start, &end),
 		CloudCostSets: cloudCostItemSets,
 	}, nil
 }
@@ -459,7 +471,6 @@ func (ccsr *CloudCostSetRange) Clone() *CloudCostSetRange {
 		ccsSlice[i] = ccs.Clone()
 	}
 	return &CloudCostSetRange{
-		Window:        ccsr.Window.Clone(),
 		CloudCostSets: ccsSlice,
 	}
 }
@@ -473,12 +484,20 @@ func (ccsr *CloudCostSetRange) IsEmpty() bool {
 	return true
 }
 
-// Accumulate sums each CloudCostSet in the given range, returning a single cumulative
+// accumulate sums each CloudCostSet in the given range, returning a single cumulative
 // CloudCostSet for the entire range.
-func (ccsr *CloudCostSetRange) Accumulate() (*CloudCostSet, error) {
+func (ccsr *CloudCostSetRange) AccumulateAll() (*CloudCostSet, error) {
 	var cloudCostSet *CloudCostSet
 	var err error
 
+	if ccsr == nil {
+		return nil, fmt.Errorf("nil CloudCostSetRange in accumulation")
+	}
+
+	if len(ccsr.CloudCostSets) == 0 {
+		return nil, fmt.Errorf("CloudCostSetRange has empty CloudCostSet in accumulation")
+	}
+
 	for _, ccs := range ccsr.CloudCostSets {
 		if cloudCostSet == nil {
 			cloudCostSet = ccs.Clone()
@@ -493,6 +512,171 @@ func (ccsr *CloudCostSetRange) Accumulate() (*CloudCostSet, error) {
 	return cloudCostSet, nil
 }
 
+// Accumulate sums CloudCostSets based on the AccumulateOption (calendar week or calendar month).
+// The accumulated set is determined by the start of the window of the allocation set.
+func (ccsr *CloudCostSetRange) Accumulate(accumulateBy AccumulateOption) (*CloudCostSetRange, error) {
+	switch accumulateBy {
+	case AccumulateOptionNone:
+		return ccsr.accumulateByNone()
+	case AccumulateOptionAll:
+		return ccsr.accumulateByAll()
+	case AccumulateOptionHour:
+		return ccsr.accumulateByHour()
+	case AccumulateOptionDay:
+		return ccsr.accumulateByDay()
+	case AccumulateOptionWeek:
+		return ccsr.accumulateByWeek()
+	case AccumulateOptionMonth:
+		return ccsr.accumulateByMonth()
+	default:
+		// ideally, this should never happen
+		return nil, fmt.Errorf("unexpected error, invalid accumulateByType: %s", accumulateBy)
+	}
+}
+
+func (ccsr *CloudCostSetRange) accumulateByAll() (*CloudCostSetRange, error) {
+
+	ccs, err := ccsr.AccumulateAll()
+	if err != nil {
+		return nil, fmt.Errorf("error accumulating all:%s", err)
+	}
+
+	accumulated := &CloudCostSetRange{
+		CloudCostSets: []*CloudCostSet{ccs},
+	}
+	return accumulated, nil
+}
+
+func (ccsr *CloudCostSetRange) accumulateByNone() (*CloudCostSetRange, error) {
+	return ccsr.Clone(), nil
+}
+func (ccsr *CloudCostSetRange) accumulateByHour() (*CloudCostSetRange, error) {
+	// ensure that the summary allocation sets have a 1-hour window, if a set exists
+	if len(ccsr.CloudCostSets) > 0 && ccsr.CloudCostSets[0].Window.Duration() != time.Hour {
+		return nil, fmt.Errorf("window duration must equal 1 hour; got:%s", ccsr.CloudCostSets[0].Window.Duration())
+	}
+
+	return ccsr.Clone(), nil
+}
+
+func (ccsr *CloudCostSetRange) accumulateByDay() (*CloudCostSetRange, error) {
+	// if the allocation set window is 1-day, just return the existing allocation set range
+	if len(ccsr.CloudCostSets) > 0 && ccsr.CloudCostSets[0].Window.Duration() == time.Hour*24 {
+		return ccsr, nil
+	}
+
+	var toAccumulate *CloudCostSetRange
+	result := &CloudCostSetRange{}
+	for i, ccs := range ccsr.CloudCostSets {
+
+		if ccs.Window.Duration() != time.Hour {
+			return nil, fmt.Errorf("window duration must equal 1 hour; got:%s", ccs.Window.Duration())
+		}
+
+		hour := ccs.Window.Start().Hour()
+
+		if toAccumulate == nil {
+			toAccumulate = &CloudCostSetRange{}
+			ccs = ccs.Clone()
+		}
+
+		toAccumulate.Append(ccs)
+		accumulated, err := toAccumulate.accumulateByAll()
+		if err != nil {
+			return nil, fmt.Errorf("error accumulating result: %s", err)
+		}
+		toAccumulate = accumulated
+
+		if hour == 23 || i == len(ccsr.CloudCostSets)-1 {
+			if length := len(toAccumulate.CloudCostSets); length != 1 {
+				return nil, fmt.Errorf("failed accumulation, detected %d sets instead of 1", length)
+			}
+			result.Append(toAccumulate.CloudCostSets[0])
+			toAccumulate = nil
+		}
+	}
+	return result, nil
+}
+
+func (ccsr *CloudCostSetRange) accumulateByWeek() (*CloudCostSetRange, error) {
+	if len(ccsr.CloudCostSets) > 0 && ccsr.CloudCostSets[0].Window.Duration() == timeutil.Week {
+		return ccsr, nil
+	}
+
+	var toAccumulate *CloudCostSetRange
+	result := &CloudCostSetRange{}
+	for i, css := range ccsr.CloudCostSets {
+		if css.Window.Duration() != time.Hour*24 {
+			return nil, fmt.Errorf("window duration must equal 24 hours; got:%s", css.Window.Duration())
+		}
+
+		dayOfWeek := css.Window.Start().Weekday()
+
+		if toAccumulate == nil {
+			toAccumulate = &CloudCostSetRange{}
+			css = css.Clone()
+		}
+
+		toAccumulate.Append(css)
+		accumulated, err := toAccumulate.accumulateByAll()
+		if err != nil {
+			return nil, fmt.Errorf("error accumulating result: %s", err)
+		}
+		toAccumulate = accumulated
+
+		// current assumption is the week always ends on Saturday, or there are no more allocation sets
+		if dayOfWeek == time.Saturday || i == len(ccsr.CloudCostSets)-1 {
+			if length := len(toAccumulate.CloudCostSets); length != 1 {
+				return nil, fmt.Errorf("failed accumulation, detected %d sets instead of 1", length)
+			}
+			result.Append(toAccumulate.CloudCostSets[0])
+			toAccumulate = nil
+		}
+	}
+	return result, nil
+}
+
+func (ccsr *CloudCostSetRange) accumulateByMonth() (*CloudCostSetRange, error) {
+	var toAccumulate *CloudCostSetRange
+	result := &CloudCostSetRange{}
+	for i, css := range ccsr.CloudCostSets {
+		if css.Window.Duration() != time.Hour*24 {
+			return nil, fmt.Errorf("window duration must equal 24 hours; got:%s", css.Window.Duration())
+		}
+
+		_, month, _ := css.Window.Start().Date()
+		_, nextDayMonth, _ := css.Window.Start().Add(time.Hour * 24).Date()
+
+		if toAccumulate == nil {
+			toAccumulate = &CloudCostSetRange{}
+			css = css.Clone()
+		}
+
+		toAccumulate.Append(css)
+		accumulated, err := toAccumulate.accumulateByAll()
+		if err != nil {
+			return nil, fmt.Errorf("error accumulating result: %s", err)
+		}
+		toAccumulate = accumulated
+
+		// either the month has ended, or there are no more allocation sets
+		if month != nextDayMonth || i == len(ccsr.CloudCostSets)-1 {
+			if length := len(toAccumulate.CloudCostSets); length != 1 {
+				return nil, fmt.Errorf("failed accumulation, detected %d sets instead of 1", length)
+			}
+			result.Append(toAccumulate.CloudCostSets[0])
+			toAccumulate = nil
+		}
+	}
+	return result, nil
+}
+
+// Append appends the given CloudCostSet to the end of the range. It does not
+// validate whether or not that violates window continuity.
+func (ccsr *CloudCostSetRange) Append(that *CloudCostSet) {
+	ccsr.CloudCostSets = append(ccsr.CloudCostSets, that)
+}
+
 // 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.
@@ -545,51 +729,3 @@ func (ccsr *CloudCostSetRange) LoadCloudCost(cloudCost *CloudCost) {
 		}
 	}
 }
-
-const (
-	ListCostMetric         string = "ListCost"
-	NetCostMetric          string = "NetCost"
-	AmortizedNetCostMetric string = "AmortizedNetCost"
-	InvoicedCostMetric     string = "InvoicedCost"
-	AmortizedCostMetric    string = "AmortizedCost"
-)
-
-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,
-	}
-}

+ 1 - 1
pkg/kubecost/cloudcost_test.go

@@ -28,7 +28,7 @@ func TestCloudCost_LoadCloudCost(t *testing.T) {
 	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")
+	emtpyCCSR, _ := NewCloudCostSetRange(start, end, AccumulateOptionDay, "integration")
 	testCases := map[string]struct {
 		cc       []*CloudCost
 		ccsr     *CloudCostSetRange

+ 14 - 3
pkg/kubecost/cloudcostprops.go

@@ -1,7 +1,6 @@
 package kubecost
 
 import (
-	"fmt"
 	"strings"
 
 	"github.com/opencost/opencost/pkg/log"
@@ -156,10 +155,22 @@ func (ccp *CloudCostProperties) Intersection(that *CloudCostProperties) *CloudCo
 	return intersectionCCP
 }
 
+var cloudCostDefaultKeyProperties = []string{
+	CloudCostProviderProp,
+	CloudCostInvoiceEntityIDProp,
+	CloudCostAccountIDProp,
+	CloudCostCategoryProp,
+	CloudCostServiceProp,
+	CloudCostProviderIDProp,
+}
+
+// GenerateKey takes a list of properties and creates a "/" seperated key based on the values of the requested properties.
+// Invalid values are ignored with a warning. A nil input returns the default key, while an empty slice  returns the empty string
 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)
+	// nil props replaced with default property list
+	if props == nil {
+		props = cloudCostDefaultKeyProperties
 	}
 
 	values := make([]string, len(props))

+ 77 - 0
pkg/kubecost/costmetric.go

@@ -0,0 +1,77 @@
+package kubecost
+
+import (
+	"fmt"
+	"strings"
+)
+
+// CostMetricName a string type that acts as an enumeration of possible CostMetric options
+type CostMetricName string
+
+const (
+	CostMetricNone             CostMetricName = ""
+	CostMetricListCost         CostMetricName = "listCost"
+	CostMetricNetCost          CostMetricName = "netCost"
+	CostMetricAmortizedNetCost CostMetricName = "amortizedNetCost"
+	CostMetricInvoicedCost     CostMetricName = "invoicedCost"
+	CostMetricAmortizedCost    CostMetricName = "amortizedCost"
+)
+
+// ParseCostMetricName provides a resilient way to parse one of the enumerated CostMetricName types from a string
+// or throws an error if it is not able to.
+func ParseCostMetricName(costMetric string) (CostMetricName, error) {
+	switch strings.ToLower(costMetric) {
+	case strings.ToLower(string(CostMetricListCost)):
+		return CostMetricListCost, nil
+	case strings.ToLower(string(CostMetricAmortizedCost)):
+		return CostMetricAmortizedCost, nil
+	case strings.ToLower(string(CostMetricAmortizedNetCost)):
+		return CostMetricAmortizedNetCost, nil
+	case strings.ToLower(string(CostMetricNetCost)):
+		return CostMetricNetCost, nil
+	case strings.ToLower(string(CostMetricInvoicedCost)):
+		return CostMetricInvoicedCost, nil
+	}
+	return CostMetricNone, fmt.Errorf("failed to parse a valid CostMetricName from '%s'", costMetric)
+}
+
+// CostMetric is a container for values associated with a specific accounting method
+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,
+	}
+}

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