Browse Source

Merge branch 'develop' into dependabot/npm_and_yarn/ui/axios-1.6.0

Matt Ray 2 years ago
parent
commit
358806ad7f

+ 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

+ 2 - 0
.gitignore

@@ -9,6 +9,8 @@ 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

+ 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/) |

+ 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 - 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).

+ 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
+)

+ 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

+ 1 - 1
pkg/cloud/aws/athenaintegration.go

@@ -147,7 +147,7 @@ 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
 	`

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

@@ -2,7 +2,7 @@ package aws
 
 import (
 	"bytes"
-	"io/ioutil"
+	"io"
 	"net/http"
 	"net/url"
 	"reflect"
@@ -306,7 +306,7 @@ func Test_populate_pricing(t *testing.T) {
 	`
 
 	testResponse := http.Response{
-		Body: ioutil.NopCloser(bytes.NewBufferString(awsUSEastString)),
+		Body: io.NopCloser(bytes.NewBufferString(awsUSEastString)),
 		Request: &http.Request{
 			URL: &url.URL{
 				Scheme: "https",
@@ -445,7 +445,7 @@ func Test_populate_pricing(t *testing.T) {
 	}
 
 	testResponse = http.Response{
-		Body: ioutil.NopCloser(bytes.NewBufferString(awsCnString)),
+		Body: io.NopCloser(bytes.NewBufferString(awsCnString)),
 		Request: &http.Request{
 			URL: &url.URL{
 				Scheme: "https",

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

@@ -67,11 +67,8 @@ 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 {

+ 114 - 246
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())
@@ -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
@@ -115,256 +113,126 @@ func (bqi *BigQueryIntegration) GetCloudCost(start time.Time, end time.Time) (*k
 
 }
 
-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
+}

+ 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
+}

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

@@ -86,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://([^/]*)/*")
@@ -772,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)
@@ -985,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" {

+ 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
+}

+ 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)
+			}
+		})
+	}
+}

+ 8 - 171
pkg/cloudcost/queryservice.go

@@ -1,16 +1,12 @@
 package cloudcost
 
 import (
-	"encoding/csv"
 	"fmt"
 	"net/http"
 	"strings"
 
 	"github.com/julienschmidt/httprouter"
-	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"
 	"go.opentelemetry.io/otel"
 )
@@ -52,7 +48,8 @@ func (s *QueryService) GetCloudCostHandler() func(w http.ResponseWriter, r *http
 			return
 		}
 
-		request, err := parseCloudCostRequest(r)
+		qp := httputil.NewQueryParams(r.URL.Query())
+		request, err := ParseCloudCostRequest(qp)
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusBadRequest)
 			return
@@ -89,7 +86,8 @@ func (s *QueryService) GetCloudCostViewGraphHandler() func(w http.ResponseWriter
 			return
 		}
 
-		request, err := parseCloudCostViewRequest(r)
+		qp := httputil.NewQueryParams(r.URL.Query())
+		request, err := parseCloudCostViewRequest(qp)
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusBadRequest)
 			return
@@ -131,7 +129,8 @@ func (s *QueryService) GetCloudCostViewTotalsHandler() func(w http.ResponseWrite
 			return
 		}
 
-		request, err := parseCloudCostViewRequest(r)
+		qp := httputil.NewQueryParams(r.URL.Query())
+		request, err := parseCloudCostViewRequest(qp)
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusBadRequest)
 			return
@@ -173,13 +172,13 @@ func (s *QueryService) GetCloudCostViewTableHandler() func(w http.ResponseWriter
 			return
 		}
 
-		request, err := parseCloudCostViewRequest(r)
+		qp := httputil.NewQueryParams(r.URL.Query())
+		request, err := parseCloudCostViewRequest(qp)
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusBadRequest)
 			return
 		}
 
-		qp := httputil.NewQueryParams(r.URL.Query())
 		format := qp.Get("format", "json")
 		if strings.HasPrefix(format, csvFormat) {
 			w.Header().Set("Content-Type", "text/csv")
@@ -206,165 +205,3 @@ func (s *QueryService) GetCloudCostViewTableHandler() func(w http.ResponseWriter
 		protocol.WriteData(w, resp)
 	}
 }
-
-func parseCloudCostRequest(r *http.Request) (*QueryRequest, error) {
-	qp := httputil.NewQueryParams(r.URL.Query())
-
-	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", ",")
-	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)
-	}
-	if len(aggregateBy) == 0 {
-		aggregateBy = []string{
-			kubecost.CloudCostInvoiceEntityIDProp,
-			kubecost.CloudCostAccountIDProp,
-			kubecost.CloudCostProviderProp,
-			kubecost.CloudCostProviderIDProp,
-			kubecost.CloudCostCategoryProp,
-			kubecost.CloudCostServiceProp,
-		}
-	}
-
-	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(r *http.Request) (*ViewQueryRequest, error) {
-	qr, err := parseCloudCostRequest(r)
-	if err != nil {
-		return nil, err
-	}
-	qp := httputil.NewQueryParams(r.URL.Query())
-
-	// 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)
-	offset := qp.GetInt("offset", 0)
-
-	// 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
-	}
-}

+ 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)
+			}
+		})
+	}
+}

+ 10 - 3
pkg/cloudcost/repositoryquerier.go

@@ -220,10 +220,17 @@ func (rq *RepositoryQuerier) QueryViewTable(request ViewQueryRequest, ctx contex
 		return make([]*ViewTableRow, 0), nil
 	}
 
-	limit := request.Offset + request.Limit
-	if limit > len(rows) {
+	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[request.Offset:limit], nil
+	return rows, nil
 }

+ 15 - 31
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())
@@ -1699,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)

+ 2 - 1
pkg/kubecost/asset.go

@@ -3841,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
@@ -3852,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

+ 118 - 0
tilt-values.yaml

@@ -0,0 +1,118 @@
+# DO NOT USE FOR DEPLOYMENT. This file is intended to be used with a Tiltfile
+# and for development purposes only. Please refer to
+# https://github.com/opencost/opencost-helm-chart
+service:
+  enabled: true
+  # --  Kubernetes Service type
+  type: ClusterIP
+  # -- extra ports.  Useful for sidecar pods such as oauth-proxy
+  extraPorts:
+    - name: debug
+      port: 40000
+      targetPort: 40000
+
+opencost:
+  exporter:
+    # -- The GCP Pricing API requires a key. This is supplied just for evaluation.
+    cloudProviderApiKey: ""
+    # -- Default cluster ID to use if cluster_id is not set in Prometheus metrics.
+    defaultClusterId: 'tilt-cluster'
+  livenessProbe:
+    # -- Whether probe is enabled
+    enabled: true
+    # -- Number of seconds before probe is initiated
+    initialDelaySeconds: 120
+    # -- Probe frequency in seconds
+    periodSeconds: 10
+    # -- Number of failures for probe to be considered failed
+    failureThreshold: 3
+  # Readiness probe configuration
+  readinessProbe:
+    # -- Whether probe is enabled
+    enabled: true
+    # -- Number of seconds before probe is initiated
+    initialDelaySeconds: 120
+    # -- Probe frequency in seconds
+    periodSeconds: 10
+    # -- Number of failures for probe to be considered failed
+    failureThreshold: 3
+
+  # Persistent volume claim for storing the data. eg: csv file
+  persistence:
+    enabled: false
+
+  aws:
+    # -- AWS secret access key
+    secret_access_key: ""
+    # -- AWS secret key id
+    access_key_id: ""
+
+  customPricing:
+    # -- Enables custom pricing configuration
+    enabled: false
+    # -- Customize the configmap name used for custom pricing
+    configmapName: custom-pricing-model
+    # -- Path for the pricing configuration.
+    configPath: /tmp/custom-config
+    # -- Configures the pricing model provided in the values file.
+    createConfigmap: true
+    # -- Sets the provider type for the custom pricing file.
+    provider: custom
+    # -- More information about these values here: https://www.opencost.io/docs/configuration/on-prem#custom-pricing-using-the-opencost-helm-chart
+    costModel:
+      description: Modified pricing configuration.
+      CPU: 1.25
+      spotCPU: 0.006655
+      RAM: 0.50
+      spotRAM: 0.000892
+      GPU: 0.95
+      storage: 0.25
+      zoneNetworkEgress: 0.01
+      regionNetworkEgress: 0.01
+      internetNetworkEgress: 0.12
+
+  dataRetention:
+    dailyResolutionDays: 15
+
+  cloudCost:
+    # -- Enable cloud cost ingestion and querying, dependant on valid integration credentials
+    enabled: false
+    # -- Number of hours between each run of the Cloud Cost pipeline
+    refreshRateHours: 6
+    # -- Number of days into the past that a Cloud Cost standard run will query for
+    runWindowDays: 3
+    # -- The number of standard runs before a Month-to-Date run occurs
+    monthToDateInterval: 6
+    # -- The max number of days that any single query will be made to construct Cloud Costs
+    queryWindowDays: 7
+
+  metrics:
+    serviceMonitor:
+      # -- Create ServiceMonitor resource for scraping metrics using PrometheusOperator
+      enabled: false
+      # -- Additional labels to add to the ServiceMonitor
+      additionalLabels: {}
+      # -- Specify if the ServiceMonitor will be deployed into a different namespace (blank deploys into same namespace as chart)
+      namespace: ""
+      # -- Interval at which metrics should be scraped
+      scrapeInterval: 30s
+      # -- Timeout after which the scrape is ended
+      scrapeTimeout: 10s
+      # -- HonorLabels chooses the metric's labels on collisions with target labels
+      honorLabels: true
+      # -- RelabelConfigs to apply to samples before scraping. Prometheus Operator automatically adds relabelings for a few standard Kubernetes fields
+      relabelings: []
+      # -- MetricRelabelConfigs to apply to samples before ingestion
+      metricRelabelings: []
+      # -- HTTP scheme used for scraping. Defaults to `http`
+      scheme: http
+  prometheus:
+    internal:
+      enabled: true
+      # -- Service name of in-cluster Prometheus
+      serviceName: prometheus-server
+      # -- Service port of in-cluster Prometheus
+      port: 80
+  ui:
+    # -- Enable OpenCost UI
+    enabled: true

+ 35 - 5
ui/src/cloudCostReports.js

@@ -4,7 +4,7 @@ import Header from "./components/Header";
 import IconButton from "@material-ui/core/IconButton";
 import RefreshIcon from "@material-ui/icons/Refresh";
 import { makeStyles } from "@material-ui/styles";
-import { Paper, Typography } from "@material-ui/core";
+import { Box, Link, Paper, Typography } from "@material-ui/core";
 import CircularProgress from "@material-ui/core/CircularProgress";
 import { get, find } from "lodash";
 import { useLocation, useHistory } from "react-router";
@@ -135,12 +135,12 @@ const CloudCostReports = () => {
           {
             primary: "Failed to load report data",
             secondary:
-              "Please update Kubecost to the latest version, then contact support if problems persist.",
+            "Please update OpenCost to the latest version, and open an Issue if problems persist.",
           },
         ]);
       } else {
         let secondary =
-          "Please contact Kubecost support with a bug report if problems persist.";
+          "Please open an Issue with OpenCost if problems persist.";
         if (err.message.length > 0) {
           secondary = err.message;
         }
@@ -204,6 +204,30 @@ const CloudCostReports = () => {
     setTitle(generateTitle({ window, aggregateBy, costMetric }));
   }, [window, aggregateBy, costMetric, filters]);
 
+  const hasCloudCostEnabled = aggregateBy.includes("item")
+    ? true // this is kind of hacky but something weird is happening
+    : // when drilling down will address in a later PR - @jjarrett21
+      !!cloudCostData.cloudCostStatus?.length;
+
+  const enabledWarnings = [
+    {
+      primary: "There are no Cloud Cost integrations currently configured.",
+      secondary: (
+        <>
+          Learn more about setting up Cloud Costs{" "}
+          <Link
+            href={
+              "https://www.opencost.io/docs/configuration/#cloud-costs"
+            }
+            target="_blank"
+          >
+            here
+          </Link>
+        </>
+      ),
+    },
+  ];
+
   return (
     <Page active="cloud.html">
       <Header>
@@ -212,13 +236,19 @@ const CloudCostReports = () => {
         </IconButton>
       </Header>
 
-      {!loading && errors.length > 0 && (
+      {!loading && !hasCloudCostEnabled && (
+        <div style={{ marginBottom: 20 }}>
+          <Warnings warnings={enabledWarnings} />
+        </div>
+      )}
+
+      {!loading && errors.length > 0 && hasCloudCostEnabled && (
         <div style={{ marginBottom: 20 }}>
           <Warnings warnings={errors} />
         </div>
       )}
 
-      {init && (
+      {init && hasCloudCostEnabled && (
         <Paper id="cloud-cost">
           <div className={classes.reportHeader}>
             <div className={classes.titles}>

+ 15 - 15
ui/src/components/Warnings.js

@@ -1,21 +1,21 @@
-import React from 'react'
-import { makeStyles } from '@material-ui/styles'
-import List from '@material-ui/core/List'
-import ListItem from '@material-ui/core/ListItem'
-import ListItemIcon from '@material-ui/core/ListItemIcon'
-import ListItemText from '@material-ui/core/ListItemText'
-import Paper from '@material-ui/core/Paper'
-import WarningIcon from '@material-ui/icons/Warning'
+import React from "react";
+import { makeStyles } from "@material-ui/styles";
+import List from "@material-ui/core/List";
+import ListItem from "@material-ui/core/ListItem";
+import ListItemIcon from "@material-ui/core/ListItemIcon";
+import ListItemText from "@material-ui/core/ListItemText";
+import Paper from "@material-ui/core/Paper";
+import WarningIcon from "@material-ui/icons/Warning";
 
 const useStyles = makeStyles({
   root: {},
-})
+});
 
-const Warnings = ({warnings}) => {
-  const classes = useStyles()
+const Warnings = ({ warnings }) => {
+  const classes = useStyles();
 
   if (!warnings || warnings.length === 0) {
-    return null
+    return null;
   }
 
   return (
@@ -31,7 +31,7 @@ const Warnings = ({warnings}) => {
         ))}
       </List>
     </Paper>
-  )
-}
+  );
+};
 
-export default Warnings
+export default Warnings;

+ 3 - 0
ui/src/services/cloudCostTop.js

@@ -43,10 +43,13 @@ class CloudCostTopService {
       params,
     });
 
+    const status = await axios.get(`${this.BASE_URL}/cloudCost/status`);
+
     return {
       tableRows: tableView.data.data,
       graphData: graphView.data.data,
       tableTotal: totalsView.data.data.combined,
+      cloudCostStatus: status.data.data,
     };
   }
 }