Ver Fonte

Merge commit '6766d4cc6791ced9a2930bbec1651fe8b048daad' into feature/kubemodel

Sean Holcomb há 14 horas atrás
pai
commit
5631175d99

+ 18 - 4
.github/actions/sign-image/action.yaml

@@ -18,14 +18,24 @@ inputs:
         required: true
     run-started-at:
         description: >-
-          ISO-8601 workflow run start time, typically `${{ github.run_started_at }}`
-          from the caller. Recorded as `runDetails.metadata.startedOn` in the
-          SLSA provenance predicate.
+          ISO-8601 workflow run start time (typically
+          `github.run_started_at` from the caller workflow). Recorded as
+          `runDetails.metadata.startedOn` in the SLSA provenance predicate.
+          If empty, the action falls back to the time at which it began
+          executing — `github.run_started_at` is reported empty in some
+          edge cases and `required: true` on a composite-action input
+          does not reject empty strings.
         required: true
 
 runs:
     using: "composite"
     steps:
+      - name: Capture fallback start timestamp
+        id: start
+        shell: bash
+        run: |
+          echo "STARTED_ON=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
+
       - name: Install cosign
         uses: sigstore/cosign-installer@v3
 
@@ -59,10 +69,14 @@ runs:
         env:
           WORKFLOW_PATH: ${{ inputs.workflow-path }}
           STARTED_ON: ${{ inputs.run-started-at }}
+          FALLBACK_STARTED_ON: ${{ steps.start.outputs.STARTED_ON }}
         run: |
           set -euo pipefail
+          if [[ -z "${STARTED_ON:-}" ]]; then
+            STARTED_ON="$FALLBACK_STARTED_ON"
+          fi
           RESOLVED_GIT_COMMIT="$(git rev-parse HEAD)"
-          export RESOLVED_GIT_COMMIT
+          export RESOLVED_GIT_COMMIT STARTED_ON
           python3 - <<'PY' > predicate.json
           import json
           import os

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

@@ -30,7 +30,7 @@ jobs:
     steps:
       - name: Get Version From Tag
         id: tag
-        if: ${{ github.event_name }} == 'push'
+        if: ${{ github.event_name == 'push' }}
         run: |
           echo "TRIGGERED_TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
 

+ 1 - 1
.github/workflows/sbom.yml

@@ -167,7 +167,7 @@ jobs:
       # Attach SBOMs to GitHub release (only for releases, not PRs)
       - name: Attach SBOMs to GitHub Release
         if: github.event_name != 'pull_request'
-        uses: softprops/action-gh-release@v2
+        uses: softprops/action-gh-release@v3
         with:
           tag_name: v${{ steps.version_number.outputs.RELEASE_VERSION }}
           files: |

+ 1 - 0
MAINTAINERS.md

@@ -9,6 +9,7 @@ Official list of [OpenCost Maintainers](https://github.com/orgs/opencost/teams/o
 | Ajay Tripathy | @AjayTripathy | IBM | <ajay.tripathy@ibm.com> |
 | Alex Meijer | @ameijer | IBM | <alexander.meijer@ibm.com> |
 | Artur Khantimirov | @r2k1 | Microsoft | <akhantimirov@microsoft.com> |
+| Christian Petersen | @cpetersen5 | IBM | <Christian.Petersen2@ibm.com> |
 | Matt Bolt | @​mbolt35 | IBM | <matthew.bolt@ibm.com> |
 | Niko Kovacevic | @nikovacevic | IBM | <Nicholas.Kovacevic@ibm.com> |
 | Sean Holcomb | @Sean-Holcomb | IBM | <sean.holcomb@ibm.com> |

+ 2 - 2
go.mod

@@ -40,7 +40,7 @@ require (
 	github.com/julienschmidt/httprouter v1.3.0
 	github.com/kubecost/events v0.0.8
 	github.com/microcosm-cc/bluemonday v1.0.27
-	github.com/modelcontextprotocol/go-sdk v1.4.1
+	github.com/modelcontextprotocol/go-sdk v1.6.0
 	github.com/opencost/opencost/core v0.0.0-20250521155634-81d2b597d1bc
 	github.com/opencost/opencost/modules/collector-source v0.0.0-00010101000000-000000000000
 	github.com/opencost/opencost/modules/prometheus-source v0.0.0-00010101000000-000000000000
@@ -95,7 +95,7 @@ require (
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
 	github.com/gofrs/flock v0.13.0 // indirect
-	github.com/google/jsonschema-go v0.4.2 // indirect
+	github.com/google/jsonschema-go v0.4.3 // indirect
 	github.com/klauspost/crc32 v1.3.0 // indirect
 	github.com/leodido/go-urn v1.4.0 // indirect
 	github.com/minio/crc64nvme v1.1.1 // indirect

+ 4 - 4
go.sum

@@ -259,8 +259,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
-github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
+github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
+github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
 github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
@@ -345,8 +345,8 @@ github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRi
 github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
 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/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
-github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
+github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY=
+github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=

+ 13 - 4
modules/collector-source/pkg/metric/synthetic/cpuallocation.go

@@ -64,15 +64,24 @@ func (usage *CpuUsageMetric) Value() float64 {
 		return 0.0
 	}
 
-	v1, t1 := usage.current.update.Value, usage.current.timestamp
-	v2, t2 := usage.prev.update.Value, usage.prev.timestamp
+	curr, t1 := usage.current.update.Value, usage.current.timestamp
+	prev, t2 := usage.prev.update.Value, usage.prev.timestamp
+
+	// handle case where current value is less than the previous value, signalling
+	// that the running total was reset, or overflowed.
+	if curr < prev {
+		return 0.0
+	}
+
 	seconds := t1.Sub(t2).Seconds()
+
+	// ensure positive non-zero duration between samples
 	if seconds <= 0.0 {
 		return 0.0
 	}
 
-	irate := (v1 - v2) / seconds
-	return irate
+	irate := (curr - prev) / seconds
+	return max(0.0, irate)
 }
 
 // Shift will set the previous to the current metric, and set the current metric to nil.

+ 176 - 0
modules/collector-source/pkg/metric/synthetic/metricsynthesizer_test.go

@@ -2,6 +2,7 @@ package synthetic
 
 import (
 	"maps"
+	"math"
 	"testing"
 	"time"
 
@@ -420,3 +421,178 @@ func TestMetricSynthesizerCPUAllocation(t *testing.T) {
 	metricSynth.Update(updateSet2)
 	metricSynth.Update(updateSet3)
 }
+
+func TestMetricSynthesizerCPUAllocation_UsageOverflow(t *testing.T) {
+	container1Info := map[string]string{
+		source.NamespaceLabel: "namespace1",
+		source.NodeLabel:      "node1",
+		source.InstanceLabel:  "node1",
+		source.PodLabel:       "pod1",
+		source.UIDLabel:       "pod-uuid1",
+		source.ContainerLabel: "container1",
+	}
+
+	// start a max uint64 nanoseconds -> seconds
+	// since the source metrics use nanoseconds, that's where overflow would occur.
+	var startingCPUNanoSeconds uint64 = math.MaxUint64
+
+	const nanosIncrement uint64 = 40 * 1e9
+
+	toSeconds := func(nanos uint64) float64 {
+		return float64(nanos) * 1e-9
+	}
+
+	updateSet1 := &metric.UpdateSet{
+		Timestamp: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC),
+		Updates: []metric.Update{
+			// First Update has requests AND 1 usage sample
+			{
+				Name:   metric.KubePodContainerResourceRequests,
+				Labels: toCpuResource(container1Info),
+				Value:  0.2,
+			},
+			{
+				Name:   metric.ContainerCPUUsageSecondsTotal,
+				Labels: maps.Clone(container1Info),
+				Value:  toSeconds(startingCPUNanoSeconds),
+			},
+		},
+	}
+
+	updateSet2 := &metric.UpdateSet{
+		Timestamp: time.Date(2026, time.January, 1, 0, 0, 30, 0, time.UTC),
+		Updates: []metric.Update{
+			// Second Update doesn't have request, and has the second usage sample
+			{
+				Name:   metric.ContainerCPUUsageSecondsTotal,
+				Labels: maps.Clone(container1Info),
+				Value:  toSeconds(startingCPUNanoSeconds + nanosIncrement),
+			},
+		},
+	}
+
+	updateSet3 := &metric.UpdateSet{
+		Timestamp: time.Date(2026, time.January, 1, 0, 1, 0, 0, time.UTC),
+		Updates: []metric.Update{
+			{
+				Name:   metric.ContainerCPUUsageSecondsTotal,
+				Labels: maps.Clone(container1Info),
+				Value:  toSeconds(startingCPUNanoSeconds + nanosIncrement + nanosIncrement),
+			},
+		},
+	}
+
+	scrape := 0
+	updater := NewFuncUpdater(func(us *metric.UpdateSet) {
+		// first scrape:
+		//  - container1: alloc = request
+		if scrape == 0 {
+			assertMetricValue(t, us, metric.ContainerCPUAllocation, "container1", 0.2)
+		}
+
+		// second scrape
+		//  - container1: alloc = overflow, reset to current sample
+		if scrape == 1 {
+			assertMetricValue(t, us, metric.ContainerCPUAllocation, "container1", 0.0)
+		}
+
+		// third scrape
+		//  - container1: alloc = 40.0/30s = 1.33333
+		if scrape == 2 {
+			assertMetricValue(t, us, metric.ContainerCPUAllocation, "container1", 1.33333333)
+		}
+
+		scrape += 1
+	})
+
+	metricSynth := NewMetricSynthesizers(updater, NewContainerCpuAllocationSynthesizer(), NewContainerMemoryAllocationSynthesizer())
+
+	metricSynth.Update(updateSet1)
+	metricSynth.Update(updateSet2)
+	metricSynth.Update(updateSet3)
+}
+
+func TestMetricSynthesizerCPUAllocation_UsageCounterReset(t *testing.T) {
+	const nanosIncrement uint64 = 40 * 1e9
+
+	container1Info := map[string]string{
+		source.NamespaceLabel: "namespace1",
+		source.NodeLabel:      "node1",
+		source.InstanceLabel:  "node1",
+		source.PodLabel:       "pod1",
+		source.UIDLabel:       "pod-uuid1",
+		source.ContainerLabel: "container1",
+	}
+
+	// Starting CPU Total Seconds
+	const startingCPUSeconds float64 = 506000.0
+
+	updateSet1 := &metric.UpdateSet{
+		Timestamp: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC),
+		Updates: []metric.Update{
+			// First Update has requests AND 1 usage sample
+			{
+				Name:   metric.KubePodContainerResourceRequests,
+				Labels: toCpuResource(container1Info),
+				Value:  0.2,
+			},
+			{
+				Name:   metric.ContainerCPUUsageSecondsTotal,
+				Labels: maps.Clone(container1Info),
+				Value:  startingCPUSeconds,
+			},
+		},
+	}
+
+	updateSet2 := &metric.UpdateSet{
+		Timestamp: time.Date(2026, time.January, 1, 0, 0, 30, 0, time.UTC),
+		Updates: []metric.Update{
+			// Second Update doesn't have request, and has the second usage sample
+			{
+				Name:   metric.ContainerCPUUsageSecondsTotal,
+				Labels: maps.Clone(container1Info),
+				Value:  startingCPUSeconds - 1000.0,
+			},
+		},
+	}
+
+	updateSet3 := &metric.UpdateSet{
+		Timestamp: time.Date(2026, time.January, 1, 0, 1, 0, 0, time.UTC),
+		Updates: []metric.Update{
+			{
+				Name:   metric.ContainerCPUUsageSecondsTotal,
+				Labels: maps.Clone(container1Info),
+				Value:  (startingCPUSeconds - 1000.0) + 40.0,
+			},
+		},
+	}
+
+	scrape := 0
+	updater := NewFuncUpdater(func(us *metric.UpdateSet) {
+		// first scrape:
+		//  - container1: alloc = request
+		if scrape == 0 {
+			assertMetricValue(t, us, metric.ContainerCPUAllocation, "container1", 0.2)
+		}
+
+		// second scrape
+		//  - container1: alloc = (subtract 1000s - usage sample is less than last recorded), reset to 0.0
+		if scrape == 1 {
+			assertMetricValue(t, us, metric.ContainerCPUAllocation, "container1", 0.0)
+		}
+
+		// third scrape
+		//  - container1: alloc = 40.0/30s = 1.33333
+		if scrape == 2 {
+			assertMetricValue(t, us, metric.ContainerCPUAllocation, "container1", 1.33333333)
+		}
+
+		scrape += 1
+	})
+
+	metricSynth := NewMetricSynthesizers(updater, NewContainerCpuAllocationSynthesizer(), NewContainerMemoryAllocationSynthesizer())
+
+	metricSynth.Update(updateSet1)
+	metricSynth.Update(updateSet2)
+	metricSynth.Update(updateSet3)
+}

+ 0 - 3
modules/prometheus-source/pkg/prom/contextnames.go

@@ -19,9 +19,6 @@ const (
 	// ClusterMapContextName is the name we assign the cluster map query context [metadata]
 	ClusterMapContextName = "cluster-map"
 
-	// FrontendContextName is the name we assign queries proxied from the frontend [metadata]
-	FrontendContextName = "frontend"
-
 	// DiagnosticContextName is the name we assign queries that check the state of the prometheus connection
 	DiagnosticContextName = "diagnostic"
 

+ 1 - 249
modules/prometheus-source/pkg/prom/datasource.go

@@ -3,8 +3,6 @@ package prom
 import (
 	"context"
 	"fmt"
-	"net/http"
-	"strconv"
 	"time"
 
 	"github.com/Masterminds/semver/v3"
@@ -13,58 +11,12 @@ import (
 	"github.com/opencost/opencost/core/pkg/clusters"
 	"github.com/opencost/opencost/core/pkg/diagnostics"
 	"github.com/opencost/opencost/core/pkg/log"
-	"github.com/opencost/opencost/core/pkg/protocol"
 	"github.com/opencost/opencost/core/pkg/source"
-	"github.com/opencost/opencost/core/pkg/util/httputil"
-	"github.com/opencost/opencost/core/pkg/util/json"
 
 	prometheus "github.com/prometheus/client_golang/api"
 	prometheusAPI "github.com/prometheus/client_golang/api/prometheus/v1"
 )
 
-const (
-	apiPrefix         = "/api/v1"
-	epAlertManagers   = apiPrefix + "/alertmanagers"
-	epLabelValues     = apiPrefix + "/label/:name/values"
-	epSeries          = apiPrefix + "/series"
-	epTargets         = apiPrefix + "/targets"
-	epSnapshot        = apiPrefix + "/admin/tsdb/snapshot"
-	epDeleteSeries    = apiPrefix + "/admin/tsdb/delete_series"
-	epCleanTombstones = apiPrefix + "/admin/tsdb/clean_tombstones"
-	epConfig          = apiPrefix + "/status/config"
-	epFlags           = apiPrefix + "/status/flags"
-	epRules           = apiPrefix + "/rules"
-)
-
-// helper for query range proxy requests
-func toStartEndStep(qp httputil.QueryParams) (start, end time.Time, step time.Duration, err error) {
-	var e error
-
-	ss := qp.Get("start", "")
-	es := qp.Get("end", "")
-	ds := qp.Get("duration", "")
-	layout := "2006-01-02T15:04:05.000Z"
-
-	start, e = time.Parse(layout, ss)
-	if e != nil {
-		err = fmt.Errorf("Error parsing time %s. Error: %s", ss, err)
-		return
-	}
-	end, e = time.Parse(layout, es)
-	if e != nil {
-		err = fmt.Errorf("Error parsing time %s. Error: %s", es, err)
-		return
-	}
-	step, e = time.ParseDuration(ds)
-	if e != nil {
-		err = fmt.Errorf("Error parsing duration %s. Error: %s", ds, err)
-		return
-	}
-	err = nil
-
-	return
-}
-
 // creates a new help error which indicates the caller can retry and is non-fatal.
 func newHelpRetryError(format string, args ...any) error {
 	formatWithHelp := format + "\nTroubleshooting help available at: %s"
@@ -179,191 +131,6 @@ func NewPrometheusDataSource(infoProvider clusters.ClusterInfoProvider, promConf
 	}, nil
 }
 
-var proto = protocol.HTTP()
-
-// prometheusMetadata returns the metadata for the prometheus server
-func (pds *PrometheusDataSource) prometheusMetadata(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
-	w.Header().Set("Content-Type", "application/json")
-	w.Header().Set("Access-Control-Allow-Origin", "*")
-
-	resp := proto.ToResponse(Validate(pds.promClient, pds.promConfig))
-	proto.WriteResponse(w, resp)
-}
-
-// prometheusRecordingRules is a proxy for /rules against prometheus
-func (pds *PrometheusDataSource) prometheusRecordingRules(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
-	w.Header().Set("Content-Type", "application/json")
-	w.Header().Set("Access-Control-Allow-Origin", "*")
-
-	u := pds.promClient.URL(epRules, nil)
-
-	req, err := http.NewRequest(http.MethodGet, u.String(), nil)
-	if err != nil {
-		fmt.Fprintf(w, "error creating Prometheus rule request: %s", err)
-		return
-	}
-
-	_, body, err := pds.promClient.Do(r.Context(), req)
-	if err != nil {
-		fmt.Fprintf(w, "error making Prometheus rule request: %s", err)
-		return
-	}
-
-	w.Write(body)
-}
-
-// prometheusConfig returns the current configuration of the prometheus server
-func (pds *PrometheusDataSource) prometheusConfig(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
-	w.Header().Set("Content-Type", "application/json")
-	w.Header().Set("Access-Control-Allow-Origin", "*")
-
-	pConfig := map[string]string{
-		"address": pds.promConfig.ServerEndpoint,
-	}
-
-	body, err := json.Marshal(pConfig)
-	if err != nil {
-		fmt.Fprintf(w, "Error marshalling prometheus config")
-	} else {
-		w.Write(body)
-	}
-}
-
-// prometheusTargets is a proxy for /targets against prometheus
-func (pds *PrometheusDataSource) prometheusTargets(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
-	w.Header().Set("Content-Type", "application/json")
-	w.Header().Set("Access-Control-Allow-Origin", "*")
-
-	u := pds.promClient.URL(epTargets, nil)
-
-	req, err := http.NewRequest(http.MethodGet, u.String(), nil)
-	if err != nil {
-		fmt.Fprintf(w, "error creating Prometheus rule request: %s", err)
-		return
-	}
-
-	_, body, err := pds.promClient.Do(r.Context(), req)
-	if err != nil {
-		fmt.Fprintf(w, "error making Prometheus rule request: %s", err)
-		return
-	}
-
-	w.Write(body)
-}
-
-// status returns the status of the prometheus client
-func (pds *PrometheusDataSource) status(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
-	w.Header().Set("Content-Type", "application/json")
-	w.Header().Set("Access-Control-Allow-Origin", "*")
-
-	promServer := pds.promConfig.ServerEndpoint
-
-	api := prometheusAPI.NewAPI(pds.promClient)
-	result, err := api.Buildinfo(r.Context())
-	if err != nil {
-		fmt.Fprintf(w, "Using Prometheus at %s, Error: %s", promServer, err)
-	} else {
-		fmt.Fprintf(w, "Using Prometheus at %s, version: %s", promServer, result.Version)
-	}
-}
-
-// prometheusQuery is a proxy for /query against prometheus
-func (pds *PrometheusDataSource) prometheusQuery(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
-	w.Header().Set("Content-Type", "application/json")
-	w.Header().Set("Access-Control-Allow-Origin", "*")
-
-	qp := httputil.NewQueryParams(r.URL.Query())
-	query := qp.Get("query", "")
-	if query == "" {
-		proto.WriteResponse(w, proto.ToResponse(nil, fmt.Errorf("Query Parameter 'query' is unset'")))
-		return
-	}
-
-	// Attempt to parse time as either a unix timestamp or as an RFC3339 value
-	var timeVal time.Time
-	timeStr := qp.Get("time", "")
-	if len(timeStr) > 0 {
-		if t, err := strconv.ParseInt(timeStr, 10, 64); err == nil {
-			timeVal = time.Unix(t, 0)
-		} else if t, err := time.Parse(time.RFC3339, timeStr); err == nil {
-			timeVal = t
-		}
-
-		// If time is given, but not parse-able, return an error
-		if timeVal.IsZero() {
-			http.Error(w, fmt.Sprintf("time must be a unix timestamp or RFC3339 value; illegal value given: %s", timeStr), http.StatusBadRequest)
-		}
-	}
-
-	ctx := pds.promContexts.NewNamedContext(FrontendContextName)
-	body, err := ctx.RawQuery(query, timeVal)
-	if err != nil {
-		proto.WriteResponse(w, proto.ToResponse(nil, fmt.Errorf("Error running query %s. Error: %s", query, err)))
-		return
-	}
-
-	w.Write(body) // prometheusQueryRange is a proxy for /query_range against prometheus
-}
-
-func (pds *PrometheusDataSource) prometheusQueryRange(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
-	w.Header().Set("Content-Type", "application/json")
-	w.Header().Set("Access-Control-Allow-Origin", "*")
-
-	qp := httputil.NewQueryParams(r.URL.Query())
-	query := qp.Get("query", "")
-	if query == "" {
-		fmt.Fprintf(w, "Error parsing query from request parameters.")
-		return
-	}
-
-	start, end, duration, err := toStartEndStep(qp)
-	if err != nil {
-		fmt.Fprintf(w, "error: %s", err)
-		return
-	}
-
-	ctx := pds.promContexts.NewNamedContext(FrontendContextName)
-	body, err := ctx.RawQueryRange(query, start, end, duration)
-	if err != nil {
-		fmt.Fprintf(w, "Error running query %s. Error: %s", query, err)
-		return
-	}
-
-	w.Write(body)
-}
-
-// promtheusQueueState returns the current state of the prometheus and thanos request queues
-func (pds *PrometheusDataSource) prometheusQueueState(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
-	w.Header().Set("Content-Type", "application/json")
-	w.Header().Set("Access-Control-Allow-Origin", "*")
-
-	promQueueState, err := GetPrometheusQueueState(pds.promClient, pds.promConfig)
-	if err != nil {
-		proto.WriteResponse(w, proto.ToResponse(nil, err))
-		return
-	}
-
-	result := map[string]*PrometheusQueueState{
-		"prometheus": promQueueState,
-	}
-
-	proto.WriteResponse(w, proto.ToResponse(result, nil))
-}
-
-// prometheusMetrics retrieves availability of Prometheus and Thanos metrics
-func (pds *PrometheusDataSource) prometheusMetrics(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
-	w.Header().Set("Content-Type", "application/json")
-	w.Header().Set("Access-Control-Allow-Origin", "*")
-
-	promMetrics := GetPrometheusMetrics(pds.promClient, pds.promConfig, "")
-
-	result := map[string][]*PrometheusDiagnostic{
-		"prometheus": promMetrics,
-	}
-
-	proto.WriteResponse(w, proto.ToResponse(result, nil))
-}
-
 func (pds *PrometheusDataSource) PrometheusClient() prometheus.Client {
 	return pds.promClient
 }
@@ -376,22 +143,7 @@ func (pds *PrometheusDataSource) PrometheusContexts() *ContextFactory {
 	return pds.promContexts
 }
 
-func (pds *PrometheusDataSource) RegisterEndPoints(router *httprouter.Router) {
-	// endpoints migrated from server
-	router.GET("/validatePrometheus", pds.prometheusMetadata)
-	router.GET("/prometheusRecordingRules", pds.prometheusRecordingRules)
-	router.GET("/prometheusConfig", pds.prometheusConfig)
-	router.GET("/prometheusTargets", pds.prometheusTargets)
-	router.GET("/status", pds.status)
-
-	// prom query proxies
-	router.GET("/prometheusQuery", pds.prometheusQuery)
-	router.GET("/prometheusQueryRange", pds.prometheusQueryRange)
-
-	// diagnostics
-	router.GET("/diagnostics/requestQueue", pds.prometheusQueueState)
-	router.GET("/diagnostics/prometheusMetrics", pds.prometheusMetrics)
-}
+func (pds *PrometheusDataSource) RegisterEndPoints(_ *httprouter.Router) {}
 
 // RegisterDiagnostics registers any custom data source diagnostics with the `DiagnosticService` that can
 // be used to report externally.

+ 1 - 0
modules/prometheus-source/pkg/prom/query.go

@@ -19,6 +19,7 @@ import (
 )
 
 const (
+	apiPrefix    = "/api/v1"
 	epQuery      = apiPrefix + "/query"
 	epQueryRange = apiPrefix + "/query_range"
 )

+ 182 - 0
pkg/allocation/autocompletequeryservice.go

@@ -0,0 +1,182 @@
+package allocation
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/opencost/opencost/core/pkg/filter"
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+// ErrAutocompleteBadRequest indicates a client error in an autocomplete request.
+var ErrAutocompleteBadRequest = errors.New("autocomplete bad request")
+
+// IsAutocompleteBadRequest reports whether err is a client validation error.
+func IsAutocompleteBadRequest(err error) bool {
+	return errors.Is(err, ErrAutocompleteBadRequest)
+}
+
+const DefaultAutocompleteResultLimit = 100
+const MaxAutocompleteResultLimit = 1000
+
+type AllocationAutocompleteRequest struct {
+	Search      string
+	Field       string
+	Limit       int
+	Window      opencost.Window
+	Filter      filter.Filter
+	LabelConfig *opencost.LabelConfig
+}
+
+type AllocationAutocompleteResponse struct {
+	Data []string `json:"data"`
+}
+
+type AutocompleteQueryService interface {
+	QueryAllocationAutocomplete(AllocationAutocompleteRequest, context.Context) (*AllocationAutocompleteResponse, error)
+}
+
+func QueryAllocationAutocompleteFromSetRange(asr *opencost.AllocationSetRange, req AllocationAutocompleteRequest) (*AllocationAutocompleteResponse, error) {
+	field, err := validateAutocompleteField(req.Field)
+	if err != nil {
+		return nil, fmt.Errorf("%w: invalid field: %w", ErrAutocompleteBadRequest, err)
+	}
+
+	limit := req.Limit
+	if limit <= 0 {
+		limit = DefaultAutocompleteResultLimit
+	}
+	if limit > MaxAutocompleteResultLimit {
+		return nil, fmt.Errorf("%w: exceeded maximum autocomplete result limit of %d", ErrAutocompleteBadRequest, MaxAutocompleteResultLimit)
+	}
+
+	var matcher opencost.AllocationMatcher
+	if req.Filter != nil {
+		compiler := opencost.NewAllocationMatchCompiler(req.LabelConfig)
+		matcher, err = compiler.Compile(req.Filter)
+		if err != nil {
+			return nil, fmt.Errorf("%w: failed to compile filter: %w", ErrAutocompleteBadRequest, err)
+		}
+	}
+
+	search := strings.ToLower(req.Search)
+	results := map[string]struct{}{}
+	for _, as := range asr.Allocations {
+		if as == nil {
+			continue
+		}
+		for _, alloc := range as.Allocations {
+			if alloc == nil || alloc.Properties == nil {
+				continue
+			}
+			if matcher != nil && !matcher.Matches(alloc) {
+				continue
+			}
+
+			values := allocationAutocompleteValues(alloc.Properties, field)
+			for _, value := range values {
+				if value == "" {
+					continue
+				}
+				if search != "" && !strings.Contains(strings.ToLower(value), search) {
+					continue
+				}
+				results[value] = struct{}{}
+			}
+		}
+	}
+
+	return &AllocationAutocompleteResponse{Data: uniqueSortedLimited(results, limit)}, nil
+}
+
+func validateAutocompleteField(field string) (string, error) {
+	if field == "" {
+		return "", fmt.Errorf("field is required")
+	}
+
+	f := strings.ToLower(field)
+	switch f {
+	case "cluster", "namespace", "node", "controllerkind", "controllername", "pod", "container", "label", "namespacelabel":
+		return f, nil
+	}
+
+	if strings.HasPrefix(f, "label:") {
+		_, labelKey, _ := strings.Cut(f, ":")
+		return "label:" + labelKey, nil
+	}
+	if strings.HasPrefix(f, "namespacelabel:") {
+		_, labelKey, _ := strings.Cut(f, ":")
+		return "namespacelabel:" + labelKey, nil
+	}
+
+	return "", fmt.Errorf("unrecognized field: %s", field)
+}
+
+func allocationAutocompleteValues(props *opencost.AllocationProperties, field string) []string {
+	switch {
+	case field == "cluster":
+		return []string{props.Cluster}
+	case field == "namespace":
+		return []string{props.Namespace}
+	case field == "node":
+		return []string{props.Node}
+	case field == "controllerkind":
+		return []string{props.ControllerKind}
+	case field == "controllername":
+		return []string{props.Controller}
+	case field == "pod":
+		return []string{props.Pod}
+	case field == "container":
+		return []string{props.Container}
+	case field == "label":
+		return mapKeys(props.Labels)
+	case strings.HasPrefix(field, "label:"):
+		label := strings.TrimPrefix(field, "label:")
+		if v, ok := mapValueFold(props.Labels, label); ok {
+			return []string{v}
+		}
+	case field == "namespacelabel":
+		return mapKeys(props.NamespaceLabels)
+	case strings.HasPrefix(field, "namespacelabel:"):
+		label := strings.TrimPrefix(field, "namespacelabel:")
+		if v, ok := mapValueFold(props.NamespaceLabels, label); ok {
+			return []string{v}
+		}
+	}
+	return nil
+}
+
+func mapKeys(values map[string]string) []string {
+	result := make([]string, 0, len(values))
+	for k := range values {
+		result = append(result, k)
+	}
+	return result
+}
+
+func mapValueFold(values map[string]string, key string) (string, bool) {
+	if v, ok := values[key]; ok {
+		return v, true
+	}
+	for k, v := range values {
+		if strings.EqualFold(k, key) {
+			return v, true
+		}
+	}
+	return "", false
+}
+
+func uniqueSortedLimited(values map[string]struct{}, limit int) []string {
+	out := make([]string, 0, len(values))
+	for v := range values {
+		out = append(out, v)
+	}
+	sort.Strings(out)
+	if len(out) > limit {
+		return out[:limit]
+	}
+	return out
+}

+ 90 - 0
pkg/allocation/autocompletequeryservice_test.go

@@ -0,0 +1,90 @@
+package allocation
+
+import (
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+func TestQueryAllocationAutocompleteFromSetRange(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	as := opencost.NewAllocationSet(start, start.Add(24*time.Hour))
+	as.Set(opencost.NewMockUnitAllocation("a1", start, 24*time.Hour, &opencost.AllocationProperties{
+		Cluster:         "cluster-a",
+		Namespace:       "ns-a",
+		Pod:             "pod-a",
+		Container:       "container-a",
+		ControllerKind:  "deployment",
+		Controller:      "deploy-a",
+		Node:            "node-a",
+		Labels:          map[string]string{"Team": "platform", "app": "api"},
+		NamespaceLabels: map[string]string{"owner": "sre"},
+	}))
+	as.Set(opencost.NewMockUnitAllocation("a2", start, 24*time.Hour, &opencost.AllocationProperties{
+		Cluster:         "cluster-b",
+		Namespace:       "ns-b",
+		Pod:             "pod-b",
+		Container:       "container-b",
+		ControllerKind:  "statefulset",
+		Controller:      "db-a",
+		Node:            "node-b",
+		Labels:          map[string]string{"Team": "data", "app": "db"},
+		NamespaceLabels: map[string]string{"owner": "db"},
+	}))
+
+	asr := opencost.NewAllocationSetRange(as)
+
+	resp, err := QueryAllocationAutocompleteFromSetRange(asr, AllocationAutocompleteRequest{
+		Field: "label",
+		Limit: 10,
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(resp.Data) != 2 || resp.Data[0] != "Team" || resp.Data[1] != "app" {
+		t.Fatalf("unexpected label autocomplete response: %+v", resp.Data)
+	}
+
+	valueResp, err := QueryAllocationAutocompleteFromSetRange(asr, AllocationAutocompleteRequest{
+		Field:  "label:team",
+		Search: "plat",
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(valueResp.Data) != 1 || valueResp.Data[0] != "platform" {
+		t.Fatalf("unexpected label value autocomplete response: %+v", valueResp.Data)
+	}
+
+	mixedCaseResp, err := QueryAllocationAutocompleteFromSetRange(asr, AllocationAutocompleteRequest{
+		Field: "label:Team",
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(mixedCaseResp.Data) != 2 || mixedCaseResp.Data[0] != "data" || mixedCaseResp.Data[1] != "platform" {
+		t.Fatalf("expected label:team to match Team label values, got %+v", mixedCaseResp.Data)
+	}
+
+	_, err = QueryAllocationAutocompleteFromSetRange(asr, AllocationAutocompleteRequest{
+		Field: "account",
+	})
+	if err == nil {
+		t.Fatal("expected error for unsupported account field")
+	}
+	if !IsAutocompleteBadRequest(err) {
+		t.Fatalf("expected bad request error, got: %v", err)
+	}
+
+	_, err = QueryAllocationAutocompleteFromSetRange(asr, AllocationAutocompleteRequest{
+		Field: "namespace",
+		Limit: MaxAutocompleteResultLimit + 1,
+	})
+	if err == nil {
+		t.Fatal("expected error for excessive limit")
+	}
+	if !IsAutocompleteBadRequest(err) {
+		t.Fatalf("expected bad request error, got: %v", err)
+	}
+}

+ 176 - 0
pkg/asset/autocompletequeryservice.go

@@ -0,0 +1,176 @@
+package asset
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/opencost/opencost/core/pkg/filter"
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+// ErrAutocompleteBadRequest indicates a client error in an autocomplete request.
+var ErrAutocompleteBadRequest = errors.New("autocomplete bad request")
+
+// IsAutocompleteBadRequest reports whether err is a client validation error.
+func IsAutocompleteBadRequest(err error) bool {
+	return errors.Is(err, ErrAutocompleteBadRequest)
+}
+
+const DefaultAutocompleteResultLimit = 100
+const MaxAutocompleteResultLimit = 1000
+
+type AssetAutocompleteRequest struct {
+	TenantID string
+	Search   string
+	Field    string
+	Limit    int
+	Window   opencost.Window
+	Filter   filter.Filter
+}
+
+type AssetAutocompleteResponse struct {
+	Data []string `json:"data"`
+}
+
+type AutocompleteQueryService interface {
+	QueryAssetAutocomplete(AssetAutocompleteRequest, context.Context) (*AssetAutocompleteResponse, error)
+}
+
+func QueryAssetAutocompleteFromSet(assetSet *opencost.AssetSet, req AssetAutocompleteRequest) (*AssetAutocompleteResponse, error) {
+	if err := validateAssetAutocompleteWindow(req.Window); err != nil {
+		return nil, err
+	}
+	if req.TenantID == "" {
+		return nil, fmt.Errorf("%w: tenant ID is required", ErrAutocompleteBadRequest)
+	}
+
+	field, err := validateAutocompleteField(req.Field)
+	if err != nil {
+		return nil, fmt.Errorf("%w: invalid field: %w", ErrAutocompleteBadRequest, err)
+	}
+
+	limit := req.Limit
+	if limit <= 0 {
+		limit = DefaultAutocompleteResultLimit
+	}
+	if limit > MaxAutocompleteResultLimit {
+		return nil, fmt.Errorf("%w: exceeded maximum autocomplete result limit of %d", ErrAutocompleteBadRequest, MaxAutocompleteResultLimit)
+	}
+
+	var matcher opencost.AssetMatcher
+	if req.Filter != nil {
+		compiler := opencost.NewAssetMatchCompiler()
+		matcher, err = compiler.Compile(req.Filter)
+		if err != nil {
+			return nil, fmt.Errorf("%w: failed to compile filter: %w", ErrAutocompleteBadRequest, err)
+		}
+	}
+
+	search := strings.ToLower(req.Search)
+	results := map[string]struct{}{}
+	for _, a := range assetSet.Assets {
+		if a == nil {
+			continue
+		}
+		if matcher != nil && !matcher.Matches(a) {
+			continue
+		}
+
+		values := assetAutocompleteValues(a, field)
+		for _, value := range values {
+			if value == "" {
+				continue
+			}
+			if search != "" && !strings.Contains(strings.ToLower(value), search) {
+				continue
+			}
+			results[value] = struct{}{}
+		}
+	}
+
+	data := make([]string, 0, len(results))
+	for value := range results {
+		data = append(data, value)
+	}
+	sort.Strings(data)
+	if len(data) > limit {
+		data = data[:limit]
+	}
+	return &AssetAutocompleteResponse{Data: data}, nil
+}
+
+func validateAutocompleteField(field string) (string, error) {
+	f := strings.ToLower(field)
+	switch f {
+	case "account", "cluster", "name", "provider", "providerid", "type", "category":
+		return f, nil
+	}
+	if f == "label" {
+		return f, nil
+	}
+	if strings.HasPrefix(f, "label:") {
+		_, labelKey, _ := strings.Cut(f, ":")
+		return "label:" + labelKey, nil
+	}
+	return "", fmt.Errorf("unrecognized field: %s", field)
+}
+
+func validateAssetAutocompleteWindow(window opencost.Window) error {
+	if window.IsOpen() {
+		return fmt.Errorf("%w: invalid window: %s", ErrAutocompleteBadRequest, window.String())
+	}
+	if window.Start() == nil || window.End() == nil {
+		return fmt.Errorf("%w: invalid window: missing start or end", ErrAutocompleteBadRequest)
+	}
+	return nil
+}
+
+func assetAutocompleteValues(asset opencost.Asset, field string) []string {
+	props := asset.GetProperties()
+	if props == nil {
+		return nil
+	}
+	switch {
+	case field == "account":
+		return []string{props.Account}
+	case field == "cluster":
+		return []string{props.Cluster}
+	case field == "name":
+		return []string{props.Name}
+	case field == "provider":
+		return []string{props.Provider}
+	case field == "providerid":
+		return []string{props.ProviderID}
+	case field == "type":
+		return []string{asset.Type().String()}
+	case field == "category":
+		return []string{props.Category}
+	case field == "label":
+		keys := make([]string, 0, len(asset.GetLabels()))
+		for key := range asset.GetLabels() {
+			keys = append(keys, key)
+		}
+		return keys
+	case strings.HasPrefix(field, "label:"):
+		labelName := strings.TrimPrefix(field, "label:")
+		if value, ok := mapValueFold(asset.GetLabels(), labelName); ok {
+			return []string{value}
+		}
+	}
+	return nil
+}
+
+func mapValueFold(values map[string]string, key string) (string, bool) {
+	if v, ok := values[key]; ok {
+		return v, true
+	}
+	for k, v := range values {
+		if strings.EqualFold(k, key) {
+			return v, true
+		}
+	}
+	return "", false
+}

+ 86 - 0
pkg/asset/autocompletequeryservice_test.go

@@ -0,0 +1,86 @@
+package asset
+
+import (
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+func TestQueryAssetAutocompleteFromSet(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(24 * time.Hour)
+	window := opencost.NewClosedWindow(start, end)
+
+	nodeA := opencost.NewNode("node-a", "cluster-a", "provider-a", start, end, window)
+	nodeA.SetLabels(map[string]string{"team": "platform", "app": "api"})
+	nodeA.GetProperties().Account = "acct-a"
+	nodeA.GetProperties().Category = opencost.ComputeCategory
+
+	nodeB := opencost.NewNode("node-b", "cluster-b", "provider-b", start, end, window)
+	nodeB.SetLabels(map[string]string{"team": "data", "app": "db"})
+	nodeB.GetProperties().Account = "acct-b"
+	nodeB.GetProperties().Category = opencost.ComputeCategory
+
+	assetSet := opencost.NewAssetSet(start, end, nodeA, nodeB)
+
+	resp, err := QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+		TenantID: "opencost",
+		Field:    "cluster",
+		Window:   window,
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(resp.Data) != 2 || resp.Data[0] != "cluster-a" || resp.Data[1] != "cluster-b" {
+		t.Fatalf("unexpected cluster autocomplete response: %+v", resp.Data)
+	}
+
+	labelResp, err := QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+		TenantID: "opencost",
+		Field:    "label:team",
+		Search:   "plat",
+		Window:   window,
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(labelResp.Data) != 1 || labelResp.Data[0] != "platform" {
+		t.Fatalf("unexpected label autocomplete response: %+v", labelResp.Data)
+	}
+
+	_, err = QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+		Field: "cluster",
+	})
+	if err == nil {
+		t.Fatal("expected error when tenant ID is missing")
+	}
+	if !IsAutocompleteBadRequest(err) {
+		t.Fatalf("expected bad request error, got: %v", err)
+	}
+
+	_, err = QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+		TenantID: "opencost",
+		Field:    "labels",
+		Window:   window,
+	})
+	if err == nil {
+		t.Fatal("expected error for invalid field prefix")
+	}
+	if !IsAutocompleteBadRequest(err) {
+		t.Fatalf("expected bad request error, got: %v", err)
+	}
+
+	openWindow := opencost.NewWindow(&start, nil)
+	_, err = QueryAssetAutocompleteFromSet(assetSet, AssetAutocompleteRequest{
+		TenantID: "opencost",
+		Field:    "name",
+		Window:   openWindow,
+	})
+	if err == nil {
+		t.Fatal("expected error for open window")
+	}
+	if !IsAutocompleteBadRequest(err) {
+		t.Fatalf("expected bad request error, got: %v", err)
+	}
+}

+ 7 - 3
pkg/cloud/azure/provider.go

@@ -1344,7 +1344,11 @@ func (key *azurePvKey) GetStorageClass() string {
 
 func (key *azurePvKey) Features() string {
 	storageClass := key.StorageClassParameters["storageaccounttype"]
-	storageSKU := key.StorageClassParameters["skuName"]
+	diskSKU := key.StorageClassParameters["skuname"]
+	fileSKU := key.StorageClassParameters["skuName"]
+	if storageClass == "" {
+		storageClass = diskSKU
+	}
 	if storageClass != "" {
 		if strings.EqualFold(storageClass, "Premium_LRS") {
 			storageClass = AzureDiskPremiumSSDStorageClass
@@ -1354,9 +1358,9 @@ func (key *azurePvKey) Features() string {
 			storageClass = AzureDiskStandardStorageClass
 		}
 	} else {
-		if strings.EqualFold(storageSKU, "Premium_LRS") {
+		if strings.EqualFold(fileSKU, "Premium_LRS") {
 			storageClass = AzureFilePremiumStorageClass
-		} else if strings.EqualFold(storageSKU, "Standard_LRS") {
+		} else if strings.EqualFold(fileSKU, "Standard_LRS") {
 			storageClass = AzureFileStandardStorageClass
 		}
 	}

+ 62 - 0
pkg/cloud/azure/provider_test.go

@@ -254,6 +254,68 @@ func TestAzure_findCostForDisk(t *testing.T) {
 	}
 }
 
+func TestAzurePVKeyFeatures(t *testing.T) {
+	tests := []struct {
+		name       string
+		parameters map[string]string
+		expected   string
+	}{
+		{
+			name: "managed disk storageaccounttype premium",
+			parameters: map[string]string{
+				"storageaccounttype": "Premium_LRS",
+			},
+			expected: "eastus,premium_ssd",
+		},
+		{
+			name: "managed disk csi skuname premium",
+			parameters: map[string]string{
+				"skuname": "Premium_LRS",
+			},
+			expected: "eastus,premium_ssd",
+		},
+		{
+			name: "managed disk csi skuname standard ssd",
+			parameters: map[string]string{
+				"skuname": "StandardSSD_LRS",
+			},
+			expected: "eastus,standard_ssd",
+		},
+		{
+			name: "managed disk csi skuname standard hdd",
+			parameters: map[string]string{
+				"skuname": "Standard_LRS",
+			},
+			expected: "eastus,standard_hdd",
+		},
+		{
+			name: "azure files skuName remains file pricing",
+			parameters: map[string]string{
+				"skuName": "Premium_LRS",
+			},
+			expected: "eastus,premium_smb",
+		},
+		{
+			name: "azure files skuName standard remains file pricing",
+			parameters: map[string]string{
+				"skuName": "Standard_LRS",
+			},
+			expected: "eastus,standard_smb",
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			key := &azurePvKey{
+				StorageClassParameters: tc.parameters,
+				DefaultRegion:          "eastus",
+			}
+
+			require.Equal(t, tc.expected, key.Features())
+		})
+	}
+}
+
 func Test_buildAzureRetailPricesURL(t *testing.T) {
 	testCases := []struct {
 		name         string

+ 23 - 1
pkg/cloud/gcp/bigqueryconfiguration.go

@@ -16,6 +16,7 @@ type BigQueryConfiguration struct {
 	Dataset              string     `json:"dataset"`
 	Table                string     `json:"table"`
 	ExcludePartitionTime bool       `json:"excludePartitionTime"`
+	Location             string     `json:"location"`
 	Authorizer           Authorizer `json:"authorizer"`
 }
 
@@ -76,6 +77,10 @@ func (bqc *BigQueryConfiguration) Equals(config cloud.Config) bool {
 		return false
 	}
 
+	if bqc.Location != thatConfig.Location {
+		return false
+	}
+
 	return true
 }
 
@@ -84,6 +89,7 @@ func (bqc *BigQueryConfiguration) Sanitize() cloud.Config {
 		ProjectID:  bqc.ProjectID,
 		Dataset:    bqc.Dataset,
 		Table:      bqc.Table,
+		Location:   bqc.Location,
 		Authorizer: bqc.Authorizer.Sanitize().(Authorizer),
 	}
 }
@@ -106,7 +112,15 @@ func (bqc *BigQueryConfiguration) GetBigQueryClient(ctx context.Context) (*bigqu
 	if err != nil {
 		return nil, err
 	}
-	return bigquery.NewClient(ctx, bqc.ProjectID, clientOpts...)
+
+	client, err := bigquery.NewClient(ctx, bqc.ProjectID, clientOpts...)
+	if err != nil {
+		return nil, err
+	}
+
+	client.Location = bqc.Location
+
+	return client, nil
 }
 
 // UnmarshalJSON assumes data is save as an BigQueryConfigurationDTO
@@ -137,6 +151,14 @@ func (bqc *BigQueryConfiguration) UnmarshalJSON(b []byte) error {
 	}
 	bqc.Table = table
 
+	if _, ok := fmap["location"]; ok {
+		location, err := cloud.GetInterfaceValue[string](fmap, "location")
+		if err != nil {
+			return fmt.Errorf("BigQueryConfiguration: FromInterface: %s", err.Error())
+		}
+		bqc.Location = location
+	}
+
 	authAny, ok := fmap["authorizer"]
 	if !ok {
 		return fmt.Errorf("StorageConfiguration: UnmarshalJSON: missing authorizer")

+ 29 - 0
pkg/cloud/gcp/bigqueryconfiguration_test.go

@@ -138,6 +138,7 @@ func TestBigQueryConfiguration_Equals(t *testing.T) {
 						"key1": "key2",
 					},
 				},
+				Location: "EU",
 			},
 			right: &BigQueryConfiguration{
 				ProjectID: "projectID",
@@ -149,6 +150,7 @@ func TestBigQueryConfiguration_Equals(t *testing.T) {
 						"key1": "key2",
 					},
 				},
+				Location: "EU",
 			},
 			expected: true,
 		},
@@ -318,6 +320,33 @@ func TestBigQueryConfiguration_Equals(t *testing.T) {
 			},
 			expected: false,
 		},
+		"different location": {
+			left: BigQueryConfiguration{
+				ProjectID: "projectID",
+				Dataset:   "dataset",
+				Table:     "table",
+				Location:  "EU",
+				Authorizer: &ServiceAccountKey{
+					Key: map[string]string{
+						"Key":  "Key",
+						"key1": "key2",
+					},
+				},
+			},
+			right: &BigQueryConfiguration{
+				ProjectID: "projectID",
+				Dataset:   "dataset2",
+				Table:     "table",
+				Location:  "US",
+				Authorizer: &ServiceAccountKey{
+					Key: map[string]string{
+						"Key":  "Key",
+						"key1": "key2",
+					},
+				},
+			},
+			expected: false,
+		},
 	}
 
 	for name, testCase := range testCases {

+ 2 - 0
pkg/cloud/gcp/bigqueryquerier.go

@@ -45,6 +45,8 @@ func (bqq *BigQueryQuerier) Query(ctx context.Context, queryStr string) (*bigque
 	}
 
 	query := client.Query(queryStr)
+	query.Location = client.Location
+
 	iter, err := query.Read(ctx)
 
 	// If result is empty and connection status is not already successful update status to missing data

+ 79 - 0
pkg/cloudcost/autocomplete_test.go

@@ -0,0 +1,79 @@
+package cloudcost
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+)
+
+func TestRepositoryQuerier_QueryCloudCostAutocomplete(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(24 * time.Hour)
+
+	repo := NewMemoryRepository()
+	ccs := DefaultMockCloudCostSet(start, end, "aws", "integration-1")
+	if err := repo.Put(ccs); err != nil {
+		t.Fatalf("failed to seed repository: %v", err)
+	}
+	rq := NewRepositoryQuerier(repo)
+
+	resp, err := rq.QueryCloudCostAutocomplete(context.Background(), CloudCostAutocompleteRequest{
+		Field:  opencost.CloudCostServiceProp,
+		Window: opencost.NewClosedWindow(start, end),
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(resp.Data) != 2 {
+		t.Fatalf("expected 2 service values, got %d: %+v", len(resp.Data), resp.Data)
+	}
+
+	labelResp, err := rq.QueryCloudCostAutocomplete(context.Background(), CloudCostAutocompleteRequest{
+		Field:  "label:label1",
+		Search: "value1",
+		Window: opencost.NewClosedWindow(start, end),
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(labelResp.Data) != 1 || labelResp.Data[0] != "value1" {
+		t.Fatalf("unexpected label autocomplete response: %+v", labelResp.Data)
+	}
+
+	_, err = rq.QueryCloudCostAutocomplete(context.Background(), CloudCostAutocompleteRequest{
+		Field:  opencost.CloudCostServiceProp,
+		Limit:  MaxAutocompleteResultLimit + 1,
+		Window: opencost.NewClosedWindow(start, end),
+	})
+	if err == nil {
+		t.Fatal("expected error for excessive limit")
+	}
+	if !IsAutocompleteBadRequest(err) {
+		t.Fatalf("expected bad request error, got: %v", err)
+	}
+
+	_, err = rq.QueryCloudCostAutocomplete(context.Background(), CloudCostAutocompleteRequest{
+		Field:  "not-a-real-field",
+		Window: opencost.NewClosedWindow(start, end),
+	})
+	if err == nil {
+		t.Fatal("expected error for invalid field")
+	}
+	if !IsAutocompleteBadRequest(err) {
+		t.Fatalf("expected bad request error, got: %v", err)
+	}
+
+	mixedCaseResp, err := rq.QueryCloudCostAutocomplete(context.Background(), CloudCostAutocompleteRequest{
+		Field:  "label:Label1",
+		Search: "value1",
+		Window: opencost.NewClosedWindow(start, end),
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if len(mixedCaseResp.Data) != 1 || mixedCaseResp.Data[0] != "value1" {
+		t.Fatalf("unexpected mixed-case label autocomplete response: %+v", mixedCaseResp.Data)
+	}
+}

+ 16 - 0
pkg/cloudcost/querier.go

@@ -13,6 +13,7 @@ import (
 // Querier allows for querying ranges of CloudCost data
 type Querier interface {
 	Query(context.Context, QueryRequest) (*opencost.CloudCostSetRange, error)
+	QueryCloudCostAutocomplete(context.Context, CloudCostAutocompleteRequest) (*CloudCostAutocompleteResponse, error)
 }
 
 type QueryRequest struct {
@@ -23,6 +24,21 @@ type QueryRequest struct {
 	Filter      filter.Filter
 }
 
+const DefaultAutocompleteResultLimit = 100
+const MaxAutocompleteResultLimit = 1000
+
+type CloudCostAutocompleteRequest struct {
+	Search string
+	Field  string
+	Limit  int
+	Window opencost.Window
+	Filter filter.Filter
+}
+
+type CloudCostAutocompleteResponse struct {
+	Data []string `json:"data"`
+}
+
 // DefaultChartItemsLength the default max number of items for a ViewGraphDataSet
 const DefaultChartItemsLength int = 10
 

+ 38 - 0
pkg/cloudcost/queryservice.go

@@ -1,6 +1,7 @@
 package cloudcost
 
 import (
+	"errors"
 	"fmt"
 	"net/http"
 	"strings"
@@ -68,6 +69,43 @@ func (s *QueryService) GetCloudCostHandler() func(w http.ResponseWriter, r *http
 	}
 }
 
+func (s *QueryService) GetCloudCostAutocompleteHandler() 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.GetCloudCostAutocompleteHandler")
+		defer span.End()
+
+		if s == nil {
+			http.Error(w, "Query Service is nil", http.StatusNotImplemented)
+			return
+		}
+		if s.Querier == nil {
+			http.Error(w, "CloudCost Query Service is nil", http.StatusNotImplemented)
+			return
+		}
+
+		qp := httputil.NewQueryParams(r.URL.Query())
+		request, err := ParseCloudCostAutocompleteRequest(qp)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		resp, err := s.Querier.QueryCloudCostAutocomplete(ctx, *request)
+		if err != nil {
+			if errors.Is(err, ErrAutocompleteBadRequest) {
+				http.Error(w, err.Error(), http.StatusBadRequest)
+				return
+			}
+			http.Error(w, fmt.Sprintf("Internal server error: %s", err), http.StatusInternalServerError)
+			return
+		}
+
+		w.Header().Set("Content-Type", "application/json")
+		protocol.WriteData(w, resp)
+	}
+}
+
 func (s *QueryService) GetCloudCostViewGraphHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
 	// Return valid handler func
 	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {

+ 39 - 2
pkg/cloudcost/queryservice_helper.go

@@ -15,7 +15,7 @@ func ParseCloudCostRequest(qp httputil.QueryParams) (*QueryRequest, error) {
 
 	windowStr := qp.Get("window", "")
 	if windowStr == "" {
-		return nil, fmt.Errorf("missing require window param")
+		return nil, fmt.Errorf("missing required 'window' parameter")
 	}
 
 	window, err := opencost.ParseWindowUTC(windowStr)
@@ -49,7 +49,7 @@ func ParseCloudCostRequest(qp httputil.QueryParams) (*QueryRequest, error) {
 		parser := cloudcost.NewCloudCostFilterParser()
 		filter, err = parser.Parse(filterString)
 		if err != nil {
-			return nil, fmt.Errorf("Parsing 'filter' parameter: %s", err)
+			return nil, fmt.Errorf("invalid 'filter' parameter: %w", err)
 		}
 	}
 
@@ -64,6 +64,43 @@ func ParseCloudCostRequest(qp httputil.QueryParams) (*QueryRequest, error) {
 	return opts, nil
 }
 
+func ParseCloudCostAutocompleteRequest(qp httputil.QueryParams) (*CloudCostAutocompleteRequest, error) {
+	windowStr := qp.Get("window", "")
+	if windowStr == "" {
+		return nil, fmt.Errorf("missing required 'window' parameter")
+	}
+
+	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())
+	}
+
+	var parsedFilter filter.Filter
+	filterString := qp.Get("filter", "")
+	if filterString != "" {
+		parser := cloudcost.NewCloudCostFilterParser()
+		parsedFilter, err = parser.Parse(filterString)
+		if err != nil {
+			return nil, fmt.Errorf("invalid 'filter' parameter: %w", err)
+		}
+	}
+
+	req := &CloudCostAutocompleteRequest{
+		Search: qp.Get("search", ""),
+		Field:  qp.Get("field", ""),
+		Limit:  qp.GetInt("limit", 0),
+		Window: window,
+		Filter: parsedFilter,
+	}
+	if req.Field == "" {
+		return nil, fmt.Errorf("missing required 'field' parameter")
+	}
+	return req, nil
+}
+
 func ParseCloudCostViewRequest(qp httputil.QueryParams) (*ViewQueryRequest, error) {
 	qr, err := ParseCloudCostRequest(qp)
 	if err != nil {

+ 79 - 0
pkg/cloudcost/queryservice_helper_test.go

@@ -134,3 +134,82 @@ func TestParseCloudCostRequest(t *testing.T) {
 		})
 	}
 }
+
+func TestParseCloudCostAutocompleteRequest(t *testing.T) {
+	windowStr := "2023-01-01T00:00:00Z,2023-01-02T00:00:00Z"
+	validFilterStr := `service:"AmazonEC2"`
+	parser := cloudcost.NewCloudCostFilterParser()
+	validFilter, _ := parser.Parse(validFilterStr)
+
+	tests := map[string]struct {
+		values  map[string][]string
+		want    *CloudCostAutocompleteRequest
+		wantErr bool
+	}{
+		"missing window": {
+			values:  map[string][]string{"field": {"service"}},
+			wantErr: true,
+		},
+		"missing field": {
+			values:  map[string][]string{"window": {windowStr}},
+			wantErr: true,
+		},
+		"invalid window": {
+			values: map[string][]string{
+				"window": {"invalid"},
+				"field":  {"service"},
+			},
+			wantErr: true,
+		},
+		"open window": {
+			values: map[string][]string{
+				"window": {"2023-01-01T00:00:00Z,"},
+				"field":  {"service"},
+			},
+			wantErr: true,
+		},
+		"invalid filter": {
+			values: map[string][]string{
+				"window": {windowStr},
+				"field":  {"service"},
+				"filter": {"invalid"},
+			},
+			wantErr: true,
+		},
+		"valid request": {
+			values: map[string][]string{
+				"window": {windowStr},
+				"field":  {"service"},
+				"filter": {validFilterStr},
+				"search": {"ec2"},
+				"limit":  {"25"},
+			},
+			want: &CloudCostAutocompleteRequest{
+				Search: "ec2",
+				Field:  "service",
+				Limit:  25,
+				Filter: validFilter,
+			},
+			wantErr: false,
+		},
+	}
+
+	for name, tt := range tests {
+		t.Run(name, func(t *testing.T) {
+			qp := httputil.NewQueryParams(tt.values)
+			got, err := ParseCloudCostAutocompleteRequest(qp)
+			if (err != nil) != tt.wantErr {
+				t.Fatalf("ParseCloudCostAutocompleteRequest() error = %v, wantErr %v", err, tt.wantErr)
+			}
+			if tt.wantErr {
+				return
+			}
+			if got.Search != tt.want.Search || got.Field != tt.want.Field || got.Limit != tt.want.Limit {
+				t.Fatalf("unexpected request: got=%+v want=%+v", got, tt.want)
+			}
+			if got.Window.IsOpen() {
+				t.Fatal("expected closed window")
+			}
+		})
+	}
+}

+ 133 - 0
pkg/cloudcost/repositoryquerier.go

@@ -2,13 +2,23 @@ package cloudcost
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"sort"
+	"strings"
 
 	"github.com/opencost/opencost/core/pkg/log"
 	"github.com/opencost/opencost/core/pkg/opencost"
 )
 
+// ErrAutocompleteBadRequest indicates a client error in an autocomplete request.
+var ErrAutocompleteBadRequest = errors.New("autocomplete bad request")
+
+// IsAutocompleteBadRequest reports whether err is a client validation error.
+func IsAutocompleteBadRequest(err error) bool {
+	return errors.Is(err, ErrAutocompleteBadRequest)
+}
+
 // RepositoryQuerier is an implementation of Querier and ViewQuerier which pulls directly from a Repository
 type RepositoryQuerier struct {
 	repo Repository
@@ -67,6 +77,129 @@ func (rq *RepositoryQuerier) Query(ctx context.Context, request QueryRequest) (*
 	return ccsr, nil
 }
 
+func (rq *RepositoryQuerier) QueryCloudCostAutocomplete(ctx context.Context, request CloudCostAutocompleteRequest) (*CloudCostAutocompleteResponse, error) {
+	if request.Window.IsOpen() {
+		return nil, fmt.Errorf("%w: invalid window for autocomplete query: %s", ErrAutocompleteBadRequest, request.Window.String())
+	}
+
+	limit := request.Limit
+	if limit <= 0 {
+		limit = DefaultAutocompleteResultLimit
+	}
+	if limit > MaxAutocompleteResultLimit {
+		return nil, fmt.Errorf("%w: exceeded maximum autocomplete result limit of %d", ErrAutocompleteBadRequest, MaxAutocompleteResultLimit)
+	}
+
+	field, err := validateCloudCostAutocompleteField(request.Field)
+	if err != nil {
+		return nil, fmt.Errorf("%w: invalid field: %w", ErrAutocompleteBadRequest, err)
+	}
+
+	ccsr, err := rq.Query(ctx, QueryRequest{
+		Start:      *request.Window.Start(),
+		End:        *request.Window.End(),
+		Accumulate: opencost.AccumulateOptionNone,
+		Filter:     request.Filter,
+	})
+	if err != nil {
+		return nil, fmt.Errorf("QueryCloudCostAutocomplete: query failed: %w", err)
+	}
+
+	search := strings.ToLower(request.Search)
+	results := map[string]struct{}{}
+	for _, ccs := range ccsr.CloudCostSets {
+		for _, cc := range ccs.CloudCosts {
+			if cc == nil || cc.Properties == nil {
+				continue
+			}
+
+			values := cloudCostAutocompleteValues(cc, field)
+			for _, value := range values {
+				if value == "" {
+					continue
+				}
+				if search != "" && !strings.Contains(strings.ToLower(value), search) {
+					continue
+				}
+				results[value] = struct{}{}
+			}
+		}
+	}
+
+	data := make([]string, 0, len(results))
+	for result := range results {
+		data = append(data, result)
+	}
+	sort.Strings(data)
+	if len(data) > limit {
+		data = data[:limit]
+	}
+
+	return &CloudCostAutocompleteResponse{Data: data}, nil
+}
+
+func validateCloudCostAutocompleteField(field string) (string, error) {
+	if field == "" {
+		return "", fmt.Errorf("field is required")
+	}
+
+	f := strings.ToLower(field)
+	if f == "label" {
+		return f, nil
+	}
+	if strings.HasPrefix(f, "label:") {
+		_, labelKey, _ := strings.Cut(f, ":")
+		return "label:" + labelKey, nil
+	}
+
+	property, err := opencost.ParseCloudCostProperty(field)
+	if err != nil {
+		return "", err
+	}
+	return string(property), nil
+}
+
+func cloudCostAutocompleteValues(cc *opencost.CloudCost, field string) []string {
+	if field == "label" {
+		keys := make([]string, 0, len(cc.Properties.Labels))
+		for label := range cc.Properties.Labels {
+			keys = append(keys, label)
+		}
+		return keys
+	}
+	if strings.HasPrefix(field, "label:") {
+		labelName := strings.TrimPrefix(field, "label:")
+		if value, ok := cloudCostLabelValueFold(cc.Properties.Labels, labelName); ok {
+			return []string{value}
+		}
+		return nil
+	}
+
+	property, err := opencost.ParseCloudCostProperty(field)
+	if err != nil {
+		return nil
+	}
+
+	value, err := cc.StringProperty(string(property))
+	if err != nil {
+		return nil
+	}
+
+	return []string{value}
+}
+
+func cloudCostLabelValueFold(labels map[string]string, key string) (string, bool) {
+	if v, ok := labels[key]; ok {
+		return v, true
+	}
+	for k, v := range labels {
+		if strings.EqualFold(k, key) {
+			return v, true
+		}
+	}
+	return "", false
+}
+
 func (rq *RepositoryQuerier) QueryViewGraph(ctx context.Context, request ViewQueryRequest) (ViewGraphData, error) {
 	ccasr, err := rq.Query(ctx, request.QueryRequest)
 	if err != nil {

+ 122 - 0
pkg/costmodel/autocomplete.go

@@ -0,0 +1,122 @@
+package costmodel
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/julienschmidt/httprouter"
+	"github.com/opencost/opencost/core/pkg/filter"
+	allocationfilter "github.com/opencost/opencost/core/pkg/filter/allocation"
+	assetfilter "github.com/opencost/opencost/core/pkg/filter/asset"
+	"github.com/opencost/opencost/core/pkg/opencost"
+	"github.com/opencost/opencost/core/pkg/util/httputil"
+	"github.com/opencost/opencost/pkg/allocation"
+	"github.com/opencost/opencost/pkg/asset"
+	"github.com/opencost/opencost/pkg/env"
+)
+
+func (a *Accesses) ComputeAllocationAutocompleteHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	w.Header().Set("Content-Type", "application/json")
+	qp := httputil.NewQueryParams(r.URL.Query())
+
+	window, err := opencost.ParseWindowWithOffset(qp.Get("window", ""), env.GetParsedUTCOffset())
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Invalid 'window' parameter: %s", err), http.StatusBadRequest)
+		return
+	}
+
+	var parsedFilter filter.Filter
+	filterString := qp.Get("filter", "")
+	if filterString != "" {
+		parser := allocationfilter.NewAllocationFilterParser()
+		parsedFilter, err = parser.Parse(filterString)
+		if err != nil {
+			http.Error(w, fmt.Sprintf("Invalid 'filter' parameter: %s", err), http.StatusBadRequest)
+			return
+		}
+	}
+
+	resp, err := a.QueryAllocationAutocomplete(allocation.AllocationAutocompleteRequest{
+		Search:      qp.Get("search", ""),
+		Field:       qp.Get("field", ""),
+		Limit:       qp.GetInt("limit", 0),
+		Window:      window,
+		Filter:      parsedFilter,
+		LabelConfig: opencost.NewLabelConfig(),
+	}, filterString, r.Context())
+	if err != nil {
+		status := http.StatusInternalServerError
+		if allocation.IsAutocompleteBadRequest(err) {
+			status = http.StatusBadRequest
+		}
+		http.Error(w, fmt.Sprintf("Error getting allocation autocomplete: %s", err), status)
+		return
+	}
+
+	WriteData(w, resp, nil)
+}
+
+func (a *Accesses) QueryAllocationAutocomplete(req allocation.AllocationAutocompleteRequest, filterString string, ctx context.Context) (*allocation.AllocationAutocompleteResponse, error) {
+	asr, err := a.Model.QueryAllocation(req.Window, req.Window.Duration(), nil, false, false, false, false, false, opencost.AccumulateOptionNone, false, filterString)
+	if err != nil {
+		return nil, fmt.Errorf("error querying allocations: %w", err)
+	}
+	return allocation.QueryAllocationAutocompleteFromSetRange(asr, req)
+}
+
+func (a *Accesses) ComputeAssetsAutocompleteHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	w.Header().Set("Content-Type", "application/json")
+	qp := httputil.NewQueryParams(r.URL.Query())
+
+	window, err := opencost.ParseWindowWithOffset(qp.Get("window", ""), env.GetParsedUTCOffset())
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Invalid 'window' parameter: %s", err), http.StatusBadRequest)
+		return
+	}
+	if window.IsOpen() || window.Start() == nil || window.End() == nil {
+		http.Error(w, fmt.Sprintf("Invalid 'window' parameter: %s", window.String()), http.StatusBadRequest)
+		return
+	}
+
+	var parsedFilter filter.Filter
+	filterString := qp.Get("filter", "")
+	if filterString != "" {
+		parser := assetfilter.NewAssetFilterParser()
+		parsedFilter, err = parser.Parse(filterString)
+		if err != nil {
+			http.Error(w, fmt.Sprintf("Invalid 'filter' parameter: %s", err), http.StatusBadRequest)
+			return
+		}
+	}
+
+	resp, err := a.QueryAssetAutocomplete(asset.AssetAutocompleteRequest{
+		TenantID: qp.Get("tenantId", "opencost"),
+		Search:   qp.Get("search", ""),
+		Field:    qp.Get("field", ""),
+		Limit:    qp.GetInt("limit", 0),
+		Window:   window,
+		Filter:   parsedFilter,
+	}, r.Context())
+	if err != nil {
+		status := http.StatusInternalServerError
+		if asset.IsAutocompleteBadRequest(err) {
+			status = http.StatusBadRequest
+		}
+		http.Error(w, fmt.Sprintf("Error getting asset autocomplete: %s", err), status)
+		return
+	}
+
+	WriteData(w, resp, nil)
+}
+
+func (a *Accesses) QueryAssetAutocomplete(req asset.AssetAutocompleteRequest, ctx context.Context) (*asset.AssetAutocompleteResponse, error) {
+	if req.Window.IsOpen() || req.Window.Start() == nil || req.Window.End() == nil {
+		return nil, fmt.Errorf("%w: invalid window: %s", asset.ErrAutocompleteBadRequest, req.Window.String())
+	}
+	assetSet, err := a.Model.ComputeAssets(*req.Window.Start(), *req.Window.End())
+	if err != nil {
+		return nil, fmt.Errorf("error computing assets: %w", err)
+	}
+	return asset.QueryAssetAutocompleteFromSet(assetSet, req)
+}

+ 3 - 0
pkg/costmodel/router.go

@@ -572,6 +572,8 @@ func Initialize(router *httprouter.Router, additionalConfigWatchers ...*watcher.
 	router.GET("/costDataModel", a.CostDataModel)
 	router.GET("/allocation/compute", a.ComputeAllocationHandler)
 	router.GET("/allocation/compute/summary", a.ComputeAllocationHandlerSummary)
+	router.GET("/allocation/autocomplete", a.ComputeAllocationAutocompleteHandler)
+	router.GET("/assets/autocomplete", a.ComputeAssetsAutocompleteHandler)
 	router.GET("/allNodePricing", a.GetAllNodePricing)
 	router.POST("/refreshPricing", a.RefreshPricingData)
 	router.GET("/managementPlatform", a.ManagementPlatform)
@@ -601,6 +603,7 @@ func InitializeCloudCost(router *httprouter.Router) *cloudcost.PipelineService {
 	cloudCostQueryService := cloudcost.NewQueryService(repoQuerier, repoQuerier)
 
 	router.GET("/cloudCost", cloudCostQueryService.GetCloudCostHandler())
+	router.GET("/cloudCost/autocomplete", cloudCostQueryService.GetCloudCostAutocompleteHandler())
 	router.GET("/cloudCost/view/graph", cloudCostQueryService.GetCloudCostViewGraphHandler())
 	router.GET("/cloudCost/view/totals", cloudCostQueryService.GetCloudCostViewTotalsHandler())
 	router.GET("/cloudCost/view/table", cloudCostQueryService.GetCloudCostViewTableHandler(nil))

+ 14 - 0
pkg/mcp/server_test.go

@@ -544,6 +544,10 @@ func (dq *dummyQuerier) Query(_ context.Context, req cloudcost.QueryRequest) (*o
 	return ccsr, nil
 }
 
+func (dq *dummyQuerier) QueryCloudCostAutocomplete(_ context.Context, _ cloudcost.CloudCostAutocompleteRequest) (*cloudcost.CloudCostAutocompleteResponse, error) {
+	return &cloudcost.CloudCostAutocompleteResponse{Data: []string{}}, nil
+}
+
 func TestBuildCloudCostQueryRequest_AccumulateParsing(t *testing.T) {
 	s := &MCPServer{}
 	req := cloudcost.QueryRequest{}
@@ -1481,6 +1485,16 @@ func (caq *contextAwareQuerier) Query(ctx context.Context, req cloudcost.QueryRe
 	}
 }
 
+func (caq *contextAwareQuerier) QueryCloudCostAutocomplete(ctx context.Context, _ cloudcost.CloudCostAutocompleteRequest) (*cloudcost.CloudCostAutocompleteResponse, error) {
+	select {
+	case <-ctx.Done():
+		caq.contextWasCancelled = true
+		return nil, ctx.Err()
+	default:
+		return &cloudcost.CloudCostAutocompleteResponse{Data: []string{}}, nil
+	}
+}
+
 func TestQueryCloudCosts_ContextCancellation(t *testing.T) {
 	// Create a context that is already cancelled
 	ctx, cancel := context.WithCancel(context.Background())