Browse Source

Add compute tests

Signed-off-by: Sean Holcomb <seanholcomb@gmail.com>
Sean Holcomb 2 days ago
parent
commit
d7f4cc6c38

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

+ 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{},
+					CPUCoresAllocated: 0.25,
+					RAMBytesAllocated: 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, 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, 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, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.DaemonSets)
+		})
+	}
+}

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

@@ -0,0 +1,170 @@
+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, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(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, 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, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.Jobs)
+		})
+	}
+}

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

@@ -0,0 +1,371 @@
+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"
+)
+
+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)
+}
+
+func newTestKubeModel(t *testing.T) (*KubeModel, *source.MockOpenCostDataSource) {
+	t.Helper()
+	ds := source.NewMockOpenCostDataSource()
+	ds.ResolutionValue = 5 * time.Minute
+	km, err := NewKubeModel(testClusterUID, ds)
+	require.NoError(t, err)
+	return km, ds
+}
+
+// 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, nil)
+	require.Error(t, err)
+}
+
+// ---- computeCluster ----
+
+func TestComputeCluster_HappyPath(t *testing.T) {
+	start, end := newTestWindow()
+	km, ds := newTestKubeModel(t)
+	seedCluster(ds, start, end)
+
+	kms, err := km.ComputeKubeModelSet(start, end)
+	require.NoError(t, err)
+	require.NotNil(t, kms.Cluster)
+	assert.Equal(t, testClusterUID, kms.Cluster.UID)
+	assert.Equal(t, "my-cluster", kms.Cluster.Name)
+}
+
+func TestComputeCluster_UIDNotFound(t *testing.T) {
+	start, end := newTestWindow()
+	km, ds := newTestKubeModel(t)
+
+	ds.Querier.SetOverride(source.QueryClusterInfo, []*source.ClusterInfoResult{
+		{UID: "other-uid", Cluster: "other-cluster"},
+	})
+
+	_, err := km.ComputeKubeModelSet(start, end)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), testClusterUID)
+}
+
+func TestComputeCluster_NoUptime(t *testing.T) {
+	start, end := newTestWindow()
+	km, ds := newTestKubeModel(t)
+
+	ds.Querier.SetOverride(source.QueryClusterInfo, []*source.ClusterInfoResult{
+		{UID: testClusterUID, Cluster: "my-cluster"},
+	})
+	// QueryClusterUptime left at NoOp — cluster Start/End stay zero, fail window
+	// validation inside RegisterCluster, so kms.Cluster is not set.
+
+	kms, err := km.ComputeKubeModelSet(start, end)
+	require.NoError(t, err)
+	assert.Nil(t, kms.Cluster)
+}
+
+// ---- computeNodes ----
+
+func TestComputeNodes_HappyPath(t *testing.T) {
+	start, end := newTestWindow()
+	km, ds := newTestKubeModel(t)
+	seedCluster(ds, start, end)
+
+	ds.Querier.SetOverride(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"},
+	})
+	ds.Querier.SetOverride(source.QueryNodeUptime, []*source.UptimeResult{
+		{UID: "node-1", First: start, Last: end},
+		{UID: "node-2", First: start, Last: end},
+	})
+	ds.Querier.SetOverride(source.QueryNodeLabels, []*source.NodeLabelsResult{
+		{UID: "node-1", Labels: map[string]string{"zone": "us-east-1a"}},
+	})
+
+	kms, err := km.ComputeKubeModelSet(start, end)
+	require.NoError(t, err)
+
+	assert.Len(t, kms.Nodes, 2)
+	n1 := kms.Nodes["node-1"]
+	require.NotNil(t, n1)
+	assert.Equal(t, "node-a", n1.Name)
+	assert.Equal(t, "aws:///us-east-1a/i-abc", n1.ProviderID)
+	assert.Equal(t, map[string]string{"zone": "us-east-1a"}, n1.Labels)
+}
+
+func TestComputeNodes_ResourceCapacities(t *testing.T) {
+	start, end := newTestWindow()
+	km, ds := newTestKubeModel(t)
+	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.QueryNodeResourceCapacities, []*source.ResourceResult{
+		{UID: "node-1", Resource: "cpu", Unit: "cores", Value: 4.0},
+		{UID: "node-1", Resource: "memory", Unit: "bytes", Value: 16 * 1024 * 1024 * 1024},
+	})
+	ds.Querier.SetOverride(source.QueryNodeResourcesAllocatable, []*source.ResourceResult{
+		{UID: "node-1", Resource: "cpu", Unit: "cores", Value: 3.9},
+	})
+
+	kms, err := km.ComputeKubeModelSet(start, end)
+	require.NoError(t, err)
+
+	n := kms.Nodes["node-1"]
+	require.NotNil(t, n)
+	assert.NotEmpty(t, n.ResourceCapacities)
+	assert.NotEmpty(t, n.ResourcesAllocatable)
+}
+
+func TestComputeNodes_LocalStorage(t *testing.T) {
+	start, end := newTestWindow()
+	km, ds := newTestKubeModel(t)
+	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.QueryKMLocalStorageBytes, []*source.UIDValueResult{
+		{UID: "node-1", Value: 500 * 1024 * 1024 * 1024},
+	})
+	ds.Querier.SetOverride(source.QueryKMLocalStorageUsedAvg, []*source.NodeUIDValueResult{
+		{UID: "node-1", Value: 100 * 1024 * 1024 * 1024},
+	})
+	ds.Querier.SetOverride(source.QueryKMLocalStorageUsedMax, []*source.NodeUIDValueResult{
+		{UID: "node-1", Value: 200 * 1024 * 1024 * 1024},
+	})
+
+	kms, err := km.ComputeKubeModelSet(start, end)
+	require.NoError(t, err)
+
+	n := kms.Nodes["node-1"]
+	require.NotNil(t, n)
+	assert.Equal(t, float64(500*1024*1024*1024), n.FileSystem.CapacityBytes)
+	assert.Equal(t, float64(100*1024*1024*1024), n.FileSystem.UsageByteAvg)
+	assert.Equal(t, float64(200*1024*1024*1024), n.FileSystem.UsageByteMax)
+}
+
+func TestComputeNodes_EmptyResults(t *testing.T) {
+	start, end := newTestWindow()
+	km, ds := newTestKubeModel(t)
+	seedCluster(ds, start, end)
+
+	kms, err := km.ComputeKubeModelSet(start, end)
+	require.NoError(t, err)
+	assert.Empty(t, kms.Nodes)
+}
+
+// ---- computePods ----
+
+func TestComputePods_HappyPath(t *testing.T) {
+	start, end := newTestWindow()
+	km, ds := newTestKubeModel(t)
+	seedCluster(ds, start, end)
+
+	ds.Querier.SetOverride(source.QueryPodInfo, []*source.PodInfoResult{
+		{UID: "pod-1", Pod: "my-pod", NamespaceUID: "ns-1", NodeUID: "node-1"},
+	})
+	ds.Querier.SetOverride(source.QueryPodUptime, []*source.UptimeResult{
+		{UID: "pod-1", First: start, Last: end},
+	})
+	ds.Querier.SetOverride(source.QueryPodLabels, []*source.PodLabelsResult{
+		{UID: "pod-1", Labels: map[string]string{"app": "my-app"}},
+	})
+	ds.Querier.SetOverride(source.QueryPodAnnotations, []*source.PodAnnotationsResult{
+		{UID: "pod-1", Annotations: map[string]string{"note": "val"}},
+	})
+
+	kms, err := km.ComputeKubeModelSet(start, end)
+	require.NoError(t, err)
+
+	pod := kms.Pods["pod-1"]
+	require.NotNil(t, pod)
+	assert.Equal(t, "my-pod", pod.Name)
+	assert.Equal(t, "ns-1", pod.NamespaceUID)
+	assert.Equal(t, "node-1", pod.NodeUID)
+	assert.Equal(t, map[string]string{"app": "my-app"}, pod.Labels)
+	assert.Equal(t, map[string]string{"note": "val"}, pod.Annotations)
+}
+
+func TestComputePods_Owners(t *testing.T) {
+	start, end := newTestWindow()
+	km, ds := newTestKubeModel(t)
+	seedCluster(ds, start, 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.QueryPodOwners, []*source.OwnerResult{
+		{UID: "pod-1", OwnerUID: "rs-1", OwnerKind: "ReplicaSet", Controller: true},
+	})
+
+	kms, err := km.ComputeKubeModelSet(start, end)
+	require.NoError(t, err)
+
+	pod := kms.Pods["pod-1"]
+	require.NotNil(t, pod)
+	require.Len(t, pod.Owners, 1)
+	assert.Equal(t, "rs-1", pod.Owners[0].UID)
+	assert.True(t, pod.Owners[0].Controller)
+}
+
+func TestComputePods_PVCVolumes(t *testing.T) {
+	start, end := newTestWindow()
+	km, ds := newTestKubeModel(t)
+	seedCluster(ds, start, 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.QueryPodPVCVolumes, []*source.PodPVCVolumeResult{
+		{UID: "pod-1", PVCUID: "pvc-1", PodVolumeName: "data"},
+	})
+
+	kms, err := km.ComputeKubeModelSet(start, end)
+	require.NoError(t, err)
+
+	pod := kms.Pods["pod-1"]
+	require.NotNil(t, pod)
+	require.Len(t, pod.PVCVolumes, 1)
+	assert.Equal(t, "pvc-1", pod.PVCVolumes[0].PersistentVolumeClaimUID)
+	assert.Equal(t, "data", pod.PVCVolumes[0].Name)
+}
+
+// ---- computeContainers ----
+
+func TestComputeContainers_HappyPath(t *testing.T) {
+	start, end := newTestWindow()
+	km, ds := newTestKubeModel(t)
+	seedCluster(ds, start, end)
+
+	ds.Querier.SetOverride(source.QueryContainerUptime, []*source.ContainerUptimeResult{
+		{UptimeResult: source.UptimeResult{UID: "pod-1", First: start, Last: end}, Container: "app"},
+		{UptimeResult: source.UptimeResult{UID: "pod-1", First: start, Last: end}, Container: "sidecar"},
+	})
+
+	kms, err := km.ComputeKubeModelSet(start, end)
+	require.NoError(t, err)
+	assert.Len(t, kms.Containers, 2)
+}
+
+func TestComputeContainers_ResourceRequestsAndLimits(t *testing.T) {
+	start, end := newTestWindow()
+	km, ds := newTestKubeModel(t)
+	seedCluster(ds, start, end)
+
+	ds.Querier.SetOverride(source.QueryContainerUptime, []*source.ContainerUptimeResult{
+		{UptimeResult: source.UptimeResult{UID: "pod-1", First: start, Last: end}, Container: "app"},
+	})
+	ds.Querier.SetOverride(source.QueryContainerResourceRequests, []*source.ContainerResourceResult{
+		{ResourceResult: source.ResourceResult{UID: "pod-1", Resource: "cpu", Unit: "cores", Value: 0.5}, Container: "app"},
+		{ResourceResult: source.ResourceResult{UID: "pod-1", Resource: "memory", Unit: "bytes", Value: 128 * 1024 * 1024}, Container: "app"},
+	})
+	ds.Querier.SetOverride(source.QueryContainerResourceLimits, []*source.ContainerResourceResult{
+		{ResourceResult: source.ResourceResult{UID: "pod-1", Resource: "cpu", Unit: "cores", Value: 1.0}, Container: "app"},
+	})
+
+	kms, err := km.ComputeKubeModelSet(start, end)
+	require.NoError(t, err)
+
+	c := findContainer(kms, "pod-1", "app")
+	require.NotNil(t, c)
+	assert.NotEmpty(t, c.ResourceRequests)
+	assert.NotEmpty(t, c.ResourceLimits)
+}
+
+func TestComputeContainers_CPUAndRAMUsage(t *testing.T) {
+	start, end := newTestWindow()
+	km, ds := newTestKubeModel(t)
+	seedCluster(ds, start, end)
+
+	ts := float64(start.Unix())
+	ds.Querier.SetOverride(source.QueryContainerUptime, []*source.ContainerUptimeResult{
+		{UptimeResult: source.UptimeResult{UID: "pod-1", First: start, Last: end}, Container: "app"},
+	})
+	ds.Querier.SetOverride(source.QueryCPUCoresAllocated, []*source.CPUCoresAllocatedResult{
+		{UID: "pod-1", Container: "app", Data: []*util.Vector{{Timestamp: ts, Value: 0.25}}},
+	})
+	ds.Querier.SetOverride(source.QueryCPUUsageAvg, []*source.CPUUsageAvgResult{
+		{UID: "pod-1", Container: "app", Data: []*util.Vector{{Timestamp: ts, Value: 0.1}}},
+	})
+	ds.Querier.SetOverride(source.QueryCPUUsageMax, []*source.CPUUsageMaxResult{
+		{UID: "pod-1", Container: "app", Data: []*util.Vector{{Timestamp: ts, Value: 0.2}}},
+	})
+	ds.Querier.SetOverride(source.QueryRAMBytesAllocated, []*source.RAMBytesAllocatedResult{
+		{UID: "pod-1", Container: "app", Data: []*util.Vector{{Timestamp: ts, Value: 256 * 1024 * 1024}}},
+	})
+	ds.Querier.SetOverride(source.QueryRAMUsageAvg, []*source.RAMUsageAvgResult{
+		{UID: "pod-1", Container: "app", Data: []*util.Vector{{Timestamp: ts, Value: 100 * 1024 * 1024}}},
+	})
+	ds.Querier.SetOverride(source.QueryRAMUsageMax, []*source.RAMUsageMaxResult{
+		{UID: "pod-1", Container: "app", Data: []*util.Vector{{Timestamp: ts, Value: 200 * 1024 * 1024}}},
+	})
+
+	kms, err := km.ComputeKubeModelSet(start, end)
+	require.NoError(t, err)
+
+	c := findContainer(kms, "pod-1", "app")
+	require.NotNil(t, c)
+	assert.Equal(t, 0.25, c.CPUCoresAllocated)
+	assert.Equal(t, 0.1, c.CPUCoreUsageAvg)
+	assert.Equal(t, 0.2, c.CPUCoreUsageMax)
+	assert.Equal(t, float64(256*1024*1024), c.RAMBytesAllocated)
+	assert.Equal(t, float64(100*1024*1024), c.RAMBytesUsageAvg)
+	assert.Equal(t, float64(200*1024*1024), c.RAMBytesUsageMax)
+}
+
+func TestComputeContainers_EmptyResults(t *testing.T) {
+	start, end := newTestWindow()
+	km, ds := newTestKubeModel(t)
+	seedCluster(ds, start, end)
+
+	kms, err := km.ComputeKubeModelSet(start, end)
+	require.NoError(t, err)
+	assert.Empty(t, kms.Containers)
+}
+
+func findContainer(kms *kubemodel.KubeModelSet, podUID, name string) *kubemodel.Container {
+	for _, c := range kms.Containers {
+		if c.PodUID == podUID && c.Name == name {
+			return c
+		}
+	}
+	return nil
+}

+ 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, 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, 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, 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, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.PersistentVolumeClaims)
+		})
+	}
+}

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

@@ -0,0 +1,211 @@
+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,
+				},
+			},
+		},
+	}
+
+	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, 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, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.ReplicaSets)
+		})
+	}
+}

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

@@ -0,0 +1,190 @@
+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: "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, 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, 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, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.StatefulSets)
+		})
+	}
+}

File diff suppressed because it is too large
+ 141 - 141
core/pkg/source/mock.go


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