Browse Source

Merge branch 'develop' into node-label

Ajay Tripathy 2 years ago
parent
commit
994cc1a544
49 changed files with 4188 additions and 612 deletions
  1. 2 2
      .github/workflows/build-and-publish-release.yml
  2. 30 0
      .github/workflows/build-test.yaml
  3. 3 0
      .gitignore
  4. 10 2
      Dockerfile.debug
  5. 38 124
      Tiltfile
  6. 169 0
      Tiltfile.opencost
  7. 20 16
      core/pkg/filter/allocation/fields.go
  8. 19 15
      core/pkg/filter/asset/fields.go
  9. 11 7
      core/pkg/filter/cloudcost/fields.go
  10. 35 0
      core/pkg/filter/fieldstrings/fieldstrings.go
  11. 18 0
      core/pkg/filter/k8sobject/fields.go
  12. 43 0
      core/pkg/filter/k8sobject/parser.go
  13. 2 0
      core/pkg/filter/ops/ops.go
  14. 0 29
      core/pkg/model/customcostrequest.go
  15. 0 317
      core/pkg/model/customcostresponse.go
  16. 1049 0
      core/pkg/model/pb/messages.pb.go
  17. 109 0
      core/pkg/model/pb/messages_grpc.pb.go
  18. 139 0
      core/pkg/opencost/k8sobjectmatcher.go
  19. 222 0
      core/pkg/opencost/k8sobjectmatcher_test.go
  20. 46 0
      core/pkg/plugin/grpc.go
  21. 10 44
      core/pkg/plugin/plugin_interface.go
  22. 6 0
      generate.sh
  23. 8 0
      go.mod
  24. 33 0
      go.sum
  25. 17 13
      justfile
  26. 2 2
      kubernetes/opencost.yaml
  27. 2 2
      pkg/cloud/provider/csvprovider.go
  28. 3 0
      pkg/cloud/scaleway/provider.go
  29. 48 21
      pkg/costmodel/router.go
  30. 396 0
      pkg/customcost/ingestor.go
  31. 66 0
      pkg/customcost/matcher.go
  32. 114 0
      pkg/customcost/memoryrepository.go
  33. 24 0
      pkg/customcost/parser.go
  34. 222 0
      pkg/customcost/pipelineservice.go
  35. 190 0
      pkg/customcost/pipelineservice_test.go
  36. 67 0
      pkg/customcost/props.go
  37. 10 0
      pkg/customcost/querier.go
  38. 96 0
      pkg/customcost/queryservice.go
  39. 92 0
      pkg/customcost/queryservice_helper.go
  40. 16 0
      pkg/customcost/repository.go
  41. 205 0
      pkg/customcost/repositoryquerier.go
  42. 80 0
      pkg/customcost/repositoryquerier_test.go
  43. 26 0
      pkg/customcost/status.go
  44. 242 0
      pkg/customcost/types.go
  45. 38 2
      pkg/env/costmodelenv.go
  46. 154 0
      protos/customcost/messages.proto
  47. 9 7
      tilt-values.yaml
  48. 40 0
      ui/Dockerfile.debug
  49. 7 9
      ui/justfile

+ 2 - 2
.github/workflows/build-and-publish-release.yml

@@ -102,7 +102,7 @@ jobs:
         uses: docker/setup-buildx-action@v3
         with:
           buildkitd-flags: --debug
-    
+
       - name: Install Go
         uses: actions/setup-go@v5
         with:
@@ -145,7 +145,7 @@ jobs:
       - name: Build and push (multiarch) OpenCost UI
         working-directory: ./opencost/ui
         run: |
-          just build '${{ steps.tags.outputs.IMAGE_TAG_UI }}'
+          just build '${{ steps.tags.outputs.IMAGE_TAG_UI }}' '${{ steps.version_number.outputs.RELEASE_VERSION }}'
           crane copy '${{ steps.tags.outputs.IMAGE_TAG_UI }}' '${{ steps.tags.outputs.IMAGE_TAG_UI_LATEST }}'
           crane copy '${{ steps.tags.outputs.IMAGE_TAG_UI }}' '${{ steps.tags.outputs.IMAGE_TAG_UI_VERSION }}'
         #  crane copy '${steps.tags.outputs.IMAGE_TAG_UI}' '${steps.tags.outputs.IMAGE_TAG_UI_QUAY}'

+ 30 - 0
.github/workflows/build-test.yaml

@@ -10,6 +10,36 @@ on:
       - develop
 
 jobs:
+  validate-protobuf:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          path: ./
+      -
+        name: Install Go
+        uses: actions/setup-go@v5
+        with:
+          go-version: 'stable'
+          
+      -
+        name: Install protoc
+        uses: arduino/setup-protoc@v3
+        with:
+          version: '25.3'
+      -
+        name: Install just
+        uses: extractions/setup-just@v1
+
+      - name: install protobuf-go
+        run: |
+          go install github.com/golang/protobuf/protoc-gen-go@latest
+          go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
+          which protoc-gen-go-grpc
+      -
+        name: Validate
+        run: |
+          just validate-protobuf
   backend:
     runs-on: ubuntu-latest
     steps:

+ 3 - 0
.gitignore

@@ -19,3 +19,6 @@ pkg/cloud/azureorphan_test.go
 
 #Apple
 *.DS_Store
+
+# tilt
+tilt_config.json

+ 10 - 2
Dockerfile.debug

@@ -4,18 +4,26 @@ FROM golang:alpine
 # outside of Docker.
 ARG binary_path
 
+LABEL org.opencontainers.image.description="Cross-cloud cost allocation models for Kubernetes workloads"
+LABEL org.opencontainers.image.documentation=https://opencost.io/docs/
+LABEL org.opencontainers.image.licenses=Apache-2.0
+LABEL org.opencontainers.image.source=https://github.com/opencost/opencost
+LABEL org.opencontainers.image.title=kubecost-cost-model
+LABEL org.opencontainers.image.url=https://opencost.io
+
 WORKDIR /app
 RUN apk add --update --no-cache ca-certificates
 RUN go install github.com/go-delve/delve/cmd/dlv@latest
 
+ADD --chmod=644 ./THIRD_PARTY_LICENSES.txt /THIRD_PARTY_LICENSES.txt
 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
+ADD --chmod=644 ./configs/oracle.json /models/oracle.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
+EXPOSE 9003 40000

+ 38 - 124
Tiltfile

@@ -1,130 +1,44 @@
-load('ext://helm_resource', 'helm_resource', 'helm_repo')
-load('ext://restart_process', 'docker_build_with_restart')
+load('Tiltfile.opencost', 'run_opencost')
 
 # WARNING: this allows any k8s context for deployment
-#allow_k8s_contexts(k8s_context())
+# 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='')
+# See https://docs.tilt.dev/api.html#api.allow_k8s_contexts for default
+# allowed contexts
+
+config.define_string('arch')
+config.define_string('cloud-integration')
+config.define_bool('delve-continue')
+config.define_string('docker-repo')
+config.define_string('helm-values')
+config.define_string('port-costmodel')
+config.define_string('port-debug')
+config.define_string('port-prometheus')
+config.define_string('port-ui')
+config.define_string('service-key')
 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.template',
-        '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
-)
+docker_repo = cfg.get('docker-repo', '')
+if docker_repo != '':
+    docker_repo += "/"
+
+port_costmodel = cfg.get('port-costmodel', 9003)
+port_debug = cfg.get('port-debug', 40000)
+port_prometheus = cfg.get('port-prometheus', 9080)
+port_ui = cfg.get('port-ui', 9090)
+
+options = {
+    'arch': cfg.get('arch'),
+    'cloud_integration': cfg.get('cloud-integration', ''),
+    'delve_continue': cfg.get('delve-continue', True),
+    'docker_repo': docker_repo,
+    'helm_values': cfg.get('helm-values', './tilt-values.yaml'),
+    'port_costmodel': cfg.get('port-costmodel', '9003'),
+    'port_debug': cfg.get('port-debug', '40000'),
+    'port_prometheus': cfg.get('port-prometheus', '9080'),
+    'port_ui': cfg.get('port-ui', '9090'),
+    'service_key': cfg.get('service-key', ''),
+}
+
+run_opencost(options)

+ 169 - 0
Tiltfile.opencost

@@ -0,0 +1,169 @@
+load('ext://helm_resource', 'helm_resource', 'helm_repo')
+load('ext://restart_process', 'docker_build_with_restart')
+load('ext://secret', 'secret_create_generic')
+
+
+def get_docker_platform(arch):
+    if arch == "arm64":
+        return "linux/arm64"
+    else:
+        return "linux/amd64"
+
+
+def get_go_arch(arch):
+    if arch == "arm64":
+        return "arm64"
+    else:
+        return "amd64"
+
+
+# run_opencost is encapsulated as a function to make import easier for running alongside kubecost.
+# The `../opencost` pattern that repeats across this function is to deal with how multiple tilt
+# files work - the base directory (`.`) is always relative to the Tiltfile executed and not the
+# directory containing this file.
+def run_opencost(options):
+
+    docker_platform = get_docker_platform(options["arch"])
+    go_arch = get_go_arch(options["arch"])
+    is_cloud_integration = options["cloud_integration"] != '' and os.path.exists(options["cloud_integration"])
+    is_service_key = options["service_key"] != '' and os.path.exists(options["service_key"])
+    continue_flag = '--continue'
+    if options["delve_continue"] == False:
+        continue_flag = ''
+
+    # 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 ../opencost/cmd/costmodel/costmodel-tilt ../opencost/cmd/costmodel/main.go',
+        deps=[
+            '../opencost/cmd/costmodel/main.go',
+            '../opencost/pkg',
+        ],
+        allow_parallel=True,
+    )
+
+    # Build back end docker container
+    # If the binary is updated, update the running container and restart binary in dlv
+    docker_build_with_restart(
+        ref=options["docker_repo"]+'opencost-costmodel',
+        context='../opencost',
+        # 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_flag+' /app/main',
+        dockerfile='../opencost/Dockerfile.debug',
+        platform=docker_platform,
+        build_args={'binary_path': './cmd/costmodel/costmodel-tilt'},
+        only=[
+            'cmd/costmodel/costmodel-tilt',
+            'configs',
+            'THIRD_PARTY_LICENSES.txt',
+        ],
+        live_update=[
+            sync('../opencost/cmd/costmodel/costmodel-tilt', '/app/main'),
+        ],
+    )
+
+    # npm install if package.json changes
+    local_resource(
+        name='build-npm-install',
+        dir='../opencost/ui',
+        cmd='npm install',
+        deps=[
+            '../opencost/ui/package.json',
+        ],
+        allow_parallel=True,
+    )
+
+    # Build FE locally when code changes
+    local_resource(
+        name='build-ui',
+        dir='../opencost/ui',
+        cmd='npx parcel build src/index.html',
+        deps=[
+            '../opencost/ui/src',
+            '../opencost/ui/package.json',
+        ],
+        allow_parallel=True,
+        resource_deps=['build-npm-install'],
+    )
+
+    # update container when relevant files change
+    docker_build(
+        ref=options["docker_repo"]+'opencost-ui',
+        context='../opencost/ui',
+        dockerfile='../opencost/ui/Dockerfile.debug',
+        only=[
+            'dist',
+            'nginx.conf',
+            'default.nginx.conf.template',
+            'docker-entrypoint.sh',
+        ],
+        live_update=[
+            sync('../opencost/ui/dist', '/var/www'),
+        ],
+    )
+
+    values_set = [
+        'opencost.ui.image.fullImageName='+options["docker_repo"]+'opencost-ui',
+        'opencost.exporter.image.fullImageName='+options["docker_repo"]+'opencost-costmodel',
+        'opencost.prometheus.internal.namespaceName='+k8s_namespace(),
+        'opencost.exporter.debugPort=40000',
+    ]
+
+    if is_cloud_integration:
+        values_set.append('opencost.cloudIntegrationSecret=cloud-integration')
+        values_set.append('opencost.cloudCost.enabled=true')
+    else:
+        values_set.append('opencost.cloudCost.enabled=false')
+
+    if is_cloud_integration:
+        secret_create_generic(
+            name='cloud-integration',
+            namespace=k8s_namespace(),
+            from_file=options["cloud_integration"],
+            secret_type=None,
+            from_env_file=None
+        )
+
+    if is_service_key:
+        secret_create_generic(
+            name='service-key',
+            namespace=k8s_namespace(),
+            from_file=options["service_key"],
+            secret_type=None,
+            from_env_file=None
+        )
+
+    # build yaml for deployment to k8s
+    yaml = helm(
+        '../opencost-helm-chart/charts/opencost',
+        name='opencost',
+        values=[options["helm_values"]],
+        set=values_set
+    )
+    k8s_yaml(yaml)  # put resulting yaml into k8s
+
+    port_forwards = [
+        options['port_costmodel']+':9003',
+        options['port_ui']+':9090',
+        options['port_debug']+':40000',
+    ]
+    k8s_resource(workload='opencost', port_forwards=port_forwards)
+
+    helm_repo('prometheus-community', 'https://prometheus-community.github.io/helm-charts')
+    helm_resource(
+        name='prometheus',
+        chart='prometheus-community/prometheus',
+        resource_deps=['prometheus-community'])
+    k8s_resource(workload='prometheus', port_forwards=[options['port_prometheus']+':9090'])
+
+    local_resource(
+        name='costmodel-test',
+        dir='../opencost',
+        cmd='go test ./...',
+        deps=[
+            './pkg',
+        ],
+        allow_parallel=True,
+        resource_deps=['opencost'],  # run tests after build to speed up deployment
+    )

+ 20 - 16
core/pkg/filter/allocation/fields.go

@@ -1,5 +1,9 @@
 package allocation
 
+import (
+	"github.com/opencost/opencost/core/pkg/filter/fieldstrings"
+)
+
 // AllocationField is an enum that represents Allocation-specific fields that can be
 // filtered on (namespace, label, etc.)
 type AllocationField string
@@ -8,17 +12,17 @@ type AllocationField string
 // Allocation value
 // does not enforce exhaustive pattern matching on "enum" types.
 const (
-	FieldClusterID      AllocationField = "cluster"
-	FieldNode           AllocationField = "node"
-	FieldNamespace      AllocationField = "namespace"
-	FieldControllerKind AllocationField = "controllerKind"
-	FieldControllerName AllocationField = "controllerName"
-	FieldPod            AllocationField = "pod"
-	FieldContainer      AllocationField = "container"
-	FieldProvider       AllocationField = "provider"
-	FieldServices       AllocationField = "services"
-	FieldLabel          AllocationField = "label"
-	FieldAnnotation     AllocationField = "annotation"
+	FieldClusterID      AllocationField = AllocationField(fieldstrings.FieldClusterID)
+	FieldNode           AllocationField = AllocationField(fieldstrings.FieldNode)
+	FieldNamespace      AllocationField = AllocationField(fieldstrings.FieldNamespace)
+	FieldControllerKind AllocationField = AllocationField(fieldstrings.FieldControllerKind)
+	FieldControllerName AllocationField = AllocationField(fieldstrings.FieldControllerName)
+	FieldPod            AllocationField = AllocationField(fieldstrings.FieldPod)
+	FieldContainer      AllocationField = AllocationField(fieldstrings.FieldContainer)
+	FieldProvider       AllocationField = AllocationField(fieldstrings.FieldProvider)
+	FieldServices       AllocationField = AllocationField(fieldstrings.FieldServices)
+	FieldLabel          AllocationField = AllocationField(fieldstrings.FieldLabel)
+	FieldAnnotation     AllocationField = AllocationField(fieldstrings.FieldAnnotation)
 )
 
 // AllocationAlias represents an alias field type for allocations.
@@ -30,9 +34,9 @@ const (
 type AllocationAlias string
 
 const (
-	AliasDepartment  AllocationAlias = "department"
-	AliasEnvironment AllocationAlias = "environment"
-	AliasOwner       AllocationAlias = "owner"
-	AliasProduct     AllocationAlias = "product"
-	AliasTeam        AllocationAlias = "team"
+	AliasDepartment  AllocationAlias = AllocationAlias(fieldstrings.AliasDepartment)
+	AliasEnvironment AllocationAlias = AllocationAlias(fieldstrings.AliasEnvironment)
+	AliasOwner       AllocationAlias = AllocationAlias(fieldstrings.AliasOwner)
+	AliasProduct     AllocationAlias = AllocationAlias(fieldstrings.AliasProduct)
+	AliasTeam        AllocationAlias = AllocationAlias(fieldstrings.AliasTeam)
 )

+ 19 - 15
core/pkg/filter/asset/fields.go

@@ -1,5 +1,9 @@
 package asset
 
+import (
+	"github.com/opencost/opencost/core/pkg/filter/fieldstrings"
+)
+
 // AssetField is an enum that represents Asset-specific fields that can be
 // filtered on (namespace, label, etc.)
 type AssetField string
@@ -7,16 +11,16 @@ type AssetField string
 // If you add a AssetField, make sure to update field maps to return the correct
 // Asset value does not enforce exhaustive pattern matching on "enum" types.
 const (
-	FieldName       AssetField = "name"
-	FieldType       AssetField = "assetType"
-	FieldCategory   AssetField = "category"
-	FieldClusterID  AssetField = "cluster"
-	FieldProject    AssetField = "project"
-	FieldProvider   AssetField = "provider"
-	FieldProviderID AssetField = "providerID"
-	FieldAccount    AssetField = "account"
-	FieldService    AssetField = "service"
-	FieldLabel      AssetField = "label"
+	FieldName       AssetField = AssetField(fieldstrings.FieldName)
+	FieldType       AssetField = AssetField(fieldstrings.FieldType)
+	FieldCategory   AssetField = AssetField(fieldstrings.FieldCategory)
+	FieldClusterID  AssetField = AssetField(fieldstrings.FieldClusterID)
+	FieldProject    AssetField = AssetField(fieldstrings.FieldProject)
+	FieldProvider   AssetField = AssetField(fieldstrings.FieldProvider)
+	FieldProviderID AssetField = AssetField(fieldstrings.FieldProviderID)
+	FieldAccount    AssetField = AssetField(fieldstrings.FieldAccount)
+	FieldService    AssetField = AssetField(fieldstrings.FieldService)
+	FieldLabel      AssetField = AssetField(fieldstrings.FieldLabel)
 )
 
 // AssetAlias represents an alias field type for assets.
@@ -27,9 +31,9 @@ const (
 type AssetAlias string
 
 const (
-	DepartmentProp  AssetAlias = "department"
-	EnvironmentProp AssetAlias = "environment"
-	OwnerProp       AssetAlias = "owner"
-	ProductProp     AssetAlias = "product"
-	TeamProp        AssetAlias = "team"
+	DepartmentProp  AssetAlias = AssetAlias(fieldstrings.AliasDepartment)
+	EnvironmentProp AssetAlias = AssetAlias(fieldstrings.AliasEnvironment)
+	OwnerProp       AssetAlias = AssetAlias(fieldstrings.AliasOwner)
+	ProductProp     AssetAlias = AssetAlias(fieldstrings.AliasProduct)
+	TeamProp        AssetAlias = AssetAlias(fieldstrings.AliasTeam)
 )

+ 11 - 7
core/pkg/filter/cloudcost/fields.go

@@ -1,14 +1,18 @@
 package cloudcost
 
+import (
+	"github.com/opencost/opencost/core/pkg/filter/fieldstrings"
+)
+
 // CloudCostField is an enum that represents CloudCost specific fields that can be filtered
 type CloudCostField string
 
 const (
-	FieldInvoiceEntityID CloudCostField = "invoiceEntityID"
-	FieldAccountID       CloudCostField = "accountID"
-	FieldProvider        CloudCostField = "provider"
-	FieldProviderID      CloudCostField = "providerID"
-	FieldCategory        CloudCostField = "category"
-	FieldService         CloudCostField = "service"
-	FieldLabel           CloudCostField = "label"
+	FieldInvoiceEntityID CloudCostField = CloudCostField(fieldstrings.FieldInvoiceEntityID)
+	FieldAccountID       CloudCostField = CloudCostField(fieldstrings.FieldAccountID)
+	FieldProvider        CloudCostField = CloudCostField(fieldstrings.FieldProvider)
+	FieldProviderID      CloudCostField = CloudCostField(fieldstrings.FieldProviderID)
+	FieldCategory        CloudCostField = CloudCostField(fieldstrings.FieldCategory)
+	FieldService         CloudCostField = CloudCostField(fieldstrings.FieldService)
+	FieldLabel           CloudCostField = CloudCostField(fieldstrings.FieldLabel)
 )

+ 35 - 0
core/pkg/filter/fieldstrings/fieldstrings.go

@@ -0,0 +1,35 @@
+package fieldstrings
+
+// These strings are the central source of filter fields across all types of
+// filters. Many filter types share fields; defining common consts means that
+// there should be no drift between types.
+const (
+	FieldClusterID      string = "cluster"
+	FieldNode           string = "node"
+	FieldNamespace      string = "namespace"
+	FieldControllerKind string = "controllerKind"
+	FieldControllerName string = "controllerName"
+	FieldPod            string = "pod"
+	FieldContainer      string = "container"
+	FieldProvider       string = "provider"
+	FieldServices       string = "services"
+	FieldLabel          string = "label"
+	FieldAnnotation     string = "annotation"
+
+	FieldName       string = "name"
+	FieldType       string = "assetType"
+	FieldCategory   string = "category"
+	FieldProject    string = "project"
+	FieldProviderID string = "providerID"
+	FieldAccount    string = "account"
+	FieldService    string = "service"
+
+	FieldInvoiceEntityID string = "invoiceEntityID"
+	FieldAccountID       string = "accountID"
+
+	AliasDepartment  string = "department"
+	AliasEnvironment string = "environment"
+	AliasOwner       string = "owner"
+	AliasProduct     string = "product"
+	AliasTeam        string = "team"
+)

+ 18 - 0
core/pkg/filter/k8sobject/fields.go

@@ -0,0 +1,18 @@
+package k8sobject
+
+import (
+	"github.com/opencost/opencost/core/pkg/filter/fieldstrings"
+)
+
+// K8sObjectField is an enum that represents K8sObject-specific fields that can
+// be filtered on.
+type K8sObjectField string
+
+const (
+	FieldNamespace      K8sObjectField = K8sObjectField(fieldstrings.FieldNamespace)
+	FieldControllerKind K8sObjectField = K8sObjectField(fieldstrings.FieldControllerKind)
+	FieldControllerName K8sObjectField = K8sObjectField(fieldstrings.FieldControllerName)
+	FieldPod            K8sObjectField = K8sObjectField(fieldstrings.FieldPod)
+	FieldLabel          K8sObjectField = K8sObjectField(fieldstrings.FieldLabel)
+	FieldAnnotation     K8sObjectField = K8sObjectField(fieldstrings.FieldAnnotation)
+)

+ 43 - 0
core/pkg/filter/k8sobject/parser.go

@@ -0,0 +1,43 @@
+package k8sobject
+
+import (
+	"github.com/opencost/opencost/core/pkg/filter/ast"
+)
+
+// a slice of all the allocation field instances the lexer should recognize as
+// valid left-hand comparators
+var k8sObjectFilterFields []*ast.Field = []*ast.Field{
+	ast.NewField(FieldNamespace),
+	ast.NewField(FieldControllerName, ast.FieldAttributeNilable),
+	ast.NewField(FieldControllerKind, ast.FieldAttributeNilable),
+	ast.NewField(FieldPod),
+	ast.NewMapField(FieldLabel),
+	ast.NewMapField(FieldAnnotation),
+}
+
+// fieldMap is a lazily loaded mapping from AllocationField to ast.Field
+var fieldMap map[K8sObjectField]*ast.Field
+
+func init() {
+	fieldMap = make(map[K8sObjectField]*ast.Field, len(k8sObjectFilterFields))
+	for _, f := range k8sObjectFilterFields {
+		ff := *f
+		fieldMap[K8sObjectField(ff.Name)] = &ff
+	}
+}
+
+// DefaultFieldByName returns only default allocation filter fields by name.
+func DefaultFieldByName(field K8sObjectField) *ast.Field {
+	if af, ok := fieldMap[field]; ok {
+		afcopy := *af
+		return &afcopy
+	}
+
+	return nil
+}
+
+// NewK8sObjectFilterParser creates a new `ast.FilterParser` implementation for
+// K8s runtime.Objects.
+func NewK8sObjectFilterParser() ast.FilterParser {
+	return ast.NewFilterParser(k8sObjectFilterFields)
+}

+ 2 - 0
core/pkg/filter/ops/ops.go

@@ -13,6 +13,7 @@ import (
 	"github.com/opencost/opencost/core/pkg/filter/asset"
 	"github.com/opencost/opencost/core/pkg/filter/ast"
 	"github.com/opencost/opencost/core/pkg/filter/cloudcost"
+	"github.com/opencost/opencost/core/pkg/filter/k8sobject"
 	"github.com/opencost/opencost/core/pkg/util/typeutil"
 )
 
@@ -29,6 +30,7 @@ var defaultFieldByType = map[string]any{
 	typeutil.TypeOf[allocation.AllocationField](): allocation.DefaultFieldByName,
 	typeutil.TypeOf[asset.AssetField]():           asset.DefaultFieldByName,
 	typeutil.TypeOf[cloudcost.CloudCostField]():   cloudcost.DefaultFieldByName,
+	typeutil.TypeOf[k8sobject.K8sObjectField]():   k8sobject.DefaultFieldByName,
 	// typeutil.TypeOf[containerstats.ContainerStatsField](): containerstats.DefaultFieldByName,
 }
 

+ 0 - 29
core/pkg/model/customcostrequest.go

@@ -1,29 +0,0 @@
-package model
-
-import (
-	"time"
-
-	"github.com/opencost/opencost/core/pkg/opencost"
-)
-
-type CustomCostRequest struct {
-	// specifies the window for data to be
-	// retrieved
-	TargetWindow *opencost.Window
-
-	// the step size to return
-	Resolution time.Duration
-}
-
-func (c CustomCostRequest) GetTargetWindow() *opencost.Window {
-	return c.TargetWindow
-}
-
-func (c CustomCostRequest) GetTargetResolution() time.Duration {
-	return c.Resolution
-}
-
-type CustomCostRequestInterface interface {
-	GetTargetWindow() *opencost.Window
-	GetTargetResolution() time.Duration
-}

+ 0 - 317
core/pkg/model/customcostresponse.go

@@ -1,317 +0,0 @@
-package model
-
-import "github.com/opencost/opencost/core/pkg/opencost"
-
-// see design at https://link.excalidraw.com/l/ABLQ24dkKai/CBEQtjH6Mr
-// for additional details on how these objects work in the context of
-// opencost's plugin system
-
-type CustomCostResponse struct {
-	// provides metadata on the Custom CostResponse
-	// deliberately left unstructured
-	Metadata map[string]string
-	// declared by plugin
-	// eg snowflake == "data management",
-	// datadog == "observability" etc
-	// intended for top level agg
-	Costsource string
-	// the name of the custom cost source
-	// e.g., "datadog"
-	Domain string
-	// the version of the Custom Cost response
-	// is set by the plugin, will vary between
-	// different plugins
-	Version string
-	// FOCUS billing currency
-	Currency string
-	// the window of the returned objects
-	Window opencost.Window
-	// array of CustomCosts
-	Costs []*CustomCost
-	// any errors in processing
-	Errors []error
-}
-
-// designed to provide a superset of the FOCUS spec
-// https://github.com/FinOps-Open-Cost-and-Usage-Spec/FOCUS_Spec/releases/latest/download/spec.pdf
-type CustomCost struct {
-	// provides metadata on the Custom CostResponse
-	// deliberately left unstructured
-	Metadata map[string]string
-	// the region that the resource was incurred
-	// corresponds to 'availability zone' of FOCUS
-	Zone string
-	// FOCUS billed Cost
-	BilledCost float32
-	// FOCUS billing account name
-	AccountName string
-	// FOCUS charge category
-	ChargeCategory string
-	// FOCUS charge description
-	Description string
-	// FOCUS List Cost
-	ListCost float32
-	// FOCUS List Unit Price
-	ListUnitPrice float32
-	// FOCUS Resource Name
-	ResourceName string
-	// FOCUS Resource type
-	// if not set, assumed to be domain
-	ResourceType string
-	// ID of the individual cost. should be globally
-	// unique. Assigned by plugin on read
-	Id string
-	// the provider's ID for the cost, if
-	// available
-	// FOCUS resource ID
-	ProviderId string
-	// the window of the returned specific
-	// custom cost
-	// equivalent to charge period start/end of FOCUS
-	Window *opencost.Window
-	// Returns key/value sets of labels
-	// equivalent to Tags in focus spec
-	Labels map[string]string
-	// FOCUS usage quantity
-	UsageQty float32
-	// FOCUS usage Unit
-	UsageUnit string
-	// Optional struct to implement other focus
-	// spec attributes
-	ExtendedAttributes *ExtendedCustomCostAttributes
-}
-
-// These parts of the FOCUS spec are not expected
-// to be implemented by every plugin
-// however, if these bits of information are available,
-// they should be provided
-type ExtendedCustomCostAttributes struct {
-	// FOCUS billing period start/end
-	BillingPeriod *opencost.Window
-	// FOCUS Billing Account ID
-	AccountID string
-	// FOCUS Charge Frequency
-	ChargeFrequency string
-	// FOCUS Charge Subcategory
-	Subcategory string
-	// FOCUS Commitment Discount Category
-	CommitmentDiscountCategory string
-	// FOCUS Commitment Discount ID
-	CommitmentDiscountID string
-	// FOCUS Commitment Discount Name
-	CommitmentDiscountName string
-	// FOCUS Commitment Discount Type
-	CommitmentDiscountType string
-	// FOCUS Effective Cost
-	EffectiveCost float32
-	// FOCUS Invoice Issuer
-	InvoiceIssuer string
-	// FOCUS Provider
-	// if unset, assumed to be domain
-	Provider string
-	// FOCUS Publisher
-	// if unset, assumed to be domain
-	Publisher string
-	// FOCUS Service Category
-	// if unset, assumed to be cost source
-	ServiceCategory string
-	// FOCUS Service Name
-	// if unset, assumed to be cost source
-	ServiceName string
-	// FOCUS SKU ID
-	SkuID string
-	// FOCUS SKU Price ID
-	SkuPriceID string
-	// FOCUS Sub Account ID
-	SubAccountID string
-	// FOCUS Sub Account Name
-	SubAccountName string
-	// FOCUS Pricing Quantity
-	PricingQuantity float32
-	// FOCUS Pricing Unit
-	PricingUnit string
-	// FOCUS Pricing Category
-	PricingCategory string
-}
-
-func (e *ExtendedCustomCostAttributes) GetBillingPeriod() *opencost.Window {
-	return e.BillingPeriod
-}
-
-func (e *ExtendedCustomCostAttributes) GetAccountID() string {
-	return e.AccountID
-}
-
-func (e *ExtendedCustomCostAttributes) GetChargeFrequency() string {
-	return e.ChargeFrequency
-}
-
-func (e *ExtendedCustomCostAttributes) GetSubcategory() string {
-	return e.Subcategory
-}
-
-func (e *ExtendedCustomCostAttributes) GetCommitmentDiscountCategory() string {
-	return e.CommitmentDiscountCategory
-}
-
-func (e *ExtendedCustomCostAttributes) GetCommitmentDiscountID() string {
-	return e.CommitmentDiscountID
-}
-
-func (e *ExtendedCustomCostAttributes) GetCommitmentDiscountName() string {
-	return e.CommitmentDiscountName
-}
-
-func (e *ExtendedCustomCostAttributes) GetCommitmentDiscountType() string {
-	return e.CommitmentDiscountType
-}
-
-func (e *ExtendedCustomCostAttributes) GetEffectiveCost() float32 {
-	return e.EffectiveCost
-}
-
-func (e *ExtendedCustomCostAttributes) GetInvoiceIssuer() string {
-	return e.InvoiceIssuer
-}
-
-func (e *ExtendedCustomCostAttributes) GetProvider() string {
-	return e.Provider
-}
-
-func (e *ExtendedCustomCostAttributes) GetPublisher() string {
-	return e.Publisher
-}
-
-func (e *ExtendedCustomCostAttributes) GetServiceCategory() string {
-	return e.ServiceCategory
-}
-
-func (e *ExtendedCustomCostAttributes) GetServiceName() string {
-	return e.ServiceName
-}
-
-func (e *ExtendedCustomCostAttributes) GetSKUID() string {
-	return e.SkuID
-}
-
-func (e *ExtendedCustomCostAttributes) GetSKUPriceID() string {
-	return e.SkuPriceID
-}
-
-func (e *ExtendedCustomCostAttributes) GetSubAccountID() string {
-	return e.SubAccountID
-}
-
-func (e *ExtendedCustomCostAttributes) GetSubAccountName() string {
-	return e.SubAccountName
-}
-func (e *ExtendedCustomCostAttributes) GetPricingQuantity() float32 {
-	return e.PricingQuantity
-}
-func (e *ExtendedCustomCostAttributes) GetPricingUnit() string {
-	return e.PricingUnit
-}
-
-func (e *ExtendedCustomCostAttributes) GetPricingCategory() string {
-	return e.PricingCategory
-}
-
-func (d *CustomCost) GetMetadata() map[string]string {
-	return d.Metadata
-}
-
-func (d *CustomCost) GetCostIncurredZone() string {
-	return d.Zone
-}
-
-func (d *CustomCost) GetBilledCost() float32 {
-	return d.BilledCost
-}
-
-func (d *CustomCost) GetAccountName() string {
-	return d.AccountName
-}
-
-func (d *CustomCost) GetChargeCategory() string {
-	return d.ChargeCategory
-}
-
-func (d *CustomCost) GetDescription() string {
-	return d.Description
-}
-
-func (d *CustomCost) GetListCost() float32 {
-	return d.ListCost
-}
-
-func (d *CustomCost) GetListUnitPrice() float32 {
-	return d.ListUnitPrice
-}
-
-func (d *CustomCost) GetResourceName() string {
-	return d.ResourceName
-}
-
-func (d *CustomCost) GetID() string {
-	return d.Id
-}
-
-func (d *CustomCost) GetProviderID() string {
-	return d.ProviderId
-}
-
-func (d *CustomCost) GetWindow() *opencost.Window {
-	return d.Window
-}
-
-func (d *CustomCost) GetLabels() map[string]string {
-	return d.Labels
-}
-
-func (d *CustomCost) GetUsageQuantity() float32 {
-	return d.UsageQty
-}
-
-func (d *CustomCost) GetUsageUnit() string {
-	return d.UsageUnit
-}
-
-func (d *CustomCost) GetExtendedAttributes() *ExtendedCustomCostAttributes {
-	return d.ExtendedAttributes
-}
-
-func (d *CustomCost) GetResourceType() string {
-	return d.ResourceType
-}
-
-func (d *CustomCostResponse) GetMetadata() map[string]string {
-	return d.Metadata
-}
-
-func (d *CustomCostResponse) GetCostSource() string {
-	return d.Costsource
-}
-
-func (d *CustomCostResponse) GetDomain() string {
-	return d.Domain
-}
-
-func (d *CustomCostResponse) GetVersion() string {
-	return d.Version
-}
-
-func (d *CustomCostResponse) GetCurrency() string {
-	return d.Currency
-}
-
-func (d *CustomCostResponse) GetWindow() opencost.Window {
-	return d.Window
-}
-
-func (d *CustomCostResponse) GetCosts() []*CustomCost {
-	return d.Costs
-}
-
-func (d *CustomCostResponse) GetErrors() []error {
-	return d.Errors
-}

+ 1049 - 0
core/pkg/model/pb/messages.pb.go

@@ -0,0 +1,1049 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.33.0
+// 	protoc        v4.25.3
+// source: protos/customcost/messages.proto
+
+package pb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	durationpb "google.golang.org/protobuf/types/known/durationpb"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type CustomCostRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// the window of the returned objects
+	Start *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=start,proto3" json:"start,omitempty"`
+	End   *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=end,proto3" json:"end,omitempty"`
+	// resolution of steps to return
+	Resolution *durationpb.Duration `protobuf:"bytes,3,opt,name=resolution,proto3" json:"resolution,omitempty"`
+}
+
+func (x *CustomCostRequest) Reset() {
+	*x = CustomCostRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_protos_customcost_messages_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *CustomCostRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CustomCostRequest) ProtoMessage() {}
+
+func (x *CustomCostRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_protos_customcost_messages_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use CustomCostRequest.ProtoReflect.Descriptor instead.
+func (*CustomCostRequest) Descriptor() ([]byte, []int) {
+	return file_protos_customcost_messages_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *CustomCostRequest) GetStart() *timestamppb.Timestamp {
+	if x != nil {
+		return x.Start
+	}
+	return nil
+}
+
+func (x *CustomCostRequest) GetEnd() *timestamppb.Timestamp {
+	if x != nil {
+		return x.End
+	}
+	return nil
+}
+
+func (x *CustomCostRequest) GetResolution() *durationpb.Duration {
+	if x != nil {
+		return x.Resolution
+	}
+	return nil
+}
+
+type CustomCostResponseSet struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Resps []*CustomCostResponse `protobuf:"bytes,1,rep,name=resps,proto3" json:"resps,omitempty"`
+}
+
+func (x *CustomCostResponseSet) Reset() {
+	*x = CustomCostResponseSet{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_protos_customcost_messages_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *CustomCostResponseSet) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CustomCostResponseSet) ProtoMessage() {}
+
+func (x *CustomCostResponseSet) ProtoReflect() protoreflect.Message {
+	mi := &file_protos_customcost_messages_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use CustomCostResponseSet.ProtoReflect.Descriptor instead.
+func (*CustomCostResponseSet) Descriptor() ([]byte, []int) {
+	return file_protos_customcost_messages_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *CustomCostResponseSet) GetResps() []*CustomCostResponse {
+	if x != nil {
+		return x.Resps
+	}
+	return nil
+}
+
+type CustomCostResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// provides metadata on the Custom CostResponse
+	// deliberately left unstructured
+	Metadata map[string]string `protobuf:"bytes,1,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
+	// declared by plugin
+	// eg snowflake == "data management",
+	// datadog == "observability" etc
+	// intended for top level agg
+	CostSource string `protobuf:"bytes,2,opt,name=cost_source,json=costSource,proto3" json:"cost_source,omitempty"`
+	// the name of the custom cost source
+	// e.g., "datadog"
+	Domain string `protobuf:"bytes,3,opt,name=domain,proto3" json:"domain,omitempty"`
+	// the version of the Custom Cost response
+	// is set by the plugin, will vary between
+	// different plugins
+	Version string `protobuf:"bytes,4,opt,name=version,proto3" json:"version,omitempty"`
+	// FOCUS billing currency
+	Currency string `protobuf:"bytes,5,opt,name=currency,proto3" json:"currency,omitempty"`
+	// the window of the returned objects
+	Start *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=start,proto3" json:"start,omitempty"`
+	End   *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=end,proto3" json:"end,omitempty"`
+	// array of CustomCosts
+	Costs []*CustomCost `protobuf:"bytes,8,rep,name=costs,proto3" json:"costs,omitempty"`
+	// any errors in processing
+	Errors []string `protobuf:"bytes,9,rep,name=errors,proto3" json:"errors,omitempty"`
+}
+
+func (x *CustomCostResponse) Reset() {
+	*x = CustomCostResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_protos_customcost_messages_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *CustomCostResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CustomCostResponse) ProtoMessage() {}
+
+func (x *CustomCostResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_protos_customcost_messages_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use CustomCostResponse.ProtoReflect.Descriptor instead.
+func (*CustomCostResponse) Descriptor() ([]byte, []int) {
+	return file_protos_customcost_messages_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *CustomCostResponse) GetMetadata() map[string]string {
+	if x != nil {
+		return x.Metadata
+	}
+	return nil
+}
+
+func (x *CustomCostResponse) GetCostSource() string {
+	if x != nil {
+		return x.CostSource
+	}
+	return ""
+}
+
+func (x *CustomCostResponse) GetDomain() string {
+	if x != nil {
+		return x.Domain
+	}
+	return ""
+}
+
+func (x *CustomCostResponse) GetVersion() string {
+	if x != nil {
+		return x.Version
+	}
+	return ""
+}
+
+func (x *CustomCostResponse) GetCurrency() string {
+	if x != nil {
+		return x.Currency
+	}
+	return ""
+}
+
+func (x *CustomCostResponse) GetStart() *timestamppb.Timestamp {
+	if x != nil {
+		return x.Start
+	}
+	return nil
+}
+
+func (x *CustomCostResponse) GetEnd() *timestamppb.Timestamp {
+	if x != nil {
+		return x.End
+	}
+	return nil
+}
+
+func (x *CustomCostResponse) GetCosts() []*CustomCost {
+	if x != nil {
+		return x.Costs
+	}
+	return nil
+}
+
+func (x *CustomCostResponse) GetErrors() []string {
+	if x != nil {
+		return x.Errors
+	}
+	return nil
+}
+
+type CustomCost struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// provides metadata on the Custom CostResponse
+	// deliberately left unstructured
+	Metadata map[string]string `protobuf:"bytes,1,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
+	// the region that the resource was incurred
+	// corresponds to 'availability zone' of FOCUS
+	Zone string `protobuf:"bytes,2,opt,name=zone,proto3" json:"zone,omitempty"`
+	// FOCUS billing account name
+	AccountName string `protobuf:"bytes,3,opt,name=account_name,json=accountName,proto3" json:"account_name,omitempty"`
+	// FOCUS charge category
+	ChargeCategory string `protobuf:"bytes,4,opt,name=charge_category,json=chargeCategory,proto3" json:"charge_category,omitempty"`
+	// FOCUS charge description
+	Description string `protobuf:"bytes,5,opt,name=description,proto3" json:"description,omitempty"`
+	// FOCUS Resource Name
+	ResourceName string `protobuf:"bytes,6,opt,name=resource_name,json=resourceName,proto3" json:"resource_name,omitempty"`
+	// FOCUS Resource type
+	// if not set, assumed to be domain
+	ResourceType string `protobuf:"bytes,7,opt,name=resource_type,json=resourceType,proto3" json:"resource_type,omitempty"`
+	// ID of the individual cost. should be globally
+	// unique. Assigned by plugin on read
+	Id string `protobuf:"bytes,8,opt,name=id,proto3" json:"id,omitempty"`
+	// the provider's ID for the cost, if
+	// available
+	// FOCUS resource ID
+	ProviderId string `protobuf:"bytes,9,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"`
+	// FOCUS billed Cost
+	BilledCost float32 `protobuf:"fixed32,10,opt,name=billed_cost,json=billedCost,proto3" json:"billed_cost,omitempty"`
+	// FOCUS List Cost
+	ListCost float32 `protobuf:"fixed32,11,opt,name=list_cost,json=listCost,proto3" json:"list_cost,omitempty"`
+	// FOCUS List Unit Price
+	ListUnitPrice float32 `protobuf:"fixed32,12,opt,name=list_unit_price,json=listUnitPrice,proto3" json:"list_unit_price,omitempty"`
+	// FOCUS usage quantity
+	UsageQuantity float32 `protobuf:"fixed32,13,opt,name=usage_quantity,json=usageQuantity,proto3" json:"usage_quantity,omitempty"`
+	// FOCUS usage Unit
+	UsageUnit string `protobuf:"bytes,14,opt,name=usage_unit,json=usageUnit,proto3" json:"usage_unit,omitempty"`
+	// Returns key/value sets of labels
+	// equivalent to Tags in focus spec
+	Labels map[string]string `protobuf:"bytes,15,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
+	// Optional struct to implement other focus
+	// spec attributes
+	ExtendedAttributes *CustomCostExtendedAttributes `protobuf:"bytes,16,opt,name=extended_attributes,json=extendedAttributes,proto3,oneof" json:"extended_attributes,omitempty"`
+}
+
+func (x *CustomCost) Reset() {
+	*x = CustomCost{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_protos_customcost_messages_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *CustomCost) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CustomCost) ProtoMessage() {}
+
+func (x *CustomCost) ProtoReflect() protoreflect.Message {
+	mi := &file_protos_customcost_messages_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use CustomCost.ProtoReflect.Descriptor instead.
+func (*CustomCost) Descriptor() ([]byte, []int) {
+	return file_protos_customcost_messages_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *CustomCost) GetMetadata() map[string]string {
+	if x != nil {
+		return x.Metadata
+	}
+	return nil
+}
+
+func (x *CustomCost) GetZone() string {
+	if x != nil {
+		return x.Zone
+	}
+	return ""
+}
+
+func (x *CustomCost) GetAccountName() string {
+	if x != nil {
+		return x.AccountName
+	}
+	return ""
+}
+
+func (x *CustomCost) GetChargeCategory() string {
+	if x != nil {
+		return x.ChargeCategory
+	}
+	return ""
+}
+
+func (x *CustomCost) GetDescription() string {
+	if x != nil {
+		return x.Description
+	}
+	return ""
+}
+
+func (x *CustomCost) GetResourceName() string {
+	if x != nil {
+		return x.ResourceName
+	}
+	return ""
+}
+
+func (x *CustomCost) GetResourceType() string {
+	if x != nil {
+		return x.ResourceType
+	}
+	return ""
+}
+
+func (x *CustomCost) GetId() string {
+	if x != nil {
+		return x.Id
+	}
+	return ""
+}
+
+func (x *CustomCost) GetProviderId() string {
+	if x != nil {
+		return x.ProviderId
+	}
+	return ""
+}
+
+func (x *CustomCost) GetBilledCost() float32 {
+	if x != nil {
+		return x.BilledCost
+	}
+	return 0
+}
+
+func (x *CustomCost) GetListCost() float32 {
+	if x != nil {
+		return x.ListCost
+	}
+	return 0
+}
+
+func (x *CustomCost) GetListUnitPrice() float32 {
+	if x != nil {
+		return x.ListUnitPrice
+	}
+	return 0
+}
+
+func (x *CustomCost) GetUsageQuantity() float32 {
+	if x != nil {
+		return x.UsageQuantity
+	}
+	return 0
+}
+
+func (x *CustomCost) GetUsageUnit() string {
+	if x != nil {
+		return x.UsageUnit
+	}
+	return ""
+}
+
+func (x *CustomCost) GetLabels() map[string]string {
+	if x != nil {
+		return x.Labels
+	}
+	return nil
+}
+
+func (x *CustomCost) GetExtendedAttributes() *CustomCostExtendedAttributes {
+	if x != nil {
+		return x.ExtendedAttributes
+	}
+	return nil
+}
+
+type CustomCostExtendedAttributes struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// FOCUS billing period start
+	BillingPeriodStart *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=billing_period_start,json=billingPeriodStart,proto3,oneof" json:"billing_period_start,omitempty"`
+	// FOCUS billing period end
+	BillingPeriodEnd *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=billing_period_end,json=billingPeriodEnd,proto3,oneof" json:"billing_period_end,omitempty"`
+	// FOCUS Billing Account ID
+	AccountId *string `protobuf:"bytes,3,opt,name=account_id,json=accountId,proto3,oneof" json:"account_id,omitempty"`
+	// FOCUS Charge Frequency
+	ChargeFrequency *string `protobuf:"bytes,4,opt,name=charge_frequency,json=chargeFrequency,proto3,oneof" json:"charge_frequency,omitempty"`
+	// FOCUS Charge Subcategory
+	Subcategory *string `protobuf:"bytes,5,opt,name=subcategory,proto3,oneof" json:"subcategory,omitempty"`
+	// FOCUS Commitment Discount Category
+	CommitmentDiscountCategory *string `protobuf:"bytes,6,opt,name=commitment_discount_category,json=commitmentDiscountCategory,proto3,oneof" json:"commitment_discount_category,omitempty"`
+	// FOCUS Commitment Discount ID
+	CommitmentDiscountId *string `protobuf:"bytes,7,opt,name=commitment_discount_id,json=commitmentDiscountId,proto3,oneof" json:"commitment_discount_id,omitempty"`
+	// FOCUS Commitment Discount Name
+	CommitmentDiscountName *string `protobuf:"bytes,8,opt,name=commitment_discount_name,json=commitmentDiscountName,proto3,oneof" json:"commitment_discount_name,omitempty"`
+	// FOCUS Commitment Discount Type
+	CommitmentDiscountType *string `protobuf:"bytes,9,opt,name=commitment_discount_type,json=commitmentDiscountType,proto3,oneof" json:"commitment_discount_type,omitempty"`
+	// FOCUS Effective Cost
+	EffectiveCost *float32 `protobuf:"fixed32,10,opt,name=effective_cost,json=effectiveCost,proto3,oneof" json:"effective_cost,omitempty"`
+	// FOCUS Invoice Issuer
+	InvoiceIssuer *string `protobuf:"bytes,11,opt,name=invoice_issuer,json=invoiceIssuer,proto3,oneof" json:"invoice_issuer,omitempty"`
+	// FOCUS Provider
+	// if unset, assumed to be domain
+	Provider *string `protobuf:"bytes,12,opt,name=provider,proto3,oneof" json:"provider,omitempty"`
+	// FOCUS Publisher
+	// if unset, assumed to be domain
+	Publisher *string `protobuf:"bytes,13,opt,name=publisher,proto3,oneof" json:"publisher,omitempty"`
+	// FOCUS Service Category
+	// if unset, assumed to be cost source
+	ServiceCategory *string `protobuf:"bytes,14,opt,name=service_category,json=serviceCategory,proto3,oneof" json:"service_category,omitempty"`
+	// FOCUS Service Name
+	// if unset, assumed to be cost source
+	ServiceName *string `protobuf:"bytes,15,opt,name=service_name,json=serviceName,proto3,oneof" json:"service_name,omitempty"`
+	// FOCUS SKU ID
+	SkuId *string `protobuf:"bytes,16,opt,name=sku_id,json=skuId,proto3,oneof" json:"sku_id,omitempty"`
+	// FOCUS SKU Price ID
+	SkuPriceId *string `protobuf:"bytes,17,opt,name=sku_price_id,json=skuPriceId,proto3,oneof" json:"sku_price_id,omitempty"`
+	// FOCUS Sub Account ID
+	SubAccountId *string `protobuf:"bytes,18,opt,name=sub_account_id,json=subAccountId,proto3,oneof" json:"sub_account_id,omitempty"`
+	// FOCUS Sub Account Name
+	SubAccountName *string `protobuf:"bytes,19,opt,name=sub_account_name,json=subAccountName,proto3,oneof" json:"sub_account_name,omitempty"`
+	// FOCUS Pricing Quantity
+	PricingQuantity *float32 `protobuf:"fixed32,20,opt,name=pricing_quantity,json=pricingQuantity,proto3,oneof" json:"pricing_quantity,omitempty"`
+	// FOCUS Pricing Unit
+	PricingUnit *string `protobuf:"bytes,21,opt,name=pricing_unit,json=pricingUnit,proto3,oneof" json:"pricing_unit,omitempty"`
+	// FOCUS Pricing Category
+	PricingCategory *string `protobuf:"bytes,22,opt,name=pricing_category,json=pricingCategory,proto3,oneof" json:"pricing_category,omitempty"`
+}
+
+func (x *CustomCostExtendedAttributes) Reset() {
+	*x = CustomCostExtendedAttributes{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_protos_customcost_messages_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *CustomCostExtendedAttributes) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CustomCostExtendedAttributes) ProtoMessage() {}
+
+func (x *CustomCostExtendedAttributes) ProtoReflect() protoreflect.Message {
+	mi := &file_protos_customcost_messages_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use CustomCostExtendedAttributes.ProtoReflect.Descriptor instead.
+func (*CustomCostExtendedAttributes) Descriptor() ([]byte, []int) {
+	return file_protos_customcost_messages_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *CustomCostExtendedAttributes) GetBillingPeriodStart() *timestamppb.Timestamp {
+	if x != nil {
+		return x.BillingPeriodStart
+	}
+	return nil
+}
+
+func (x *CustomCostExtendedAttributes) GetBillingPeriodEnd() *timestamppb.Timestamp {
+	if x != nil {
+		return x.BillingPeriodEnd
+	}
+	return nil
+}
+
+func (x *CustomCostExtendedAttributes) GetAccountId() string {
+	if x != nil && x.AccountId != nil {
+		return *x.AccountId
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetChargeFrequency() string {
+	if x != nil && x.ChargeFrequency != nil {
+		return *x.ChargeFrequency
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetSubcategory() string {
+	if x != nil && x.Subcategory != nil {
+		return *x.Subcategory
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetCommitmentDiscountCategory() string {
+	if x != nil && x.CommitmentDiscountCategory != nil {
+		return *x.CommitmentDiscountCategory
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetCommitmentDiscountId() string {
+	if x != nil && x.CommitmentDiscountId != nil {
+		return *x.CommitmentDiscountId
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetCommitmentDiscountName() string {
+	if x != nil && x.CommitmentDiscountName != nil {
+		return *x.CommitmentDiscountName
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetCommitmentDiscountType() string {
+	if x != nil && x.CommitmentDiscountType != nil {
+		return *x.CommitmentDiscountType
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetEffectiveCost() float32 {
+	if x != nil && x.EffectiveCost != nil {
+		return *x.EffectiveCost
+	}
+	return 0
+}
+
+func (x *CustomCostExtendedAttributes) GetInvoiceIssuer() string {
+	if x != nil && x.InvoiceIssuer != nil {
+		return *x.InvoiceIssuer
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetProvider() string {
+	if x != nil && x.Provider != nil {
+		return *x.Provider
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetPublisher() string {
+	if x != nil && x.Publisher != nil {
+		return *x.Publisher
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetServiceCategory() string {
+	if x != nil && x.ServiceCategory != nil {
+		return *x.ServiceCategory
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetServiceName() string {
+	if x != nil && x.ServiceName != nil {
+		return *x.ServiceName
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetSkuId() string {
+	if x != nil && x.SkuId != nil {
+		return *x.SkuId
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetSkuPriceId() string {
+	if x != nil && x.SkuPriceId != nil {
+		return *x.SkuPriceId
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetSubAccountId() string {
+	if x != nil && x.SubAccountId != nil {
+		return *x.SubAccountId
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetSubAccountName() string {
+	if x != nil && x.SubAccountName != nil {
+		return *x.SubAccountName
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetPricingQuantity() float32 {
+	if x != nil && x.PricingQuantity != nil {
+		return *x.PricingQuantity
+	}
+	return 0
+}
+
+func (x *CustomCostExtendedAttributes) GetPricingUnit() string {
+	if x != nil && x.PricingUnit != nil {
+		return *x.PricingUnit
+	}
+	return ""
+}
+
+func (x *CustomCostExtendedAttributes) GetPricingCategory() string {
+	if x != nil && x.PricingCategory != nil {
+		return *x.PricingCategory
+	}
+	return ""
+}
+
+var File_protos_customcost_messages_proto protoreflect.FileDescriptor
+
+var file_protos_customcost_messages_proto_rawDesc = []byte{
+	0x0a, 0x20, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x73, 0x2f, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x63,
+	0x6f, 0x73, 0x74, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x12, 0x13, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x63, 0x6f, 0x73, 0x74, 0x2e, 0x6d,
+	0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
+	0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+	0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xae, 0x01, 0x0a, 0x11, 0x43, 0x75, 0x73,
+	0x74, 0x6f, 0x6d, 0x43, 0x6f, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x30,
+	0x0a, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
+	0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e,
+	0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74,
+	0x12, 0x2c, 0x0a, 0x03, 0x65, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
+	0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e,
+	0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x12, 0x39,
+	0x0a, 0x0a, 0x72, 0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x72,
+	0x65, 0x73, 0x6f, 0x6c, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x56, 0x0a, 0x15, 0x43, 0x75, 0x73,
+	0x74, 0x6f, 0x6d, 0x43, 0x6f, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x53,
+	0x65, 0x74, 0x12, 0x3d, 0x0a, 0x05, 0x72, 0x65, 0x73, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
+	0x0b, 0x32, 0x27, 0x2e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x63, 0x6f, 0x73, 0x74, 0x2e, 0x6d,
+	0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x43, 0x6f,
+	0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x52, 0x05, 0x72, 0x65, 0x73, 0x70,
+	0x73, 0x22, 0xc2, 0x03, 0x0a, 0x12, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x43, 0x6f, 0x73, 0x74,
+	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61,
+	0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x35, 0x2e, 0x63, 0x75, 0x73,
+	0x74, 0x6f, 0x6d, 0x63, 0x6f, 0x73, 0x74, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73,
+	0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x43, 0x6f, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
+	0x6e, 0x73, 0x65, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72,
+	0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1f, 0x0a, 0x0b, 0x63,
+	0x6f, 0x73, 0x74, 0x5f, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x0a, 0x63, 0x6f, 0x73, 0x74, 0x53, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06,
+	0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f,
+	0x6d, 0x61, 0x69, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18,
+	0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1a,
+	0x0a, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x08, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x30, 0x0a, 0x05, 0x73, 0x74,
+	0x61, 0x72, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
+	0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65,
+	0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x05, 0x73, 0x74, 0x61, 0x72, 0x74, 0x12, 0x2c, 0x0a, 0x03,
+	0x65, 0x6e, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
+	0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65,
+	0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x03, 0x65, 0x6e, 0x64, 0x12, 0x35, 0x0a, 0x05, 0x63, 0x6f,
+	0x73, 0x74, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x63, 0x75, 0x73, 0x74,
+	0x6f, 0x6d, 0x63, 0x6f, 0x73, 0x74, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
+	0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x43, 0x6f, 0x73, 0x74, 0x52, 0x05, 0x63, 0x6f, 0x73, 0x74,
+	0x73, 0x12, 0x16, 0x0a, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28,
+	0x09, 0x52, 0x06, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, 0x74,
+	0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65,
+	0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05,
+	0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c,
+	0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xbe, 0x06, 0x0a, 0x0a, 0x43, 0x75, 0x73, 0x74, 0x6f,
+	0x6d, 0x43, 0x6f, 0x73, 0x74, 0x12, 0x49, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
+	0x61, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d,
+	0x63, 0x6f, 0x73, 0x74, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x75,
+	0x73, 0x74, 0x6f, 0x6d, 0x43, 0x6f, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
+	0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
+	0x12, 0x12, 0x0a, 0x04, 0x7a, 0x6f, 0x6e, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
+	0x7a, 0x6f, 0x6e, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f,
+	0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x63, 0x63, 0x6f,
+	0x75, 0x6e, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x63, 0x68, 0x61, 0x72, 0x67,
+	0x65, 0x5f, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x0e, 0x63, 0x68, 0x61, 0x72, 0x67, 0x65, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
+	0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18,
+	0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69,
+	0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x6e,
+	0x61, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x73, 0x6f, 0x75,
+	0x72, 0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x6f, 0x75,
+	0x72, 0x63, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c,
+	0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x02,
+	0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1f, 0x0a, 0x0b,
+	0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1f, 0x0a,
+	0x0b, 0x62, 0x69, 0x6c, 0x6c, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x0a, 0x20, 0x01,
+	0x28, 0x02, 0x52, 0x0a, 0x62, 0x69, 0x6c, 0x6c, 0x65, 0x64, 0x43, 0x6f, 0x73, 0x74, 0x12, 0x1b,
+	0x0a, 0x09, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28,
+	0x02, 0x52, 0x08, 0x6c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0f, 0x6c,
+	0x69, 0x73, 0x74, 0x5f, 0x75, 0x6e, 0x69, 0x74, 0x5f, 0x70, 0x72, 0x69, 0x63, 0x65, 0x18, 0x0c,
+	0x20, 0x01, 0x28, 0x02, 0x52, 0x0d, 0x6c, 0x69, 0x73, 0x74, 0x55, 0x6e, 0x69, 0x74, 0x50, 0x72,
+	0x69, 0x63, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x71, 0x75, 0x61,
+	0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x02, 0x52, 0x0d, 0x75, 0x73, 0x61,
+	0x67, 0x65, 0x51, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73,
+	0x61, 0x67, 0x65, 0x5f, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
+	0x75, 0x73, 0x61, 0x67, 0x65, 0x55, 0x6e, 0x69, 0x74, 0x12, 0x43, 0x0a, 0x06, 0x6c, 0x61, 0x62,
+	0x65, 0x6c, 0x73, 0x18, 0x0f, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x63, 0x75, 0x73, 0x74,
+	0x6f, 0x6d, 0x63, 0x6f, 0x73, 0x74, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e,
+	0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x43, 0x6f, 0x73, 0x74, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c,
+	0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x67,
+	0x0a, 0x13, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x74, 0x72, 0x69,
+	0x62, 0x75, 0x74, 0x65, 0x73, 0x18, 0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x31, 0x2e, 0x63, 0x75,
+	0x73, 0x74, 0x6f, 0x6d, 0x63, 0x6f, 0x73, 0x74, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
+	0x73, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x43, 0x6f, 0x73, 0x74, 0x45, 0x78, 0x74, 0x65,
+	0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x48, 0x00,
+	0x52, 0x12, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74, 0x74, 0x72, 0x69, 0x62,
+	0x75, 0x74, 0x65, 0x73, 0x88, 0x01, 0x01, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64,
+	0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61,
+	0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
+	0x3a, 0x02, 0x38, 0x01, 0x1a, 0x39, 0x0a, 0x0b, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x45, 0x6e,
+	0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x42,
+	0x16, 0x0a, 0x14, 0x5f, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x74,
+	0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x22, 0x94, 0x0c, 0x0a, 0x1c, 0x43, 0x75, 0x73, 0x74,
+	0x6f, 0x6d, 0x43, 0x6f, 0x73, 0x74, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x74,
+	0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65, 0x73, 0x12, 0x51, 0x0a, 0x14, 0x62, 0x69, 0x6c, 0x6c,
+	0x69, 0x6e, 0x67, 0x5f, 0x70, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
+	0x6d, 0x70, 0x48, 0x00, 0x52, 0x12, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x50, 0x65, 0x72,
+	0x69, 0x6f, 0x64, 0x53, 0x74, 0x61, 0x72, 0x74, 0x88, 0x01, 0x01, 0x12, 0x4d, 0x0a, 0x12, 0x62,
+	0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x5f, 0x65, 0x6e,
+	0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74,
+	0x61, 0x6d, 0x70, 0x48, 0x01, 0x52, 0x10, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x50, 0x65,
+	0x72, 0x69, 0x6f, 0x64, 0x45, 0x6e, 0x64, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0a, 0x61, 0x63,
+	0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x02,
+	0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x2e,
+	0x0a, 0x10, 0x63, 0x68, 0x61, 0x72, 0x67, 0x65, 0x5f, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e,
+	0x63, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x0f, 0x63, 0x68, 0x61, 0x72,
+	0x67, 0x65, 0x46, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x79, 0x88, 0x01, 0x01, 0x12, 0x25,
+	0x0a, 0x0b, 0x73, 0x75, 0x62, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x18, 0x05, 0x20,
+	0x01, 0x28, 0x09, 0x48, 0x04, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f,
+	0x72, 0x79, 0x88, 0x01, 0x01, 0x12, 0x45, 0x0a, 0x1c, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x6d,
+	0x65, 0x6e, 0x74, 0x5f, 0x64, 0x69, 0x73, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x63, 0x61, 0x74,
+	0x65, 0x67, 0x6f, 0x72, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x05, 0x52, 0x1a, 0x63,
+	0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x75, 0x6e,
+	0x74, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x88, 0x01, 0x01, 0x12, 0x39, 0x0a, 0x16,
+	0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x64, 0x69, 0x73, 0x63, 0x6f,
+	0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x48, 0x06, 0x52, 0x14,
+	0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x75,
+	0x6e, 0x74, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x3d, 0x0a, 0x18, 0x63, 0x6f, 0x6d, 0x6d, 0x69,
+	0x74, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x64, 0x69, 0x73, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x6e,
+	0x61, 0x6d, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x48, 0x07, 0x52, 0x16, 0x63, 0x6f, 0x6d,
+	0x6d, 0x69, 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e,
+	0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x3d, 0x0a, 0x18, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74,
+	0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x64, 0x69, 0x73, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x74, 0x79,
+	0x70, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x48, 0x08, 0x52, 0x16, 0x63, 0x6f, 0x6d, 0x6d,
+	0x69, 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x54, 0x79,
+	0x70, 0x65, 0x88, 0x01, 0x01, 0x12, 0x2a, 0x0a, 0x0e, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69,
+	0x76, 0x65, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x02, 0x48, 0x09, 0x52,
+	0x0d, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x43, 0x6f, 0x73, 0x74, 0x88, 0x01,
+	0x01, 0x12, 0x2a, 0x0a, 0x0e, 0x69, 0x6e, 0x76, 0x6f, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x73, 0x73,
+	0x75, 0x65, 0x72, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x48, 0x0a, 0x52, 0x0d, 0x69, 0x6e, 0x76,
+	0x6f, 0x69, 0x63, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x72, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a,
+	0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x48,
+	0x0b, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x88, 0x01, 0x01, 0x12, 0x21,
+	0x0a, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x65, 0x72, 0x18, 0x0d, 0x20, 0x01, 0x28,
+	0x09, 0x48, 0x0c, 0x52, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x65, 0x72, 0x88, 0x01,
+	0x01, 0x12, 0x2e, 0x0a, 0x10, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x63, 0x61, 0x74,
+	0x65, 0x67, 0x6f, 0x72, 0x79, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x48, 0x0d, 0x52, 0x0f, 0x73,
+	0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x88, 0x01,
+	0x01, 0x12, 0x26, 0x0a, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d,
+	0x65, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x48, 0x0e, 0x52, 0x0b, 0x73, 0x65, 0x72, 0x76, 0x69,
+	0x63, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1a, 0x0a, 0x06, 0x73, 0x6b, 0x75,
+	0x5f, 0x69, 0x64, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x48, 0x0f, 0x52, 0x05, 0x73, 0x6b, 0x75,
+	0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x25, 0x0a, 0x0c, 0x73, 0x6b, 0x75, 0x5f, 0x70, 0x72, 0x69,
+	0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x48, 0x10, 0x52, 0x0a, 0x73,
+	0x6b, 0x75, 0x50, 0x72, 0x69, 0x63, 0x65, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x29, 0x0a, 0x0e,
+	0x73, 0x75, 0x62, 0x5f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x12,
+	0x20, 0x01, 0x28, 0x09, 0x48, 0x11, 0x52, 0x0c, 0x73, 0x75, 0x62, 0x41, 0x63, 0x63, 0x6f, 0x75,
+	0x6e, 0x74, 0x49, 0x64, 0x88, 0x01, 0x01, 0x12, 0x2d, 0x0a, 0x10, 0x73, 0x75, 0x62, 0x5f, 0x61,
+	0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x13, 0x20, 0x01, 0x28,
+	0x09, 0x48, 0x12, 0x52, 0x0e, 0x73, 0x75, 0x62, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x4e,
+	0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x2e, 0x0a, 0x10, 0x70, 0x72, 0x69, 0x63, 0x69, 0x6e,
+	0x67, 0x5f, 0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x18, 0x14, 0x20, 0x01, 0x28, 0x02,
+	0x48, 0x13, 0x52, 0x0f, 0x70, 0x72, 0x69, 0x63, 0x69, 0x6e, 0x67, 0x51, 0x75, 0x61, 0x6e, 0x74,
+	0x69, 0x74, 0x79, 0x88, 0x01, 0x01, 0x12, 0x26, 0x0a, 0x0c, 0x70, 0x72, 0x69, 0x63, 0x69, 0x6e,
+	0x67, 0x5f, 0x75, 0x6e, 0x69, 0x74, 0x18, 0x15, 0x20, 0x01, 0x28, 0x09, 0x48, 0x14, 0x52, 0x0b,
+	0x70, 0x72, 0x69, 0x63, 0x69, 0x6e, 0x67, 0x55, 0x6e, 0x69, 0x74, 0x88, 0x01, 0x01, 0x12, 0x2e,
+	0x0a, 0x10, 0x70, 0x72, 0x69, 0x63, 0x69, 0x6e, 0x67, 0x5f, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f,
+	0x72, 0x79, 0x18, 0x16, 0x20, 0x01, 0x28, 0x09, 0x48, 0x15, 0x52, 0x0f, 0x70, 0x72, 0x69, 0x63,
+	0x69, 0x6e, 0x67, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x88, 0x01, 0x01, 0x42, 0x17,
+	0x0a, 0x15, 0x5f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x70, 0x65, 0x72, 0x69, 0x6f,
+	0x64, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x74, 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x62, 0x69, 0x6c, 0x6c,
+	0x69, 0x6e, 0x67, 0x5f, 0x70, 0x65, 0x72, 0x69, 0x6f, 0x64, 0x5f, 0x65, 0x6e, 0x64, 0x42, 0x0d,
+	0x0a, 0x0b, 0x5f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x42, 0x13, 0x0a,
+	0x11, 0x5f, 0x63, 0x68, 0x61, 0x72, 0x67, 0x65, 0x5f, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e,
+	0x63, 0x79, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x73, 0x75, 0x62, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f,
+	0x72, 0x79, 0x42, 0x1f, 0x0a, 0x1d, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x6d, 0x65, 0x6e,
+	0x74, 0x5f, 0x64, 0x69, 0x73, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x63, 0x61, 0x74, 0x65, 0x67,
+	0x6f, 0x72, 0x79, 0x42, 0x19, 0x0a, 0x17, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x6d, 0x65,
+	0x6e, 0x74, 0x5f, 0x64, 0x69, 0x73, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x42, 0x1b,
+	0x0a, 0x19, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x64, 0x69,
+	0x73, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x1b, 0x0a, 0x19, 0x5f,
+	0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x64, 0x69, 0x73, 0x63, 0x6f,
+	0x75, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x42, 0x11, 0x0a, 0x0f, 0x5f, 0x65, 0x66, 0x66,
+	0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x5f, 0x63, 0x6f, 0x73, 0x74, 0x42, 0x11, 0x0a, 0x0f, 0x5f,
+	0x69, 0x6e, 0x76, 0x6f, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x42, 0x0b,
+	0x0a, 0x09, 0x5f, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x42, 0x0c, 0x0a, 0x0a, 0x5f,
+	0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x65, 0x72, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x73, 0x65,
+	0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x42, 0x0f,
+	0x0a, 0x0d, 0x5f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42,
+	0x09, 0x0a, 0x07, 0x5f, 0x73, 0x6b, 0x75, 0x5f, 0x69, 0x64, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x73,
+	0x6b, 0x75, 0x5f, 0x70, 0x72, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x42, 0x11, 0x0a, 0x0f, 0x5f,
+	0x73, 0x75, 0x62, 0x5f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x42, 0x13,
+	0x0a, 0x11, 0x5f, 0x73, 0x75, 0x62, 0x5f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x6e,
+	0x61, 0x6d, 0x65, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x70, 0x72, 0x69, 0x63, 0x69, 0x6e, 0x67, 0x5f,
+	0x71, 0x75, 0x61, 0x6e, 0x74, 0x69, 0x74, 0x79, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x70, 0x72, 0x69,
+	0x63, 0x69, 0x6e, 0x67, 0x5f, 0x75, 0x6e, 0x69, 0x74, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x70, 0x72,
+	0x69, 0x63, 0x69, 0x6e, 0x67, 0x5f, 0x63, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, 0x32, 0x79,
+	0x0a, 0x11, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x43, 0x6f, 0x73, 0x74, 0x73, 0x53, 0x6f, 0x75,
+	0x72, 0x63, 0x65, 0x12, 0x64, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d,
+	0x43, 0x6f, 0x73, 0x74, 0x73, 0x12, 0x26, 0x2e, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x63, 0x6f,
+	0x73, 0x74, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x73, 0x2e, 0x43, 0x75, 0x73, 0x74,
+	0x6f, 0x6d, 0x43, 0x6f, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e,
+	0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x63, 0x6f, 0x73, 0x74, 0x2e, 0x6d, 0x65, 0x73, 0x73, 0x61,
+	0x67, 0x65, 0x73, 0x2e, 0x43, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x43, 0x6f, 0x73, 0x74, 0x52, 0x65,
+	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x53, 0x65, 0x74, 0x42, 0x30, 0x5a, 0x2e, 0x67, 0x69, 0x74,
+	0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6f, 0x73, 0x74,
+	0x2f, 0x6f, 0x70, 0x65, 0x6e, 0x63, 0x6f, 0x73, 0x74, 0x2f, 0x63, 0x6f, 0x72, 0x65, 0x2f, 0x70,
+	0x6b, 0x67, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x33,
+}
+
+var (
+	file_protos_customcost_messages_proto_rawDescOnce sync.Once
+	file_protos_customcost_messages_proto_rawDescData = file_protos_customcost_messages_proto_rawDesc
+)
+
+func file_protos_customcost_messages_proto_rawDescGZIP() []byte {
+	file_protos_customcost_messages_proto_rawDescOnce.Do(func() {
+		file_protos_customcost_messages_proto_rawDescData = protoimpl.X.CompressGZIP(file_protos_customcost_messages_proto_rawDescData)
+	})
+	return file_protos_customcost_messages_proto_rawDescData
+}
+
+var file_protos_customcost_messages_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
+var file_protos_customcost_messages_proto_goTypes = []interface{}{
+	(*CustomCostRequest)(nil),            // 0: customcost.messages.CustomCostRequest
+	(*CustomCostResponseSet)(nil),        // 1: customcost.messages.CustomCostResponseSet
+	(*CustomCostResponse)(nil),           // 2: customcost.messages.CustomCostResponse
+	(*CustomCost)(nil),                   // 3: customcost.messages.CustomCost
+	(*CustomCostExtendedAttributes)(nil), // 4: customcost.messages.CustomCostExtendedAttributes
+	nil,                                  // 5: customcost.messages.CustomCostResponse.MetadataEntry
+	nil,                                  // 6: customcost.messages.CustomCost.MetadataEntry
+	nil,                                  // 7: customcost.messages.CustomCost.LabelsEntry
+	(*timestamppb.Timestamp)(nil),        // 8: google.protobuf.Timestamp
+	(*durationpb.Duration)(nil),          // 9: google.protobuf.Duration
+}
+var file_protos_customcost_messages_proto_depIdxs = []int32{
+	8,  // 0: customcost.messages.CustomCostRequest.start:type_name -> google.protobuf.Timestamp
+	8,  // 1: customcost.messages.CustomCostRequest.end:type_name -> google.protobuf.Timestamp
+	9,  // 2: customcost.messages.CustomCostRequest.resolution:type_name -> google.protobuf.Duration
+	2,  // 3: customcost.messages.CustomCostResponseSet.resps:type_name -> customcost.messages.CustomCostResponse
+	5,  // 4: customcost.messages.CustomCostResponse.metadata:type_name -> customcost.messages.CustomCostResponse.MetadataEntry
+	8,  // 5: customcost.messages.CustomCostResponse.start:type_name -> google.protobuf.Timestamp
+	8,  // 6: customcost.messages.CustomCostResponse.end:type_name -> google.protobuf.Timestamp
+	3,  // 7: customcost.messages.CustomCostResponse.costs:type_name -> customcost.messages.CustomCost
+	6,  // 8: customcost.messages.CustomCost.metadata:type_name -> customcost.messages.CustomCost.MetadataEntry
+	7,  // 9: customcost.messages.CustomCost.labels:type_name -> customcost.messages.CustomCost.LabelsEntry
+	4,  // 10: customcost.messages.CustomCost.extended_attributes:type_name -> customcost.messages.CustomCostExtendedAttributes
+	8,  // 11: customcost.messages.CustomCostExtendedAttributes.billing_period_start:type_name -> google.protobuf.Timestamp
+	8,  // 12: customcost.messages.CustomCostExtendedAttributes.billing_period_end:type_name -> google.protobuf.Timestamp
+	0,  // 13: customcost.messages.CustomCostsSource.GetCustomCosts:input_type -> customcost.messages.CustomCostRequest
+	1,  // 14: customcost.messages.CustomCostsSource.GetCustomCosts:output_type -> customcost.messages.CustomCostResponseSet
+	14, // [14:15] is the sub-list for method output_type
+	13, // [13:14] is the sub-list for method input_type
+	13, // [13:13] is the sub-list for extension type_name
+	13, // [13:13] is the sub-list for extension extendee
+	0,  // [0:13] is the sub-list for field type_name
+}
+
+func init() { file_protos_customcost_messages_proto_init() }
+func file_protos_customcost_messages_proto_init() {
+	if File_protos_customcost_messages_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_protos_customcost_messages_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*CustomCostRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_protos_customcost_messages_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*CustomCostResponseSet); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_protos_customcost_messages_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*CustomCostResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_protos_customcost_messages_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*CustomCost); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_protos_customcost_messages_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*CustomCostExtendedAttributes); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	file_protos_customcost_messages_proto_msgTypes[3].OneofWrappers = []interface{}{}
+	file_protos_customcost_messages_proto_msgTypes[4].OneofWrappers = []interface{}{}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_protos_customcost_messages_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   8,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_protos_customcost_messages_proto_goTypes,
+		DependencyIndexes: file_protos_customcost_messages_proto_depIdxs,
+		MessageInfos:      file_protos_customcost_messages_proto_msgTypes,
+	}.Build()
+	File_protos_customcost_messages_proto = out.File
+	file_protos_customcost_messages_proto_rawDesc = nil
+	file_protos_customcost_messages_proto_goTypes = nil
+	file_protos_customcost_messages_proto_depIdxs = nil
+}

+ 109 - 0
core/pkg/model/pb/messages_grpc.pb.go

@@ -0,0 +1,109 @@
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+// versions:
+// - protoc-gen-go-grpc v1.3.0
+// - protoc             v4.25.3
+// source: protos/customcost/messages.proto
+
+package pb
+
+import (
+	context "context"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.32.0 or later.
+const _ = grpc.SupportPackageIsVersion7
+
+const (
+	CustomCostsSource_GetCustomCosts_FullMethodName = "/customcost.messages.CustomCostsSource/GetCustomCosts"
+)
+
+// CustomCostsSourceClient is the client API for CustomCostsSource service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+type CustomCostsSourceClient interface {
+	GetCustomCosts(ctx context.Context, in *CustomCostRequest, opts ...grpc.CallOption) (*CustomCostResponseSet, error)
+}
+
+type customCostsSourceClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewCustomCostsSourceClient(cc grpc.ClientConnInterface) CustomCostsSourceClient {
+	return &customCostsSourceClient{cc}
+}
+
+func (c *customCostsSourceClient) GetCustomCosts(ctx context.Context, in *CustomCostRequest, opts ...grpc.CallOption) (*CustomCostResponseSet, error) {
+	out := new(CustomCostResponseSet)
+	err := c.cc.Invoke(ctx, CustomCostsSource_GetCustomCosts_FullMethodName, in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// CustomCostsSourceServer is the server API for CustomCostsSource service.
+// All implementations must embed UnimplementedCustomCostsSourceServer
+// for forward compatibility
+type CustomCostsSourceServer interface {
+	GetCustomCosts(context.Context, *CustomCostRequest) (*CustomCostResponseSet, error)
+	mustEmbedUnimplementedCustomCostsSourceServer()
+}
+
+// UnimplementedCustomCostsSourceServer must be embedded to have forward compatible implementations.
+type UnimplementedCustomCostsSourceServer struct {
+}
+
+func (UnimplementedCustomCostsSourceServer) GetCustomCosts(context.Context, *CustomCostRequest) (*CustomCostResponseSet, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GetCustomCosts not implemented")
+}
+func (UnimplementedCustomCostsSourceServer) mustEmbedUnimplementedCustomCostsSourceServer() {}
+
+// UnsafeCustomCostsSourceServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to CustomCostsSourceServer will
+// result in compilation errors.
+type UnsafeCustomCostsSourceServer interface {
+	mustEmbedUnimplementedCustomCostsSourceServer()
+}
+
+func RegisterCustomCostsSourceServer(s grpc.ServiceRegistrar, srv CustomCostsSourceServer) {
+	s.RegisterService(&CustomCostsSource_ServiceDesc, srv)
+}
+
+func _CustomCostsSource_GetCustomCosts_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(CustomCostRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(CustomCostsSourceServer).GetCustomCosts(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: CustomCostsSource_GetCustomCosts_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(CustomCostsSourceServer).GetCustomCosts(ctx, req.(*CustomCostRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+// CustomCostsSource_ServiceDesc is the grpc.ServiceDesc for CustomCostsSource service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var CustomCostsSource_ServiceDesc = grpc.ServiceDesc{
+	ServiceName: "customcost.messages.CustomCostsSource",
+	HandlerType: (*CustomCostsSourceServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "GetCustomCosts",
+			Handler:    _CustomCostsSource_GetCustomCosts_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "protos/customcost/messages.proto",
+}

+ 139 - 0
core/pkg/opencost/k8sobjectmatcher.go

@@ -0,0 +1,139 @@
+package opencost
+
+import (
+	"fmt"
+
+	"github.com/opencost/opencost/core/pkg/filter/ast"
+	kfilter "github.com/opencost/opencost/core/pkg/filter/k8sobject"
+	"github.com/opencost/opencost/core/pkg/filter/matcher"
+	"github.com/opencost/opencost/core/pkg/filter/transform"
+	appsv1 "k8s.io/api/apps/v1"
+	batchv1 "k8s.io/api/batch/v1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+)
+
+// K8sObjectMatcher is a matcher implementation for Kubernetes runtime.Object
+// instances, compiled using the matcher.MatchCompiler.
+type K8sObjectMatcher matcher.Matcher[runtime.Object]
+
+// NewK8sObjectMatchCompiler creates a new instance of a
+// matcher.MatchCompiler[runtime.Object] which can be used to compile
+// filter.Filter ASTs into matcher.Matcher[runtime.Object] implementations.
+//
+// If the label config is nil, the compiler will fail to compile alias filters
+// if any are present in the AST.
+func NewK8sObjectMatchCompiler() *matcher.MatchCompiler[runtime.Object] {
+	passes := []transform.CompilerPass{}
+
+	return matcher.NewMatchCompiler(
+		k8sObjectFieldMap,
+		k8sObjectSliceFieldMap,
+		k8sObjectMapFieldMap,
+		passes...,
+	)
+}
+
+func objectMetaFromObject(o runtime.Object) (metav1.ObjectMeta, error) {
+	switch v := o.(type) {
+	case *appsv1.Deployment:
+		return v.ObjectMeta, nil
+	case *appsv1.StatefulSet:
+		return v.ObjectMeta, nil
+	case *appsv1.DaemonSet:
+		return v.ObjectMeta, nil
+	case *corev1.Pod:
+		return v.ObjectMeta, nil
+	case *batchv1.CronJob:
+		return v.ObjectMeta, nil
+	}
+
+	return metav1.ObjectMeta{}, fmt.Errorf("currently-unsupported runtime.Object type for filtering: %T", o)
+}
+
+// Maps fields from an allocation to a string value based on an identifier
+func k8sObjectFieldMap(o runtime.Object, identifier ast.Identifier) (string, error) {
+	if identifier.Field == nil {
+		return "", fmt.Errorf("cannot map field from identifier with nil field")
+	}
+
+	m, err := objectMetaFromObject(o)
+	if err != nil {
+		return "", fmt.Errorf("retrieving object meta: %w", err)
+	}
+	var controllerKind string
+	var controllerName string
+	var pod string
+
+	switch v := o.(type) {
+	case *appsv1.Deployment:
+		controllerKind = "deployment"
+		controllerName = v.Name
+	case *appsv1.StatefulSet:
+		controllerKind = "statefulset"
+		controllerName = v.Name
+	case *appsv1.DaemonSet:
+		controllerKind = "daemonset"
+		controllerName = v.Name
+	case *corev1.Pod:
+		pod = v.Name
+		if len(v.OwnerReferences) == 0 {
+			controllerKind = "pod"
+			controllerName = v.Name
+		}
+	case *batchv1.CronJob:
+		controllerKind = "cronjob"
+		controllerName = v.Name
+	default:
+		return "", fmt.Errorf("currently-unsupported runtime.Object type for filtering: %T", o)
+	}
+
+	// For now, we will just do our best to implement Allocation fields because
+	// most k8s-based queries are on Allocation data. The other we will
+	// eventually want to support is Asset, but I'm not sure that I have time
+	// for that right now.
+	field := kfilter.K8sObjectField(identifier.Field.Name)
+	switch field {
+	case kfilter.FieldNamespace:
+		return m.Namespace, nil
+	case kfilter.FieldControllerName:
+		return controllerName, nil
+	case kfilter.FieldControllerKind:
+		return controllerKind, nil
+	case kfilter.FieldPod:
+		return pod, nil
+	case kfilter.FieldLabel:
+		if m.Labels != nil {
+			return m.Labels[identifier.Key], nil
+		}
+		return "", nil
+	case kfilter.FieldAnnotation:
+		if m.Annotations != nil {
+			return m.Annotations[identifier.Key], nil
+		}
+		return "", nil
+	}
+
+	return "", fmt.Errorf("Failed to find string identifier on K8sObject: %s (consider adding support if this is an expected field)", identifier.Field.Name)
+}
+
+// Maps slice fields from an allocation to a []string value based on an identifier
+func k8sObjectSliceFieldMap(o runtime.Object, identifier ast.Identifier) ([]string, error) {
+	return nil, fmt.Errorf("K8sObject filters current have no supported []string identifiers")
+}
+
+// Maps map fields from an allocation to a map[string]string value based on an identifier
+func k8sObjectMapFieldMap(o runtime.Object, identifier ast.Identifier) (map[string]string, error) {
+	m, err := objectMetaFromObject(o)
+	if err != nil {
+		return nil, fmt.Errorf("retrieving object meta: %w", err)
+	}
+	switch kfilter.K8sObjectField(identifier.Field.Name) {
+	case kfilter.FieldLabel:
+		return m.Labels, nil
+	case kfilter.FieldAnnotation:
+		return m.Annotations, nil
+	}
+	return nil, fmt.Errorf("Failed to find map[string]string identifier on K8sObject: %s", identifier.Field.Name)
+}

+ 222 - 0
core/pkg/opencost/k8sobjectmatcher_test.go

@@ -0,0 +1,222 @@
+package opencost
+
+import (
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/filter/ast"
+	k8sobject "github.com/opencost/opencost/core/pkg/filter/k8sobject"
+	appsv1 "k8s.io/api/apps/v1"
+	batchv1 "k8s.io/api/batch/v1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+)
+
+func TestK8sObjectMatcher(t *testing.T) {
+	cases := []struct {
+		filter string
+		o      runtime.Object
+
+		expected bool
+	}{
+		{
+			filter: `namespace:"kubecost"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: "kubecost",
+				},
+			},
+			expected: true,
+		},
+		{
+			filter: `namespace:"kubecost"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: "kube-system",
+				},
+			},
+			expected: false,
+		},
+		{
+			filter: `pod:"foo"`,
+			o: &corev1.Pod{
+				ObjectMeta: metav1.ObjectMeta{Name: "foo"},
+			},
+			expected: true,
+		},
+		{
+			filter: `pod:"foo"`,
+			o: &corev1.Pod{
+				ObjectMeta: metav1.ObjectMeta{Name: "bar"},
+			},
+			expected: false,
+		},
+		{
+			filter: `pod:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{Name: "foo"},
+			},
+			expected: false,
+		},
+		{
+			filter:   `controllerKind:"deployment"`,
+			o:        &appsv1.Deployment{},
+			expected: true,
+		},
+		{
+			filter: `controllerName:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{Name: "foo"},
+			},
+			expected: true,
+		},
+		{
+			filter:   `controllerKind:"statefulset"`,
+			o:        &appsv1.StatefulSet{},
+			expected: true,
+		},
+		{
+			filter: `controllerName:"foo"`,
+			o: &appsv1.StatefulSet{
+				ObjectMeta: metav1.ObjectMeta{Name: "foo"},
+			},
+			expected: true,
+		},
+		{
+			filter:   `controllerKind:"daemonset"`,
+			o:        &appsv1.DaemonSet{},
+			expected: true,
+		},
+		{
+			filter: `controllerName:"foo"`,
+			o: &appsv1.DaemonSet{
+				ObjectMeta: metav1.ObjectMeta{Name: "foo"},
+			},
+			expected: true,
+		},
+		{
+			filter:   `controllerKind:"cronjob"`,
+			o:        &batchv1.CronJob{},
+			expected: true,
+		},
+		{
+			filter: `controllerName:"foo"`,
+			o: &batchv1.CronJob{
+				ObjectMeta: metav1.ObjectMeta{Name: "foo"},
+			},
+			expected: true,
+		},
+		{
+			filter:   `controllerKind:"pod"`,
+			o:        &corev1.Pod{},
+			expected: true,
+		},
+		{
+			filter: `controllerKind:"pod"`,
+			o: &corev1.Pod{
+				ObjectMeta: metav1.ObjectMeta{
+					OwnerReferences: []metav1.OwnerReference{
+						{}, // Having an owner reference makes this Pod "controlled"
+					},
+				},
+			},
+			expected: false,
+		},
+		{
+			filter: `label[app]:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels: map[string]string{"app": "foo"},
+				},
+			},
+			expected: true,
+		},
+		{
+			filter: `label[app]:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels: map[string]string{"app": "bar"},
+				},
+			},
+			expected: false,
+		},
+		{
+			filter: `label[app]:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels: map[string]string{},
+				},
+			},
+			expected: false,
+		},
+		{
+			filter: `label[app]:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels: nil,
+				},
+			},
+			expected: false,
+		},
+		{
+			filter: `annotation[app]:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{"app": "foo"},
+				},
+			},
+			expected: true,
+		},
+		{
+			filter: `annotation[app]:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{"app": "bar"},
+				},
+			},
+			expected: false,
+		},
+		{
+			filter: `annotation[app]:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{},
+				},
+			},
+			expected: false,
+		},
+		{
+			filter: `annotation[app]:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: nil,
+				},
+			},
+			expected: false,
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.filter, func(t *testing.T) {
+			parser := k8sobject.NewK8sObjectFilterParser()
+			parsed, err := parser.Parse(c.filter)
+			if err != nil {
+				t.Fatalf("parsing '%s': %s", c.filter, err)
+			}
+			t.Logf("Parsed: %s", ast.ToPreOrderString(parsed))
+
+			compiler := NewK8sObjectMatchCompiler()
+			matcher, err := compiler.Compile(parsed)
+			if err != nil {
+				t.Fatalf("compiling: %s", err)
+			}
+			t.Logf("Compiled: %s", matcher.String())
+
+			result := matcher.Matches(c.o)
+
+			if result != c.expected {
+				t.Errorf("Expected %t, got %t", c.expected, result)
+			}
+		})
+	}
+}

+ 46 - 0
core/pkg/plugin/grpc.go

@@ -0,0 +1,46 @@
+package plugin
+
+import (
+	"context"
+
+	"github.com/opencost/opencost/core/pkg/model/pb"
+)
+
+// GRPCClient is an implementation of CustomCostsSource that talks over RPC.
+type GRPCClient struct{ client pb.CustomCostsSourceClient }
+
+func (m *GRPCClient) GetCustomCosts(req *pb.CustomCostRequest) []*pb.CustomCostResponse {
+	resp, err := m.client.GetCustomCosts(context.Background(), req)
+	if err != nil {
+		return []*pb.CustomCostResponse{
+			{
+				Errors: []string{err.Error()},
+			},
+		}
+	}
+	derefs := []*pb.CustomCostResponse{}
+	for _, resp := range resp.Resps {
+		derefs = append(derefs, resp)
+	}
+	return derefs
+}
+
+// Here is the gRPC server that GRPCClient talks to.
+type GRPCServer struct {
+	pb.UnimplementedCustomCostsSourceServer
+	// This is the real implementation
+	Impl CustomCostSource
+}
+
+func (m *GRPCServer) GetCustomCosts(
+	ctx context.Context,
+	req *pb.CustomCostRequest) (*pb.CustomCostResponseSet, error) {
+	ptrs := []*pb.CustomCostResponse{}
+	costs := m.Impl.GetCustomCosts(req)
+	for _, cost := range costs {
+		ptrs = append(ptrs, cost)
+	}
+	return &pb.CustomCostResponseSet{
+		Resps: ptrs,
+	}, nil
+}

+ 10 - 44
core/pkg/plugin/plugin_interface.go

@@ -1,55 +1,20 @@
 package plugin
 
 import (
-	"encoding/gob"
-	"fmt"
-	"net/rpc"
+	"context"
 
 	"github.com/hashicorp/go-plugin"
-	"github.com/opencost/opencost/core/pkg/log"
-	"github.com/opencost/opencost/core/pkg/model"
+	"github.com/opencost/opencost/core/pkg/model/pb"
+	grpc "google.golang.org/grpc"
 )
 
 // plugin interface
 type CustomCostSource interface {
-	GetCustomCosts(req model.CustomCostRequestInterface) []model.CustomCostResponse
-}
-
-// RPC impl
-type CustomCostRPC struct{ client *rpc.Client }
-
-func init() {
-	gob.Register(model.CustomCostRequest{})
-}
-func (c *CustomCostRPC) GetCustomCosts(req model.CustomCostRequestInterface) []model.CustomCostResponse {
-
-	var resp []model.CustomCostResponse
-	err := c.client.Call("Plugin.GetCustomCosts", &req, &resp)
-	if err != nil {
-		log.Errorf("error calling plugin: %v", err)
-		resp = []model.CustomCostResponse{
-			{
-				Errors: []error{
-					fmt.Errorf("error calling plugin: %v", err),
-				},
-			},
-		}
-	}
-
-	return resp
-}
-
-type CustomCostRPCServer struct {
-	// This is the real implementation
-	Impl CustomCostSource
-}
-
-func (s *CustomCostRPCServer) GetCustomCosts(args interface{}, resp *[]model.CustomCostResponse) error {
-	*resp = s.Impl.GetCustomCosts(args.(model.CustomCostRequestInterface))
-	return nil
+	GetCustomCosts(req *pb.CustomCostRequest) []*pb.CustomCostResponse
 }
 
 type CustomCostPlugin struct {
+	plugin.Plugin
 	// Impl Injection
 	Impl CustomCostSource
 }
@@ -57,13 +22,14 @@ type CustomCostPlugin struct {
 // this method is called for as part of the reference plugin implementation
 // see https://github.com/hashicorp/go-plugin/blob/main/examples/basic/shared/greeter_interface.go#L59
 // for context and details
-func (p *CustomCostPlugin) Server(*plugin.MuxBroker) (interface{}, error) {
-	return &CustomCostRPCServer{Impl: p.Impl}, nil
+func (p *CustomCostPlugin) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error {
+	pb.RegisterCustomCostsSourceServer(s, &GRPCServer{Impl: p.Impl})
+	return nil
 }
 
 // this method is called for as part of the reference plugin implementation
 // see https://github.com/hashicorp/go-plugin/blob/main/examples/basic/shared/greeter_interface.go#L63
 // for context and details
-func (CustomCostPlugin) Client(b *plugin.MuxBroker, c *rpc.Client) (interface{}, error) {
-	return &CustomCostRPC{client: c}, nil
+func (CustomCostPlugin) GRPCClient(context context.Context, b *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) {
+	return &GRPCClient{client: pb.NewCustomCostsSourceClient(c)}, nil
 }

+ 6 - 0
generate.sh

@@ -0,0 +1,6 @@
+#!/usr/bin/env sh
+#
+
+protoc --go_out=./core --go_opt=module=github.com/opencost/opencost/core \
+    --go-grpc_out=./core --go-grpc_opt=module=github.com/opencost/opencost/core \
+    protos/**/*.proto

+ 8 - 0
go.mod

@@ -32,6 +32,8 @@ require (
 	github.com/davecgh/go-spew v1.1.1
 	github.com/getsentry/sentry-go v0.25.0
 	github.com/google/uuid v1.6.0
+	github.com/hashicorp/go-hclog v1.6.2
+	github.com/hashicorp/go-plugin v1.6.0
 	github.com/jszwec/csvutil v1.2.1
 	github.com/julienschmidt/httprouter v1.3.0
 	github.com/kubecost/events v0.0.6
@@ -98,6 +100,7 @@ require (
 	github.com/dimchansky/utfbom v1.1.1 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/emicklei/go-restful/v3 v3.10.2 // indirect
+	github.com/fatih/color v1.16.0 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/fsnotify/fsnotify v1.6.0 // indirect
 	github.com/go-logr/logr v1.4.1 // indirect
@@ -123,6 +126,7 @@ require (
 	github.com/hashicorp/errwrap v1.0.0 // indirect
 	github.com/hashicorp/go-multierror v1.1.1 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
+	github.com/hashicorp/yamux v0.1.1 // indirect
 	github.com/imdario/mergo v0.3.12 // indirect
 	github.com/inconshreveable/mousetrap v1.0.0 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
@@ -133,15 +137,19 @@ require (
 	github.com/kylelemons/godebug v1.1.0 // indirect
 	github.com/magiconair/properties v1.8.5 // indirect
 	github.com/mailru/easyjson v0.7.7 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-ieproxy v0.0.1 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
 	github.com/minio/md5-simd v1.1.2 // indirect
 	github.com/minio/sha256-simd v1.0.0 // indirect
 	github.com/mitchellh/go-homedir v1.1.0 // indirect
+	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
 	github.com/mitchellh/mapstructure v1.5.0 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+	github.com/oklog/run v1.1.0 // indirect
 	github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
 	github.com/pelletier/go-toml v1.9.3 // indirect
 	github.com/pierrec/lz4/v4 v4.1.18 // indirect

+ 33 - 0
go.sum

@@ -161,6 +161,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
 github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
 github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
+github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
+github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@@ -199,6 +201,9 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
 github.com/envoyproxy/protoc-gen-validate v1.0.4 h1:gVPz/FMfvh57HdSJQyvBtF00j8JU4zdyUgIUNhlgg0A=
 github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
+github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
+github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
+github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
@@ -336,11 +341,15 @@ github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyN
 github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
+github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I=
+github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
 github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
 github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
 github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
 github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
 github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A=
+github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI=
 github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
 github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
 github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
@@ -355,12 +364,16 @@ github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO
 github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
 github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
 github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
+github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
+github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
 github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
 github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
+github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
+github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
 github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
 github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
 github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
@@ -413,9 +426,18 @@ github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ
 github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
 github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
+github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
 github.com/mattn/go-ieproxy v0.0.1 h1:qiyop7gCflfhwCzGyeT0gro3sF9AIg9HU98JORTkqfI=
 github.com/mattn/go-ieproxy v0.0.1/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E=
 github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
 github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
 github.com/microcosm-cc/bluemonday v1.0.23 h1:SMZe2IGa0NuHvnVNAZ+6B38gsTbi5e4sViiWJyDDqFY=
@@ -432,6 +454,8 @@ github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk
 github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
+github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
+github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
 github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
 github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
 github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
@@ -451,6 +475,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8m
 github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU=
 github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
+github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
+github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
 github.com/onsi/ginkgo/v2 v2.1.6 h1:Fx2POJZfKRQcM1pH49qSZiYeu319wji004qX+GDovrU=
 github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk=
 github.com/onsi/gomega v1.20.1 h1:PA/3qinGoukvymdIDV8pii6tiZgC8kbmJO6Z5+b002Q=
@@ -531,6 +557,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 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.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
@@ -728,6 +755,7 @@ golang.org/x/sys v0.0.0-20191112214154-59a1497f0cea/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -756,13 +784,18 @@ golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

+ 17 - 13
justfile

@@ -16,8 +16,8 @@ build-local:
     cd ./cmd/costmodel && \
         {{commonenv}} go build \
         -ldflags \
-          "-X github.com/opencost/opencost/pkg/version.Version={{version}} \
-           -X github.com/opencost/opencost/pkg/version.GitCommit={{commit}}" \
+          "-X github.com/opencost/opencost/core/pkg/version.Version={{version}} \
+           -X github.com/opencost/opencost/core/pkg/version.GitCommit={{commit}}" \
         -o ./costmodel
 
 # Build multiarch binaries
@@ -25,28 +25,28 @@ build-binary VERSION=version:
     cd ./cmd/costmodel && \
         {{commonenv}} GOOS=linux GOARCH=amd64 go build \
         -ldflags \
-          "-X github.com/opencost/opencost/pkg/version.Version={{VERSION}} \
-           -X github.com/opencost/opencost/pkg/version.GitCommit={{commit}}" \
+          "-X github.com/opencost/opencost/core/pkg/version.Version={{VERSION}} \
+           -X github.com/opencost/opencost/core/pkg/version.GitCommit={{commit}}" \
         -o ./costmodel-amd64
 
     cd ./cmd/costmodel && \
         {{commonenv}} GOOS=linux GOARCH=arm64 go build \
         -ldflags \
-          "-X github.com/opencost/opencost/pkg/version.Version={{VERSION}} \
-           -X github.com/opencost/opencost/pkg/version.GitCommit={{commit}}" \
+          "-X github.com/opencost/opencost/core/pkg/version.Version={{VERSION}} \
+           -X github.com/opencost/opencost/core/pkg/version.GitCommit={{commit}}" \
         -o ./costmodel-arm64
 
 # Build and push a multi-arch Docker image
-build IMAGETAG VERSION=version: test (build-binary VERSION)
+build IMAGE_TAG RELEASE_VERSION: test (build-binary RELEASE_VERSION)
     docker buildx build \
         --rm \
         --platform "linux/amd64" \
         -f 'Dockerfile.cross' \
         --build-arg binarypath=./cmd/costmodel/costmodel-amd64 \
-        --build-arg version={{version}} \
+        --build-arg version={{RELEASE_VERSION}} \
         --build-arg commit={{commit}} \
         --provenance=false \
-        -t {{IMAGETAG}}-amd64 \
+        -t {{IMAGE_TAG}}-amd64 \
         --push \
         .
 
@@ -55,14 +55,18 @@ build IMAGETAG VERSION=version: test (build-binary VERSION)
         --platform "linux/arm64" \
         -f 'Dockerfile.cross' \
         --build-arg binarypath=./cmd/costmodel/costmodel-arm64 \
-        --build-arg version={{version}} \
+        --build-arg version={{RELEASE_VERSION}} \
         --build-arg commit={{commit}} \
         --provenance=false \
-        -t {{IMAGETAG}}-arm64 \
+        -t {{IMAGE_TAG}}-arm64 \
         --push \
         .
 
     manifest-tool push from-args \
         --platforms "linux/amd64,linux/arm64" \
-        --template {{IMAGETAG}}-ARCH \
-        --target {{IMAGETAG}}
+        --template {{IMAGE_TAG}}-ARCH \
+        --target {{IMAGE_TAG}}
+
+validate-protobuf:
+    ./generate.sh
+    git diff --exit-code

+ 2 - 2
kubernetes/opencost.yaml

@@ -142,7 +142,7 @@ spec:
       restartPolicy: Always
       serviceAccountName: opencost
       containers:
-        - image: quay.io/kubecost1/kubecost-cost-model:latest
+        - image: ghcr.io/opencost/opencost:latest
           name: opencost
           resources:
             requests:
@@ -167,7 +167,7 @@ spec:
             privileged: false
             readOnlyRootFilesystem: true
             runAsUser: 1001
-        - image: quay.io/kubecost1/opencost-ui:latest
+        - image: ghcr.io/opencost/opencost-ui:latest
           name: opencost-ui
           resources:
             requests:

+ 2 - 2
pkg/cloud/provider/csvprovider.go

@@ -320,11 +320,11 @@ func NodeValueFromMapField(m string, n *v1.Node, useRegion bool) string {
 			akey := strings.Join(mf[2:len(mf)], ".")
 			return toReturn + n.Annotations[akey]
 		} else {
-			log.Errorf("Unsupported InstanceIDField %s in CSV For Node", m)
+			log.DedupedInfof(10, "Unsupported InstanceIDField %s in CSV For Node", m)
 			return ""
 		}
 	} else {
-		log.Errorf("Unsupported InstanceIDField %s in CSV For Node", m)
+		log.DedupedInfof(10, "Unsupported InstanceIDField %s in CSV For Node", m)
 		return ""
 	}
 }

+ 3 - 0
pkg/cloud/scaleway/provider.go

@@ -68,7 +68,10 @@ func (c *Scaleway) DownloadPricingData() error {
 		"fr-par-3": 0.00032,
 		"nl-ams-1": 0.00008,
 		"nl-ams-2": 0.00008,
+		"nl-ams-3": 0.00008,
 		"pl-waw-1": 0.00011,
+		"pl-waw-2": 0.00011,
+		"pl-waw-3": 0.00011,
 	}
 
 	c.Pricing = make(map[string]*ScalewayPricing)

+ 48 - 21
pkg/costmodel/router.go

@@ -28,11 +28,11 @@ import (
 	"github.com/opencost/opencost/pkg/cloudcost"
 	"github.com/opencost/opencost/pkg/config"
 	clustermap "github.com/opencost/opencost/pkg/costmodel/clusters"
+	"github.com/opencost/opencost/pkg/customcost"
 	"github.com/opencost/opencost/pkg/kubeconfig"
 	"github.com/opencost/opencost/pkg/metrics"
 	"github.com/opencost/opencost/pkg/services"
 	"github.com/spf13/viper"
-
 	v1 "k8s.io/api/core/v1"
 
 	"github.com/julienschmidt/httprouter"
@@ -86,26 +86,28 @@ 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
-	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
+	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
+	CustomCostQueryService    *customcost.QueryService
+	CustomCostPipelineService *customcost.PipelineService
+	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
@@ -1770,6 +1772,22 @@ func Initialize(additionalConfigWatchers ...*watcher.ConfigMapWatcher) *Accesses
 		a.MetricsEmitter.Start()
 	}
 
+	log.Infof("Custom Costs enabled: %t", env.IsCustomCostEnabled())
+	if env.IsCustomCostEnabled() {
+		hourlyRepo := customcost.NewMemoryRepository()
+		dailyRepo := customcost.NewMemoryRepository()
+		ingConfig := customcost.DefaultIngestorConfiguration()
+		var err error
+		a.CustomCostPipelineService, err = customcost.NewPipelineService(hourlyRepo, dailyRepo, ingConfig)
+		if err != nil {
+			log.Errorf("error instantiating custom cost pipeline service: %v", err)
+			return nil
+		}
+
+		customCostQuerier := customcost.NewRepositoryQuerier(hourlyRepo, dailyRepo, ingConfig.HourlyDuration, ingConfig.DailyDuration)
+		a.CustomCostQueryService = customcost.NewQueryService(customCostQuerier)
+	}
+
 	a.Router.GET("/costDataModel", a.CostDataModel)
 	a.Router.GET("/costDataModelRange", a.CostDataModelRange)
 	a.Router.GET("/aggregatedCostModel", a.AggregateCostModelHandler)
@@ -1828,6 +1846,15 @@ func Initialize(additionalConfigWatchers ...*watcher.ConfigMapWatcher) *Accesses
 	a.Router.GET("/cloud/config/disable", a.CloudConfigController.GetDisableConfigHandler())
 	a.Router.GET("/cloud/config/delete", a.CloudConfigController.GetDeleteConfigHandler())
 
+	if env.IsCustomCostEnabled() {
+		a.Router.GET("/customCost/total", a.CustomCostQueryService.GetCustomCostTotalHandler())
+		a.Router.GET("/customCost/timeseries", a.CustomCostQueryService.GetCustomCostTimeseriesHandler())
+	}
+
+	// this endpoint is intentionally left out of the "if env.IsCustomCostEnabled()" conditional; in the handler, it is
+	// valid for CustomCostPipelineService to be nil
+	a.Router.GET("/customCost/status", a.CustomCostPipelineService.GetCustomCostStatusHandler())
+
 	a.httpServices.RegisterAll(a.Router)
 
 	return a

+ 396 - 0
pkg/customcost/ingestor.go

@@ -0,0 +1,396 @@
+package customcost
+
+import (
+	"fmt"
+	"strings"
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/hashicorp/go-plugin"
+	"google.golang.org/protobuf/types/known/durationpb"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/pb"
+	"github.com/opencost/opencost/core/pkg/opencost"
+	ocplugin "github.com/opencost/opencost/core/pkg/plugin"
+	"github.com/opencost/opencost/core/pkg/util/stringutil"
+	"github.com/opencost/opencost/core/pkg/util/timeutil"
+	"github.com/opencost/opencost/pkg/env"
+	"github.com/opencost/opencost/pkg/errors"
+)
+
+// IngestorStatus includes diagnostic values for a given Ingestor
+type IngestorStatus struct {
+	Created     time.Time
+	LastRun     time.Time
+	NextRun     time.Time
+	Runs        int
+	Coverage    map[string]opencost.Window
+	RefreshRate time.Duration
+}
+
+// CustomCost IngestorConfig is a configuration struct for an Ingestor
+type CustomCostIngestorConfig struct {
+	MonthToDateRunInterval               int
+	HourlyDuration, DailyDuration        time.Duration
+	DailyQueryWindow, HourlyQueryWindow  time.Duration
+	PluginConfigDir, PluginExecutableDir string
+}
+
+// DefaultIngestorConfiguration retrieves an CustomCostIngestorConfig from env variables
+func DefaultIngestorConfiguration() CustomCostIngestorConfig {
+	return CustomCostIngestorConfig{
+		DailyDuration:       timeutil.Day * time.Duration(env.GetDataRetentionDailyResolutionDays()),
+		HourlyDuration:      time.Hour * time.Duration(env.GetDataRetentionHourlyResolutionHours()),
+		DailyQueryWindow:    timeutil.Day * time.Duration(env.GetCustomCostQueryWindowDays()),
+		HourlyQueryWindow:   time.Hour * time.Duration(env.GetCustomCostQueryWindowHours()),
+		PluginConfigDir:     env.GetPluginConfigDir(),
+		PluginExecutableDir: env.GetPluginExecutableDir(),
+	}
+}
+
+type CustomCostIngestor struct {
+	key          string
+	config       *CustomCostIngestorConfig
+	repo         Repository
+	runID        string
+	lastRun      time.Time
+	runs         int
+	creationTime time.Time
+	coverage     map[string]opencost.Window
+	coverageLock sync.Mutex
+	isRunning    atomic.Bool
+	isStopping   atomic.Bool
+	exitBuildCh  chan string
+	exitRunCh    chan string
+	plugins      map[string]*plugin.Client
+	resolution   time.Duration
+	refreshRate  time.Duration
+}
+
+// NewIngestor is an initializer for ingestor
+func NewCustomCostIngestor(ingestorConfig *CustomCostIngestorConfig, repo Repository, plugins map[string]*plugin.Client, res time.Duration) (*CustomCostIngestor, error) {
+	if repo == nil {
+		return nil, fmt.Errorf("CustomCost: NewCustomCostIngestor: repository connot be nil")
+	}
+	if ingestorConfig == nil {
+		return nil, fmt.Errorf("CustomCost: NewCustomCostIngestor: config connot be nil")
+	}
+	key := ""
+
+	for name := range plugins {
+		key += "," + name
+	}
+
+	key = strings.TrimPrefix(key, ",")
+
+	now := time.Now().UTC()
+
+	return &CustomCostIngestor{
+		key:          key,
+		config:       ingestorConfig,
+		repo:         repo,
+		creationTime: now,
+		lastRun:      now,
+		coverage:     map[string]opencost.Window{},
+		plugins:      plugins,
+		resolution:   res,
+		refreshRate:  res,
+	}, nil
+}
+
+func (ing *CustomCostIngestor) LoadWindow(start, end time.Time) {
+	var targets []opencost.Window
+	if ing.resolution == timeutil.Day {
+		oldestDailyDate := time.Now().UTC().Add(-1 * ing.config.DailyDuration).Truncate(timeutil.Day)
+		if !oldestDailyDate.After(start) {
+			windows, err := opencost.GetWindows(start, end, timeutil.Day)
+			if err != nil {
+				log.Errorf("CustomCost[%s]: ingestor: invalid window %s", ing.key, opencost.NewWindow(&start, &end))
+				return
+			}
+			targets = windows
+		}
+	} else {
+
+		oldestHourlyDate := time.Now().UTC().Add(-1 * ing.config.HourlyDuration).Truncate(time.Hour)
+		if !oldestHourlyDate.After(start) {
+			windows, err := opencost.GetWindows(start, end, time.Hour)
+			if err != nil {
+				log.Errorf("CustomCost[%s]: ingestor: invalid window %s", ing.key, opencost.NewWindow(&start, &end))
+				return
+			}
+			targets = windows
+		}
+	}
+
+	for _, window := range targets {
+		allPluginsHave := true
+		for domain := range ing.plugins {
+			has, err2 := ing.repo.Has(*window.Start(), domain)
+			if err2 != nil {
+				log.Errorf("CustomCost[%s]: ingestor: error when loading window for plugin %s: %s", ing.key, domain, err2.Error())
+			}
+			if !has {
+				allPluginsHave = false
+				break
+			}
+		}
+		if !allPluginsHave {
+			ing.BuildWindow(*window.Start(), *window.End())
+		} else {
+			for domain := range ing.plugins {
+				ing.expandCoverage(window, domain)
+			}
+			log.Debugf("CustomCost[%s]: ingestor: skipping build for window %s, coverage already exists", ing.key, window.String())
+		}
+	}
+
+}
+
+func (ing *CustomCostIngestor) BuildWindow(start, end time.Time) {
+
+	for domain := range ing.plugins {
+		ing.buildSingleDomain(start, end, domain)
+	}
+}
+
+func (ing *CustomCostIngestor) buildSingleDomain(start, end time.Time, domain string) {
+	req := &pb.CustomCostRequest{
+		Start:      timestamppb.New(start),
+		End:        timestamppb.New(end),
+		Resolution: durationpb.New(ing.resolution),
+	}
+	log.Infof("ingestor: building window %s for plugin %s", opencost.NewWindow(&start, &end), domain)
+	// make RPC call via plugin
+	pluginClient, found := ing.plugins[domain]
+	if !found {
+		log.Errorf("could not find plugin client for plugin %s. Did you initialize the plugin correctly?", domain)
+		return
+	}
+
+	// connect the client
+	rpcClient, err := pluginClient.Client()
+	if err != nil {
+		log.Errorf("error connecting client for plugin %s: %v", domain, err)
+		return
+	}
+
+	// Request the plugin
+	raw, err := rpcClient.Dispense("CustomCostSource")
+	if err != nil {
+		log.Errorf("error creating new plugin client for plugin %s: %v", domain, err)
+		return
+	}
+
+	custCostSrc := raw.(ocplugin.CustomCostSource)
+
+	custCostResps := custCostSrc.GetCustomCosts(req)
+	// loop through each customCostResponse, adding to repo
+	for _, ccr := range custCostResps {
+
+		// check for errors in response
+		if len(ccr.Errors) > 0 {
+			for _, errResp := range ccr.Errors {
+				log.Errorf("error in getting custom costs for plugin %s: %v", domain, errResp)
+			}
+			log.Errorf("not adding any costs for window %v-%v on plugin %s", req.Start, req.End, domain)
+			continue
+		}
+		log.Debugf("BuildWindow[%s]: GetCustomCost: writing custom costs for window %v-%v: %d", domain, ccr.Start, ccr.End, len(ccr.Costs))
+
+		err2 := ing.repo.Put(ccr)
+		if err2 != nil {
+			log.Errorf("CustomCost[%s]: ingestor: failed to save Custom Cost Set with window %v-%v: %s", domain, ccr.Start, ccr.End, err2.Error())
+		}
+
+		ing.expandCoverage(opencost.NewClosedWindow(ccr.Start.AsTime(), ccr.End.AsTime()), domain)
+	}
+}
+
+func (ing *CustomCostIngestor) Start(rebuild bool) {
+
+	// If already running, log that and return.
+	if !ing.isRunning.CompareAndSwap(false, true) {
+		log.Infof("CustomCost: 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 *CustomCostIngestor) Stop() {
+	// If already stopping, log that and return.
+	if !ing.isStopping.CompareAndSwap(false, true) {
+		log.Infof("CustomCost: 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 *CustomCostIngestor) Status() IngestorStatus {
+	return IngestorStatus{
+		Created:     ing.creationTime,
+		LastRun:     ing.lastRun,
+		NextRun:     ing.lastRun.Add(ing.refreshRate).UTC(),
+		Runs:        ing.runs,
+		Coverage:    ing.coverage,
+		RefreshRate: ing.refreshRate,
+	}
+}
+
+func (ing *CustomCostIngestor) build(rebuild bool) {
+	defer errors.HandlePanic()
+	e := opencost.RoundBack(time.Now().UTC(), ing.resolution)
+	s := e.Add(-ing.config.DailyDuration)
+	if ing.resolution == time.Hour {
+		s = e.Add(-ing.config.HourlyDuration)
+	}
+	// Profile the full Duration of the build time
+	buildStart := time.Now()
+
+	log.Infof("CustomCost[%s]: ingestor: build[%s]: Starting build back to %s in blocks of %s", ing.key, ing.runID, s, ing.resolution)
+
+	// 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(fmt.Sprintf("CustomCost[%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 *CustomCostIngestor) Rebuild(domain string) error {
+	targetDur := ing.config.DailyDuration
+	if ing.resolution == time.Hour {
+		targetDur = ing.config.HourlyDuration
+	}
+	// Build as far back as the configures build Duration
+	limit := opencost.RoundBack(time.Now().UTC().Add(-1*targetDur), ing.resolution)
+	e := time.Now().UTC()
+
+	ing.buildSingleDomain(limit, e, domain)
+	return nil
+}
+
+func (ing *CustomCostIngestor) 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("CustomCost[%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.resolution)
+
+		queryWin := ing.config.DailyQueryWindow
+		if ing.resolution == time.Hour {
+			queryWin = ing.config.HourlyQueryWindow
+		}
+
+		// Round start time back to the nearest Resolution point in the past from the
+		// last update to the QueryWindow
+		s := opencost.RoundBack(start.UTC(), ing.resolution)
+		e := s.Add(queryWin)
+
+		// 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("CustomCost[%s]: ingestor: Run[%s]: completed %s in %v", ing.key, ing.runID, opencost.NewWindow(&s, &e), time.Since(profStart))
+
+			s = s.Add(queryWin)
+			e = e.Add(queryWin)
+			// prevent builds into the future
+			if e.After(time.Now().UTC()) {
+				e = opencost.RoundForward(time.Now().UTC(), ing.resolution)
+			}
+
+		}
+		ing.lastRun = time.Now().UTC()
+
+		ing.runs++
+
+		ticker.TickIn(ing.refreshRate)
+	}
+}
+
+func (ing *CustomCostIngestor) expandCoverage(window opencost.Window, plugin string) {
+	if window.IsOpen() {
+		return
+	}
+	ing.coverageLock.Lock()
+	defer ing.coverageLock.Unlock()
+
+	if _, hasCoverage := ing.coverage[plugin]; !hasCoverage {
+		ing.coverage[plugin] = window.Clone()
+	} else {
+		// expand existing coverage
+		ing.coverage[plugin] = ing.coverage[plugin].ExpandStart(*window.Start())
+		ing.coverage[plugin] = ing.coverage[plugin].ExpandEnd(*window.End())
+	}
+
+}

+ 66 - 0
pkg/customcost/matcher.go

@@ -0,0 +1,66 @@
+package customcost
+
+import (
+	"fmt"
+
+	"github.com/opencost/opencost/core/pkg/filter/ast"
+	"github.com/opencost/opencost/core/pkg/filter/matcher"
+	"github.com/opencost/opencost/core/pkg/filter/transform"
+)
+
+func NewCustomCostMatchCompiler() *matcher.MatchCompiler[*CustomCost] {
+	passes := []transform.CompilerPass{
+		transform.UnallocatedReplacementPass(),
+	}
+
+	return matcher.NewMatchCompiler(
+		customCostFieldMap,
+		customCostSliceFieldMap,
+		customCostMapFieldMap,
+		passes...,
+	)
+}
+
+// Maps fields from a custom cost to a string value based on an identifier
+func customCostFieldMap(cc *CustomCost, identifier ast.Identifier) (string, error) {
+	if cc == nil {
+		return "", fmt.Errorf("cannot map to nil custom cost")
+	}
+	if identifier.Field == nil {
+		return "", fmt.Errorf("cannot map field from identifier with nil field")
+	}
+	switch CustomCostProperty(identifier.Field.Name) {
+	case CustomCostZoneProp:
+		return cc.Zone, nil
+	case CustomCostAccountNameProp:
+		return cc.AccountName, nil
+	case CustomCostChargeCategoryProp:
+		return cc.ChargeCategory, nil
+	case CustomCostDescriptionProp:
+		return cc.Description, nil
+	case CustomCostResourceNameProp:
+		return cc.ResourceName, nil
+	case CustomCostResourceTypeProp:
+		return cc.ResourceType, nil
+	case CustomCostProviderIdProp:
+		return cc.ProviderId, nil
+	case CustomCostUsageUnitProp:
+		return cc.UsageUnit, nil
+	case CustomCostDomainProp:
+		return cc.Domain, nil
+	case CustomCostCostSourceProp:
+		return cc.CostSource, nil
+	}
+
+	return "", fmt.Errorf("failed to find string identifier on CustomCost: %s", identifier.Field.Name)
+}
+
+// Maps slice fields from an asset to a []string value based on an identifier
+func customCostSliceFieldMap(cc *CustomCost, identifier ast.Identifier) ([]string, error) {
+	return nil, fmt.Errorf("custom costs have no slice fields")
+}
+
+// Maps map fields from a custom cost to a map[string]string value based on an identifier
+func customCostMapFieldMap(cc *CustomCost, identifier ast.Identifier) (map[string]string, error) {
+	return nil, fmt.Errorf("custom costs have no map fields")
+}

+ 114 - 0
pkg/customcost/memoryrepository.go

@@ -0,0 +1,114 @@
+package customcost
+
+import (
+	"fmt"
+	"sync"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/model/pb"
+	"golang.org/x/exp/maps"
+	"google.golang.org/protobuf/proto"
+)
+
+// 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][]byte
+}
+
+func NewMemoryRepository() *MemoryRepository {
+	return &MemoryRepository{
+		data: make(map[string]map[time.Time][]byte),
+	}
+}
+
+func (m *MemoryRepository) Has(startTime time.Time, domain string) (bool, error) {
+	m.rwLock.RLock()
+	defer m.rwLock.RUnlock()
+
+	domainData, ok := m.data[domain]
+	if !ok {
+		return false, nil
+	}
+
+	_, ook := domainData[startTime.UTC()]
+	return ook, nil
+}
+
+func (m *MemoryRepository) Get(startTime time.Time, domain string) (*pb.CustomCostResponse, error) {
+	m.rwLock.RLock()
+	defer m.rwLock.RUnlock()
+
+	domainData, ok := m.data[domain]
+	if !ok {
+		return &pb.CustomCostResponse{}, nil
+	}
+
+	b, ook := domainData[startTime.UTC()]
+	if !ook {
+		return &pb.CustomCostResponse{}, nil
+	}
+
+	ccr := &pb.CustomCostResponse{}
+	err := proto.Unmarshal(b, ccr)
+	if err != nil {
+		return nil, fmt.Errorf("error unmarshalling data: %w", err)
+	}
+	return ccr, 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(ccr *pb.CustomCostResponse) error {
+	m.rwLock.Lock()
+	defer m.rwLock.Unlock()
+
+	if ccr == nil {
+		return fmt.Errorf("MemoryRepository: Put: cannot save nil")
+	}
+
+	if ccr.Start == nil || ccr.End == nil {
+		return fmt.Errorf("MemoryRepository: Put: custom cost response has invalid window")
+	}
+
+	if ccr.GetDomain() == "" {
+		return fmt.Errorf("MemoryRepository: Put: custom cost response does not have a domain value")
+	}
+
+	if _, ok := m.data[ccr.GetDomain()]; !ok {
+		m.data[ccr.GetDomain()] = make(map[time.Time][]byte)
+	}
+	b, err := proto.Marshal(ccr)
+	if err != nil {
+		return fmt.Errorf("MemoryRepository: Put: custom cost could not be marshalled")
+	}
+	m.data[ccr.GetDomain()][ccr.Start.AsTime().UTC()] = b
+
+	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
+}

+ 24 - 0
pkg/customcost/parser.go

@@ -0,0 +1,24 @@
+package customcost
+
+import "github.com/opencost/opencost/core/pkg/filter/ast"
+
+// a slice of all the custom costs field instances the lexer should recognize as
+// valid left-hand comparators
+var customCostFilterFields = []*ast.Field{
+	ast.NewField(CustomCostZoneProp),
+	ast.NewField(CustomCostAccountNameProp),
+	ast.NewField(CustomCostChargeCategoryProp),
+	ast.NewField(CustomCostDescriptionProp),
+	ast.NewField(CustomCostResourceNameProp),
+	ast.NewField(CustomCostResourceTypeProp),
+	ast.NewField(CustomCostProviderIdProp),
+	ast.NewField(CustomCostUsageUnitProp),
+	ast.NewField(CustomCostDomainProp),
+	ast.NewField(CustomCostCostSourceProp),
+}
+
+// NewCustomCostFilterParser creates a new `ast.FilterParser` implementation
+// which uses CustomCost specific fields
+func NewCustomCostFilterParser() ast.FilterParser {
+	return ast.NewFilterParser(customCostFilterFields)
+}

+ 222 - 0
pkg/customcost/pipelineservice.go

@@ -0,0 +1,222 @@
+package customcost
+
+import (
+	"fmt"
+	"net/http"
+	"os"
+	"os/exec"
+	"runtime"
+	"strings"
+	"time"
+
+	"github.com/hashicorp/go-hclog"
+	"github.com/hashicorp/go-plugin"
+	"github.com/julienschmidt/httprouter"
+	"github.com/opencost/opencost/core/pkg/log"
+	ocplugin "github.com/opencost/opencost/core/pkg/plugin"
+	proto "github.com/opencost/opencost/core/pkg/protocol"
+	"github.com/opencost/opencost/core/pkg/util/timeutil"
+)
+
+var protocol = proto.HTTP()
+
+const execFmt = `%s/%s.ocplugin.%s.%s`
+
+// PipelineService exposes CustomCost pipeline controls and diagnostics endpoints
+type PipelineService struct {
+	hourlyIngestor, dailyIngestor *CustomCostIngestor
+	hourlyStore, dailyStore       Repository
+}
+
+func getRegisteredPlugins(configDir string, execDir string) (map[string]*plugin.Client, error) {
+
+	pluginNames := map[string]string{}
+	// scan plugin config directory for all file names
+	configFiles, err := os.ReadDir(configDir)
+	if err != nil {
+		log.Errorf("error reading files in directory %s: %v", configDir, err)
+	}
+
+	// list of plugins that we must run are the strings before _
+	for _, file := range configFiles {
+		log.Tracef("parsing config file name: %s", file.Name())
+		fileParts := strings.Split(file.Name(), "_")
+
+		if len(fileParts) != 2 || fileParts[1] == "_config.json" {
+			return nil, fmt.Errorf("plugin config file name %s invalid. Config files must have the form <plugin name>_config.json", file.Name())
+		}
+
+		pluginNames[fileParts[0]] = configDir + "/" + file.Name()
+	}
+
+	if len(pluginNames) == 0 {
+		log.Infof("no plugins detected.")
+		return nil, nil
+	}
+
+	log.Infof("requiring plugins matching your architecture: " + runtime.GOARCH)
+	configs := map[string]*plugin.ClientConfig{}
+	// set up the client config
+	for name, config := range pluginNames {
+		file := fmt.Sprintf(execFmt, execDir, name, runtime.GOOS, runtime.GOARCH)
+		log.Debugf("looking for file: %s", file)
+		if _, err := os.Stat(file); err != nil {
+			msg := fmt.Sprintf("error reading executable for %s plugin. Plugin executables must be in %s and have name format <plugin name>.ocplugin.<os>.<opencost binary archtecture (arm64 or amd64)>", name, execDir)
+			log.Errorf(msg)
+			return nil, fmt.Errorf(msg)
+		}
+
+		var handshakeConfig = plugin.HandshakeConfig{
+			ProtocolVersion:  1,
+			MagicCookieKey:   "PLUGIN_NAME",
+			MagicCookieValue: name,
+		}
+
+		logger := hclog.New(&hclog.LoggerOptions{
+			Name:   "plugin[" + name + "]",
+			Output: os.Stdout,
+			Level:  hclog.Debug,
+		})
+
+		// pluginMap is the map of plugins we can dispense.
+		var pluginMap = map[string]plugin.Plugin{
+			"CustomCostSource": &ocplugin.CustomCostPlugin{},
+		}
+		configs[name] = &plugin.ClientConfig{
+			HandshakeConfig:  handshakeConfig,
+			Plugins:          pluginMap,
+			Cmd:              exec.Command(fmt.Sprintf(execFmt, execDir, name, runtime.GOOS, runtime.GOARCH), config),
+			Logger:           logger,
+			AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC},
+		}
+	}
+
+	plugins := map[string]*plugin.Client{}
+
+	for name, config := range configs {
+		client := plugin.NewClient(config)
+
+		// add the connected, initialized client to the ma
+		plugins[name] = client
+	}
+
+	return plugins, nil
+}
+
+// NewPipelineService is a constructor for a PipelineService
+func NewPipelineService(hourlyrepo, dailyrepo Repository, ingConf CustomCostIngestorConfig) (*PipelineService, error) {
+
+	registeredPlugins, err := getRegisteredPlugins(ingConf.PluginConfigDir, ingConf.PluginExecutableDir)
+	if err != nil {
+		log.Errorf("error getting registered plugins: %v", err)
+		return nil, fmt.Errorf("error getting registered plugins: %v", err)
+	}
+
+	hourlyIngestor, err := NewCustomCostIngestor(&ingConf, hourlyrepo, registeredPlugins, time.Hour)
+	if err != nil {
+		return nil, err
+	}
+
+	hourlyIngestor.Start(false)
+
+	dailyIngestor, err := NewCustomCostIngestor(&ingConf, dailyrepo, registeredPlugins, timeutil.Day)
+	if err != nil {
+		return nil, err
+	}
+
+	dailyIngestor.Start(false)
+	return &PipelineService{
+		hourlyIngestor: hourlyIngestor,
+		hourlyStore:    hourlyrepo,
+		dailyStore:     dailyrepo,
+		dailyIngestor:  dailyIngestor,
+	}, nil
+}
+
+// Status gives a combined view of the state of configs and the ingestior status
+func (dp *PipelineService) Status() Status {
+
+	// Pull config status from the config controller
+	ingstatusHourly := dp.hourlyIngestor.Status()
+
+	// Pull config status from the config controller
+	ingstatusDaily := dp.dailyIngestor.Status()
+
+	// These are the statuses
+	return Status{
+		CoverageDaily:     ingstatusDaily.Coverage,
+		CoverageHourly:    ingstatusHourly.Coverage,
+		RefreshRateHourly: ingstatusHourly.RefreshRate.String(),
+		RefreshRateDaily:  ingstatusDaily.RefreshRate.String(),
+	}
+
+}
+
+// GetCustomCostRebuildHandler creates a handler from a http request which initiates a rebuild of custom cost pipeline, if a
+// domain is provided then it only rebuilds the specified billing domain
+func (s *PipelineService) GetCustomCostRebuildHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	// If pipeline Service is nil, always return 501
+	if s == nil {
+		return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+			http.Error(w, "Custom Cost Pipeline Service is nil", http.StatusNotImplemented)
+		}
+	}
+	if s.dailyIngestor == nil || s.hourlyIngestor == nil {
+		return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+			http.Error(w, "Custom 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 Custom Cost rebuild")
+			return
+		}
+
+		domain := r.URL.Query().Get("domain")
+
+		err := s.hourlyIngestor.Rebuild(domain)
+		if err != nil {
+			log.Errorf("error rebuilding hourly ingestor")
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		err = s.dailyIngestor.Rebuild(domain)
+		if err != nil {
+			log.Errorf("error rebuilding daily ingestor")
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		protocol.WriteData(w, fmt.Sprintf("Rebuilding Custom Cost For Domain %s", domain))
+	}
+}
+
+// GetCustomCostStatusHandler creates a handler from a http request which returns the custom cost ingestor status
+func (s *PipelineService) GetCustomCostStatusHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+
+	if s == nil {
+		resultStatus := Status{
+			Enabled: false,
+		}
+		return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+			protocol.WriteData(w, resultStatus)
+		}
+	}
+	if s.hourlyIngestor == nil || s.dailyIngestor == nil {
+		return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+			http.Error(w, "Custom Cost Pipeline Service Ingestor 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")
+		stat := s.Status()
+		stat.Enabled = true
+		protocol.WriteData(w, stat)
+	}
+}

+ 190 - 0
pkg/customcost/pipelineservice_test.go

@@ -0,0 +1,190 @@
+package customcost
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"runtime"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/util/timeutil"
+)
+
+func TestPipelineService(t *testing.T) {
+	// establish temporary test assets dir
+
+	dir := t.TempDir()
+
+	err := os.MkdirAll(dir+"/config", 0777)
+	if err != nil {
+		t.Fatalf("error creating temp config dir: %v", err)
+	}
+
+	err = os.MkdirAll(dir+"/executable", 0777)
+	if err != nil {
+		t.Fatalf("error creating temp exec dir: %v", err)
+	}
+	// write DD secrets to config files
+	// write config file to temp dir
+	writeDDConfig(dir+"/config", t)
+
+	// set env vars for plugin config and executable
+	err = os.Setenv("PLUGIN_CONFIG_DIR", dir+"/config")
+	if err != nil {
+		t.Fatalf("error setting config dir env var: %v", err)
+	}
+	err = os.Setenv("PLUGIN_EXECUTABLE_DIR", dir+"/executable")
+	if err != nil {
+		t.Fatalf("error setting config dir env var: %v", err)
+	}
+
+	// download amd plugin, store in tmp executable dir
+	downloadLatestPluginExec(dir+"/executable", t)
+
+	// set up repos
+	hourlyRepo := NewMemoryRepository()
+	dailyRepo := NewMemoryRepository()
+	// set up ingestor config
+	config := DefaultIngestorConfiguration()
+
+	config.DailyDuration = 7 * timeutil.Day
+	config.HourlyDuration = 16 * time.Hour
+	pipeline, err := NewPipelineService(hourlyRepo, dailyRepo, config)
+	if err != nil {
+		t.Fatalf("error starting pipeline: %v", err)
+	}
+
+	// wait until coverage is complete, then stop ingestor
+	ingestionComplete := false
+	maxLoops := 10
+	loopCount := 0
+	for !ingestionComplete && loopCount < maxLoops {
+		status := pipeline.Status()
+		log.Debugf("got status: %v", status)
+		coverageHourly, foundHourly := status.CoverageHourly["datadog"]
+		coverageDaily, foundDaily := status.CoverageDaily["datadog"]
+		if foundHourly && foundDaily {
+			// check coverage
+			minTime := time.Now().UTC().Add(-6 * timeutil.Day)
+			maxTime := time.Now().UTC().Add(-3 * time.Hour)
+			if coverageDaily.Start().Before(minTime) && coverageHourly.End().After(maxTime) {
+				log.Infof("good coverage, breaking out of loop")
+				ingestionComplete = true
+				break
+			} else {
+				log.Infof("coverage not within range. Looking for coverage %v to %v, but current coverage is %v/%v", minTime, maxTime, coverageDaily, coverageHourly)
+			}
+		} else {
+			log.Debugf("no coverage info ready yet for datadog")
+		}
+		log.Infof("sleeping 10s...")
+		time.Sleep(10 * time.Second)
+		loopCount++
+	}
+
+	if !ingestionComplete {
+		t.Fatal("ingestor never completed within allocated time")
+	}
+
+	pipeline.hourlyIngestor.Stop()
+	pipeline.dailyIngestor.Stop()
+
+	// inspect data from yesterday
+	targetTime := time.Now().UTC().Add(-1 * timeutil.Day).Truncate(timeutil.Day)
+	log.Infof("querying for data with window start: %v", targetTime)
+	// check for presence of hosts in DD response
+	ddCosts, err := dailyRepo.Get(targetTime, "datadog")
+	if err != nil {
+		t.Fatalf("error getting results for targetTime")
+	}
+	foundInfraHosts := false
+	for _, cost := range ddCosts.Costs {
+		if cost.ResourceType == "infra_hosts" {
+			foundInfraHosts = true
+		}
+	}
+
+	if !foundInfraHosts {
+		t.Fatal("expecting infra_hosts costs in daily response")
+	}
+
+	// query data from 4 hours ago (hourly)
+	targetTime = time.Now().UTC().Add(-13 * time.Hour).Truncate(time.Hour)
+	log.Infof("querying for data with window start: %v", targetTime)
+	// check for presence of hosts in DD response
+	ddCosts, err = hourlyRepo.Get(targetTime, "datadog")
+	if err != nil {
+		t.Fatalf("error getting results for targetTime")
+	}
+	foundInfraHosts = false
+	for _, cost := range ddCosts.Costs {
+		if cost.ResourceType == "infra_hosts" {
+			foundInfraHosts = true
+		}
+	}
+
+	if !foundInfraHosts {
+		t.Fatal("expecting infra_hosts costs in hourly response")
+	}
+}
+
+func downloadLatestPluginExec(dirName string, t *testing.T) {
+	ddPluginURL := "https://github.com/opencost/opencost-plugins/releases/download/v0.0.3/datadog.ocplugin." + runtime.GOOS + "." + runtime.GOARCH
+	out, err := os.OpenFile(dirName+"/datadog.ocplugin."+runtime.GOOS+"."+runtime.GOARCH, 0755|os.O_CREATE, 0755)
+	if err != nil {
+		t.Fatalf("error creating executable file: %v", err)
+	}
+	resp, err := http.Get(ddPluginURL)
+	if err != nil {
+		t.Fatalf("error downloading: %v", err)
+	}
+	defer resp.Body.Close()
+	defer out.Close()
+
+	_, err = io.Copy(out, resp.Body)
+	if err != nil {
+		t.Fatalf("error copying: %v", err)
+	}
+}
+
+func writeDDConfig(pluginConfigDir string, t *testing.T) {
+	// read necessary env vars. If any are missing, log warning and skip test
+	ddSite := os.Getenv("DD_SITE")
+	ddApiKey := os.Getenv("DD_API_KEY")
+	ddAppKey := os.Getenv("DD_APPLICATION_KEY")
+
+	if ddSite == "" {
+		log.Warnf("DD_SITE undefined, this needs to have the URL of your DD instance, skipping test")
+		t.Skip()
+		return
+	}
+
+	if ddApiKey == "" {
+		log.Warnf("DD_API_KEY undefined, skipping test")
+		t.Skip()
+		return
+	}
+
+	if ddAppKey == "" {
+		log.Warnf("DD_APPLICATION_KEY undefined, skipping test")
+		t.Skip()
+		return
+	}
+
+	// write out config to temp file using contents of env vars
+	ddConf := fmt.Sprintf(`{"datadog_site": "%s", "datadog_api_key": "%s", "datadog_app_key": "%s"}`, ddSite, ddApiKey, ddAppKey)
+
+	// set up custom cost request
+	file, err := os.CreateTemp(pluginConfigDir, "datadog_config.json")
+	if err != nil {
+		t.Fatalf("could not create temp config dir: %v", err)
+	}
+
+	err = os.WriteFile(file.Name(), []byte(ddConf), 0777)
+	if err != nil {
+		t.Fatalf("could not write file: %v", err)
+	}
+}

+ 67 - 0
pkg/customcost/props.go

@@ -0,0 +1,67 @@
+package customcost
+
+import (
+	"fmt"
+	"strings"
+)
+
+type CustomCostProperty string
+
+const (
+	CustomCostZoneProp           CustomCostProperty = "zone"
+	CustomCostAccountNameProp                       = "accountName"
+	CustomCostChargeCategoryProp                    = "chargeCategory"
+	CustomCostDescriptionProp                       = "description"
+	CustomCostResourceNameProp                      = "resourceName"
+	CustomCostResourceTypeProp                      = "resourceType"
+	CustomCostProviderIdProp                        = "providerId"
+	CustomCostUsageUnitProp                         = "usageUnit"
+	CustomCostDomainProp                            = "domain"
+	CustomCostCostSourceProp                        = "costSource"
+)
+
+func ParseCustomCostProperties(props []string) ([]CustomCostProperty, error) {
+	var properties []CustomCostProperty
+	added := make(map[CustomCostProperty]struct{})
+
+	for _, prop := range props {
+		property, err := ParseCustomCostProperty(prop)
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse property: %w", err)
+		}
+
+		if _, ok := added[property]; !ok {
+			added[property] = struct{}{}
+			properties = append(properties, property)
+		}
+	}
+
+	return properties, nil
+}
+
+func ParseCustomCostProperty(text string) (CustomCostProperty, error) {
+	switch strings.TrimSpace(strings.ToLower(text)) {
+	case strings.TrimSpace(strings.ToLower(string(CustomCostZoneProp))):
+		return CustomCostZoneProp, nil
+	case strings.TrimSpace(strings.ToLower(CustomCostAccountNameProp)):
+		return CustomCostAccountNameProp, nil
+	case strings.TrimSpace(strings.ToLower(CustomCostChargeCategoryProp)):
+		return CustomCostChargeCategoryProp, nil
+	case strings.TrimSpace(strings.ToLower(CustomCostDescriptionProp)):
+		return CustomCostDescriptionProp, nil
+	case strings.TrimSpace(strings.ToLower(CustomCostResourceNameProp)):
+		return CustomCostResourceNameProp, nil
+	case strings.TrimSpace(strings.ToLower(CustomCostResourceTypeProp)):
+		return CustomCostResourceTypeProp, nil
+	case strings.TrimSpace(strings.ToLower(CustomCostProviderIdProp)):
+		return CustomCostProviderIdProp, nil
+	case strings.TrimSpace(strings.ToLower(CustomCostUsageUnitProp)):
+		return CustomCostUsageUnitProp, nil
+	case strings.TrimSpace(strings.ToLower(CustomCostDomainProp)):
+		return CustomCostDomainProp, nil
+	case strings.TrimSpace(strings.ToLower(CustomCostCostSourceProp)):
+		return CustomCostCostSourceProp, nil
+	}
+
+	return "", fmt.Errorf("invalid custom cost property: %s", text)
+}

+ 10 - 0
pkg/customcost/querier.go

@@ -0,0 +1,10 @@
+package customcost
+
+import (
+	"context"
+)
+
+type Querier interface {
+	QueryTotal(ctx context.Context, request CostTotalRequest) (*CostResponse, error)
+	QueryTimeseries(ctx context.Context, request CostTimeseriesRequest) (*CostTimeseriesResponse, error)
+}

+ 96 - 0
pkg/customcost/queryservice.go

@@ -0,0 +1,96 @@
+package customcost
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/julienschmidt/httprouter"
+	"github.com/opencost/opencost/core/pkg/util/httputil"
+	"go.opentelemetry.io/otel"
+)
+
+const tracerName = "github.com/opencost/opencost/pkg/customcost"
+
+type QueryService struct {
+	Querier Querier
+}
+
+func NewQueryService(querier Querier) *QueryService {
+	return &QueryService{
+		Querier: querier,
+	}
+}
+
+func (qs *QueryService) GetCustomCostTotalHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		tracer := otel.Tracer(tracerName)
+		ctx, span := tracer.Start(r.Context(), "Service.GetCustomCostTotalHandler")
+		defer span.End()
+
+		// If Query Service is nil, always return 501
+		if qs == nil {
+			http.Error(w, "Query Service is nil", http.StatusNotImplemented)
+			return
+		}
+
+		if qs.Querier == nil {
+			http.Error(w, "CustomCost Query Service is nil", http.StatusNotImplemented)
+			return
+		}
+
+		qp := httputil.NewQueryParams(r.URL.Query())
+		request, err := ParseCustomCostTotalRequest(qp)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		resp, err := qs.Querier.QueryTotal(ctx, *request)
+		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 (qs *QueryService) GetCustomCostTimeseriesHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		tracer := otel.Tracer(tracerName)
+		ctx, span := tracer.Start(r.Context(), "Service.GetCustomCostTimeseriesHandler")
+		defer span.End()
+
+		// If Query Service is nil, always return 501
+		if qs == nil {
+			http.Error(w, "Query Service is nil", http.StatusNotImplemented)
+			return
+		}
+
+		if qs.Querier == nil {
+			http.Error(w, "CustomCost Query Service is nil", http.StatusNotImplemented)
+			return
+		}
+
+		qp := httputil.NewQueryParams(r.URL.Query())
+		request, err := ParseCustomCostTimeseriesRequest(qp)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		resp, err := qs.Querier.QueryTimeseries(ctx, *request)
+		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()
+	}
+}

+ 92 - 0
pkg/customcost/queryservice_helper.go

@@ -0,0 +1,92 @@
+package customcost
+
+import (
+	"fmt"
+
+	"github.com/opencost/opencost/core/pkg/filter"
+	"github.com/opencost/opencost/core/pkg/opencost"
+	"github.com/opencost/opencost/core/pkg/util/httputil"
+)
+
+func ParseCustomCostTotalRequest(qp httputil.QueryParams) (*CostTotalRequest, error) {
+	windowStr := qp.Get("window", "")
+	if windowStr == "" {
+		return nil, fmt.Errorf("missing require window param")
+	}
+
+	window, err := opencost.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, err := ParseCustomCostProperties(aggregateByRaw)
+	if err != nil {
+		return nil, err
+	}
+
+	var filter filter.Filter
+	filterString := qp.Get("filter", "")
+	if filterString != "" {
+		parser := NewCustomCostFilterParser()
+		filter, err = parser.Parse(filterString)
+		if err != nil {
+			return nil, fmt.Errorf("parsing 'filter' parameter: %s", err)
+		}
+	}
+
+	opts := &CostTotalRequest{
+		Start:       *window.Start(),
+		End:         *window.End(),
+		AggregateBy: aggregateBy,
+		Filter:      filter,
+	}
+
+	return opts, nil
+}
+
+func ParseCustomCostTimeseriesRequest(qp httputil.QueryParams) (*CostTimeseriesRequest, error) {
+	windowStr := qp.Get("window", "")
+	if windowStr == "" {
+		return nil, fmt.Errorf("missing require window param")
+	}
+
+	window, err := opencost.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, err := ParseCustomCostProperties(aggregateByRaw)
+	if err != nil {
+		return nil, err
+	}
+
+	accumulate := opencost.ParseAccumulate(qp.Get("accumulate", ""))
+
+	var filter filter.Filter
+	filterString := qp.Get("filter", "")
+	if filterString != "" {
+		parser := NewCustomCostFilterParser()
+		filter, err = parser.Parse(filterString)
+		if err != nil {
+			return nil, fmt.Errorf("parsing 'filter' parameter: %s", err)
+		}
+	}
+
+	opts := &CostTimeseriesRequest{
+		Start:       *window.Start(),
+		End:         *window.End(),
+		AggregateBy: aggregateBy,
+		Accumulate:  accumulate,
+		Filter:      filter,
+	}
+
+	return opts, nil
+}

+ 16 - 0
pkg/customcost/repository.go

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

+ 205 - 0
pkg/customcost/repositoryquerier.go

@@ -0,0 +1,205 @@
+package customcost
+
+import (
+	"context"
+	"fmt"
+	"sync"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+	"github.com/opencost/opencost/core/pkg/util/timeutil"
+	"github.com/opencost/opencost/pkg/env"
+)
+
+type RepositoryQuerier struct {
+	hourlyRepo     Repository
+	dailyRepo      Repository
+	hourlyDuration time.Duration
+	dailyDuration  time.Duration
+}
+
+func NewRepositoryQuerier(hourlyRepo, dailyRepo Repository, hourlyDuration, dailyDuration time.Duration) *RepositoryQuerier {
+	return &RepositoryQuerier{
+		hourlyRepo:     hourlyRepo,
+		dailyRepo:      dailyRepo,
+		hourlyDuration: hourlyDuration,
+		dailyDuration:  dailyDuration,
+	}
+}
+
+func (rq *RepositoryQuerier) QueryTotal(ctx context.Context, request CostTotalRequest) (*CostResponse, error) {
+	repo := rq.dailyRepo
+	step := timeutil.Day
+	if request.Accumulate == opencost.AccumulateOptionHour {
+		repo = rq.hourlyRepo
+		step = time.Hour
+	}
+	domains, err := repo.Keys()
+	if err != nil {
+		return nil, fmt.Errorf("QueryTotal: %w", err)
+	}
+
+	compiler := NewCustomCostMatchCompiler()
+	matcher, err := compiler.Compile(request.Filter)
+	if err != nil {
+		return nil, fmt.Errorf("RepositoryQuerier: Query: failed to compile filters: %w", err)
+	}
+
+	requestWindow := opencost.NewClosedWindow(request.Start, request.End)
+	ccs := NewCustomCostSet(requestWindow)
+	queryStart := request.Start
+	for queryStart.Before(request.End) {
+		queryEnd := queryStart.Add(step)
+
+		for _, domain := range domains {
+			ccResponse, err := repo.Get(queryStart, domain)
+			if err != nil {
+				return nil, fmt.Errorf("QueryTotal: %w", err)
+			} else if ccResponse == nil || ccResponse.Start == nil || ccResponse.End == nil {
+				continue
+			}
+
+			customCosts := ParseCustomCostResponse(ccResponse)
+			for _, customCost := range customCosts {
+				if matcher.Matches(customCost) {
+					ccs.Add(customCost)
+				}
+			}
+		}
+
+		queryStart = queryEnd
+	}
+
+	err = ccs.Aggregate(request.AggregateBy)
+	if err != nil {
+		return nil, err
+	}
+
+	return NewCostResponse(ccs), nil
+}
+
+var allSteppedAccumulateOptions = []opencost.AccumulateOption{
+	opencost.AccumulateOptionHour,
+	opencost.AccumulateOptionDay,
+}
+
+func hasHourly(opts []opencost.AccumulateOption) bool {
+	for _, opt := range opts {
+		if opt == opencost.AccumulateOptionHour {
+			return true
+		}
+	}
+
+	return false
+}
+
+func hasDaily(opts []opencost.AccumulateOption) bool {
+	for _, opt := range opts {
+		if opt == opencost.AccumulateOptionDay {
+			return true
+		}
+	}
+
+	return false
+}
+
+// GetCustomCostAccumulateOption determines defaults in a way that matches options presented in the UI
+func getCustomCostAccumulateOption(window opencost.Window, from []opencost.AccumulateOption) (opencost.AccumulateOption, error) {
+	if window.IsOpen() || window.IsNegative() {
+		return opencost.AccumulateOptionNone, fmt.Errorf("invalid window '%s'", window.String())
+	}
+
+	if len(from) == 0 {
+		from = allSteppedAccumulateOptions
+	}
+
+	hourlyStoreHours := env.GetDataRetentionHourlyResolutionHours()
+	hourlySteps := time.Duration(hourlyStoreHours) * time.Hour
+	oldestHourly := time.Now().Add(-1 * hourlySteps)
+
+	// Use hourly if...
+	//  (1) hourly is an option;
+	//  (2) we have hourly store coverage; and
+	//  (3) the window duration is less than the hourly break point.
+	if hasHourly(from) && oldestHourly.Before(*window.Start()) && window.Duration() <= hourlySteps {
+		return opencost.AccumulateOptionHour, nil
+	}
+
+	dailyStoreDays := env.GetDataRetentionDailyResolutionDays()
+	dailySteps := time.Duration(dailyStoreDays) * timeutil.Day
+	oldestDaily := time.Now().Add(-1 * dailySteps)
+	// Use daily if...
+	//  (1) daily is an option; and
+	//  (2) we have daily store coverage
+	if hasDaily(from) && oldestDaily.Before(*window.Start()) {
+		return opencost.AccumulateOptionDay, nil
+	}
+
+	if oldestDaily.After(*window.Start()) {
+		return opencost.AccumulateOptionNone, fmt.Errorf("data store does not have coverage for %v", window)
+	}
+
+	return opencost.AccumulateOptionNone, fmt.Errorf("no valid accumulate option in %v for %s", from, window)
+}
+
+func (rq *RepositoryQuerier) QueryTimeseries(ctx context.Context, request CostTimeseriesRequest) (*CostTimeseriesResponse, error) {
+	window, _ := opencost.NewClosedWindow(request.Start, request.End).GetAccumulateWindow(request.Accumulate)
+	var err error
+	if request.Accumulate == opencost.AccumulateOptionNone {
+		request.Accumulate, err = getCustomCostAccumulateOption(window, nil)
+		if err != nil {
+			return nil, fmt.Errorf("error determining accumulation option: %v", err)
+		}
+	}
+
+	windows, err := window.GetAccumulateWindows(request.Accumulate)
+	if err != nil {
+		return nil, fmt.Errorf("error getting timeseries windows: %w", err)
+	}
+
+	totals := make([]*CostResponse, len(windows))
+	errors := make([]error, len(windows))
+
+	// Query concurrently for each result, error
+	var wg sync.WaitGroup
+	wg.Add(len(windows))
+
+	for i, w := range windows {
+		go func(i int, window opencost.Window, res []*CostResponse) {
+			defer wg.Done()
+			totals[i], errors[i] = rq.QueryTotal(ctx, CostTotalRequest{
+				Start:       *window.Start(),
+				End:         *window.End(),
+				AggregateBy: request.AggregateBy,
+				Filter:      request.Filter,
+				Accumulate:  request.Accumulate,
+			})
+		}(i, w, totals)
+	}
+
+	wg.Wait()
+
+	// Return an error if any errors occurred
+	for i, err := range errors {
+		if err != nil {
+			return nil, fmt.Errorf("one of %d errors: error querying costs for %s: %w", numErrors(errors), windows[i], err)
+		}
+	}
+
+	result := &CostTimeseriesResponse{
+		Window:     window,
+		Timeseries: totals,
+	}
+
+	return result, nil
+}
+
+func numErrors(errors []error) int {
+	numErrs := 0
+	for i := range errors {
+		if errors[i] != nil {
+			numErrs++
+		}
+	}
+	return numErrs
+}

+ 80 - 0
pkg/customcost/repositoryquerier_test.go

@@ -0,0 +1,80 @@
+package customcost
+
+import (
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+	"github.com/opencost/opencost/core/pkg/util/timeutil"
+)
+
+func TestGetCustomCostAccumulateOption(t *testing.T) {
+	now := time.Now().UTC()
+	nextHour := opencost.RoundForward(now, time.Hour)
+	midnight := opencost.RoundForward(now, timeutil.Day)
+
+	tests := map[string]struct {
+		window  opencost.Window
+		want    opencost.AccumulateOption
+		from    []opencost.AccumulateOption
+		wantErr bool
+	}{
+		"open window": {
+			window:  opencost.NewWindow(nil, nil),
+			from:    nil,
+			want:    opencost.AccumulateOptionNone,
+			wantErr: true,
+		},
+		"negative window": {
+			window:  opencost.NewClosedWindow(midnight, midnight.Add(-1)),
+			from:    nil,
+			want:    opencost.AccumulateOptionNone,
+			wantErr: true,
+		},
+		"hourly max": {
+			window:  opencost.NewClosedWindow(nextHour.Add(-time.Hour*49).Add(-1), nextHour),
+			from:    nil,
+			want:    opencost.AccumulateOptionDay,
+			wantErr: false,
+		},
+		"daily min": {
+			window:  opencost.NewClosedWindow(nextHour.Add(-time.Hour*49).Add(-1), nextHour),
+			from:    nil,
+			want:    opencost.AccumulateOptionDay,
+			wantErr: false,
+		},
+		"daily max": {
+			window:  opencost.NewClosedWindow(midnight.Add(-timeutil.Day*7), midnight),
+			from:    nil,
+			want:    opencost.AccumulateOptionDay,
+			wantErr: false,
+		},
+		"out of range": {
+			window:  opencost.NewClosedWindow(midnight.Add(-timeutil.Day*120), midnight.Add(-timeutil.Day*30)),
+			from:    nil,
+			want:    opencost.AccumulateOptionNone,
+			wantErr: true,
+		},
+		"daily from daily, monthly": {
+			window: opencost.NewClosedWindow(nextHour.Add(-time.Hour*24), nextHour),
+			from: []opencost.AccumulateOption{
+				opencost.AccumulateOptionDay,
+				opencost.AccumulateOptionMonth,
+			},
+			want:    opencost.AccumulateOptionDay,
+			wantErr: false,
+		},
+	}
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			got, err := getCustomCostAccumulateOption(tt.window, tt.from)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("GetAccumulateOption() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if got != tt.want {
+				t.Errorf("GetAccumulateOption() got = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}

+ 26 - 0
pkg/customcost/status.go

@@ -0,0 +1,26 @@
+package customcost
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+// Status gives the details and metadata of a CustomCost integration
+type Status struct {
+	Enabled           bool                       `json:"enabled"`
+	Key               string                     `json:"key,omitempty"`
+	Source            string                     `json:"source,omitempty"`
+	Provider          string                     `json:"provider,omitempty"`
+	Active            bool                       `json:"active,omitempty"`
+	Valid             bool                       `json:"valid,omitempty"`
+	LastRun           time.Time                  `json:"lastRun,omitempty"`
+	NextRun           time.Time                  `json:"nextRun,omitempty"`
+	RefreshRateDaily  string                     `json:"RefreshRateDaily,omitempty"`
+	RefreshRateHourly string                     `json:"RefreshRateHourly,omitempty"`
+	Created           time.Time                  `json:"created,omitempty"`
+	Runs              int                        `json:"runs,omitempty"`
+	CoverageHourly    map[string]opencost.Window `json:"coverageHourly,omitempty"`
+	CoverageDaily     map[string]opencost.Window `json:"coverageDaily,omitempty"`
+	ConnectionStatus  string                     `json:"connectionStatus,omitempty"`
+}

+ 242 - 0
pkg/customcost/types.go

@@ -0,0 +1,242 @@
+package customcost
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/filter"
+	"github.com/opencost/opencost/core/pkg/model/pb"
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+type CostTotalRequest struct {
+	Start       time.Time
+	End         time.Time
+	AggregateBy []CustomCostProperty
+	Accumulate  opencost.AccumulateOption
+	Filter      filter.Filter
+}
+
+type CostTimeseriesRequest struct {
+	Start       time.Time
+	End         time.Time
+	AggregateBy []CustomCostProperty
+	Accumulate  opencost.AccumulateOption
+	Filter      filter.Filter
+}
+
+type CostResponse struct {
+	Window          opencost.Window `json:"window"`
+	TotalBilledCost float32         `json:"totalBilledCost"`
+	TotalListCost   float32         `json:"totalListCost"`
+	CustomCosts     []*CustomCost   `json:"customCosts"`
+}
+
+type CustomCost struct {
+	Id             string  `json:"id"`
+	Zone           string  `json:"zone"`
+	AccountName    string  `json:"account_name"`
+	ChargeCategory string  `json:"charge_category"`
+	Description    string  `json:"description"`
+	ResourceName   string  `json:"resource_name"`
+	ResourceType   string  `json:"resource_type"`
+	ProviderId     string  `json:"provider_id"`
+	BilledCost     float32 `json:"billedCost"`
+	ListCost       float32 `json:"listCost"`
+	ListUnitPrice  float32 `json:"list_unit_price"`
+	UsageQuantity  float32 `json:"usage_quantity"`
+	UsageUnit      string  `json:"usage_unit"`
+	Domain         string  `json:"domain"`
+	CostSource     string  `json:"cost_source"`
+	Aggregate      string  `json:"aggregate"`
+}
+
+type CostTimeseriesResponse struct {
+	Window     opencost.Window `json:"window"`
+	Timeseries []*CostResponse `json:"timeseries"`
+}
+
+func NewCostResponse(ccs *CustomCostSet) *CostResponse {
+	costResponse := &CostResponse{
+		Window:      ccs.Window,
+		CustomCosts: []*CustomCost{},
+	}
+
+	for _, cc := range ccs.CustomCosts {
+		costResponse.TotalBilledCost += cc.BilledCost
+		costResponse.TotalListCost += cc.ListCost
+		costResponse.CustomCosts = append(costResponse.CustomCosts, cc)
+	}
+
+	return costResponse
+}
+
+func ParseCustomCostResponse(ccResponse *pb.CustomCostResponse) []*CustomCost {
+	costs := ccResponse.GetCosts()
+
+	customCosts := make([]*CustomCost, len(costs))
+	for i, cost := range costs {
+		customCosts[i] = &CustomCost{
+			Id:             cost.GetId(),
+			Zone:           cost.GetZone(),
+			AccountName:    cost.GetAccountName(),
+			ChargeCategory: cost.GetChargeCategory(),
+			Description:    cost.GetDescription(),
+			ResourceName:   cost.GetResourceName(),
+			ResourceType:   cost.GetResourceType(),
+			ProviderId:     cost.GetProviderId(),
+			BilledCost:     cost.GetBilledCost(),
+			ListCost:       cost.GetListCost(),
+			ListUnitPrice:  cost.GetListUnitPrice(),
+			UsageQuantity:  cost.GetUsageQuantity(),
+			UsageUnit:      cost.GetUsageUnit(),
+			Domain:         ccResponse.GetDomain(),
+			CostSource:     ccResponse.GetCostSource(),
+		}
+	}
+
+	return customCosts
+}
+
+func (cc *CustomCost) Add(other *CustomCost) {
+	cc.BilledCost += other.BilledCost
+	cc.ListCost += other.ListCost
+	cc.ListUnitPrice += other.ListUnitPrice
+
+	if cc.Id != other.Id {
+		cc.Id = ""
+	}
+
+	if cc.Zone != other.Zone {
+		cc.Zone = ""
+	}
+
+	if cc.AccountName != other.AccountName {
+		cc.AccountName = ""
+	}
+
+	if cc.ChargeCategory != other.ChargeCategory {
+		cc.ChargeCategory = ""
+	}
+
+	if cc.Description != other.Description {
+		cc.Description = ""
+	}
+
+	if cc.ResourceName != other.ResourceName {
+		cc.ResourceName = ""
+	}
+
+	if cc.ResourceType != other.ResourceType {
+		cc.ResourceType = ""
+	}
+
+	if cc.ProviderId != other.ProviderId {
+		cc.ProviderId = ""
+	}
+
+	if cc.UsageUnit != other.UsageUnit {
+		cc.UsageUnit = ""
+	} else {
+		// when usage units are the same, then we can sum the usages
+		cc.UsageQuantity += other.UsageQuantity
+	}
+
+	if cc.Domain != other.Domain {
+		cc.Domain = ""
+	}
+
+	if cc.CostSource != other.CostSource {
+		cc.CostSource = ""
+	}
+
+	if cc.Aggregate != other.Aggregate {
+		cc.Aggregate = ""
+	}
+
+}
+
+type CustomCostSet struct {
+	CustomCosts []*CustomCost
+	Window      opencost.Window
+}
+
+func NewCustomCostSet(window opencost.Window) *CustomCostSet {
+	return &CustomCostSet{
+		CustomCosts: []*CustomCost{},
+		Window:      window,
+	}
+}
+
+func (ccs *CustomCostSet) Add(customCost *CustomCost) {
+	ccs.CustomCosts = append(ccs.CustomCosts, customCost)
+}
+
+func (ccs *CustomCostSet) Aggregate(aggregateBy []CustomCostProperty) error {
+	// when no aggregation, return the original CustomCostSet
+	if len(aggregateBy) == 0 {
+		return nil
+	}
+
+	aggMap := make(map[string]*CustomCost)
+	for _, cc := range ccs.CustomCosts {
+		aggKey, err := generateAggKey(cc, aggregateBy)
+		if err != nil {
+			return fmt.Errorf("failed to aggregate CustomCostSet: %w", err)
+		}
+		cc.Aggregate = aggKey
+
+		if existing, ok := aggMap[aggKey]; ok {
+			existing.Add(cc)
+		} else {
+			aggMap[aggKey] = cc
+		}
+	}
+
+	var newCustomCosts []*CustomCost
+	for _, customCost := range aggMap {
+		newCustomCosts = append(newCustomCosts, customCost)
+	}
+	ccs.CustomCosts = newCustomCosts
+
+	return nil
+}
+
+func generateAggKey(cc *CustomCost, aggregateBy []CustomCostProperty) (string, error) {
+	var aggKeys []string
+	for _, agg := range aggregateBy {
+		var aggKey string
+		if agg == CustomCostZoneProp {
+			aggKey = cc.Zone
+		} else if agg == CustomCostAccountNameProp {
+			aggKey = cc.AccountName
+		} else if agg == CustomCostChargeCategoryProp {
+			aggKey = cc.ChargeCategory
+		} else if agg == CustomCostDescriptionProp {
+			aggKey = cc.Description
+		} else if agg == CustomCostResourceNameProp {
+			aggKey = cc.ResourceName
+		} else if agg == CustomCostResourceTypeProp {
+			aggKey = cc.ResourceType
+		} else if agg == CustomCostProviderIdProp {
+			aggKey = cc.ProviderId
+		} else if agg == CustomCostUsageUnitProp {
+			aggKey = cc.UsageUnit
+		} else if agg == CustomCostDomainProp {
+			aggKey = cc.Domain
+		} else if agg == CustomCostCostSourceProp {
+			aggKey = cc.CostSource
+		} else {
+			return "", fmt.Errorf("unsupported aggregation type: %s", agg)
+		}
+
+		if len(aggKey) == 0 {
+			aggKey = opencost.UnallocatedSuffix
+		}
+		aggKeys = append(aggKeys, aggKey)
+	}
+	aggKey := strings.Join(aggKeys, "/")
+
+	return aggKey, nil
+}

+ 38 - 2
pkg/env/costmodelenv.go

@@ -114,7 +114,8 @@ const (
 	ExportCSVLabelsAll  = "EXPORT_CSV_LABELS_ALL"
 	ExportCSVMaxDays    = "EXPORT_CSV_MAX_DAYS"
 
-	DataRetentionDailyResolutionDaysEnvVar = "DATA_RETENTION_DAILY_RESOLUTION_DAYS"
+	DataRetentionDailyResolutionDaysEnvVar   = "DATA_RETENTION_DAILY_RESOLUTION_DAYS"
+	DataRetentionHourlyResolutionHoursEnvVar = "DATA_RETENTION_HOURLY_RESOLUTION_HOURS"
 
 	// We assume that Kubernetes is enabled if there is a KUBERNETES_PORT environment variable present
 	KubernetesEnabledEnvVar         = "KUBERNETES_PORT"
@@ -125,6 +126,13 @@ const (
 	CloudCostQueryWindowDaysEnvVar  = "CLOUD_COST_QUERY_WINDOW_DAYS"
 	CloudCostRunWindowDaysEnvVar    = "CLOUD_COST_RUN_WINDOW_DAYS"
 
+	CustomCostEnabledEnvVar          = "CUSTOM_COST_ENABLED"
+	CustomCostQueryWindowDaysEnvVar  = "CUSTOM_COST_QUERY_WINDOW_DAYS"
+	CustomCostRefreshRateHoursEnvVar = "CUSTOM_COST_REFRESH_RATE_HOURS"
+
+	PluginConfigDirEnvVar     = "PLUGIN_CONFIG_DIR"
+	PluginExecutableDirEnvVar = "PLUGIN_EXECUTABLE_DIR"
+
 	OCIPricingURL = "OCI_PRICING_URL"
 )
 
@@ -634,7 +642,11 @@ func GetRegionOverrideList() []string {
 }
 
 func GetDataRetentionDailyResolutionDays() int64 {
-	return env.GetInt64(DataRetentionDailyResolutionDaysEnvVar, 15)
+	return env.GetInt64(DataRetentionDailyResolutionDaysEnvVar, 30)
+}
+
+func GetDataRetentionHourlyResolutionHours() int64 {
+	return env.GetInt64(DataRetentionHourlyResolutionHoursEnvVar, 49)
 }
 
 func IsKubernetesEnabled() bool {
@@ -645,6 +657,10 @@ func IsCloudCostEnabled() bool {
 	return env.GetBool(CloudCostEnabledEnvVar, false)
 }
 
+func IsCustomCostEnabled() bool {
+	return env.GetBool(CustomCostEnabledEnvVar, false)
+}
+
 func GetCloudCostConfigPath() string {
 	return env.Get(CloudCostConfigPath, "cloud-integration.json")
 }
@@ -661,6 +677,14 @@ func GetCloudCostQueryWindowDays() int64 {
 	return env.GetInt64(CloudCostQueryWindowDaysEnvVar, 7)
 }
 
+func GetCustomCostQueryWindowHours() int64 {
+	return env.GetInt64(CustomCostQueryWindowDaysEnvVar, 1)
+}
+
+func GetCustomCostQueryWindowDays() int64 {
+	return env.GetInt64(CustomCostQueryWindowDaysEnvVar, 7)
+}
+
 func GetCloudCostRunWindowDays() int64 {
 	return env.GetInt64(CloudCostRunWindowDaysEnvVar, 3)
 }
@@ -668,3 +692,15 @@ func GetCloudCostRunWindowDays() int64 {
 func GetOCIPricingURL() string {
 	return env.Get(OCIPricingURL, "https://apexapps.oracle.com/pls/apex/cetools/api/v1/products")
 }
+
+func GetPluginConfigDir() string {
+	return env.Get(PluginConfigDirEnvVar, "/opt/opencost/plugin/config")
+}
+
+func GetPluginExecutableDir() string {
+	return env.Get(PluginExecutableDirEnvVar, "/opt/opencost/plugin/bin")
+}
+
+func GetCustomCostRefreshRateHours() string {
+	return env.Get(CustomCostRefreshRateHoursEnvVar, "12h")
+}

+ 154 - 0
protos/customcost/messages.proto

@@ -0,0 +1,154 @@
+syntax = "proto3";
+
+package customcost.messages;
+
+import "google/protobuf/timestamp.proto";
+import "google/protobuf/duration.proto";
+// Sets the golang package for the protobuf generated code
+option go_package = "github.com/opencost/opencost/core/pkg/model/pb";
+
+// see design at https://link.excalidraw.com/l/ABLQ24dkKai/CBEQtjH6Mr
+// for additional details on how these objects work in the context of
+// opencost's plugin system
+
+message CustomCostRequest {
+  // the window of the returned objects
+  google.protobuf.Timestamp start = 1;
+  google.protobuf.Timestamp end = 2;
+
+  // resolution of steps to return
+  google.protobuf.Duration resolution = 3;
+
+}
+
+message CustomCostResponseSet {
+  repeated CustomCostResponse resps = 1;
+}
+
+message CustomCostResponse {
+  // provides metadata on the Custom CostResponse
+  // deliberately left unstructured
+  map<string, string>  metadata = 1;
+  // declared by plugin
+  // eg snowflake == "data management",
+  // datadog == "observability" etc
+  // intended for top level agg
+  string cost_source = 2;
+  // the name of the custom cost source
+  // e.g., "datadog"
+  string domain = 3;
+  // the version of the Custom Cost response
+  // is set by the plugin, will vary between
+  // different plugins
+  string version = 4;
+  // FOCUS billing currency
+  string currency = 5;
+  // the window of the returned objects
+  google.protobuf.Timestamp start = 6;
+  google.protobuf.Timestamp end = 7;
+
+  // array of CustomCosts
+  repeated CustomCost costs = 8;
+  // any errors in processing
+  repeated string errors = 9;
+}
+
+message CustomCost {
+  // provides metadata on the Custom CostResponse
+  // deliberately left unstructured
+  map<string, string>  metadata = 1;
+  // the region that the resource was incurred
+  // corresponds to 'availability zone' of FOCUS
+  string zone = 2;
+  // FOCUS billing account name
+  string account_name = 3;
+  // FOCUS charge category
+  string charge_category = 4;
+  // FOCUS charge description
+  string description = 5;
+  // FOCUS Resource Name
+  string resource_name = 6;
+  // FOCUS Resource type
+  // if not set, assumed to be domain
+  string resource_type = 7;
+  // ID of the individual cost. should be globally
+  // unique. Assigned by plugin on read
+  string id = 8;
+  // the provider's ID for the cost, if
+  // available
+  // FOCUS resource ID
+  string provider_id = 9;
+
+  // FOCUS billed Cost
+  float billed_cost = 10;
+  // FOCUS List Cost
+  float list_cost = 11;
+  // FOCUS List Unit Price
+  float list_unit_price = 12;
+  // FOCUS usage quantity
+  float usage_quantity = 13;
+  // FOCUS usage Unit
+  string usage_unit = 14;
+  // Returns key/value sets of labels
+  // equivalent to Tags in focus spec
+  map<string, string> labels = 15;
+  // Optional struct to implement other focus
+  // spec attributes
+  optional CustomCostExtendedAttributes extended_attributes = 16;
+
+}
+
+message CustomCostExtendedAttributes {
+  // FOCUS billing period start
+  optional google.protobuf.Timestamp billing_period_start = 1;
+  // FOCUS billing period end
+  optional google.protobuf.Timestamp billing_period_end = 2;
+  // FOCUS Billing Account ID
+  optional string account_id = 3;
+  // FOCUS Charge Frequency
+  optional string charge_frequency = 4;
+  // FOCUS Charge Subcategory
+  optional string subcategory = 5;
+  // FOCUS Commitment Discount Category
+  optional string commitment_discount_category = 6;
+  // FOCUS Commitment Discount ID
+  optional string commitment_discount_id = 7;
+  // FOCUS Commitment Discount Name
+  optional string commitment_discount_name = 8;
+  // FOCUS Commitment Discount Type
+  optional string commitment_discount_type = 9;
+  // FOCUS Effective Cost
+  optional float effective_cost = 10;
+  // FOCUS Invoice Issuer
+  optional string invoice_issuer = 11;
+  // FOCUS Provider
+  // if unset, assumed to be domain
+  optional string provider = 12;
+  // FOCUS Publisher
+  // if unset, assumed to be domain
+  optional string publisher = 13;
+  // FOCUS Service Category
+  // if unset, assumed to be cost source
+  optional string service_category = 14;
+  // FOCUS Service Name
+  // if unset, assumed to be cost source
+  optional string service_name = 15;
+  // FOCUS SKU ID
+  optional string sku_id = 16;
+  // FOCUS SKU Price ID
+  optional string sku_price_id = 17;
+  // FOCUS Sub Account ID
+  optional string sub_account_id = 18;
+  // FOCUS Sub Account Name
+  optional string sub_account_name = 19;
+  // FOCUS Pricing Quantity
+  optional float pricing_quantity = 20;
+  // FOCUS Pricing Unit
+  optional string pricing_unit = 21;
+  // FOCUS Pricing Category
+  optional string pricing_category = 22;
+}
+
+service CustomCostsSource {
+    rpc GetCustomCosts(CustomCostRequest) returns (CustomCostResponseSet);
+}

+ 9 - 7
tilt-values.yaml

@@ -5,18 +5,13 @@ 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'
+    defaultClusterId: "tilt-cluster"
   livenessProbe:
     # -- Whether probe is enabled
     enabled: true
@@ -36,6 +31,9 @@ opencost:
     periodSeconds: 10
     # -- Number of failures for probe to be considered failed
     failureThreshold: 3
+  # extraVolumeMounts:
+  #   - mountPath: /var/secrets
+  #     name: service-key-secret
 
   # Persistent volume claim for storing the data. eg: csv file
   persistence:
@@ -115,4 +113,8 @@ opencost:
       port: 80
   ui:
     # -- Enable OpenCost UI
-    enabled: true
+    enabled: true
+# extraVolumes:
+#   - name: service-key-secret
+#     secret:
+#       secretName: service-key

+ 40 - 0
ui/Dockerfile.debug

@@ -0,0 +1,40 @@
+# This dockerfile is for development purposes only; do not use this for production deployments
+# This file exists due to changes introduced in https://github.com/opencost/opencost/pull/2502
+# Tilt cannot reference files that exist outside of this ./ui folder so the reference to THIRD_PARTY_LICENSES.txt is removed
+FROM nginx:alpine
+
+LABEL org.opencontainers.image.description="Cross-cloud cost allocation models for Kubernetes workloads"
+LABEL org.opencontainers.image.documentation=https://opencost.io/docs/
+LABEL org.opencontainers.image.licenses=Apache-2.0
+LABEL org.opencontainers.image.source=https://github.com/opencost/opencost
+LABEL org.opencontainers.image.title=opencost-ui
+LABEL org.opencontainers.image.url=https://opencost.io
+
+ARG version=dev
+ARG	commit=HEAD
+ENV VERSION=${version}
+ENV HEAD=${commit}
+
+ENV API_PORT=9003
+ENV API_SERVER=0.0.0.0
+ENV UI_PORT=9090
+
+COPY ./dist /opt/ui/dist
+COPY default.nginx.conf.template /etc/nginx/conf.d/default.nginx.conf.template
+COPY nginx.conf /etc/nginx/
+COPY ./docker-entrypoint.sh /usr/local/bin/
+RUN mkdir -p /var/www
+
+RUN rm -rf /etc/nginx/conf.d/default.conf
+
+RUN adduser 1001 -g 1000 -D
+RUN chown 1001:1000 -R /var/www
+RUN chown 1001:1000 -R /etc/nginx
+RUN chown 1001:1000 -R /usr/local/bin/docker-entrypoint.sh
+
+ENV BASE_URL=/model
+
+USER 1001
+
+ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+CMD ["nginx", "-g", "daemon off;"]

+ 7 - 9
ui/justfile

@@ -1,4 +1,3 @@
-version := `../tools/image-tag`
 commit := `git rev-parse --short HEAD`
 thirdPartyLicenseFile := "THIRD_PARTY_LICENSES.txt"
 
@@ -10,15 +9,15 @@ build-local:
 
     npx parcel build src/index.html
 
-build IMAGETAG: build-local
+build IMAGE_TAG RELEASE_VERSION: build-local
     cp ../{{thirdPartyLicenseFile}} .
     docker buildx build \
         --rm \
         --platform "linux/amd64" \
         -f 'Dockerfile.cross' \
         --provenance=false \
-        -t {{IMAGETAG}}-amd64 \
-        --build-arg version={{version}} \
+        -t {{IMAGE_TAG}}-amd64 \
+        --build-arg version={{RELEASE_VERSION}} \
         --build-arg commit={{commit}} \
         --push \
         .
@@ -28,16 +27,15 @@ build IMAGETAG: build-local
         --platform "linux/arm64" \
         -f 'Dockerfile.cross' \
         --provenance=false \
-        -t {{IMAGETAG}}-arm64 \
-        --build-arg version={{version}} \
+        -t {{IMAGE_TAG}}-arm64 \
+        --build-arg version={{RELEASE_VERSION}} \
         --build-arg commit={{commit}} \
         --push \
         .
 
     manifest-tool push from-args \
         --platforms "linux/amd64,linux/arm64" \
-        --template {{IMAGETAG}}-ARCH \
-        --target {{IMAGETAG}}
+        --template {{IMAGE_TAG}}-ARCH \
+        --target {{IMAGE_TAG}}
 
     rm -f {{thirdPartyLicenseFile}}
-