Просмотр исходного кода

Merge branch 'feature/kubemodel-2' into feature/pricing-module

Sean Holcomb 23 часов назад
Родитель
Сommit
6b6aca7608
61 измененных файлов с 7621 добавлено и 402 удалено
  1. 13 0
      core/pkg/clusters/mock.go
  2. 116 0
      core/pkg/compute/kubemodel/cluster_test.go
  3. 2 2
      core/pkg/compute/kubemodel/container.go
  4. 205 0
      core/pkg/compute/kubemodel/container_test.go
  5. 117 0
      core/pkg/compute/kubemodel/cronjob_test.go
  6. 138 0
      core/pkg/compute/kubemodel/daemonset_test.go
  7. 172 0
      core/pkg/compute/kubemodel/dcgmdevice_test.go
  8. 162 0
      core/pkg/compute/kubemodel/deployment_test.go
  9. 138 0
      core/pkg/compute/kubemodel/job_test.go
  10. 63 102
      core/pkg/compute/kubemodel/kubemodel.go
  11. 213 0
      core/pkg/compute/kubemodel/kubemodel_test.go
  12. 154 0
      core/pkg/compute/kubemodel/namespace_test.go
  13. 325 0
      core/pkg/compute/kubemodel/node_test.go
  14. 142 0
      core/pkg/compute/kubemodel/persistentvolume_test.go
  15. 155 0
      core/pkg/compute/kubemodel/persistentvolumeclaim_test.go
  16. 309 0
      core/pkg/compute/kubemodel/pod_test.go
  17. 166 0
      core/pkg/compute/kubemodel/replicaset_test.go
  18. 362 0
      core/pkg/compute/kubemodel/resourcequota_test.go
  19. 159 0
      core/pkg/compute/kubemodel/service_test.go
  20. 141 0
      core/pkg/compute/kubemodel/statefulset_test.go
  21. 11 3
      core/pkg/env/core.go
  22. 102 0
      core/pkg/env/metrics.go
  23. 12 12
      core/pkg/model/kubemodel/container.go
  24. 1 1
      core/pkg/model/kubemodel/daemonset.go
  25. 1 1
      core/pkg/model/kubemodel/dcgm.go
  26. 1 1
      core/pkg/model/kubemodel/kubemodel.go
  27. 7 160
      core/pkg/model/kubemodel/kubemodel_codecs.go
  28. 2 1
      core/pkg/model/kubemodel/kubemodel_helpers_test.go
  29. 8 8
      core/pkg/model/kubemodel/mock.go
  30. 213 0
      core/pkg/source/datasource.go
  31. 16 0
      core/pkg/source/decoders.go
  32. 1084 0
      core/pkg/source/mock.go
  33. 716 0
      core/pkg/source/noop.go
  34. 856 0
      core/pkg/source/record.go
  35. 329 0
      core/pkg/source/record_test.go
  36. 20 0
      modules/collector-source/pkg/collector/collector.go
  37. 4 0
      modules/collector-source/pkg/collector/metricsquerier.go
  38. 2 1
      modules/collector-source/pkg/metric/collector.go
  39. 4 0
      modules/collector-source/pkg/scrape/clusterinfo.go
  40. 60 6
      modules/collector-source/pkg/scrape/scrapecontroller.go
  41. 30 0
      modules/collector-source/pkg/scrape/scraper.go
  42. 226 0
      modules/collector-source/pkg/scrape/scraper_test.go
  43. 18 0
      modules/prometheus-source/pkg/prom/metricsquerier.go
  44. 8 4
      pkg/cloud/aws/provider.go
  45. 5 1
      pkg/cloud/azure/pricesheetdownloader.go
  46. 79 27
      pkg/cloud/azure/provider.go
  47. 209 13
      pkg/cloud/azure/provider_test.go
  48. 6 1
      pkg/cloud/gcp/provider.go
  49. 3 2
      pkg/cloud/gcp/provider_test.go
  50. 78 0
      pkg/cloud/httputil/httputil.go
  51. 130 0
      pkg/cloud/httputil/httputil_test.go
  52. 5 2
      pkg/cloud/oracle/ratecard.go
  53. 4 1
      pkg/cloud/otc/pricingapi.go
  54. 2 1
      pkg/costmodel/costmodel.go
  55. 6 5
      pkg/costmodel/metrics.go
  56. 11 7
      pkg/costmodel/router.go
  57. 17 1
      pkg/customcost/ingestor.go
  58. 74 0
      pkg/customcost/ingestor_test.go
  59. 0 33
      pkg/env/costmodel.go
  60. 1 1
      pkg/kubemodel/pipeline.go
  61. 8 5
      pkg/metrics/kubemodel.go

+ 13 - 0
core/pkg/clusters/mock.go

@@ -0,0 +1,13 @@
+package clusters
+
+type MockClusterInfoProvider struct {
+	clusterInfo map[string]string
+}
+
+func NewMockClusterInfoProvider(clusterInfo map[string]string) *MockClusterInfoProvider {
+	return &MockClusterInfoProvider{clusterInfo: clusterInfo}
+}
+
+func (m *MockClusterInfoProvider) GetClusterInfo() map[string]string {
+	return m.clusterInfo
+}

+ 116 - 0
core/pkg/compute/kubemodel/cluster_test.go

@@ -0,0 +1,116 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/model/shared"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func TestComputeCluster(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+
+	tests := []struct {
+		name      string
+		overrides map[string]any
+		want      *kubemodel.Cluster
+		wantErr   bool
+	}{
+		{
+			name:      "no data returns error",
+			overrides: map[string]any{},
+			wantErr:   true,
+		},
+		{
+			name: "basic cluster info and uptime",
+			overrides: map[string]any{
+				source.QueryClusterInfo: []*source.ClusterInfoResult{
+					{UID: testClusterUID, Cluster: "my-cluster"},
+				},
+				source.QueryClusterUptime: []*source.UptimeResult{
+					{UID: testClusterUID, First: start, Last: end},
+				},
+			},
+			want: &kubemodel.Cluster{
+				UID:   testClusterUID,
+				Name:  "my-cluster",
+				Start: start,
+				End:   end,
+			},
+		},
+		{
+			name: "cluster with provider, account, and region",
+			overrides: map[string]any{
+				source.QueryClusterInfo: []*source.ClusterInfoResult{
+					{UID: testClusterUID, Cluster: "prod-cluster", Provider: "aws", AccountID: "123456789", Region: "us-east-1"},
+				},
+				source.QueryClusterUptime: []*source.UptimeResult{
+					{UID: testClusterUID, First: start, Last: end},
+				},
+			},
+			want: &kubemodel.Cluster{
+				UID:      testClusterUID,
+				Name:     "prod-cluster",
+				Provider: shared.ProviderAWS,
+				Account:  "123456789",
+				Region:   "us-east-1",
+				Start:    start,
+				End:      end,
+			},
+		},
+		{
+			name: "cluster without uptime is registered with zero window but fails validation",
+			overrides: map[string]any{
+				source.QueryClusterInfo: []*source.ClusterInfoResult{
+					{UID: testClusterUID, Cluster: "my-cluster"},
+				},
+			},
+			want: nil,
+		},
+		{
+			name: "uptime for unknown cluster is ignored",
+			overrides: map[string]any{
+				source.QueryClusterInfo: []*source.ClusterInfoResult{
+					{UID: testClusterUID, Cluster: "my-cluster"},
+				},
+				source.QueryClusterUptime: []*source.UptimeResult{
+					{UID: testClusterUID, First: start, Last: end},
+					{UID: "unknown-cluster", First: start, Last: end},
+				},
+			},
+			want: &kubemodel.Cluster{
+				UID:   testClusterUID,
+				Name:  "my-cluster",
+				Start: start,
+				End:   end,
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			for method, result := range tt.overrides {
+				ds.Querier.SetOverride(method, result)
+			}
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			if tt.wantErr {
+				require.Error(t, err)
+				return
+			}
+			require.NoError(t, err)
+			assert.Equal(t, tt.want, kms.Cluster)
+		})
+	}
+}

+ 2 - 2
core/pkg/compute/kubemodel/container.go

@@ -92,7 +92,7 @@ func (km *KubeModel) computeContainers(kms *kubemodel.KubeModelSet, start, end t
 			continue
 		}
 		if len(res.Data) > 0 {
-			container.CPUCoresAllocated = res.Data[0].Value
+			container.CPUCoreAllocationAvg = res.Data[0].Value
 		}
 	}
 
@@ -105,7 +105,7 @@ func (km *KubeModel) computeContainers(kms *kubemodel.KubeModelSet, start, end t
 			continue
 		}
 		if len(res.Data) > 0 {
-			container.RAMBytesAllocated = res.Data[0].Value
+			container.RAMBytesAllocationAvg = res.Data[0].Value
 		}
 	}
 

+ 205 - 0
core/pkg/compute/kubemodel/container_test.go

@@ -0,0 +1,205 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+	"github.com/opencost/opencost/core/pkg/util"
+)
+
+func TestComputeContainers(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+
+	tests := []struct {
+		name      string
+		overrides map[string]any
+		want      map[string]*kubemodel.Container
+	}{
+		{
+			name:      "no data returns empty container map",
+			overrides: map[string]any{},
+			want:      map[string]*kubemodel.Container{},
+		},
+		{
+			name: "basic container uptime",
+			overrides: map[string]any{
+				source.QueryContainerUptime: []*source.ContainerUptimeResult{
+					{UptimeResult: source.UptimeResult{UID: "pod-1", First: start, Last: end}, Container: "main"},
+				},
+			},
+			want: map[string]*kubemodel.Container{
+				"pod-1/main": {
+					PodUID:           "pod-1",
+					Name:             "main",
+					Start:            start,
+					End:              end,
+					ResourceRequests: kubemodel.ResourceQuantities{},
+					ResourceLimits:   kubemodel.ResourceQuantities{},
+				},
+			},
+		},
+		{
+			name: "multiple containers on same pod",
+			overrides: map[string]any{
+				source.QueryContainerUptime: []*source.ContainerUptimeResult{
+					{UptimeResult: source.UptimeResult{UID: "pod-1", First: start, Last: end}, Container: "main"},
+					{UptimeResult: source.UptimeResult{UID: "pod-1", First: start, Last: end}, Container: "sidecar"},
+				},
+			},
+			want: map[string]*kubemodel.Container{
+				"pod-1/main": {
+					PodUID:           "pod-1",
+					Name:             "main",
+					Start:            start,
+					End:              end,
+					ResourceRequests: kubemodel.ResourceQuantities{},
+					ResourceLimits:   kubemodel.ResourceQuantities{},
+				},
+				"pod-1/sidecar": {
+					PodUID:           "pod-1",
+					Name:             "sidecar",
+					Start:            start,
+					End:              end,
+					ResourceRequests: kubemodel.ResourceQuantities{},
+					ResourceLimits:   kubemodel.ResourceQuantities{},
+				},
+			},
+		},
+		{
+			name: "resource requests and limits are populated",
+			overrides: map[string]any{
+				source.QueryContainerUptime: []*source.ContainerUptimeResult{
+					{UptimeResult: source.UptimeResult{UID: "pod-1", First: start, Last: end}, Container: "main"},
+				},
+				source.QueryContainerResourceRequests: []*source.ContainerResourceResult{
+					{ResourceResult: source.ResourceResult{UID: "pod-1", Resource: "cpu", Unit: "cores", Value: 0.5}, Container: "main"},
+					{ResourceResult: source.ResourceResult{UID: "pod-1", Resource: "memory", Unit: "bytes", Value: 512 * 1024 * 1024}, Container: "main"},
+				},
+				source.QueryContainerResourceLimits: []*source.ContainerResourceResult{
+					{ResourceResult: source.ResourceResult{UID: "pod-1", Resource: "cpu", Unit: "cores", Value: 1.0}, Container: "main"},
+					{ResourceResult: source.ResourceResult{UID: "pod-1", Resource: "memory", Unit: "bytes", Value: 1024 * 1024 * 1024}, Container: "main"},
+				},
+			},
+			want: map[string]*kubemodel.Container{
+				"pod-1/main": {
+					PodUID: "pod-1",
+					Name:   "main",
+					Start:  start,
+					End:    end,
+					ResourceRequests: kubemodel.ResourceQuantities{
+						kubemodel.ResourceCPU: {
+							Resource: kubemodel.ResourceCPU,
+							Unit:     kubemodel.UnitCore,
+							Values:   kubemodel.Stats{kubemodel.StatAvg: 0.5},
+						},
+						kubemodel.ResourceMemory: {
+							Resource: kubemodel.ResourceMemory,
+							Unit:     kubemodel.UnitByte,
+							Values:   kubemodel.Stats{kubemodel.StatAvg: 512 * 1024 * 1024},
+						},
+					},
+					ResourceLimits: kubemodel.ResourceQuantities{
+						kubemodel.ResourceCPU: {
+							Resource: kubemodel.ResourceCPU,
+							Unit:     kubemodel.UnitCore,
+							Values:   kubemodel.Stats{kubemodel.StatAvg: 1.0},
+						},
+						kubemodel.ResourceMemory: {
+							Resource: kubemodel.ResourceMemory,
+							Unit:     kubemodel.UnitByte,
+							Values:   kubemodel.Stats{kubemodel.StatAvg: 1024 * 1024 * 1024},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "cpu and ram allocation and usage are populated",
+			overrides: map[string]any{
+				source.QueryContainerUptime: []*source.ContainerUptimeResult{
+					{UptimeResult: source.UptimeResult{UID: "pod-1", First: start, Last: end}, Container: "main"},
+				},
+				source.QueryCPUCoresAllocated: []*source.CPUCoresAllocatedResult{
+					{UID: "pod-1", Container: "main", Data: []*util.Vector{{Value: 0.25}}},
+				},
+				source.QueryRAMBytesAllocated: []*source.RAMBytesAllocatedResult{
+					{UID: "pod-1", Container: "main", Data: []*util.Vector{{Value: 256 * 1024 * 1024}}},
+				},
+				source.QueryCPUUsageAvg: []*source.CPUUsageAvgResult{
+					{UID: "pod-1", Container: "main", Data: []*util.Vector{{Value: 0.1}}},
+				},
+				source.QueryCPUUsageMax: []*source.CPUUsageMaxResult{
+					{UID: "pod-1", Container: "main", Data: []*util.Vector{{Value: 0.2}}},
+				},
+				source.QueryRAMUsageAvg: []*source.RAMUsageAvgResult{
+					{UID: "pod-1", Container: "main", Data: []*util.Vector{{Value: 128 * 1024 * 1024}}},
+				},
+				source.QueryRAMUsageMax: []*source.RAMUsageMaxResult{
+					{UID: "pod-1", Container: "main", Data: []*util.Vector{{Value: 200 * 1024 * 1024}}},
+				},
+			},
+			want: map[string]*kubemodel.Container{
+				"pod-1/main": {
+					PodUID:                "pod-1",
+					Name:                  "main",
+					Start:                 start,
+					End:                   end,
+					ResourceRequests:      kubemodel.ResourceQuantities{},
+					ResourceLimits:        kubemodel.ResourceQuantities{},
+					CPUCoreAllocationAvg:  0.25,
+					RAMBytesAllocationAvg: 256 * 1024 * 1024,
+					CPUCoreUsageAvg:       0.1,
+					CPUCoreUsageMax:       0.2,
+					RAMBytesUsageAvg:      128 * 1024 * 1024,
+					RAMBytesUsageMax:      200 * 1024 * 1024,
+				},
+			},
+		},
+		{
+			name: "resource requests for unknown container are ignored",
+			overrides: map[string]any{
+				source.QueryContainerUptime: []*source.ContainerUptimeResult{
+					{UptimeResult: source.UptimeResult{UID: "pod-1", First: start, Last: end}, Container: "main"},
+				},
+				source.QueryContainerResourceRequests: []*source.ContainerResourceResult{
+					{ResourceResult: source.ResourceResult{UID: "pod-1", Resource: "cpu", Unit: "cores", Value: 0.5}, Container: "unknown-container"},
+				},
+			},
+			want: map[string]*kubemodel.Container{
+				"pod-1/main": {
+					PodUID:           "pod-1",
+					Name:             "main",
+					Start:            start,
+					End:              end,
+					ResourceRequests: kubemodel.ResourceQuantities{},
+					ResourceLimits:   kubemodel.ResourceQuantities{},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			seedCluster(ds, start, end)
+			for method, result := range tt.overrides {
+				ds.Querier.SetOverride(method, result)
+			}
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.Containers)
+		})
+	}
+}

+ 117 - 0
core/pkg/compute/kubemodel/cronjob_test.go

@@ -0,0 +1,117 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func TestComputeCronJobs(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+
+	tests := []struct {
+		name      string
+		overrides map[string]any
+		want      map[string]*kubemodel.CronJob
+	}{
+		{
+			name:      "no data returns empty cronjob map",
+			overrides: map[string]any{},
+			want:      map[string]*kubemodel.CronJob{},
+		},
+		{
+			name: "basic cronjob info and uptime",
+			overrides: map[string]any{
+				source.QueryCronJobInfo: []*source.CronJobInfoResult{
+					{UID: "cj-1", CronJob: "nightly-backup", NamespaceUID: "ns-1"},
+				},
+				source.QueryCronJobUptime: []*source.UptimeResult{
+					{UID: "cj-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.CronJob{
+				"cj-1": {
+					UID:          "cj-1",
+					Name:         "nightly-backup",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+		{
+			name: "cronjob without uptime is not registered",
+			overrides: map[string]any{
+				source.QueryCronJobInfo: []*source.CronJobInfoResult{
+					{UID: "cj-1", CronJob: "nightly-backup", NamespaceUID: "ns-1"},
+				},
+			},
+			want: map[string]*kubemodel.CronJob{},
+		},
+		{
+			name: "cronjob without namespace uid is not registered",
+			overrides: map[string]any{
+				source.QueryCronJobInfo: []*source.CronJobInfoResult{
+					{UID: "cj-1", CronJob: "nightly-backup"},
+				},
+				source.QueryCronJobUptime: []*source.UptimeResult{
+					{UID: "cj-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.CronJob{},
+		},
+		{
+			name: "cronjob labels and annotations are attached",
+			overrides: map[string]any{
+				source.QueryCronJobInfo: []*source.CronJobInfoResult{
+					{UID: "cj-1", CronJob: "nightly-backup", NamespaceUID: "ns-1"},
+				},
+				source.QueryCronJobUptime: []*source.UptimeResult{
+					{UID: "cj-1", First: start, Last: end},
+				},
+				source.QueryCronJobLabels: []*source.LabelsResult{
+					{UID: "cj-1", Labels: map[string]string{"schedule": "nightly"}},
+				},
+				source.QueryCronJobAnnotations: []*source.AnnotationsResult{
+					{UID: "cj-1", Annotations: map[string]string{"owner": "ops"}},
+				},
+			},
+			want: map[string]*kubemodel.CronJob{
+				"cj-1": {
+					UID:          "cj-1",
+					Name:         "nightly-backup",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					Labels:       map[string]string{"schedule": "nightly"},
+					Annotations:  map[string]string{"owner": "ops"},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			seedCluster(ds, start, end)
+			for method, result := range tt.overrides {
+				ds.Querier.SetOverride(method, result)
+			}
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.CronJobs)
+		})
+	}
+}

+ 138 - 0
core/pkg/compute/kubemodel/daemonset_test.go

@@ -0,0 +1,138 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func TestComputeDaemonSets(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+
+	tests := []struct {
+		name      string
+		overrides map[string]any
+		want      map[string]*kubemodel.DaemonSet
+	}{
+		{
+			name:      "no data returns empty daemonset map",
+			overrides: map[string]any{},
+			want:      map[string]*kubemodel.DaemonSet{},
+		},
+		{
+			name: "basic daemonset info and uptime",
+			overrides: map[string]any{
+				source.QueryDaemonSetInfo: []*source.DaemonSetInfoResult{
+					{UID: "ds-1", DaemonSet: "fluentd", NamespaceUID: "ns-1"},
+				},
+				source.QueryDaemonSetUptime: []*source.UptimeResult{
+					{UID: "ds-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.DaemonSet{
+				"ds-1": {
+					UID:          "ds-1",
+					Name:         "fluentd",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+		{
+			name: "daemonset without uptime is not registered",
+			overrides: map[string]any{
+				source.QueryDaemonSetInfo: []*source.DaemonSetInfoResult{
+					{UID: "ds-1", DaemonSet: "fluentd", NamespaceUID: "ns-1"},
+				},
+			},
+			want: map[string]*kubemodel.DaemonSet{},
+		},
+		{
+			name: "daemonset without namespace uid is not registered",
+			overrides: map[string]any{
+				source.QueryDaemonSetInfo: []*source.DaemonSetInfoResult{
+					{UID: "ds-1", DaemonSet: "fluentd"},
+				},
+				source.QueryDaemonSetUptime: []*source.UptimeResult{
+					{UID: "ds-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.DaemonSet{},
+		},
+		{
+			name: "daemonset labels and annotations are attached",
+			overrides: map[string]any{
+				source.QueryDaemonSetInfo: []*source.DaemonSetInfoResult{
+					{UID: "ds-1", DaemonSet: "fluentd", NamespaceUID: "ns-1"},
+				},
+				source.QueryDaemonSetUptime: []*source.UptimeResult{
+					{UID: "ds-1", First: start, Last: end},
+				},
+				source.QueryDaemonSetLabels: []*source.LabelsResult{
+					{UID: "ds-1", Labels: map[string]string{"component": "logging"}},
+				},
+				source.QueryDaemonSetAnnotations: []*source.AnnotationsResult{
+					{UID: "ds-1", Annotations: map[string]string{"managed-by": "helm"}},
+				},
+			},
+			want: map[string]*kubemodel.DaemonSet{
+				"ds-1": {
+					UID:          "ds-1",
+					Name:         "fluentd",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					Labels:       map[string]string{"component": "logging"},
+					Annotations:  map[string]string{"managed-by": "helm"},
+				},
+			},
+		},
+		{
+			name: "uptime for unknown daemonset is ignored",
+			overrides: map[string]any{
+				source.QueryDaemonSetInfo: []*source.DaemonSetInfoResult{
+					{UID: "ds-1", DaemonSet: "fluentd", NamespaceUID: "ns-1"},
+				},
+				source.QueryDaemonSetUptime: []*source.UptimeResult{
+					{UID: "ds-1", First: start, Last: end},
+					{UID: "unknown-ds", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.DaemonSet{
+				"ds-1": {
+					UID:          "ds-1",
+					Name:         "fluentd",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			seedCluster(ds, start, end)
+			for method, result := range tt.overrides {
+				ds.Querier.SetOverride(method, result)
+			}
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.DaemonSets)
+		})
+	}
+}

+ 172 - 0
core/pkg/compute/kubemodel/dcgmdevice_test.go

@@ -0,0 +1,172 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func TestComputeDCGMDevices(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+
+	tests := []struct {
+		name      string
+		overrides map[string]any
+		want      map[string]*kubemodel.DCGMDevice
+	}{
+		{
+			name:      "no data returns empty dcgm device map",
+			overrides: map[string]any{},
+			want:      map[string]*kubemodel.DCGMDevice{},
+		},
+		{
+			name: "basic dcgm device info and uptime",
+			overrides: map[string]any{
+				source.QueryDCGMDeviceInfo: []*source.DCGMDeviceInfoResult{
+					{UUID: "GPU-abc123", Device: "nvidia0", ModelName: "A100"},
+				},
+				source.QueryDCGMDeviceUptime: []*source.DCGMDeviceUptimeResult{
+					{UUID: "GPU-abc123", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.DCGMDevice{
+				"GPU-abc123": {
+					UUID:      "GPU-abc123",
+					Device:    "nvidia0",
+					ModelName: "A100",
+					Start:     start,
+					End:       end,
+					PodUsages: map[string]kubemodel.DCGMPod{},
+				},
+			},
+		},
+		{
+			name: "dcgm device without uptime is not registered",
+			overrides: map[string]any{
+				source.QueryDCGMDeviceInfo: []*source.DCGMDeviceInfoResult{
+					{UUID: "GPU-abc123", Device: "nvidia0", ModelName: "A100"},
+				},
+			},
+			want: map[string]*kubemodel.DCGMDevice{},
+		},
+		{
+			name: "dcgm device with empty uuid is skipped",
+			overrides: map[string]any{
+				source.QueryDCGMDeviceInfo: []*source.DCGMDeviceInfoResult{
+					{UUID: "", Device: "nvidia0", ModelName: "A100"},
+				},
+				source.QueryDCGMDeviceUptime: []*source.DCGMDeviceUptimeResult{
+					{UUID: "GPU-abc123", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.DCGMDevice{},
+		},
+		{
+			name: "dcgm container usage avg and max are populated",
+			overrides: map[string]any{
+				source.QueryDCGMDeviceInfo: []*source.DCGMDeviceInfoResult{
+					{UUID: "GPU-abc123", Device: "nvidia0", ModelName: "A100"},
+				},
+				source.QueryDCGMDeviceUptime: []*source.DCGMDeviceUptimeResult{
+					{UUID: "GPU-abc123", First: start, Last: end},
+				},
+				source.QueryDCGMContainerUsageAvg: []*source.DCGMDeviceContainerUsageResult{
+					{UUID: "GPU-abc123", PodUID: "pod-1", Container: "training", Value: 0.75},
+				},
+				source.QueryDCGMContainerUsageMax: []*source.DCGMDeviceContainerUsageResult{
+					{UUID: "GPU-abc123", PodUID: "pod-1", Container: "training", Value: 0.95},
+				},
+			},
+			want: map[string]*kubemodel.DCGMDevice{
+				"GPU-abc123": {
+					UUID:      "GPU-abc123",
+					Device:    "nvidia0",
+					ModelName: "A100",
+					Start:     start,
+					End:       end,
+					PodUsages: map[string]kubemodel.DCGMPod{
+						"pod-1": {
+							ContainerUsages: map[string]kubemodel.DCGMContainer{
+								"training": {UsageAvg: 0.75, UsageMax: 0.95},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "usage with empty pod uid or container is ignored",
+			overrides: map[string]any{
+				source.QueryDCGMDeviceInfo: []*source.DCGMDeviceInfoResult{
+					{UUID: "GPU-abc123", Device: "nvidia0", ModelName: "A100"},
+				},
+				source.QueryDCGMDeviceUptime: []*source.DCGMDeviceUptimeResult{
+					{UUID: "GPU-abc123", First: start, Last: end},
+				},
+				source.QueryDCGMContainerUsageAvg: []*source.DCGMDeviceContainerUsageResult{
+					{UUID: "GPU-abc123", PodUID: "", Container: "training", Value: 0.5},
+					{UUID: "GPU-abc123", PodUID: "pod-1", Container: "", Value: 0.5},
+				},
+			},
+			want: map[string]*kubemodel.DCGMDevice{
+				"GPU-abc123": {
+					UUID:      "GPU-abc123",
+					Device:    "nvidia0",
+					ModelName: "A100",
+					Start:     start,
+					End:       end,
+					PodUsages: map[string]kubemodel.DCGMPod{},
+				},
+			},
+		},
+		{
+			name: "duplicate device info entries use first occurrence",
+			overrides: map[string]any{
+				source.QueryDCGMDeviceInfo: []*source.DCGMDeviceInfoResult{
+					{UUID: "GPU-abc123", Device: "nvidia0", ModelName: "A100"},
+					{UUID: "GPU-abc123", Device: "nvidia0-dup", ModelName: "A100-dup"},
+				},
+				source.QueryDCGMDeviceUptime: []*source.DCGMDeviceUptimeResult{
+					{UUID: "GPU-abc123", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.DCGMDevice{
+				"GPU-abc123": {
+					UUID:      "GPU-abc123",
+					Device:    "nvidia0",
+					ModelName: "A100",
+					Start:     start,
+					End:       end,
+					PodUsages: map[string]kubemodel.DCGMPod{},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			seedCluster(ds, start, end)
+			for method, result := range tt.overrides {
+				ds.Querier.SetOverride(method, result)
+			}
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms := kubemodel.NewKubeModelSet(start, end)
+
+			err = km.computeDCGMDevices(kms, start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.DCGMDevices)
+		})
+	}
+}

+ 162 - 0
core/pkg/compute/kubemodel/deployment_test.go

@@ -0,0 +1,162 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func TestComputeDeployments(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+
+	tests := []struct {
+		name      string
+		overrides map[string]any
+		want      map[string]*kubemodel.Deployment
+	}{
+		{
+			name:      "no data returns empty deployment map",
+			overrides: map[string]any{},
+			want:      map[string]*kubemodel.Deployment{},
+		},
+		{
+			name: "basic deployment info and uptime",
+			overrides: map[string]any{
+				source.QueryDeploymentInfo: []*source.DeploymentInfoResult{
+					{UID: "dep-1", Deployment: "my-app", NamespaceUID: "ns-1"},
+				},
+				source.QueryDeploymentUptime: []*source.UptimeResult{
+					{UID: "dep-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Deployment{
+				"dep-1": {
+					UID:          "dep-1",
+					Name:         "my-app",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+		{
+			name: "deployment without uptime is not registered",
+			overrides: map[string]any{
+				source.QueryDeploymentInfo: []*source.DeploymentInfoResult{
+					{UID: "dep-1", Deployment: "my-app", NamespaceUID: "ns-1"},
+				},
+			},
+			want: map[string]*kubemodel.Deployment{},
+		},
+		{
+			name: "deployment without namespace uid is not registered",
+			overrides: map[string]any{
+				source.QueryDeploymentInfo: []*source.DeploymentInfoResult{
+					{UID: "dep-1", Deployment: "my-app"},
+				},
+				source.QueryDeploymentUptime: []*source.UptimeResult{
+					{UID: "dep-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Deployment{},
+		},
+		{
+			name: "deployment labels and annotations are attached",
+			overrides: map[string]any{
+				source.QueryDeploymentInfo: []*source.DeploymentInfoResult{
+					{UID: "dep-1", Deployment: "my-app", NamespaceUID: "ns-1"},
+				},
+				source.QueryDeploymentUptime: []*source.UptimeResult{
+					{UID: "dep-1", First: start, Last: end},
+				},
+				source.QueryDeploymentLabels: []*source.LabelsResult{
+					{UID: "dep-1", Labels: map[string]string{"app": "web"}},
+				},
+				source.QueryDeploymentAnnotations: []*source.AnnotationsResult{
+					{UID: "dep-1", Annotations: map[string]string{"team": "platform"}},
+				},
+			},
+			want: map[string]*kubemodel.Deployment{
+				"dep-1": {
+					UID:          "dep-1",
+					Name:         "my-app",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					Labels:       map[string]string{"app": "web"},
+					Annotations:  map[string]string{"team": "platform"},
+				},
+			},
+		},
+		{
+			name: "deployment match labels are attached",
+			overrides: map[string]any{
+				source.QueryDeploymentInfo: []*source.DeploymentInfoResult{
+					{UID: "dep-1", Deployment: "my-app", NamespaceUID: "ns-1"},
+				},
+				source.QueryDeploymentUptime: []*source.UptimeResult{
+					{UID: "dep-1", First: start, Last: end},
+				},
+				source.QueryDeploymentMatchLabels: []*source.DeploymentLabelsResult{
+					{UID: "dep-1", Labels: map[string]string{"app": "web", "tier": "frontend"}},
+				},
+			},
+			want: map[string]*kubemodel.Deployment{
+				"dep-1": {
+					UID:          "dep-1",
+					Name:         "my-app",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					MatchLabels:  map[string]string{"app": "web", "tier": "frontend"},
+				},
+			},
+		},
+		{
+			name: "uptime for unknown deployment is ignored",
+			overrides: map[string]any{
+				source.QueryDeploymentInfo: []*source.DeploymentInfoResult{
+					{UID: "dep-1", Deployment: "my-app", NamespaceUID: "ns-1"},
+				},
+				source.QueryDeploymentUptime: []*source.UptimeResult{
+					{UID: "dep-1", First: start, Last: end},
+					{UID: "unknown-dep", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Deployment{
+				"dep-1": {
+					UID:          "dep-1",
+					Name:         "my-app",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			seedCluster(ds, start, end)
+			for method, result := range tt.overrides {
+				ds.Querier.SetOverride(method, result)
+			}
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.Deployments)
+		})
+	}
+}

+ 138 - 0
core/pkg/compute/kubemodel/job_test.go

@@ -0,0 +1,138 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func TestComputeJobs(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+
+	tests := []struct {
+		name      string
+		overrides map[string]any
+		want      map[string]*kubemodel.Job
+	}{
+		{
+			name:      "no data returns empty job map",
+			overrides: map[string]any{},
+			want:      map[string]*kubemodel.Job{},
+		},
+		{
+			name: "basic job info and uptime",
+			overrides: map[string]any{
+				source.QueryJobInfo: []*source.JobInfoResult{
+					{UID: "job-1", Job: "batch-processor", NamespaceUID: "ns-1"},
+				},
+				source.QueryJobUptime: []*source.UptimeResult{
+					{UID: "job-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Job{
+				"job-1": {
+					UID:          "job-1",
+					Name:         "batch-processor",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+		{
+			name: "job without uptime is not registered",
+			overrides: map[string]any{
+				source.QueryJobInfo: []*source.JobInfoResult{
+					{UID: "job-1", Job: "batch-processor", NamespaceUID: "ns-1"},
+				},
+			},
+			want: map[string]*kubemodel.Job{},
+		},
+		{
+			name: "job without namespace uid is not registered",
+			overrides: map[string]any{
+				source.QueryJobInfo: []*source.JobInfoResult{
+					{UID: "job-1", Job: "batch-processor"},
+				},
+				source.QueryJobUptime: []*source.UptimeResult{
+					{UID: "job-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Job{},
+		},
+		{
+			name: "job labels and annotations are attached",
+			overrides: map[string]any{
+				source.QueryJobInfo: []*source.JobInfoResult{
+					{UID: "job-1", Job: "etl-job", NamespaceUID: "ns-1"},
+				},
+				source.QueryJobUptime: []*source.UptimeResult{
+					{UID: "job-1", First: start, Last: end},
+				},
+				source.QueryJobLabels: []*source.LabelsResult{
+					{UID: "job-1", Labels: map[string]string{"batch": "nightly"}},
+				},
+				source.QueryJobAnnotations: []*source.AnnotationsResult{
+					{UID: "job-1", Annotations: map[string]string{"schedule": "0 2 * * *"}},
+				},
+			},
+			want: map[string]*kubemodel.Job{
+				"job-1": {
+					UID:          "job-1",
+					Name:         "etl-job",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					Labels:       map[string]string{"batch": "nightly"},
+					Annotations:  map[string]string{"schedule": "0 2 * * *"},
+				},
+			},
+		},
+		{
+			name: "uptime for unknown job is ignored",
+			overrides: map[string]any{
+				source.QueryJobInfo: []*source.JobInfoResult{
+					{UID: "job-1", Job: "batch-processor", NamespaceUID: "ns-1"},
+				},
+				source.QueryJobUptime: []*source.UptimeResult{
+					{UID: "job-1", First: start, Last: end},
+					{UID: "unknown-job", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Job{
+				"job-1": {
+					UID:          "job-1",
+					Name:         "batch-processor",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			seedCluster(ds, start, end)
+			for method, result := range tt.overrides {
+				ds.Querier.SetOverride(method, result)
+			}
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.Jobs)
+		})
+	}
+}

+ 63 - 102
core/pkg/compute/kubemodel/kubemodel.go

@@ -10,20 +10,20 @@ import (
 	"github.com/opencost/opencost/core/pkg/source"
 )
 
-const logTimeFmt string = "2006-01-02T15:04:05"
-
 type KubeModel struct {
 	ds         source.OpenCostDataSource
+	forceV1    bool
 	clusterUID string
 }
 
-func NewKubeModel(clusterUID string, dataSource source.OpenCostDataSource) (*KubeModel, error) {
+func NewKubeModel(clusterUID string, forceV1 bool, dataSource source.OpenCostDataSource) (*KubeModel, error) {
 	if dataSource == nil {
 		return nil, errors.New("OpenCostDataSource cannot be nil")
 	}
 
 	km := &KubeModel{
 		ds:         dataSource,
+		forceV1:    forceV1,
 		clusterUID: clusterUID,
 	}
 
@@ -34,115 +34,76 @@ func NewKubeModel(clusterUID string, dataSource source.OpenCostDataSource) (*Kub
 	return km, nil
 }
 
+type computeFunc func(*kubemodel.KubeModelSet, time.Time, time.Time) error
+
 // ComputeKubeModel uses the CostModel instance to compute an KubeModelSet
 // for the window defined by the given start and end times. The KubeModels
 // returned are unaggregated (i.e. down to the container level).
 func (km *KubeModel) ComputeKubeModelSet(start, end time.Time) (*kubemodel.KubeModelSet, error) {
-	// 1. Initialize new KubeModelSet for requested Window
 	kms := kubemodel.NewKubeModelSet(start, end)
 
-	// 2. Query CostModel for each set of objects
-	var err error
-
-	// 2.1 Compute Cluster
-	err = km.computeCluster(kms, start, end)
-	if err != nil {
-		kms.Error(err)
-		return kms, fmt.Errorf("error computing kubemodel.Cluster for (%s, %s): %w", start.Format(logTimeFmt), end.Format(logTimeFmt), err)
-	}
-
-	// 2.2 Compute Nodes
-	err = km.computeNodes(kms, start, end)
-	if err != nil {
-		kms.Error(err)
-	}
-
-	// 2.3 Compute Namespaces
-	err = km.computeNamespaces(kms, start, end)
-	if err != nil {
-		kms.Error(err)
-	}
-
-	// 2.5 Compute Pods
-	err = km.computePods(kms, start, end)
-	if err != nil {
-		kms.Error(err)
-	}
-
-	// 2.6 Compute Deployments
-	err = km.computeDeployments(kms, start, end)
-	if err != nil {
-		kms.Error(err)
-	}
+	computeFuncs := km.computeFuncs(start, end)
 
-	// 2.7 Compute StatefulSets
-	err = km.computeStatefulSets(kms, start, end)
-	if err != nil {
-		kms.Error(err)
+	for _, f := range computeFuncs {
+		if err := f(kms, start, end); err != nil {
+			kms.Error(err)
+			return kms, fmt.Errorf("error computing kubemodel for (%s, %s): %w", start.Format(time.DateTime), end.Format(time.DateTime), err)
+		}
 	}
 
-	// 2.8 Compute DaemonSets
-	err = km.computeDaemonSets(kms, start, end)
-	if err != nil {
-		kms.Error(err)
-	}
-
-	// 2.9 Compute Jobs
-	err = km.computeJobs(kms, start, end)
-	if err != nil {
-		kms.Error(err)
-	}
-
-	// 2.10 Compute CronJobs
-	err = km.computeCronJobs(kms, start, end)
-	if err != nil {
-		kms.Error(err)
-	}
-
-	// 2.11 Compute ReplicaSets
-	err = km.computeReplicaSets(kms, start, end)
-	if err != nil {
-		kms.Error(err)
-	}
-
-	// 2.12 Compute Containers
-	err = km.computeContainers(kms, start, end)
-	if err != nil {
-		kms.Error(err)
-	}
-
-	// 2.13 Compute ResourceQuotas
-	err = km.computeResourceQuotas(kms, start, end)
-	if err != nil {
-		kms.Error(err)
-	}
-
-	// 2.14 Compute Services
-	err = km.computeServices(kms, start, end)
-	if err != nil {
-		kms.Error(err)
-	}
-
-	// 2.15 Compute PersistentVolumes
-	err = km.computePersistentVolumes(kms, start, end)
-	if err != nil {
-		kms.Error(err)
-	}
-
-	// 2.16 Compute PersistentVolumeClaims
-	err = km.computePersistentVolumeClaims(kms, start, end)
-	if err != nil {
-		kms.Error(err)
-	}
-
-	// 2.17 Compute DCGM Devices
-	err = km.computeDCGMDevices(kms, start, end)
-	if err != nil {
-		kms.Error(err)
-	}
-
-	// 3. Mark KubeModelSet as completed
 	kms.Metadata.CompletedAt = time.Now().UTC()
 
 	return kms, nil
 }
+
+// computeFuncs returns the set of compute functions to run for the window,
+// in struct field order. If cluster_info is not yet reporting the
+// complete_kubemodel label for any cluster in the result, the source has not
+// been upgraded to emit a full kubemodel, so only the minimal set of
+// resources (cluster, namespaces, resource quotas) is computed. The
+// FORCE_KUBEMODEL_V1 env var skips the check entirely and always returns the
+// minimal set.
+func (km *KubeModel) computeFuncs(start, end time.Time) []computeFunc {
+	KubeModelV1ComputeFucs := []computeFunc{
+		km.computeCluster,
+		km.computeNamespaces,
+		km.computeResourceQuotas,
+	}
+
+	if km.forceV1 {
+		return KubeModelV1ComputeFucs
+	}
+
+	results, err := km.ds.Metrics().QueryClusterKubeModelVersion(start, end).Await()
+	if err != nil {
+		log.Errorf("computeFuncs: querying cluster complete kubemodel: %s", err)
+		return KubeModelV1ComputeFucs
+	}
+
+	// If the window contains a result which is missing the version number then the window will produce incomplete KubeModel data
+	// and should not export
+	for _, res := range results {
+		if res.Version == "" {
+			return KubeModelV1ComputeFucs
+		}
+	}
+
+	return []computeFunc{
+		km.computeCluster,
+		km.computeNamespaces,
+		km.computeResourceQuotas,
+		km.computeServices,
+		km.computeDeployments,
+		km.computeStatefulSets,
+		km.computeDaemonSets,
+		km.computeJobs,
+		km.computeCronJobs,
+		km.computeReplicaSets,
+		km.computeNodes,
+		km.computePersistentVolumes,
+		km.computePersistentVolumeClaims,
+		km.computePods,
+		km.computeContainers,
+		//km.computeDCGMDevices,
+	}
+}

+ 213 - 0
core/pkg/compute/kubemodel/kubemodel_test.go

@@ -0,0 +1,213 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+const testClusterUID = "cluster-uid-1"
+
+func newTestWindow() (time.Time, time.Time) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	return start, start.Add(time.Hour)
+}
+
+// seedCluster sets the minimum overrides needed for computeCluster to succeed.
+func seedCluster(ds *source.MockOpenCostDataSource, start, end time.Time) {
+	ds.Querier.SetOverride(source.QueryClusterInfo, []*source.ClusterInfoResult{
+		{UID: testClusterUID, Cluster: "my-cluster", Provider: "aws"},
+	})
+	ds.Querier.SetOverride(source.QueryClusterUptime, []*source.UptimeResult{
+		{UID: testClusterUID, First: start, Last: end},
+	})
+}
+
+// ---- NewKubeModel ----
+
+func TestNewKubeModel_NilDataSource(t *testing.T) {
+	_, err := NewKubeModel(testClusterUID, false, nil)
+	require.Error(t, err)
+}
+
+// ---- ComputeKubeModelSet orchestration ----
+
+func TestComputeKubeModelSet(t *testing.T) {
+	start, end := newTestWindow()
+
+	tests := []struct {
+		name    string
+		setup   func(ds *source.MockOpenCostDataSource)
+		wantErr bool
+		check   func(t *testing.T, kms *kubemodel.KubeModelSet)
+	}{
+		{
+			name: "cluster UID not found returns error",
+			setup: func(ds *source.MockOpenCostDataSource) {
+				ds.Querier.SetOverride(source.QueryClusterInfo, []*source.ClusterInfoResult{
+					{UID: "wrong-uid", Cluster: "other"},
+				})
+			},
+			wantErr: true,
+		},
+		{
+			name: "cluster uptime missing: cluster nil but other compute functions still run",
+			setup: func(ds *source.MockOpenCostDataSource) {
+				// cluster info present but no uptime — cluster won't register, but no error
+				ds.Querier.SetOverride(source.QueryClusterInfo, []*source.ClusterInfoResult{
+					{UID: testClusterUID, Cluster: "my-cluster"},
+				})
+				ds.Querier.SetOverride(source.QueryNamespaceInfo, []*source.NamespaceInfoResult{
+					{UID: "ns-1", Namespace: "default"},
+				})
+				ds.Querier.SetOverride(source.QueryNamespaceUptime, []*source.UptimeResult{
+					{UID: "ns-1", First: start, Last: end},
+				})
+			},
+			check: func(t *testing.T, kms *kubemodel.KubeModelSet) {
+				assert.Nil(t, kms.Cluster, "cluster with no uptime should not be registered")
+				assert.NotEmpty(t, kms.Namespaces, "namespace compute should still run after cluster registration fails")
+			},
+		},
+		{
+			name: "all compute functions produce results when given data",
+			setup: func(ds *source.MockOpenCostDataSource) {
+				seedCluster(ds, start, end)
+				ds.Querier.SetOverride(source.QueryNodeInfo, []*source.NodeInfoResult{
+					{UID: "node-1", Node: "node-a"},
+				})
+				ds.Querier.SetOverride(source.QueryNodeUptime, []*source.UptimeResult{
+					{UID: "node-1", First: start, Last: end},
+				})
+				ds.Querier.SetOverride(source.QueryNamespaceInfo, []*source.NamespaceInfoResult{
+					{UID: "ns-1", Namespace: "default"},
+				})
+				ds.Querier.SetOverride(source.QueryNamespaceUptime, []*source.UptimeResult{
+					{UID: "ns-1", First: start, Last: end},
+				})
+				ds.Querier.SetOverride(source.QueryPodInfo, []*source.PodInfoResult{
+					{UID: "pod-1", Pod: "my-pod", NamespaceUID: "ns-1"},
+				})
+				ds.Querier.SetOverride(source.QueryPodUptime, []*source.UptimeResult{
+					{UID: "pod-1", First: start, Last: end},
+				})
+				ds.Querier.SetOverride(source.QueryContainerUptime, []*source.ContainerUptimeResult{
+					{UptimeResult: source.UptimeResult{UID: "pod-1", First: start, Last: end}, Container: "app"},
+				})
+				ds.Querier.SetOverride(source.QueryDeploymentInfo, []*source.DeploymentInfoResult{
+					{UID: "dep-1", Deployment: "my-dep", NamespaceUID: "ns-1"},
+				})
+				ds.Querier.SetOverride(source.QueryDeploymentUptime, []*source.UptimeResult{
+					{UID: "dep-1", First: start, Last: end},
+				})
+				ds.Querier.SetOverride(source.QueryStatefulSetInfo, []*source.StatefulSetInfoResult{
+					{UID: "sts-1", StatefulSet: "my-sts", NamespaceUID: "ns-1"},
+				})
+				ds.Querier.SetOverride(source.QueryStatefulSetUptime, []*source.UptimeResult{
+					{UID: "sts-1", First: start, Last: end},
+				})
+				ds.Querier.SetOverride(source.QueryDaemonSetInfo, []*source.DaemonSetInfoResult{
+					{UID: "ds-1", DaemonSet: "my-ds", NamespaceUID: "ns-1"},
+				})
+				ds.Querier.SetOverride(source.QueryDaemonSetUptime, []*source.UptimeResult{
+					{UID: "ds-1", First: start, Last: end},
+				})
+				ds.Querier.SetOverride(source.QueryJobInfo, []*source.JobInfoResult{
+					{UID: "job-1", Job: "my-job", NamespaceUID: "ns-1"},
+				})
+				ds.Querier.SetOverride(source.QueryJobUptime, []*source.UptimeResult{
+					{UID: "job-1", First: start, Last: end},
+				})
+				ds.Querier.SetOverride(source.QueryCronJobInfo, []*source.CronJobInfoResult{
+					{UID: "cj-1", CronJob: "my-cj", NamespaceUID: "ns-1"},
+				})
+				ds.Querier.SetOverride(source.QueryCronJobUptime, []*source.UptimeResult{
+					{UID: "cj-1", First: start, Last: end},
+				})
+				ds.Querier.SetOverride(source.QueryReplicaSetInfo, []*source.ReplicaSetInfoResult{
+					{UID: "rs-1", ReplicaSet: "my-rs", NamespaceUID: "ns-1"},
+				})
+				ds.Querier.SetOverride(source.QueryReplicaSetUptime, []*source.UptimeResult{
+					{UID: "rs-1", First: start, Last: end},
+				})
+				ds.Querier.SetOverride(source.QueryResourceQuotaInfo, []*source.ResourceQuotaInfoResult{
+					{UID: "rq-1", ResourceQuota: "default-quota", NamespaceUID: "ns-1"},
+				})
+				ds.Querier.SetOverride(source.QueryResourceQuotaUptime, []*source.UptimeResult{
+					{UID: "rq-1", First: start, Last: end},
+				})
+				ds.Querier.SetOverride(source.QueryServiceInfo, []*source.ServiceInfoResult{
+					{UID: "svc-1", Service: "my-svc", NamespaceUID: "ns-1"},
+				})
+				ds.Querier.SetOverride(source.QueryServiceUptime, []*source.UptimeResult{
+					{UID: "svc-1", First: start, Last: end},
+				})
+				ds.Querier.SetOverride(source.QueryKMPVInfo, []*source.PVInfoResult{
+					{UID: "pv-1", PersistentVolume: "my-pv"},
+				})
+				ds.Querier.SetOverride(source.QueryPVUptime, []*source.UptimeResult{
+					{UID: "pv-1", First: start, Last: end},
+				})
+				ds.Querier.SetOverride(source.QueryPVBytes, []*source.PVBytesResult{
+					{UID: "pv-1", Value: 10 * 1024 * 1024 * 1024},
+				})
+				ds.Querier.SetOverride(source.QueryKMPVCInfo, []*source.PVCInfoResult{
+					{UID: "pvc-1", PersistentVolumeClaim: "data-claim", NamespaceUID: "ns-1"},
+				})
+				ds.Querier.SetOverride(source.QueryPVCUptime, []*source.UptimeResult{
+					{UID: "pvc-1", First: start, Last: end},
+				})
+				ds.Querier.SetOverride(source.QueryDCGMDeviceInfo, []*source.DCGMDeviceInfoResult{
+					{UUID: "GPU-abc123", Device: "nvidia0", ModelName: "A100"},
+				})
+				ds.Querier.SetOverride(source.QueryDCGMDeviceUptime, []*source.DCGMDeviceUptimeResult{
+					{UUID: "GPU-abc123", First: start, Last: end},
+				})
+			},
+			check: func(t *testing.T, kms *kubemodel.KubeModelSet) {
+				assert.NotNil(t, kms.Cluster)
+				assert.NotEmpty(t, kms.Nodes)
+				assert.NotEmpty(t, kms.Namespaces)
+				assert.NotEmpty(t, kms.Pods)
+				assert.NotEmpty(t, kms.Containers)
+				assert.NotEmpty(t, kms.Deployments)
+				assert.NotEmpty(t, kms.StatefulSets)
+				assert.NotEmpty(t, kms.DaemonSets)
+				assert.NotEmpty(t, kms.Jobs)
+				assert.NotEmpty(t, kms.CronJobs)
+				assert.NotEmpty(t, kms.ReplicaSets)
+				assert.NotEmpty(t, kms.ResourceQuotas)
+				assert.NotEmpty(t, kms.Services)
+				assert.NotEmpty(t, kms.PersistentVolumes)
+				assert.NotEmpty(t, kms.PersistentVolumeClaims)
+				//assert.NotEmpty(t, kms.DCGMDevices)
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			tt.setup(ds)
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			if tt.wantErr {
+				require.Error(t, err)
+				return
+			}
+			require.NoError(t, err)
+			if tt.check != nil {
+				tt.check(t, kms)
+			}
+		})
+	}
+}

+ 154 - 0
core/pkg/compute/kubemodel/namespace_test.go

@@ -0,0 +1,154 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func TestComputeNamespaces(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+
+	tests := []struct {
+		name      string
+		overrides map[string]any
+		want      map[string]*kubemodel.Namespace
+	}{
+		{
+			name:      "no data returns empty namespace map",
+			overrides: map[string]any{},
+			want:      map[string]*kubemodel.Namespace{},
+		},
+		{
+			name: "basic namespace info and uptime",
+			overrides: map[string]any{
+				source.QueryNamespaceInfo: []*source.NamespaceInfoResult{
+					{UID: "ns-1", Namespace: "default"},
+				},
+				source.QueryNamespaceUptime: []*source.UptimeResult{
+					{UID: "ns-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Namespace{
+				"ns-1": {
+					UID:   "ns-1",
+					Name:  "default",
+					Start: start,
+					End:   end,
+				},
+			},
+		},
+		{
+			name: "namespace without uptime is not registered",
+			overrides: map[string]any{
+				source.QueryNamespaceInfo: []*source.NamespaceInfoResult{
+					{UID: "ns-1", Namespace: "default"},
+				},
+			},
+			want: map[string]*kubemodel.Namespace{},
+		},
+		{
+			name: "namespace labels are attached",
+			overrides: map[string]any{
+				source.QueryNamespaceInfo: []*source.NamespaceInfoResult{
+					{UID: "ns-1", Namespace: "production"},
+				},
+				source.QueryNamespaceUptime: []*source.UptimeResult{
+					{UID: "ns-1", First: start, Last: end},
+				},
+				source.QueryNamespaceLabels: []*source.NamespaceLabelsResult{
+					{UID: "ns-1", Labels: map[string]string{"env": "prod", "team": "platform"}},
+				},
+			},
+			want: map[string]*kubemodel.Namespace{
+				"ns-1": {
+					UID:    "ns-1",
+					Name:   "production",
+					Start:  start,
+					End:    end,
+					Labels: map[string]string{"env": "prod", "team": "platform"},
+				},
+			},
+		},
+		{
+			name: "namespace annotations are attached",
+			overrides: map[string]any{
+				source.QueryNamespaceInfo: []*source.NamespaceInfoResult{
+					{UID: "ns-1", Namespace: "staging"},
+				},
+				source.QueryNamespaceUptime: []*source.UptimeResult{
+					{UID: "ns-1", First: start, Last: end},
+				},
+				source.QueryNamespaceAnnotations: []*source.NamespaceAnnotationsResult{
+					{UID: "ns-1", Annotations: map[string]string{"owner": "team-a"}},
+				},
+			},
+			want: map[string]*kubemodel.Namespace{
+				"ns-1": {
+					UID:         "ns-1",
+					Name:        "staging",
+					Start:       start,
+					End:         end,
+					Annotations: map[string]string{"owner": "team-a"},
+				},
+			},
+		},
+		{
+			name: "multiple namespaces are registered",
+			overrides: map[string]any{
+				source.QueryNamespaceInfo: []*source.NamespaceInfoResult{
+					{UID: "ns-1", Namespace: "default"},
+					{UID: "ns-2", Namespace: "kube-system"},
+				},
+				source.QueryNamespaceUptime: []*source.UptimeResult{
+					{UID: "ns-1", First: start, Last: end},
+					{UID: "ns-2", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Namespace{
+				"ns-1": {UID: "ns-1", Name: "default", Start: start, End: end},
+				"ns-2": {UID: "ns-2", Name: "kube-system", Start: start, End: end},
+			},
+		},
+		{
+			name: "uptime for unknown namespace is ignored",
+			overrides: map[string]any{
+				source.QueryNamespaceInfo: []*source.NamespaceInfoResult{
+					{UID: "ns-1", Namespace: "default"},
+				},
+				source.QueryNamespaceUptime: []*source.UptimeResult{
+					{UID: "ns-1", First: start, Last: end},
+					{UID: "unknown-ns", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Namespace{
+				"ns-1": {UID: "ns-1", Name: "default", Start: start, End: end},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			seedCluster(ds, start, end)
+			for method, result := range tt.overrides {
+				ds.Querier.SetOverride(method, result)
+			}
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.Namespaces)
+		})
+	}
+}

+ 325 - 0
core/pkg/compute/kubemodel/node_test.go

@@ -0,0 +1,325 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func TestComputeNodes(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+
+	tests := []struct {
+		name      string
+		overrides map[string]any
+		want      map[string]*kubemodel.Node
+	}{
+		{
+			name:      "no data returns empty node map",
+			overrides: map[string]any{},
+			want:      map[string]*kubemodel.Node{},
+		},
+		{
+			name: "basic node info and uptime",
+			overrides: map[string]any{
+				source.QueryNodeInfo: []*source.NodeInfoResult{
+					{UID: "node-1", Node: "node-a", ProviderID: "aws:///us-east-1a/i-abc"},
+					{UID: "node-2", Node: "node-b", ProviderID: "aws:///us-east-1b/i-def"},
+				},
+				source.QueryNodeUptime: []*source.UptimeResult{
+					{UID: "node-1", First: start, Last: end},
+					{UID: "node-2", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Node{
+				"node-1": {
+					UID: "node-1", Name: "node-a", ProviderID: "aws:///us-east-1a/i-abc",
+					Start:                start,
+					End:                  end,
+					ResourceCapacities:   kubemodel.ResourceQuantities{},
+					ResourcesAllocatable: kubemodel.ResourceQuantities{},
+				},
+				"node-2": {
+					UID: "node-2", Name: "node-b", ProviderID: "aws:///us-east-1b/i-def",
+					Start:                start,
+					End:                  end,
+					ResourceCapacities:   kubemodel.ResourceQuantities{},
+					ResourcesAllocatable: kubemodel.ResourceQuantities{},
+				},
+			},
+		},
+		{
+			name: "node without uptime is not registered",
+			overrides: map[string]any{
+				source.QueryNodeInfo: []*source.NodeInfoResult{
+					{UID: "node-1", Node: "node-a"},
+				},
+				// QueryNodeUptime intentionally absent
+			},
+			want: map[string]*kubemodel.Node{},
+		},
+		{
+			name: "labels are attached to node",
+			overrides: map[string]any{
+				source.QueryNodeInfo: []*source.NodeInfoResult{
+					{UID: "node-1", Node: "node-a"},
+				},
+				source.QueryNodeUptime: []*source.UptimeResult{
+					{UID: "node-1", First: start, Last: end},
+				},
+				source.QueryNodeLabels: []*source.NodeLabelsResult{
+					{UID: "node-1", Labels: map[string]string{"zone": "us-east-1a", "role": "worker"}},
+				},
+			},
+			want: map[string]*kubemodel.Node{
+				"node-1": {
+					UID: "node-1", Name: "node-a",
+					Start:                start,
+					End:                  end,
+					Labels:               map[string]string{"zone": "us-east-1a", "role": "worker"},
+					ResourceCapacities:   kubemodel.ResourceQuantities{},
+					ResourcesAllocatable: kubemodel.ResourceQuantities{},
+				},
+			},
+		},
+		{
+			name: "resource capacities and allocatable are populated",
+			overrides: map[string]any{
+				source.QueryNodeInfo: []*source.NodeInfoResult{
+					{UID: "node-1", Node: "node-a"},
+				},
+				source.QueryNodeUptime: []*source.UptimeResult{
+					{UID: "node-1", First: start, Last: end},
+				},
+				source.QueryNodeResourceCapacities: []*source.ResourceResult{
+					{UID: "node-1", Resource: "cpu", Unit: "cores", Value: 4.0},
+					{UID: "node-1", Resource: "memory", Unit: "bytes", Value: 8 * 1024 * 1024 * 1024},
+				},
+				source.QueryNodeResourcesAllocatable: []*source.ResourceResult{
+					{UID: "node-1", Resource: "cpu", Unit: "cores", Value: 3.9},
+					{UID: "node-1", Resource: "memory", Unit: "bytes", Value: 7 * 1024 * 1024 * 1024},
+				},
+			},
+			want: map[string]*kubemodel.Node{
+				"node-1": {
+					UID: "node-1", Name: "node-a",
+					Start: start,
+					End:   end,
+					ResourceCapacities: kubemodel.ResourceQuantities{
+						kubemodel.ResourceCPU: {
+							Resource: kubemodel.ResourceCPU,
+							Unit:     kubemodel.UnitCore,
+							Values:   kubemodel.Stats{kubemodel.StatAvg: 4.0},
+						},
+						kubemodel.ResourceMemory: {
+							Resource: kubemodel.ResourceMemory,
+							Unit:     kubemodel.UnitByte,
+							Values:   kubemodel.Stats{kubemodel.StatAvg: 8 * 1024 * 1024 * 1024},
+						},
+					},
+					ResourcesAllocatable: kubemodel.ResourceQuantities{
+						kubemodel.ResourceCPU: {
+							Resource: kubemodel.ResourceCPU,
+							Unit:     kubemodel.UnitCore,
+							Values:   kubemodel.Stats{kubemodel.StatAvg: 3.9},
+						},
+						kubemodel.ResourceMemory: {
+							Resource: kubemodel.ResourceMemory,
+							Unit:     kubemodel.UnitByte,
+							Values:   kubemodel.Stats{kubemodel.StatAvg: 7 * 1024 * 1024 * 1024},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "local storage bytes are populated",
+			overrides: map[string]any{
+				source.QueryNodeInfo: []*source.NodeInfoResult{
+					{UID: "node-1", Node: "node-a"},
+				},
+				source.QueryNodeUptime: []*source.UptimeResult{
+					{UID: "node-1", First: start, Last: end},
+				},
+				source.QueryKMLocalStorageBytes: []*source.UIDValueResult{
+					{UID: "node-1", Value: 500 * 1024 * 1024 * 1024},
+				},
+				source.QueryKMLocalStorageUsedAvg: []*source.NodeUIDValueResult{
+					{UID: "node-1", Value: 100 * 1024 * 1024 * 1024},
+				},
+				source.QueryKMLocalStorageUsedMax: []*source.NodeUIDValueResult{
+					{UID: "node-1", Value: 200 * 1024 * 1024 * 1024},
+				},
+			},
+			want: map[string]*kubemodel.Node{
+				"node-1": {
+					UID: "node-1", Name: "node-a",
+					Start: start,
+					End:   end,
+					FileSystem: kubemodel.FileSystem{
+						CapacityBytes: 500 * 1024 * 1024 * 1024,
+						UsageByteAvg:  100 * 1024 * 1024 * 1024,
+						UsageByteMax:  200 * 1024 * 1024 * 1024,
+					},
+					ResourceCapacities:   kubemodel.ResourceQuantities{},
+					ResourcesAllocatable: kubemodel.ResourceQuantities{},
+				},
+			},
+		},
+		{
+			name: "uptime for unknown node is ignored",
+			overrides: map[string]any{
+				source.QueryNodeInfo: []*source.NodeInfoResult{
+					{UID: "node-1", Node: "node-a"},
+				},
+				source.QueryNodeUptime: []*source.UptimeResult{
+					{UID: "node-1", First: start, Last: end},
+					{UID: "unknown-node", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Node{
+				"node-1": {
+					UID: "node-1", Name: "node-a",
+					Start:                start,
+					End:                  end,
+					ResourceCapacities:   kubemodel.ResourceQuantities{},
+					ResourcesAllocatable: kubemodel.ResourceQuantities{},
+				},
+			},
+		},
+		{
+			name: "local storage for unknown node is ignored",
+			overrides: map[string]any{
+				source.QueryNodeInfo: []*source.NodeInfoResult{
+					{UID: "node-1", Node: "node-a"},
+				},
+				source.QueryNodeUptime: []*source.UptimeResult{
+					{UID: "node-1", First: start, Last: end},
+				},
+				source.QueryKMLocalStorageBytes: []*source.UIDValueResult{
+					{UID: "unknown-node", Value: 999},
+				},
+			},
+			want: map[string]*kubemodel.Node{
+				"node-1": {
+					UID: "node-1", Name: "node-a",
+					Start:                start,
+					End:                  end,
+					ResourceCapacities:   kubemodel.ResourceQuantities{},
+					ResourcesAllocatable: kubemodel.ResourceQuantities{},
+				},
+			},
+		},
+		{
+			name: "resource capacities for unknown node are ignored",
+			overrides: map[string]any{
+				source.QueryNodeInfo: []*source.NodeInfoResult{
+					{UID: "node-1", Node: "node-a"},
+				},
+				source.QueryNodeUptime: []*source.UptimeResult{
+					{UID: "node-1", First: start, Last: end},
+				},
+				source.QueryNodeResourceCapacities: []*source.ResourceResult{
+					{UID: "unknown-node", Resource: "cpu", Unit: "cores", Value: 4.0},
+				},
+			},
+			want: map[string]*kubemodel.Node{
+				"node-1": {
+					UID: "node-1", Name: "node-a",
+					Start:                start,
+					End:                  end,
+					ResourceCapacities:   kubemodel.ResourceQuantities{},
+					ResourcesAllocatable: kubemodel.ResourceQuantities{},
+				},
+			},
+		},
+		{
+			name: "cpu usage data is populated via resource capacities",
+			overrides: map[string]any{
+				source.QueryNodeInfo: []*source.NodeInfoResult{
+					{UID: "node-1", Node: "node-a"},
+					{UID: "node-2", Node: "node-b"},
+				},
+				source.QueryNodeUptime: []*source.UptimeResult{
+					{UID: "node-1", First: start, Last: end},
+					{UID: "node-2", First: start, Last: end},
+				},
+				source.QueryKMLocalStorageUsedAvg: []*source.NodeUIDValueResult{
+					{UID: "node-1", Value: 50 * 1024 * 1024 * 1024},
+				},
+				source.QueryKMLocalStorageUsedMax: []*source.NodeUIDValueResult{
+					{UID: "node-2", Value: 75 * 1024 * 1024 * 1024},
+				},
+			},
+			want: map[string]*kubemodel.Node{
+				"node-1": {
+					UID: "node-1", Name: "node-a",
+					Start: start, End: end,
+					FileSystem:           kubemodel.FileSystem{UsageByteAvg: 50 * 1024 * 1024 * 1024},
+					ResourceCapacities:   kubemodel.ResourceQuantities{},
+					ResourcesAllocatable: kubemodel.ResourceQuantities{},
+				},
+				"node-2": {
+					UID: "node-2", Name: "node-b",
+					Start: start, End: end,
+					FileSystem:           kubemodel.FileSystem{UsageByteMax: 75 * 1024 * 1024 * 1024},
+					ResourceCapacities:   kubemodel.ResourceQuantities{},
+					ResourcesAllocatable: kubemodel.ResourceQuantities{},
+				},
+			},
+		},
+		{
+			name: "gpu count via resource capacities",
+			overrides: map[string]any{
+				source.QueryNodeInfo: []*source.NodeInfoResult{
+					{UID: "node-1", Node: "gpu-node"},
+				},
+				source.QueryNodeUptime: []*source.UptimeResult{
+					{UID: "node-1", First: start, Last: end},
+				},
+				source.QueryNodeResourceCapacities: []*source.ResourceResult{
+					{UID: "node-1", Resource: "nvidia.com/gpu", Unit: "count", Value: 8},
+				},
+			},
+			want: map[string]*kubemodel.Node{
+				"node-1": {
+					UID: "node-1", Name: "gpu-node",
+					Start: start, End: end,
+					ResourceCapacities: kubemodel.ResourceQuantities{
+						kubemodel.ResourceNvidia: {
+							Resource: kubemodel.ResourceNvidia,
+							Unit:     "count",
+							Values:   kubemodel.Stats{kubemodel.StatAvg: 8},
+						},
+					},
+					ResourcesAllocatable: kubemodel.ResourceQuantities{},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			seedCluster(ds, start, end)
+			for method, result := range tt.overrides {
+				ds.Querier.SetOverride(method, result)
+			}
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.Nodes)
+		})
+	}
+}

+ 142 - 0
core/pkg/compute/kubemodel/persistentvolume_test.go

@@ -0,0 +1,142 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func TestComputePersistentVolumes(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+
+	tests := []struct {
+		name      string
+		overrides map[string]any
+		want      map[string]*kubemodel.PersistentVolume
+	}{
+		{
+			name:      "no data returns empty pv map",
+			overrides: map[string]any{},
+			want:      map[string]*kubemodel.PersistentVolume{},
+		},
+		{
+			name: "basic pv info and uptime",
+			overrides: map[string]any{
+				source.QueryKMPVInfo: []*source.PVInfoResult{
+					{UID: "pv-1", PersistentVolume: "pvc-data-0"},
+				},
+				source.QueryPVUptime: []*source.UptimeResult{
+					{UID: "pv-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.PersistentVolume{
+				"pv-1": {
+					UID:   "pv-1",
+					Name:  "pvc-data-0",
+					Start: start,
+					End:   end,
+				},
+			},
+		},
+		{
+			name: "pv without uptime is not registered",
+			overrides: map[string]any{
+				source.QueryKMPVInfo: []*source.PVInfoResult{
+					{UID: "pv-1", PersistentVolume: "pvc-data-0"},
+				},
+			},
+			want: map[string]*kubemodel.PersistentVolume{},
+		},
+		{
+			name: "pv with storage class and csi volume handle",
+			overrides: map[string]any{
+				source.QueryKMPVInfo: []*source.PVInfoResult{
+					{UID: "pv-1", PersistentVolume: "pvc-data-0", StorageClass: "gp2", CSIVolumeHandle: "vol-abc123"},
+				},
+				source.QueryPVUptime: []*source.UptimeResult{
+					{UID: "pv-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.PersistentVolume{
+				"pv-1": {
+					UID:             "pv-1",
+					Name:            "pvc-data-0",
+					StorageClass:    "gp2",
+					CSIVolumeHandle: "vol-abc123",
+					Start:           start,
+					End:             end,
+				},
+			},
+		},
+		{
+			name: "pv size bytes is populated",
+			overrides: map[string]any{
+				source.QueryKMPVInfo: []*source.PVInfoResult{
+					{UID: "pv-1", PersistentVolume: "pvc-data-0"},
+				},
+				source.QueryPVUptime: []*source.UptimeResult{
+					{UID: "pv-1", First: start, Last: end},
+				},
+				source.QueryPVBytes: []*source.PVBytesResult{
+					{UID: "pv-1", Value: 10 * 1024 * 1024 * 1024},
+				},
+			},
+			want: map[string]*kubemodel.PersistentVolume{
+				"pv-1": {
+					UID:       "pv-1",
+					Name:      "pvc-data-0",
+					Start:     start,
+					End:       end,
+					SizeBytes: 10 * 1024 * 1024 * 1024,
+				},
+			},
+		},
+		{
+			name: "pv bytes for unknown pv is ignored",
+			overrides: map[string]any{
+				source.QueryKMPVInfo: []*source.PVInfoResult{
+					{UID: "pv-1", PersistentVolume: "pvc-data-0"},
+				},
+				source.QueryPVUptime: []*source.UptimeResult{
+					{UID: "pv-1", First: start, Last: end},
+				},
+				source.QueryPVBytes: []*source.PVBytesResult{
+					{UID: "unknown-pv", Value: 999},
+				},
+			},
+			want: map[string]*kubemodel.PersistentVolume{
+				"pv-1": {
+					UID:   "pv-1",
+					Name:  "pvc-data-0",
+					Start: start,
+					End:   end,
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			seedCluster(ds, start, end)
+			for method, result := range tt.overrides {
+				ds.Querier.SetOverride(method, result)
+			}
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.PersistentVolumes)
+		})
+	}
+}

+ 155 - 0
core/pkg/compute/kubemodel/persistentvolumeclaim_test.go

@@ -0,0 +1,155 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+	"github.com/opencost/opencost/core/pkg/util"
+)
+
+func TestComputePersistentVolumeClaims(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+
+	tests := []struct {
+		name      string
+		overrides map[string]any
+		want      map[string]*kubemodel.PersistentVolumeClaim
+	}{
+		{
+			name:      "no data returns empty pvc map",
+			overrides: map[string]any{},
+			want:      map[string]*kubemodel.PersistentVolumeClaim{},
+		},
+		{
+			name: "basic pvc info and uptime",
+			overrides: map[string]any{
+				source.QueryKMPVCInfo: []*source.PVCInfoResult{
+					{UID: "pvc-1", PersistentVolumeClaim: "data-claim", NamespaceUID: "ns-1"},
+				},
+				source.QueryPVCUptime: []*source.UptimeResult{
+					{UID: "pvc-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.PersistentVolumeClaim{
+				"pvc-1": {
+					UID:          "pvc-1",
+					Name:         "data-claim",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+		{
+			name: "pvc without uptime is not registered",
+			overrides: map[string]any{
+				source.QueryKMPVCInfo: []*source.PVCInfoResult{
+					{UID: "pvc-1", PersistentVolumeClaim: "data-claim", NamespaceUID: "ns-1"},
+				},
+			},
+			want: map[string]*kubemodel.PersistentVolumeClaim{},
+		},
+		{
+			name: "pvc with pv uid and storage class",
+			overrides: map[string]any{
+				source.QueryKMPVCInfo: []*source.PVCInfoResult{
+					{UID: "pvc-1", PersistentVolumeClaim: "data-claim", NamespaceUID: "ns-1", PVUID: "pv-1", StorageClass: "gp2"},
+				},
+				source.QueryPVCUptime: []*source.UptimeResult{
+					{UID: "pvc-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.PersistentVolumeClaim{
+				"pvc-1": {
+					UID:                 "pvc-1",
+					Name:                "data-claim",
+					NamespaceUID:        "ns-1",
+					PersistentVolumeUID: "pv-1",
+					StorageClass:        "gp2",
+					Start:               start,
+					End:                 end,
+				},
+			},
+		},
+		{
+			name: "pvc requested bytes and usage are populated",
+			overrides: map[string]any{
+				source.QueryKMPVCInfo: []*source.PVCInfoResult{
+					{UID: "pvc-1", PersistentVolumeClaim: "data-claim", NamespaceUID: "ns-1"},
+				},
+				source.QueryPVCUptime: []*source.UptimeResult{
+					{UID: "pvc-1", First: start, Last: end},
+				},
+				source.QueryPVCBytesRequested: []*source.PVCBytesRequestedResult{
+					{UID: "pvc-1", Data: []*util.Vector{{Value: 5 * 1024 * 1024 * 1024}}},
+				},
+				source.QueryPVCBytesUsedAverage: []*source.PVCUIDValueResult{
+					{UID: "pvc-1", Value: 2 * 1024 * 1024 * 1024},
+				},
+				source.QueryPVCBytesUsedMax: []*source.PVCUIDValueResult{
+					{UID: "pvc-1", Value: 4 * 1024 * 1024 * 1024},
+				},
+			},
+			want: map[string]*kubemodel.PersistentVolumeClaim{
+				"pvc-1": {
+					UID:            "pvc-1",
+					Name:           "data-claim",
+					NamespaceUID:   "ns-1",
+					Start:          start,
+					End:            end,
+					RequestedBytes: 5 * 1024 * 1024 * 1024,
+					UsageBytesAvg:  2 * 1024 * 1024 * 1024,
+					UsageBytesMax:  4 * 1024 * 1024 * 1024,
+				},
+			},
+		},
+		{
+			name: "bytes for unknown pvc are ignored",
+			overrides: map[string]any{
+				source.QueryKMPVCInfo: []*source.PVCInfoResult{
+					{UID: "pvc-1", PersistentVolumeClaim: "data-claim", NamespaceUID: "ns-1"},
+				},
+				source.QueryPVCUptime: []*source.UptimeResult{
+					{UID: "pvc-1", First: start, Last: end},
+				},
+				source.QueryPVCBytesUsedAverage: []*source.PVCUIDValueResult{
+					{UID: "unknown-pvc", Value: 999},
+				},
+			},
+			want: map[string]*kubemodel.PersistentVolumeClaim{
+				"pvc-1": {
+					UID:          "pvc-1",
+					Name:         "data-claim",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			seedCluster(ds, start, end)
+			for method, result := range tt.overrides {
+				ds.Querier.SetOverride(method, result)
+			}
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.PersistentVolumeClaims)
+		})
+	}
+}

+ 309 - 0
core/pkg/compute/kubemodel/pod_test.go

@@ -0,0 +1,309 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func TestComputePods(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+
+	tests := []struct {
+		name      string
+		overrides map[string]any
+		want      map[string]*kubemodel.Pod
+	}{
+		{
+			name:      "no data returns empty pod map",
+			overrides: map[string]any{},
+			want:      map[string]*kubemodel.Pod{},
+		},
+		{
+			name: "basic pod info and uptime",
+			overrides: map[string]any{
+				source.QueryPodInfo: []*source.PodInfoResult{
+					{UID: "pod-1", Pod: "my-pod", NamespaceUID: "ns-1"},
+				},
+				source.QueryPodUptime: []*source.UptimeResult{
+					{UID: "pod-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Pod{
+				"pod-1": {
+					UID:          "pod-1",
+					Name:         "my-pod",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+		{
+			name: "pod without uptime is not registered",
+			overrides: map[string]any{
+				source.QueryPodInfo: []*source.PodInfoResult{
+					{UID: "pod-1", Pod: "my-pod", NamespaceUID: "ns-1"},
+				},
+			},
+			want: map[string]*kubemodel.Pod{},
+		},
+		{
+			name: "pod without namespace uid is not registered",
+			overrides: map[string]any{
+				source.QueryPodInfo: []*source.PodInfoResult{
+					{UID: "pod-1", Pod: "my-pod"},
+				},
+				source.QueryPodUptime: []*source.UptimeResult{
+					{UID: "pod-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Pod{},
+		},
+		{
+			name: "pod with node uid",
+			overrides: map[string]any{
+				source.QueryPodInfo: []*source.PodInfoResult{
+					{UID: "pod-1", Pod: "my-pod", NamespaceUID: "ns-1", NodeUID: "node-1"},
+				},
+				source.QueryPodUptime: []*source.UptimeResult{
+					{UID: "pod-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Pod{
+				"pod-1": {
+					UID:          "pod-1",
+					Name:         "my-pod",
+					NamespaceUID: "ns-1",
+					NodeUID:      "node-1",
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+		{
+			name: "pod owners are attached",
+			overrides: map[string]any{
+				source.QueryPodInfo: []*source.PodInfoResult{
+					{UID: "pod-1", Pod: "my-pod", NamespaceUID: "ns-1"},
+				},
+				source.QueryPodUptime: []*source.UptimeResult{
+					{UID: "pod-1", First: start, Last: end},
+				},
+				source.QueryPodOwners: []*source.OwnerResult{
+					{UID: "pod-1", OwnerUID: "rs-1", OwnerKind: "ReplicaSet", Controller: true},
+				},
+			},
+			want: map[string]*kubemodel.Pod{
+				"pod-1": {
+					UID:          "pod-1",
+					Name:         "my-pod",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					Owners: []kubemodel.Owner{
+						{UID: "rs-1", Kind: kubemodel.OwnerKindReplicaSet, Controller: true},
+					},
+				},
+			},
+		},
+		{
+			name: "pod pvc volumes are attached",
+			overrides: map[string]any{
+				source.QueryPodInfo: []*source.PodInfoResult{
+					{UID: "pod-1", Pod: "my-pod", NamespaceUID: "ns-1"},
+				},
+				source.QueryPodUptime: []*source.UptimeResult{
+					{UID: "pod-1", First: start, Last: end},
+				},
+				source.QueryPodPVCVolumes: []*source.PodPVCVolumeResult{
+					{UID: "pod-1", PVCUID: "pvc-1", PodVolumeName: "data"},
+				},
+			},
+			want: map[string]*kubemodel.Pod{
+				"pod-1": {
+					UID:          "pod-1",
+					Name:         "my-pod",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					PVCVolumes: []kubemodel.PodPVCVolume{
+						{Name: "data", PersistentVolumeClaimUID: "pvc-1"},
+					},
+				},
+			},
+		},
+		{
+			name: "pod labels and annotations are attached",
+			overrides: map[string]any{
+				source.QueryPodInfo: []*source.PodInfoResult{
+					{UID: "pod-1", Pod: "my-pod", NamespaceUID: "ns-1"},
+				},
+				source.QueryPodUptime: []*source.UptimeResult{
+					{UID: "pod-1", First: start, Last: end},
+				},
+				source.QueryPodLabels: []*source.PodLabelsResult{
+					{UID: "pod-1", Labels: map[string]string{"app": "web"}},
+				},
+				source.QueryPodAnnotations: []*source.PodAnnotationsResult{
+					{UID: "pod-1", Annotations: map[string]string{"team": "platform"}},
+				},
+			},
+			want: map[string]*kubemodel.Pod{
+				"pod-1": {
+					UID:          "pod-1",
+					Name:         "my-pod",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					Labels:       map[string]string{"app": "web"},
+					Annotations:  map[string]string{"team": "platform"},
+				},
+			},
+		},
+		{
+			name: "uptime for unknown pod is ignored",
+			overrides: map[string]any{
+				source.QueryPodInfo: []*source.PodInfoResult{
+					{UID: "pod-1", Pod: "my-pod", NamespaceUID: "ns-1"},
+				},
+				source.QueryPodUptime: []*source.UptimeResult{
+					{UID: "pod-1", First: start, Last: end},
+					{UID: "unknown-pod", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Pod{
+				"pod-1": {
+					UID:          "pod-1",
+					Name:         "my-pod",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+		{
+			name: "network egress: internet, cross-region, cross-zone traffic types",
+			overrides: map[string]any{
+				source.QueryPodInfo: []*source.PodInfoResult{
+					{UID: "pod-1", Pod: "my-pod", NamespaceUID: "ns-1"},
+				},
+				source.QueryPodUptime: []*source.UptimeResult{
+					{UID: "pod-1", First: start, Last: end},
+				},
+				source.QueryPodNetworkEgressBytes: []*source.PodNetworkBytesResult{
+					{UID: "pod-1", Internet: true, Service: "svc-a", Value: 100},
+					{UID: "pod-1", Internet: false, SameRegion: false, Service: "svc-b", Value: 200},
+					{UID: "pod-1", Internet: false, SameRegion: true, SameZone: false, Service: "svc-c", Value: 300},
+					{UID: "pod-1", Internet: false, SameRegion: true, SameZone: true, Service: "svc-d", Value: 400}, // no traffic type → skipped
+					{UID: "pod-1", Internet: true, Service: "svc-e", Value: 0},                                      // zero bytes → skipped
+					{UID: "unknown-pod", Internet: true, Service: "svc-f", Value: 500},                              // unknown pod → skipped
+				},
+			},
+			want: map[string]*kubemodel.Pod{
+				"pod-1": {
+					UID:          "pod-1",
+					Name:         "my-pod",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					NetworkTrafficDetails: []kubemodel.NetworkTrafficDetail{
+						{PodUID: "pod-1", TrafficDirection: kubemodel.TrafficDirectionEgress, TrafficType: kubemodel.TrafficTypeInternet, Endpoint: "svc-a", Bytes: 100},
+						{PodUID: "pod-1", TrafficDirection: kubemodel.TrafficDirectionEgress, TrafficType: kubemodel.TrafficTypeCrossRegion, Endpoint: "svc-b", Bytes: 200},
+						{PodUID: "pod-1", TrafficDirection: kubemodel.TrafficDirectionEgress, TrafficType: kubemodel.TrafficTypeCrossZone, Endpoint: "svc-c", Bytes: 300},
+					},
+				},
+			},
+		},
+		{
+			name: "unknown uid in supplementary data is ignored",
+			overrides: map[string]any{
+				source.QueryPodInfo: []*source.PodInfoResult{
+					{UID: "pod-1", Pod: "my-pod", NamespaceUID: "ns-1"},
+				},
+				source.QueryPodUptime: []*source.UptimeResult{
+					{UID: "pod-1", First: start, Last: end},
+				},
+				// all of these carry an unknown UID — hit the !ok warn paths
+				source.QueryPodOwners: []*source.OwnerResult{
+					{UID: "unknown-pod", OwnerUID: "rs-1", OwnerKind: "ReplicaSet", Controller: true},
+				},
+				source.QueryPodPVCVolumes: []*source.PodPVCVolumeResult{
+					{UID: "unknown-pod", PVCUID: "pvc-1", PodVolumeName: "data"},
+				},
+				source.QueryPodLabels: []*source.PodLabelsResult{
+					{UID: "unknown-pod", Labels: map[string]string{"app": "web"}},
+				},
+				source.QueryPodAnnotations: []*source.PodAnnotationsResult{
+					{UID: "unknown-pod", Annotations: map[string]string{"team": "platform"}},
+				},
+				// same-zone+same-region ingress: triggers !ok continue in ingress loop
+				source.QueryPodNetworkIngressBytes: []*source.PodNetworkBytesResult{
+					{UID: "pod-1", Internet: false, SameRegion: true, SameZone: true, Value: 100},
+				},
+			},
+			want: map[string]*kubemodel.Pod{
+				"pod-1": {
+					UID:          "pod-1",
+					Name:         "my-pod",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+		{
+			name: "network ingress traffic is recorded",
+			overrides: map[string]any{
+				source.QueryPodInfo: []*source.PodInfoResult{
+					{UID: "pod-1", Pod: "my-pod", NamespaceUID: "ns-1"},
+				},
+				source.QueryPodUptime: []*source.UptimeResult{
+					{UID: "pod-1", First: start, Last: end},
+				},
+				source.QueryPodNetworkIngressBytes: []*source.PodNetworkBytesResult{
+					{UID: "pod-1", Internet: true, NatGateway: true, Service: "svc-a", Value: 512},
+					{UID: "pod-1", Internet: false, SameRegion: false, Service: "svc-b", Value: 256},
+				},
+			},
+			want: map[string]*kubemodel.Pod{
+				"pod-1": {
+					UID:          "pod-1",
+					Name:         "my-pod",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					NetworkTrafficDetails: []kubemodel.NetworkTrafficDetail{
+						{PodUID: "pod-1", TrafficDirection: kubemodel.TrafficDirectionIngress, TrafficType: kubemodel.TrafficTypeInternet, IsNatGateway: true, Endpoint: "svc-a", Bytes: 512},
+						{PodUID: "pod-1", TrafficDirection: kubemodel.TrafficDirectionIngress, TrafficType: kubemodel.TrafficTypeCrossRegion, Endpoint: "svc-b", Bytes: 256},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			seedCluster(ds, start, end)
+			for method, result := range tt.overrides {
+				ds.Querier.SetOverride(method, result)
+			}
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.Pods)
+		})
+	}
+}

+ 166 - 0
core/pkg/compute/kubemodel/replicaset_test.go

@@ -0,0 +1,166 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func TestComputeReplicaSets(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+
+	tests := []struct {
+		name      string
+		overrides map[string]any
+		want      map[string]*kubemodel.ReplicaSet
+	}{
+		{
+			name:      "no data returns empty replicaset map",
+			overrides: map[string]any{},
+			want:      map[string]*kubemodel.ReplicaSet{},
+		},
+		{
+			name: "basic replicaset info and uptime",
+			overrides: map[string]any{
+				source.QueryReplicaSetInfo: []*source.ReplicaSetInfoResult{
+					{UID: "rs-1", ReplicaSet: "my-app-v1", NamespaceUID: "ns-1"},
+				},
+				source.QueryReplicaSetUptime: []*source.UptimeResult{
+					{UID: "rs-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.ReplicaSet{
+				"rs-1": {
+					UID:          "rs-1",
+					Name:         "my-app-v1",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+		{
+			name: "replicaset without uptime is not registered",
+			overrides: map[string]any{
+				source.QueryReplicaSetInfo: []*source.ReplicaSetInfoResult{
+					{UID: "rs-1", ReplicaSet: "my-app-v1", NamespaceUID: "ns-1"},
+				},
+			},
+			want: map[string]*kubemodel.ReplicaSet{},
+		},
+		{
+			name: "replicaset without namespace uid is not registered",
+			overrides: map[string]any{
+				source.QueryReplicaSetInfo: []*source.ReplicaSetInfoResult{
+					{UID: "rs-1", ReplicaSet: "my-app-v1"},
+				},
+				source.QueryReplicaSetUptime: []*source.UptimeResult{
+					{UID: "rs-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.ReplicaSet{},
+		},
+		{
+			name: "replicaset owner is attached",
+			overrides: map[string]any{
+				source.QueryReplicaSetInfo: []*source.ReplicaSetInfoResult{
+					{UID: "rs-1", ReplicaSet: "my-app-v1", NamespaceUID: "ns-1"},
+				},
+				source.QueryReplicaSetUptime: []*source.UptimeResult{
+					{UID: "rs-1", First: start, Last: end},
+				},
+				source.QueryReplicaSetOwners: []*source.OwnerResult{
+					{UID: "rs-1", OwnerUID: "dep-1", OwnerKind: "Deployment", Controller: true},
+				},
+			},
+			want: map[string]*kubemodel.ReplicaSet{
+				"rs-1": {
+					UID:          "rs-1",
+					Name:         "my-app-v1",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					Owners: []kubemodel.Owner{
+						{UID: "dep-1", Kind: kubemodel.OwnerKindDeployment, Controller: true},
+					},
+				},
+			},
+		},
+		{
+			name: "replicaset labels and annotations are attached",
+			overrides: map[string]any{
+				source.QueryReplicaSetInfo: []*source.ReplicaSetInfoResult{
+					{UID: "rs-1", ReplicaSet: "my-app-v1", NamespaceUID: "ns-1"},
+				},
+				source.QueryReplicaSetUptime: []*source.UptimeResult{
+					{UID: "rs-1", First: start, Last: end},
+				},
+				source.QueryReplicaSetLabels: []*source.LabelsResult{
+					{UID: "rs-1", Labels: map[string]string{"app": "my-app", "version": "v1"}},
+				},
+				source.QueryReplicaSetAnnotations: []*source.AnnotationsResult{
+					{UID: "rs-1", Annotations: map[string]string{"rollout": "stable"}},
+				},
+			},
+			want: map[string]*kubemodel.ReplicaSet{
+				"rs-1": {
+					UID:          "rs-1",
+					Name:         "my-app-v1",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					Labels:       map[string]string{"app": "my-app", "version": "v1"},
+					Annotations:  map[string]string{"rollout": "stable"},
+				},
+			},
+		},
+		{
+			name: "owner for unknown replicaset is ignored",
+			overrides: map[string]any{
+				source.QueryReplicaSetInfo: []*source.ReplicaSetInfoResult{
+					{UID: "rs-1", ReplicaSet: "my-app-v1", NamespaceUID: "ns-1"},
+				},
+				source.QueryReplicaSetUptime: []*source.UptimeResult{
+					{UID: "rs-1", First: start, Last: end},
+				},
+				source.QueryReplicaSetOwners: []*source.OwnerResult{
+					{UID: "unknown-rs", OwnerUID: "dep-1", OwnerKind: "Deployment", Controller: true},
+				},
+			},
+			want: map[string]*kubemodel.ReplicaSet{
+				"rs-1": {
+					UID:          "rs-1",
+					Name:         "my-app-v1",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			seedCluster(ds, start, end)
+			for method, result := range tt.overrides {
+				ds.Querier.SetOverride(method, result)
+			}
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.ReplicaSets)
+		})
+	}
+}

+ 362 - 0
core/pkg/compute/kubemodel/resourcequota_test.go

@@ -0,0 +1,362 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func TestComputeResourceQuotas(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+
+	emptyRQ := func(uid, name, nsUID string) *kubemodel.ResourceQuota {
+		return &kubemodel.ResourceQuota{
+			UID:          uid,
+			Name:         name,
+			NamespaceUID: nsUID,
+			Start:        start,
+			End:          end,
+			Spec:         &kubemodel.ResourceQuotaSpec{Hard: &kubemodel.ResourceQuotaSpecHard{}},
+			Status:       &kubemodel.ResourceQuotaStatus{Used: &kubemodel.ResourceQuotaStatusUsed{}},
+		}
+	}
+
+	tests := []struct {
+		name      string
+		overrides map[string]any
+		want      map[string]*kubemodel.ResourceQuota
+	}{
+		{
+			name:      "no data returns empty resource quota map",
+			overrides: map[string]any{},
+			want:      map[string]*kubemodel.ResourceQuota{},
+		},
+		{
+			name: "basic resource quota info and uptime",
+			overrides: map[string]any{
+				source.QueryResourceQuotaInfo: []*source.ResourceQuotaInfoResult{
+					{UID: "rq-1", ResourceQuota: "compute-quota", NamespaceUID: "ns-1"},
+				},
+				source.QueryResourceQuotaUptime: []*source.UptimeResult{
+					{UID: "rq-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.ResourceQuota{
+				"rq-1": emptyRQ("rq-1", "compute-quota", "ns-1"),
+			},
+		},
+		{
+			name: "resource quota without uptime is not registered",
+			overrides: map[string]any{
+				source.QueryResourceQuotaInfo: []*source.ResourceQuotaInfoResult{
+					{UID: "rq-1", ResourceQuota: "compute-quota", NamespaceUID: "ns-1"},
+				},
+			},
+			want: map[string]*kubemodel.ResourceQuota{},
+		},
+		{
+			name: "spec cpu and ram request limits are populated",
+			overrides: map[string]any{
+				source.QueryResourceQuotaInfo: []*source.ResourceQuotaInfoResult{
+					{UID: "rq-1", ResourceQuota: "compute-quota", NamespaceUID: "ns-1"},
+				},
+				source.QueryResourceQuotaUptime: []*source.UptimeResult{
+					{UID: "rq-1", First: start, Last: end},
+				},
+				source.QueryResourceQuotaSpecCPURequestAverage: []*source.ResourceResult{
+					{UID: "rq-1", Value: 2.0},
+				},
+				source.QueryResourceQuotaSpecCPURequestMax: []*source.ResourceResult{
+					{UID: "rq-1", Value: 4.0},
+				},
+				source.QueryResourceQuotaSpecRAMRequestAverage: []*source.ResourceResult{
+					{UID: "rq-1", Value: 4 * 1024 * 1024 * 1024},
+				},
+				source.QueryResourceQuotaSpecRAMRequestMax: []*source.ResourceResult{
+					{UID: "rq-1", Value: 8 * 1024 * 1024 * 1024},
+				},
+			},
+			want: map[string]*kubemodel.ResourceQuota{
+				"rq-1": {
+					UID:          "rq-1",
+					Name:         "compute-quota",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					Spec: &kubemodel.ResourceQuotaSpec{
+						Hard: &kubemodel.ResourceQuotaSpecHard{
+							Requests: kubemodel.ResourceQuantities{
+								kubemodel.ResourceCPU: {
+									Resource: kubemodel.ResourceCPU,
+									Unit:     kubemodel.UnitMillicore,
+									Values:   kubemodel.Stats{kubemodel.StatAvg: 2000, kubemodel.StatMax: 4000},
+								},
+								kubemodel.ResourceMemory: {
+									Resource: kubemodel.ResourceMemory,
+									Unit:     kubemodel.UnitByte,
+									Values:   kubemodel.Stats{kubemodel.StatAvg: 4 * 1024 * 1024 * 1024, kubemodel.StatMax: 8 * 1024 * 1024 * 1024},
+								},
+							},
+						},
+					},
+					Status: &kubemodel.ResourceQuotaStatus{Used: &kubemodel.ResourceQuotaStatusUsed{}},
+				},
+			},
+		},
+		{
+			name: "status used cpu and ram are populated",
+			overrides: map[string]any{
+				source.QueryResourceQuotaInfo: []*source.ResourceQuotaInfoResult{
+					{UID: "rq-1", ResourceQuota: "compute-quota", NamespaceUID: "ns-1"},
+				},
+				source.QueryResourceQuotaUptime: []*source.UptimeResult{
+					{UID: "rq-1", First: start, Last: end},
+				},
+				source.QueryResourceQuotaStatusUsedCPURequestAverage: []*source.ResourceResult{
+					{UID: "rq-1", Value: 1.0},
+				},
+				source.QueryResourceQuotaStatusUsedRAMRequestAverage: []*source.ResourceResult{
+					{UID: "rq-1", Value: 2 * 1024 * 1024 * 1024},
+				},
+			},
+			want: map[string]*kubemodel.ResourceQuota{
+				"rq-1": {
+					UID:          "rq-1",
+					Name:         "compute-quota",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					Spec:         &kubemodel.ResourceQuotaSpec{Hard: &kubemodel.ResourceQuotaSpecHard{}},
+					Status: &kubemodel.ResourceQuotaStatus{
+						Used: &kubemodel.ResourceQuotaStatusUsed{
+							Requests: kubemodel.ResourceQuantities{
+								kubemodel.ResourceCPU: {
+									Resource: kubemodel.ResourceCPU,
+									Unit:     kubemodel.UnitMillicore,
+									Values:   kubemodel.Stats{kubemodel.StatAvg: 1000},
+								},
+								kubemodel.ResourceMemory: {
+									Resource: kubemodel.ResourceMemory,
+									Unit:     kubemodel.UnitByte,
+									Values:   kubemodel.Stats{kubemodel.StatAvg: 2 * 1024 * 1024 * 1024},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "spec cpu and ram limits are populated",
+			overrides: map[string]any{
+				source.QueryResourceQuotaInfo: []*source.ResourceQuotaInfoResult{
+					{UID: "rq-1", ResourceQuota: "compute-quota", NamespaceUID: "ns-1"},
+				},
+				source.QueryResourceQuotaUptime: []*source.UptimeResult{
+					{UID: "rq-1", First: start, Last: end},
+				},
+				source.QueryResourceQuotaSpecCPULimitAverage: []*source.ResourceResult{
+					{UID: "rq-1", Value: 4.0},
+				},
+				source.QueryResourceQuotaSpecCPULimitMax: []*source.ResourceResult{
+					{UID: "rq-1", Value: 8.0},
+				},
+				source.QueryResourceQuotaSpecRAMLimitAverage: []*source.ResourceResult{
+					{UID: "rq-1", Value: 8 * 1024 * 1024 * 1024},
+				},
+				source.QueryResourceQuotaSpecRAMLimitMax: []*source.ResourceResult{
+					{UID: "rq-1", Value: 16 * 1024 * 1024 * 1024},
+				},
+			},
+			want: map[string]*kubemodel.ResourceQuota{
+				"rq-1": {
+					UID:          "rq-1",
+					Name:         "compute-quota",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					Spec: &kubemodel.ResourceQuotaSpec{
+						Hard: &kubemodel.ResourceQuotaSpecHard{
+							Limits: kubemodel.ResourceQuantities{
+								kubemodel.ResourceCPU: {
+									Resource: kubemodel.ResourceCPU,
+									Unit:     kubemodel.UnitMillicore,
+									Values:   kubemodel.Stats{kubemodel.StatAvg: 4000, kubemodel.StatMax: 8000},
+								},
+								kubemodel.ResourceMemory: {
+									Resource: kubemodel.ResourceMemory,
+									Unit:     kubemodel.UnitByte,
+									Values:   kubemodel.Stats{kubemodel.StatAvg: 8 * 1024 * 1024 * 1024, kubemodel.StatMax: 16 * 1024 * 1024 * 1024},
+								},
+							},
+						},
+					},
+					Status: &kubemodel.ResourceQuotaStatus{Used: &kubemodel.ResourceQuotaStatusUsed{}},
+				},
+			},
+		},
+		{
+			name: "status used cpu and ram request max are populated",
+			overrides: map[string]any{
+				source.QueryResourceQuotaInfo: []*source.ResourceQuotaInfoResult{
+					{UID: "rq-1", ResourceQuota: "compute-quota", NamespaceUID: "ns-1"},
+				},
+				source.QueryResourceQuotaUptime: []*source.UptimeResult{
+					{UID: "rq-1", First: start, Last: end},
+				},
+				source.QueryResourceQuotaStatusUsedCPURequestMax: []*source.ResourceResult{
+					{UID: "rq-1", Value: 3.0},
+				},
+				source.QueryResourceQuotaStatusUsedRAMRequestMax: []*source.ResourceResult{
+					{UID: "rq-1", Value: 6 * 1024 * 1024 * 1024},
+				},
+			},
+			want: map[string]*kubemodel.ResourceQuota{
+				"rq-1": {
+					UID:          "rq-1",
+					Name:         "compute-quota",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					Spec:         &kubemodel.ResourceQuotaSpec{Hard: &kubemodel.ResourceQuotaSpecHard{}},
+					Status: &kubemodel.ResourceQuotaStatus{
+						Used: &kubemodel.ResourceQuotaStatusUsed{
+							Requests: kubemodel.ResourceQuantities{
+								kubemodel.ResourceCPU: {
+									Resource: kubemodel.ResourceCPU,
+									Unit:     kubemodel.UnitMillicore,
+									Values:   kubemodel.Stats{kubemodel.StatMax: 3000},
+								},
+								kubemodel.ResourceMemory: {
+									Resource: kubemodel.ResourceMemory,
+									Unit:     kubemodel.UnitByte,
+									Values:   kubemodel.Stats{kubemodel.StatMax: 6 * 1024 * 1024 * 1024},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "status used cpu and ram limits are populated",
+			overrides: map[string]any{
+				source.QueryResourceQuotaInfo: []*source.ResourceQuotaInfoResult{
+					{UID: "rq-1", ResourceQuota: "compute-quota", NamespaceUID: "ns-1"},
+				},
+				source.QueryResourceQuotaUptime: []*source.UptimeResult{
+					{UID: "rq-1", First: start, Last: end},
+				},
+				source.QueryResourceQuotaStatusUsedCPULimitAverage: []*source.ResourceResult{
+					{UID: "rq-1", Value: 2.0},
+				},
+				source.QueryResourceQuotaStatusUsedCPULimitMax: []*source.ResourceResult{
+					{UID: "rq-1", Value: 4.0},
+				},
+				source.QueryResourceQuotaStatusUsedRAMLimitAverage: []*source.ResourceResult{
+					{UID: "rq-1", Value: 4 * 1024 * 1024 * 1024},
+				},
+				source.QueryResourceQuotaStatusUsedRAMLimitMax: []*source.ResourceResult{
+					{UID: "rq-1", Value: 8 * 1024 * 1024 * 1024},
+				},
+			},
+			want: map[string]*kubemodel.ResourceQuota{
+				"rq-1": {
+					UID:          "rq-1",
+					Name:         "compute-quota",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					Spec:         &kubemodel.ResourceQuotaSpec{Hard: &kubemodel.ResourceQuotaSpecHard{}},
+					Status: &kubemodel.ResourceQuotaStatus{
+						Used: &kubemodel.ResourceQuotaStatusUsed{
+							Limits: kubemodel.ResourceQuantities{
+								kubemodel.ResourceCPU: {
+									Resource: kubemodel.ResourceCPU,
+									Unit:     kubemodel.UnitMillicore,
+									Values:   kubemodel.Stats{kubemodel.StatAvg: 2000, kubemodel.StatMax: 4000},
+								},
+								kubemodel.ResourceMemory: {
+									Resource: kubemodel.ResourceMemory,
+									Unit:     kubemodel.UnitByte,
+									Values:   kubemodel.Stats{kubemodel.StatAvg: 4 * 1024 * 1024 * 1024, kubemodel.StatMax: 8 * 1024 * 1024 * 1024},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "metric data for unknown resource quota is ignored",
+			overrides: map[string]any{
+				source.QueryResourceQuotaInfo: []*source.ResourceQuotaInfoResult{
+					{UID: "rq-1", ResourceQuota: "compute-quota", NamespaceUID: "ns-1"},
+				},
+				source.QueryResourceQuotaUptime: []*source.UptimeResult{
+					{UID: "rq-1", First: start, Last: end},
+				},
+				// all 16 metric queries carry an unknown UID — hit every !ok warn path
+				source.QueryResourceQuotaSpecCPURequestAverage:       []*source.ResourceResult{{UID: "unknown-rq", Value: 1.0}},
+				source.QueryResourceQuotaSpecCPURequestMax:           []*source.ResourceResult{{UID: "unknown-rq", Value: 1.0}},
+				source.QueryResourceQuotaSpecRAMRequestAverage:       []*source.ResourceResult{{UID: "unknown-rq", Value: 1.0}},
+				source.QueryResourceQuotaSpecRAMRequestMax:           []*source.ResourceResult{{UID: "unknown-rq", Value: 1.0}},
+				source.QueryResourceQuotaSpecCPULimitAverage:         []*source.ResourceResult{{UID: "unknown-rq", Value: 1.0}},
+				source.QueryResourceQuotaSpecCPULimitMax:             []*source.ResourceResult{{UID: "unknown-rq", Value: 1.0}},
+				source.QueryResourceQuotaSpecRAMLimitAverage:         []*source.ResourceResult{{UID: "unknown-rq", Value: 1.0}},
+				source.QueryResourceQuotaSpecRAMLimitMax:             []*source.ResourceResult{{UID: "unknown-rq", Value: 1.0}},
+				source.QueryResourceQuotaStatusUsedCPURequestAverage: []*source.ResourceResult{{UID: "unknown-rq", Value: 1.0}},
+				source.QueryResourceQuotaStatusUsedCPURequestMax:     []*source.ResourceResult{{UID: "unknown-rq", Value: 1.0}},
+				source.QueryResourceQuotaStatusUsedRAMRequestAverage: []*source.ResourceResult{{UID: "unknown-rq", Value: 1.0}},
+				source.QueryResourceQuotaStatusUsedRAMRequestMax:     []*source.ResourceResult{{UID: "unknown-rq", Value: 1.0}},
+				source.QueryResourceQuotaStatusUsedCPULimitAverage:   []*source.ResourceResult{{UID: "unknown-rq", Value: 1.0}},
+				source.QueryResourceQuotaStatusUsedCPULimitMax:       []*source.ResourceResult{{UID: "unknown-rq", Value: 1.0}},
+				source.QueryResourceQuotaStatusUsedRAMLimitAverage:   []*source.ResourceResult{{UID: "unknown-rq", Value: 1.0}},
+				source.QueryResourceQuotaStatusUsedRAMLimitMax:       []*source.ResourceResult{{UID: "unknown-rq", Value: 1.0}},
+			},
+			want: map[string]*kubemodel.ResourceQuota{
+				"rq-1": emptyRQ("rq-1", "compute-quota", "ns-1"),
+			},
+		},
+		{
+			name: "uptime for unknown resource quota is ignored",
+			overrides: map[string]any{
+				source.QueryResourceQuotaInfo: []*source.ResourceQuotaInfoResult{
+					{UID: "rq-1", ResourceQuota: "compute-quota", NamespaceUID: "ns-1"},
+				},
+				source.QueryResourceQuotaUptime: []*source.UptimeResult{
+					{UID: "rq-1", First: start, Last: end},
+					{UID: "unknown-rq", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.ResourceQuota{
+				"rq-1": emptyRQ("rq-1", "compute-quota", "ns-1"),
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			seedCluster(ds, start, end)
+			for method, result := range tt.overrides {
+				ds.Querier.SetOverride(method, result)
+			}
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.ResourceQuotas)
+		})
+	}
+}

+ 159 - 0
core/pkg/compute/kubemodel/service_test.go

@@ -0,0 +1,159 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func TestComputeServices(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+
+	tests := []struct {
+		name      string
+		overrides map[string]any
+		want      map[string]*kubemodel.Service
+	}{
+		{
+			name:      "no data returns empty service map",
+			overrides: map[string]any{},
+			want:      map[string]*kubemodel.Service{},
+		},
+		{
+			name: "basic service info and uptime",
+			overrides: map[string]any{
+				source.QueryServiceInfo: []*source.ServiceInfoResult{
+					{UID: "svc-1", Service: "my-service", NamespaceUID: "ns-1", ServiceType: "ClusterIP"},
+				},
+				source.QueryServiceUptime: []*source.UptimeResult{
+					{UID: "svc-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Service{
+				"svc-1": {
+					UID:          "svc-1",
+					Name:         "my-service",
+					NamespaceUID: "ns-1",
+					Type:         kubemodel.ServiceTypeClusterIP,
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+		{
+			name: "service without uptime is not registered",
+			overrides: map[string]any{
+				source.QueryServiceInfo: []*source.ServiceInfoResult{
+					{UID: "svc-1", Service: "my-service", NamespaceUID: "ns-1"},
+				},
+			},
+			want: map[string]*kubemodel.Service{},
+		},
+		{
+			name: "service without namespace uid is not registered",
+			overrides: map[string]any{
+				source.QueryServiceInfo: []*source.ServiceInfoResult{
+					{UID: "svc-1", Service: "my-service"},
+				},
+				source.QueryServiceUptime: []*source.UptimeResult{
+					{UID: "svc-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Service{},
+		},
+		{
+			name: "load balancer service with ingress address",
+			overrides: map[string]any{
+				source.QueryServiceInfo: []*source.ServiceInfoResult{
+					{UID: "svc-1", Service: "my-lb", NamespaceUID: "ns-1", ServiceType: "LoadBalancer", LBIngressAddress: "1.2.3.4"},
+				},
+				source.QueryServiceUptime: []*source.UptimeResult{
+					{UID: "svc-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Service{
+				"svc-1": {
+					UID:              "svc-1",
+					Name:             "my-lb",
+					NamespaceUID:     "ns-1",
+					Type:             kubemodel.ServiceTypeLoadBalancer,
+					LBIngressAddress: "1.2.3.4",
+					Start:            start,
+					End:              end,
+				},
+			},
+		},
+		{
+			name: "service selector labels are attached",
+			overrides: map[string]any{
+				source.QueryServiceInfo: []*source.ServiceInfoResult{
+					{UID: "svc-1", Service: "my-service", NamespaceUID: "ns-1", ServiceType: "ClusterIP"},
+				},
+				source.QueryServiceUptime: []*source.UptimeResult{
+					{UID: "svc-1", First: start, Last: end},
+				},
+				source.QueryServiceSelectorLabels: []*source.ServiceLabelsResult{
+					{UID: "svc-1", Labels: map[string]string{"app": "web", "tier": "frontend"}},
+				},
+			},
+			want: map[string]*kubemodel.Service{
+				"svc-1": {
+					UID:          "svc-1",
+					Name:         "my-service",
+					NamespaceUID: "ns-1",
+					Type:         kubemodel.ServiceTypeClusterIP,
+					Start:        start,
+					End:          end,
+					Selector:     map[string]string{"app": "web", "tier": "frontend"},
+				},
+			},
+		},
+		{
+			name: "uptime for unknown service is ignored",
+			overrides: map[string]any{
+				source.QueryServiceInfo: []*source.ServiceInfoResult{
+					{UID: "svc-1", Service: "my-service", NamespaceUID: "ns-1"},
+				},
+				source.QueryServiceUptime: []*source.UptimeResult{
+					{UID: "svc-1", First: start, Last: end},
+					{UID: "unknown-svc", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.Service{
+				"svc-1": {
+					UID:          "svc-1",
+					Name:         "my-service",
+					NamespaceUID: "ns-1",
+					Type:         kubemodel.ServiceTypeClusterIP,
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			seedCluster(ds, start, end)
+			for method, result := range tt.overrides {
+				ds.Querier.SetOverride(method, result)
+			}
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.Services)
+		})
+	}
+}

+ 141 - 0
core/pkg/compute/kubemodel/statefulset_test.go

@@ -0,0 +1,141 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func TestComputeStatefulSets(t *testing.T) {
+	start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := start.Add(time.Hour)
+
+	tests := []struct {
+		name      string
+		overrides map[string]any
+		want      map[string]*kubemodel.StatefulSet
+	}{
+		{
+			name:      "no data returns empty statefulset map",
+			overrides: map[string]any{},
+			want:      map[string]*kubemodel.StatefulSet{},
+		},
+		{
+			name: "basic statefulset info and uptime",
+			overrides: map[string]any{
+				source.QueryStatefulSetInfo: []*source.StatefulSetInfoResult{
+					{UID: "sts-1", StatefulSet: "my-db", NamespaceUID: "ns-1"},
+				},
+				source.QueryStatefulSetUptime: []*source.UptimeResult{
+					{UID: "sts-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.StatefulSet{
+				"sts-1": {
+					UID:          "sts-1",
+					Name:         "my-db",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+				},
+			},
+		},
+		{
+			name: "statefulset without uptime is not registered",
+			overrides: map[string]any{
+				source.QueryStatefulSetInfo: []*source.StatefulSetInfoResult{
+					{UID: "sts-1", StatefulSet: "my-db", NamespaceUID: "ns-1"},
+				},
+			},
+			want: map[string]*kubemodel.StatefulSet{},
+		},
+		{
+			name: "statefulset without namespace uid is not registered",
+			overrides: map[string]any{
+				source.QueryStatefulSetInfo: []*source.StatefulSetInfoResult{
+					{UID: "sts-1", StatefulSet: "my-db"},
+				},
+				source.QueryStatefulSetUptime: []*source.UptimeResult{
+					{UID: "sts-1", First: start, Last: end},
+				},
+			},
+			want: map[string]*kubemodel.StatefulSet{},
+		},
+		{
+			name: "statefulset labels and annotations are attached",
+			overrides: map[string]any{
+				source.QueryStatefulSetInfo: []*source.StatefulSetInfoResult{
+					{UID: "sts-1", StatefulSet: "my-db", NamespaceUID: "ns-1"},
+				},
+				source.QueryStatefulSetUptime: []*source.UptimeResult{
+					{UID: "sts-1", First: start, Last: end},
+				},
+				source.QueryStatefulSetLabels: []*source.LabelsResult{
+					{UID: "sts-1", Labels: map[string]string{"app": "postgres"}},
+				},
+				source.QueryStatefulSetAnnotations: []*source.AnnotationsResult{
+					{UID: "sts-1", Annotations: map[string]string{"version": "14"}},
+				},
+			},
+			want: map[string]*kubemodel.StatefulSet{
+				"sts-1": {
+					UID:          "sts-1",
+					Name:         "my-db",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					Labels:       map[string]string{"app": "postgres"},
+					Annotations:  map[string]string{"version": "14"},
+				},
+			},
+		},
+		{
+			name: "statefulset match labels are attached",
+			overrides: map[string]any{
+				source.QueryStatefulSetInfo: []*source.StatefulSetInfoResult{
+					{UID: "sts-1", StatefulSet: "my-db", NamespaceUID: "ns-1"},
+				},
+				source.QueryStatefulSetUptime: []*source.UptimeResult{
+					{UID: "sts-1", First: start, Last: end},
+				},
+				source.QueryStatefulSetMatchLabels: []*source.StatefulSetLabelsResult{
+					{UID: "sts-1", Labels: map[string]string{"app": "postgres"}},
+				},
+			},
+			want: map[string]*kubemodel.StatefulSet{
+				"sts-1": {
+					UID:          "sts-1",
+					Name:         "my-db",
+					NamespaceUID: "ns-1",
+					Start:        start,
+					End:          end,
+					MatchLabels:  map[string]string{"app": "postgres"},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := source.NewMockOpenCostDataSource()
+			ds.ResolutionValue = 5 * time.Minute
+			seedCluster(ds, start, end)
+			for method, result := range tt.overrides {
+				ds.Querier.SetOverride(method, result)
+			}
+
+			km, err := NewKubeModel(testClusterUID, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.StatefulSets)
+		})
+	}
+}

+ 11 - 3
core/pkg/env/core.go

@@ -23,6 +23,7 @@ const (
 
 	ExportLegacyDataModelEnvVar = "EXPORT_LEGACY_DATA_MODEL"
 	ExportKubeModelEnvVar       = "EXPORT_KUBEMODEL"
+	ForceKubeModelV1EnvVar      = "FORCE_KUBEMODEL_V1"
 )
 
 // GetAPIPort returns the environment variable value for APIPortEnvVar which
@@ -65,10 +66,17 @@ func GetInstallNamespace(def string) string {
 	return Get(InstallNamespaceEnvVar, def)
 }
 
-func GetExportLegacyDataModel() bool {
+func IsLegacyDataModelExported() bool {
 	return GetBool(ExportLegacyDataModelEnvVar, true)
 }
 
-func GetExportKubeModel() bool {
-	return GetBool(ExportKubeModelEnvVar, true)
+func IsKubeModelExported() bool {
+	return GetBool(ExportKubeModelEnvVar, false)
+}
+
+// IsKubeModelV1Forced returns true if the kubemodel pipeline should always
+// export the legacy v1 (cluster, namespaces, resource quotas only) shape,
+// regardless of whether the source reports a complete kubemodel.
+func IsKubeModelV1Forced() bool {
+	return GetBool(ForceKubeModelV1EnvVar, true)
 }

+ 102 - 0
core/pkg/env/metrics.go

@@ -0,0 +1,102 @@
+package env
+
+const (
+	EmitPodAnnotationsMetricEnvVar       = "EMIT_POD_ANNOTATIONS_METRIC"
+	EmitNamespaceAnnotationsMetricEnvVar = "EMIT_NAMESPACE_ANNOTATIONS_METRIC"
+	EmitDeprecatedMetrics                = "EMIT_DEPRECATED_METRICS"
+
+	EmitKsmV1MetricsEnvVar = "EMIT_KSM_V1_METRICS"
+	EmitKsmV1MetricsOnly   = "EMIT_KSM_V1_METRICS_ONLY"
+
+	EmitDeploymentLabelsMetricEnvVar      = "EMIT_DEPLOYMENT_LABELS_METRIC"
+	EmitDeploymentAnnotationsMetricEnvVar = "EMIT_DEPLOYMENT_ANNOTATIONS_METRIC"
+
+	EmitStatefulSetLabelsMetricEnvVar      = "EMIT_STATEFULSET_LABELS_METRIC"
+	EmitStatefulSetAnnotationsMetricEnvVar = "EMIT_STATEFULSET_ANNOTATIONS_METRIC"
+
+	EmitDaemonSetLabelsMetricEnvVar      = "EMIT_DAEMONSET_LABELS_METRIC"
+	EmitDaemonSetAnnotationsMetricEnvVar = "EMIT_DAEMONSET_ANNOTATIONS_METRIC"
+
+	EmitJobLabelsMetricEnvVar      = "EMIT_JOB_LABELS_METRIC"
+	EmitJobAnnotationsMetricEnvVar = "EMIT_JOB_ANNOTATIONS_METRIC"
+
+	EmitCronJobLabelsMetricEnvVar      = "EMIT_CRONJOB_LABELS_METRIC"
+	EmitCronJobAnnotationsMetricEnvVar = "EMIT_CRONJOB_ANNOTATIONS_METRIC"
+
+	EmitReplicaSetLabelsMetricEnvVar      = "EMIT_REPLICASET_LABELS_METRIC"
+	EmitReplicaSetAnnotationsMetricEnvVar = "EMIT_REPLICASET_ANNOTATIONS_METRIC"
+)
+
+// IsEmitNamespaceAnnotationsMetric returns true if cost-model is configured to emit the kube_namespace_annotations metric
+// containing the namespace annotations
+func IsEmitNamespaceAnnotationsMetric() bool {
+	return GetBool(EmitNamespaceAnnotationsMetricEnvVar, false)
+}
+
+// IsEmitPodAnnotationsMetric returns true if cost-model is configured to emit the kube_pod_annotations metric containing
+// pod annotations.
+func IsEmitPodAnnotationsMetric() bool {
+	return GetBool(EmitPodAnnotationsMetricEnvVar, false)
+}
+
+// IsEmitKsmV1Metrics returns true if cost-model is configured to emit all necessary KSM v1
+// metrics that were removed in KSM v2
+func IsEmitKsmV1Metrics() bool {
+	return GetBool(EmitKsmV1MetricsEnvVar, true)
+}
+
+func IsEmitKsmV1MetricsOnly() bool {
+	return GetBool(EmitKsmV1MetricsOnly, false)
+}
+
+func IsEmitDeprecatedMetrics() bool {
+	return GetBool(EmitDeprecatedMetrics, false)
+}
+
+func IsEmitDeploymentLabelsMetric() bool {
+	return GetBool(EmitDeploymentLabelsMetricEnvVar, true)
+}
+
+func IsEmitDeploymentAnnotationsMetric() bool {
+	return GetBool(EmitDeploymentAnnotationsMetricEnvVar, false)
+}
+
+func IsEmitStatefulSetLabelsMetric() bool {
+	return GetBool(EmitStatefulSetLabelsMetricEnvVar, true)
+}
+
+func IsEmitStatefulSetAnnotationsMetric() bool {
+	return GetBool(EmitStatefulSetAnnotationsMetricEnvVar, false)
+}
+
+func IsEmitDaemonSetLabelsMetric() bool {
+	return GetBool(EmitDaemonSetLabelsMetricEnvVar, true)
+}
+
+func IsEmitDaemonSetAnnotationsMetric() bool {
+	return GetBool(EmitDaemonSetAnnotationsMetricEnvVar, false)
+}
+
+func IsEmitJobLabelsMetric() bool {
+	return GetBool(EmitJobLabelsMetricEnvVar, true)
+}
+
+func IsEmitJobAnnotationsMetric() bool {
+	return GetBool(EmitJobAnnotationsMetricEnvVar, false)
+}
+
+func IsEmitCronJobLabelsMetric() bool {
+	return GetBool(EmitCronJobLabelsMetricEnvVar, true)
+}
+
+func IsEmitCronJobAnnotationsMetric() bool {
+	return GetBool(EmitCronJobAnnotationsMetricEnvVar, false)
+}
+
+func IsEmitReplicaSetLabelsMetric() bool {
+	return GetBool(EmitReplicaSetLabelsMetricEnvVar, true)
+}
+
+func IsEmitReplicaSetAnnotationsMetric() bool {
+	return GetBool(EmitReplicaSetAnnotationsMetricEnvVar, false)
+}

+ 12 - 12
core/pkg/model/kubemodel/container.go

@@ -7,18 +7,18 @@ import (
 
 // @bingen:generate:Container
 type Container struct {
-	PodUID            string             `json:"podUid"`
-	Name              string             `json:"name"`
-	ResourceRequests  ResourceQuantities `json:"resourceRequests"`
-	ResourceLimits    ResourceQuantities `json:"resourceLimits"`
-	CPUCoresAllocated float64            `json:"cpuCoresAllocated"`
-	CPUCoreUsageAvg   float64            `json:"cpuCoreUsageAvg"`
-	CPUCoreUsageMax   float64            `json:"cpuCoreUsageMax"`
-	RAMBytesAllocated float64            `json:"ramBytesAllocated"`
-	RAMBytesUsageAvg  float64            `json:"ramBytesUsageAvg"`
-	RAMBytesUsageMax  float64            `json:"ramBytesUsageMax"`
-	Start             time.Time          `json:"start"`
-	End               time.Time          `json:"end"`
+	PodUID                string             `json:"podUid"`
+	Name                  string             `json:"name"`
+	ResourceRequests      ResourceQuantities `json:"resourceRequests"`
+	ResourceLimits        ResourceQuantities `json:"resourceLimits"`
+	CPUCoreAllocationAvg  float64            `json:"cpuCoreAllocationAvg"`
+	CPUCoreUsageAvg       float64            `json:"cpuCoreUsageAvg"`
+	CPUCoreUsageMax       float64            `json:"cpuCoreUsageMax"`
+	RAMBytesAllocationAvg float64            `json:"ramBytesAllocationAvg"`
+	RAMBytesUsageAvg      float64            `json:"ramBytesUsageAvg"`
+	RAMBytesUsageMax      float64            `json:"ramBytesUsageMax"`
+	Start                 time.Time          `json:"start"`
+	End                   time.Time          `json:"end"`
 }
 
 func (c *Container) GetKey() string {

+ 1 - 1
core/pkg/model/kubemodel/daemonset.go

@@ -13,7 +13,7 @@ type DaemonSet struct {
 	Name             string            `json:"name"`
 	Labels           map[string]string `json:"labels,omitempty"`
 	Annotations      map[string]string `json:"annotations,omitempty"`
-	DevicePluginInfo map[string]string `json:"devicePluginInfo"`
+	DevicePluginInfo map[string]string `json:"devicePluginInfo"` // bingen:field[ignore]
 	Start            time.Time         `json:"start,omitempty"`
 	End              time.Time         `json:"end,omitempty"`
 }

+ 1 - 1
core/pkg/model/kubemodel/dcgm.go

@@ -8,7 +8,7 @@ import (
 // DCGMDevice holds recording from the DCGM exporter which provides identification and usage metrics for
 // Nvidia gpu. These Nvidia devices can be incorporated into the cluster via k8s Device Plugin API or DRAs.
 // While the DCGM exporter does provide unique identifiers for the containers that it is reporting metrics on,
-// It is split out here to provide some isolate from the rest of the KubeModel which represent universal structures
+// It is split out here to provide some isolation from the rest of the KubeModel which represent universal structures
 // from the k8s API. It is left to the end user to interpret the relationships to the rest of the cluster based on
 // container unique identifiers
 // @bingen:generate:DCGMDevice

+ 1 - 1
core/pkg/model/kubemodel/kubemodel.go

@@ -24,7 +24,7 @@ type KubeModelSet struct {
 	PersistentVolumeClaims map[string]*PersistentVolumeClaim `json:"pvcs"`              // @bingen:field[version=2]
 	Pods                   map[string]*Pod                   `json:"pods"`              // @bingen:field[version=2]
 	Containers             map[string]*Container             `json:"containers"`        // @bingen:field[version=2]
-	DCGMDevices            map[string]*DCGMDevice            `json:"dcgmDevices"`       // @bingen:field[version=2]
+	DCGMDevices            map[string]*DCGMDevice            `json:"dcgmDevices"`       // @bingen:field[ignore]
 }
 
 func NewKubeModelSet(start time.Time, end time.Time) *KubeModelSet {

+ 7 - 160
core/pkg/model/kubemodel/kubemodel_codecs.go

@@ -14,6 +14,7 @@ package kubemodel
 import (
 	"cmp"
 	"fmt"
+	"github.com/opencost/opencost/core/pkg/model/shared"
 	"io"
 	"iter"
 	"os"
@@ -24,9 +25,7 @@ import (
 	"time"
 	"unsafe"
 
-	"github.com/opencost/opencost/core/pkg/model/shared"
-
-	"github.com/opencost/opencost/core/pkg/util"
+	util "github.com/opencost/opencost/core/pkg/util"
 )
 
 const (
@@ -1207,13 +1206,13 @@ func (target *Container) MarshalBinaryWithContext(ctx *EncodingContext) (err err
 	}
 	// --- [end][write][alias](ResourceQuantities) ---
 
-	buff.WriteFloat64(target.CPUCoresAllocated) // write float64
+	buff.WriteFloat64(target.CPUCoreAllocationAvg) // write float64
 
 	buff.WriteFloat64(target.CPUCoreUsageAvg) // write float64
 
 	buff.WriteFloat64(target.CPUCoreUsageMax) // write float64
 
-	buff.WriteFloat64(target.RAMBytesAllocated) // write float64
+	buff.WriteFloat64(target.RAMBytesAllocationAvg) // write float64
 
 	buff.WriteFloat64(target.RAMBytesUsageAvg) // write float64
 
@@ -1400,7 +1399,7 @@ func (target *Container) UnmarshalBinaryWithContext(ctx *DecodingContext) (err e
 	// --- [end][read][alias](ResourceQuantities) ---
 
 	bb := buff.ReadFloat64() // read float64
-	target.CPUCoresAllocated = bb
+	target.CPUCoreAllocationAvg = bb
 
 	cc := buff.ReadFloat64() // read float64
 	target.CPUCoreUsageAvg = cc
@@ -1409,7 +1408,7 @@ func (target *Container) UnmarshalBinaryWithContext(ctx *DecodingContext) (err e
 	target.CPUCoreUsageMax = dd
 
 	ee := buff.ReadFloat64() // read float64
-	target.RAMBytesAllocated = ee
+	target.RAMBytesAllocationAvg = ee
 
 	ff := buff.ReadFloat64() // read float64
 	target.RAMBytesUsageAvg = ff
@@ -4286,38 +4285,6 @@ func (target *KubeModelSet) MarshalBinaryWithContext(ctx *EncodingContext) (err
 		}
 		// --- [end][write][map](map[string]*Container) ---
 
-	}
-	if target.DCGMDevices == nil {
-		buff.WriteUInt8(uint8(0)) // write nil byte
-	} else {
-		buff.WriteUInt8(uint8(1)) // write non-nil byte
-
-		// --- [begin][write][map](map[string]*DCGMDevice) ---
-		buff.WriteInt(len(target.DCGMDevices)) // map length
-		for vvvvvvvvvvvvvvv, zzzzzzzzzzzzzzz := range target.DCGMDevices {
-			if ctx.IsStringTable() {
-				r := ctx.Table.AddOrGet(vvvvvvvvvvvvvvv)
-				buff.WriteInt(r) // write table index
-			} else {
-				buff.WriteString(vvvvvvvvvvvvvvv) // write string
-			}
-			if zzzzzzzzzzzzzzz == nil {
-				buff.WriteUInt8(uint8(0)) // write nil byte
-			} else {
-				buff.WriteUInt8(uint8(1)) // write non-nil byte
-
-				// --- [begin][write][struct](DCGMDevice) ---
-				buff.WriteInt(0) // [compatibility, unused]
-				errR := zzzzzzzzzzzzzzz.MarshalBinaryWithContext(ctx)
-				if errR != nil {
-					return errR
-				}
-				// --- [end][write][struct](DCGMDevice) ---
-
-			}
-		}
-		// --- [end][write][map](map[string]*DCGMDevice) ---
-
 	}
 
 	return nil
@@ -4396,7 +4363,6 @@ func (target *KubeModelSet) UnmarshalBinaryWithContext(ctx *DecodingContext) (er
 	}
 	// field version check
 	if uint8(1) <= version {
-
 		// --- [begin][read][struct](Window) ---
 		b := new(Window)
 		buff.ReadInt() // [compatibility, unused]
@@ -4678,7 +4644,6 @@ func (target *KubeModelSet) UnmarshalBinaryWithContext(ctx *DecodingContext) (er
 				if buff.ReadUInt8() == uint8(0) {
 					zzzzzz = nil
 				} else {
-
 					// --- [begin][read][struct](DaemonSet) ---
 					uu := new(DaemonSet)
 					buff.ReadInt() // [compatibility, unused]
@@ -4904,7 +4869,6 @@ func (target *KubeModelSet) UnmarshalBinaryWithContext(ctx *DecodingContext) (er
 				if buff.ReadUInt8() == uint8(0) {
 					zzzzzzzzzzz = nil
 				} else {
-
 					// --- [begin][read][struct](PersistentVolume) ---
 					ffff := new(PersistentVolume)
 					buff.ReadInt() // [compatibility, unused]
@@ -5040,7 +5004,6 @@ func (target *KubeModelSet) UnmarshalBinaryWithContext(ctx *DecodingContext) (er
 				if buff.ReadUInt8() == uint8(0) {
 					zzzzzzzzzzzzzz = nil
 				} else {
-
 					// --- [begin][read][struct](Container) ---
 					ccccc := new(Container)
 					buff.ReadInt() // [compatibility, unused]
@@ -5062,52 +5025,6 @@ func (target *KubeModelSet) UnmarshalBinaryWithContext(ctx *DecodingContext) (er
 	} else {
 		target.Containers = nil
 	}
-	// field version check
-	if uint8(2) <= version {
-		if buff.ReadUInt8() == uint8(0) {
-			target.DCGMDevices = nil
-		} else {
-			// --- [begin][read][map](map[string]*DCGMDevice) ---
-			eeeee := buff.ReadInt() // map len
-			ddddd := make(map[string]*DCGMDevice, eeeee)
-			for range eeeee {
-				var vvvvvvvvvvvvvvv string
-				var ggggg string
-				if ctx.IsStringTable() {
-					hhhhh := buff.ReadInt() // read string index
-					ggggg = ctx.Table.At(hhhhh)
-				} else {
-					ggggg = buff.ReadString() // read string
-				}
-				fffff := ggggg
-				vvvvvvvvvvvvvvv = fffff
-
-				var zzzzzzzzzzzzzzz *DCGMDevice
-				if buff.ReadUInt8() == uint8(0) {
-					zzzzzzzzzzzzzzz = nil
-				} else {
-
-					// --- [begin][read][struct](DCGMDevice) ---
-					lllll := new(DCGMDevice)
-					buff.ReadInt() // [compatibility, unused]
-					errR := lllll.UnmarshalBinaryWithContext(ctx)
-					if errR != nil {
-						return errR
-					}
-					zzzzzzzzzzzzzzz = lllll
-					// --- [end][read][struct](DCGMDevice) ---
-
-				}
-				ddddd[vvvvvvvvvvvvvvv] = zzzzzzzzzzzzzzz
-			}
-			target.DCGMDevices = ddddd
-			// --- [end][read][map](map[string]*DCGMDevice) ---
-
-		}
-
-	} else {
-		target.DCGMDevices = nil
-	}
 
 	return nil
 }
@@ -5589,7 +5506,6 @@ func (stream *KubeModelSetStream) Stream() iter.Seq2[BingenFieldInfo, *BingenVal
 					if buff.ReadUInt8() == uint8(0) {
 						zzzzzz = nil
 					} else {
-
 						// --- [begin][read][struct](DaemonSet) ---
 						rr := new(DaemonSet)
 						buff.ReadInt() // [compatibility, unused]
@@ -5885,7 +5801,6 @@ func (stream *KubeModelSetStream) Stream() iter.Seq2[BingenFieldInfo, *BingenVal
 					if buff.ReadUInt8() == uint8(0) {
 						zzzzzzzzzzz = nil
 					} else {
-
 						// --- [begin][read][struct](PersistentVolume) ---
 						www := new(PersistentVolume)
 						buff.ReadInt() // [compatibility, unused]
@@ -6063,7 +5978,6 @@ func (stream *KubeModelSetStream) Stream() iter.Seq2[BingenFieldInfo, *BingenVal
 					if buff.ReadUInt8() == uint8(0) {
 						zzzzzzzzzzzzzz = nil
 					} else {
-
 						// --- [begin][read][struct](Container) ---
 						pppp := new(Container)
 						buff.ReadInt() // [compatibility, unused]
@@ -6092,66 +6006,6 @@ func (stream *KubeModelSetStream) Stream() iter.Seq2[BingenFieldInfo, *BingenVal
 				return
 			}
 		}
-
-		fi = BingenFieldInfo{
-			Type: reflect.TypeFor[map[string]*DCGMDevice](),
-			Name: "DCGMDevices",
-		}
-		// field version check
-		if uint8(2) <= version {
-
-			if buff.ReadUInt8() == uint8(0) {
-				if !yield(fi, nil) {
-					return
-				}
-			} else {
-				// --- [begin][read][streaming-map](map[string]*DCGMDevice) ---
-				qqqq := buff.ReadInt() // map len
-				for range qqqq {
-					var vvvvvvvvvvvvvvv string
-					var ssss string
-					if ctx.IsStringTable() {
-						tttt := buff.ReadInt() // read string index
-						ssss = ctx.Table.At(tttt)
-					} else {
-						ssss = buff.ReadString() // read string
-					}
-					rrrr := ssss
-					vvvvvvvvvvvvvvv = rrrr
-
-					var zzzzzzzzzzzzzzz *DCGMDevice
-					if buff.ReadUInt8() == uint8(0) {
-						zzzzzzzzzzzzzzz = nil
-					} else {
-
-						// --- [begin][read][struct](DCGMDevice) ---
-						uuuu := new(DCGMDevice)
-						buff.ReadInt() // [compatibility, unused]
-						errR := uuuu.UnmarshalBinaryWithContext(ctx)
-						if errR != nil {
-							stream.err = errR
-							return
-
-						}
-						zzzzzzzzzzzzzzz = uuuu
-						// --- [end][read][struct](DCGMDevice) ---
-
-					}
-
-					if !yield(fi, pairV(vvvvvvvvvvvvvvv, zzzzzzzzzzzzzzz)) {
-						return
-					}
-				}
-				// --- [end][read][streaming-map](map[string]*DCGMDevice) ---
-
-			}
-
-		} else {
-
-			if !yield(fi, nil) {
-				return
-			}
-		}
 	}
 }
 
@@ -6230,7 +6084,6 @@ func (target *Metadata) MarshalBinaryWithContext(ctx *EncodingContext) (err erro
 		// --- [begin][write][slice]([]Diagnostic) ---
 		buff.WriteInt(len(target.Diagnostics)) // slice length
 		for i := range target.Diagnostics {
-
 			// --- [begin][write][struct](Diagnostic) ---
 			buff.WriteInt(0) // [compatibility, unused]
 			errC := target.Diagnostics[i].MarshalBinaryWithContext(ctx)
@@ -6354,7 +6207,6 @@ func (target *Metadata) UnmarshalBinaryWithContext(ctx *DecodingContext) (err er
 			l := buff.ReadInt() // slice len
 			h := make([]Diagnostic, l)
 			for i := range l {
-
 				// --- [begin][read][struct](Diagnostic) ---
 				n := new(Diagnostic)
 				buff.ReadInt() // [compatibility, unused]
@@ -6377,7 +6229,6 @@ func (target *Metadata) UnmarshalBinaryWithContext(ctx *DecodingContext) (err er
 	}
 	// field version check
 	if uint8(1) <= version {
-
 		// --- [begin][read][alias](DiagnosticLevel) ---
 		var o int
 		p := buff.ReadInt() // read int
@@ -8169,7 +8020,6 @@ func (target *Pod) MarshalBinaryWithContext(ctx *EncodingContext) (err error) {
 		// --- [begin][write][slice]([]NetworkTrafficDetail) ---
 		buff.WriteInt(len(target.NetworkTrafficDetails)) // slice length
 		for ii := range target.NetworkTrafficDetails {
-
 			// --- [begin][write][struct](NetworkTrafficDetail) ---
 			buff.WriteInt(0) // [compatibility, unused]
 			errC := target.NetworkTrafficDetails[ii].MarshalBinaryWithContext(ctx)
@@ -8422,7 +8272,6 @@ func (target *Pod) UnmarshalBinaryWithContext(ctx *DecodingContext) (err error)
 		tt := buff.ReadInt() // slice len
 		ss := make([]NetworkTrafficDetail, tt)
 		for ii := range tt {
-
 			// --- [begin][read][struct](NetworkTrafficDetail) ---
 			ww := new(NetworkTrafficDetail)
 			buff.ReadInt() // [compatibility, unused]
@@ -8679,7 +8528,6 @@ func (target *ReplicaSet) MarshalBinaryWithContext(ctx *EncodingContext) (err er
 		// --- [begin][write][slice]([]Owner) ---
 		buff.WriteInt(len(target.Owners)) // slice length
 		for i := range target.Owners {
-
 			// --- [begin][write][struct](Owner) ---
 			buff.WriteInt(0) // [compatibility, unused]
 			errA := target.Owners[i].MarshalBinaryWithContext(ctx)
@@ -8854,7 +8702,6 @@ func (target *ReplicaSet) UnmarshalBinaryWithContext(ctx *DecodingContext) (err
 		n := buff.ReadInt() // slice len
 		m := make([]Owner, n)
 		for i := range n {
-
 			// --- [begin][read][struct](Owner) ---
 			p := new(Owner)
 			buff.ReadInt() // [compatibility, unused]
@@ -9140,7 +8987,6 @@ func (target *ResourceQuantity) UnmarshalBinaryWithContext(ctx *DecodingContext)
 	}
 	// field version check
 	if uint8(1) <= version {
-
 		// --- [begin][read][alias](Unit) ---
 		var e string
 		var g string
@@ -9160,6 +9006,7 @@ func (target *ResourceQuantity) UnmarshalBinaryWithContext(ctx *DecodingContext)
 	}
 	// field version check
 	if uint8(1) <= version {
+
 		// --- [begin][read][alias](Stats) ---
 		var l map[StatType]float64
 		if buff.ReadUInt8() == uint8(0) {

+ 2 - 1
core/pkg/model/kubemodel/kubemodel_helpers_test.go

@@ -59,5 +59,6 @@ func KubeModelSetEquals(t *testing.T, this, that *KubeModelSet) {
 	require.Equal(t, this.PersistentVolumeClaims, that.PersistentVolumeClaims)
 	require.Equal(t, this.Services, that.Services)
 	require.Equal(t, this.PersistentVolumes, that.PersistentVolumes)
-	require.Equal(t, this.DCGMDevices, that.DCGMDevices)
+	// DCGM is ignored by bingen
+	// require.Equal(t, this.DCGMDevices, that.DCGMDevices)
 }

+ 8 - 8
core/pkg/model/kubemodel/mock.go

@@ -121,14 +121,14 @@ func NewMockKubeModelSet(start, end time.Time) *KubeModelSet {
 			ResourceCPU:    {Resource: ResourceCPU, Unit: UnitMillicore, Values: Stats{StatAvg: 500, StatMax: 500}},
 			ResourceMemory: {Resource: ResourceMemory, Unit: UnitByte, Values: Stats{StatAvg: 1e9, StatMax: 1e9}},
 		},
-		CPUCoresAllocated: 0.25,
-		CPUCoreUsageAvg:   0.18,
-		CPUCoreUsageMax:   0.42,
-		RAMBytesAllocated: 512e6,
-		RAMBytesUsageAvg:  300e6,
-		RAMBytesUsageMax:  480e6,
-		Start:             start,
-		End:               end,
+		CPUCoreAllocationAvg:  0.25,
+		CPUCoreUsageAvg:       0.18,
+		CPUCoreUsageMax:       0.42,
+		RAMBytesAllocationAvg: 512e6,
+		RAMBytesUsageAvg:      300e6,
+		RAMBytesUsageMax:      480e6,
+		Start:                 start,
+		End:                   end,
 	})
 
 	// --- Deployment ---

+ 213 - 0
core/pkg/source/datasource.go

@@ -8,6 +8,216 @@ import (
 	"github.com/opencost/opencost/core/pkg/diagnostics"
 )
 
+// Query name constants for use with MockMetricsQuerier.SetOverride.
+const (
+	// Local Cluster Disks
+	QueryLocalStorageActiveMinutes = "QueryLocalStorageActiveMinutes"
+	QueryLocalStorageUsedAvg       = "QueryLocalStorageUsedAvg"
+	QueryLocalStorageUsedMax       = "QueryLocalStorageUsedMax"
+	QueryLocalStorageBytes         = "QueryLocalStorageBytes"
+	QueryKMLocalStorageUsedAvg     = "QueryKMLocalStorageUsedAvg"
+	QueryKMLocalStorageUsedMax     = "QueryKMLocalStorageUsedMax"
+	QueryKMLocalStorageBytes       = "QueryKMLocalStorageBytes"
+
+	// Nodes
+	QueryNodeInfo                 = "QueryNodeInfo"
+	QueryNodeUptime               = "QueryNodeUptime"
+	QueryNodeActiveMinutes        = "QueryNodeActiveMinutes"
+	QueryNodeCPUCoresCapacity     = "QueryNodeCPUCoresCapacity"
+	QueryNodeCPUCoresAllocatable  = "QueryNodeCPUCoresAllocatable"
+	QueryNodeRAMBytesCapacity     = "QueryNodeRAMBytesCapacity"
+	QueryNodeRAMBytesAllocatable  = "QueryNodeRAMBytesAllocatable"
+	QueryNodeGPUCount             = "QueryNodeGPUCount"
+	QueryNodeCPUModeTotal         = "QueryNodeCPUModeTotal"
+	QueryNodeIsSpot               = "QueryNodeIsSpot"
+	QueryNodeRAMSystemPercent     = "QueryNodeRAMSystemPercent"
+	QueryNodeRAMUserPercent       = "QueryNodeRAMUserPercent"
+	QueryNodeResourceCapacities   = "QueryNodeResourceCapacities"
+	QueryNodeResourcesAllocatable = "QueryNodeResourcesAllocatable"
+
+	// Load Balancers
+	QueryLBActiveMinutes = "QueryLBActiveMinutes"
+	QueryLBPricePerHr    = "QueryLBPricePerHr"
+
+	// Cluster Management
+	QueryClusterInfo                 = "QueryClusterInfo"
+	QueryClusterKubeModelVersion     = "QueryClusterKubeModelVersion"
+	QueryClusterUptime               = "QueryClusterUptime"
+	QueryClusterManagementDuration   = "QueryClusterManagementDuration"
+	QueryClusterManagementPricePerHr = "QueryClusterManagementPricePerHr"
+
+	// Pods
+	QueryPods                   = "QueryPods"
+	QueryPodsUID                = "QueryPodsUID"
+	QueryPodInfo                = "QueryPodInfo"
+	QueryPodUptime              = "QueryPodUptime"
+	QueryPodOwners              = "QueryPodOwners"
+	QueryPodPVCVolumes          = "QueryPodPVCVolumes"
+	QueryPodNetworkEgressBytes  = "QueryPodNetworkEgressBytes"
+	QueryPodNetworkIngressBytes = "QueryPodNetworkIngressBytes"
+
+	// Container
+	QueryContainerUptime           = "QueryContainerUptime"
+	QueryContainerResourceRequests = "QueryContainerResourceRequests"
+	QueryContainerResourceLimits   = "QueryContainerResourceLimits"
+
+	// RAM
+	QueryRAMBytesAllocated    = "QueryRAMBytesAllocated"
+	QueryRAMRequests          = "QueryRAMRequests"
+	QueryRAMLimits            = "QueryRAMLimits"
+	QueryRAMUsageAvg          = "QueryRAMUsageAvg"
+	QueryRAMUsageMax          = "QueryRAMUsageMax"
+	QueryNodeRAMPricePerGiBHr = "QueryNodeRAMPricePerGiBHr"
+
+	// CPU
+	QueryCPUCoresAllocated = "QueryCPUCoresAllocated"
+	QueryCPURequests       = "QueryCPURequests"
+	QueryCPULimits         = "QueryCPULimits"
+	QueryCPUUsageAvg       = "QueryCPUUsageAvg"
+	QueryCPUUsageMax       = "QueryCPUUsageMax"
+	QueryNodeCPUPricePerHr = "QueryNodeCPUPricePerHr"
+
+	// GPU
+	QueryGPUsAllocated     = "QueryGPUsAllocated"
+	QueryGPUsRequested     = "QueryGPUsRequested"
+	QueryGPUsUsageAvg      = "QueryGPUsUsageAvg"
+	QueryGPUsUsageMax      = "QueryGPUsUsageMax"
+	QueryNodeGPUPricePerHr = "QueryNodeGPUPricePerHr"
+	QueryGPUInfo           = "QueryGPUInfo"
+	QueryIsGPUShared       = "QueryIsGPUShared"
+
+	// Device
+	QueryDCGMDeviceInfo        = "QueryDCGMDeviceInfo"
+	QueryDCGMDeviceUptime      = "QueryDCGMDeviceUptime"
+	QueryDCGMContainerUsageAvg = "QueryDCGMContainerUsageAvg"
+	QueryDCGMContainerUsageMax = "QueryDCGMContainerUsageMax"
+
+	// PVC
+	QueryPodPVCAllocation    = "QueryPodPVCAllocation"
+	QueryPVCBytesRequested   = "QueryPVCBytesRequested"
+	QueryPVCInfo             = "QueryPVCInfo"
+	QueryKMPVCInfo           = "QueryKMPVCInfo"
+	QueryPVCUptime           = "QueryPVCUptime"
+	QueryPVCBytesUsedAverage = "QueryPVCBytesUsedAverage"
+	QueryPVCBytesUsedMax     = "QueryPVCBytesUsedMax"
+
+	// PV
+	QueryPVBytes           = "QueryPVBytes"
+	QueryPVPricePerGiBHour = "QueryPVPricePerGiBHour"
+	QueryPVInfo            = "QueryPVInfo"
+	QueryPVActiveMinutes   = "QueryPVActiveMinutes"
+	QueryPVUsedAverage     = "QueryPVUsedAverage"
+	QueryPVUsedMax         = "QueryPVUsedMax"
+	QueryKMPVInfo          = "QueryKMPVInfo"
+	QueryPVUptime          = "QueryPVUptime"
+
+	// Deployment
+	QueryDeploymentInfo        = "QueryDeploymentInfo"
+	QueryDeploymentUptime      = "QueryDeploymentUptime"
+	QueryDeploymentLabels      = "QueryDeploymentLabels"
+	QueryDeploymentAnnotations = "QueryDeploymentAnnotations"
+	QueryDeploymentMatchLabels = "QueryDeploymentMatchLabels"
+
+	// StatefulSet
+	QueryStatefulSetInfo        = "QueryStatefulSetInfo"
+	QueryStatefulSetUptime      = "QueryStatefulSetUptime"
+	QueryStatefulSetLabels      = "QueryStatefulSetLabels"
+	QueryStatefulSetAnnotations = "QueryStatefulSetAnnotations"
+	QueryStatefulSetMatchLabels = "QueryStatefulSetMatchLabels"
+
+	// DaemonSet
+	QueryDaemonSetInfo        = "QueryDaemonSetInfo"
+	QueryDaemonSetUptime      = "QueryDaemonSetUptime"
+	QueryDaemonSetLabels      = "QueryDaemonSetLabels"
+	QueryDaemonSetAnnotations = "QueryDaemonSetAnnotations"
+
+	// Job
+	QueryJobInfo        = "QueryJobInfo"
+	QueryJobUptime      = "QueryJobUptime"
+	QueryJobLabels      = "QueryJobLabels"
+	QueryJobAnnotations = "QueryJobAnnotations"
+
+	// CronJob
+	QueryCronJobInfo        = "QueryCronJobInfo"
+	QueryCronJobUptime      = "QueryCronJobUptime"
+	QueryCronJobLabels      = "QueryCronJobLabels"
+	QueryCronJobAnnotations = "QueryCronJobAnnotations"
+
+	// ReplicaSet
+	QueryReplicaSetInfo           = "QueryReplicaSetInfo"
+	QueryReplicaSetUptime         = "QueryReplicaSetUptime"
+	QueryReplicaSetLabels         = "QueryReplicaSetLabels"
+	QueryReplicaSetAnnotations    = "QueryReplicaSetAnnotations"
+	QueryReplicaSetOwners         = "QueryReplicaSetOwners"
+	QueryPodsWithReplicaSetOwner  = "QueryPodsWithReplicaSetOwner"
+	QueryReplicaSetsWithoutOwners = "QueryReplicaSetsWithoutOwners"
+	QueryReplicaSetsWithRollout   = "QueryReplicaSetsWithRollout"
+
+	// Namespace
+	QueryNamespaceInfo        = "QueryNamespaceInfo"
+	QueryNamespaceUptime      = "QueryNamespaceUptime"
+	QueryNamespaceAnnotations = "QueryNamespaceAnnotations"
+	QueryNamespaceLabels      = "QueryNamespaceLabels"
+
+	// Service
+	QueryServiceInfo           = "QueryServiceInfo"
+	QueryServiceUptime         = "QueryServiceUptime"
+	QueryServiceSelectorLabels = "QueryServiceSelectorLabels"
+
+	// Network Egress
+	QueryNetZoneGiB               = "QueryNetZoneGiB"
+	QueryNetZonePricePerGiB       = "QueryNetZonePricePerGiB"
+	QueryNetRegionGiB             = "QueryNetRegionGiB"
+	QueryNetRegionPricePerGiB     = "QueryNetRegionPricePerGiB"
+	QueryNetInternetGiB           = "QueryNetInternetGiB"
+	QueryNetInternetPricePerGiB   = "QueryNetInternetPricePerGiB"
+	QueryNetInternetServiceGiB    = "QueryNetInternetServiceGiB"
+	QueryNetNatGatewayPricePerGiB = "QueryNetNatGatewayPricePerGiB"
+	QueryNetNatGatewayGiB         = "QueryNetNatGatewayGiB"
+	QueryNetTransferBytes         = "QueryNetTransferBytes"
+
+	// Network Ingress
+	QueryNetZoneIngressGiB               = "QueryNetZoneIngressGiB"
+	QueryNetRegionIngressGiB             = "QueryNetRegionIngressGiB"
+	QueryNetInternetIngressGiB           = "QueryNetInternetIngressGiB"
+	QueryNetInternetServiceIngressGiB    = "QueryNetInternetServiceIngressGiB"
+	QueryNetNatGatewayIngressPricePerGiB = "QueryNetNatGatewayIngressPricePerGiB"
+	QueryNetNatGatewayIngressGiB         = "QueryNetNatGatewayIngressGiB"
+	QueryNetReceiveBytes                 = "QueryNetReceiveBytes"
+
+	// Labels
+	QueryNodeLabels = "QueryNodeLabels"
+	QueryPodLabels  = "QueryPodLabels"
+
+	// Pod ownership
+	QueryPodAnnotations         = "QueryPodAnnotations"
+	QueryPodsWithDaemonSetOwner = "QueryPodsWithDaemonSetOwner"
+	QueryPodsWithJobOwner       = "QueryPodsWithJobOwner"
+
+	// ResourceQuotas
+	QueryResourceQuotaInfo                        = "QueryResourceQuotaInfo"
+	QueryResourceQuotaUptime                      = "QueryResourceQuotaUptime"
+	QueryResourceQuotaSpecCPURequestAverage       = "QueryResourceQuotaSpecCPURequestAverage"
+	QueryResourceQuotaSpecCPURequestMax           = "QueryResourceQuotaSpecCPURequestMax"
+	QueryResourceQuotaSpecRAMRequestAverage       = "QueryResourceQuotaSpecRAMRequestAverage"
+	QueryResourceQuotaSpecRAMRequestMax           = "QueryResourceQuotaSpecRAMRequestMax"
+	QueryResourceQuotaSpecCPULimitAverage         = "QueryResourceQuotaSpecCPULimitAverage"
+	QueryResourceQuotaSpecCPULimitMax             = "QueryResourceQuotaSpecCPULimitMax"
+	QueryResourceQuotaSpecRAMLimitAverage         = "QueryResourceQuotaSpecRAMLimitAverage"
+	QueryResourceQuotaSpecRAMLimitMax             = "QueryResourceQuotaSpecRAMLimitMax"
+	QueryResourceQuotaStatusUsedCPURequestAverage = "QueryResourceQuotaStatusUsedCPURequestAverage"
+	QueryResourceQuotaStatusUsedCPURequestMax     = "QueryResourceQuotaStatusUsedCPURequestMax"
+	QueryResourceQuotaStatusUsedRAMRequestAverage = "QueryResourceQuotaStatusUsedRAMRequestAverage"
+	QueryResourceQuotaStatusUsedRAMRequestMax     = "QueryResourceQuotaStatusUsedRAMRequestMax"
+	QueryResourceQuotaStatusUsedCPULimitAverage   = "QueryResourceQuotaStatusUsedCPULimitAverage"
+	QueryResourceQuotaStatusUsedCPULimitMax       = "QueryResourceQuotaStatusUsedCPULimitMax"
+	QueryResourceQuotaStatusUsedRAMLimitAverage   = "QueryResourceQuotaStatusUsedRAMLimitAverage"
+	QueryResourceQuotaStatusUsedRAMLimitMax       = "QueryResourceQuotaStatusUsedRAMLimitMax"
+
+	// Data Coverage
+	QueryDataCoverage = "QueryDataCoverage"
+)
+
 type MetricsQuerier interface {
 	// Cluster Disks
 
@@ -17,6 +227,7 @@ type MetricsQuerier interface {
 	QueryLocalStorageUsedMax(start, end time.Time) *Future[LocalStorageUsedMaxResult]
 	QueryLocalStorageBytes(start, end time.Time) *Future[LocalStorageBytesResult]
 
+	// Local Storage Metrics aggregated exclusively on NodeUID
 	QueryKMLocalStorageUsedAvg(start, end time.Time) *Future[NodeUIDValueResult]
 	QueryKMLocalStorageUsedMax(start, end time.Time) *Future[NodeUIDValueResult]
 	QueryKMLocalStorageBytes(start, end time.Time) *Future[UIDValueResult]
@@ -43,6 +254,7 @@ type MetricsQuerier interface {
 
 	// Cluster Management
 	QueryClusterInfo(start, end time.Time) *Future[ClusterInfoResult]
+	QueryClusterKubeModelVersion(start, end time.Time) *Future[ClusterKubeModelVersionResult]
 	QueryClusterUptime(start, end time.Time) *Future[UptimeResult]
 	QueryClusterManagementDuration(start, end time.Time) *Future[ClusterManagementDurationResult]
 	QueryClusterManagementPricePerHr(start, end time.Time) *Future[ClusterManagementPricePerHrResult]
@@ -97,6 +309,7 @@ type MetricsQuerier interface {
 	QueryPodPVCAllocation(start, end time.Time) *Future[PodPVCAllocationResult]
 	QueryPVCBytesRequested(start, end time.Time) *Future[PVCBytesRequestedResult]
 	QueryPVCInfo(start, end time.Time) *Future[PVCInfoResult]
+	// UID aggregated version of PVCInfo query
 	QueryKMPVCInfo(start, end time.Time) *Future[PVCInfoResult]
 	QueryPVCUptime(start, end time.Time) *Future[UptimeResult]
 	QueryPVCBytesUsedAverage(start, end time.Time) *Future[PVCUIDValueResult]

+ 16 - 0
core/pkg/source/decoders.go

@@ -62,6 +62,7 @@ const (
 	SameZoneLabel        = "same_zone"
 	SameRegionLabel      = "same_region"
 	NatGatewayLabel      = "nat_gateway"
+	KubeModelVersion     = "kubemodel_version"
 )
 
 const (
@@ -671,6 +672,21 @@ func DecodeClusterInfoResult(result *QueryResult) *ClusterInfoResult {
 	}
 }
 
+type ClusterKubeModelVersionResult struct {
+	UID     string
+	Version string
+}
+
+func DecodeClusterKubeModelVersionResult(result *QueryResult) *ClusterKubeModelVersionResult {
+	uid, _ := result.GetString(UIDLabel)
+	version, _ := result.GetString(KubeModelVersion)
+
+	return &ClusterKubeModelVersionResult{
+		UID:     uid,
+		Version: version,
+	}
+}
+
 type ClusterManagementDurationResult struct {
 	UID         string
 	Cluster     string

+ 1084 - 0
core/pkg/source/mock.go

@@ -0,0 +1,1084 @@
+package source
+
+import (
+	"time"
+
+	"github.com/julienschmidt/httprouter"
+	"github.com/opencost/opencost/core/pkg/clusters"
+	"github.com/opencost/opencost/core/pkg/diagnostics"
+)
+
+//--------------------------------------------------------------------------
+//  Mock MetricsQuerier (per-method override map, NoOp fallback)
+//--------------------------------------------------------------------------
+
+var _ MetricsQuerier = (*MockMetricsQuerier)(nil)
+
+// MockMetricsQuerier is a test double for MetricsQuerier. Set a field to a
+// non-nil *Future[T] to override what a specific method returns; unset methods
+// fall back to NoOpMetricsQuerier (empty results, no error).
+type MockMetricsQuerier struct {
+	noop      *NoOpMetricsQuerier
+	overrides map[string]any
+}
+
+func NewMockMetricsQuerier() *MockMetricsQuerier {
+	return &MockMetricsQuerier{
+		noop:      NewNoOpMetricsQuerier(),
+		overrides: make(map[string]any),
+	}
+}
+
+// SetOverride registers a []*T result slice to be returned when method is called.
+// Pass nil to clear an override.
+func (m *MockMetricsQuerier) SetOverride(method string, result any) {
+	if result == nil {
+		delete(m.overrides, method)
+		return
+	}
+	m.overrides[method] = result
+}
+
+// getFutureFromOverride looks up method in overrides and, if found, wraps the
+// stored []*T slice in a Future via NewFutureFrom. Falls back to fallback otherwise.
+func getFutureFromOverride[T any](overrides map[string]any, method string, fallback func() *Future[T]) *Future[T] {
+	if v, ok := overrides[method]; ok {
+		if results, ok := v.([]*T); ok {
+			return NewFutureFrom(results)
+		}
+	}
+	return fallback()
+}
+
+// Local Cluster Disks
+
+func (m *MockMetricsQuerier) QueryLocalStorageActiveMinutes(start, end time.Time) *Future[LocalStorageActiveMinutesResult] {
+	return getFutureFromOverride(m.overrides, QueryLocalStorageActiveMinutes, func() *Future[LocalStorageActiveMinutesResult] {
+		return m.noop.QueryLocalStorageActiveMinutes(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryLocalStorageUsedAvg(start, end time.Time) *Future[LocalStorageUsedAvgResult] {
+	return getFutureFromOverride(m.overrides, QueryLocalStorageUsedAvg, func() *Future[LocalStorageUsedAvgResult] {
+		return m.noop.QueryLocalStorageUsedAvg(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryLocalStorageUsedMax(start, end time.Time) *Future[LocalStorageUsedMaxResult] {
+	return getFutureFromOverride(m.overrides, QueryLocalStorageUsedMax, func() *Future[LocalStorageUsedMaxResult] {
+		return m.noop.QueryLocalStorageUsedMax(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryLocalStorageBytes(start, end time.Time) *Future[LocalStorageBytesResult] {
+	return getFutureFromOverride(m.overrides, QueryLocalStorageBytes, func() *Future[LocalStorageBytesResult] {
+		return m.noop.QueryLocalStorageBytes(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryKMLocalStorageUsedAvg(start, end time.Time) *Future[NodeUIDValueResult] {
+	return getFutureFromOverride(m.overrides, QueryKMLocalStorageUsedAvg, func() *Future[NodeUIDValueResult] {
+		return m.noop.QueryKMLocalStorageUsedAvg(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryKMLocalStorageUsedMax(start, end time.Time) *Future[NodeUIDValueResult] {
+	return getFutureFromOverride(m.overrides, QueryKMLocalStorageUsedMax, func() *Future[NodeUIDValueResult] {
+		return m.noop.QueryKMLocalStorageUsedMax(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryKMLocalStorageBytes(start, end time.Time) *Future[UIDValueResult] {
+	return getFutureFromOverride(m.overrides, QueryKMLocalStorageBytes, func() *Future[UIDValueResult] {
+		return m.noop.QueryKMLocalStorageBytes(start, end)
+	})
+}
+
+// Nodes
+
+func (m *MockMetricsQuerier) QueryNodeInfo(start, end time.Time) *Future[NodeInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeInfo, func() *Future[NodeInfoResult] {
+		return m.noop.QueryNodeInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNodeUptime(start, end time.Time) *Future[UptimeResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeUptime, func() *Future[UptimeResult] {
+		return m.noop.QueryNodeUptime(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNodeActiveMinutes(start, end time.Time) *Future[NodeActiveMinutesResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeActiveMinutes, func() *Future[NodeActiveMinutesResult] {
+		return m.noop.QueryNodeActiveMinutes(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNodeCPUCoresCapacity(start, end time.Time) *Future[NodeCPUCoresCapacityResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeCPUCoresCapacity, func() *Future[NodeCPUCoresCapacityResult] {
+		return m.noop.QueryNodeCPUCoresCapacity(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNodeCPUCoresAllocatable(start, end time.Time) *Future[NodeCPUCoresAllocatableResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeCPUCoresAllocatable, func() *Future[NodeCPUCoresAllocatableResult] {
+		return m.noop.QueryNodeCPUCoresAllocatable(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNodeRAMBytesCapacity(start, end time.Time) *Future[NodeRAMBytesCapacityResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeRAMBytesCapacity, func() *Future[NodeRAMBytesCapacityResult] {
+		return m.noop.QueryNodeRAMBytesCapacity(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNodeRAMBytesAllocatable(start, end time.Time) *Future[NodeRAMBytesAllocatableResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeRAMBytesAllocatable, func() *Future[NodeRAMBytesAllocatableResult] {
+		return m.noop.QueryNodeRAMBytesAllocatable(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNodeGPUCount(start, end time.Time) *Future[NodeGPUCountResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeGPUCount, func() *Future[NodeGPUCountResult] {
+		return m.noop.QueryNodeGPUCount(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNodeCPUModeTotal(start, end time.Time) *Future[NodeCPUModeTotalResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeCPUModeTotal, func() *Future[NodeCPUModeTotalResult] {
+		return m.noop.QueryNodeCPUModeTotal(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNodeIsSpot(start, end time.Time) *Future[NodeIsSpotResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeIsSpot, func() *Future[NodeIsSpotResult] {
+		return m.noop.QueryNodeIsSpot(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNodeRAMSystemPercent(start, end time.Time) *Future[NodeRAMSystemPercentResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeRAMSystemPercent, func() *Future[NodeRAMSystemPercentResult] {
+		return m.noop.QueryNodeRAMSystemPercent(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNodeRAMUserPercent(start, end time.Time) *Future[NodeRAMUserPercentResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeRAMUserPercent, func() *Future[NodeRAMUserPercentResult] {
+		return m.noop.QueryNodeRAMUserPercent(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNodeResourceCapacities(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeResourceCapacities, func() *Future[ResourceResult] {
+		return m.noop.QueryNodeResourceCapacities(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNodeResourcesAllocatable(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeResourcesAllocatable, func() *Future[ResourceResult] {
+		return m.noop.QueryNodeResourcesAllocatable(start, end)
+	})
+}
+
+// Load Balancers
+
+func (m *MockMetricsQuerier) QueryLBActiveMinutes(start, end time.Time) *Future[LBActiveMinutesResult] {
+	return getFutureFromOverride(m.overrides, QueryLBActiveMinutes, func() *Future[LBActiveMinutesResult] {
+		return m.noop.QueryLBActiveMinutes(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryLBPricePerHr(start, end time.Time) *Future[LBPricePerHrResult] {
+	return getFutureFromOverride(m.overrides, QueryLBPricePerHr, func() *Future[LBPricePerHrResult] {
+		return m.noop.QueryLBPricePerHr(start, end)
+	})
+}
+
+// Cluster Management
+
+func (m *MockMetricsQuerier) QueryClusterInfo(start, end time.Time) *Future[ClusterInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryClusterInfo, func() *Future[ClusterInfoResult] {
+		return m.noop.QueryClusterInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryClusterKubeModelVersion(start, end time.Time) *Future[ClusterKubeModelVersionResult] {
+	return getFutureFromOverride(m.overrides, QueryClusterKubeModelVersion, func() *Future[ClusterKubeModelVersionResult] {
+		return m.noop.QueryClusterKubeModelVersion(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryClusterUptime(start, end time.Time) *Future[UptimeResult] {
+	return getFutureFromOverride(m.overrides, QueryClusterUptime, func() *Future[UptimeResult] {
+		return m.noop.QueryClusterUptime(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryClusterManagementDuration(start, end time.Time) *Future[ClusterManagementDurationResult] {
+	return getFutureFromOverride(m.overrides, QueryClusterManagementDuration, func() *Future[ClusterManagementDurationResult] {
+		return m.noop.QueryClusterManagementDuration(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryClusterManagementPricePerHr(start, end time.Time) *Future[ClusterManagementPricePerHrResult] {
+	return getFutureFromOverride(m.overrides, QueryClusterManagementPricePerHr, func() *Future[ClusterManagementPricePerHrResult] {
+		return m.noop.QueryClusterManagementPricePerHr(start, end)
+	})
+}
+
+// Pods
+
+func (m *MockMetricsQuerier) QueryPods(start, end time.Time) *Future[PodsResult] {
+	return getFutureFromOverride(m.overrides, QueryPods, func() *Future[PodsResult] {
+		return m.noop.QueryPods(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPodsUID(start, end time.Time) *Future[PodsResult] {
+	return getFutureFromOverride(m.overrides, QueryPodsUID, func() *Future[PodsResult] {
+		return m.noop.QueryPodsUID(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPodInfo(start, end time.Time) *Future[PodInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryPodInfo, func() *Future[PodInfoResult] {
+		return m.noop.QueryPodInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPodUptime(start, end time.Time) *Future[UptimeResult] {
+	return getFutureFromOverride(m.overrides, QueryPodUptime, func() *Future[UptimeResult] {
+		return m.noop.QueryPodUptime(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPodOwners(start, end time.Time) *Future[OwnerResult] {
+	return getFutureFromOverride(m.overrides, QueryPodOwners, func() *Future[OwnerResult] {
+		return m.noop.QueryPodOwners(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPodPVCVolumes(start, end time.Time) *Future[PodPVCVolumeResult] {
+	return getFutureFromOverride(m.overrides, QueryPodPVCVolumes, func() *Future[PodPVCVolumeResult] {
+		return m.noop.QueryPodPVCVolumes(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPodNetworkEgressBytes(start, end time.Time) *Future[PodNetworkBytesResult] {
+	return getFutureFromOverride(m.overrides, QueryPodNetworkEgressBytes, func() *Future[PodNetworkBytesResult] {
+		return m.noop.QueryPodNetworkEgressBytes(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPodNetworkIngressBytes(start, end time.Time) *Future[PodNetworkBytesResult] {
+	return getFutureFromOverride(m.overrides, QueryPodNetworkIngressBytes, func() *Future[PodNetworkBytesResult] {
+		return m.noop.QueryPodNetworkIngressBytes(start, end)
+	})
+}
+
+// Container
+
+func (m *MockMetricsQuerier) QueryContainerUptime(start, end time.Time) *Future[ContainerUptimeResult] {
+	return getFutureFromOverride(m.overrides, QueryContainerUptime, func() *Future[ContainerUptimeResult] {
+		return m.noop.QueryContainerUptime(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryContainerResourceRequests(start, end time.Time) *Future[ContainerResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryContainerResourceRequests, func() *Future[ContainerResourceResult] {
+		return m.noop.QueryContainerResourceRequests(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryContainerResourceLimits(start, end time.Time) *Future[ContainerResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryContainerResourceLimits, func() *Future[ContainerResourceResult] {
+		return m.noop.QueryContainerResourceLimits(start, end)
+	})
+}
+
+// RAM
+
+func (m *MockMetricsQuerier) QueryRAMBytesAllocated(start, end time.Time) *Future[RAMBytesAllocatedResult] {
+	return getFutureFromOverride(m.overrides, QueryRAMBytesAllocated, func() *Future[RAMBytesAllocatedResult] {
+		return m.noop.QueryRAMBytesAllocated(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryRAMRequests(start, end time.Time) *Future[RAMRequestsResult] {
+	return getFutureFromOverride(m.overrides, QueryRAMRequests, func() *Future[RAMRequestsResult] {
+		return m.noop.QueryRAMRequests(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryRAMLimits(start, end time.Time) *Future[RAMLimitsResult] {
+	return getFutureFromOverride(m.overrides, QueryRAMLimits, func() *Future[RAMLimitsResult] {
+		return m.noop.QueryRAMLimits(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryRAMUsageAvg(start, end time.Time) *Future[RAMUsageAvgResult] {
+	return getFutureFromOverride(m.overrides, QueryRAMUsageAvg, func() *Future[RAMUsageAvgResult] {
+		return m.noop.QueryRAMUsageAvg(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryRAMUsageMax(start, end time.Time) *Future[RAMUsageMaxResult] {
+	return getFutureFromOverride(m.overrides, QueryRAMUsageMax, func() *Future[RAMUsageMaxResult] {
+		return m.noop.QueryRAMUsageMax(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNodeRAMPricePerGiBHr(start, end time.Time) *Future[NodeRAMPricePerGiBHrResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeRAMPricePerGiBHr, func() *Future[NodeRAMPricePerGiBHrResult] {
+		return m.noop.QueryNodeRAMPricePerGiBHr(start, end)
+	})
+}
+
+// CPU
+
+func (m *MockMetricsQuerier) QueryCPUCoresAllocated(start, end time.Time) *Future[CPUCoresAllocatedResult] {
+	return getFutureFromOverride(m.overrides, QueryCPUCoresAllocated, func() *Future[CPUCoresAllocatedResult] {
+		return m.noop.QueryCPUCoresAllocated(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryCPURequests(start, end time.Time) *Future[CPURequestsResult] {
+	return getFutureFromOverride(m.overrides, QueryCPURequests, func() *Future[CPURequestsResult] {
+		return m.noop.QueryCPURequests(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryCPULimits(start, end time.Time) *Future[CPULimitsResult] {
+	return getFutureFromOverride(m.overrides, QueryCPULimits, func() *Future[CPULimitsResult] {
+		return m.noop.QueryCPULimits(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryCPUUsageAvg(start, end time.Time) *Future[CPUUsageAvgResult] {
+	return getFutureFromOverride(m.overrides, QueryCPUUsageAvg, func() *Future[CPUUsageAvgResult] {
+		return m.noop.QueryCPUUsageAvg(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryCPUUsageMax(start, end time.Time) *Future[CPUUsageMaxResult] {
+	return getFutureFromOverride(m.overrides, QueryCPUUsageMax, func() *Future[CPUUsageMaxResult] {
+		return m.noop.QueryCPUUsageMax(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNodeCPUPricePerHr(start, end time.Time) *Future[NodeCPUPricePerHrResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeCPUPricePerHr, func() *Future[NodeCPUPricePerHrResult] {
+		return m.noop.QueryNodeCPUPricePerHr(start, end)
+	})
+}
+
+// GPU
+
+func (m *MockMetricsQuerier) QueryGPUsAllocated(start, end time.Time) *Future[GPUsAllocatedResult] {
+	return getFutureFromOverride(m.overrides, QueryGPUsAllocated, func() *Future[GPUsAllocatedResult] {
+		return m.noop.QueryGPUsAllocated(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryGPUsRequested(start, end time.Time) *Future[GPUsRequestedResult] {
+	return getFutureFromOverride(m.overrides, QueryGPUsRequested, func() *Future[GPUsRequestedResult] {
+		return m.noop.QueryGPUsRequested(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryGPUsUsageAvg(start, end time.Time) *Future[GPUsUsageAvgResult] {
+	return getFutureFromOverride(m.overrides, QueryGPUsUsageAvg, func() *Future[GPUsUsageAvgResult] {
+		return m.noop.QueryGPUsUsageAvg(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryGPUsUsageMax(start, end time.Time) *Future[GPUsUsageMaxResult] {
+	return getFutureFromOverride(m.overrides, QueryGPUsUsageMax, func() *Future[GPUsUsageMaxResult] {
+		return m.noop.QueryGPUsUsageMax(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNodeGPUPricePerHr(start, end time.Time) *Future[NodeGPUPricePerHrResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeGPUPricePerHr, func() *Future[NodeGPUPricePerHrResult] {
+		return m.noop.QueryNodeGPUPricePerHr(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryGPUInfo(start, end time.Time) *Future[GPUInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryGPUInfo, func() *Future[GPUInfoResult] {
+		return m.noop.QueryGPUInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryIsGPUShared(start, end time.Time) *Future[IsGPUSharedResult] {
+	return getFutureFromOverride(m.overrides, QueryIsGPUShared, func() *Future[IsGPUSharedResult] {
+		return m.noop.QueryIsGPUShared(start, end)
+	})
+}
+
+// Device
+
+func (m *MockMetricsQuerier) QueryDCGMDeviceInfo(start, end time.Time) *Future[DCGMDeviceInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryDCGMDeviceInfo, func() *Future[DCGMDeviceInfoResult] {
+		return m.noop.QueryDCGMDeviceInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryDCGMDeviceUptime(start, end time.Time) *Future[DCGMDeviceUptimeResult] {
+	return getFutureFromOverride(m.overrides, QueryDCGMDeviceUptime, func() *Future[DCGMDeviceUptimeResult] {
+		return m.noop.QueryDCGMDeviceUptime(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryDCGMContainerUsageAvg(start, end time.Time) *Future[DCGMDeviceContainerUsageResult] {
+	return getFutureFromOverride(m.overrides, QueryDCGMContainerUsageAvg, func() *Future[DCGMDeviceContainerUsageResult] {
+		return m.noop.QueryDCGMContainerUsageAvg(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryDCGMContainerUsageMax(start, end time.Time) *Future[DCGMDeviceContainerUsageResult] {
+	return getFutureFromOverride(m.overrides, QueryDCGMContainerUsageMax, func() *Future[DCGMDeviceContainerUsageResult] {
+		return m.noop.QueryDCGMContainerUsageMax(start, end)
+	})
+}
+
+// PVC
+
+func (m *MockMetricsQuerier) QueryPodPVCAllocation(start, end time.Time) *Future[PodPVCAllocationResult] {
+	return getFutureFromOverride(m.overrides, QueryPodPVCAllocation, func() *Future[PodPVCAllocationResult] {
+		return m.noop.QueryPodPVCAllocation(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPVCBytesRequested(start, end time.Time) *Future[PVCBytesRequestedResult] {
+	return getFutureFromOverride(m.overrides, QueryPVCBytesRequested, func() *Future[PVCBytesRequestedResult] {
+		return m.noop.QueryPVCBytesRequested(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPVCInfo(start, end time.Time) *Future[PVCInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryPVCInfo, func() *Future[PVCInfoResult] {
+		return m.noop.QueryPVCInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryKMPVCInfo(start, end time.Time) *Future[PVCInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryKMPVCInfo, func() *Future[PVCInfoResult] {
+		return m.noop.QueryKMPVCInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPVCUptime(start, end time.Time) *Future[UptimeResult] {
+	return getFutureFromOverride(m.overrides, QueryPVCUptime, func() *Future[UptimeResult] {
+		return m.noop.QueryPVCUptime(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPVCBytesUsedAverage(start, end time.Time) *Future[PVCUIDValueResult] {
+	return getFutureFromOverride(m.overrides, QueryPVCBytesUsedAverage, func() *Future[PVCUIDValueResult] {
+		return m.noop.QueryPVCBytesUsedAverage(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPVCBytesUsedMax(start, end time.Time) *Future[PVCUIDValueResult] {
+	return getFutureFromOverride(m.overrides, QueryPVCBytesUsedMax, func() *Future[PVCUIDValueResult] {
+		return m.noop.QueryPVCBytesUsedMax(start, end)
+	})
+}
+
+// PV
+
+func (m *MockMetricsQuerier) QueryPVBytes(start, end time.Time) *Future[PVBytesResult] {
+	return getFutureFromOverride(m.overrides, QueryPVBytes, func() *Future[PVBytesResult] {
+		return m.noop.QueryPVBytes(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPVPricePerGiBHour(start, end time.Time) *Future[PVPricePerGiBHourResult] {
+	return getFutureFromOverride(m.overrides, QueryPVPricePerGiBHour, func() *Future[PVPricePerGiBHourResult] {
+		return m.noop.QueryPVPricePerGiBHour(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPVInfo(start, end time.Time) *Future[PVInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryPVInfo, func() *Future[PVInfoResult] {
+		return m.noop.QueryPVInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPVActiveMinutes(start, end time.Time) *Future[PVActiveMinutesResult] {
+	return getFutureFromOverride(m.overrides, QueryPVActiveMinutes, func() *Future[PVActiveMinutesResult] {
+		return m.noop.QueryPVActiveMinutes(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPVUsedAverage(start, end time.Time) *Future[PVUsedAvgResult] {
+	return getFutureFromOverride(m.overrides, QueryPVUsedAverage, func() *Future[PVUsedAvgResult] {
+		return m.noop.QueryPVUsedAverage(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPVUsedMax(start, end time.Time) *Future[PVUsedMaxResult] {
+	return getFutureFromOverride(m.overrides, QueryPVUsedMax, func() *Future[PVUsedMaxResult] {
+		return m.noop.QueryPVUsedMax(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryKMPVInfo(start, end time.Time) *Future[PVInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryKMPVInfo, func() *Future[PVInfoResult] {
+		return m.noop.QueryKMPVInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPVUptime(start, end time.Time) *Future[UptimeResult] {
+	return getFutureFromOverride(m.overrides, QueryPVUptime, func() *Future[UptimeResult] {
+		return m.noop.QueryPVUptime(start, end)
+	})
+}
+
+// Deployment
+
+func (m *MockMetricsQuerier) QueryDeploymentInfo(start, end time.Time) *Future[DeploymentInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryDeploymentInfo, func() *Future[DeploymentInfoResult] {
+		return m.noop.QueryDeploymentInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryDeploymentUptime(start, end time.Time) *Future[UptimeResult] {
+	return getFutureFromOverride(m.overrides, QueryDeploymentUptime, func() *Future[UptimeResult] {
+		return m.noop.QueryDeploymentUptime(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryDeploymentLabels(start, end time.Time) *Future[LabelsResult] {
+	return getFutureFromOverride(m.overrides, QueryDeploymentLabels, func() *Future[LabelsResult] {
+		return m.noop.QueryDeploymentLabels(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryDeploymentAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	return getFutureFromOverride(m.overrides, QueryDeploymentAnnotations, func() *Future[AnnotationsResult] {
+		return m.noop.QueryDeploymentAnnotations(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryDeploymentMatchLabels(start, end time.Time) *Future[DeploymentLabelsResult] {
+	return getFutureFromOverride(m.overrides, QueryDeploymentMatchLabels, func() *Future[DeploymentLabelsResult] {
+		return m.noop.QueryDeploymentMatchLabels(start, end)
+	})
+}
+
+// StatefulSet
+
+func (m *MockMetricsQuerier) QueryStatefulSetInfo(start, end time.Time) *Future[StatefulSetInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryStatefulSetInfo, func() *Future[StatefulSetInfoResult] {
+		return m.noop.QueryStatefulSetInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryStatefulSetUptime(start, end time.Time) *Future[UptimeResult] {
+	return getFutureFromOverride(m.overrides, QueryStatefulSetUptime, func() *Future[UptimeResult] {
+		return m.noop.QueryStatefulSetUptime(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryStatefulSetLabels(start, end time.Time) *Future[LabelsResult] {
+	return getFutureFromOverride(m.overrides, QueryStatefulSetLabels, func() *Future[LabelsResult] {
+		return m.noop.QueryStatefulSetLabels(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryStatefulSetAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	return getFutureFromOverride(m.overrides, QueryStatefulSetAnnotations, func() *Future[AnnotationsResult] {
+		return m.noop.QueryStatefulSetAnnotations(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryStatefulSetMatchLabels(start, end time.Time) *Future[StatefulSetLabelsResult] {
+	return getFutureFromOverride(m.overrides, QueryStatefulSetMatchLabels, func() *Future[StatefulSetLabelsResult] {
+		return m.noop.QueryStatefulSetMatchLabels(start, end)
+	})
+}
+
+// DaemonSet
+
+func (m *MockMetricsQuerier) QueryDaemonSetInfo(start, end time.Time) *Future[DaemonSetInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryDaemonSetInfo, func() *Future[DaemonSetInfoResult] {
+		return m.noop.QueryDaemonSetInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryDaemonSetUptime(start, end time.Time) *Future[UptimeResult] {
+	return getFutureFromOverride(m.overrides, QueryDaemonSetUptime, func() *Future[UptimeResult] {
+		return m.noop.QueryDaemonSetUptime(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryDaemonSetLabels(start, end time.Time) *Future[LabelsResult] {
+	return getFutureFromOverride(m.overrides, QueryDaemonSetLabels, func() *Future[LabelsResult] {
+		return m.noop.QueryDaemonSetLabels(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryDaemonSetAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	return getFutureFromOverride(m.overrides, QueryDaemonSetAnnotations, func() *Future[AnnotationsResult] {
+		return m.noop.QueryDaemonSetAnnotations(start, end)
+	})
+}
+
+// Job
+
+func (m *MockMetricsQuerier) QueryJobInfo(start, end time.Time) *Future[JobInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryJobInfo, func() *Future[JobInfoResult] {
+		return m.noop.QueryJobInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryJobUptime(start, end time.Time) *Future[UptimeResult] {
+	return getFutureFromOverride(m.overrides, QueryJobUptime, func() *Future[UptimeResult] {
+		return m.noop.QueryJobUptime(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryJobLabels(start, end time.Time) *Future[LabelsResult] {
+	return getFutureFromOverride(m.overrides, QueryJobLabels, func() *Future[LabelsResult] {
+		return m.noop.QueryJobLabels(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryJobAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	return getFutureFromOverride(m.overrides, QueryJobAnnotations, func() *Future[AnnotationsResult] {
+		return m.noop.QueryJobAnnotations(start, end)
+	})
+}
+
+// CronJob
+
+func (m *MockMetricsQuerier) QueryCronJobInfo(start, end time.Time) *Future[CronJobInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryCronJobInfo, func() *Future[CronJobInfoResult] {
+		return m.noop.QueryCronJobInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryCronJobUptime(start, end time.Time) *Future[UptimeResult] {
+	return getFutureFromOverride(m.overrides, QueryCronJobUptime, func() *Future[UptimeResult] {
+		return m.noop.QueryCronJobUptime(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryCronJobLabels(start, end time.Time) *Future[LabelsResult] {
+	return getFutureFromOverride(m.overrides, QueryCronJobLabels, func() *Future[LabelsResult] {
+		return m.noop.QueryCronJobLabels(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryCronJobAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	return getFutureFromOverride(m.overrides, QueryCronJobAnnotations, func() *Future[AnnotationsResult] {
+		return m.noop.QueryCronJobAnnotations(start, end)
+	})
+}
+
+// ReplicaSet
+
+func (m *MockMetricsQuerier) QueryReplicaSetInfo(start, end time.Time) *Future[ReplicaSetInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryReplicaSetInfo, func() *Future[ReplicaSetInfoResult] {
+		return m.noop.QueryReplicaSetInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryReplicaSetUptime(start, end time.Time) *Future[UptimeResult] {
+	return getFutureFromOverride(m.overrides, QueryReplicaSetUptime, func() *Future[UptimeResult] {
+		return m.noop.QueryReplicaSetUptime(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryReplicaSetLabels(start, end time.Time) *Future[LabelsResult] {
+	return getFutureFromOverride(m.overrides, QueryReplicaSetLabels, func() *Future[LabelsResult] {
+		return m.noop.QueryReplicaSetLabels(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryReplicaSetAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	return getFutureFromOverride(m.overrides, QueryReplicaSetAnnotations, func() *Future[AnnotationsResult] {
+		return m.noop.QueryReplicaSetAnnotations(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryReplicaSetOwners(start, end time.Time) *Future[OwnerResult] {
+	return getFutureFromOverride(m.overrides, QueryReplicaSetOwners, func() *Future[OwnerResult] {
+		return m.noop.QueryReplicaSetOwners(start, end)
+	})
+}
+
+// Namespace
+
+func (m *MockMetricsQuerier) QueryNamespaceInfo(start, end time.Time) *Future[NamespaceInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryNamespaceInfo, func() *Future[NamespaceInfoResult] {
+		return m.noop.QueryNamespaceInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNamespaceUptime(start, end time.Time) *Future[UptimeResult] {
+	return getFutureFromOverride(m.overrides, QueryNamespaceUptime, func() *Future[UptimeResult] {
+		return m.noop.QueryNamespaceUptime(start, end)
+	})
+}
+
+// Service
+
+func (m *MockMetricsQuerier) QueryServiceInfo(start, end time.Time) *Future[ServiceInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryServiceInfo, func() *Future[ServiceInfoResult] {
+		return m.noop.QueryServiceInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryServiceUptime(start, end time.Time) *Future[UptimeResult] {
+	return getFutureFromOverride(m.overrides, QueryServiceUptime, func() *Future[UptimeResult] {
+		return m.noop.QueryServiceUptime(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryServiceSelectorLabels(start, end time.Time) *Future[ServiceLabelsResult] {
+	return getFutureFromOverride(m.overrides, QueryServiceSelectorLabels, func() *Future[ServiceLabelsResult] {
+		return m.noop.QueryServiceSelectorLabels(start, end)
+	})
+}
+
+// Network Egress
+
+func (m *MockMetricsQuerier) QueryNetZoneGiB(start, end time.Time) *Future[NetZoneGiBResult] {
+	return getFutureFromOverride(m.overrides, QueryNetZoneGiB, func() *Future[NetZoneGiBResult] {
+		return m.noop.QueryNetZoneGiB(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNetZonePricePerGiB(start, end time.Time) *Future[NetZonePricePerGiBResult] {
+	return getFutureFromOverride(m.overrides, QueryNetZonePricePerGiB, func() *Future[NetZonePricePerGiBResult] {
+		return m.noop.QueryNetZonePricePerGiB(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNetRegionGiB(start, end time.Time) *Future[NetRegionGiBResult] {
+	return getFutureFromOverride(m.overrides, QueryNetRegionGiB, func() *Future[NetRegionGiBResult] {
+		return m.noop.QueryNetRegionGiB(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNetRegionPricePerGiB(start, end time.Time) *Future[NetRegionPricePerGiBResult] {
+	return getFutureFromOverride(m.overrides, QueryNetRegionPricePerGiB, func() *Future[NetRegionPricePerGiBResult] {
+		return m.noop.QueryNetRegionPricePerGiB(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNetInternetGiB(start, end time.Time) *Future[NetInternetGiBResult] {
+	return getFutureFromOverride(m.overrides, QueryNetInternetGiB, func() *Future[NetInternetGiBResult] {
+		return m.noop.QueryNetInternetGiB(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNetInternetPricePerGiB(start, end time.Time) *Future[NetInternetPricePerGiBResult] {
+	return getFutureFromOverride(m.overrides, QueryNetInternetPricePerGiB, func() *Future[NetInternetPricePerGiBResult] {
+		return m.noop.QueryNetInternetPricePerGiB(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNetInternetServiceGiB(start, end time.Time) *Future[NetInternetServiceGiBResult] {
+	return getFutureFromOverride(m.overrides, QueryNetInternetServiceGiB, func() *Future[NetInternetServiceGiBResult] {
+		return m.noop.QueryNetInternetServiceGiB(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNetNatGatewayPricePerGiB(start, end time.Time) *Future[NetNatGatewayPricePerGiBResult] {
+	return getFutureFromOverride(m.overrides, QueryNetNatGatewayPricePerGiB, func() *Future[NetNatGatewayPricePerGiBResult] {
+		return m.noop.QueryNetNatGatewayPricePerGiB(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNetNatGatewayGiB(start, end time.Time) *Future[NetNatGatewayGiBResult] {
+	return getFutureFromOverride(m.overrides, QueryNetNatGatewayGiB, func() *Future[NetNatGatewayGiBResult] {
+		return m.noop.QueryNetNatGatewayGiB(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNetTransferBytes(start, end time.Time) *Future[NetTransferBytesResult] {
+	return getFutureFromOverride(m.overrides, QueryNetTransferBytes, func() *Future[NetTransferBytesResult] {
+		return m.noop.QueryNetTransferBytes(start, end)
+	})
+}
+
+// Network Ingress
+
+func (m *MockMetricsQuerier) QueryNetZoneIngressGiB(start, end time.Time) *Future[NetZoneIngressGiBResult] {
+	return getFutureFromOverride(m.overrides, QueryNetZoneIngressGiB, func() *Future[NetZoneIngressGiBResult] {
+		return m.noop.QueryNetZoneIngressGiB(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNetRegionIngressGiB(start, end time.Time) *Future[NetRegionIngressGiBResult] {
+	return getFutureFromOverride(m.overrides, QueryNetRegionIngressGiB, func() *Future[NetRegionIngressGiBResult] {
+		return m.noop.QueryNetRegionIngressGiB(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNetInternetIngressGiB(start, end time.Time) *Future[NetInternetIngressGiBResult] {
+	return getFutureFromOverride(m.overrides, QueryNetInternetIngressGiB, func() *Future[NetInternetIngressGiBResult] {
+		return m.noop.QueryNetInternetIngressGiB(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNetInternetServiceIngressGiB(start, end time.Time) *Future[NetInternetServiceIngressGiBResult] {
+	return getFutureFromOverride(m.overrides, QueryNetInternetServiceIngressGiB, func() *Future[NetInternetServiceIngressGiBResult] {
+		return m.noop.QueryNetInternetServiceIngressGiB(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNetNatGatewayIngressPricePerGiB(start, end time.Time) *Future[NetNatGatewayPricePerGiBResult] {
+	return getFutureFromOverride(m.overrides, QueryNetNatGatewayIngressPricePerGiB, func() *Future[NetNatGatewayPricePerGiBResult] {
+		return m.noop.QueryNetNatGatewayIngressPricePerGiB(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNetNatGatewayIngressGiB(start, end time.Time) *Future[NetNatGatewayIngressGiBResult] {
+	return getFutureFromOverride(m.overrides, QueryNetNatGatewayIngressGiB, func() *Future[NetNatGatewayIngressGiBResult] {
+		return m.noop.QueryNetNatGatewayIngressGiB(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNetReceiveBytes(start, end time.Time) *Future[NetReceiveBytesResult] {
+	return getFutureFromOverride(m.overrides, QueryNetReceiveBytes, func() *Future[NetReceiveBytesResult] {
+		return m.noop.QueryNetReceiveBytes(start, end)
+	})
+}
+
+// Annotations
+
+func (m *MockMetricsQuerier) QueryNamespaceAnnotations(start, end time.Time) *Future[NamespaceAnnotationsResult] {
+	return getFutureFromOverride(m.overrides, QueryNamespaceAnnotations, func() *Future[NamespaceAnnotationsResult] {
+		return m.noop.QueryNamespaceAnnotations(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPodAnnotations(start, end time.Time) *Future[PodAnnotationsResult] {
+	return getFutureFromOverride(m.overrides, QueryPodAnnotations, func() *Future[PodAnnotationsResult] {
+		return m.noop.QueryPodAnnotations(start, end)
+	})
+}
+
+// Labels
+
+func (m *MockMetricsQuerier) QueryNodeLabels(start, end time.Time) *Future[NodeLabelsResult] {
+	return getFutureFromOverride(m.overrides, QueryNodeLabels, func() *Future[NodeLabelsResult] {
+		return m.noop.QueryNodeLabels(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryNamespaceLabels(start, end time.Time) *Future[NamespaceLabelsResult] {
+	return getFutureFromOverride(m.overrides, QueryNamespaceLabels, func() *Future[NamespaceLabelsResult] {
+		return m.noop.QueryNamespaceLabels(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPodLabels(start, end time.Time) *Future[PodLabelsResult] {
+	return getFutureFromOverride(m.overrides, QueryPodLabels, func() *Future[PodLabelsResult] {
+		return m.noop.QueryPodLabels(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPodsWithDaemonSetOwner(start, end time.Time) *Future[PodsWithDaemonSetOwnerResult] {
+	return getFutureFromOverride(m.overrides, QueryPodsWithDaemonSetOwner, func() *Future[PodsWithDaemonSetOwnerResult] {
+		return m.noop.QueryPodsWithDaemonSetOwner(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryPodsWithJobOwner(start, end time.Time) *Future[PodsWithJobOwnerResult] {
+	return getFutureFromOverride(m.overrides, QueryPodsWithJobOwner, func() *Future[PodsWithJobOwnerResult] {
+		return m.noop.QueryPodsWithJobOwner(start, end)
+	})
+}
+
+// ReplicaSet -> Controller mapping
+
+func (m *MockMetricsQuerier) QueryPodsWithReplicaSetOwner(start, end time.Time) *Future[PodsWithReplicaSetOwnerResult] {
+	return getFutureFromOverride(m.overrides, QueryPodsWithReplicaSetOwner, func() *Future[PodsWithReplicaSetOwnerResult] {
+		return m.noop.QueryPodsWithReplicaSetOwner(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryReplicaSetsWithoutOwners(start, end time.Time) *Future[ReplicaSetsWithoutOwnersResult] {
+	return getFutureFromOverride(m.overrides, QueryReplicaSetsWithoutOwners, func() *Future[ReplicaSetsWithoutOwnersResult] {
+		return m.noop.QueryReplicaSetsWithoutOwners(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryReplicaSetsWithRollout(start, end time.Time) *Future[ReplicaSetsWithRolloutResult] {
+	return getFutureFromOverride(m.overrides, QueryReplicaSetsWithRollout, func() *Future[ReplicaSetsWithRolloutResult] {
+		return m.noop.QueryReplicaSetsWithRollout(start, end)
+	})
+}
+
+// ResourceQuotas
+
+func (m *MockMetricsQuerier) QueryResourceQuotaInfo(start, end time.Time) *Future[ResourceQuotaInfoResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaInfo, func() *Future[ResourceQuotaInfoResult] {
+		return m.noop.QueryResourceQuotaInfo(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaUptime(start, end time.Time) *Future[UptimeResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaUptime, func() *Future[UptimeResult] {
+		return m.noop.QueryResourceQuotaUptime(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaSpecCPURequestAverage(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaSpecCPURequestAverage, func() *Future[ResourceResult] {
+		return m.noop.QueryResourceQuotaSpecCPURequestAverage(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaSpecCPURequestMax(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaSpecCPURequestMax, func() *Future[ResourceResult] {
+		return m.noop.QueryResourceQuotaSpecCPURequestMax(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaSpecRAMRequestAverage(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaSpecRAMRequestAverage, func() *Future[ResourceResult] {
+		return m.noop.QueryResourceQuotaSpecRAMRequestAverage(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaSpecRAMRequestMax(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaSpecRAMRequestMax, func() *Future[ResourceResult] {
+		return m.noop.QueryResourceQuotaSpecRAMRequestMax(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaSpecCPULimitAverage(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaSpecCPULimitAverage, func() *Future[ResourceResult] {
+		return m.noop.QueryResourceQuotaSpecCPULimitAverage(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaSpecCPULimitMax(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaSpecCPULimitMax, func() *Future[ResourceResult] {
+		return m.noop.QueryResourceQuotaSpecCPULimitMax(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaSpecRAMLimitAverage(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaSpecRAMLimitAverage, func() *Future[ResourceResult] {
+		return m.noop.QueryResourceQuotaSpecRAMLimitAverage(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaSpecRAMLimitMax(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaSpecRAMLimitMax, func() *Future[ResourceResult] {
+		return m.noop.QueryResourceQuotaSpecRAMLimitMax(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaStatusUsedCPURequestAverage(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaStatusUsedCPURequestAverage, func() *Future[ResourceResult] {
+		return m.noop.QueryResourceQuotaStatusUsedCPURequestAverage(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaStatusUsedCPURequestMax(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaStatusUsedCPURequestMax, func() *Future[ResourceResult] {
+		return m.noop.QueryResourceQuotaStatusUsedCPURequestMax(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaStatusUsedRAMRequestAverage(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaStatusUsedRAMRequestAverage, func() *Future[ResourceResult] {
+		return m.noop.QueryResourceQuotaStatusUsedRAMRequestAverage(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaStatusUsedRAMRequestMax(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaStatusUsedRAMRequestMax, func() *Future[ResourceResult] {
+		return m.noop.QueryResourceQuotaStatusUsedRAMRequestMax(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaStatusUsedCPULimitAverage(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaStatusUsedCPULimitAverage, func() *Future[ResourceResult] {
+		return m.noop.QueryResourceQuotaStatusUsedCPULimitAverage(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaStatusUsedCPULimitMax(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaStatusUsedCPULimitMax, func() *Future[ResourceResult] {
+		return m.noop.QueryResourceQuotaStatusUsedCPULimitMax(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaStatusUsedRAMLimitAverage(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaStatusUsedRAMLimitAverage, func() *Future[ResourceResult] {
+		return m.noop.QueryResourceQuotaStatusUsedRAMLimitAverage(start, end)
+	})
+}
+
+func (m *MockMetricsQuerier) QueryResourceQuotaStatusUsedRAMLimitMax(start, end time.Time) *Future[ResourceResult] {
+	return getFutureFromOverride(m.overrides, QueryResourceQuotaStatusUsedRAMLimitMax, func() *Future[ResourceResult] {
+		return m.noop.QueryResourceQuotaStatusUsedRAMLimitMax(start, end)
+	})
+}
+
+// Data Coverage Query
+
+func (m *MockMetricsQuerier) QueryDataCoverage(limitDays int) (time.Time, time.Time, error) {
+	if v, ok := m.overrides[QueryDataCoverage]; ok {
+		if f, ok := v.(func(int) (time.Time, time.Time, error)); ok {
+			return f(limitDays)
+		}
+	}
+	return m.noop.QueryDataCoverage(limitDays)
+}
+
+//--------------------------------------------------------------------------
+//  Mock OpenCostDataSource
+//--------------------------------------------------------------------------
+
+var _ OpenCostDataSource = (*MockOpenCostDataSource)(nil)
+
+// MockOpenCostDataSource is a minimal OpenCostDataSource for tests. Set fields
+// directly to configure what the data source returns.
+type MockOpenCostDataSource struct {
+	Querier            *MockMetricsQuerier
+	ClusterMapValue    clusters.ClusterMap
+	ClusterInfoValue   clusters.ClusterInfoProvider
+	BatchDurationValue time.Duration
+	ResolutionValue    time.Duration
+}
+
+func NewMockOpenCostDataSource() *MockOpenCostDataSource {
+	return &MockOpenCostDataSource{
+		Querier: NewMockMetricsQuerier(),
+	}
+}
+
+func (m *MockOpenCostDataSource) RegisterEndPoints(_ *httprouter.Router) {}
+
+func (m *MockOpenCostDataSource) RegisterDiagnostics(_ diagnostics.DiagnosticService) {}
+
+func (m *MockOpenCostDataSource) Metrics() MetricsQuerier {
+	return m.Querier
+}
+
+func (m *MockOpenCostDataSource) ClusterMap() clusters.ClusterMap {
+	return m.ClusterMapValue
+}
+
+func (m *MockOpenCostDataSource) ClusterInfo() clusters.ClusterInfoProvider {
+	return m.ClusterInfoValue
+}
+
+func (m *MockOpenCostDataSource) BatchDuration() time.Duration {
+	return m.BatchDurationValue
+}
+
+func (m *MockOpenCostDataSource) Resolution() time.Duration {
+	return m.ResolutionValue
+}

+ 716 - 0
core/pkg/source/noop.go

@@ -0,0 +1,716 @@
+package source
+
+import (
+	"time"
+)
+
+//--------------------------------------------------------------------------
+//  No-Op MetricsQuerier (empty query results)
+//--------------------------------------------------------------------------
+
+// NoOpMetricsQuerier is a no-op implementation of the MetricsQuerier interface
+// that returns empty results for all queries.
+type NoOpMetricsQuerier struct{}
+
+// NewNoOpMetricsQuerier creates a new mock metrics querier
+func NewNoOpMetricsQuerier() *NoOpMetricsQuerier {
+	return &NoOpMetricsQuerier{}
+}
+
+// Local Cluster Disks
+
+func (m *NoOpMetricsQuerier) QueryLocalStorageActiveMinutes(start, end time.Time) *Future[LocalStorageActiveMinutesResult] {
+	return newEmptyResult(DecodeLocalStorageActiveMinutesResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryLocalStorageUsedAvg(start, end time.Time) *Future[LocalStorageUsedAvgResult] {
+	return newEmptyResult(DecodeLocalStorageUsedAvgResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryLocalStorageUsedMax(start, end time.Time) *Future[LocalStorageUsedMaxResult] {
+	return newEmptyResult(DecodeLocalStorageUsedMaxResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryLocalStorageBytes(start, end time.Time) *Future[LocalStorageBytesResult] {
+	return newEmptyResult(DecodeLocalStorageBytesResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryKMLocalStorageUsedAvg(start, end time.Time) *Future[NodeUIDValueResult] {
+	return newEmptyResult(DecodeNodeUIDValueResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryKMLocalStorageUsedMax(start, end time.Time) *Future[NodeUIDValueResult] {
+	return newEmptyResult(DecodeNodeUIDValueResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryKMLocalStorageBytes(start, end time.Time) *Future[UIDValueResult] {
+	return newEmptyResult(DecodeUIDValueResult)
+}
+
+// Nodes
+
+func (m *NoOpMetricsQuerier) QueryNodeInfo(start, end time.Time) *Future[NodeInfoResult] {
+	return newEmptyResult(DecodeNodeInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNodeUptime(start, end time.Time) *Future[UptimeResult] {
+	return newEmptyResult(DecodeUptimeResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNodeActiveMinutes(start, end time.Time) *Future[NodeActiveMinutesResult] {
+	return newEmptyResult(DecodeNodeActiveMinutesResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNodeCPUCoresCapacity(start, end time.Time) *Future[NodeCPUCoresCapacityResult] {
+	return newEmptyResult(DecodeNodeCPUCoresCapacityResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNodeCPUCoresAllocatable(start, end time.Time) *Future[NodeCPUCoresAllocatableResult] {
+	return newEmptyResult(DecodeNodeCPUCoresAllocatableResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNodeRAMBytesCapacity(start, end time.Time) *Future[NodeRAMBytesCapacityResult] {
+	return newEmptyResult(DecodeNodeRAMBytesCapacityResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNodeRAMBytesAllocatable(start, end time.Time) *Future[NodeRAMBytesAllocatableResult] {
+	return newEmptyResult(DecodeNodeRAMBytesAllocatableResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNodeGPUCount(start, end time.Time) *Future[NodeGPUCountResult] {
+	return newEmptyResult(DecodeNodeGPUCountResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNodeCPUModeTotal(start, end time.Time) *Future[NodeCPUModeTotalResult] {
+	return newEmptyResult(DecodeNodeCPUModeTotalResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNodeIsSpot(start, end time.Time) *Future[NodeIsSpotResult] {
+	return newEmptyResult(DecodeNodeIsSpotResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNodeRAMSystemPercent(start, end time.Time) *Future[NodeRAMSystemPercentResult] {
+	return newEmptyResult(DecodeNodeRAMSystemPercentResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNodeRAMUserPercent(start, end time.Time) *Future[NodeRAMUserPercentResult] {
+	return newEmptyResult(DecodeNodeRAMUserPercentResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNodeResourceCapacities(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNodeResourcesAllocatable(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+// Load Balancers
+
+func (m *NoOpMetricsQuerier) QueryLBActiveMinutes(start, end time.Time) *Future[LBActiveMinutesResult] {
+	return newEmptyResult(DecodeLBActiveMinutesResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryLBPricePerHr(start, end time.Time) *Future[LBPricePerHrResult] {
+	return newEmptyResult(DecodeLBPricePerHrResult)
+}
+
+// Cluster Management
+
+func (m *NoOpMetricsQuerier) QueryClusterInfo(start, end time.Time) *Future[ClusterInfoResult] {
+	return newEmptyResult(DecodeClusterInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryClusterKubeModelVersion(start, end time.Time) *Future[ClusterKubeModelVersionResult] {
+	return newEmptyResult(DecodeClusterKubeModelVersionResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryClusterUptime(start, end time.Time) *Future[UptimeResult] {
+	return newEmptyResult(DecodeUptimeResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryClusterManagementDuration(start, end time.Time) *Future[ClusterManagementDurationResult] {
+	return newEmptyResult(DecodeClusterManagementDurationResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryClusterManagementPricePerHr(start, end time.Time) *Future[ClusterManagementPricePerHrResult] {
+	return newEmptyResult(DecodeClusterManagementPricePerHrResult)
+}
+
+// Pods
+
+func (m *NoOpMetricsQuerier) QueryPods(start, end time.Time) *Future[PodsResult] {
+	return newEmptyResult(DecodePodsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPodsUID(start, end time.Time) *Future[PodsResult] {
+	return newEmptyResult(DecodePodsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPodInfo(start, end time.Time) *Future[PodInfoResult] {
+	return newEmptyResult(DecodePodInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPodUptime(start, end time.Time) *Future[UptimeResult] {
+	return newEmptyResult(DecodeUptimeResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPodOwners(start, end time.Time) *Future[OwnerResult] {
+	return newEmptyResult(DecodeOwnerResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPodPVCVolumes(start, end time.Time) *Future[PodPVCVolumeResult] {
+	return newEmptyResult(DecodePodPVCVolumeResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPodNetworkEgressBytes(start, end time.Time) *Future[PodNetworkBytesResult] {
+	return newEmptyResult(DecodePodNetworkBytesResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPodNetworkIngressBytes(start, end time.Time) *Future[PodNetworkBytesResult] {
+	return newEmptyResult(DecodePodNetworkBytesResult)
+}
+
+// Container
+
+func (m *NoOpMetricsQuerier) QueryContainerUptime(start, end time.Time) *Future[ContainerUptimeResult] {
+	return newEmptyResult(DecodeContainerUptimeResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryContainerResourceRequests(start, end time.Time) *Future[ContainerResourceResult] {
+	return newEmptyResult(DecodeContainerResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryContainerResourceLimits(start, end time.Time) *Future[ContainerResourceResult] {
+	return newEmptyResult(DecodeContainerResourceResult)
+}
+
+// RAM
+
+func (m *NoOpMetricsQuerier) QueryRAMBytesAllocated(start, end time.Time) *Future[RAMBytesAllocatedResult] {
+	return newEmptyResult(DecodeRAMBytesAllocatedResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryRAMRequests(start, end time.Time) *Future[RAMRequestsResult] {
+	return newEmptyResult(DecodeRAMRequestsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryRAMLimits(start, end time.Time) *Future[RAMLimitsResult] {
+	return newEmptyResult(DecodeRAMLimitsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryRAMUsageAvg(start, end time.Time) *Future[RAMUsageAvgResult] {
+	return newEmptyResult(DecodeRAMUsageAvgResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryRAMUsageMax(start, end time.Time) *Future[RAMUsageMaxResult] {
+	return newEmptyResult(DecodeRAMUsageMaxResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNodeRAMPricePerGiBHr(start, end time.Time) *Future[NodeRAMPricePerGiBHrResult] {
+	return newEmptyResult(DecodeNodeRAMPricePerGiBHrResult)
+}
+
+// CPU
+
+func (m *NoOpMetricsQuerier) QueryCPUCoresAllocated(start, end time.Time) *Future[CPUCoresAllocatedResult] {
+	return newEmptyResult(DecodeCPUCoresAllocatedResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryCPURequests(start, end time.Time) *Future[CPURequestsResult] {
+	return newEmptyResult(DecodeCPURequestsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryCPULimits(start, end time.Time) *Future[CPULimitsResult] {
+	return newEmptyResult(DecodeCPULimitsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryCPUUsageAvg(start, end time.Time) *Future[CPUUsageAvgResult] {
+	return newEmptyResult(DecodeCPUUsageAvgResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryCPUUsageMax(start, end time.Time) *Future[CPUUsageMaxResult] {
+	return newEmptyResult(DecodeCPUUsageMaxResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNodeCPUPricePerHr(start, end time.Time) *Future[NodeCPUPricePerHrResult] {
+	return newEmptyResult(DecodeNodeCPUPricePerHrResult)
+}
+
+// GPU
+
+func (m *NoOpMetricsQuerier) QueryGPUsAllocated(start, end time.Time) *Future[GPUsAllocatedResult] {
+	return newEmptyResult(DecodeGPUsAllocatedResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryGPUsRequested(start, end time.Time) *Future[GPUsRequestedResult] {
+	return newEmptyResult(DecodeGPUsRequestedResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryGPUsUsageAvg(start, end time.Time) *Future[GPUsUsageAvgResult] {
+	return newEmptyResult(DecodeGPUsUsageAvgResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryGPUsUsageMax(start, end time.Time) *Future[GPUsUsageMaxResult] {
+	return newEmptyResult(DecodeGPUsUsageMaxResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNodeGPUPricePerHr(start, end time.Time) *Future[NodeGPUPricePerHrResult] {
+	return newEmptyResult(DecodeNodeGPUPricePerHrResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryGPUInfo(start, end time.Time) *Future[GPUInfoResult] {
+	return newEmptyResult(DecodeGPUInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryIsGPUShared(start, end time.Time) *Future[IsGPUSharedResult] {
+	return newEmptyResult(DecodeIsGPUSharedResult)
+}
+
+// Device
+
+func (m *NoOpMetricsQuerier) QueryDCGMDeviceInfo(start, end time.Time) *Future[DCGMDeviceInfoResult] {
+	return newEmptyResult(DecodeDCGMDeviceInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryDCGMDeviceUptime(start, end time.Time) *Future[DCGMDeviceUptimeResult] {
+	return newEmptyResult(DecodeDCGMDeviceUptimeResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryDCGMContainerUsageAvg(start, end time.Time) *Future[DCGMDeviceContainerUsageResult] {
+	return newEmptyResult(DecodeDCGMDeviceContainerUsageResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryDCGMContainerUsageMax(start, end time.Time) *Future[DCGMDeviceContainerUsageResult] {
+	return newEmptyResult(DecodeDCGMDeviceContainerUsageResult)
+}
+
+// PVC
+
+func (m *NoOpMetricsQuerier) QueryPodPVCAllocation(start, end time.Time) *Future[PodPVCAllocationResult] {
+	return newEmptyResult(DecodePodPVCAllocationResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPVCBytesRequested(start, end time.Time) *Future[PVCBytesRequestedResult] {
+	return newEmptyResult(DecodePVCBytesRequestedResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPVCInfo(start, end time.Time) *Future[PVCInfoResult] {
+	return newEmptyResult(DecodePVCInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryKMPVCInfo(start, end time.Time) *Future[PVCInfoResult] {
+	return newEmptyResult(DecodePVCInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPVCUptime(start, end time.Time) *Future[UptimeResult] {
+	return newEmptyResult(DecodeUptimeResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPVCBytesUsedAverage(start, end time.Time) *Future[PVCUIDValueResult] {
+	return newEmptyResult(DecodePVCUIDValueResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPVCBytesUsedMax(start, end time.Time) *Future[PVCUIDValueResult] {
+	return newEmptyResult(DecodePVCUIDValueResult)
+}
+
+// PV
+
+func (m *NoOpMetricsQuerier) QueryPVBytes(start, end time.Time) *Future[PVBytesResult] {
+	return newEmptyResult(DecodePVBytesResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPVPricePerGiBHour(start, end time.Time) *Future[PVPricePerGiBHourResult] {
+	return newEmptyResult(DecodePVPricePerGiBHourResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPVInfo(start, end time.Time) *Future[PVInfoResult] {
+	return newEmptyResult(DecodePVInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPVActiveMinutes(start, end time.Time) *Future[PVActiveMinutesResult] {
+	return newEmptyResult(DecodePVActiveMinutesResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPVUsedAverage(start, end time.Time) *Future[PVUsedAvgResult] {
+	return newEmptyResult(DecodePVUsedAvgResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPVUsedMax(start, end time.Time) *Future[PVUsedMaxResult] {
+	return newEmptyResult(DecodePVUsedMaxResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryKMPVInfo(start, end time.Time) *Future[PVInfoResult] {
+	return newEmptyResult(DecodePVInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPVUptime(start, end time.Time) *Future[UptimeResult] {
+	return newEmptyResult(DecodeUptimeResult)
+}
+
+// Deployment
+
+func (m *NoOpMetricsQuerier) QueryDeploymentInfo(start, end time.Time) *Future[DeploymentInfoResult] {
+	return newEmptyResult(DecodeDeploymentInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryDeploymentUptime(start, end time.Time) *Future[UptimeResult] {
+	return newEmptyResult(DecodeUptimeResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryDeploymentLabels(start, end time.Time) *Future[LabelsResult] {
+	return newEmptyResult(DecodeLabelsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryDeploymentAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	return newEmptyResult(DecodeAnnotationsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryDeploymentMatchLabels(start, end time.Time) *Future[DeploymentLabelsResult] {
+	return newEmptyResult(DecodeDeploymentLabelsResult)
+}
+
+// StatefulSet
+
+func (m *NoOpMetricsQuerier) QueryStatefulSetInfo(start, end time.Time) *Future[StatefulSetInfoResult] {
+	return newEmptyResult(DecodeStatefulSetInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryStatefulSetUptime(start, end time.Time) *Future[UptimeResult] {
+	return newEmptyResult(DecodeUptimeResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryStatefulSetLabels(start, end time.Time) *Future[LabelsResult] {
+	return newEmptyResult(DecodeLabelsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryStatefulSetAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	return newEmptyResult(DecodeAnnotationsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryStatefulSetMatchLabels(start, end time.Time) *Future[StatefulSetLabelsResult] {
+	return newEmptyResult(DecodeStatefulSetLabelsResult)
+}
+
+// DaemonSet
+
+func (m *NoOpMetricsQuerier) QueryDaemonSetInfo(start, end time.Time) *Future[DaemonSetInfoResult] {
+	return newEmptyResult(DecodeDaemonSetInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryDaemonSetUptime(start, end time.Time) *Future[UptimeResult] {
+	return newEmptyResult(DecodeUptimeResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryDaemonSetLabels(start, end time.Time) *Future[LabelsResult] {
+	return newEmptyResult(DecodeLabelsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryDaemonSetAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	return newEmptyResult(DecodeAnnotationsResult)
+}
+
+// Job
+
+func (m *NoOpMetricsQuerier) QueryJobInfo(start, end time.Time) *Future[JobInfoResult] {
+	return newEmptyResult(DecodeJobInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryJobUptime(start, end time.Time) *Future[UptimeResult] {
+	return newEmptyResult(DecodeUptimeResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryJobLabels(start, end time.Time) *Future[LabelsResult] {
+	return newEmptyResult(DecodeLabelsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryJobAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	return newEmptyResult(DecodeAnnotationsResult)
+}
+
+// CronJob
+
+func (m *NoOpMetricsQuerier) QueryCronJobInfo(start, end time.Time) *Future[CronJobInfoResult] {
+	return newEmptyResult(DecodeCronJobInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryCronJobUptime(start, end time.Time) *Future[UptimeResult] {
+	return newEmptyResult(DecodeUptimeResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryCronJobLabels(start, end time.Time) *Future[LabelsResult] {
+	return newEmptyResult(DecodeLabelsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryCronJobAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	return newEmptyResult(DecodeAnnotationsResult)
+}
+
+// ReplicaSet
+
+func (m *NoOpMetricsQuerier) QueryReplicaSetInfo(start, end time.Time) *Future[ReplicaSetInfoResult] {
+	return newEmptyResult(DecodeReplicaSetInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryReplicaSetUptime(start, end time.Time) *Future[UptimeResult] {
+	return newEmptyResult(DecodeUptimeResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryReplicaSetLabels(start, end time.Time) *Future[LabelsResult] {
+	return newEmptyResult(DecodeLabelsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryReplicaSetAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	return newEmptyResult(DecodeAnnotationsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryReplicaSetOwners(start, end time.Time) *Future[OwnerResult] {
+	return newEmptyResult(DecodeOwnerResult)
+}
+
+// Namespace
+
+func (m *NoOpMetricsQuerier) QueryNamespaceInfo(start, end time.Time) *Future[NamespaceInfoResult] {
+	return newEmptyResult(DecodeNamespaceInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNamespaceUptime(start, end time.Time) *Future[UptimeResult] {
+	return newEmptyResult(DecodeUptimeResult)
+}
+
+// Service
+
+func (m *NoOpMetricsQuerier) QueryServiceInfo(start, end time.Time) *Future[ServiceInfoResult] {
+	return newEmptyResult(DecodeServiceInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryServiceUptime(start, end time.Time) *Future[UptimeResult] {
+	return newEmptyResult(DecodeUptimeResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryServiceSelectorLabels(start, end time.Time) *Future[ServiceLabelsResult] {
+	return newEmptyResult(DecodeServiceLabelsResult)
+}
+
+// Network Egress
+
+func (m *NoOpMetricsQuerier) QueryNetZoneGiB(start, end time.Time) *Future[NetZoneGiBResult] {
+	return newEmptyResult(DecodeNetZoneGiBResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNetZonePricePerGiB(start, end time.Time) *Future[NetZonePricePerGiBResult] {
+	return newEmptyResult(DecodeNetZonePricePerGiBResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNetRegionGiB(start, end time.Time) *Future[NetRegionGiBResult] {
+	return newEmptyResult(DecodeNetRegionGiBResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNetRegionPricePerGiB(start, end time.Time) *Future[NetRegionPricePerGiBResult] {
+	return newEmptyResult(DecodeNetRegionPricePerGiBResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNetInternetGiB(start, end time.Time) *Future[NetInternetGiBResult] {
+	return newEmptyResult(DecodeNetInternetGiBResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNetInternetPricePerGiB(start, end time.Time) *Future[NetInternetPricePerGiBResult] {
+	return newEmptyResult(DecodeNetInternetPricePerGiBResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNetInternetServiceGiB(start, end time.Time) *Future[NetInternetServiceGiBResult] {
+	return newEmptyResult(DecodeNetInternetServiceGiBResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNetNatGatewayPricePerGiB(start, end time.Time) *Future[NetNatGatewayPricePerGiBResult] {
+	return newEmptyResult(DecodeNetNatGatewayPricePerGiBResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNetNatGatewayGiB(start, end time.Time) *Future[NetNatGatewayGiBResult] {
+	return newEmptyResult(DecodeNetNatGatewayGiBResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNetTransferBytes(start, end time.Time) *Future[NetTransferBytesResult] {
+	return newEmptyResult(DecodeNetTransferBytesResult)
+}
+
+// Network Ingress
+
+func (m *NoOpMetricsQuerier) QueryNetZoneIngressGiB(start, end time.Time) *Future[NetZoneIngressGiBResult] {
+	return newEmptyResult(DecodeNetZoneIngressGiBResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNetRegionIngressGiB(start, end time.Time) *Future[NetRegionIngressGiBResult] {
+	return newEmptyResult(DecodeNetRegionIngressGiBResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNetInternetIngressGiB(start, end time.Time) *Future[NetInternetIngressGiBResult] {
+	return newEmptyResult(DecodeNetInternetIngressGiBResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNetInternetServiceIngressGiB(start, end time.Time) *Future[NetInternetServiceIngressGiBResult] {
+	return newEmptyResult(DecodeNetInternetServiceIngressGiBResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNetNatGatewayIngressPricePerGiB(start, end time.Time) *Future[NetNatGatewayPricePerGiBResult] {
+	return newEmptyResult(DecodeNetNatGatewayPricePerGiBResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNetNatGatewayIngressGiB(start, end time.Time) *Future[NetNatGatewayIngressGiBResult] {
+	return newEmptyResult(DecodeNetNatGatewayIngressGiBResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNetReceiveBytes(start, end time.Time) *Future[NetReceiveBytesResult] {
+	return newEmptyResult(DecodeNetReceiveBytesResult)
+}
+
+// Annotations
+
+func (m *NoOpMetricsQuerier) QueryNamespaceAnnotations(start, end time.Time) *Future[NamespaceAnnotationsResult] {
+	return newEmptyResult(DecodeNamespaceAnnotationsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPodAnnotations(start, end time.Time) *Future[PodAnnotationsResult] {
+	return newEmptyResult(DecodePodAnnotationsResult)
+}
+
+// Labels
+
+func (m *NoOpMetricsQuerier) QueryNodeLabels(start, end time.Time) *Future[NodeLabelsResult] {
+	return newEmptyResult(DecodeNodeLabelsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryNamespaceLabels(start, end time.Time) *Future[NamespaceLabelsResult] {
+	return newEmptyResult(DecodeNamespaceLabelsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPodLabels(start, end time.Time) *Future[PodLabelsResult] {
+	return newEmptyResult(DecodePodLabelsResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPodsWithDaemonSetOwner(start, end time.Time) *Future[PodsWithDaemonSetOwnerResult] {
+	return newEmptyResult(DecodePodsWithDaemonSetOwnerResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryPodsWithJobOwner(start, end time.Time) *Future[PodsWithJobOwnerResult] {
+	return newEmptyResult(DecodePodsWithJobOwnerResult)
+}
+
+// ReplicaSet -> Controller mapping
+
+func (m *NoOpMetricsQuerier) QueryPodsWithReplicaSetOwner(start, end time.Time) *Future[PodsWithReplicaSetOwnerResult] {
+	return newEmptyResult(DecodePodsWithReplicaSetOwnerResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryReplicaSetsWithoutOwners(start, end time.Time) *Future[ReplicaSetsWithoutOwnersResult] {
+	return newEmptyResult(DecodeReplicaSetsWithoutOwnersResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryReplicaSetsWithRollout(start, end time.Time) *Future[ReplicaSetsWithRolloutResult] {
+	return newEmptyResult(DecodeReplicaSetsWithRolloutResult)
+}
+
+// ResourceQuotas
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaInfo(start, end time.Time) *Future[ResourceQuotaInfoResult] {
+	return newEmptyResult(DecodeResourceQuotaInfoResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaUptime(start, end time.Time) *Future[UptimeResult] {
+	return newEmptyResult(DecodeUptimeResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaSpecCPURequestAverage(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaSpecCPURequestMax(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaSpecRAMRequestAverage(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaSpecRAMRequestMax(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaSpecCPULimitAverage(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaSpecCPULimitMax(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaSpecRAMLimitAverage(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaSpecRAMLimitMax(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaStatusUsedCPURequestAverage(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaStatusUsedCPURequestMax(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaStatusUsedRAMRequestAverage(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaStatusUsedRAMRequestMax(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaStatusUsedCPULimitAverage(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaStatusUsedCPULimitMax(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaStatusUsedRAMLimitAverage(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryResourceQuotaStatusUsedRAMLimitMax(start, end time.Time) *Future[ResourceResult] {
+	return newEmptyResult(DecodeResourceResult)
+}
+
+// Data Coverage Query
+
+func (m *NoOpMetricsQuerier) QueryDataCoverage(_ int) (time.Time, time.Time, error) {
+	return time.Time{}, time.Time{}, nil
+}
+
+// Extra methods not in MetricsQuerier interface
+
+func (m *NoOpMetricsQuerier) QueryLocalStorageCost(start, end time.Time) *Future[LocalStorageCostResult] {
+	return newEmptyResult(DecodeLocalStorageCostResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryLocalStorageUsedCost(start, end time.Time) *Future[LocalStorageUsedCostResult] {
+	return newEmptyResult(DecodeLocalStorageUsedCostResult)
+}
+
+func (m *NoOpMetricsQuerier) QueryServiceLabels(start, end time.Time) *Future[ServiceLabelsResult] {
+	return newEmptyResult(DecodeServiceLabelsResult)
+}
+
+func newEmptyResult[T any](decoder ResultDecoder[T]) *Future[T] {
+	ch := make(QueryResultsChan)
+	go func() {
+		results := NewQueryResults("")
+		ch <- results
+	}()
+
+	return NewFuture(decoder, ch)
+}

+ 856 - 0
core/pkg/source/record.go

@@ -0,0 +1,856 @@
+package source
+
+import (
+	"time"
+)
+
+var _ MetricsQuerier = (*RecordMetricsQuerier)(nil)
+
+// RecordMetricsQuerier is a wrapper implementation of MetricsQuerier which counts the number of times each function is
+// called
+type RecordMetricsQuerier struct {
+	Calls   map[string]int
+	Querier MetricsQuerier
+}
+
+// NewRecordMetricsQuerier creates a new mock metrics querier
+func NewRecordMetricsQuerier(querier MetricsQuerier) *RecordMetricsQuerier {
+	return &RecordMetricsQuerier{
+		Calls:   make(map[string]int),
+		Querier: querier,
+	}
+}
+
+// Helper to record method calls
+func (m *RecordMetricsQuerier) recordCall(method string) {
+	m.Calls[method]++
+}
+
+// Local Cluster Disks
+
+func (m *RecordMetricsQuerier) QueryLocalStorageActiveMinutes(start, end time.Time) *Future[LocalStorageActiveMinutesResult] {
+	m.recordCall(QueryLocalStorageActiveMinutes)
+	return m.Querier.QueryLocalStorageActiveMinutes(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryLocalStorageUsedAvg(start, end time.Time) *Future[LocalStorageUsedAvgResult] {
+	m.recordCall(QueryLocalStorageUsedAvg)
+	return m.Querier.QueryLocalStorageUsedAvg(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryLocalStorageUsedMax(start, end time.Time) *Future[LocalStorageUsedMaxResult] {
+	m.recordCall(QueryLocalStorageUsedMax)
+	return m.Querier.QueryLocalStorageUsedMax(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryLocalStorageBytes(start, end time.Time) *Future[LocalStorageBytesResult] {
+	m.recordCall(QueryLocalStorageBytes)
+	return m.Querier.QueryLocalStorageBytes(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryKMLocalStorageUsedAvg(start, end time.Time) *Future[NodeUIDValueResult] {
+	m.recordCall(QueryKMLocalStorageUsedAvg)
+	return m.Querier.QueryKMLocalStorageUsedAvg(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryKMLocalStorageUsedMax(start, end time.Time) *Future[NodeUIDValueResult] {
+	m.recordCall(QueryKMLocalStorageUsedMax)
+	return m.Querier.QueryKMLocalStorageUsedMax(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryKMLocalStorageBytes(start, end time.Time) *Future[UIDValueResult] {
+	m.recordCall(QueryKMLocalStorageBytes)
+	return m.Querier.QueryKMLocalStorageBytes(start, end)
+}
+
+// Nodes
+
+func (m *RecordMetricsQuerier) QueryNodeInfo(start, end time.Time) *Future[NodeInfoResult] {
+	m.recordCall(QueryNodeInfo)
+	return m.Querier.QueryNodeInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNodeUptime(start, end time.Time) *Future[UptimeResult] {
+	m.recordCall(QueryNodeUptime)
+	return m.Querier.QueryNodeUptime(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNodeActiveMinutes(start, end time.Time) *Future[NodeActiveMinutesResult] {
+	m.recordCall(QueryNodeActiveMinutes)
+	return m.Querier.QueryNodeActiveMinutes(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNodeCPUCoresCapacity(start, end time.Time) *Future[NodeCPUCoresCapacityResult] {
+	m.recordCall(QueryNodeCPUCoresCapacity)
+	return m.Querier.QueryNodeCPUCoresCapacity(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNodeCPUCoresAllocatable(start, end time.Time) *Future[NodeCPUCoresAllocatableResult] {
+	m.recordCall(QueryNodeCPUCoresAllocatable)
+	return m.Querier.QueryNodeCPUCoresAllocatable(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNodeRAMBytesCapacity(start, end time.Time) *Future[NodeRAMBytesCapacityResult] {
+	m.recordCall(QueryNodeRAMBytesCapacity)
+	return m.Querier.QueryNodeRAMBytesCapacity(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNodeRAMBytesAllocatable(start, end time.Time) *Future[NodeRAMBytesAllocatableResult] {
+	m.recordCall(QueryNodeRAMBytesAllocatable)
+	return m.Querier.QueryNodeRAMBytesAllocatable(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNodeGPUCount(start, end time.Time) *Future[NodeGPUCountResult] {
+	m.recordCall(QueryNodeGPUCount)
+	return m.Querier.QueryNodeGPUCount(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNodeCPUModeTotal(start, end time.Time) *Future[NodeCPUModeTotalResult] {
+	m.recordCall(QueryNodeCPUModeTotal)
+	return m.Querier.QueryNodeCPUModeTotal(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNodeIsSpot(start, end time.Time) *Future[NodeIsSpotResult] {
+	m.recordCall(QueryNodeIsSpot)
+	return m.Querier.QueryNodeIsSpot(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNodeRAMSystemPercent(start, end time.Time) *Future[NodeRAMSystemPercentResult] {
+	m.recordCall(QueryNodeRAMSystemPercent)
+	return m.Querier.QueryNodeRAMSystemPercent(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNodeRAMUserPercent(start, end time.Time) *Future[NodeRAMUserPercentResult] {
+	m.recordCall(QueryNodeRAMUserPercent)
+	return m.Querier.QueryNodeRAMUserPercent(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNodeResourceCapacities(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryNodeResourceCapacities)
+	return m.Querier.QueryNodeResourceCapacities(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNodeResourcesAllocatable(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryNodeResourcesAllocatable)
+	return m.Querier.QueryNodeResourcesAllocatable(start, end)
+}
+
+// Load Balancers
+
+func (m *RecordMetricsQuerier) QueryLBActiveMinutes(start, end time.Time) *Future[LBActiveMinutesResult] {
+	m.recordCall(QueryLBActiveMinutes)
+	return m.Querier.QueryLBActiveMinutes(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryLBPricePerHr(start, end time.Time) *Future[LBPricePerHrResult] {
+	m.recordCall(QueryLBPricePerHr)
+	return m.Querier.QueryLBPricePerHr(start, end)
+}
+
+// Cluster Management
+
+func (m *RecordMetricsQuerier) QueryClusterInfo(start, end time.Time) *Future[ClusterInfoResult] {
+	m.recordCall(QueryClusterInfo)
+	return m.Querier.QueryClusterInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryClusterKubeModelVersion(start, end time.Time) *Future[ClusterKubeModelVersionResult] {
+	m.recordCall(QueryClusterKubeModelVersion)
+	return m.Querier.QueryClusterKubeModelVersion(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryClusterUptime(start, end time.Time) *Future[UptimeResult] {
+	m.recordCall(QueryClusterUptime)
+	return m.Querier.QueryClusterUptime(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryClusterManagementDuration(start, end time.Time) *Future[ClusterManagementDurationResult] {
+	m.recordCall(QueryClusterManagementDuration)
+	return m.Querier.QueryClusterManagementDuration(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryClusterManagementPricePerHr(start, end time.Time) *Future[ClusterManagementPricePerHrResult] {
+	m.recordCall(QueryClusterManagementPricePerHr)
+	return m.Querier.QueryClusterManagementPricePerHr(start, end)
+}
+
+// Pods
+
+func (m *RecordMetricsQuerier) QueryPods(start, end time.Time) *Future[PodsResult] {
+	m.recordCall(QueryPods)
+	return m.Querier.QueryPods(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPodsUID(start, end time.Time) *Future[PodsResult] {
+	m.recordCall(QueryPodsUID)
+	return m.Querier.QueryPodsUID(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPodInfo(start, end time.Time) *Future[PodInfoResult] {
+	m.recordCall(QueryPodInfo)
+	return m.Querier.QueryPodInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPodUptime(start, end time.Time) *Future[UptimeResult] {
+	m.recordCall(QueryPodUptime)
+	return m.Querier.QueryPodUptime(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPodOwners(start, end time.Time) *Future[OwnerResult] {
+	m.recordCall(QueryPodOwners)
+	return m.Querier.QueryPodOwners(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPodPVCVolumes(start, end time.Time) *Future[PodPVCVolumeResult] {
+	m.recordCall(QueryPodPVCVolumes)
+	return m.Querier.QueryPodPVCVolumes(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPodNetworkEgressBytes(start, end time.Time) *Future[PodNetworkBytesResult] {
+	m.recordCall(QueryPodNetworkEgressBytes)
+	return m.Querier.QueryPodNetworkEgressBytes(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPodNetworkIngressBytes(start, end time.Time) *Future[PodNetworkBytesResult] {
+	m.recordCall(QueryPodNetworkIngressBytes)
+	return m.Querier.QueryPodNetworkIngressBytes(start, end)
+}
+
+// Container
+
+func (m *RecordMetricsQuerier) QueryContainerUptime(start, end time.Time) *Future[ContainerUptimeResult] {
+	m.recordCall(QueryContainerUptime)
+	return m.Querier.QueryContainerUptime(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryContainerResourceRequests(start, end time.Time) *Future[ContainerResourceResult] {
+	m.recordCall(QueryContainerResourceRequests)
+	return m.Querier.QueryContainerResourceRequests(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryContainerResourceLimits(start, end time.Time) *Future[ContainerResourceResult] {
+	m.recordCall(QueryContainerResourceLimits)
+	return m.Querier.QueryContainerResourceLimits(start, end)
+}
+
+// RAM
+
+func (m *RecordMetricsQuerier) QueryRAMBytesAllocated(start, end time.Time) *Future[RAMBytesAllocatedResult] {
+	m.recordCall(QueryRAMBytesAllocated)
+	return m.Querier.QueryRAMBytesAllocated(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryRAMRequests(start, end time.Time) *Future[RAMRequestsResult] {
+	m.recordCall(QueryRAMRequests)
+	return m.Querier.QueryRAMRequests(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryRAMLimits(start, end time.Time) *Future[RAMLimitsResult] {
+	m.recordCall(QueryRAMLimits)
+	return m.Querier.QueryRAMLimits(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryRAMUsageAvg(start, end time.Time) *Future[RAMUsageAvgResult] {
+	m.recordCall(QueryRAMUsageAvg)
+	return m.Querier.QueryRAMUsageAvg(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryRAMUsageMax(start, end time.Time) *Future[RAMUsageMaxResult] {
+	m.recordCall(QueryRAMUsageMax)
+	return m.Querier.QueryRAMUsageMax(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNodeRAMPricePerGiBHr(start, end time.Time) *Future[NodeRAMPricePerGiBHrResult] {
+	m.recordCall(QueryNodeRAMPricePerGiBHr)
+	return m.Querier.QueryNodeRAMPricePerGiBHr(start, end)
+}
+
+// CPU
+
+func (m *RecordMetricsQuerier) QueryCPUCoresAllocated(start, end time.Time) *Future[CPUCoresAllocatedResult] {
+	m.recordCall(QueryCPUCoresAllocated)
+	return m.Querier.QueryCPUCoresAllocated(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryCPURequests(start, end time.Time) *Future[CPURequestsResult] {
+	m.recordCall(QueryCPURequests)
+	return m.Querier.QueryCPURequests(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryCPULimits(start, end time.Time) *Future[CPULimitsResult] {
+	m.recordCall(QueryCPULimits)
+	return m.Querier.QueryCPULimits(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryCPUUsageAvg(start, end time.Time) *Future[CPUUsageAvgResult] {
+	m.recordCall(QueryCPUUsageAvg)
+	return m.Querier.QueryCPUUsageAvg(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryCPUUsageMax(start, end time.Time) *Future[CPUUsageMaxResult] {
+	m.recordCall(QueryCPUUsageMax)
+	return m.Querier.QueryCPUUsageMax(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNodeCPUPricePerHr(start, end time.Time) *Future[NodeCPUPricePerHrResult] {
+	m.recordCall(QueryNodeCPUPricePerHr)
+	return m.Querier.QueryNodeCPUPricePerHr(start, end)
+}
+
+// GPU
+
+func (m *RecordMetricsQuerier) QueryGPUsAllocated(start, end time.Time) *Future[GPUsAllocatedResult] {
+	m.recordCall(QueryGPUsAllocated)
+	return m.Querier.QueryGPUsAllocated(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryGPUsRequested(start, end time.Time) *Future[GPUsRequestedResult] {
+	m.recordCall(QueryGPUsRequested)
+	return m.Querier.QueryGPUsRequested(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryGPUsUsageAvg(start, end time.Time) *Future[GPUsUsageAvgResult] {
+	m.recordCall(QueryGPUsUsageAvg)
+	return m.Querier.QueryGPUsUsageAvg(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryGPUsUsageMax(start, end time.Time) *Future[GPUsUsageMaxResult] {
+	m.recordCall(QueryGPUsUsageMax)
+	return m.Querier.QueryGPUsUsageMax(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNodeGPUPricePerHr(start, end time.Time) *Future[NodeGPUPricePerHrResult] {
+	m.recordCall(QueryNodeGPUPricePerHr)
+	return m.Querier.QueryNodeGPUPricePerHr(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryGPUInfo(start, end time.Time) *Future[GPUInfoResult] {
+	m.recordCall(QueryGPUInfo)
+	return m.Querier.QueryGPUInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryIsGPUShared(start, end time.Time) *Future[IsGPUSharedResult] {
+	m.recordCall(QueryIsGPUShared)
+	return m.Querier.QueryIsGPUShared(start, end)
+}
+
+// Device
+
+func (m *RecordMetricsQuerier) QueryDCGMDeviceInfo(start, end time.Time) *Future[DCGMDeviceInfoResult] {
+	m.recordCall(QueryDCGMDeviceInfo)
+	return m.Querier.QueryDCGMDeviceInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryDCGMDeviceUptime(start, end time.Time) *Future[DCGMDeviceUptimeResult] {
+	m.recordCall(QueryDCGMDeviceUptime)
+	return m.Querier.QueryDCGMDeviceUptime(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryDCGMContainerUsageAvg(start, end time.Time) *Future[DCGMDeviceContainerUsageResult] {
+	m.recordCall(QueryDCGMContainerUsageAvg)
+	return m.Querier.QueryDCGMContainerUsageAvg(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryDCGMContainerUsageMax(start, end time.Time) *Future[DCGMDeviceContainerUsageResult] {
+	m.recordCall(QueryDCGMContainerUsageMax)
+	return m.Querier.QueryDCGMContainerUsageMax(start, end)
+}
+
+// PVC
+
+func (m *RecordMetricsQuerier) QueryPodPVCAllocation(start, end time.Time) *Future[PodPVCAllocationResult] {
+	m.recordCall(QueryPodPVCAllocation)
+	return m.Querier.QueryPodPVCAllocation(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPVCBytesRequested(start, end time.Time) *Future[PVCBytesRequestedResult] {
+	m.recordCall(QueryPVCBytesRequested)
+	return m.Querier.QueryPVCBytesRequested(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPVCInfo(start, end time.Time) *Future[PVCInfoResult] {
+	m.recordCall(QueryPVCInfo)
+	return m.Querier.QueryPVCInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryKMPVCInfo(start, end time.Time) *Future[PVCInfoResult] {
+	m.recordCall(QueryKMPVCInfo)
+	return m.Querier.QueryKMPVCInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPVCUptime(start, end time.Time) *Future[UptimeResult] {
+	m.recordCall(QueryPVCUptime)
+	return m.Querier.QueryPVCUptime(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPVCBytesUsedAverage(start, end time.Time) *Future[PVCUIDValueResult] {
+	m.recordCall(QueryPVCBytesUsedAverage)
+	return m.Querier.QueryPVCBytesUsedAverage(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPVCBytesUsedMax(start, end time.Time) *Future[PVCUIDValueResult] {
+	m.recordCall(QueryPVCBytesUsedMax)
+	return m.Querier.QueryPVCBytesUsedMax(start, end)
+}
+
+// PV
+
+func (m *RecordMetricsQuerier) QueryPVBytes(start, end time.Time) *Future[PVBytesResult] {
+	m.recordCall(QueryPVBytes)
+	return m.Querier.QueryPVBytes(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPVPricePerGiBHour(start, end time.Time) *Future[PVPricePerGiBHourResult] {
+	m.recordCall(QueryPVPricePerGiBHour)
+	return m.Querier.QueryPVPricePerGiBHour(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPVInfo(start, end time.Time) *Future[PVInfoResult] {
+	m.recordCall(QueryPVInfo)
+	return m.Querier.QueryPVInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPVActiveMinutes(start, end time.Time) *Future[PVActiveMinutesResult] {
+	m.recordCall(QueryPVActiveMinutes)
+	return m.Querier.QueryPVActiveMinutes(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPVUsedAverage(start, end time.Time) *Future[PVUsedAvgResult] {
+	m.recordCall(QueryPVUsedAverage)
+	return m.Querier.QueryPVUsedAverage(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPVUsedMax(start, end time.Time) *Future[PVUsedMaxResult] {
+	m.recordCall(QueryPVUsedMax)
+	return m.Querier.QueryPVUsedMax(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryKMPVInfo(start, end time.Time) *Future[PVInfoResult] {
+	m.recordCall(QueryKMPVInfo)
+	return m.Querier.QueryKMPVInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPVUptime(start, end time.Time) *Future[UptimeResult] {
+	m.recordCall(QueryPVUptime)
+	return m.Querier.QueryPVUptime(start, end)
+}
+
+// Deployment
+
+func (m *RecordMetricsQuerier) QueryDeploymentInfo(start, end time.Time) *Future[DeploymentInfoResult] {
+	m.recordCall(QueryDeploymentInfo)
+	return m.Querier.QueryDeploymentInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryDeploymentUptime(start, end time.Time) *Future[UptimeResult] {
+	m.recordCall(QueryDeploymentUptime)
+	return m.Querier.QueryDeploymentUptime(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryDeploymentLabels(start, end time.Time) *Future[LabelsResult] {
+	m.recordCall(QueryDeploymentLabels)
+	return m.Querier.QueryDeploymentLabels(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryDeploymentAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	m.recordCall(QueryDeploymentAnnotations)
+	return m.Querier.QueryDeploymentAnnotations(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryDeploymentMatchLabels(start, end time.Time) *Future[DeploymentLabelsResult] {
+	m.recordCall(QueryDeploymentMatchLabels)
+	return m.Querier.QueryDeploymentMatchLabels(start, end)
+}
+
+// StatefulSet
+
+func (m *RecordMetricsQuerier) QueryStatefulSetInfo(start, end time.Time) *Future[StatefulSetInfoResult] {
+	m.recordCall(QueryStatefulSetInfo)
+	return m.Querier.QueryStatefulSetInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryStatefulSetUptime(start, end time.Time) *Future[UptimeResult] {
+	m.recordCall(QueryStatefulSetUptime)
+	return m.Querier.QueryStatefulSetUptime(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryStatefulSetLabels(start, end time.Time) *Future[LabelsResult] {
+	m.recordCall(QueryStatefulSetLabels)
+	return m.Querier.QueryStatefulSetLabels(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryStatefulSetAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	m.recordCall(QueryStatefulSetAnnotations)
+	return m.Querier.QueryStatefulSetAnnotations(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryStatefulSetMatchLabels(start, end time.Time) *Future[StatefulSetLabelsResult] {
+	m.recordCall(QueryStatefulSetMatchLabels)
+	return m.Querier.QueryStatefulSetMatchLabels(start, end)
+}
+
+// DaemonSet
+
+func (m *RecordMetricsQuerier) QueryDaemonSetInfo(start, end time.Time) *Future[DaemonSetInfoResult] {
+	m.recordCall(QueryDaemonSetInfo)
+	return m.Querier.QueryDaemonSetInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryDaemonSetUptime(start, end time.Time) *Future[UptimeResult] {
+	m.recordCall(QueryDaemonSetUptime)
+	return m.Querier.QueryDaemonSetUptime(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryDaemonSetLabels(start, end time.Time) *Future[LabelsResult] {
+	m.recordCall(QueryDaemonSetLabels)
+	return m.Querier.QueryDaemonSetLabels(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryDaemonSetAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	m.recordCall(QueryDaemonSetAnnotations)
+	return m.Querier.QueryDaemonSetAnnotations(start, end)
+}
+
+// Job
+
+func (m *RecordMetricsQuerier) QueryJobInfo(start, end time.Time) *Future[JobInfoResult] {
+	m.recordCall(QueryJobInfo)
+	return m.Querier.QueryJobInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryJobUptime(start, end time.Time) *Future[UptimeResult] {
+	m.recordCall(QueryJobUptime)
+	return m.Querier.QueryJobUptime(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryJobLabels(start, end time.Time) *Future[LabelsResult] {
+	m.recordCall(QueryJobLabels)
+	return m.Querier.QueryJobLabels(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryJobAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	m.recordCall(QueryJobAnnotations)
+	return m.Querier.QueryJobAnnotations(start, end)
+}
+
+// CronJob
+
+func (m *RecordMetricsQuerier) QueryCronJobInfo(start, end time.Time) *Future[CronJobInfoResult] {
+	m.recordCall(QueryCronJobInfo)
+	return m.Querier.QueryCronJobInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryCronJobUptime(start, end time.Time) *Future[UptimeResult] {
+	m.recordCall(QueryCronJobUptime)
+	return m.Querier.QueryCronJobUptime(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryCronJobLabels(start, end time.Time) *Future[LabelsResult] {
+	m.recordCall(QueryCronJobLabels)
+	return m.Querier.QueryCronJobLabels(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryCronJobAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	m.recordCall(QueryCronJobAnnotations)
+	return m.Querier.QueryCronJobAnnotations(start, end)
+}
+
+// ReplicaSet
+
+func (m *RecordMetricsQuerier) QueryReplicaSetInfo(start, end time.Time) *Future[ReplicaSetInfoResult] {
+	m.recordCall(QueryReplicaSetInfo)
+	return m.Querier.QueryReplicaSetInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryReplicaSetUptime(start, end time.Time) *Future[UptimeResult] {
+	m.recordCall(QueryReplicaSetUptime)
+	return m.Querier.QueryReplicaSetUptime(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryReplicaSetLabels(start, end time.Time) *Future[LabelsResult] {
+	m.recordCall(QueryReplicaSetLabels)
+	return m.Querier.QueryReplicaSetLabels(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryReplicaSetAnnotations(start, end time.Time) *Future[AnnotationsResult] {
+	m.recordCall(QueryReplicaSetAnnotations)
+	return m.Querier.QueryReplicaSetAnnotations(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryReplicaSetOwners(start, end time.Time) *Future[OwnerResult] {
+	m.recordCall(QueryReplicaSetOwners)
+	return m.Querier.QueryReplicaSetOwners(start, end)
+}
+
+// Namespace
+
+func (m *RecordMetricsQuerier) QueryNamespaceInfo(start, end time.Time) *Future[NamespaceInfoResult] {
+	m.recordCall(QueryNamespaceInfo)
+	return m.Querier.QueryNamespaceInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNamespaceUptime(start, end time.Time) *Future[UptimeResult] {
+	m.recordCall(QueryNamespaceUptime)
+	return m.Querier.QueryNamespaceUptime(start, end)
+}
+
+// Service
+
+func (m *RecordMetricsQuerier) QueryServiceInfo(start, end time.Time) *Future[ServiceInfoResult] {
+	m.recordCall(QueryServiceInfo)
+	return m.Querier.QueryServiceInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryServiceUptime(start, end time.Time) *Future[UptimeResult] {
+	m.recordCall(QueryServiceUptime)
+	return m.Querier.QueryServiceUptime(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryServiceSelectorLabels(start, end time.Time) *Future[ServiceLabelsResult] {
+	m.recordCall(QueryServiceSelectorLabels)
+	return m.Querier.QueryServiceSelectorLabels(start, end)
+}
+
+// Network Egress
+
+func (m *RecordMetricsQuerier) QueryNetZoneGiB(start, end time.Time) *Future[NetZoneGiBResult] {
+	m.recordCall(QueryNetZoneGiB)
+	return m.Querier.QueryNetZoneGiB(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNetZonePricePerGiB(start, end time.Time) *Future[NetZonePricePerGiBResult] {
+	m.recordCall(QueryNetZonePricePerGiB)
+	return m.Querier.QueryNetZonePricePerGiB(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNetRegionGiB(start, end time.Time) *Future[NetRegionGiBResult] {
+	m.recordCall(QueryNetRegionGiB)
+	return m.Querier.QueryNetRegionGiB(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNetRegionPricePerGiB(start, end time.Time) *Future[NetRegionPricePerGiBResult] {
+	m.recordCall(QueryNetRegionPricePerGiB)
+	return m.Querier.QueryNetRegionPricePerGiB(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNetInternetGiB(start, end time.Time) *Future[NetInternetGiBResult] {
+	m.recordCall(QueryNetInternetGiB)
+	return m.Querier.QueryNetInternetGiB(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNetInternetPricePerGiB(start, end time.Time) *Future[NetInternetPricePerGiBResult] {
+	m.recordCall(QueryNetInternetPricePerGiB)
+	return m.Querier.QueryNetInternetPricePerGiB(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNetInternetServiceGiB(start, end time.Time) *Future[NetInternetServiceGiBResult] {
+	m.recordCall(QueryNetInternetServiceGiB)
+	return m.Querier.QueryNetInternetServiceGiB(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNetNatGatewayPricePerGiB(start, end time.Time) *Future[NetNatGatewayPricePerGiBResult] {
+	m.recordCall(QueryNetNatGatewayPricePerGiB)
+	return m.Querier.QueryNetNatGatewayPricePerGiB(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNetNatGatewayGiB(start, end time.Time) *Future[NetNatGatewayGiBResult] {
+	m.recordCall(QueryNetNatGatewayGiB)
+	return m.Querier.QueryNetNatGatewayGiB(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNetTransferBytes(start, end time.Time) *Future[NetTransferBytesResult] {
+	m.recordCall(QueryNetTransferBytes)
+	return m.Querier.QueryNetTransferBytes(start, end)
+}
+
+// Network Ingress
+
+func (m *RecordMetricsQuerier) QueryNetZoneIngressGiB(start, end time.Time) *Future[NetZoneIngressGiBResult] {
+	m.recordCall(QueryNetZoneIngressGiB)
+	return m.Querier.QueryNetZoneIngressGiB(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNetRegionIngressGiB(start, end time.Time) *Future[NetRegionIngressGiBResult] {
+	m.recordCall(QueryNetRegionIngressGiB)
+	return m.Querier.QueryNetRegionIngressGiB(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNetInternetIngressGiB(start, end time.Time) *Future[NetInternetIngressGiBResult] {
+	m.recordCall(QueryNetInternetIngressGiB)
+	return m.Querier.QueryNetInternetIngressGiB(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNetInternetServiceIngressGiB(start, end time.Time) *Future[NetInternetServiceIngressGiBResult] {
+	m.recordCall(QueryNetInternetServiceIngressGiB)
+	return m.Querier.QueryNetInternetServiceIngressGiB(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNetNatGatewayIngressPricePerGiB(start, end time.Time) *Future[NetNatGatewayPricePerGiBResult] {
+	m.recordCall(QueryNetNatGatewayIngressPricePerGiB)
+	return m.Querier.QueryNetNatGatewayIngressPricePerGiB(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNetNatGatewayIngressGiB(start, end time.Time) *Future[NetNatGatewayIngressGiBResult] {
+	m.recordCall(QueryNetNatGatewayIngressGiB)
+	return m.Querier.QueryNetNatGatewayIngressGiB(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNetReceiveBytes(start, end time.Time) *Future[NetReceiveBytesResult] {
+	m.recordCall(QueryNetReceiveBytes)
+	return m.Querier.QueryNetReceiveBytes(start, end)
+}
+
+// Annotations
+
+func (m *RecordMetricsQuerier) QueryNamespaceAnnotations(start, end time.Time) *Future[NamespaceAnnotationsResult] {
+	m.recordCall(QueryNamespaceAnnotations)
+	return m.Querier.QueryNamespaceAnnotations(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPodAnnotations(start, end time.Time) *Future[PodAnnotationsResult] {
+	m.recordCall(QueryPodAnnotations)
+	return m.Querier.QueryPodAnnotations(start, end)
+}
+
+// Labels
+
+func (m *RecordMetricsQuerier) QueryNodeLabels(start, end time.Time) *Future[NodeLabelsResult] {
+	m.recordCall(QueryNodeLabels)
+	return m.Querier.QueryNodeLabels(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryNamespaceLabels(start, end time.Time) *Future[NamespaceLabelsResult] {
+	m.recordCall(QueryNamespaceLabels)
+	return m.Querier.QueryNamespaceLabels(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPodLabels(start, end time.Time) *Future[PodLabelsResult] {
+	m.recordCall(QueryPodLabels)
+	return m.Querier.QueryPodLabels(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPodsWithDaemonSetOwner(start, end time.Time) *Future[PodsWithDaemonSetOwnerResult] {
+	m.recordCall(QueryPodsWithDaemonSetOwner)
+	return m.Querier.QueryPodsWithDaemonSetOwner(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryPodsWithJobOwner(start, end time.Time) *Future[PodsWithJobOwnerResult] {
+	m.recordCall(QueryPodsWithJobOwner)
+	return m.Querier.QueryPodsWithJobOwner(start, end)
+}
+
+// ReplicaSet -> Controller mapping
+
+func (m *RecordMetricsQuerier) QueryPodsWithReplicaSetOwner(start, end time.Time) *Future[PodsWithReplicaSetOwnerResult] {
+	m.recordCall(QueryPodsWithReplicaSetOwner)
+	return m.Querier.QueryPodsWithReplicaSetOwner(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryReplicaSetsWithoutOwners(start, end time.Time) *Future[ReplicaSetsWithoutOwnersResult] {
+	m.recordCall(QueryReplicaSetsWithoutOwners)
+	return m.Querier.QueryReplicaSetsWithoutOwners(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryReplicaSetsWithRollout(start, end time.Time) *Future[ReplicaSetsWithRolloutResult] {
+	m.recordCall(QueryReplicaSetsWithRollout)
+	return m.Querier.QueryReplicaSetsWithRollout(start, end)
+}
+
+// ResourceQuotas
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaInfo(start, end time.Time) *Future[ResourceQuotaInfoResult] {
+	m.recordCall(QueryResourceQuotaInfo)
+	return m.Querier.QueryResourceQuotaInfo(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaUptime(start, end time.Time) *Future[UptimeResult] {
+	m.recordCall(QueryResourceQuotaUptime)
+	return m.Querier.QueryResourceQuotaUptime(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaSpecCPURequestAverage(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryResourceQuotaSpecCPURequestAverage)
+	return m.Querier.QueryResourceQuotaSpecCPURequestAverage(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaSpecCPURequestMax(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryResourceQuotaSpecCPURequestMax)
+	return m.Querier.QueryResourceQuotaSpecCPURequestMax(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaSpecRAMRequestAverage(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryResourceQuotaSpecRAMRequestAverage)
+	return m.Querier.QueryResourceQuotaSpecRAMRequestAverage(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaSpecRAMRequestMax(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryResourceQuotaSpecRAMRequestMax)
+	return m.Querier.QueryResourceQuotaSpecRAMRequestMax(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaSpecCPULimitAverage(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryResourceQuotaSpecCPULimitAverage)
+	return m.Querier.QueryResourceQuotaSpecCPULimitAverage(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaSpecCPULimitMax(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryResourceQuotaSpecCPULimitMax)
+	return m.Querier.QueryResourceQuotaSpecCPULimitMax(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaSpecRAMLimitAverage(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryResourceQuotaSpecRAMLimitAverage)
+	return m.Querier.QueryResourceQuotaSpecRAMLimitAverage(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaSpecRAMLimitMax(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryResourceQuotaSpecRAMLimitMax)
+	return m.Querier.QueryResourceQuotaSpecRAMLimitMax(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaStatusUsedCPURequestAverage(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryResourceQuotaStatusUsedCPURequestAverage)
+	return m.Querier.QueryResourceQuotaStatusUsedCPURequestAverage(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaStatusUsedCPURequestMax(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryResourceQuotaStatusUsedCPURequestMax)
+	return m.Querier.QueryResourceQuotaStatusUsedCPURequestMax(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaStatusUsedRAMRequestAverage(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryResourceQuotaStatusUsedRAMRequestAverage)
+	return m.Querier.QueryResourceQuotaStatusUsedRAMRequestAverage(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaStatusUsedRAMRequestMax(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryResourceQuotaStatusUsedRAMRequestMax)
+	return m.Querier.QueryResourceQuotaStatusUsedRAMRequestMax(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaStatusUsedCPULimitAverage(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryResourceQuotaStatusUsedCPULimitAverage)
+	return m.Querier.QueryResourceQuotaStatusUsedCPULimitAverage(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaStatusUsedCPULimitMax(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryResourceQuotaStatusUsedCPULimitMax)
+	return m.Querier.QueryResourceQuotaStatusUsedCPULimitMax(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaStatusUsedRAMLimitAverage(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryResourceQuotaStatusUsedRAMLimitAverage)
+	return m.Querier.QueryResourceQuotaStatusUsedRAMLimitAverage(start, end)
+}
+
+func (m *RecordMetricsQuerier) QueryResourceQuotaStatusUsedRAMLimitMax(start, end time.Time) *Future[ResourceResult] {
+	m.recordCall(QueryResourceQuotaStatusUsedRAMLimitMax)
+	return m.Querier.QueryResourceQuotaStatusUsedRAMLimitMax(start, end)
+}
+
+// Data Coverage Query
+
+func (m *RecordMetricsQuerier) QueryDataCoverage(limitDays int) (time.Time, time.Time, error) {
+	m.recordCall(QueryDataCoverage)
+	return m.Querier.QueryDataCoverage(limitDays)
+}

+ 329 - 0
core/pkg/source/record_test.go

@@ -0,0 +1,329 @@
+package source
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestRecordMetricsQuerier_Counts(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	r := NewRecordMetricsQuerier(NewNoOpMetricsQuerier())
+
+	r.QueryLocalStorageActiveMinutes(start, end)
+	r.QueryLocalStorageUsedAvg(start, end)
+	r.QueryLocalStorageUsedMax(start, end)
+	r.QueryLocalStorageBytes(start, end)
+	r.QueryKMLocalStorageUsedAvg(start, end)
+	r.QueryKMLocalStorageUsedMax(start, end)
+	r.QueryKMLocalStorageBytes(start, end)
+	r.QueryNodeInfo(start, end)
+	r.QueryNodeUptime(start, end)
+	r.QueryNodeActiveMinutes(start, end)
+	r.QueryNodeCPUCoresCapacity(start, end)
+	r.QueryNodeCPUCoresAllocatable(start, end)
+	r.QueryNodeRAMBytesCapacity(start, end)
+	r.QueryNodeRAMBytesAllocatable(start, end)
+	r.QueryNodeGPUCount(start, end)
+	r.QueryNodeCPUModeTotal(start, end)
+	r.QueryNodeIsSpot(start, end)
+	r.QueryNodeRAMSystemPercent(start, end)
+	r.QueryNodeRAMUserPercent(start, end)
+	r.QueryNodeResourceCapacities(start, end)
+	r.QueryNodeResourcesAllocatable(start, end)
+	r.QueryLBActiveMinutes(start, end)
+	r.QueryLBPricePerHr(start, end)
+	r.QueryClusterInfo(start, end)
+	r.QueryClusterUptime(start, end)
+	r.QueryClusterManagementDuration(start, end)
+	r.QueryClusterManagementPricePerHr(start, end)
+	r.QueryPods(start, end)
+	r.QueryPodsUID(start, end)
+	r.QueryPodInfo(start, end)
+	r.QueryPodUptime(start, end)
+	r.QueryPodOwners(start, end)
+	r.QueryPodPVCVolumes(start, end)
+	r.QueryPodNetworkEgressBytes(start, end)
+	r.QueryPodNetworkIngressBytes(start, end)
+	r.QueryContainerUptime(start, end)
+	r.QueryContainerResourceRequests(start, end)
+	r.QueryContainerResourceLimits(start, end)
+	r.QueryRAMBytesAllocated(start, end)
+	r.QueryRAMRequests(start, end)
+	r.QueryRAMLimits(start, end)
+	r.QueryRAMUsageAvg(start, end)
+	r.QueryRAMUsageMax(start, end)
+	r.QueryNodeRAMPricePerGiBHr(start, end)
+	r.QueryCPUCoresAllocated(start, end)
+	r.QueryCPURequests(start, end)
+	r.QueryCPULimits(start, end)
+	r.QueryCPUUsageAvg(start, end)
+	r.QueryCPUUsageMax(start, end)
+	r.QueryNodeCPUPricePerHr(start, end)
+	r.QueryGPUsAllocated(start, end)
+	r.QueryGPUsRequested(start, end)
+	r.QueryGPUsUsageAvg(start, end)
+	r.QueryGPUsUsageMax(start, end)
+	r.QueryNodeGPUPricePerHr(start, end)
+	r.QueryGPUInfo(start, end)
+	r.QueryIsGPUShared(start, end)
+	r.QueryDCGMDeviceInfo(start, end)
+	r.QueryDCGMDeviceUptime(start, end)
+	r.QueryDCGMContainerUsageAvg(start, end)
+	r.QueryDCGMContainerUsageMax(start, end)
+	r.QueryPodPVCAllocation(start, end)
+	r.QueryPVCBytesRequested(start, end)
+	r.QueryPVCInfo(start, end)
+	r.QueryKMPVCInfo(start, end)
+	r.QueryPVCUptime(start, end)
+	r.QueryPVCBytesUsedAverage(start, end)
+	r.QueryPVCBytesUsedMax(start, end)
+	r.QueryPVBytes(start, end)
+	r.QueryPVPricePerGiBHour(start, end)
+	r.QueryPVInfo(start, end)
+	r.QueryPVActiveMinutes(start, end)
+	r.QueryPVUsedAverage(start, end)
+	r.QueryPVUsedMax(start, end)
+	r.QueryKMPVInfo(start, end)
+	r.QueryPVUptime(start, end)
+	r.QueryDeploymentInfo(start, end)
+	r.QueryDeploymentUptime(start, end)
+	r.QueryDeploymentLabels(start, end)
+	r.QueryDeploymentAnnotations(start, end)
+	r.QueryDeploymentMatchLabels(start, end)
+	r.QueryStatefulSetInfo(start, end)
+	r.QueryStatefulSetUptime(start, end)
+	r.QueryStatefulSetLabels(start, end)
+	r.QueryStatefulSetAnnotations(start, end)
+	r.QueryStatefulSetMatchLabels(start, end)
+	r.QueryDaemonSetInfo(start, end)
+	r.QueryDaemonSetUptime(start, end)
+	r.QueryDaemonSetLabels(start, end)
+	r.QueryDaemonSetAnnotations(start, end)
+	r.QueryJobInfo(start, end)
+	r.QueryJobUptime(start, end)
+	r.QueryJobLabels(start, end)
+	r.QueryJobAnnotations(start, end)
+	r.QueryCronJobInfo(start, end)
+	r.QueryCronJobUptime(start, end)
+	r.QueryCronJobLabels(start, end)
+	r.QueryCronJobAnnotations(start, end)
+	r.QueryReplicaSetInfo(start, end)
+	r.QueryReplicaSetUptime(start, end)
+	r.QueryReplicaSetLabels(start, end)
+	r.QueryReplicaSetAnnotations(start, end)
+	r.QueryReplicaSetOwners(start, end)
+	r.QueryNamespaceInfo(start, end)
+	r.QueryNamespaceUptime(start, end)
+	r.QueryServiceInfo(start, end)
+	r.QueryServiceUptime(start, end)
+	r.QueryServiceSelectorLabels(start, end)
+	r.QueryNetZoneGiB(start, end)
+	r.QueryNetZonePricePerGiB(start, end)
+	r.QueryNetRegionGiB(start, end)
+	r.QueryNetRegionPricePerGiB(start, end)
+	r.QueryNetInternetGiB(start, end)
+	r.QueryNetInternetPricePerGiB(start, end)
+	r.QueryNetInternetServiceGiB(start, end)
+	r.QueryNetNatGatewayPricePerGiB(start, end)
+	r.QueryNetNatGatewayGiB(start, end)
+	r.QueryNetTransferBytes(start, end)
+	r.QueryNetZoneIngressGiB(start, end)
+	r.QueryNetRegionIngressGiB(start, end)
+	r.QueryNetInternetIngressGiB(start, end)
+	r.QueryNetInternetServiceIngressGiB(start, end)
+	r.QueryNetNatGatewayIngressPricePerGiB(start, end)
+	r.QueryNetNatGatewayIngressGiB(start, end)
+	r.QueryNetReceiveBytes(start, end)
+	r.QueryNamespaceAnnotations(start, end)
+	r.QueryPodAnnotations(start, end)
+	r.QueryNodeLabels(start, end)
+	r.QueryNamespaceLabels(start, end)
+	r.QueryPodLabels(start, end)
+	r.QueryPodsWithDaemonSetOwner(start, end)
+	r.QueryPodsWithJobOwner(start, end)
+	r.QueryPodsWithReplicaSetOwner(start, end)
+	r.QueryReplicaSetsWithoutOwners(start, end)
+	r.QueryReplicaSetsWithRollout(start, end)
+	r.QueryResourceQuotaInfo(start, end)
+	r.QueryResourceQuotaUptime(start, end)
+	r.QueryResourceQuotaSpecCPURequestAverage(start, end)
+	r.QueryResourceQuotaSpecCPURequestMax(start, end)
+	r.QueryResourceQuotaSpecRAMRequestAverage(start, end)
+	r.QueryResourceQuotaSpecRAMRequestMax(start, end)
+	r.QueryResourceQuotaSpecCPULimitAverage(start, end)
+	r.QueryResourceQuotaSpecCPULimitMax(start, end)
+	r.QueryResourceQuotaSpecRAMLimitAverage(start, end)
+	r.QueryResourceQuotaSpecRAMLimitMax(start, end)
+	r.QueryResourceQuotaStatusUsedCPURequestAverage(start, end)
+	r.QueryResourceQuotaStatusUsedCPURequestMax(start, end)
+	r.QueryResourceQuotaStatusUsedRAMRequestAverage(start, end)
+	r.QueryResourceQuotaStatusUsedRAMRequestMax(start, end)
+	r.QueryResourceQuotaStatusUsedCPULimitAverage(start, end)
+	r.QueryResourceQuotaStatusUsedCPULimitMax(start, end)
+	r.QueryResourceQuotaStatusUsedRAMLimitAverage(start, end)
+	r.QueryResourceQuotaStatusUsedRAMLimitMax(start, end)
+	r.QueryDataCoverage(30)
+
+	expected := map[string]int{
+		"QueryLocalStorageActiveMinutes":                1,
+		"QueryLocalStorageUsedAvg":                      1,
+		"QueryLocalStorageUsedMax":                      1,
+		"QueryLocalStorageBytes":                        1,
+		"QueryKMLocalStorageUsedAvg":                    1,
+		"QueryKMLocalStorageUsedMax":                    1,
+		"QueryKMLocalStorageBytes":                      1,
+		"QueryNodeInfo":                                 1,
+		"QueryNodeUptime":                               1,
+		"QueryNodeActiveMinutes":                        1,
+		"QueryNodeCPUCoresCapacity":                     1,
+		"QueryNodeCPUCoresAllocatable":                  1,
+		"QueryNodeRAMBytesCapacity":                     1,
+		"QueryNodeRAMBytesAllocatable":                  1,
+		"QueryNodeGPUCount":                             1,
+		"QueryNodeCPUModeTotal":                         1,
+		"QueryNodeIsSpot":                               1,
+		"QueryNodeRAMSystemPercent":                     1,
+		"QueryNodeRAMUserPercent":                       1,
+		"QueryNodeResourceCapacities":                   1,
+		"QueryNodeResourcesAllocatable":                 1,
+		"QueryLBActiveMinutes":                          1,
+		"QueryLBPricePerHr":                             1,
+		"QueryClusterInfo":                              1,
+		"QueryClusterUptime":                            1,
+		"QueryClusterManagementDuration":                1,
+		"QueryClusterManagementPricePerHr":              1,
+		"QueryPods":                                     1,
+		"QueryPodsUID":                                  1,
+		"QueryPodInfo":                                  1,
+		"QueryPodUptime":                                1,
+		"QueryPodOwners":                                1,
+		"QueryPodPVCVolumes":                            1,
+		"QueryPodNetworkEgressBytes":                    1,
+		"QueryPodNetworkIngressBytes":                   1,
+		"QueryContainerUptime":                          1,
+		"QueryContainerResourceRequests":                1,
+		"QueryContainerResourceLimits":                  1,
+		"QueryRAMBytesAllocated":                        1,
+		"QueryRAMRequests":                              1,
+		"QueryRAMLimits":                                1,
+		"QueryRAMUsageAvg":                              1,
+		"QueryRAMUsageMax":                              1,
+		"QueryNodeRAMPricePerGiBHr":                     1,
+		"QueryCPUCoresAllocated":                        1,
+		"QueryCPURequests":                              1,
+		"QueryCPULimits":                                1,
+		"QueryCPUUsageAvg":                              1,
+		"QueryCPUUsageMax":                              1,
+		"QueryNodeCPUPricePerHr":                        1,
+		"QueryGPUsAllocated":                            1,
+		"QueryGPUsRequested":                            1,
+		"QueryGPUsUsageAvg":                             1,
+		"QueryGPUsUsageMax":                             1,
+		"QueryNodeGPUPricePerHr":                        1,
+		"QueryGPUInfo":                                  1,
+		"QueryIsGPUShared":                              1,
+		"QueryDCGMDeviceInfo":                           1,
+		"QueryDCGMDeviceUptime":                         1,
+		"QueryDCGMContainerUsageAvg":                    1,
+		"QueryDCGMContainerUsageMax":                    1,
+		"QueryPodPVCAllocation":                         1,
+		"QueryPVCBytesRequested":                        1,
+		"QueryPVCInfo":                                  1,
+		"QueryKMPVCInfo":                                1,
+		"QueryPVCUptime":                                1,
+		"QueryPVCBytesUsedAverage":                      1,
+		"QueryPVCBytesUsedMax":                          1,
+		"QueryPVBytes":                                  1,
+		"QueryPVPricePerGiBHour":                        1,
+		"QueryPVInfo":                                   1,
+		"QueryPVActiveMinutes":                          1,
+		"QueryPVUsedAverage":                            1,
+		"QueryPVUsedMax":                                1,
+		"QueryKMPVInfo":                                 1,
+		"QueryPVUptime":                                 1,
+		"QueryDeploymentInfo":                           1,
+		"QueryDeploymentUptime":                         1,
+		"QueryDeploymentLabels":                         1,
+		"QueryDeploymentAnnotations":                    1,
+		"QueryDeploymentMatchLabels":                    1,
+		"QueryStatefulSetInfo":                          1,
+		"QueryStatefulSetUptime":                        1,
+		"QueryStatefulSetLabels":                        1,
+		"QueryStatefulSetAnnotations":                   1,
+		"QueryStatefulSetMatchLabels":                   1,
+		"QueryDaemonSetInfo":                            1,
+		"QueryDaemonSetUptime":                          1,
+		"QueryDaemonSetLabels":                          1,
+		"QueryDaemonSetAnnotations":                     1,
+		"QueryJobInfo":                                  1,
+		"QueryJobUptime":                                1,
+		"QueryJobLabels":                                1,
+		"QueryJobAnnotations":                           1,
+		"QueryCronJobInfo":                              1,
+		"QueryCronJobUptime":                            1,
+		"QueryCronJobLabels":                            1,
+		"QueryCronJobAnnotations":                       1,
+		"QueryReplicaSetInfo":                           1,
+		"QueryReplicaSetUptime":                         1,
+		"QueryReplicaSetLabels":                         1,
+		"QueryReplicaSetAnnotations":                    1,
+		"QueryReplicaSetOwners":                         1,
+		"QueryNamespaceInfo":                            1,
+		"QueryNamespaceUptime":                          1,
+		"QueryServiceInfo":                              1,
+		"QueryServiceUptime":                            1,
+		"QueryServiceSelectorLabels":                    1,
+		"QueryNetZoneGiB":                               1,
+		"QueryNetZonePricePerGiB":                       1,
+		"QueryNetRegionGiB":                             1,
+		"QueryNetRegionPricePerGiB":                     1,
+		"QueryNetInternetGiB":                           1,
+		"QueryNetInternetPricePerGiB":                   1,
+		"QueryNetInternetServiceGiB":                    1,
+		"QueryNetNatGatewayPricePerGiB":                 1,
+		"QueryNetNatGatewayGiB":                         1,
+		"QueryNetTransferBytes":                         1,
+		"QueryNetZoneIngressGiB":                        1,
+		"QueryNetRegionIngressGiB":                      1,
+		"QueryNetInternetIngressGiB":                    1,
+		"QueryNetInternetServiceIngressGiB":             1,
+		"QueryNetNatGatewayIngressPricePerGiB":          1,
+		"QueryNetNatGatewayIngressGiB":                  1,
+		"QueryNetReceiveBytes":                          1,
+		"QueryNamespaceAnnotations":                     1,
+		"QueryPodAnnotations":                           1,
+		"QueryNodeLabels":                               1,
+		"QueryNamespaceLabels":                          1,
+		"QueryPodLabels":                                1,
+		"QueryPodsWithDaemonSetOwner":                   1,
+		"QueryPodsWithJobOwner":                         1,
+		"QueryPodsWithReplicaSetOwner":                  1,
+		"QueryReplicaSetsWithoutOwners":                 1,
+		"QueryReplicaSetsWithRollout":                   1,
+		"QueryResourceQuotaInfo":                        1,
+		"QueryResourceQuotaUptime":                      1,
+		"QueryResourceQuotaSpecCPURequestAverage":       1,
+		"QueryResourceQuotaSpecCPURequestMax":           1,
+		"QueryResourceQuotaSpecRAMRequestAverage":       1,
+		"QueryResourceQuotaSpecRAMRequestMax":           1,
+		"QueryResourceQuotaSpecCPULimitAverage":         1,
+		"QueryResourceQuotaSpecCPULimitMax":             1,
+		"QueryResourceQuotaSpecRAMLimitAverage":         1,
+		"QueryResourceQuotaSpecRAMLimitMax":             1,
+		"QueryResourceQuotaStatusUsedCPURequestAverage": 1,
+		"QueryResourceQuotaStatusUsedCPURequestMax":     1,
+		"QueryResourceQuotaStatusUsedRAMRequestAverage": 1,
+		"QueryResourceQuotaStatusUsedRAMRequestMax":     1,
+		"QueryResourceQuotaStatusUsedCPULimitAverage":   1,
+		"QueryResourceQuotaStatusUsedCPULimitMax":       1,
+		"QueryResourceQuotaStatusUsedRAMLimitAverage":   1,
+		"QueryResourceQuotaStatusUsedRAMLimitMax":       1,
+		"QueryDataCoverage":                             1,
+	}
+
+	require.Equal(t, expected, r.Calls)
+}

+ 20 - 0
modules/collector-source/pkg/collector/collector.go

@@ -50,6 +50,7 @@ func NewOpenCostMetricStore() metric.MetricStore {
 	memStore.Register(NewLBPricePerHourMetricCollector())
 	memStore.Register(NewLBActiveMinutesMetricCollector())
 	memStore.Register(NewClusterInfoMetricCollector())
+	memStore.Register(NewClusterCompleteKubeModelMetricCollector())
 	memStore.Register(NewClusterUptimeMetricCollector())
 	memStore.Register(NewClusterManagementDurationMetricCollector())
 	memStore.Register(NewClusterManagementPricePerHourMetricCollector())
@@ -840,6 +841,25 @@ func NewClusterInfoMetricCollector() *metric.MetricCollector {
 	)
 }
 
+//	avg(
+//		cluster_info{
+//			<some_custom_filter>
+//		}
+//	) by (uid, complete_kubemodel)[%s:%dm]
+
+func NewClusterCompleteKubeModelMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.ClusterCompleteKubeModelID,
+		metric.ClusterInfo,
+		[]string{
+			source.UIDLabel,
+			source.KubeModelVersion,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
 //	avg(
 //		cluster_info{
 //			<some_custom_filter>

+ 4 - 0
modules/collector-source/pkg/collector/metricsquerier.go

@@ -223,6 +223,10 @@ func (c *collectorMetricsQuerier) QueryClusterInfo(start, end time.Time) *source
 	return queryCollector(c, start, end, metric.ClusterInfoID, source.DecodeClusterInfoResult)
 }
 
+func (c *collectorMetricsQuerier) QueryClusterKubeModelVersion(start, end time.Time) *source.Future[source.ClusterKubeModelVersionResult] {
+	return queryCollector(c, start, end, metric.ClusterCompleteKubeModelID, source.DecodeClusterKubeModelVersionResult)
+}
+
 func (c *collectorMetricsQuerier) QueryClusterUptime(start, end time.Time) *source.Future[source.UptimeResult] {
 	return queryCollector(c, start, end, metric.ClusterUptimeID, source.DecodeUptimeResult)
 }

+ 2 - 1
modules/collector-source/pkg/metric/collector.go

@@ -48,6 +48,7 @@ const (
 	LBPricePerHourID                           MetricCollectorID = "LBPricePerHour"
 	LBActiveMinutesID                          MetricCollectorID = "LBActiveMinutes"
 	ClusterInfoID                              MetricCollectorID = "ClusterInfo"
+	ClusterCompleteKubeModelID                 MetricCollectorID = "ClusterCompleteKubeModel"
 	ClusterUptimeID                            MetricCollectorID = "ClusterUptime"
 	ClusterManagementDurationID                MetricCollectorID = "ClusterManagementDuration"
 	ClusterManagementPricePerHourID            MetricCollectorID = "ClusterManagementPricePerHour"
@@ -64,7 +65,7 @@ const (
 	RAMLimitsID                                MetricCollectorID = "RAMLimits"
 	RAMUsageAverageID                          MetricCollectorID = "RAMUsageAverage"
 	RAMUsageMaxID                              MetricCollectorID = "RAMBytesUsageMax"
-	CPUCoresAllocatedID                        MetricCollectorID = "CPUCoresAllocated"
+	CPUCoresAllocatedID                        MetricCollectorID = "CPUCoreAllocationAvg"
 	CPURequestsID                              MetricCollectorID = "CPURequestsID"
 	CPULimitsID                                MetricCollectorID = "CPULimitsID"
 	CPUUsageAverageID                          MetricCollectorID = "CPUUsageAverage"

+ 4 - 0
modules/collector-source/pkg/scrape/clusterinfo.go

@@ -1,7 +1,10 @@
 package scrape
 
 import (
+	"fmt"
+
 	"github.com/opencost/opencost/core/pkg/clusters"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
 	"github.com/opencost/opencost/core/pkg/source"
 	"github.com/opencost/opencost/modules/collector-source/pkg/metric"
 )
@@ -43,6 +46,7 @@ func (cis *ClusterInfoScrapper) Scrape() []metric.Update {
 		source.AccountIDLabel:       accountID,
 		source.ProvisionerNameLabel: provisioner,
 		source.RegionLabel:          region,
+		source.KubeModelVersion:     fmt.Sprintf("%d", kubemodel.DefaultCodecVersion),
 	}
 
 	scrapeResults = append(scrapeResults, metric.Update{

+ 60 - 6
modules/collector-source/pkg/scrape/scrapecontroller.go

@@ -6,6 +6,7 @@ import (
 
 	"github.com/opencost/opencost/core/pkg/clustercache"
 	"github.com/opencost/opencost/core/pkg/clusters"
+	coreenv "github.com/opencost/opencost/core/pkg/env"
 	"github.com/opencost/opencost/core/pkg/log"
 	"github.com/opencost/opencost/core/pkg/nodestats"
 	"github.com/opencost/opencost/core/pkg/util/atomic"
@@ -21,6 +22,57 @@ type ScrapeController struct {
 	updater        metric.Updater
 }
 
+// getDefaultMetricFilter builds a MetricFilter from environment variable configuration.
+// Metrics whose corresponding env var is false (the default) are added to the deny set.
+func getDefaultMetricFilter() MetricFilter {
+	f := MetricFilter{}
+	deny := func(name string) { f[name] = struct{}{} }
+
+	if !coreenv.IsEmitPodAnnotationsMetric() {
+		deny(metric.KubePodAnnotations)
+	}
+	if !coreenv.IsEmitNamespaceAnnotationsMetric() {
+		deny(metric.KubeNamespaceAnnotations)
+	}
+	if !coreenv.IsEmitDeploymentLabelsMetric() {
+		deny(metric.DeploymentLabels)
+	}
+	if !coreenv.IsEmitDeploymentAnnotationsMetric() {
+		deny(metric.DeploymentAnnotations)
+	}
+	if !coreenv.IsEmitStatefulSetLabelsMetric() {
+		deny(metric.StatefulSetLabels)
+	}
+	if !coreenv.IsEmitStatefulSetAnnotationsMetric() {
+		deny(metric.StatefulSetAnnotations)
+	}
+	if !coreenv.IsEmitDaemonSetLabelsMetric() {
+		deny(metric.DaemonSetLabels)
+	}
+	if !coreenv.IsEmitDaemonSetAnnotationsMetric() {
+		deny(metric.DaemonSetAnnotations)
+	}
+	if !coreenv.IsEmitJobLabelsMetric() {
+		deny(metric.JobLabels)
+	}
+	if !coreenv.IsEmitJobAnnotationsMetric() {
+		deny(metric.JobAnnotations)
+	}
+	if !coreenv.IsEmitCronJobLabelsMetric() {
+		deny(metric.CronJobLabels)
+	}
+	if !coreenv.IsEmitCronJobAnnotationsMetric() {
+		deny(metric.CronJobAnnotations)
+	}
+	if !coreenv.IsEmitReplicaSetLabelsMetric() {
+		deny(metric.ReplicaSetLabels)
+	}
+	if !coreenv.IsEmitReplicaSetAnnotationsMetric() {
+		deny(metric.ReplicaSetAnnotations)
+	}
+	return f
+}
+
 func NewScrapeController(
 	clusterUID string,
 	scrapeInterval string,
@@ -30,24 +82,26 @@ func NewScrapeController(
 	clusterCache clustercache.ClusterCache,
 	statSummaryClient nodestats.StatSummaryClient,
 ) *ScrapeController {
+	// Start with env-driven defaults, then layer in any caller-supplied entries.
+	filter := getDefaultMetricFilter()
 
 	var scrapers []Scraper
-	clusterInfoScrapper := newClusterInfoScrapper(clusterUID, clusterInfoProvider)
+	clusterInfoScrapper := withFilter(newClusterInfoScrapper(clusterUID, clusterInfoProvider), filter)
 	scrapers = append(scrapers, clusterInfoScrapper)
 
-	clusterCacheScraper := newClusterCacheScraper(clusterCache)
+	clusterCacheScraper := withFilter(newClusterCacheScraper(clusterCache), filter)
 	scrapers = append(scrapers, clusterCacheScraper)
 
-	opencostScraper := newOpenCostScraper()
+	opencostScraper := withFilter(newOpenCostScraper(), filter)
 	scrapers = append(scrapers, opencostScraper)
 
-	statSummaryScraper := newStatSummaryScraper(statSummaryClient, clusterCache)
+	statSummaryScraper := withFilter(newStatSummaryScraper(statSummaryClient, clusterCache), filter)
 	scrapers = append(scrapers, statSummaryScraper)
 
-	networkScraper := newNetworkScraper(networkPort, clusterCache)
+	networkScraper := withFilter(newNetworkScraper(networkPort, clusterCache), filter)
 	scrapers = append(scrapers, networkScraper)
 
-	dcgmScraper := newDCGMScrapper(clusterCache)
+	dcgmScraper := withFilter(newDCGMScrapper(clusterCache), filter)
 	scrapers = append(scrapers, dcgmScraper)
 
 	si, err := util.NewInterval(scrapeInterval)

+ 30 - 0
modules/collector-source/pkg/scrape/scraper.go

@@ -9,6 +9,36 @@ type Scraper interface {
 	Scrape() []metric.Update
 }
 
+// MetricFilter is a set of metric names to suppress from scrape output.
+// An empty or nil filter allows all metrics through.
+type MetricFilter map[string]struct{}
+
+// filteredScraper wraps a Scraper and removes any Update whose Name appears in the filter.
+type filteredScraper struct {
+	inner  Scraper
+	filter MetricFilter
+}
+
+// withFilter returns s wrapped with f so that denied metric names are stripped from Scrape output.
+// If f is empty, s is returned unchanged.
+func withFilter(s Scraper, f MetricFilter) Scraper {
+	if len(f) == 0 {
+		return s
+	}
+	return &filteredScraper{inner: s, filter: f}
+}
+
+func (fs *filteredScraper) Scrape() []metric.Update {
+	results := fs.inner.Scrape()
+	out := results[:0]
+	for _, u := range results {
+		if _, denied := fs.filter[u.Name]; !denied {
+			out = append(out, u)
+		}
+	}
+	return out
+}
+
 type ScrapeFunc func() []metric.Update
 
 func concurrentScrape(scrapeFuncs ...ScrapeFunc) []metric.Update {

+ 226 - 0
modules/collector-source/pkg/scrape/scraper_test.go

@@ -0,0 +1,226 @@
+package scrape
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	coreenv "github.com/opencost/opencost/core/pkg/env"
+	"github.com/opencost/opencost/modules/collector-source/pkg/metric"
+)
+
+// staticScraper is a test double that returns a fixed set of updates.
+type staticScraper struct {
+	updates []metric.Update
+}
+
+func (s *staticScraper) Scrape() []metric.Update { return s.updates }
+
+func updates(names ...string) []metric.Update {
+	out := make([]metric.Update, len(names))
+	for i, n := range names {
+		out[i] = metric.Update{Name: n}
+	}
+	return out
+}
+
+func updateNames(us []metric.Update) []string {
+	names := make([]string, len(us))
+	for i, u := range us {
+		names[i] = u.Name
+	}
+	return names
+}
+
+func TestWithFilter_EmptyFilterReturnsOriginalScraper(t *testing.T) {
+	s := &staticScraper{}
+	wrapped := withFilter(s, MetricFilter{})
+	assert.Same(t, s, wrapped, "empty filter should return the original scraper unchanged")
+}
+
+func TestWithFilter_NilFilterReturnsOriginalScraper(t *testing.T) {
+	s := &staticScraper{}
+	wrapped := withFilter(s, nil)
+	assert.Same(t, s, wrapped, "nil filter should return the original scraper unchanged")
+}
+
+func TestWithFilter_NonEmptyFilterWraps(t *testing.T) {
+	s := &staticScraper{}
+	f := MetricFilter{"some_metric": {}}
+	wrapped := withFilter(s, f)
+	_, isFiltered := wrapped.(*filteredScraper)
+	assert.True(t, isFiltered, "non-empty filter should wrap in a filteredScraper")
+}
+
+func TestFilteredScraper_AllowsMetricsNotInFilter(t *testing.T) {
+	s := &staticScraper{updates: updates("cpu_usage", "memory_usage")}
+	f := MetricFilter{"kube_pod_annotations": {}}
+	wrapped := withFilter(s, f)
+
+	got := wrapped.Scrape()
+	assert.Equal(t, []string{"cpu_usage", "memory_usage"}, updateNames(got))
+}
+
+func TestFilteredScraper_BlocksDeniedMetrics(t *testing.T) {
+	s := &staticScraper{updates: updates("cpu_usage", metric.KubePodAnnotations, "memory_usage")}
+	f := MetricFilter{metric.KubePodAnnotations: {}}
+	wrapped := withFilter(s, f)
+
+	got := wrapped.Scrape()
+	assert.Equal(t, []string{"cpu_usage", "memory_usage"}, updateNames(got))
+}
+
+func TestFilteredScraper_BlocksMultipleDeniedMetrics(t *testing.T) {
+	s := &staticScraper{updates: updates(
+		metric.KubePodAnnotations,
+		"cpu_usage",
+		metric.KubeNamespaceAnnotations,
+		"memory_usage",
+	)}
+	f := MetricFilter{
+		metric.KubePodAnnotations:       {},
+		metric.KubeNamespaceAnnotations: {},
+	}
+	wrapped := withFilter(s, f)
+
+	got := wrapped.Scrape()
+	assert.Equal(t, []string{"cpu_usage", "memory_usage"}, updateNames(got))
+}
+
+func TestFilteredScraper_EmptyResultsPassThrough(t *testing.T) {
+	s := &staticScraper{updates: []metric.Update{}}
+	f := MetricFilter{metric.KubePodAnnotations: {}}
+	wrapped := withFilter(s, f)
+
+	got := wrapped.Scrape()
+	assert.Empty(t, got)
+}
+
+func TestFilteredScraper_AllMetricsDenied(t *testing.T) {
+	s := &staticScraper{updates: updates(metric.KubePodAnnotations, metric.KubeNamespaceAnnotations)}
+	f := MetricFilter{
+		metric.KubePodAnnotations:       {},
+		metric.KubeNamespaceAnnotations: {},
+	}
+	wrapped := withFilter(s, f)
+
+	got := wrapped.Scrape()
+	assert.Empty(t, got)
+}
+
+func TestGetDefaultMetricFilter_PodAnnotationsDisabled(t *testing.T) {
+	t.Setenv(coreenv.EmitPodAnnotationsMetricEnvVar, "false")
+	t.Setenv(coreenv.EmitNamespaceAnnotationsMetricEnvVar, "true")
+
+	f := getDefaultMetricFilter()
+
+	_, podDenied := f[metric.KubePodAnnotations]
+	_, nsDenied := f[metric.KubeNamespaceAnnotations]
+	assert.True(t, podDenied, "pod annotations should be denied when env var is false")
+	assert.False(t, nsDenied, "namespace annotations should be allowed when env var is true")
+}
+
+func TestGetDefaultMetricFilter_NamespaceAnnotationsDisabled(t *testing.T) {
+	t.Setenv(coreenv.EmitPodAnnotationsMetricEnvVar, "true")
+	t.Setenv(coreenv.EmitNamespaceAnnotationsMetricEnvVar, "false")
+
+	f := getDefaultMetricFilter()
+
+	_, podDenied := f[metric.KubePodAnnotations]
+	_, nsDenied := f[metric.KubeNamespaceAnnotations]
+	assert.False(t, podDenied, "pod annotations should be allowed when env var is true")
+	assert.True(t, nsDenied, "namespace annotations should be denied when env var is false")
+}
+
+func TestGetDefaultMetricFilter_BothDisabledByDefault(t *testing.T) {
+	t.Setenv(coreenv.EmitPodAnnotationsMetricEnvVar, "")
+	t.Setenv(coreenv.EmitNamespaceAnnotationsMetricEnvVar, "")
+
+	f := getDefaultMetricFilter()
+
+	_, podDenied := f[metric.KubePodAnnotations]
+	_, nsDenied := f[metric.KubeNamespaceAnnotations]
+	assert.True(t, podDenied, "pod annotations should be denied by default")
+	assert.True(t, nsDenied, "namespace annotations should be denied by default")
+}
+
+func TestGetDefaultMetricFilter_AllEnabled(t *testing.T) {
+	t.Setenv(coreenv.EmitPodAnnotationsMetricEnvVar, "true")
+	t.Setenv(coreenv.EmitNamespaceAnnotationsMetricEnvVar, "true")
+	t.Setenv(coreenv.EmitDeploymentLabelsMetricEnvVar, "true")
+	t.Setenv(coreenv.EmitDeploymentAnnotationsMetricEnvVar, "true")
+	t.Setenv(coreenv.EmitStatefulSetLabelsMetricEnvVar, "true")
+	t.Setenv(coreenv.EmitStatefulSetAnnotationsMetricEnvVar, "true")
+	t.Setenv(coreenv.EmitDaemonSetLabelsMetricEnvVar, "true")
+	t.Setenv(coreenv.EmitDaemonSetAnnotationsMetricEnvVar, "true")
+	t.Setenv(coreenv.EmitJobLabelsMetricEnvVar, "true")
+	t.Setenv(coreenv.EmitJobAnnotationsMetricEnvVar, "true")
+	t.Setenv(coreenv.EmitCronJobLabelsMetricEnvVar, "true")
+	t.Setenv(coreenv.EmitCronJobAnnotationsMetricEnvVar, "true")
+	t.Setenv(coreenv.EmitReplicaSetLabelsMetricEnvVar, "true")
+	t.Setenv(coreenv.EmitReplicaSetAnnotationsMetricEnvVar, "true")
+
+	f := getDefaultMetricFilter()
+
+	assert.Empty(t, f, "filter should be empty when all metrics are enabled")
+}
+
+func TestGetDefaultMetricFilter_AnnotationsDefaultFalse(t *testing.T) {
+	// Unset all env vars to exercise pure defaults.
+	for _, v := range []string{
+		coreenv.EmitPodAnnotationsMetricEnvVar,
+		coreenv.EmitNamespaceAnnotationsMetricEnvVar,
+		coreenv.EmitDeploymentAnnotationsMetricEnvVar,
+		coreenv.EmitStatefulSetAnnotationsMetricEnvVar,
+		coreenv.EmitDaemonSetAnnotationsMetricEnvVar,
+		coreenv.EmitJobAnnotationsMetricEnvVar,
+		coreenv.EmitCronJobAnnotationsMetricEnvVar,
+		coreenv.EmitReplicaSetAnnotationsMetricEnvVar,
+	} {
+		t.Setenv(v, "")
+	}
+
+	f := getDefaultMetricFilter()
+
+	for _, name := range []string{
+		metric.KubePodAnnotations,
+		metric.KubeNamespaceAnnotations,
+		metric.DeploymentAnnotations,
+		metric.StatefulSetAnnotations,
+		metric.DaemonSetAnnotations,
+		metric.JobAnnotations,
+		metric.CronJobAnnotations,
+		metric.ReplicaSetAnnotations,
+	} {
+		_, denied := f[name]
+		assert.True(t, denied, "%s should be denied by default", name)
+	}
+}
+
+func TestGetDefaultMetricFilter_LabelsDefaultTrue(t *testing.T) {
+	// Unset all label env vars to exercise pure defaults.
+	for _, v := range []string{
+		coreenv.EmitDeploymentLabelsMetricEnvVar,
+		coreenv.EmitStatefulSetLabelsMetricEnvVar,
+		coreenv.EmitDaemonSetLabelsMetricEnvVar,
+		coreenv.EmitJobLabelsMetricEnvVar,
+		coreenv.EmitCronJobLabelsMetricEnvVar,
+		coreenv.EmitReplicaSetLabelsMetricEnvVar,
+	} {
+		t.Setenv(v, "")
+	}
+
+	f := getDefaultMetricFilter()
+
+	for _, name := range []string{
+		metric.DeploymentLabels,
+		metric.StatefulSetLabels,
+		metric.DaemonSetLabels,
+		metric.JobLabels,
+		metric.CronJobLabels,
+		metric.ReplicaSetLabels,
+	} {
+		_, denied := f[name]
+		assert.False(t, denied, "%s should be allowed by default", name)
+	}
+}

+ 18 - 0
modules/prometheus-source/pkg/prom/metricsquerier.go

@@ -661,6 +661,24 @@ func (pds *PrometheusMetricsQuerier) QueryClusterInfo(start, end time.Time) *sou
 	return source.NewFuture(source.DecodeClusterInfoResult, ctx.QueryAtTime(queryClusterInfo, end))
 }
 
+func (pds *PrometheusMetricsQuerier) QueryClusterKubeModelVersion(start, end time.Time) *source.Future[source.ClusterKubeModelVersionResult] {
+	const queryName = "QueryClusterKubeModelVersion"
+	const queryFmtClusterCompleteKubeModel = `avg(avg_over_time(cluster_info{%s}[%s])) by (%s, uid, complete_kubemodel)`
+
+	cfg := pds.promConfig
+
+	durStr := timeutil.DurationString(end.Sub(start))
+	if durStr == "" {
+		panic(fmt.Sprintf("failed to parse duration string passed to %s", queryName))
+	}
+
+	queryClusterCompleteKubeModel := fmt.Sprintf(queryFmtClusterCompleteKubeModel, cfg.ClusterFilter, durStr, cfg.ClusterLabel)
+	log.Debugf(PrometheusMetricsQueryLogFormat, queryName, end.Unix(), queryClusterCompleteKubeModel)
+
+	ctx := pds.promContexts.NewNamedContext(ClusterContextName)
+	return source.NewFuture(source.DecodeClusterKubeModelVersionResult, ctx.QueryAtTime(queryClusterCompleteKubeModel, end))
+}
+
 func (pds *PrometheusMetricsQuerier) QueryClusterUptime(start, end time.Time) *source.Future[source.UptimeResult] {
 	const queryName = "QueryClusterUptime"
 	const queryFmtClusterUptime = `avg(cluster_info{%s}) by (%s, uid)[%s:%dm]`

+ 8 - 4
pkg/cloud/aws/provider.go

@@ -17,6 +17,7 @@ import (
 	"time"
 
 	"github.com/aws/smithy-go"
+	"github.com/opencost/opencost/pkg/cloud/httputil"
 	"github.com/opencost/opencost/pkg/cloud/models"
 	"github.com/opencost/opencost/pkg/cloud/utils"
 
@@ -771,7 +772,10 @@ func (aws *AWS) getRegionPricing(nodeList []*clustercache.Node) (*http.Response,
 	}
 
 	log.Infof("starting download of \"%s\", which is quite large ...", pricingURL)
-	resp, err := http.Get(pricingURL)
+	// This file is large and can take a while to stream, so the streaming client
+	// bounds connect/TLS/response-header time but not the total body read - enough
+	// to bail on a hung endpoint without truncating a legitimate slow download.
+	resp, err := httputil.StreamingGet(context.Background(), pricingURL)
 	if err != nil {
 		log.Errorf("Bogus fetch of \"%s\": %v", pricingURL, err)
 		return nil, pricingURL, err
@@ -1265,7 +1269,7 @@ func (aws *AWS) spotPricingFromHistory(k models.Key) (*SpotPriceHistoryEntry, bo
 
 	price, err := aws.SpotPriceHistoryCache.GetSpotPrice(region, instanceType, availabilityZone)
 	if err != nil {
-		log.DedupedWarningf(10, "Failed to get spot price history for instance %s: %s", k.ID(), err.Error())
+		log.Debugf("Failed to get spot price history for instance %s: %s", k.ID(), err.Error())
 		return nil, false
 	}
 	return price, true
@@ -1396,7 +1400,7 @@ func (aws *AWS) createNode(terms *AWSProductTerms, usageType string, k models.Ke
 			UsageType:    PreemptibleType,
 		}, meta, nil
 	} else if aws.isPreemptible(key) { // Preemptible but we don't have any data in the pricing report.
-		log.DedupedWarningf(5, "Node %s marked preemptible but no spot feed data available; falling back to other pricing sources", k.ID())
+		log.Debugf("Node %s marked preemptible but no spot feed data available; falling back to other pricing sources", k.ID())
 
 		// Try to get spot pricing from DescribeSpotPriceHistory API
 		if historyEntry, ok := aws.spotPricingFromHistory(k); ok {
@@ -1418,7 +1422,7 @@ func (aws *AWS) createNode(terms *AWSProductTerms, usageType string, k models.Ke
 
 		if publicPricingFound {
 			// return public price if found
-			log.DedupedWarningf(5, "No spot price history available for %s, falling back to on-demand pricing", k.ID())
+			log.Debugf("No spot price history available for %s, falling back to on-demand pricing", k.ID())
 			return &models.Node{
 				Cost:         cost,
 				VCPU:         terms.VCpu,

+ 5 - 1
pkg/cloud/azure/pricesheetdownloader.go

@@ -18,6 +18,7 @@ import (
 	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
 
 	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/pkg/cloud/httputil"
 )
 
 type PriceSheetDownloader struct {
@@ -79,7 +80,10 @@ func (d PriceSheetDownloader) saveData(ctx context.Context, url, tempName string
 		return nil, fmt.Errorf("creating %s temp file: %w", tempName, err)
 	}
 
-	resp, err := http.Get(url)
+	// The price sheet can be large, so the streaming client bounds connect/TLS/
+	// response-header time but not the body read, avoiding truncation of a slow
+	// download. Pass the caller's context so the download is cancelable.
+	resp, err := httputil.StreamingGet(ctx, url)
 	if err != nil {
 		return nil, fmt.Errorf("downloading: %w", err)
 	}

+ 79 - 27
pkg/cloud/azure/provider.go

@@ -29,6 +29,7 @@ import (
 	"github.com/opencost/opencost/core/pkg/util/fileutil"
 	"github.com/opencost/opencost/core/pkg/util/json"
 	"github.com/opencost/opencost/core/pkg/util/timeutil"
+	"github.com/opencost/opencost/pkg/cloud/httputil"
 	"github.com/opencost/opencost/pkg/cloud/models"
 	"github.com/opencost/opencost/pkg/cloud/utils"
 	"github.com/opencost/opencost/pkg/env"
@@ -273,60 +274,92 @@ func buildAzureRetailPricesURL(region string, skuName string, currencyCode strin
 	return pricingURL
 }
 
-func extractAzureVMRetailAndSpotPrices(resp *http.Response) (retailPrice string, spotPrice string, err error) {
+func extractAzureVMRetailAndSpotPrices(resp *http.Response) (linuxRetailPrice string, windowsRetailPrice string, spotPrice string, windowsSpotPrice string, err error) {
 	body, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return "", "", fmt.Errorf("Error getting response: %v", err)
+		return "", "", "", "", fmt.Errorf("error getting response: %w", err)
 	}
 
 	pricingPayload := AzureRetailPricing{}
 	jsonErr := json.Unmarshal(body, &pricingPayload)
 	if jsonErr != nil {
-		return "", "", fmt.Errorf("error unmarshalling data: %v", jsonErr)
+		return "", "", "", "", fmt.Errorf("error unmarshalling data: %w", jsonErr)
 	}
 	for _, item := range pricingPayload.Items {
-		// note: Windows OS ondemand price will be equal to Linux, Adoption of Windows based
-		// computes are increasing in Azure we might want to enhance this in future.
-		if !strings.Contains(item.ProductName, "Windows") {
-			if strings.Contains(strings.ToLower(item.SkuName), " spot") {
+		skuLower := strings.ToLower(item.SkuName)
+		productLower := strings.ToLower(item.ProductName)
+		isWindowsProduct := strings.Contains(productLower, "windows")
+		if strings.Contains(skuLower, " spot") {
+			if isWindowsProduct {
+				windowsSpotPrice = fmt.Sprintf("%f", item.RetailPrice)
+			} else {
 				spotPrice = fmt.Sprintf("%f", item.RetailPrice)
-			} else if !(strings.Contains(strings.ToLower(item.SkuName), "low priority") || strings.Contains(strings.ToLower(item.ProductName), "cloud services") || strings.Contains(strings.ToLower(item.ProductName), "cloudservices")) {
-				retailPrice = fmt.Sprintf("%f", item.RetailPrice)
+			}
+		} else if !(strings.Contains(skuLower, "low priority") || strings.Contains(productLower, "cloud services") || strings.Contains(productLower, "cloudservices")) {
+			if isWindowsProduct {
+				windowsRetailPrice = fmt.Sprintf("%f", item.RetailPrice)
+			} else {
+				linuxRetailPrice = fmt.Sprintf("%f", item.RetailPrice)
 			}
 		}
 	}
-	return retailPrice, spotPrice, nil
+	return linuxRetailPrice, windowsRetailPrice, spotPrice, windowsSpotPrice, nil
 }
 
-func getRetailPrice(region string, skuName string, currencyCode string, spot bool) (string, error) {
+func getRetailPrice(region string, skuName string, currencyCode string, spot bool, isWindows bool) (string, error) {
 	pricingURL := buildAzureRetailPricesURL(region, skuName, currencyCode)
 	log.Infof("starting download retail price payload from \"%s\"", pricingURL)
 
-	resp, err := http.Get(pricingURL)
+	// Single SKU lookup returns a small payload, so the shared bounded client
+	// keeps a hung endpoint from blocking pricing without risking truncation.
+	client := httputil.BoundedClient()
+	resp, err := client.Get(pricingURL)
 	if err != nil {
-		return "", fmt.Errorf("failed to fetch retail price with URL \"%s\": %v", pricingURL, err)
+		return "", fmt.Errorf("failed to fetch retail price with URL \"%s\": %w", pricingURL, err)
 	}
 
 	if resp.StatusCode < 200 && resp.StatusCode > 299 {
 		return "", fmt.Errorf("retail price responded with error status code %d", resp.StatusCode)
 	}
 
-	retailPrice, spotPrice, err := extractAzureVMRetailAndSpotPrices(resp)
+	linuxRetailPrice, windowsRetailPrice, spotPrice, windowsSpotPrice, err := extractAzureVMRetailAndSpotPrices(resp)
 	if err != nil {
-		return "", fmt.Errorf("failed to extract azure prices: %v", err)
+		return "", fmt.Errorf("failed to extract azure prices: %w", err)
 	}
 
 	log.DedupedInfof(5, "done parsing retail price payload from \"%s\"\n", pricingURL)
 
-	if spot && spotPrice != "" {
-		return spotPrice, nil
+	return selectRetailPrice(region, skuName, linuxRetailPrice, windowsRetailPrice, spotPrice, windowsSpotPrice, spot, isWindows)
+}
+
+// selectRetailPrice picks the price matching the node OS and pricing model.
+// Windows nodes prefer the Windows-specific price; when it is absent the Linux
+// price is used as a best-effort estimate and the fallback is logged so the
+// substitution is not silent.
+func selectRetailPrice(region, skuName, linuxRetailPrice, windowsRetailPrice, spotPrice, windowsSpotPrice string, spot, isWindows bool) (string, error) {
+	if spot {
+		if isWindows && windowsSpotPrice != "" {
+			return windowsSpotPrice, nil
+		}
+		if spotPrice != "" {
+			if isWindows {
+				log.Warnf("no Windows spot price for %q in %q region; falling back to Linux spot price", skuName, region)
+			}
+			return spotPrice, nil
+		}
 	}
 
-	if retailPrice == "" {
-		return retailPrice, fmt.Errorf("Couldn't find price for product \"%s\" in \"%s\" region", skuName, region)
+	selectedRetail := linuxRetailPrice
+	if isWindows && windowsRetailPrice != "" {
+		selectedRetail = windowsRetailPrice
+	} else if isWindows && linuxRetailPrice != "" {
+		log.Warnf("no Windows retail price for %q in %q region; falling back to Linux retail price", skuName, region)
+	}
+	if selectedRetail == "" {
+		return "", fmt.Errorf("couldn't find price for product %q in %q region", skuName, region)
 	}
 
-	return retailPrice, nil
+	return selectedRetail, nil
 }
 
 func toRegionID(meterRegion string, regions map[string]string) (string, error) {
@@ -440,6 +473,17 @@ func (az *Azure) PricingSourceSummary() interface{} {
 	return az.Pricing
 }
 
+// azureWindowsOS is the node OS label value that identifies a Windows node and
+// the suffix used to qualify Windows-specific pricing keys.
+const azureWindowsOS = "windows"
+
+// isWindowsNode reports whether the node labels identify a Windows node. It
+// centralizes the OS detection shared by azureKey.Features and NodePricing.
+func isWindowsNode(labels map[string]string) bool {
+	osLabel, ok := util.GetOperatingSystem(labels)
+	return ok && strings.ToLower(osLabel) == azureWindowsOS
+}
+
 type azureKey struct {
 	Labels        map[string]string
 	GPULabel      string
@@ -451,6 +495,9 @@ func (k *azureKey) Features() string {
 	region := strings.ToLower(r)
 	instance, _ := util.GetInstanceType(k.Labels)
 	usageType := "ondemand"
+	if isWindowsNode(k.Labels) {
+		return fmt.Sprintf("%s,%s,%s,%s", region, instance, usageType, azureWindowsOS)
+	}
 	return fmt.Sprintf("%s,%s,%s", region, instance, usageType)
 }
 
@@ -992,10 +1039,7 @@ func convertMeterToPricings(info commerce.MeterInfo, regions map[string]string,
 		return nil, nil
 	}
 
-	if strings.Contains(meterSubCategory, "Windows") {
-		// This meter doesn't correspond to any pricings.
-		return nil, nil
-	}
+	isWindowsMeter := strings.Contains(meterSubCategory, "Windows")
 
 	if strings.Contains(meterSubCategory, "Cloud Services") || strings.Contains(meterSubCategory, "CloudServices") {
 		// This meter doesn't correspond to any pricings.
@@ -1079,8 +1123,10 @@ func convertMeterToPricings(info commerce.MeterInfo, regions map[string]string,
 	priceStr := fmt.Sprintf("%f", priceInUsd)
 	results := make(map[string]*AzurePricing)
 	for _, instanceType := range instanceTypes {
-
 		key := fmt.Sprintf("%s,%s,%s", region, instanceType, usageType)
+		if isWindowsMeter {
+			key = fmt.Sprintf("%s,%s,%s,%s", region, instanceType, usageType, azureWindowsOS)
+		}
 		pricing := &AzurePricing{
 			Node: &models.Node{
 				Cost:         priceStr,
@@ -1165,12 +1211,18 @@ func (az *Azure) NodePricing(key models.Key) (*models.Node, models.PricingMetada
 	slv, ok := azKey.Labels[config.SpotLabel]
 	isSpot := ok && slv == config.SpotLabelValue && config.SpotLabel != "" && config.SpotLabelValue != ""
 
+	isWindows := isWindowsNode(azKey.Labels)
+
 	features := strings.Split(azKey.Features(), ",")
 	region := features[0]
 	instance := features[1]
 	var featureString string
 	if isSpot {
-		featureString = fmt.Sprintf("%s,%s,spot", region, instance)
+		if isWindows {
+			featureString = fmt.Sprintf("%s,%s,spot,%s", region, instance, azureWindowsOS)
+		} else {
+			featureString = fmt.Sprintf("%s,%s,spot", region, instance)
+		}
 	} else {
 		featureString = azKey.Features()
 	}
@@ -1187,7 +1239,7 @@ func (az *Azure) NodePricing(key models.Key) (*models.Node, models.PricingMetada
 		}
 	}
 
-	cost, err := getRetailPrice(region, instance, config.CurrencyCode, isSpot)
+	cost, err := getRetailPrice(region, instance, config.CurrencyCode, isSpot, isWindows)
 
 	if err != nil {
 		log.DedupedWarningf(5, "failed to retrieve retail pricing: %s", err)

+ 209 - 13
pkg/cloud/azure/provider_test.go

@@ -69,7 +69,13 @@ func TestConvertMeterToPricings(t *testing.T) {
 		info := meterInfo("Virtual Machines", "D2 Series Windows", "D2s v3", "AU Southeast", 0.3)
 		results, err := convertMeterToPricings(info, regions, baseCPUPrice)
 		require.NoError(t, err)
-		require.Nil(t, results)
+		key := "australiasoutheast,Standard_D2s_v3,ondemand,windows"
+		pricing, ok := results[key]
+		require.Truef(t, ok, "expected a pricing entry under key %q", key)
+		require.NotNil(t, pricing.Node)
+		require.Equal(t, "ondemand", pricing.Node.UsageType)
+		require.Equal(t, "0.300000", pricing.Node.Cost)
+		require.Equal(t, baseCPUPrice, pricing.Node.BaseCPUPrice)
 	})
 
 	t.Run("storage", func(t *testing.T) {
@@ -102,6 +108,86 @@ func TestConvertMeterToPricings(t *testing.T) {
 	})
 }
 
+func TestSelectRetailPrice(t *testing.T) {
+	cases := []struct {
+		name               string
+		linuxRetailPrice   string
+		windowsRetailPrice string
+		spotPrice          string
+		windowsSpotPrice   string
+		spot               bool
+		isWindows          bool
+		expected           string
+		expectErr          bool
+	}{
+		{
+			name:               "windows retail prefers windows price",
+			linuxRetailPrice:   "1.000000",
+			windowsRetailPrice: "2.000000",
+			isWindows:          true,
+			expected:           "2.000000",
+		},
+		{
+			name:             "windows retail falls back to linux when windows missing",
+			linuxRetailPrice: "1.000000",
+			isWindows:        true,
+			expected:         "1.000000",
+		},
+		{
+			name:             "linux retail uses linux price",
+			linuxRetailPrice: "1.000000",
+			isWindows:        false,
+			expected:         "1.000000",
+		},
+		{
+			name:             "windows spot prefers windows spot price",
+			spotPrice:        "0.500000",
+			windowsSpotPrice: "0.900000",
+			spot:             true,
+			isWindows:        true,
+			expected:         "0.900000",
+		},
+		{
+			name:      "windows spot falls back to linux spot when windows missing",
+			spotPrice: "0.500000",
+			spot:      true,
+			isWindows: true,
+			expected:  "0.500000",
+		},
+		{
+			name:      "linux spot uses linux spot price",
+			spotPrice: "0.500000",
+			spot:      true,
+			isWindows: false,
+			expected:  "0.500000",
+		},
+		{
+			name:               "spot windows with no spot price falls back to retail",
+			windowsRetailPrice: "2.000000",
+			spot:               true,
+			isWindows:          true,
+			expected:           "2.000000",
+		},
+		{
+			name:      "no price available returns error",
+			isWindows: true,
+			expectErr: true,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got, err := selectRetailPrice("eastus", "Standard_D2s_v3", tc.linuxRetailPrice, tc.windowsRetailPrice, tc.spotPrice, tc.windowsSpotPrice, tc.spot, tc.isWindows)
+			if tc.expectErr {
+				require.Error(t, err)
+				return
+			}
+			require.NoError(t, err)
+			require.Equal(t, tc.expected, got)
+		})
+	}
+}
+
 func TestAzure_findCostForDisk(t *testing.T) {
 	var loc string = "location"
 	var size int32 = 1
@@ -390,14 +476,76 @@ func Test_buildAzureRetailPricesURL(t *testing.T) {
 	}
 }
 
+func TestAzureKeyFeaturesOS(t *testing.T) {
+	tests := []struct {
+		name     string
+		labels   map[string]string
+		expected string
+	}{
+		{
+			name: "windows node via kubernetes.io/os",
+			labels: map[string]string{
+				"kubernetes.io/os":                 "windows",
+				"node.kubernetes.io/instance-type": "Standard_D4s_v3",
+				"topology.kubernetes.io/region":    "eastus",
+			},
+			expected: "eastus,Standard_D4s_v3,ondemand,windows",
+		},
+		{
+			name: "windows node via beta.kubernetes.io/os",
+			labels: map[string]string{
+				"beta.kubernetes.io/os":            "windows",
+				"node.kubernetes.io/instance-type": "Standard_D4s_v3",
+				"topology.kubernetes.io/region":    "eastus",
+			},
+			expected: "eastus,Standard_D4s_v3,ondemand,windows",
+		},
+		{
+			name: "linux node",
+			labels: map[string]string{
+				"kubernetes.io/os":                 "linux",
+				"node.kubernetes.io/instance-type": "Standard_D4s_v3",
+				"topology.kubernetes.io/region":    "eastus",
+			},
+			expected: "eastus,Standard_D4s_v3,ondemand",
+		},
+		{
+			name: "no OS label defaults to linux key",
+			labels: map[string]string{
+				"node.kubernetes.io/instance-type": "Standard_D4s_v3",
+				"topology.kubernetes.io/region":    "eastus",
+			},
+			expected: "eastus,Standard_D4s_v3,ondemand",
+		},
+		{
+			name: "windows case-insensitive",
+			labels: map[string]string{
+				"kubernetes.io/os":                 "Windows",
+				"node.kubernetes.io/instance-type": "Standard_D4s_v3",
+				"topology.kubernetes.io/region":    "eastus",
+			},
+			expected: "eastus,Standard_D4s_v3,ondemand,windows",
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			key := &azureKey{Labels: tc.labels}
+			require.Equal(t, tc.expected, key.Features())
+		})
+	}
+}
+
 func Test_extractAzureVMRetailAndSpotPrices(t *testing.T) {
 	testCases := []struct {
-		name             string
-		jsonResponse     string
-		expectedRetail   string
-		expectedSpot     string
-		expectedError    bool
-		expectedErrorMsg string
+		name                  string
+		jsonResponse          string
+		expectedRetail        string
+		expectedWindowsRetail string
+		expectedSpot          string
+		expectedWindowsSpot   string
+		expectedError         bool
+		expectedErrorMsg      string
 	}{
 		{
 			name: "valid response with retail and spot prices",
@@ -503,7 +651,7 @@ func Test_extractAzureVMRetailAndSpotPrices(t *testing.T) {
 			expectedError:  false,
 		},
 		{
-			name: "filters out Windows instances",
+			name: "returns separate Windows and Linux prices",
 			jsonResponse: `{
 				"BillingCurrency": "USD",
 				"CustomerEntityId": "Default",
@@ -528,9 +676,35 @@ func Test_extractAzureVMRetailAndSpotPrices(t *testing.T) {
 				],
 				"Count": 2
 			}`,
-			expectedRetail: "0.192000",
-			expectedSpot:   "",
-			expectedError:  false,
+			expectedRetail:        "0.192000",
+			expectedWindowsRetail: "0.500000",
+			expectedSpot:          "",
+			expectedWindowsSpot:   "",
+			expectedError:         false,
+		},
+		{
+			name: "windows spot price available",
+			jsonResponse: `{
+				"BillingCurrency": "USD",
+				"CustomerEntityId": "Default",
+				"CustomerEntityType": "Retail",
+				"Items": [
+					{
+						"currencyCode": "USD",
+						"retailPrice": 0.12,
+						"armRegionName": "eastus",
+						"productName": "Virtual Machines Dsv3 Series Windows",
+						"skuName": "D4s v3 Spot",
+						"armSkuName": "Standard_D4s_v3"
+					}
+				],
+				"Count": 1
+			}`,
+			expectedRetail:        "",
+			expectedWindowsRetail: "",
+			expectedSpot:          "",
+			expectedWindowsSpot:   "0.120000",
+			expectedError:         false,
 		},
 		{
 			name: "filters out low priority instances",
@@ -600,7 +774,7 @@ func Test_extractAzureVMRetailAndSpotPrices(t *testing.T) {
 				Body:       io.NopCloser(bytes.NewBufferString(tc.jsonResponse)),
 			}
 
-			retailPrice, spotPrice, err := extractAzureVMRetailAndSpotPrices(resp)
+			linuxRetail, windowsRetail, spotPrice, windowsSpotPrice, err := extractAzureVMRetailAndSpotPrices(resp)
 
 			if tc.expectedError {
 				require.Error(t, err)
@@ -609,9 +783,31 @@ func Test_extractAzureVMRetailAndSpotPrices(t *testing.T) {
 				}
 			} else {
 				require.NoError(t, err)
-				require.Equal(t, tc.expectedRetail, retailPrice, "Retail price mismatch")
+				require.Equal(t, tc.expectedRetail, linuxRetail, "Linux retail price mismatch")
+				require.Equal(t, tc.expectedWindowsRetail, windowsRetail, "Windows retail price mismatch")
 				require.Equal(t, tc.expectedSpot, spotPrice, "Spot price mismatch")
+				require.Equal(t, tc.expectedWindowsSpot, windowsSpotPrice, "Windows spot price mismatch")
 			}
 		})
 	}
 }
+
+// failingReader is an io.Reader that always errors, used to exercise the
+// response body read-failure path in extractAzureVMRetailAndSpotPrices.
+type failingReader struct{}
+
+func (failingReader) Read(_ []byte) (int, error) {
+	return 0, fmt.Errorf("simulated read failure")
+}
+
+func Test_extractAzureVMRetailAndSpotPrices_bodyReadError(t *testing.T) {
+	resp := &http.Response{
+		StatusCode: 200,
+		Body:       io.NopCloser(failingReader{}),
+	}
+
+	_, _, _, _, err := extractAzureVMRetailAndSpotPrices(resp)
+
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "error getting response")
+}

+ 6 - 1
pkg/cloud/gcp/provider.go

@@ -17,6 +17,7 @@ import (
 
 	coreenv "github.com/opencost/opencost/core/pkg/env"
 	"github.com/opencost/opencost/pkg/cloud/aws"
+	"github.com/opencost/opencost/pkg/cloud/httputil"
 	"github.com/opencost/opencost/pkg/cloud/models"
 	"github.com/opencost/opencost/pkg/cloud/utils"
 
@@ -979,7 +980,9 @@ func (gcp *GCP) getBillingAPIClientAndURL(apiKey, currencyCode string) (*http.Cl
 	url := gcp.buildBillingAPIURL(apiKey, currencyCode)
 
 	if apiKey != "" {
-		return http.DefaultClient, url.String(), nil
+		// Shared client carries a request timeout so a hung billing endpoint
+		// can't block the pricing refresh.
+		return httputil.BoundedClient(), url.String(), nil
 	}
 
 	googleHttpClient, err := google.DefaultClient(context.TODO(), GCPCloudOAuthScope)
@@ -987,6 +990,8 @@ func (gcp *GCP) getBillingAPIClientAndURL(apiKey, currencyCode string) (*http.Cl
 		log.Errorf("GCP Billing API: Workload Identity detected but failed to create authenticated client: %v", err)
 		return nil, "", err
 	}
+	// google.DefaultClient has no timeout by default; bound it to match the keyed path.
+	googleHttpClient.Timeout = httputil.PricingTimeout
 
 	return googleHttpClient, url.String(), nil
 }

+ 3 - 2
pkg/cloud/gcp/provider_test.go

@@ -4,7 +4,6 @@ import (
 	"bytes"
 	"encoding/json"
 	"fmt"
-	"net/http"
 	"net/url"
 	"os"
 	"reflect"
@@ -14,6 +13,7 @@ import (
 
 	"github.com/google/martian/log"
 	"github.com/opencost/opencost/core/pkg/clustercache"
+	"github.com/opencost/opencost/pkg/cloud/httputil"
 	"github.com/opencost/opencost/pkg/cloud/models"
 	"github.com/opencost/opencost/pkg/config"
 	"github.com/stretchr/testify/assert"
@@ -758,7 +758,8 @@ func TestGCP_getBillingAPIClientAndURL(t *testing.T) {
 	client, rawURL, err := gcp.getBillingAPIClientAndURL("test-key", "USD")
 
 	assert.NoError(t, err)
-	assert.Equal(t, http.DefaultClient, client)
+	assert.NotNil(t, client)
+	assert.Equal(t, httputil.PricingTimeout, client.Timeout)
 
 	parsedURL, err := url.Parse(rawURL)
 	assert.NoError(t, err)

+ 78 - 0
pkg/cloud/httputil/httputil.go

@@ -0,0 +1,78 @@
+// Package httputil provides shared HTTP clients for cloud pricing ingestion.
+//
+// The default net/http client has no timeout, so a hung or unreachable provider
+// pricing endpoint can block pricing refresh indefinitely. These helpers return
+// clients with sensible timeouts. They are shared and reused so a new connection
+// pool is not created on every pricing fetch.
+package httputil
+
+import (
+	"context"
+	"net"
+	"net/http"
+	"time"
+)
+
+// PricingTimeout is applied to pricing HTTP requests. For the bounded client it
+// caps the whole request; for the streaming client it caps the wait for
+// response headers only (not the body read).
+const PricingTimeout = 30 * time.Second
+
+var (
+	boundedClient   = &http.Client{Timeout: PricingTimeout}
+	streamingClient = newStreamingClient(http.DefaultTransport)
+)
+
+// BoundedClient returns a shared http.Client with a total request timeout,
+// suitable for small or bounded pricing API responses.
+func BoundedClient() *http.Client {
+	return boundedClient
+}
+
+// StreamingClient returns a shared http.Client for large pricing downloads (for
+// example the AWS pricing file or the Azure price sheet). It bounds the connect,
+// TLS handshake, and response-header wait, but not the total body read, so a
+// legitimately large or slow download is not truncated while a hung endpoint is
+// still abandoned.
+func StreamingClient() *http.Client {
+	return streamingClient
+}
+
+// StreamingGet issues a GET for a large download using the streaming client and
+// the caller's context, so the request is cancelable. It centralizes the
+// context-aware request construction shared by the large-download paths (the AWS
+// pricing file and the Azure price sheet). Callers without a context of their
+// own can pass context.Background().
+func StreamingGet(ctx context.Context, url string) (*http.Response, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+	if err != nil {
+		return nil, err
+	}
+	return StreamingClient().Do(req)
+}
+
+// newStreamingClient builds the streaming client from a base RoundTripper. It
+// takes the base transport as a parameter so the fallback path can be exercised
+// by tests; production passes http.DefaultTransport.
+func newStreamingClient(base http.RoundTripper) *http.Client {
+	// Clone the base transport when possible so we keep its dial and TLS
+	// handshake timeouts. Guard the type assertion: http.DefaultTransport is
+	// declared as a RoundTripper and can be replaced (e.g. in tests), in which
+	// case we fall back to a fresh transport rather than panicking.
+	transport, ok := base.(*http.Transport)
+	if ok {
+		transport = transport.Clone()
+	} else {
+		// base is not a *http.Transport, so we can't clone its timeouts. Build a
+		// fresh transport that still bounds dial and TLS handshake time, matching
+		// the guarantee in StreamingClient's doc.
+		transport = &http.Transport{
+			Proxy:                 http.ProxyFromEnvironment,
+			DialContext:           (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
+			TLSHandshakeTimeout:   10 * time.Second,
+			ExpectContinueTimeout: 1 * time.Second,
+		}
+	}
+	transport.ResponseHeaderTimeout = PricingTimeout
+	return &http.Client{Transport: transport}
+}

+ 130 - 0
pkg/cloud/httputil/httputil_test.go

@@ -0,0 +1,130 @@
+package httputil
+
+import (
+	"context"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+func TestBoundedClientHasTotalTimeout(t *testing.T) {
+	c := BoundedClient()
+	if c.Timeout != PricingTimeout {
+		t.Fatalf("expected total timeout %v, got %v", PricingTimeout, c.Timeout)
+	}
+}
+
+func TestBoundedClientIsShared(t *testing.T) {
+	if BoundedClient() != BoundedClient() {
+		t.Fatal("expected BoundedClient to return a shared instance")
+	}
+}
+
+func TestStreamingClientHasNoTotalTimeout(t *testing.T) {
+	c := StreamingClient()
+	if c.Timeout != 0 {
+		t.Fatalf("streaming client must not set a total timeout, got %v", c.Timeout)
+	}
+}
+
+func TestStreamingClientHasResponseHeaderTimeout(t *testing.T) {
+	c := StreamingClient()
+	tr, ok := c.Transport.(*http.Transport)
+	if !ok {
+		t.Fatalf("expected *http.Transport, got %T", c.Transport)
+	}
+	if tr.ResponseHeaderTimeout != PricingTimeout {
+		t.Fatalf("expected response-header timeout %v, got %v", PricingTimeout, tr.ResponseHeaderTimeout)
+	}
+}
+
+func TestStreamingClientIsShared(t *testing.T) {
+	if StreamingClient() != StreamingClient() {
+		t.Fatal("expected StreamingClient to return a shared instance")
+	}
+}
+
+type roundTripperFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
+	return f(r)
+}
+
+// A base transport that is not a *http.Transport must not panic and must still
+// yield a usable client with the response-header timeout applied.
+func TestNewStreamingClientFallsBackWhenNotTransport(t *testing.T) {
+	// Honor the RoundTripper contract (non-nil response when error is nil), even
+	// though this base is only used to exercise the fallback and never round-trips.
+	base := roundTripperFunc(func(*http.Request) (*http.Response, error) {
+		return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
+	})
+	c := newStreamingClient(base)
+	tr, ok := c.Transport.(*http.Transport)
+	if !ok {
+		t.Fatalf("expected fallback *http.Transport, got %T", c.Transport)
+	}
+	if tr.ResponseHeaderTimeout != PricingTimeout {
+		t.Fatalf("expected response-header timeout %v, got %v", PricingTimeout, tr.ResponseHeaderTimeout)
+	}
+	// The fallback transport must still bound TLS handshake time.
+	if tr.TLSHandshakeTimeout == 0 {
+		t.Fatal("expected fallback transport to set a TLS handshake timeout")
+	}
+}
+
+func TestStreamingGetReturnsBody(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		_, _ = w.Write([]byte("price-data"))
+	}))
+	defer srv.Close()
+
+	resp, err := StreamingGet(context.Background(), srv.URL)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	defer resp.Body.Close()
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		t.Fatalf("reading body: %v", err)
+	}
+	if string(body) != "price-data" {
+		t.Fatalf("expected body %q, got %q", "price-data", string(body))
+	}
+}
+
+func TestStreamingGetHonorsCanceledContext(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		_, _ = w.Write([]byte("ok"))
+	}))
+	defer srv.Close()
+
+	ctx, cancel := context.WithCancel(context.Background())
+	cancel() // cancel before the request runs
+
+	if _, err := StreamingGet(ctx, srv.URL); err == nil {
+		t.Fatal("expected an error from a canceled context, got nil")
+	}
+}
+
+func TestStreamingGetRejectsBadURL(t *testing.T) {
+	if _, err := StreamingGet(context.Background(), "://not-a-url"); err == nil {
+		t.Fatal("expected an error building the request, got nil")
+	}
+}
+
+// Cloning must not mutate the shared default transport.
+func TestNewStreamingClientClonesWithoutMutatingDefault(t *testing.T) {
+	def, ok := http.DefaultTransport.(*http.Transport)
+	if !ok {
+		t.Skip("default transport is not *http.Transport in this environment")
+	}
+	c := newStreamingClient(def)
+	tr := c.Transport.(*http.Transport)
+	if tr == def {
+		t.Fatal("expected a cloned transport, got the shared default")
+	}
+	if def.ResponseHeaderTimeout == PricingTimeout {
+		t.Fatal("mutated the shared default transport's ResponseHeaderTimeout")
+	}
+}

+ 5 - 2
pkg/cloud/oracle/ratecard.go

@@ -8,6 +8,7 @@ import (
 	"strconv"
 	"strings"
 
+	"github.com/opencost/opencost/pkg/cloud/httputil"
 	"github.com/opencost/opencost/pkg/cloud/models"
 )
 
@@ -52,8 +53,10 @@ func NewRateCardStore(url, currencyCode string) *RateCardStore {
 	return &RateCardStore{
 		url:          url,
 		currencyCode: currencyCode,
-		client:       &http.Client{},
-		prices:       map[string]Price{},
+		// Zero-value http.Client has no timeout; use the shared bounded client so
+		// a stalled rate-card endpoint can't hang ingestion.
+		client: httputil.BoundedClient(),
+		prices: map[string]Price{},
 	}
 }
 

+ 4 - 1
pkg/cloud/otc/pricingapi.go

@@ -7,9 +7,12 @@ import (
 	"net/http"
 
 	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/pkg/cloud/httputil"
 )
 
-var otcHTTPClient = http.DefaultClient
+// http.DefaultClient has no timeout, so a hung pricing endpoint would block the
+// paginated fetch loop forever. Use the shared bounded client instead.
+var otcHTTPClient = httputil.BoundedClient()
 
 // Fetches and flattens all product entries across multiple services with pagination
 func (otc *OTC) fetchPaginatedProducts(serviceNames []string) ([]Product, error) {

+ 2 - 1
pkg/costmodel/costmodel.go

@@ -73,7 +73,8 @@ func NewCostModel(
 	var kubeModel *km.KubeModel
 	var err error
 	if dataSource != nil {
-		kubeModel, err = km.NewKubeModel(clusterUID, dataSource)
+
+		kubeModel, err = km.NewKubeModel(clusterUID, coreenv.IsKubeModelV1Forced(), dataSource)
 		if err != nil {
 			// KubeModel is required. Log a fatal error if we fail to init.
 			log.Fatalf("error initializing KubeModel: %s", err)

+ 6 - 5
pkg/costmodel/metrics.go

@@ -9,6 +9,7 @@ import (
 
 	"github.com/opencost/opencost/core/pkg/clustercache"
 	"github.com/opencost/opencost/core/pkg/clusters"
+	coreenv "github.com/opencost/opencost/core/pkg/env"
 	"github.com/opencost/opencost/core/pkg/errors"
 	"github.com/opencost/opencost/core/pkg/log"
 	"github.com/opencost/opencost/core/pkg/source"
@@ -361,11 +362,11 @@ func NewCostModelMetricsEmitter(clusterCache clustercache.ClusterCache, provider
 
 	metrics.InitKubeMetrics(clusterInfo, clusterCache, metricsConfig, &metrics.KubeMetricsOpts{
 		EmitKubecostControllerMetrics: true,
-		EmitNamespaceAnnotations:      env.IsEmitNamespaceAnnotationsMetric(),
-		EmitPodAnnotations:            env.IsEmitPodAnnotationsMetric(),
-		EmitKubeStateMetrics:          env.IsEmitKsmV1Metrics(),
-		EmitKubeStateMetricsV1Only:    env.IsEmitKsmV1MetricsOnly(),
-		EmitDeprecatedMetrics:         env.IsEmitDeprecatedMetrics(),
+		EmitNamespaceAnnotations:      coreenv.IsEmitNamespaceAnnotationsMetric(),
+		EmitPodAnnotations:            coreenv.IsEmitPodAnnotationsMetric(),
+		EmitKubeStateMetrics:          coreenv.IsEmitKsmV1Metrics(),
+		EmitKubeStateMetricsV1Only:    coreenv.IsEmitKsmV1MetricsOnly(),
+		EmitDeprecatedMetrics:         coreenv.IsEmitDeprecatedMetrics(),
 	})
 
 	metrics.InitOpencostTelemetry(metricsConfig)

+ 11 - 7
pkg/costmodel/router.go

@@ -531,15 +531,19 @@ func Initialize(router *httprouter.Router, additionalConfigWatchers ...*watcher.
 	costModel := NewCostModel(clusterUID, dataSource, cloudProvider, k8sCache, clusterMap, dataSource.BatchDuration())
 	metricsEmitter := NewCostModelMetricsEmitter(k8sCache, cloudProvider, clusterInfoProvider, costModel)
 
-	appName := sysenv.GetAppName()
 	var kubeModelPipeline *km.Pipeline
-	if p, err := km.NewPipeline(appName, clusterUID, store, costModel); err != nil {
-		log.Errorf("Failed to initialize KubeModel pipeline: %v", err)
-	} else {
-		p.Start()
-		kubeModelPipeline = p
+	var kubeModelQuerier km.Querier
+	if sysenv.IsKubeModelExported() {
+		appName := sysenv.GetAppName()
+
+		if p, err := km.NewPipeline(appName, clusterUID, store, costModel); err != nil {
+			log.Errorf("Failed to initialize KubeModel pipeline: %v", err)
+		} else {
+			p.Start()
+			kubeModelPipeline = p
+		}
+		kubeModelQuerier = km.NewQuerier(appName, clusterUID, store)
 	}
-	kubeModelQuerier := km.NewQuerier(appName, clusterUID, store)
 
 	a := &Accesses{
 		DataSource:          dataSource,

+ 17 - 1
pkg/customcost/ingestor.go

@@ -311,11 +311,27 @@ func (ing *CustomCostIngestor) Status() IngestorStatus {
 		LastRun:     ing.lastRun,
 		NextRun:     ing.lastRun.Add(ing.refreshRate).UTC(),
 		Runs:        ing.runs,
-		Coverage:    ing.coverage,
+		Coverage:    ing.copyCoverage(),
 		RefreshRate: ing.refreshRate,
 	}
 }
 
+// copyCoverage returns a shallow copy of the coverage map taken under the lock.
+// Returning ing.coverage directly hands a live reference to callers (the
+// /customCost/status handler serializes it, which iterates it), racing
+// expandCoverage() writing under coverageLock and risking a fatal "concurrent
+// map iteration and map write" crash. expandCoverage replaces Window values
+// wholesale, so a shallow copy is sufficient.
+func (ing *CustomCostIngestor) copyCoverage() map[string]opencost.Window {
+	ing.coverageLock.Lock()
+	defer ing.coverageLock.Unlock()
+	coverage := make(map[string]opencost.Window, len(ing.coverage))
+	for plugin, window := range ing.coverage {
+		coverage[plugin] = window
+	}
+	return coverage
+}
+
 func (ing *CustomCostIngestor) build(rebuild bool) {
 	defer errors.HandlePanic()
 	e := opencost.RoundBack(time.Now().UTC(), ing.resolution)

+ 74 - 0
pkg/customcost/ingestor_test.go

@@ -260,6 +260,80 @@ func TestBuildSingleDomain_DispenseError(t *testing.T) {
 	ingestor.BuildWindow(now.Add(-time.Hour), now)
 }
 
+// TestIngestor_Status_ReturnsCopyOfCoverage deterministically proves Status()
+// hands back a copy, not the live map: mutating the returned Coverage must not
+// leak into the ingestor's internal state. Unlike the concurrent test below,
+// this fails without needing the race detector.
+func TestIngestor_Status_ReturnsCopyOfCoverage(t *testing.T) {
+	ingestor := &CustomCostIngestor{
+		coverage: map[string]opencost.Window{},
+	}
+	start := time.Now().UTC()
+	end := start.Add(time.Hour)
+	ingestor.expandCoverage(opencost.NewWindow(&start, &end), "plugin-a")
+
+	status := ingestor.Status()
+	if len(status.Coverage) != 1 {
+		t.Fatalf("expected 1 coverage entry, got %d", len(status.Coverage))
+	}
+
+	// Mutating the returned map must not affect the ingestor.
+	status.Coverage["plugin-b"] = opencost.NewWindow(&start, &end)
+	delete(status.Coverage, "plugin-a")
+
+	again := ingestor.Status()
+	if _, ok := again.Coverage["plugin-a"]; !ok {
+		t.Error("plugin-a should remain in the ingestor's coverage; Status() leaked a live reference")
+	}
+	if _, ok := again.Coverage["plugin-b"]; ok {
+		t.Error("plugin-b leaked into the ingestor's coverage; Status() returned a live reference")
+	}
+}
+
+// TestIngestor_Status_ConcurrentWithExpandCoverage guards against a data race:
+// Status() returns the coverage map by reference while expandCoverage() writes
+// to it under coverageLock. The /customCost/status handler serializes the
+// returned map (which iterates it), racing the writer and crashing the process
+// with "concurrent map iteration and map write". Run with -race to detect it.
+func TestIngestor_Status_ConcurrentWithExpandCoverage(t *testing.T) {
+	ingestor := &CustomCostIngestor{
+		coverage: map[string]opencost.Window{},
+	}
+
+	start := time.Now().UTC()
+	end := start.Add(time.Hour)
+	window := opencost.NewWindow(&start, &end)
+
+	var wg sync.WaitGroup
+	wg.Add(2)
+
+	// writer: continuously expands coverage under the lock
+	go func() {
+		defer wg.Done()
+		for i := 0; i < 2000; i++ {
+			ingestor.expandCoverage(window, fmt.Sprintf("plugin-%d", i%16))
+		}
+	}()
+
+	// reader: reads Status() and iterates the returned map, mimicking the JSON
+	// serialization the /customCost/status handler performs on the response
+	go func() {
+		defer wg.Done()
+		for i := 0; i < 2000; i++ {
+			for range ingestor.Status().Coverage {
+			}
+		}
+	}()
+
+	wg.Wait()
+
+	// The writer touched 16 distinct plugins, so coverage should hold 16 entries
+	// once the race-free reads settle.
+	if got := len(ingestor.Status().Coverage); got != 16 {
+		t.Fatalf("expected 16 coverage entries after concurrent writes, got %d", got)
+	}
+}
+
 func TestBuildSingleDomain_ClientError(t *testing.T) {
 	mock := &mockPluginConnector{
 		clientErr: fmt.Errorf("connection failed"),

+ 0 - 33
pkg/env/costmodel.go

@@ -62,13 +62,6 @@ const (
 	CollectorDataSourceEnabledEnvVar = "COLLECTOR_DATA_SOURCE_ENABLED"
 	LocalCollectorDirectoryEnvVar    = "LOCAL_COLLECTOR_DIRECTORY"
 
-	EmitPodAnnotationsMetricEnvVar       = "EMIT_POD_ANNOTATIONS_METRIC"
-	EmitNamespaceAnnotationsMetricEnvVar = "EMIT_NAMESPACE_ANNOTATIONS_METRIC"
-	EmitDeprecatedMetrics                = "EMIT_DEPRECATED_METRICS"
-
-	EmitKsmV1MetricsEnvVar = "EMIT_KSM_V1_METRICS"
-	EmitKsmV1MetricsOnly   = "EMIT_KSM_V1_METRICS_ONLY"
-
 	LogCollectionEnabledEnvVar    = "LOG_COLLECTION_ENABLED"
 	ProductAnalyticsEnabledEnvVar = "PRODUCT_ANALYTICS_ENABLED"
 	ErrorReportingEnabledEnvVar   = "ERROR_REPORTING_ENABLED"
@@ -157,32 +150,6 @@ func GetMetricsConfigmapName() string {
 	return env.Get(MetricsConfigmapName, "metrics-config")
 }
 
-// IsEmitNamespaceAnnotationsMetric returns true if cost-model is configured to emit the kube_namespace_annotations metric
-// containing the namespace annotations
-func IsEmitNamespaceAnnotationsMetric() bool {
-	return env.GetBool(EmitNamespaceAnnotationsMetricEnvVar, false)
-}
-
-// IsEmitPodAnnotationsMetric returns true if cost-model is configured to emit the kube_pod_annotations metric containing
-// pod annotations.
-func IsEmitPodAnnotationsMetric() bool {
-	return env.GetBool(EmitPodAnnotationsMetricEnvVar, false)
-}
-
-// IsEmitKsmV1Metrics returns true if cost-model is configured to emit all necessary KSM v1
-// metrics that were removed in KSM v2
-func IsEmitKsmV1Metrics() bool {
-	return env.GetBool(EmitKsmV1MetricsEnvVar, true)
-}
-
-func IsEmitKsmV1MetricsOnly() bool {
-	return env.GetBool(EmitKsmV1MetricsOnly, false)
-}
-
-func IsEmitDeprecatedMetrics() bool {
-	return env.GetBool(EmitDeprecatedMetrics, false)
-}
-
 // GetAWSAccessKeyID returns the environment variable value for AWSAccessKeyIDEnvVar which represents
 // the AWS access key for authentication
 func GetAWSAccessKeyID() string {

+ 1 - 1
pkg/kubemodel/pipeline.go

@@ -30,7 +30,7 @@ func NewPipeline(appName, clusterUID string, store storage.Storage, cm ocexporte
 		return nil, fmt.Errorf("NewPipeline: clusterUID cannot be empty")
 	}
 
-	config := ocexporter.NewPipelinesExportConfig(appName, clusterUID, "", false, env.GetExportKubeModel())
+	config := ocexporter.NewPipelinesExportConfig(appName, clusterUID, "", false, env.IsKubeModelExported())
 
 	controllers := ocexporter.NewPipelineExportControllers(store, cm, config)
 

+ 8 - 5
pkg/metrics/kubemodel.go

@@ -6,6 +6,8 @@ import (
 	"github.com/opencost/opencost/core/pkg/clustercache"
 	"github.com/opencost/opencost/core/pkg/clusters"
 	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
 	coreutil "github.com/opencost/opencost/core/pkg/util"
 	"github.com/opencost/opencost/core/pkg/util/promutil"
 	"github.com/prometheus/client_golang/prometheus"
@@ -184,11 +186,12 @@ func (c KubeModelCollector) scrapeClusterInfo(disabled map[string]struct{}) []ku
 	}
 	info := c.ClusterInfo.GetClusterInfo()
 	labels := map[string]string{
-		"uid":              info[clusters.ClusterInfoIdKey],
-		"provider":         info[clusters.ClusterInfoProviderKey],
-		"account_id":       info[clusters.ClusterInfoAccountKey],
-		"provisioner_name": info[clusters.ClusterInfoProvisionerKey],
-		"region":           info[clusters.ClusterInfoRegionKey],
+		"uid":                   info[clusters.ClusterInfoIdKey],
+		"provider":              info[clusters.ClusterInfoProviderKey],
+		"account_id":            info[clusters.ClusterInfoAccountKey],
+		"provisioner_name":      info[clusters.ClusterInfoProvisionerKey],
+		"region":                info[clusters.ClusterInfoRegionKey],
+		source.KubeModelVersion: fmt.Sprintf("%d", kubemodel.DefaultCodecVersion),
 	}
 	// GCP uses "project" instead of "account"
 	if labels["account_id"] == "" {