Przeglądaj źródła

KubeModel Changes (#3875)

Signed-off-by: Sean Holcomb <seanholcomb@gmail.com>
Signed-off-by: Sean Holcomb <sean.holcomb@ibm.com>
Co-authored-by: Alex Meijer <ameijer@users.noreply.github.com>
Sean Holcomb 10 godzin temu
rodzic
commit
87faadd1d7
100 zmienionych plików z 16375 dodań i 3595 usunięć
  1. 39 9
      core/pkg/clustercache/clustercache.go
  2. 15 0
      core/pkg/clustercache/helper.go
  3. 89 0
      core/pkg/clustercache/helper_test.go
  4. 49 0
      core/pkg/clustercache/mock.go
  5. 13 0
      core/pkg/clusters/mock.go
  6. 53 0
      core/pkg/compute/kubemodel/cluster.go
  7. 116 0
      core/pkg/compute/kubemodel/cluster_test.go
  8. 172 0
      core/pkg/compute/kubemodel/container.go
  9. 205 0
      core/pkg/compute/kubemodel/container_test.go
  10. 71 0
      core/pkg/compute/kubemodel/cronjob.go
  11. 117 0
      core/pkg/compute/kubemodel/cronjob_test.go
  12. 71 0
      core/pkg/compute/kubemodel/daemonset.go
  13. 138 0
      core/pkg/compute/kubemodel/daemonset_test.go
  14. 89 0
      core/pkg/compute/kubemodel/dcgmdevice.go
  15. 172 0
      core/pkg/compute/kubemodel/dcgmdevice_test.go
  16. 82 0
      core/pkg/compute/kubemodel/deployment.go
  17. 162 0
      core/pkg/compute/kubemodel/deployment_test.go
  18. 71 0
      core/pkg/compute/kubemodel/job.go
  19. 138 0
      core/pkg/compute/kubemodel/job_test.go
  20. 109 0
      core/pkg/compute/kubemodel/kubemodel.go
  21. 213 0
      core/pkg/compute/kubemodel/kubemodel_test.go
  22. 71 0
      core/pkg/compute/kubemodel/namespace.go
  23. 154 0
      core/pkg/compute/kubemodel/namespace_test.go
  24. 114 0
      core/pkg/compute/kubemodel/node.go
  25. 325 0
      core/pkg/compute/kubemodel/node_test.go
  26. 63 0
      core/pkg/compute/kubemodel/persistentvolume.go
  27. 142 0
      core/pkg/compute/kubemodel/persistentvolume_test.go
  28. 86 0
      core/pkg/compute/kubemodel/persistentvolumeclaim.go
  29. 155 0
      core/pkg/compute/kubemodel/persistentvolumeclaim_test.go
  30. 150 0
      core/pkg/compute/kubemodel/pod.go
  31. 309 0
      core/pkg/compute/kubemodel/pod_test.go
  32. 86 0
      core/pkg/compute/kubemodel/replicaset.go
  33. 166 0
      core/pkg/compute/kubemodel/replicaset_test.go
  34. 262 0
      core/pkg/compute/kubemodel/resourcequota.go
  35. 362 0
      core/pkg/compute/kubemodel/resourcequota_test.go
  36. 63 0
      core/pkg/compute/kubemodel/service.go
  37. 159 0
      core/pkg/compute/kubemodel/service_test.go
  38. 82 0
      core/pkg/compute/kubemodel/statefulset.go
  39. 141 0
      core/pkg/compute/kubemodel/statefulset_test.go
  40. 19 0
      core/pkg/env/core.go
  41. 102 0
      core/pkg/env/metrics.go
  42. 1 1
      core/pkg/exporter/controller.go
  43. 13 6
      core/pkg/exporter/encoder.go
  44. 8 6
      core/pkg/exporter/exporter_test.go
  45. 5 6
      core/pkg/exporter/pathing/bingenpath.go
  46. 58 44
      core/pkg/exporter/pathing/path_test.go
  47. 0 35
      core/pkg/model/helper.go
  48. 0 88
      core/pkg/model/helper_test.go
  49. 3 1
      core/pkg/model/kubemodel/bingen.go
  50. 30 14
      core/pkg/model/kubemodel/cluster.go
  51. 131 0
      core/pkg/model/kubemodel/cluster_test.go
  52. 29 82
      core/pkg/model/kubemodel/container.go
  53. 123 0
      core/pkg/model/kubemodel/container_test.go
  54. 58 0
      core/pkg/model/kubemodel/cronjob.go
  55. 148 0
      core/pkg/model/kubemodel/cronjob_test.go
  56. 59 0
      core/pkg/model/kubemodel/daemonset.go
  57. 148 0
      core/pkg/model/kubemodel/daemonset_test.go
  58. 66 0
      core/pkg/model/kubemodel/dcgm.go
  59. 138 0
      core/pkg/model/kubemodel/dcgm_test.go
  60. 59 0
      core/pkg/model/kubemodel/deployment.go
  61. 148 0
      core/pkg/model/kubemodel/deployment_test.go
  62. 0 91
      core/pkg/model/kubemodel/device.go
  63. 0 85
      core/pkg/model/kubemodel/device_usage.go
  64. 58 0
      core/pkg/model/kubemodel/job.go
  65. 148 0
      core/pkg/model/kubemodel/job_test.go
  66. 50 50
      core/pkg/model/kubemodel/kubemodel.go
  67. 7457 557
      core/pkg/model/kubemodel/kubemodel_codecs.go
  68. 32 452
      core/pkg/model/kubemodel/kubemodel_codecs_test.go
  69. 64 0
      core/pkg/model/kubemodel/kubemodel_helpers_test.go
  70. 109 69
      core/pkg/model/kubemodel/kubemodel_test.go
  71. 0 627
      core/pkg/model/kubemodel/merge.go
  72. 266 0
      core/pkg/model/kubemodel/mock.go
  73. 31 24
      core/pkg/model/kubemodel/namespace.go
  74. 143 0
      core/pkg/model/kubemodel/namespace_test.go
  75. 28 0
      core/pkg/model/kubemodel/networktrafficdetail.go
  76. 31 70
      core/pkg/model/kubemodel/node.go
  77. 143 0
      core/pkg/model/kubemodel/node_test.go
  78. 28 39
      core/pkg/model/kubemodel/owner.go
  79. 41 0
      core/pkg/model/kubemodel/owner_test.go
  80. 45 27
      core/pkg/model/kubemodel/pod.go
  81. 148 0
      core/pkg/model/kubemodel/pod_test.go
  82. 0 14
      core/pkg/model/kubemodel/provider.go
  83. 28 50
      core/pkg/model/kubemodel/pv.go
  84. 123 0
      core/pkg/model/kubemodel/pv_test.go
  85. 35 36
      core/pkg/model/kubemodel/pvc.go
  86. 128 0
      core/pkg/model/kubemodel/pvc_test.go
  87. 59 0
      core/pkg/model/kubemodel/replicaset.go
  88. 148 0
      core/pkg/model/kubemodel/replicaset_test.go
  89. 5 4
      core/pkg/model/kubemodel/resource.go
  90. 35 16
      core/pkg/model/kubemodel/resourcequota.go
  91. 135 0
      core/pkg/model/kubemodel/resourcequota_test.go
  92. 65 22
      core/pkg/model/kubemodel/service.go
  93. 164 0
      core/pkg/model/kubemodel/service_test.go
  94. 59 0
      core/pkg/model/kubemodel/statefulset.go
  95. 148 0
      core/pkg/model/kubemodel/statefulset_test.go
  96. 1 2
      core/pkg/model/kubemodel/unit.go
  97. 0 246
      core/pkg/model/pb/kubemodel/cluster.pb.go
  98. 0 258
      core/pkg/model/pb/kubemodel/container.pb.go
  99. 0 298
      core/pkg/model/pb/kubemodel/controller.pb.go
  100. 0 266
      core/pkg/model/pb/kubemodel/diagnostic.pb.go

+ 39 - 9
core/pkg/clustercache/clustercache.go

@@ -71,9 +71,11 @@ type Service struct {
 }
 
 type DaemonSet struct {
+	UID            types.UID
 	Name           string
 	Namespace      string
 	Labels         map[string]string
+	Annotations    map[string]string
 	SpecContainers []v1.Container
 }
 
@@ -122,10 +124,12 @@ type StorageClass struct {
 }
 
 type Job struct {
-	UID       types.UID
-	Name      string
-	Namespace string
-	Status    batchv1.JobStatus
+	UID         types.UID
+	Name        string
+	Namespace   string
+	Labels      map[string]string
+	Annotations map[string]string
+	Status      batchv1.JobStatus
 }
 
 type PersistentVolume struct {
@@ -155,6 +159,8 @@ type ReplicaSet struct {
 	UID             types.UID
 	Name            string
 	Namespace       string
+	Labels          map[string]string
+	Annotations     map[string]string
 	OwnerReferences []metav1.OwnerReference
 	SpecSelector    *metav1.LabelSelector
 	Spec            appsv1.ReplicaSetSpec
@@ -168,7 +174,12 @@ type ResourceQuota struct {
 	Status    v1.ResourceQuotaStatus
 }
 
-type Volume struct {
+type CronJob struct {
+	UID         types.UID
+	Name        string
+	Namespace   string
+	Labels      map[string]string
+	Annotations map[string]string
 }
 
 // GetPublicIPAddresses returns all external IP addresses associated with the node
@@ -304,9 +315,11 @@ func TransformService(input *v1.Service) *Service {
 
 func TransformDaemonSet(input *appsv1.DaemonSet) *DaemonSet {
 	return &DaemonSet{
+		UID:            input.UID,
 		Name:           input.Name,
 		Namespace:      input.Namespace,
 		Labels:         input.Labels,
+		Annotations:    input.Annotations,
 		SpecContainers: input.Spec.Template.Spec.Containers,
 	}
 }
@@ -376,10 +389,22 @@ func TransformStorageClass(input *stv1.StorageClass) *StorageClass {
 
 func TransformJob(input *batchv1.Job) *Job {
 	return &Job{
-		UID:       input.UID,
-		Name:      input.Name,
-		Namespace: input.Namespace,
-		Status:    input.Status,
+		UID:         input.UID,
+		Name:        input.Name,
+		Namespace:   input.Namespace,
+		Labels:      input.Labels,
+		Annotations: input.Annotations,
+		Status:      input.Status,
+	}
+}
+
+func TransformCronJob(input *batchv1.CronJob) *CronJob {
+	return &CronJob{
+		UID:         input.UID,
+		Name:        input.Name,
+		Namespace:   input.Namespace,
+		Labels:      input.Labels,
+		Annotations: input.Annotations,
 	}
 }
 
@@ -405,6 +430,8 @@ func TransformReplicaSet(input *appsv1.ReplicaSet) *ReplicaSet {
 		UID:             input.UID,
 		Name:            input.Name,
 		Namespace:       input.Namespace,
+		Labels:          input.Labels,
+		Annotations:     input.Annotations,
 		OwnerReferences: input.OwnerReferences,
 		Spec:            input.Spec,
 		SpecSelector:    input.Spec.Selector,
@@ -466,6 +493,9 @@ type ClusterCache interface {
 	// GetAllJobs returns all the cached jobs
 	GetAllJobs() []*Job
 
+	// GetAllCronJobs returns all the cached cronjobs
+	GetAllCronJobs() []*CronJob
+
 	// GetAllPodDisruptionBudgets returns all cached pod disruption budgets
 	GetAllPodDisruptionBudgets() []*PodDisruptionBudget
 

+ 15 - 0
core/pkg/clustercache/helper.go

@@ -0,0 +1,15 @@
+package clustercache
+
+func GetLoadBalancerIngressAddress(service *Service) []string {
+	var addresses []string
+	for _, loadBalancerIngress := range service.Status.LoadBalancer.Ingress {
+		address := loadBalancerIngress.IP
+		// Some cloud providers use hostname rather than IP
+		if address == "" {
+			address = loadBalancerIngress.Hostname
+		}
+		addresses = append(addresses, address)
+
+	}
+	return addresses
+}

+ 89 - 0
core/pkg/clustercache/helper_test.go

@@ -0,0 +1,89 @@
+package clustercache
+
+import (
+	"testing"
+
+	v1 "k8s.io/api/core/v1"
+)
+
+func TestGetLoadBalancerIngressAddress(t *testing.T) {
+	tests := []struct {
+		name     string
+		service  *Service
+		expected []string
+	}{
+		{
+			name:     "no ingresses",
+			service:  &Service{},
+			expected: nil,
+		},
+		{
+			name: "single IP ingress",
+			service: &Service{
+				Status: v1.ServiceStatus{
+					LoadBalancer: v1.LoadBalancerStatus{
+						Ingress: []v1.LoadBalancerIngress{
+							{IP: "1.2.3.4"},
+						},
+					},
+				},
+			},
+			expected: []string{"1.2.3.4"},
+		},
+		{
+			name: "single hostname ingress",
+			service: &Service{
+				Status: v1.ServiceStatus{
+					LoadBalancer: v1.LoadBalancerStatus{
+						Ingress: []v1.LoadBalancerIngress{
+							{Hostname: "lb.example.com"},
+						},
+					},
+				},
+			},
+			expected: []string{"lb.example.com"},
+		},
+		{
+			name: "IP takes priority over hostname",
+			service: &Service{
+				Status: v1.ServiceStatus{
+					LoadBalancer: v1.LoadBalancerStatus{
+						Ingress: []v1.LoadBalancerIngress{
+							{IP: "1.2.3.4", Hostname: "lb.example.com"},
+						},
+					},
+				},
+			},
+			expected: []string{"1.2.3.4"},
+		},
+		{
+			name: "multiple ingresses",
+			service: &Service{
+				Status: v1.ServiceStatus{
+					LoadBalancer: v1.LoadBalancerStatus{
+						Ingress: []v1.LoadBalancerIngress{
+							{IP: "1.2.3.4"},
+							{Hostname: "lb2.example.com"},
+							{IP: "5.6.7.8"},
+						},
+					},
+				},
+			},
+			expected: []string{"1.2.3.4", "lb2.example.com", "5.6.7.8"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := GetLoadBalancerIngressAddress(tt.service)
+			if len(got) != len(tt.expected) {
+				t.Fatalf("got %v, want %v", got, tt.expected)
+			}
+			for i := range tt.expected {
+				if got[i] != tt.expected[i] {
+					t.Errorf("index %d: got %q, want %q", i, got[i], tt.expected[i])
+				}
+			}
+		})
+	}
+}

+ 49 - 0
core/pkg/clustercache/mock.go

@@ -0,0 +1,49 @@
+package clustercache
+
+// MockClusterCache is a configurable implementation of ClusterCache for use in tests.
+// Each field corresponds to the slice returned by the matching GetAll* method.
+// Any field left nil causes its method to return nil.
+type MockClusterCache struct {
+	Nodes                  []*Node
+	Pods                   []*Pod
+	Namespaces             []*Namespace
+	Services               []*Service
+	DaemonSets             []*DaemonSet
+	Deployments            []*Deployment
+	StatefulSets           []*StatefulSet
+	ReplicaSets            []*ReplicaSet
+	PersistentVolumes      []*PersistentVolume
+	PersistentVolumeClaims []*PersistentVolumeClaim
+	StorageClasses         []*StorageClass
+	Jobs                   []*Job
+	CronJobs               []*CronJob
+	PodDisruptionBudgets   []*PodDisruptionBudget
+	ReplicationControllers []*ReplicationController
+	ResourceQuotas         []*ResourceQuota
+}
+
+func (m *MockClusterCache) Run()  {}
+func (m *MockClusterCache) Stop() {}
+
+func (m *MockClusterCache) GetAllNodes() []*Node                         { return m.Nodes }
+func (m *MockClusterCache) GetAllPods() []*Pod                           { return m.Pods }
+func (m *MockClusterCache) GetAllNamespaces() []*Namespace               { return m.Namespaces }
+func (m *MockClusterCache) GetAllServices() []*Service                   { return m.Services }
+func (m *MockClusterCache) GetAllDaemonSets() []*DaemonSet               { return m.DaemonSets }
+func (m *MockClusterCache) GetAllDeployments() []*Deployment             { return m.Deployments }
+func (m *MockClusterCache) GetAllStatefulSets() []*StatefulSet           { return m.StatefulSets }
+func (m *MockClusterCache) GetAllReplicaSets() []*ReplicaSet             { return m.ReplicaSets }
+func (m *MockClusterCache) GetAllPersistentVolumes() []*PersistentVolume { return m.PersistentVolumes }
+func (m *MockClusterCache) GetAllPersistentVolumeClaims() []*PersistentVolumeClaim {
+	return m.PersistentVolumeClaims
+}
+func (m *MockClusterCache) GetAllStorageClasses() []*StorageClass { return m.StorageClasses }
+func (m *MockClusterCache) GetAllJobs() []*Job                    { return m.Jobs }
+func (m *MockClusterCache) GetAllCronJobs() []*CronJob            { return m.CronJobs }
+func (m *MockClusterCache) GetAllPodDisruptionBudgets() []*PodDisruptionBudget {
+	return m.PodDisruptionBudgets
+}
+func (m *MockClusterCache) GetAllReplicationControllers() []*ReplicationController {
+	return m.ReplicationControllers
+}
+func (m *MockClusterCache) GetAllResourceQuotas() []*ResourceQuota { return m.ResourceQuotas }

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

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

+ 53 - 0
core/pkg/compute/kubemodel/cluster.go

@@ -0,0 +1,53 @@
+package kubemodel
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/model/shared"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func (km *KubeModel) computeCluster(kms *kubemodel.KubeModelSet, start, end time.Time) error {
+
+	grp := source.NewQueryGroup()
+	metrics := km.ds.Metrics()
+	clusterInfoResultFuture := source.WithGroup(grp, metrics.QueryClusterInfo(start, end))
+	clusterUptimeResultFuture := source.WithGroup(grp, metrics.QueryClusterUptime(start, end))
+
+	clusterMap := make(map[string]*kubemodel.Cluster)
+
+	clusterInfoResult, _ := clusterInfoResultFuture.Await()
+	for _, res := range clusterInfoResult {
+		clusterMap[res.UID] = &kubemodel.Cluster{
+			UID:      res.UID,
+			Provider: shared.ParseProvider(res.Provider),
+			Account:  res.AccountID,
+			Name:     res.Cluster,
+			Region:   res.Region,
+		}
+	}
+
+	clusterUptimeResult, _ := clusterUptimeResultFuture.Await()
+	for _, res := range clusterUptimeResult {
+		cluster, ok := clusterMap[res.UID]
+		if !ok {
+			log.Warnf("cluster with UID '%s' has not been initialized to add uptime", res.UID)
+			continue
+		}
+		s, e := res.GetStartEnd(start, end, km.ds.Resolution())
+		cluster.Start = s
+		cluster.End = e
+	}
+
+	cluster, ok := clusterMap[km.clusterUID]
+	if !ok {
+		return fmt.Errorf("failed to compute cluster with UID '%s'", km.clusterUID)
+	}
+
+	kms.RegisterCluster(cluster)
+
+	return nil
+}

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

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

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

@@ -0,0 +1,172 @@
+package kubemodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+// resourceUnitValue converts prometheus resource/unit strings from ResourceResult
+// into kubemodel types, applying any necessary unit conversions.
+func resourceUnitValue(resource, unit string, value float64) (kubemodel.Resource, kubemodel.Unit, float64) {
+	switch resource {
+	case "cpu":
+		return kubemodel.ResourceCPU, kubemodel.UnitCore, value
+	case "memory":
+		return kubemodel.ResourceMemory, kubemodel.UnitByte, value
+	default:
+		return kubemodel.Resource(resource), kubemodel.Unit(unit), value
+	}
+}
+
+func (km *KubeModel) computeContainers(kms *kubemodel.KubeModelSet, start, end time.Time) error {
+	grp := source.NewQueryGroup()
+	metrics := km.ds.Metrics()
+
+	containerUptimeFuture := source.WithGroup(grp, metrics.QueryContainerUptime(start, end))
+
+	containerResourceRequestsFuture := source.WithGroup(grp, metrics.QueryContainerResourceRequests(start, end))
+	containerResourceLimitsFuture := source.WithGroup(grp, metrics.QueryContainerResourceLimits(start, end))
+
+	cpuCoresAllocatedFuture := source.WithGroup(grp, metrics.QueryCPUCoresAllocated(start, end))
+	cpuUsageAvgFuture := source.WithGroup(grp, metrics.QueryCPUUsageAvg(start, end))
+	cpuUsageMaxFuture := source.WithGroup(grp, metrics.QueryCPUUsageMax(start, end))
+
+	ramBytesAllocatedFuture := source.WithGroup(grp, metrics.QueryRAMBytesAllocated(start, end))
+	ramUsageAvgFuture := source.WithGroup(grp, metrics.QueryRAMUsageAvg(start, end))
+	ramUsageMaxFuture := source.WithGroup(grp, metrics.QueryRAMUsageMax(start, end))
+
+	type containerKey struct {
+		podUID string
+		name   string
+	}
+
+	containerMap := make(map[containerKey]*kubemodel.Container)
+
+	containerUptimeResult, _ := containerUptimeFuture.Await()
+	for _, res := range containerUptimeResult {
+		key := containerKey{podUID: res.UID, name: res.Container}
+		s, e := res.GetStartEnd(start, end, km.ds.Resolution())
+		containerMap[key] = &kubemodel.Container{
+			PodUID:           res.UID,
+			Name:             res.Container,
+			ResourceRequests: make(kubemodel.ResourceQuantities),
+			ResourceLimits:   make(kubemodel.ResourceQuantities),
+			Start:            s,
+			End:              e,
+		}
+	}
+
+	containerResourceRequestsResult, _ := containerResourceRequestsFuture.Await()
+	for _, res := range containerResourceRequestsResult {
+		key := containerKey{podUID: res.UID, name: res.Container}
+		container, ok := containerMap[key]
+		if !ok {
+			log.Warnf("container %s/%s has not been initialized to add resource requests", res.UID, res.Container)
+			continue
+		}
+		resource, unit, value := resourceUnitValue(res.Resource, res.Unit, res.Value)
+		container.ResourceRequests.Set(resource, unit, kubemodel.StatAvg, value)
+	}
+
+	containerResourceLimitsResult, _ := containerResourceLimitsFuture.Await()
+	for _, res := range containerResourceLimitsResult {
+		key := containerKey{podUID: res.UID, name: res.Container}
+		container, ok := containerMap[key]
+		if !ok {
+			log.Warnf("container %s/%s has not been initialized to add resource limits", res.UID, res.Container)
+			continue
+		}
+		resource, unit, value := resourceUnitValue(res.Resource, res.Unit, res.Value)
+		container.ResourceLimits.Set(resource, unit, kubemodel.StatAvg, value)
+	}
+
+	cpuCoresAllocatedResult, _ := cpuCoresAllocatedFuture.Await()
+	for _, res := range cpuCoresAllocatedResult {
+		key := containerKey{podUID: res.UID, name: res.Container}
+		container, ok := containerMap[key]
+		if !ok {
+			log.Warnf("container %s/%s has not been initialized to add CPU cores allocated", res.UID, res.Container)
+			continue
+		}
+		if len(res.Data) > 0 {
+			container.CPUCoreAllocationAvg = res.Data[0].Value
+		}
+	}
+
+	ramBytesAllocatedResult, _ := ramBytesAllocatedFuture.Await()
+	for _, res := range ramBytesAllocatedResult {
+		key := containerKey{podUID: res.UID, name: res.Container}
+		container, ok := containerMap[key]
+		if !ok {
+			log.Warnf("container %s/%s has not been initialized to add RAM bytes allocated", res.UID, res.Container)
+			continue
+		}
+		if len(res.Data) > 0 {
+			container.RAMBytesAllocationAvg = res.Data[0].Value
+		}
+	}
+
+	cpuUsageAvgResult, _ := cpuUsageAvgFuture.Await()
+	for _, res := range cpuUsageAvgResult {
+		key := containerKey{podUID: res.UID, name: res.Container}
+		container, ok := containerMap[key]
+		if !ok {
+			log.Warnf("container %s/%s has not been initialized to add CPU usage avg", res.UID, res.Container)
+			continue
+		}
+		if len(res.Data) > 0 {
+			container.CPUCoreUsageAvg = res.Data[0].Value
+		}
+	}
+
+	cpuUsageMaxResult, _ := cpuUsageMaxFuture.Await()
+	for _, res := range cpuUsageMaxResult {
+		key := containerKey{podUID: res.UID, name: res.Container}
+		container, ok := containerMap[key]
+		if !ok {
+			log.Warnf("container %s/%s has not been initialized to add CPU usage max", res.UID, res.Container)
+			continue
+		}
+		if len(res.Data) > 0 {
+			container.CPUCoreUsageMax = res.Data[0].Value
+		}
+	}
+
+	ramUsageAvgResult, _ := ramUsageAvgFuture.Await()
+	for _, res := range ramUsageAvgResult {
+		key := containerKey{podUID: res.UID, name: res.Container}
+		container, ok := containerMap[key]
+		if !ok {
+			log.Warnf("container %s/%s has not been initialized to add RAM usage avg", res.UID, res.Container)
+			continue
+		}
+		if len(res.Data) > 0 {
+			container.RAMBytesUsageAvg = res.Data[0].Value
+		}
+	}
+
+	ramUsageMaxResult, _ := ramUsageMaxFuture.Await()
+	for _, res := range ramUsageMaxResult {
+		key := containerKey{podUID: res.UID, name: res.Container}
+		container, ok := containerMap[key]
+		if !ok {
+			log.Warnf("container %s/%s has not been initialized to add RAM usage max", res.UID, res.Container)
+			continue
+		}
+		if len(res.Data) > 0 {
+			container.RAMBytesUsageMax = res.Data[0].Value
+		}
+	}
+
+	for _, container := range containerMap {
+		err := kms.RegisterContainer(container)
+		if err != nil {
+			log.Warnf("Failed to register container: %s", err.Error())
+		}
+	}
+
+	return nil
+}

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

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

+ 71 - 0
core/pkg/compute/kubemodel/cronjob.go

@@ -0,0 +1,71 @@
+package kubemodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func (km *KubeModel) computeCronJobs(kms *kubemodel.KubeModelSet, start, end time.Time) error {
+	grp := source.NewQueryGroup()
+	metrics := km.ds.Metrics()
+
+	cronJobInfoResultFuture := source.WithGroup(grp, metrics.QueryCronJobInfo(start, end))
+	cronJobUptimeResultFuture := source.WithGroup(grp, metrics.QueryCronJobUptime(start, end))
+	cronJobLabelsResultFuture := source.WithGroup(grp, metrics.QueryCronJobLabels(start, end))
+	cronJobAnnotationsResultFuture := source.WithGroup(grp, metrics.QueryCronJobAnnotations(start, end))
+
+	cronJobMap := make(map[string]*kubemodel.CronJob)
+
+	cronJobInfoResult, _ := cronJobInfoResultFuture.Await()
+	for _, res := range cronJobInfoResult {
+		cronJobMap[res.UID] = &kubemodel.CronJob{
+			UID:          res.UID,
+			Name:         res.CronJob,
+			NamespaceUID: res.NamespaceUID,
+		}
+	}
+
+	cronJobUptimeResult, _ := cronJobUptimeResultFuture.Await()
+	for _, res := range cronJobUptimeResult {
+		cronJob, ok := cronJobMap[res.UID]
+		if !ok {
+			log.Warnf("cronjob with UID '%s' has not been initialized to add uptime", res.UID)
+			continue
+		}
+		s, e := res.GetStartEnd(start, end, km.ds.Resolution())
+		cronJob.Start = s
+		cronJob.End = e
+	}
+
+	cronJobLabelsResult, _ := cronJobLabelsResultFuture.Await()
+	for _, res := range cronJobLabelsResult {
+		cronJob, ok := cronJobMap[res.UID]
+		if !ok {
+			log.Warnf("cronjob with UID '%s' has not been initialized to add labels", res.UID)
+			continue
+		}
+		cronJob.Labels = res.Labels
+	}
+
+	cronJobAnnotationsResult, _ := cronJobAnnotationsResultFuture.Await()
+	for _, res := range cronJobAnnotationsResult {
+		cronJob, ok := cronJobMap[res.UID]
+		if !ok {
+			log.Warnf("cronjob with UID '%s' has not been initialized to add annotations", res.UID)
+			continue
+		}
+		cronJob.Annotations = res.Annotations
+	}
+
+	for _, cronJob := range cronJobMap {
+		err := kms.RegisterCronJob(cronJob)
+		if err != nil {
+			log.Warnf("Failed to register cronjob: %s", err.Error())
+		}
+	}
+
+	return nil
+}

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

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

+ 71 - 0
core/pkg/compute/kubemodel/daemonset.go

@@ -0,0 +1,71 @@
+package kubemodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func (km *KubeModel) computeDaemonSets(kms *kubemodel.KubeModelSet, start, end time.Time) error {
+	grp := source.NewQueryGroup()
+	metrics := km.ds.Metrics()
+
+	daemonSetInfoResultFuture := source.WithGroup(grp, metrics.QueryDaemonSetInfo(start, end))
+	daemonSetUptimeResultFuture := source.WithGroup(grp, metrics.QueryDaemonSetUptime(start, end))
+	daemonSetLabelsResultFuture := source.WithGroup(grp, metrics.QueryDaemonSetLabels(start, end))
+	daemonSetAnnotationsResultFuture := source.WithGroup(grp, metrics.QueryDaemonSetAnnotations(start, end))
+
+	daemonSetMap := make(map[string]*kubemodel.DaemonSet)
+
+	daemonSetInfoResult, _ := daemonSetInfoResultFuture.Await()
+	for _, res := range daemonSetInfoResult {
+		daemonSetMap[res.UID] = &kubemodel.DaemonSet{
+			UID:          res.UID,
+			Name:         res.DaemonSet,
+			NamespaceUID: res.NamespaceUID,
+		}
+	}
+
+	daemonSetUptimeResult, _ := daemonSetUptimeResultFuture.Await()
+	for _, res := range daemonSetUptimeResult {
+		daemonSet, ok := daemonSetMap[res.UID]
+		if !ok {
+			log.Warnf("daemonset with UID '%s' has not been initialized to add uptime", res.UID)
+			continue
+		}
+		s, e := res.GetStartEnd(start, end, km.ds.Resolution())
+		daemonSet.Start = s
+		daemonSet.End = e
+	}
+
+	daemonSetLabelsResult, _ := daemonSetLabelsResultFuture.Await()
+	for _, res := range daemonSetLabelsResult {
+		daemonSet, ok := daemonSetMap[res.UID]
+		if !ok {
+			log.Warnf("daemonset with UID '%s' has not been initialized to add labels", res.UID)
+			continue
+		}
+		daemonSet.Labels = res.Labels
+	}
+
+	daemonSetAnnotationsResult, _ := daemonSetAnnotationsResultFuture.Await()
+	for _, res := range daemonSetAnnotationsResult {
+		daemonSet, ok := daemonSetMap[res.UID]
+		if !ok {
+			log.Warnf("daemonset with UID '%s' has not been initialized to add annotations", res.UID)
+			continue
+		}
+		daemonSet.Annotations = res.Annotations
+	}
+
+	for _, daemonSet := range daemonSetMap {
+		err := kms.RegisterDaemonSet(daemonSet)
+		if err != nil {
+			log.Warnf("Failed to register daemonset: %s", err.Error())
+		}
+	}
+
+	return nil
+}

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

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

+ 89 - 0
core/pkg/compute/kubemodel/dcgmdevice.go

@@ -0,0 +1,89 @@
+package kubemodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func (km *KubeModel) computeDCGMDevices(kms *kubemodel.KubeModelSet, start, end time.Time) error {
+	grp := source.NewQueryGroup()
+	metrics := km.ds.Metrics()
+
+	dcgmInfoFuture := source.WithGroup(grp, metrics.QueryDCGMDeviceInfo(start, end))
+	dcgmUptimeFuture := source.WithGroup(grp, metrics.QueryDCGMDeviceUptime(start, end))
+	dcgmUsageAvgFuture := source.WithGroup(grp, metrics.QueryDCGMContainerUsageAvg(start, end))
+	dcgmUsageMaxFuture := source.WithGroup(grp, metrics.QueryDCGMContainerUsageMax(start, end))
+
+	deviceMap := make(map[string]*kubemodel.DCGMDevice)
+
+	dcgmInfoResult, _ := dcgmInfoFuture.Await()
+	for _, res := range dcgmInfoResult {
+		if res.UUID == "" {
+			continue
+		}
+		if _, ok := deviceMap[res.UUID]; ok {
+			continue
+		}
+		deviceMap[res.UUID] = &kubemodel.DCGMDevice{
+			UUID:      res.UUID,
+			Device:    res.Device,
+			ModelName: res.ModelName,
+			PodUsages: make(map[string]kubemodel.DCGMPod),
+		}
+	}
+
+	dcgmUptimeResult, _ := dcgmUptimeFuture.Await()
+	for _, res := range dcgmUptimeResult {
+		d, ok := deviceMap[res.UUID]
+		if !ok {
+			log.Warnf("DCGM uptime result for unknown device UUID '%s'", res.UUID)
+			continue
+		}
+		s, e := res.GetStartEnd(start, end, km.ds.Resolution())
+		d.Start = s
+		d.End = e
+	}
+
+	dcgmUsageAvgResult, _ := dcgmUsageAvgFuture.Await()
+	for _, res := range dcgmUsageAvgResult {
+		device, ok := deviceMap[res.UUID]
+		if !ok || res.PodUID == "" || res.Container == "" {
+			continue
+		}
+		pod, ok := device.PodUsages[res.PodUID]
+		if !ok {
+			pod = kubemodel.DCGMPod{ContainerUsages: make(map[string]kubemodel.DCGMContainer)}
+		}
+		c := pod.ContainerUsages[res.Container]
+		c.UsageAvg = res.Value
+		pod.ContainerUsages[res.Container] = c
+		device.PodUsages[res.PodUID] = pod
+	}
+
+	dcgmUsageMaxResult, _ := dcgmUsageMaxFuture.Await()
+	for _, res := range dcgmUsageMaxResult {
+		device, ok := deviceMap[res.UUID]
+		if !ok || res.PodUID == "" || res.Container == "" {
+			continue
+		}
+		pod, ok := device.PodUsages[res.PodUID]
+		if !ok {
+			pod = kubemodel.DCGMPod{ContainerUsages: make(map[string]kubemodel.DCGMContainer)}
+		}
+		c := pod.ContainerUsages[res.Container]
+		c.UsageMax = res.Value
+		pod.ContainerUsages[res.Container] = c
+		device.PodUsages[res.PodUID] = pod
+	}
+
+	for _, device := range deviceMap {
+		if err := kms.RegisterDCGMDevice(device); err != nil {
+			log.Warnf("Failed to register DCGM device: %s", err.Error())
+		}
+	}
+
+	return nil
+}

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

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

+ 82 - 0
core/pkg/compute/kubemodel/deployment.go

@@ -0,0 +1,82 @@
+package kubemodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func (km *KubeModel) computeDeployments(kms *kubemodel.KubeModelSet, start, end time.Time) error {
+	grp := source.NewQueryGroup()
+	metrics := km.ds.Metrics()
+
+	deploymentInfoResultFuture := source.WithGroup(grp, metrics.QueryDeploymentInfo(start, end))
+	deploymentUptimeResultFuture := source.WithGroup(grp, metrics.QueryDeploymentUptime(start, end))
+	deploymentLabelsResultFuture := source.WithGroup(grp, metrics.QueryDeploymentLabels(start, end))
+	deploymentAnnotationsResultFuture := source.WithGroup(grp, metrics.QueryDeploymentAnnotations(start, end))
+	deploymentMatchLabelsResultFuture := source.WithGroup(grp, metrics.QueryDeploymentMatchLabels(start, end))
+
+	deploymentMap := make(map[string]*kubemodel.Deployment)
+
+	deploymentInfoResult, _ := deploymentInfoResultFuture.Await()
+	for _, res := range deploymentInfoResult {
+		deploymentMap[res.UID] = &kubemodel.Deployment{
+			UID:          res.UID,
+			Name:         res.Deployment,
+			NamespaceUID: res.NamespaceUID,
+		}
+	}
+
+	deploymentUptimeResult, _ := deploymentUptimeResultFuture.Await()
+	for _, res := range deploymentUptimeResult {
+		deployment, ok := deploymentMap[res.UID]
+		if !ok {
+			log.Warnf("deployment with UID '%s' has not been initialized to add uptime", res.UID)
+			continue
+		}
+		s, e := res.GetStartEnd(start, end, km.ds.Resolution())
+		deployment.Start = s
+		deployment.End = e
+	}
+
+	deploymentLabelsResult, _ := deploymentLabelsResultFuture.Await()
+	for _, res := range deploymentLabelsResult {
+		deployment, ok := deploymentMap[res.UID]
+		if !ok {
+			log.Warnf("deployment with UID '%s' has not been initialized to add labels", res.UID)
+			continue
+		}
+		deployment.Labels = res.Labels
+	}
+
+	deploymentAnnotationsResult, _ := deploymentAnnotationsResultFuture.Await()
+	for _, res := range deploymentAnnotationsResult {
+		deployment, ok := deploymentMap[res.UID]
+		if !ok {
+			log.Warnf("deployment with UID '%s' has not been initialized to add annotations", res.UID)
+			continue
+		}
+		deployment.Annotations = res.Annotations
+	}
+
+	deploymentMatchLabelsResult, _ := deploymentMatchLabelsResultFuture.Await()
+	for _, res := range deploymentMatchLabelsResult {
+		deployment, ok := deploymentMap[res.UID]
+		if !ok {
+			log.Warnf("deployment with UID '%s' has not been initialized to add match labels", res.UID)
+			continue
+		}
+		deployment.MatchLabels = res.Labels
+	}
+
+	for _, deployment := range deploymentMap {
+		err := kms.RegisterDeployment(deployment)
+		if err != nil {
+			log.Warnf("Failed to register deployment: %s", err.Error())
+		}
+	}
+
+	return nil
+}

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

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

+ 71 - 0
core/pkg/compute/kubemodel/job.go

@@ -0,0 +1,71 @@
+package kubemodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func (km *KubeModel) computeJobs(kms *kubemodel.KubeModelSet, start, end time.Time) error {
+	grp := source.NewQueryGroup()
+	metrics := km.ds.Metrics()
+
+	jobInfoResultFuture := source.WithGroup(grp, metrics.QueryJobInfo(start, end))
+	jobUptimeResultFuture := source.WithGroup(grp, metrics.QueryJobUptime(start, end))
+	jobLabelsResultFuture := source.WithGroup(grp, metrics.QueryJobLabels(start, end))
+	jobAnnotationsResultFuture := source.WithGroup(grp, metrics.QueryJobAnnotations(start, end))
+
+	jobMap := make(map[string]*kubemodel.Job)
+
+	jobInfoResult, _ := jobInfoResultFuture.Await()
+	for _, res := range jobInfoResult {
+		jobMap[res.UID] = &kubemodel.Job{
+			UID:          res.UID,
+			Name:         res.Job,
+			NamespaceUID: res.NamespaceUID,
+		}
+	}
+
+	jobUptimeResult, _ := jobUptimeResultFuture.Await()
+	for _, res := range jobUptimeResult {
+		job, ok := jobMap[res.UID]
+		if !ok {
+			log.Warnf("job with UID '%s' has not been initialized to add uptime", res.UID)
+			continue
+		}
+		s, e := res.GetStartEnd(start, end, km.ds.Resolution())
+		job.Start = s
+		job.End = e
+	}
+
+	jobLabelsResult, _ := jobLabelsResultFuture.Await()
+	for _, res := range jobLabelsResult {
+		job, ok := jobMap[res.UID]
+		if !ok {
+			log.Warnf("job with UID '%s' has not been initialized to add labels", res.UID)
+			continue
+		}
+		job.Labels = res.Labels
+	}
+
+	jobAnnotationsResult, _ := jobAnnotationsResultFuture.Await()
+	for _, res := range jobAnnotationsResult {
+		job, ok := jobMap[res.UID]
+		if !ok {
+			log.Warnf("job with UID '%s' has not been initialized to add annotations", res.UID)
+			continue
+		}
+		job.Annotations = res.Annotations
+	}
+
+	for _, job := range jobMap {
+		err := kms.RegisterJob(job)
+		if err != nil {
+			log.Warnf("Failed to register job: %s", err.Error())
+		}
+	}
+
+	return nil
+}

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

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

+ 109 - 0
core/pkg/compute/kubemodel/kubemodel.go

@@ -0,0 +1,109 @@
+package kubemodel
+
+import (
+	"errors"
+	"fmt"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+type KubeModel struct {
+	ds         source.OpenCostDataSource
+	forceV1    bool
+	clusterUID string
+}
+
+func NewKubeModel(clusterUID string, forceV1 bool, dataSource source.OpenCostDataSource) (*KubeModel, error) {
+	if dataSource == nil {
+		return nil, errors.New("OpenCostDataSource cannot be nil")
+	}
+
+	km := &KubeModel{
+		ds:         dataSource,
+		forceV1:    forceV1,
+		clusterUID: clusterUID,
+	}
+
+	km.clusterUID = clusterUID
+
+	log.Debugf("NewKubeModel(%s)", km.clusterUID)
+
+	return km, nil
+}
+
+type computeFunc func(*kubemodel.KubeModelSet, time.Time, time.Time) error
+
+// ComputeKubeModel uses the CostModel instance to compute an KubeModelSet
+// for the window defined by the given start and end times. The KubeModels
+// returned are unaggregated (i.e. down to the container level).
+func (km *KubeModel) ComputeKubeModelSet(start, end time.Time) (*kubemodel.KubeModelSet, error) {
+	kms := kubemodel.NewKubeModelSet(start, end)
+
+	computeFuncs := km.computeFuncs(start, end)
+
+	for _, f := range computeFuncs {
+		if err := f(kms, start, end); err != nil {
+			kms.Error(err)
+			return kms, fmt.Errorf("error computing kubemodel for (%s, %s): %w", start.Format(time.DateTime), end.Format(time.DateTime), err)
+		}
+	}
+
+	kms.Metadata.CompletedAt = time.Now().UTC()
+
+	return kms, nil
+}
+
+// computeFuncs returns the set of compute functions to run for the window,
+// in struct field order. If cluster_info is not yet reporting the
+// complete_kubemodel label for any cluster in the result, the source has not
+// been upgraded to emit a full kubemodel, so only the minimal set of
+// resources (cluster, namespaces, resource quotas) is computed. The
+// FORCE_KUBEMODEL_V1 env var skips the check entirely and always returns the
+// minimal set.
+func (km *KubeModel) computeFuncs(start, end time.Time) []computeFunc {
+	KubeModelV1ComputeFucs := []computeFunc{
+		km.computeCluster,
+		km.computeNamespaces,
+		km.computeResourceQuotas,
+	}
+
+	if km.forceV1 {
+		return KubeModelV1ComputeFucs
+	}
+
+	results, err := km.ds.Metrics().QueryClusterKubeModelVersion(start, end).Await()
+	if err != nil {
+		log.Errorf("computeFuncs: querying cluster complete kubemodel: %s", err)
+		return KubeModelV1ComputeFucs
+	}
+
+	// If the window contains a result which is missing the version number then the window will produce incomplete KubeModel data
+	// and should not export
+	for _, res := range results {
+		if res.Version == "" {
+			return KubeModelV1ComputeFucs
+		}
+	}
+
+	return []computeFunc{
+		km.computeCluster,
+		km.computeNamespaces,
+		km.computeResourceQuotas,
+		km.computeServices,
+		km.computeDeployments,
+		km.computeStatefulSets,
+		km.computeDaemonSets,
+		km.computeJobs,
+		km.computeCronJobs,
+		km.computeReplicaSets,
+		km.computeNodes,
+		km.computePersistentVolumes,
+		km.computePersistentVolumeClaims,
+		km.computePods,
+		km.computeContainers,
+		//km.computeDCGMDevices,
+	}
+}

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

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

+ 71 - 0
core/pkg/compute/kubemodel/namespace.go

@@ -0,0 +1,71 @@
+package kubemodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func (km *KubeModel) computeNamespaces(kms *kubemodel.KubeModelSet, start, end time.Time) error {
+	grp := source.NewQueryGroup()
+	metrics := km.ds.Metrics()
+
+	nsInfoResultFuture := source.WithGroup(grp, metrics.QueryNamespaceInfo(start, end))
+	nsUptimeResultFuture := source.WithGroup(grp, metrics.QueryNamespaceUptime(start, end))
+	nsLabelsResultFuture := source.WithGroup(grp, metrics.QueryNamespaceLabels(start, end))
+	nsAnnosResultFuture := source.WithGroup(grp, metrics.QueryNamespaceAnnotations(start, end))
+
+	nsMap := make(map[string]*kubemodel.Namespace)
+
+	// Initialize namespaces from info
+	nsInfoResult, _ := nsInfoResultFuture.Await()
+	for _, res := range nsInfoResult {
+		nsMap[res.UID] = &kubemodel.Namespace{
+			UID:  res.UID,
+			Name: res.Namespace,
+		}
+	}
+
+	nsUptimeResult, _ := nsUptimeResultFuture.Await()
+	for _, res := range nsUptimeResult {
+		ns, ok := nsMap[res.UID]
+		if !ok {
+			log.Warnf("namespace with UID '%s' has not been initialized to add uptime", res.UID)
+			continue
+		}
+		s, e := res.GetStartEnd(start, end, km.ds.Resolution())
+		ns.Start = s
+		ns.End = e
+	}
+
+	nsLabelsResult, _ := nsLabelsResultFuture.Await()
+	for _, res := range nsLabelsResult {
+		ns, ok := nsMap[res.UID]
+		if !ok {
+			log.Warnf("namespace with UID '%s' has not been initialized to add labels", res.UID)
+			continue
+		}
+		ns.Labels = res.Labels
+	}
+
+	nsAnnosResult, _ := nsAnnosResultFuture.Await()
+	for _, res := range nsAnnosResult {
+		ns, ok := nsMap[res.UID]
+		if !ok {
+			log.Warnf("namespace with UID '%s' has not been initialized to add annotations", res.UID)
+			continue
+		}
+		ns.Annotations = res.Annotations
+	}
+
+	for _, namespace := range nsMap {
+		err := kms.RegisterNamespace(namespace)
+		if err != nil {
+			log.Warnf("Failed to register namespace: %s", err.Error())
+		}
+	}
+
+	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, false, ds)
+			require.NoError(t, err)
+
+			kms, err := km.ComputeKubeModelSet(start, end)
+			require.NoError(t, err)
+
+			assert.Equal(t, tt.want, kms.Namespaces)
+		})
+	}
+}

+ 114 - 0
core/pkg/compute/kubemodel/node.go

@@ -0,0 +1,114 @@
+package kubemodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func (km *KubeModel) computeNodes(kms *kubemodel.KubeModelSet, start, end time.Time) error {
+	grp := source.NewQueryGroup()
+	metrics := km.ds.Metrics()
+
+	nodeInfoResultFuture := source.WithGroup(grp, metrics.QueryNodeInfo(start, end))
+	nodeUptimeResultFuture := source.WithGroup(grp, metrics.QueryNodeUptime(start, end))
+	nodeLabelsResultFuture := source.WithGroup(grp, metrics.QueryNodeLabels(start, end))
+	nodeResourceCapacitiesFuture := source.WithGroup(grp, metrics.QueryNodeResourceCapacities(start, end))
+	nodeResourcesAllocatableFuture := source.WithGroup(grp, metrics.QueryNodeResourcesAllocatable(start, end))
+
+	localStorageBytesFuture := source.WithGroup(grp, metrics.QueryKMLocalStorageBytes(start, end))
+	localStorageUsedAvgFuture := source.WithGroup(grp, metrics.QueryKMLocalStorageUsedAvg(start, end))
+	localStorageUsedMaxFuture := source.WithGroup(grp, metrics.QueryKMLocalStorageUsedMax(start, end))
+
+	nodeMap := make(map[string]*kubemodel.Node)
+
+	nodeInfoResult, _ := nodeInfoResultFuture.Await()
+	for _, res := range nodeInfoResult {
+		nodeMap[res.UID] = &kubemodel.Node{
+			UID:                  res.UID,
+			ProviderID:           res.ProviderID,
+			Name:                 res.Node,
+			ResourceCapacities:   make(kubemodel.ResourceQuantities),
+			ResourcesAllocatable: make(kubemodel.ResourceQuantities),
+		}
+	}
+
+	nodeUptimeResult, _ := nodeUptimeResultFuture.Await()
+	for _, res := range nodeUptimeResult {
+		node, ok := nodeMap[res.UID]
+		if !ok {
+			log.Warnf("node with UID '%s' has not been initialized to add uptime", res.UID)
+			continue
+		}
+		s, e := res.GetStartEnd(start, end, km.ds.Resolution())
+		node.Start = s
+		node.End = e
+	}
+
+	nodeResourceCapacitiesResult, _ := nodeResourceCapacitiesFuture.Await()
+	for _, res := range nodeResourceCapacitiesResult {
+		node, ok := nodeMap[res.UID]
+		if !ok {
+			log.Warnf("node with UID '%s' has not been initialized to add resource capacities", res.UID)
+			continue
+		}
+		resource, unit, value := resourceUnitValue(res.Resource, res.Unit, res.Value)
+		node.ResourceCapacities.Set(resource, unit, kubemodel.StatAvg, value)
+	}
+
+	nodeResourcesAllocatableResult, _ := nodeResourcesAllocatableFuture.Await()
+	for _, res := range nodeResourcesAllocatableResult {
+		node, ok := nodeMap[res.UID]
+		if !ok {
+			log.Warnf("node with UID '%s' has not been initialized to add resources allocatable", res.UID)
+			continue
+		}
+		resource, unit, value := resourceUnitValue(res.Resource, res.Unit, res.Value)
+		node.ResourcesAllocatable.Set(resource, unit, kubemodel.StatAvg, value)
+	}
+
+	nodeLabelsResult, _ := nodeLabelsResultFuture.Await()
+	for _, res := range nodeLabelsResult {
+		node, ok := nodeMap[res.UID]
+		if !ok {
+			log.Warnf("node with UID '%s' has not been initialized to add labels", res.UID)
+			continue
+		}
+		node.Labels = res.Labels
+	}
+
+	localStorageBytesResult, _ := localStorageBytesFuture.Await()
+	for _, res := range localStorageBytesResult {
+		node, ok := nodeMap[res.UID]
+		if ok {
+			node.FileSystem.CapacityBytes = res.Value
+		}
+	}
+
+	localStorageUsedAvgResult, _ := localStorageUsedAvgFuture.Await()
+	for _, res := range localStorageUsedAvgResult {
+		node, ok := nodeMap[res.UID]
+		if ok {
+			node.FileSystem.UsageByteAvg = res.Value
+		}
+	}
+
+	localStorageUsedMaxResult, _ := localStorageUsedMaxFuture.Await()
+	for _, res := range localStorageUsedMaxResult {
+		node, ok := nodeMap[res.UID]
+		if ok {
+			node.FileSystem.UsageByteMax = res.Value
+		}
+	}
+
+	for _, node := range nodeMap {
+		err := kms.RegisterNode(node)
+		if err != nil {
+			log.Warnf("Failed to register node: %s", err.Error())
+		}
+	}
+
+	return nil
+}

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

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

+ 63 - 0
core/pkg/compute/kubemodel/persistentvolume.go

@@ -0,0 +1,63 @@
+package kubemodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func (km *KubeModel) computePersistentVolumes(kms *kubemodel.KubeModelSet, start, end time.Time) error {
+	grp := source.NewQueryGroup()
+	metrics := km.ds.Metrics()
+
+	pvInfoResultFuture := source.WithGroup(grp, metrics.QueryKMPVInfo(start, end))
+	pvUptimeResultFuture := source.WithGroup(grp, metrics.QueryPVUptime(start, end))
+	pvBytesResultFuture := source.WithGroup(grp, metrics.QueryPVBytes(start, end))
+
+	pvMap := make(map[string]*kubemodel.PersistentVolume)
+
+	pvInfoResult, _ := pvInfoResultFuture.Await()
+	for _, res := range pvInfoResult {
+		pvMap[res.UID] = &kubemodel.PersistentVolume{
+			UID:             res.UID,
+			Name:            res.PersistentVolume,
+			StorageClass:    res.StorageClass,
+			CSIVolumeHandle: res.CSIVolumeHandle,
+		}
+	}
+
+	pvUptimeResult, _ := pvUptimeResultFuture.Await()
+	for _, res := range pvUptimeResult {
+		pv, ok := pvMap[res.UID]
+		if !ok {
+			log.Warnf("persistent volume with UID '%s' has not been initialized to add uptime", res.UID)
+			continue
+		}
+		s, e := res.GetStartEnd(start, end, km.ds.Resolution())
+		pv.Start = s
+		pv.End = e
+	}
+
+	pvBytesResult, _ := pvBytesResultFuture.Await()
+	for _, res := range pvBytesResult {
+		pv, ok := pvMap[res.UID]
+		if !ok {
+			log.Warnf("persistent volume with UID '%s' has not been initialized to add bytes", res.UID)
+			continue
+		}
+
+		pv.SizeBytes = res.Value
+
+	}
+
+	for _, pv := range pvMap {
+		err := kms.RegisterPersistentVolume(pv)
+		if err != nil {
+			log.Warnf("Failed to register persistent volume: %s", err.Error())
+		}
+	}
+
+	return nil
+}

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

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

+ 86 - 0
core/pkg/compute/kubemodel/persistentvolumeclaim.go

@@ -0,0 +1,86 @@
+package kubemodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func (km *KubeModel) computePersistentVolumeClaims(kms *kubemodel.KubeModelSet, start, end time.Time) error {
+	grp := source.NewQueryGroup()
+	metrics := km.ds.Metrics()
+
+	pvcInfoResultFuture := source.WithGroup(grp, metrics.QueryKMPVCInfo(start, end))
+	pvcUptimeResultFuture := source.WithGroup(grp, metrics.QueryPVCUptime(start, end))
+	pvcBytesRequestedResultFuture := source.WithGroup(grp, metrics.QueryPVCBytesRequested(start, end))
+	pvcBytesUsedAvgResultFuture := source.WithGroup(grp, metrics.QueryPVCBytesUsedAverage(start, end))
+	pvcBytesUsedMaxResultFuture := source.WithGroup(grp, metrics.QueryPVCBytesUsedMax(start, end))
+
+	pvcMap := make(map[string]*kubemodel.PersistentVolumeClaim)
+
+	pvcInfoResult, _ := pvcInfoResultFuture.Await()
+	for _, res := range pvcInfoResult {
+		pvcMap[res.UID] = &kubemodel.PersistentVolumeClaim{
+			UID:                 res.UID,
+			Name:                res.PersistentVolumeClaim,
+			NamespaceUID:        res.NamespaceUID,
+			PersistentVolumeUID: res.PVUID,
+			StorageClass:        res.StorageClass,
+		}
+	}
+
+	pvcUptimeResult, _ := pvcUptimeResultFuture.Await()
+	for _, res := range pvcUptimeResult {
+		pvc, ok := pvcMap[res.UID]
+		if !ok {
+			log.Warnf("persistent volume claim with UID '%s' has not been initialized to add uptime", res.UID)
+			continue
+		}
+		s, e := res.GetStartEnd(start, end, km.ds.Resolution())
+		pvc.Start = s
+		pvc.End = e
+	}
+
+	pvcBytesRequestedResult, _ := pvcBytesRequestedResultFuture.Await()
+	for _, res := range pvcBytesRequestedResult {
+		pvc, ok := pvcMap[res.UID]
+		if !ok {
+			log.Warnf("persistent volume claim with UID '%s' has not been initialized to add requested bytes", res.UID)
+			continue
+		}
+		if len(res.Data) > 0 {
+			pvc.RequestedBytes = res.Data[0].Value
+		}
+	}
+
+	pvcBytesUsedAvgResult, _ := pvcBytesUsedAvgResultFuture.Await()
+	for _, res := range pvcBytesUsedAvgResult {
+		pvc, ok := pvcMap[res.UID]
+		if !ok {
+			log.Warnf("persistent volume claim with UID '%s' has not been initialized to add bytes used average", res.UID)
+			continue
+		}
+		pvc.UsageBytesAvg = res.Value
+	}
+
+	pvcBytesUsedMaxResult, _ := pvcBytesUsedMaxResultFuture.Await()
+	for _, res := range pvcBytesUsedMaxResult {
+		pvc, ok := pvcMap[res.UID]
+		if !ok {
+			log.Warnf("persistent volume claim with UID '%s' has not been initialized to add bytes used max", res.UID)
+			continue
+		}
+		pvc.UsageBytesMax = res.Value
+	}
+
+	for _, pvc := range pvcMap {
+		err := kms.RegisterPVC(pvc)
+		if err != nil {
+			log.Warnf("Failed to register persistent volume claim: %s", err.Error())
+		}
+	}
+
+	return nil
+}

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

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

+ 150 - 0
core/pkg/compute/kubemodel/pod.go

@@ -0,0 +1,150 @@
+package kubemodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func (km *KubeModel) computePods(kms *kubemodel.KubeModelSet, start, end time.Time) error {
+	grp := source.NewQueryGroup()
+	metrics := km.ds.Metrics()
+
+	podInfoResultFuture := source.WithGroup(grp, metrics.QueryPodInfo(start, end))
+	podUptimeResultFuture := source.WithGroup(grp, metrics.QueryPodUptime(start, end))
+	podOwnerResultFuture := source.WithGroup(grp, metrics.QueryPodOwners(start, end))
+	podPVCVolumesResultFuture := source.WithGroup(grp, metrics.QueryPodPVCVolumes(start, end))
+	podLabelsResultFuture := source.WithGroup(grp, metrics.QueryPodLabels(start, end))
+	podAnnosResultFuture := source.WithGroup(grp, metrics.QueryPodAnnotations(start, end))
+
+	podNetworkEgressBytesResultFuture := source.WithGroup(grp, metrics.QueryPodNetworkEgressBytes(start, end))
+	podNetworkIngressBytesResultFuture := source.WithGroup(grp, metrics.QueryPodNetworkIngressBytes(start, end))
+
+	podMap := make(map[string]*kubemodel.Pod)
+
+	podInfoResult, _ := podInfoResultFuture.Await()
+	for _, res := range podInfoResult {
+		podMap[res.UID] = &kubemodel.Pod{
+			UID:          res.UID,
+			Name:         res.Pod,
+			NamespaceUID: res.NamespaceUID,
+			NodeUID:      res.NodeUID,
+		}
+	}
+
+	podUptimeResult, _ := podUptimeResultFuture.Await()
+	for _, res := range podUptimeResult {
+		pod, ok := podMap[res.UID]
+		if !ok {
+			log.Warnf("pod with UID '%s' has not been initialized to add uptime", res.UID)
+			continue
+		}
+		s, e := res.GetStartEnd(start, end, km.ds.Resolution())
+		pod.Start = s
+		pod.End = e
+	}
+
+	podOwnersResult, _ := podOwnerResultFuture.Await()
+	for _, res := range podOwnersResult {
+		pod, ok := podMap[res.UID]
+		if !ok {
+			log.Warnf("pod with UID '%s' has not been initialized to add labels", res.UID)
+			continue
+		}
+		pod.Owners = append(pod.Owners, kubemodel.Owner{
+			UID:        res.OwnerUID,
+			Kind:       kubemodel.ParseOwnerKind(res.OwnerKind),
+			Controller: res.Controller,
+		})
+	}
+
+	podPVCVolumesResult, _ := podPVCVolumesResultFuture.Await()
+	for _, res := range podPVCVolumesResult {
+		pod, ok := podMap[res.UID]
+		if !ok {
+			log.Warnf("pod with UID '%s' has not been initialized to add PVC volumes", res.UID)
+			continue
+		}
+		pod.PVCVolumes = append(pod.PVCVolumes, kubemodel.PodPVCVolume{
+			Name:                     res.PodVolumeName,
+			PersistentVolumeClaimUID: res.PVCUID,
+		})
+	}
+
+	podLabelsResult, _ := podLabelsResultFuture.Await()
+	for _, res := range podLabelsResult {
+		pod, ok := podMap[res.UID]
+		if !ok {
+			log.Warnf("pod with UID '%s' has not been initialized to add labels", res.UID)
+			continue
+		}
+		pod.Labels = res.Labels
+	}
+
+	podAnnosResult, _ := podAnnosResultFuture.Await()
+	for _, res := range podAnnosResult {
+		pod, ok := podMap[res.UID]
+		if !ok {
+			log.Warnf("pod with UID '%s' has not been initialized to add annotations", res.UID)
+			continue
+		}
+		pod.Annotations = res.Annotations
+	}
+
+	appendDetail := func(uid string, dir kubemodel.TrafficDirection, tt kubemodel.TrafficType, isNatGateway bool, endpoint string, bytes float64) {
+		pod, ok := podMap[uid]
+		if !ok || bytes <= 0 {
+			return
+		}
+		pod.NetworkTrafficDetails = append(pod.NetworkTrafficDetails, kubemodel.NetworkTrafficDetail{
+			PodUID:           uid,
+			TrafficDirection: dir,
+			TrafficType:      tt,
+			IsNatGateway:     isNatGateway,
+			Endpoint:         endpoint,
+			Bytes:            bytes,
+		})
+	}
+
+	networkTrafficType := func(res *source.PodNetworkBytesResult) (kubemodel.TrafficType, bool) {
+		if res.Internet {
+			return kubemodel.TrafficTypeInternet, true
+		}
+		if !res.SameRegion {
+			return kubemodel.TrafficTypeCrossRegion, true
+		}
+		if !res.SameZone {
+			return kubemodel.TrafficTypeCrossZone, true
+		}
+		return "", false
+	}
+
+	podNetworkEgressResult, _ := podNetworkEgressBytesResultFuture.Await()
+	for _, res := range podNetworkEgressResult {
+		tt, ok := networkTrafficType(res)
+		if !ok {
+			continue
+		}
+		appendDetail(res.UID, kubemodel.TrafficDirectionEgress, tt, res.NatGateway, res.Service, res.Value)
+	}
+
+	podNetworkIngressResult, _ := podNetworkIngressBytesResultFuture.Await()
+	for _, res := range podNetworkIngressResult {
+		tt, ok := networkTrafficType(res)
+		if !ok {
+			continue
+		}
+		appendDetail(res.UID, kubemodel.TrafficDirectionIngress, tt, res.NatGateway, res.Service, res.Value)
+	}
+
+	for _, pod := range podMap {
+		err := kms.RegisterPod(pod)
+		if err != nil {
+			log.Warnf("Failed to register pod: %s", err.Error())
+		}
+	}
+
+	return nil
+}

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

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

+ 86 - 0
core/pkg/compute/kubemodel/replicaset.go

@@ -0,0 +1,86 @@
+package kubemodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func (km *KubeModel) computeReplicaSets(kms *kubemodel.KubeModelSet, start, end time.Time) error {
+	grp := source.NewQueryGroup()
+	metrics := km.ds.Metrics()
+
+	replicaSetInfoResultFuture := source.WithGroup(grp, metrics.QueryReplicaSetInfo(start, end))
+	replicaSetUptimeResultFuture := source.WithGroup(grp, metrics.QueryReplicaSetUptime(start, end))
+	replicaSetOwnerResultFuture := source.WithGroup(grp, metrics.QueryReplicaSetOwners(start, end))
+	replicaSetLabelsResultFuture := source.WithGroup(grp, metrics.QueryReplicaSetLabels(start, end))
+	replicaSetAnnotationsResultFuture := source.WithGroup(grp, metrics.QueryReplicaSetAnnotations(start, end))
+
+	replicaSetMap := make(map[string]*kubemodel.ReplicaSet)
+
+	replicaSetInfoResult, _ := replicaSetInfoResultFuture.Await()
+	for _, res := range replicaSetInfoResult {
+		replicaSetMap[res.UID] = &kubemodel.ReplicaSet{
+			UID:          res.UID,
+			Name:         res.ReplicaSet,
+			NamespaceUID: res.NamespaceUID,
+		}
+	}
+
+	replicaSetUptimeResult, _ := replicaSetUptimeResultFuture.Await()
+	for _, res := range replicaSetUptimeResult {
+		replicaSet, ok := replicaSetMap[res.UID]
+		if !ok {
+			log.Warnf("replicaset with UID '%s' has not been initialized to add uptime", res.UID)
+			continue
+		}
+		s, e := res.GetStartEnd(start, end, km.ds.Resolution())
+		replicaSet.Start = s
+		replicaSet.End = e
+	}
+
+	replicaSetOwnersResult, _ := replicaSetOwnerResultFuture.Await()
+	for _, res := range replicaSetOwnersResult {
+		replicaSet, ok := replicaSetMap[res.UID]
+		if !ok {
+			log.Warnf("replicaset with UID '%s' has not been initialized to add owner", res.UID)
+			continue
+		}
+		replicaSet.Owners = append(replicaSet.Owners, kubemodel.Owner{
+			UID:        res.OwnerUID,
+			Kind:       kubemodel.ParseOwnerKind(res.OwnerKind),
+			Controller: res.Controller,
+		})
+	}
+
+	replicaSetLabelsResult, _ := replicaSetLabelsResultFuture.Await()
+	for _, res := range replicaSetLabelsResult {
+		replicaSet, ok := replicaSetMap[res.UID]
+		if !ok {
+			log.Warnf("replicaset with UID '%s' has not been initialized to add labels", res.UID)
+			continue
+		}
+		replicaSet.Labels = res.Labels
+	}
+
+	replicaSetAnnotationsResult, _ := replicaSetAnnotationsResultFuture.Await()
+	for _, res := range replicaSetAnnotationsResult {
+		replicaSet, ok := replicaSetMap[res.UID]
+		if !ok {
+			log.Warnf("replicaset with UID '%s' has not been initialized to add annotations", res.UID)
+			continue
+		}
+		replicaSet.Annotations = res.Annotations
+	}
+
+	for _, replicaSet := range replicaSetMap {
+		err := kms.RegisterReplicaSet(replicaSet)
+		if err != nil {
+			log.Warnf("Failed to register replicaset: %s", err.Error())
+		}
+	}
+
+	return nil
+}

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

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

+ 262 - 0
core/pkg/compute/kubemodel/resourcequota.go

@@ -0,0 +1,262 @@
+package kubemodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func (km *KubeModel) computeResourceQuotas(kms *kubemodel.KubeModelSet, start, end time.Time) error {
+	grp := source.NewQueryGroup()
+	metrics := km.ds.Metrics()
+
+	rqInfoResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaInfo(start, end))
+	rqUptimeResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaUptime(start, end))
+
+	// spec.hard.requests
+	rqSpecCPURequestAverageResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaSpecCPURequestAverage(start, end))
+	rqSpecCPURequestMaxResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaSpecCPURequestMax(start, end))
+	rqSpecRAMRequestAverageResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaSpecRAMRequestAverage(start, end))
+	rqSpecRAMRequestMaxResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaSpecRAMRequestMax(start, end))
+
+	// spec.hard.limits
+	rqSpecCPULimitAverageResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaSpecCPULimitAverage(start, end))
+	rqSpecCPULimitMaxResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaSpecCPULimitMax(start, end))
+	rqSpecRAMLimitAverageResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaSpecRAMLimitAverage(start, end))
+	rqSpecRAMLimitMaxResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaSpecRAMLimitMax(start, end))
+
+	// status.used.requests
+	rqStatusUsedCPURequestAverageResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaStatusUsedCPURequestAverage(start, end))
+	rqStatusUsedCPURequestMaxResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaStatusUsedCPURequestMax(start, end))
+	rqStatusUsedRAMRequestAverageResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaStatusUsedRAMRequestAverage(start, end))
+	rqStatusUsedRAMRequestMaxResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaStatusUsedRAMRequestMax(start, end))
+
+	// status.used.limits
+	rqStatusUsedCPULimitAverageResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaStatusUsedCPULimitAverage(start, end))
+	rqStatusUsedCPULimitMaxResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaStatusUsedCPULimitMax(start, end))
+	rqStatusUsedRAMLimitAverageResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaStatusUsedRAMLimitAverage(start, end))
+	rqStatusUsedRAMLimitMaxResultFuture := source.WithGroup(grp, metrics.QueryResourceQuotaStatusUsedRAMLimitMax(start, end))
+
+	rqMap := make(map[string]*kubemodel.ResourceQuota)
+
+	// Initialize resource quotas from info
+	rqInfoResult, _ := rqInfoResultFuture.Await()
+	for _, res := range rqInfoResult {
+		rqMap[res.UID] = &kubemodel.ResourceQuota{
+			UID:          res.UID,
+			Name:         res.ResourceQuota,
+			NamespaceUID: res.NamespaceUID,
+			Spec:         &kubemodel.ResourceQuotaSpec{Hard: &kubemodel.ResourceQuotaSpecHard{}},
+			Status:       &kubemodel.ResourceQuotaStatus{Used: &kubemodel.ResourceQuotaStatusUsed{}},
+		}
+	}
+
+	rqUptimeResult, _ := rqUptimeResultFuture.Await()
+	for _, res := range rqUptimeResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add uptime", res.UID)
+			continue
+		}
+		s, e := res.GetStartEnd(start, end, km.ds.Resolution())
+		rq.Start = s
+		rq.End = e
+	}
+
+	rqSpecCPURequestAverageResult, _ := rqSpecCPURequestAverageResultFuture.Await()
+	for _, res := range rqSpecCPURequestAverageResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add spec CPU request average", res.UID)
+			continue
+		}
+
+		mcpu := res.Value * 1000
+		rq.Spec.Hard.SetRequest(kubemodel.ResourceCPU, kubemodel.UnitMillicore, kubemodel.StatAvg, mcpu)
+
+	}
+
+	rqSpecCPURequestMaxResult, _ := rqSpecCPURequestMaxResultFuture.Await()
+	for _, res := range rqSpecCPURequestMaxResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add spec CPU request max", res.UID)
+			continue
+		}
+
+		mcpu := res.Value * 1000
+		rq.Spec.Hard.SetRequest(kubemodel.ResourceCPU, kubemodel.UnitMillicore, kubemodel.StatMax, mcpu)
+	}
+
+	rqSpecRAMRequestAverageResult, _ := rqSpecRAMRequestAverageResultFuture.Await()
+	for _, res := range rqSpecRAMRequestAverageResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add spec RAM request average", res.UID)
+			continue
+		}
+
+		rq.Spec.Hard.SetRequest(kubemodel.ResourceMemory, kubemodel.UnitByte, kubemodel.StatAvg, res.Value)
+	}
+
+	rqSpecRAMRequestMaxResult, _ := rqSpecRAMRequestMaxResultFuture.Await()
+	for _, res := range rqSpecRAMRequestMaxResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add spec RAM request max", res.UID)
+			continue
+		}
+
+		rq.Spec.Hard.SetRequest(kubemodel.ResourceMemory, kubemodel.UnitByte, kubemodel.StatMax, res.Value)
+	}
+
+	rqSpecCPULimitAverageResult, _ := rqSpecCPULimitAverageResultFuture.Await()
+	for _, res := range rqSpecCPULimitAverageResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add spec CPU limit average", res.UID)
+			continue
+		}
+
+		mcpu := res.Value * 1000
+		rq.Spec.Hard.SetLimit(kubemodel.ResourceCPU, kubemodel.UnitMillicore, kubemodel.StatAvg, mcpu)
+
+	}
+
+	rqSpecCPULimitMaxResult, _ := rqSpecCPULimitMaxResultFuture.Await()
+	for _, res := range rqSpecCPULimitMaxResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add spec CPU limit max", res.UID)
+			continue
+		}
+
+		mcpu := res.Value * 1000
+		rq.Spec.Hard.SetLimit(kubemodel.ResourceCPU, kubemodel.UnitMillicore, kubemodel.StatMax, mcpu)
+	}
+
+	rqSpecRAMLimitAverageResult, _ := rqSpecRAMLimitAverageResultFuture.Await()
+	for _, res := range rqSpecRAMLimitAverageResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add spec RAM limit average", res.UID)
+			continue
+		}
+
+		rq.Spec.Hard.SetLimit(kubemodel.ResourceMemory, kubemodel.UnitByte, kubemodel.StatAvg, res.Value)
+	}
+
+	rqSpecRAMLimitMaxResult, _ := rqSpecRAMLimitMaxResultFuture.Await()
+	for _, res := range rqSpecRAMLimitMaxResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add spec RAM limit max", res.UID)
+			continue
+		}
+
+		rq.Spec.Hard.SetLimit(kubemodel.ResourceMemory, kubemodel.UnitByte, kubemodel.StatMax, res.Value)
+	}
+
+	rqStatusUsedCPURequestAverageResult, _ := rqStatusUsedCPURequestAverageResultFuture.Await()
+	for _, res := range rqStatusUsedCPURequestAverageResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add status CPU request average", res.UID)
+			continue
+		}
+
+		mcpu := res.Value * 1000
+		rq.Status.Used.SetRequest(kubemodel.ResourceCPU, kubemodel.UnitMillicore, kubemodel.StatAvg, mcpu)
+	}
+
+	rqStatusUsedCPURequestMaxResult, _ := rqStatusUsedCPURequestMaxResultFuture.Await()
+	for _, res := range rqStatusUsedCPURequestMaxResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add status CPU request max", res.UID)
+			continue
+		}
+
+		mcpu := res.Value * 1000
+		rq.Status.Used.SetRequest(kubemodel.ResourceCPU, kubemodel.UnitMillicore, kubemodel.StatMax, mcpu)
+	}
+
+	rqStatusUsedRAMRequestAverageResult, _ := rqStatusUsedRAMRequestAverageResultFuture.Await()
+	for _, res := range rqStatusUsedRAMRequestAverageResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add status RAM request average", res.UID)
+			continue
+		}
+
+		rq.Status.Used.SetRequest(kubemodel.ResourceMemory, kubemodel.UnitByte, kubemodel.StatAvg, res.Value)
+	}
+
+	rqStatusUsedRAMRequestMaxResult, _ := rqStatusUsedRAMRequestMaxResultFuture.Await()
+	for _, res := range rqStatusUsedRAMRequestMaxResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add status RAM request max", res.UID)
+			continue
+		}
+
+		rq.Status.Used.SetRequest(kubemodel.ResourceMemory, kubemodel.UnitByte, kubemodel.StatMax, res.Value)
+	}
+
+	rqStatusUsedCPULimitAverageResult, _ := rqStatusUsedCPULimitAverageResultFuture.Await()
+	for _, res := range rqStatusUsedCPULimitAverageResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add status CPU limit average", res.UID)
+			continue
+		}
+
+		mcpu := res.Value * 1000
+		rq.Status.Used.SetLimit(kubemodel.ResourceCPU, kubemodel.UnitMillicore, kubemodel.StatAvg, mcpu)
+	}
+
+	rqStatusUsedCPULimitMaxResult, _ := rqStatusUsedCPULimitMaxResultFuture.Await()
+	for _, res := range rqStatusUsedCPULimitMaxResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add status CPU limit max", res.UID)
+			continue
+		}
+
+		mcpu := res.Value * 1000
+		rq.Status.Used.SetLimit(kubemodel.ResourceCPU, kubemodel.UnitMillicore, kubemodel.StatMax, mcpu)
+	}
+
+	rqStatusUsedRAMLimitAverageResult, _ := rqStatusUsedRAMLimitAverageResultFuture.Await()
+	for _, res := range rqStatusUsedRAMLimitAverageResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add status RAM limit average", res.UID)
+			continue
+		}
+
+		rq.Status.Used.SetLimit(kubemodel.ResourceMemory, kubemodel.UnitByte, kubemodel.StatAvg, res.Value)
+	}
+
+	rqStatusUsedRAMLimitMaxResult, _ := rqStatusUsedRAMLimitMaxResultFuture.Await()
+	for _, res := range rqStatusUsedRAMLimitMaxResult {
+		rq, ok := rqMap[res.UID]
+		if !ok {
+			log.Warnf("resource quota with UID '%s' has not been initialized to add status RAM limit max", res.UID)
+			continue
+		}
+
+		rq.Status.Used.SetLimit(kubemodel.ResourceMemory, kubemodel.UnitByte, kubemodel.StatMax, res.Value)
+	}
+
+	for _, resourceQuota := range rqMap {
+		err := kms.RegisterResourceQuota(resourceQuota)
+		if err != nil {
+			log.Warnf("Failed to register resource quota: %s", err.Error())
+		}
+	}
+
+	return nil
+}

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

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

+ 63 - 0
core/pkg/compute/kubemodel/service.go

@@ -0,0 +1,63 @@
+package kubemodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func (km *KubeModel) computeServices(kms *kubemodel.KubeModelSet, start, end time.Time) error {
+	grp := source.NewQueryGroup()
+	metrics := km.ds.Metrics()
+
+	serviceInfoResultFuture := source.WithGroup(grp, metrics.QueryServiceInfo(start, end))
+	serviceUptimeResultFuture := source.WithGroup(grp, metrics.QueryServiceUptime(start, end))
+	serviceSelectorLabelsResultFuture := source.WithGroup(grp, metrics.QueryServiceSelectorLabels(start, end))
+
+	serviceMap := make(map[string]*kubemodel.Service)
+
+	// Initialize services from info
+	serviceInfoResult, _ := serviceInfoResultFuture.Await()
+	for _, res := range serviceInfoResult {
+		serviceMap[res.UID] = &kubemodel.Service{
+			UID:              res.UID,
+			NamespaceUID:     res.NamespaceUID,
+			Name:             res.Service,
+			Type:             kubemodel.ParseServiceType(res.ServiceType),
+			LBIngressAddress: res.LBIngressAddress,
+		}
+	}
+
+	serviceUptimeResult, _ := serviceUptimeResultFuture.Await()
+	for _, res := range serviceUptimeResult {
+		service, ok := serviceMap[res.UID]
+		if !ok {
+			log.Warnf("service with UID '%s' has not been initialized to add uptime", res.UID)
+			continue
+		}
+		s, e := res.GetStartEnd(start, end, km.ds.Resolution())
+		service.Start = s
+		service.End = e
+	}
+
+	serviceSelectorLabelsResult, _ := serviceSelectorLabelsResultFuture.Await()
+	for _, res := range serviceSelectorLabelsResult {
+		service, ok := serviceMap[res.UID]
+		if !ok {
+			log.Warnf("service with UID '%s' has not been initialized to add selector labels", res.UID)
+			continue
+		}
+		service.Selector = res.Labels
+	}
+
+	for _, service := range serviceMap {
+		err := kms.RegisterService(service)
+		if err != nil {
+			log.Warnf("Failed to register service: %s", err.Error())
+		}
+	}
+
+	return nil
+}

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

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

+ 82 - 0
core/pkg/compute/kubemodel/statefulset.go

@@ -0,0 +1,82 @@
+package kubemodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/source"
+)
+
+func (km *KubeModel) computeStatefulSets(kms *kubemodel.KubeModelSet, start, end time.Time) error {
+	grp := source.NewQueryGroup()
+	metrics := km.ds.Metrics()
+
+	statefulSetInfoResultFuture := source.WithGroup(grp, metrics.QueryStatefulSetInfo(start, end))
+	statefulSetUptimeResultFuture := source.WithGroup(grp, metrics.QueryStatefulSetUptime(start, end))
+	statefulSetLabelsResultFuture := source.WithGroup(grp, metrics.QueryStatefulSetLabels(start, end))
+	statefulSetAnnotationsResultFuture := source.WithGroup(grp, metrics.QueryStatefulSetAnnotations(start, end))
+	statefulSetMatchLabelsResultFuture := source.WithGroup(grp, metrics.QueryStatefulSetMatchLabels(start, end))
+
+	statefulSetMap := make(map[string]*kubemodel.StatefulSet)
+
+	statefulSetInfoResult, _ := statefulSetInfoResultFuture.Await()
+	for _, res := range statefulSetInfoResult {
+		statefulSetMap[res.UID] = &kubemodel.StatefulSet{
+			UID:          res.UID,
+			Name:         res.StatefulSet,
+			NamespaceUID: res.NamespaceUID,
+		}
+	}
+
+	statefulSetUptimeResult, _ := statefulSetUptimeResultFuture.Await()
+	for _, res := range statefulSetUptimeResult {
+		statefulSet, ok := statefulSetMap[res.UID]
+		if !ok {
+			log.Warnf("statefulset with UID '%s' has not been initialized to add uptime", res.UID)
+			continue
+		}
+		s, e := res.GetStartEnd(start, end, km.ds.Resolution())
+		statefulSet.Start = s
+		statefulSet.End = e
+	}
+
+	statefulSetLabelsResult, _ := statefulSetLabelsResultFuture.Await()
+	for _, res := range statefulSetLabelsResult {
+		statefulSet, ok := statefulSetMap[res.UID]
+		if !ok {
+			log.Warnf("statefulset with UID '%s' has not been initialized to add labels", res.UID)
+			continue
+		}
+		statefulSet.Labels = res.Labels
+	}
+
+	statefulSetAnnotationsResult, _ := statefulSetAnnotationsResultFuture.Await()
+	for _, res := range statefulSetAnnotationsResult {
+		statefulSet, ok := statefulSetMap[res.UID]
+		if !ok {
+			log.Warnf("statefulset with UID '%s' has not been initialized to add annotations", res.UID)
+			continue
+		}
+		statefulSet.Annotations = res.Annotations
+	}
+
+	statefulSetMatchLabelsResult, _ := statefulSetMatchLabelsResultFuture.Await()
+	for _, res := range statefulSetMatchLabelsResult {
+		statefulSet, ok := statefulSetMap[res.UID]
+		if !ok {
+			log.Warnf("statefulset with UID '%s' has not been initialized to add match labels", res.UID)
+			continue
+		}
+		statefulSet.MatchLabels = res.Labels
+	}
+
+	for _, statefulSet := range statefulSetMap {
+		err := kms.RegisterStatefulSet(statefulSet)
+		if err != nil {
+			log.Warnf("Failed to register statefulset: %s", err.Error())
+		}
+	}
+
+	return nil
+}

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

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

+ 19 - 0
core/pkg/env/core.go

@@ -20,6 +20,10 @@ const (
 	Resolution1dRetentionEnvVar  = "RESOLUTION_1D_RETENTION"  // int: number of days
 	Resolution1hRetentionEnvVar  = "RESOLUTION_1H_RETENTION"  // int: number of hours
 	Resolution10mRetentionEnvVar = "RESOLUTION_10M_RETENTION" // int: number of 10m segments
+
+	ExportLegacyDataModelEnvVar = "EXPORT_LEGACY_DATA_MODEL"
+	ExportKubeModelEnvVar       = "EXPORT_KUBEMODEL"
+	ForceKubeModelV1EnvVar      = "FORCE_KUBEMODEL_V1"
 )
 
 // GetAPIPort returns the environment variable value for APIPortEnvVar which
@@ -61,3 +65,18 @@ func IsPProfEnabled() bool {
 func GetInstallNamespace(def string) string {
 	return Get(InstallNamespaceEnvVar, def)
 }
+
+func IsLegacyDataModelExported() bool {
+	return GetBool(ExportLegacyDataModelEnvVar, true)
+}
+
+func IsKubeModelExported() bool {
+	return GetBool(ExportKubeModelEnvVar, false)
+}
+
+// IsKubeModelV1Forced returns true if the kubemodel pipeline should always
+// export the legacy v1 (cluster, namespaces, resource quotas only) shape,
+// regardless of whether the source reports a complete kubemodel.
+func IsKubeModelV1Forced() bool {
+	return GetBool(ForceKubeModelV1EnvVar, true)
+}

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

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

+ 1 - 1
core/pkg/exporter/controller.go

@@ -277,7 +277,7 @@ func (g *ComputeExportControllerGroup[T]) Name() string {
 
 func (g *ComputeExportControllerGroup[T]) Start(interval time.Duration) bool {
 	if len(g.controllers) == 0 {
-		log.Warnf("ComputeExportControllerGroup[%s] has no controllers to start", typeutil.TypeOf[T]())
+		log.Debugf("ComputeExportControllerGroup[%s] has no controllers to start", typeutil.TypeOf[T]())
 		return false
 	}
 

+ 13 - 6
core/pkg/exporter/encoder.go

@@ -12,6 +12,13 @@ import (
 	"google.golang.org/protobuf/proto"
 )
 
+const (
+	BingenExt = "bingen"
+	JSONExt   = "json"
+	GZipExt   = "gz"
+	PBExt     = "binpb"
+)
+
 // Encoder[T] is a generic interface for encoding an instance of a T type into a byte slice.
 type Encoder[T any] interface {
 	Encode(*T) ([]byte, error)
@@ -52,7 +59,7 @@ func NewBingenEncoder[T any, U BinaryMarshalerPtr[T]]() Encoder[T] {
 // 'T' type with the ".bingen" file extension.
 func NewBingenFileEncoder[T any, U BinaryMarshalerPtr[T]]() Encoder[T] {
 	return &BingenEncoder[T, U]{
-		fileExt: "bingen",
+		fileExt: BingenExt,
 	}
 }
 
@@ -99,7 +106,7 @@ func (j *JSONEncoder[T]) EncodeTo(writer io.Writer, data *T) error {
 // FileExt returns the file extension for the encoded data. In this case, it returns "json" to indicate
 // that the data is in JSON format.
 func (j *JSONEncoder[T]) FileExt() string {
-	return "json"
+	return JSONExt
 }
 
 type GZipEncoder[T any] struct {
@@ -175,9 +182,9 @@ func gZipEncode(data []byte, level int) ([]byte, error) {
 func (gz *GZipEncoder[T]) FileExt() string {
 	prev := gz.encoder.FileExt()
 	if prev == "" {
-		return "gz"
+		return GZipExt
 	}
-	return prev + ".gz"
+	return fmt.Sprintf("%s.%s", prev, GZipExt)
 }
 
 // ProtoMessagePtr [T] is a generic constraint to ensure types passed to the encoder implement
@@ -225,7 +232,7 @@ func (p *ProtobufEncoder[T, U]) EncodeTo(writer io.Writer, data *T) error {
 // FileExt returns the file extension for the encoded data. In this case, it returns an empty string
 // to indicate that there is no specific file extension for the binary encoded data.
 func (p *ProtobufEncoder[T, U]) FileExt() string {
-	return "binpb"
+	return PBExt
 }
 
 // ProtoJsonEncoder [T, U] is a generic encoder that uses the proto.Message interface to encode data in json format.
@@ -268,5 +275,5 @@ func (p *ProtoJsonEncoder[T, U]) EncodeTo(writer io.Writer, data *T) error {
 // FileExt returns the file extension for the encoded data. In this case, it returns an empty string
 // to indicate that there is no specific file extension for the binary encoded data.
 func (p *ProtoJsonEncoder[T, U]) FileExt() string {
-	return "json"
+	return JSONExt
 }

+ 8 - 6
core/pkg/exporter/exporter_test.go

@@ -15,8 +15,10 @@ import (
 )
 
 const (
-	TestClusterId = "test-cluster"
-	TestEventName = "test-event-path"
+	TestAppName     = "test-app"
+	TestClusterID   = "test-cluster-id"
+	TestClusterName = "test-cluster"
+	TestEventName   = "test-event-path"
 )
 
 type TestData struct {
@@ -26,7 +28,7 @@ type TestData struct {
 func TestStorageExporters(t *testing.T) {
 	t.Run("test event storage exporter", func(t *testing.T) {
 		store := storage.NewMemoryStorage()
-		p, err := pathing.NewEventStoragePathFormatter("root", TestClusterId, TestEventName)
+		p, err := pathing.NewEventStoragePathFormatter("root", TestClusterName, TestEventName)
 		if err != nil {
 			t.Fatalf("failed to create path formatter: %v", err)
 		}
@@ -67,7 +69,7 @@ func TestStorageExporters(t *testing.T) {
 	t.Run("test compute storage exporter", func(t *testing.T) {
 		res := 24 * time.Hour
 		store := storage.NewMemoryStorage()
-		p, err := pathing.NewDefaultStoragePathFormatter(TestClusterId, pipelines.AllocationPipelineName, &res)
+		p, err := pathing.NewDefaultStoragePathFormatter(TestAppName, TestClusterID, TestClusterName, pipelines.AllocationPipelineName, &res)
 		if err != nil {
 			t.Fatalf("failed to create path formatter: %v", err)
 		}
@@ -115,7 +117,7 @@ func TestStorageExporters(t *testing.T) {
 	t.Run("test streaming compute storage exporter", func(t *testing.T) {
 		res := 24 * time.Hour
 		store := storage.NewMemoryStorage()
-		p, err := pathing.NewDefaultStoragePathFormatter(TestClusterId, pipelines.AllocationPipelineName, &res)
+		p, err := pathing.NewDefaultStoragePathFormatter(TestAppName, TestClusterID, TestClusterName, pipelines.AllocationPipelineName, &res)
 		if err != nil {
 			t.Fatalf("failed to create path formatter: %v", err)
 		}
@@ -163,7 +165,7 @@ func TestStorageExporters(t *testing.T) {
 	t.Run("test compressed streaming compute storage exporter", func(t *testing.T) {
 		res := 24 * time.Hour
 		store := storage.NewMemoryStorage()
-		p, err := pathing.NewDefaultStoragePathFormatter(TestClusterId, pipelines.AllocationPipelineName, &res)
+		p, err := pathing.NewDefaultStoragePathFormatter(TestAppName, TestClusterID, TestClusterName, pipelines.AllocationPipelineName, &res)
 		if err != nil {
 			t.Fatalf("failed to create path formatter: %v", err)
 		}

+ 5 - 6
core/pkg/exporter/pathing/bingenpath.go

@@ -12,9 +12,8 @@ import (
 )
 
 const (
-	DefaultRootDir   string = "federated"
-	BaseStorageDir   string = "etl/bingen"
-	FinOpsAgentAppID string = "finops-agent"
+	DefaultRootDir string = "federated"
+	BaseStorageDir string = "etl/bingen"
 )
 
 // BingenStoragePathFormatter is an implementation of the StoragePathFormatter interface for
@@ -28,7 +27,7 @@ type BingenStoragePathFormatter struct {
 	resolution string
 }
 
-func NewDefaultStoragePathFormatter(clusterId, pipeline string, resolution *time.Duration) (StoragePathFormatter[opencost.Window], error) {
+func NewDefaultStoragePathFormatter(appName, clusterID, clusterName, pipeline string, resolution *time.Duration) (StoragePathFormatter[opencost.Window], error) {
 	res := "."
 	if resolution != nil {
 		res = timeutil.FormatStoreResolution(*resolution)
@@ -37,10 +36,10 @@ func NewDefaultStoragePathFormatter(clusterId, pipeline string, resolution *time
 	// KubeModel uses a distinct pathing pattern which breaks with the original
 	// Allocations and Assets bingen pathing.
 	if pipeline == pipelines.KubeModelPipelineName {
-		return NewKubeModelStoragePathFormatter(FinOpsAgentAppID, clusterId, res)
+		return NewKubeModelStoragePathFormatter(appName, clusterID, res)
 	}
 
-	return NewBingenStoragePathFormatter(DefaultRootDir, clusterId, pipeline, res)
+	return NewBingenStoragePathFormatter(DefaultRootDir, clusterName, pipeline, res)
 }
 
 // NewBingenStoragePathFormatter creates a StoragePathFormatter for a cluster separated storage path

+ 58 - 44
core/pkg/exporter/pathing/path_test.go

@@ -9,70 +9,84 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestBingenPathFormatter(t *testing.T) {
+func TestDefaultPathFormatter(t *testing.T) {
 	type testCase struct {
-		name       string
-		clusterID  string
-		pipeline   string
-		resolution *time.Duration
-		prefix     string
-		expected   string
+		name        string
+		appName     string
+		clusterID   string
+		clusterName string
+		pipeline    string
+		resolution  *time.Duration
+		prefix      string
+		expected    string
 	}
 
 	testCases := []testCase{
 		{
-			name:       "no resolution",
-			clusterID:  "cluster-a",
-			pipeline:   "allocation",
-			resolution: nil,
-			prefix:     "",
-			expected:   fmt.Sprintf("%s/cluster-a/%s/allocation/1704110400-1704114000", DefaultRootDir, BaseStorageDir),
+			name:        "no resolution",
+			appName:     "test-app",
+			clusterID:   "clusterID-a",
+			clusterName: "cluster-a",
+			pipeline:    "allocation",
+			resolution:  nil,
+			prefix:      "",
+			expected:    fmt.Sprintf("%s/cluster-a/%s/allocation/1704110400-1704114000", DefaultRootDir, BaseStorageDir),
 		},
 		{
-			name:       "with resolution",
-			clusterID:  "cluster-a",
-			pipeline:   "allocation",
-			resolution: &[]time.Duration{1 * time.Hour}[0],
-			prefix:     "",
-			expected:   fmt.Sprintf("%s/cluster-a/%s/allocation/1h/1704110400-1704114000", DefaultRootDir, BaseStorageDir),
+			name:        "with resolution",
+			appName:     "test-app",
+			clusterID:   "clusterID-a",
+			clusterName: "cluster-a",
+			pipeline:    "allocation",
+			resolution:  &[]time.Duration{1 * time.Hour}[0],
+			prefix:      "",
+			expected:    fmt.Sprintf("%s/cluster-a/%s/allocation/1h/1704110400-1704114000", DefaultRootDir, BaseStorageDir),
 		},
 		{
-			name:       "no resolution with prefix",
-			clusterID:  "cluster-a",
-			pipeline:   "allocation",
-			resolution: nil,
-			prefix:     "test",
-			expected:   fmt.Sprintf("%s/cluster-a/%s/allocation/test.1704110400-1704114000", DefaultRootDir, BaseStorageDir),
+			name:        "no resolution with prefix",
+			appName:     "test-app",
+			clusterID:   "clusterID-a",
+			clusterName: "cluster-a",
+			pipeline:    "allocation",
+			resolution:  nil,
+			prefix:      "test",
+			expected:    fmt.Sprintf("%s/cluster-a/%s/allocation/test.1704110400-1704114000", DefaultRootDir, BaseStorageDir),
 		},
 		{
-			name:       "with resolution with prefix",
-			clusterID:  "cluster-a",
-			pipeline:   "allocation",
-			resolution: &[]time.Duration{1 * time.Hour}[0],
-			prefix:     "test",
-			expected:   fmt.Sprintf("%s/cluster-a/%s/allocation/1h/test.1704110400-1704114000", DefaultRootDir, BaseStorageDir),
+			name:        "with resolution with prefix",
+			appName:     "test-app",
+			clusterID:   "clusterID-a",
+			clusterName: "cluster-a",
+			pipeline:    "allocation",
+			resolution:  &[]time.Duration{1 * time.Hour}[0],
+			prefix:      "test",
+			expected:    fmt.Sprintf("%s/cluster-a/%s/allocation/1h/test.1704110400-1704114000", DefaultRootDir, BaseStorageDir),
 		},
 		{
-			name:       "daily resolution",
-			clusterID:  "cluster-a",
-			pipeline:   "allocation",
-			resolution: &[]time.Duration{24 * time.Hour}[0],
-			prefix:     "",
-			expected:   fmt.Sprintf("%s/cluster-a/%s/allocation/1d/1704110400-1704196800", DefaultRootDir, BaseStorageDir),
+			name:        "daily resolution",
+			appName:     "test-app",
+			clusterID:   "clusterID-a",
+			clusterName: "cluster-a",
+			pipeline:    "allocation",
+			resolution:  &[]time.Duration{24 * time.Hour}[0],
+			prefix:      "",
+			expected:    fmt.Sprintf("%s/cluster-a/%s/allocation/1d/1704110400-1704196800", DefaultRootDir, BaseStorageDir),
 		},
 		{
-			name:       "weekly resolution",
-			clusterID:  "cluster-a",
-			pipeline:   "allocation",
-			resolution: &[]time.Duration{7 * 24 * time.Hour}[0],
-			prefix:     "",
-			expected:   fmt.Sprintf("%s/cluster-a/%s/allocation/1w/1704110400-1704715200", DefaultRootDir, BaseStorageDir),
+			name:        "weekly resolution",
+			appName:     "test-app",
+			clusterID:   "clusterID-a",
+			clusterName: "cluster-a",
+			pipeline:    "allocation",
+			resolution:  &[]time.Duration{7 * 24 * time.Hour}[0],
+			prefix:      "",
+			expected:    fmt.Sprintf("%s/cluster-a/%s/allocation/1w/1704110400-1704715200", DefaultRootDir, BaseStorageDir),
 		},
 	}
 
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
-			pathing, err := NewDefaultStoragePathFormatter(tc.clusterID, tc.pipeline, tc.resolution)
+			pathing, err := NewDefaultStoragePathFormatter(tc.appName, tc.clusterID, tc.clusterName, tc.pipeline, tc.resolution)
 			if err != nil {
 				t.Fatalf("Unexpected error: %v", err)
 			}

+ 0 - 35
core/pkg/model/helper.go

@@ -1,35 +0,0 @@
-package model
-
-import (
-	"fmt"
-	"time"
-
-	"github.com/opencost/opencost/core/pkg/model/pb"
-	"github.com/opencost/opencost/core/pkg/opencost"
-	"github.com/opencost/opencost/core/pkg/util/timeutil"
-)
-
-// ConvertWindow validates and converts a protobuf window to a closed opencost.Window or returns an error
-func ConvertWindow(window *pb.Window) (opencost.Window, error) {
-	if window == nil {
-		return opencost.Window{}, fmt.Errorf("cannot convert nil window")
-	}
-	var res time.Duration
-	switch window.Resolution {
-	case pb.Resolution_RESOLUTION_1D:
-		res = timeutil.Day
-	case pb.Resolution_RESOLUTION_1H:
-		res = time.Hour
-	case pb.Resolution_RESOLUTION_10M:
-		res = time.Minute * 10
-	default:
-		return opencost.Window{}, fmt.Errorf("invalid window resolution %v", window.Resolution)
-	}
-
-	start := window.Start.AsTime().UTC()
-	if !start.Equal(start.Truncate(res)) {
-		return opencost.Window{}, fmt.Errorf("invalid start time for resolution '%s': %s", window.Resolution, start.Format(time.RFC3339))
-	}
-	win := opencost.NewClosedWindow(start, start.Add(res))
-	return win, nil
-}

+ 0 - 88
core/pkg/model/helper_test.go

@@ -1,88 +0,0 @@
-package model
-
-import (
-	"github.com/opencost/opencost/core/pkg/model/pb"
-	"github.com/opencost/opencost/core/pkg/opencost"
-	"github.com/opencost/opencost/core/pkg/util/timeutil"
-	"google.golang.org/protobuf/types/known/timestamppb"
-	"reflect"
-	"testing"
-	"time"
-)
-
-func TestConvertWindow(t *testing.T) {
-	timeDay := time.Date(2025, 9, 1, 0, 0, 0, 0, time.UTC)
-	timeHour := timeDay.Add(time.Hour)
-	timeTenMinute := timeHour.Add(time.Minute * 10)
-	invalidTime := timeTenMinute.Add(time.Second)
-	tests := []struct {
-		name    string
-		window  *pb.Window
-		want    opencost.Window
-		wantErr bool
-	}{
-		{
-			name:    "nil window",
-			window:  nil,
-			want:    opencost.Window{},
-			wantErr: true,
-		},
-		{
-			name: "invalid resolution",
-			window: &pb.Window{
-				Resolution: 999,
-				Start:      timestamppb.New(timeDay),
-			},
-			want:    opencost.Window{},
-			wantErr: true,
-		},
-		{
-			name: "invalid time",
-			window: &pb.Window{
-				Resolution: pb.Resolution_RESOLUTION_1D,
-				Start:      timestamppb.New(invalidTime),
-			},
-			want:    opencost.Window{},
-			wantErr: true,
-		},
-		{
-			name: "valid 1d",
-			window: &pb.Window{
-				Resolution: pb.Resolution_RESOLUTION_1D,
-				Start:      timestamppb.New(timeDay),
-			},
-			want:    opencost.NewClosedWindow(timeDay, timeDay.Add(timeutil.Day)),
-			wantErr: false,
-		},
-		{
-			name: "valid 1h",
-			window: &pb.Window{
-				Resolution: pb.Resolution_RESOLUTION_1H,
-				Start:      timestamppb.New(timeHour),
-			},
-			want:    opencost.NewClosedWindow(timeHour, timeHour.Add(time.Hour)),
-			wantErr: false,
-		},
-		{
-			name: "valid 10m",
-			window: &pb.Window{
-				Resolution: pb.Resolution_RESOLUTION_10M,
-				Start:      timestamppb.New(timeTenMinute),
-			},
-			want:    opencost.NewClosedWindow(timeTenMinute, timeTenMinute.Add(10*time.Minute)),
-			wantErr: false,
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			got, err := ConvertWindow(tt.window)
-			if (err != nil) != tt.wantErr {
-				t.Errorf("ConvertWindow() error = %v, wantErr %v", err, tt.wantErr)
-				return
-			}
-			if !reflect.DeepEqual(got, tt.want) {
-				t.Errorf("ConvertWindow() got = %v, want %v", got, tt.want)
-			}
-		})
-	}
-}

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

@@ -20,4 +20,6 @@ package kubemodel
 //
 ////////////////////////////////////////////////////////////////////////////////
 
-//go:generate bingen -package=kubemodel -version=1 -buffer=github.com/opencost/opencost/core/pkg/util
+// @bingen:define[string]:github.com/opencost/opencost/core/pkg/model/shared.Provider
+
+//go:generate bingen -package=kubemodel -version=2 -buffer=github.com/opencost/opencost/core/pkg/util

+ 30 - 14
core/pkg/model/kubemodel/cluster.go

@@ -1,33 +1,49 @@
 package kubemodel
 
 import (
-	"errors"
+	"fmt"
 	"time"
+
+	"github.com/opencost/opencost/core/pkg/model/shared"
 )
 
 // @bingen:generate:Cluster
 type Cluster struct {
-	UID      string    `json:"uid"`      // @bingen:field[version=1]
-	Provider Provider  `json:"provider"` // @bingen:field[version=1]
-	Account  string    `json:"account"`  // @bingen:field[version=1]
-	Name     string    `json:"name"`     // @bingen:field[version=1]
-	Start    time.Time `json:"start"`    // @bingen:field[version=1]
-	End      time.Time `json:"end"`      // @bingen:field[version=1]
+	UID      string          `json:"uid"`      // @bingen:field[version=1]
+	Provider shared.Provider `json:"provider"` // @bingen:field[version=1]
+	Account  string          `json:"account"`  // @bingen:field[version=1]
+	Name     string          `json:"name"`     // @bingen:field[version=1]
+	Region   string          `json:"region"`   // @bingen:field[version=2]
+	Start    time.Time       `json:"start"`    // @bingen:field[version=1]
+	End      time.Time       `json:"end"`      // @bingen:field[version=1]
+}
+
+func (c *Cluster) ValidateCluster(window Window) error {
+	if c.UID == "" {
+		err := fmt.Errorf("UID is missing for Cluster with name '%s'", c.Name)
+		return err
+	}
+
+	if err := checkWindow(window, c.Start, c.End); err != nil {
+		return err
+	}
+
+	return nil
 }
 
-func (kms *KubeModelSet) RegisterCluster(uid string) error {
-	if uid == "" {
-		err := errors.New("RegisterCluster: uid is nil")
+func (kms *KubeModelSet) RegisterCluster(cluster *Cluster) error {
+	if err := cluster.ValidateCluster(kms.Window); err != nil {
+		err = fmt.Errorf("RegisterCluster: invalid cluster: %w", err)
 		kms.Error(err)
 		return err
 	}
 
 	if kms.Cluster == nil {
-		kms.Cluster = &Cluster{UID: uid}
-	} else if uid != kms.Cluster.UID {
-		kms.Warnf("RegisterCluster(%s): attempting to change cluster UID from %s to %s", uid, kms.Cluster.UID, uid)
+		kms.Cluster = cluster
+	} else if cluster.UID != kms.Cluster.UID {
+		kms.Warnf("RegisterCluster(%s): attempting to change cluster UID from %s to %s", cluster.UID, kms.Cluster.UID, cluster.UID)
 	} else {
-		kms.Debugf("RegisterCluster(%s): cluster already registered", uid)
+		kms.Debugf("RegisterCluster(%s): cluster already registered", cluster.UID)
 	}
 
 	return nil

+ 131 - 0
core/pkg/model/kubemodel/cluster_test.go

@@ -0,0 +1,131 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidateCluster(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	tests := []struct {
+		name    string
+		cluster *Cluster
+		wantErr string
+	}{
+		{
+			name:    "empty UID",
+			cluster: &Cluster{Name: "my-cluster", Start: start, End: end},
+			wantErr: "UID is missing for Cluster with name 'my-cluster'",
+		},
+		{
+			name:    "outside window",
+			cluster: &Cluster{UID: "cluster-uid", Name: "my-cluster", Start: start.Add(-time.Hour), End: end},
+			wantErr: checkWindow(window, start.Add(-time.Hour), end).Error(),
+		},
+		{
+			name:    "valid",
+			cluster: &Cluster{UID: "cluster-uid", Name: "my-cluster", Start: start, End: end},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.cluster.ValidateCluster(window)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestRegisterCluster(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	newCluster := func(uid, name string) *Cluster {
+		return &Cluster{UID: uid, Name: name, Start: start, End: end}
+	}
+
+	tests := []struct {
+		name    string
+		setup   func(*KubeModelSet)
+		cluster *Cluster
+		wantErr string
+		want    *KubeModelSet
+	}{
+		{
+			name:    "validation failure",
+			cluster: &Cluster{UID: "", Name: "my-cluster", Start: start, End: end},
+			wantErr: "RegisterCluster: invalid cluster: UID is missing for Cluster with name 'my-cluster'",
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelError, Message: "RegisterCluster: invalid cluster: UID is missing for Cluster with name 'my-cluster'"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:    "registers cluster",
+			cluster: newCluster("cluster-uid", "my-cluster"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Cluster = newCluster("cluster-uid", "my-cluster")
+				return kms
+			}(),
+		},
+		{
+			name: "same UID is a no-op",
+			setup: func(kms *KubeModelSet) {
+				kms.RegisterCluster(newCluster("cluster-uid", "original"))
+			},
+			cluster: newCluster("cluster-uid", "duplicate"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Cluster = newCluster("cluster-uid", "original")
+				return kms
+			}(),
+		},
+		{
+			name: "different UID emits warning and keeps original",
+			setup: func(kms *KubeModelSet) {
+				kms.RegisterCluster(newCluster("cluster-uid", "original"))
+			},
+			cluster: newCluster("another-uid", "another"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Cluster = newCluster("cluster-uid", "original")
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelWarning, Message: "RegisterCluster(another-uid): attempting to change cluster UID from cluster-uid to another-uid"},
+				}
+				return kms
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			kms := NewKubeModelSet(start, end)
+			if tt.setup != nil {
+				tt.setup(kms)
+			}
+
+			err := kms.RegisterCluster(tt.cluster)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+
+			KubeModelSetEquals(t, tt.want, kms)
+		})
+	}
+}

+ 29 - 82
core/pkg/model/kubemodel/container.go

@@ -5,105 +5,52 @@ import (
 	"time"
 )
 
+// @bingen:generate:Container
 type Container struct {
-	PodUID                     string                 `json:"podUid"`
-	Name                       string                 `json:"name"`
-	DurationSeconds            Measurement            `json:"durationSeconds"`
-	CpuMillicoreSeconds        Measurement            `json:"cpuMillicoreSeconds"`
-	CpuMillicoreUsageMax       Measurement            `json:"cpuMillicoreUsageMax"`
-	CpuMillicoreRequestSeconds Measurement            `json:"cpuMillicoreRequestSeconds"`
-	RAMByteSeconds             Measurement            `json:"ramByteSeconds"`
-	RAMByteUsageMax            Measurement            `json:"ramByteUsageMax"`
-	RAMByteSecondRequest       Measurement            `json:"ramByteSecondRequest"`
-	VolumeStorageByteSeconds   map[string]Measurement `json:"volumeStorageByteSeconds,omitempty"`
-	VolumeStorageByteUsageMax  map[string]Measurement `json:"volumeStorageByteUsageMax,omitempty"`
-	CpuMillicoreLimitSeconds   Measurement            `json:"cpuMillicoreLimitSeconds,omitempty"`
-	RAMByteSecondsLimit        Measurement            `json:"ramByteSecondsLimit,omitempty"`
-	Start                      time.Time              `json:"start"`
-	End                        time.Time              `json:"end"`
+	PodUID                string             `json:"podUid"`
+	Name                  string             `json:"name"`
+	ResourceRequests      ResourceQuantities `json:"resourceRequests"`
+	ResourceLimits        ResourceQuantities `json:"resourceLimits"`
+	CPUCoreAllocationAvg  float64            `json:"cpuCoreAllocationAvg"`
+	CPUCoreUsageAvg       float64            `json:"cpuCoreUsageAvg"`
+	CPUCoreUsageMax       float64            `json:"cpuCoreUsageMax"`
+	RAMBytesAllocationAvg float64            `json:"ramBytesAllocationAvg"`
+	RAMBytesUsageAvg      float64            `json:"ramBytesUsageAvg"`
+	RAMBytesUsageMax      float64            `json:"ramBytesUsageMax"`
+	Start                 time.Time          `json:"start"`
+	End                   time.Time          `json:"end"`
 }
 
-func (c *Container) CpuMillicoreUsageAverage() Measurement {
-	if c.DurationSeconds == 0 {
-		return 0
-	}
-	return c.CpuMillicoreSeconds / c.DurationSeconds
-}
-
-func (c *Container) RAMByteUsageAverage() Measurement {
-	if c.DurationSeconds == 0 {
-		return 0
-	}
-	return c.RAMByteSeconds / c.DurationSeconds
-}
-
-func (c *Container) TotalStorageByteSeconds() Measurement {
-	var total Measurement
-	for _, ByteSeconds := range c.VolumeStorageByteSeconds {
-		total += ByteSeconds
-	}
-	return total
-}
-
-func (c *Container) TotalStorageByteUsageMax() Measurement {
-	var max Measurement
-	for _, usage := range c.VolumeStorageByteUsageMax {
-		if usage > max {
-			max = usage
-		}
-	}
-	return max
+func (c *Container) GetKey() string {
+	return fmt.Sprintf("%s/%s", c.PodUID, c.Name)
 }
 
-func (c *Container) StorageByteUsageAverage() Measurement {
-	if c.DurationSeconds == 0 {
-		return 0
+func (c *Container) ValidateContainer(window Window) error {
+	if c.PodUID == "" {
+		return fmt.Errorf("PodUID is missing for Container with name '%s'", c.Name)
 	}
-	totalByteSeconds := c.TotalStorageByteSeconds()
-	return totalByteSeconds / c.DurationSeconds
-}
 
-func (c *Container) CpuMillicoreRequestAverage() Measurement {
-	if c.DurationSeconds == 0 {
-		return 0
+	if c.Name == "" {
+		return fmt.Errorf("Name is missing for Container on pod '%s'", c.PodUID)
 	}
-	return c.CpuMillicoreRequestSeconds / c.DurationSeconds
-}
-
-func (c *Container) RAMByteRequestAverage() Measurement {
-	if c.DurationSeconds == 0 {
-		return 0
-	}
-	return c.RAMByteSecondRequest / c.DurationSeconds
-}
 
-func (c *Container) CpuMillicoreLimitAverage() Measurement {
-	if c.DurationSeconds == 0 {
-		return 0
+	if err := checkWindow(window, c.Start, c.End); err != nil {
+		return err
 	}
-	return c.CpuMillicoreLimitSeconds / c.DurationSeconds
-}
 
-func (c *Container) RAMByteLimitAverage() Measurement {
-	if c.DurationSeconds == 0 {
-		return 0
-	}
-	return c.RAMByteSecondsLimit / c.DurationSeconds
+	return nil
 }
 
-func (kms *KubeModelSet) RegisterContainer(uid, name, podUID string) error {
-	if uid == "" {
-		err := fmt.Errorf("UID is nil for Container '%s'", name)
+func (kms *KubeModelSet) RegisterContainer(container *Container) error {
+	if err := container.ValidateContainer(kms.Window); err != nil {
+		err = fmt.Errorf("RegisterContainer: invalid container: %w", err)
 		kms.Error(err)
 		return err
 	}
 
-	if _, ok := kms.Containers[uid]; !ok {
-		kms.Containers[uid] = &Container{
-			PodUID: podUID,
-			Name:   name,
-		}
-
+	key := container.GetKey()
+	if _, ok := kms.Containers[key]; !ok {
+		kms.Containers[key] = container
 		kms.Metadata.ObjectCount++
 	}
 

+ 123 - 0
core/pkg/model/kubemodel/container_test.go

@@ -0,0 +1,123 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidateContainer(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	tests := []struct {
+		name      string
+		container *Container
+		wantErr   string
+	}{
+		{
+			name:      "empty PodUID",
+			container: &Container{Name: "my-container", Start: start, End: end},
+			wantErr:   "PodUID is missing for Container with name 'my-container'",
+		},
+		{
+			name:      "empty Name",
+			container: &Container{PodUID: "pod-uid", Start: start, End: end},
+			wantErr:   "Name is missing for Container on pod 'pod-uid'",
+		},
+		{
+			name:      "outside window",
+			container: &Container{PodUID: "pod-uid", Name: "my-container", Start: start.Add(-time.Hour), End: end},
+			wantErr:   checkWindow(window, start.Add(-time.Hour), end).Error(),
+		},
+		{
+			name:      "valid",
+			container: &Container{PodUID: "pod-uid", Name: "my-container", Start: start, End: end},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.container.ValidateContainer(window)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestRegisterContainer(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	newContainer := func(podUID, name string) *Container {
+		return &Container{PodUID: podUID, Name: name, Start: start, End: end}
+	}
+
+	tests := []struct {
+		name      string
+		setup     func(*KubeModelSet)
+		container *Container
+		wantErr   string
+		want      *KubeModelSet
+	}{
+		{
+			name:      "validation failure",
+			container: &Container{PodUID: "", Name: "my-container", Start: start, End: end},
+			wantErr:   "RegisterContainer: invalid container: PodUID is missing for Container with name 'my-container'",
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelError, Message: "RegisterContainer: invalid container: PodUID is missing for Container with name 'my-container'"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:      "registers container",
+			container: newContainer("pod-uid", "my-container"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Containers["pod-uid/my-container"] = newContainer("pod-uid", "my-container")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+		{
+			name: "duplicate registration is a no-op",
+			setup: func(kms *KubeModelSet) {
+				kms.RegisterContainer(newContainer("pod-uid", "original"))
+			},
+			container: newContainer("pod-uid", "original"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Containers["pod-uid/original"] = newContainer("pod-uid", "original")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			kms := NewKubeModelSet(start, end)
+			if tt.setup != nil {
+				tt.setup(kms)
+			}
+
+			err := kms.RegisterContainer(tt.container)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+
+			KubeModelSetEquals(t, tt.want, kms)
+		})
+	}
+}

+ 58 - 0
core/pkg/model/kubemodel/cronjob.go

@@ -0,0 +1,58 @@
+package kubemodel
+
+import (
+	"fmt"
+	"time"
+)
+
+// @bingen:generate:CronJob
+// CronJob represents a Kubernetes CronJob resource
+type CronJob struct {
+	UID          string            `json:"uid"`
+	NamespaceUID string            `json:"namespaceUid"`
+	Name         string            `json:"name"`
+	Labels       map[string]string `json:"labels,omitempty"`
+	Annotations  map[string]string `json:"annotations,omitempty"`
+	Start        time.Time         `json:"start,omitempty"`
+	End          time.Time         `json:"end,omitempty"`
+}
+
+func (c *CronJob) ValidateCronJob(window Window) error {
+	if c.UID == "" {
+		return fmt.Errorf("UID is missing for CronJob with name '%s'", c.Name)
+	}
+
+	if c.Name == "" {
+		return fmt.Errorf("Name is missing for CronJob '%s'", c.UID)
+	}
+
+	if c.NamespaceUID == "" {
+		return fmt.Errorf("NamespaceUID is missing for CronJob '%s'", c.UID)
+	}
+
+	if err := checkWindow(window, c.Start, c.End); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (kms *KubeModelSet) RegisterCronJob(cronJob *CronJob) error {
+	if err := cronJob.ValidateCronJob(kms.Window); err != nil {
+		err = fmt.Errorf("RegisterCronJob: invalid cronjob: %w", err)
+		kms.Error(err)
+		return err
+	}
+
+	if _, ok := kms.CronJobs[cronJob.UID]; !ok {
+		if kms.Cluster == nil {
+			kms.Warnf("RegisterCronJob: Cluster is nil")
+		}
+
+		kms.CronJobs[cronJob.UID] = cronJob
+
+		kms.Metadata.ObjectCount++
+	}
+
+	return nil
+}

+ 148 - 0
core/pkg/model/kubemodel/cronjob_test.go

@@ -0,0 +1,148 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidateCronJob(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	tests := []struct {
+		name    string
+		cronJob *CronJob
+		wantErr string
+	}{
+		{
+			name:    "empty UID",
+			cronJob: &CronJob{Name: "my-cj", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr: "UID is missing for CronJob with name 'my-cj'",
+		},
+		{
+			name:    "empty Name",
+			cronJob: &CronJob{UID: "cj-uid", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr: "Name is missing for CronJob 'cj-uid'",
+		},
+		{
+			name:    "empty NamespaceUID",
+			cronJob: &CronJob{UID: "cj-uid", Name: "my-cj", Start: start, End: end},
+			wantErr: "NamespaceUID is missing for CronJob 'cj-uid'",
+		},
+		{
+			name:    "outside window",
+			cronJob: &CronJob{UID: "cj-uid", Name: "my-cj", NamespaceUID: "ns-uid", Start: start.Add(-time.Hour), End: end},
+			wantErr: checkWindow(window, start.Add(-time.Hour), end).Error(),
+		},
+		{
+			name:    "valid",
+			cronJob: &CronJob{UID: "cj-uid", Name: "my-cj", NamespaceUID: "ns-uid", Start: start, End: end},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.cronJob.ValidateCronJob(window)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestRegisterCronJob(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	newCronJob := func(uid, name string) *CronJob {
+		return &CronJob{UID: uid, Name: name, NamespaceUID: "ns-uid", Start: start, End: end}
+	}
+	withCluster := func(kms *KubeModelSet) {
+		kms.RegisterCluster(&Cluster{UID: "cluster-uid", Start: start, End: end})
+	}
+
+	tests := []struct {
+		name    string
+		setup   func(*KubeModelSet)
+		cronJob *CronJob
+		wantErr string
+		want    *KubeModelSet
+	}{
+		{
+			name:    "validation failure",
+			cronJob: &CronJob{UID: "", Name: "my-cj", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr: "RegisterCronJob: invalid cronjob: UID is missing for CronJob with name 'my-cj'",
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelError, Message: "RegisterCronJob: invalid cronjob: UID is missing for CronJob with name 'my-cj'"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:    "warns when cluster is nil",
+			cronJob: newCronJob("cj-uid", "my-cj"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.CronJobs["cj-uid"] = newCronJob("cj-uid", "my-cj")
+				kms.Metadata.ObjectCount = 1
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelWarning, Message: "RegisterCronJob: Cluster is nil"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:    "registers cronjob with cluster",
+			setup:   withCluster,
+			cronJob: newCronJob("cj-uid", "my-cj"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.CronJobs["cj-uid"] = newCronJob("cj-uid", "my-cj")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+		{
+			name: "duplicate registration is a no-op",
+			setup: func(kms *KubeModelSet) {
+				withCluster(kms)
+				kms.RegisterCronJob(newCronJob("cj-uid", "original"))
+			},
+			cronJob: newCronJob("cj-uid", "duplicate"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.CronJobs["cj-uid"] = newCronJob("cj-uid", "original")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			kms := NewKubeModelSet(start, end)
+			if tt.setup != nil {
+				tt.setup(kms)
+			}
+
+			err := kms.RegisterCronJob(tt.cronJob)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+
+			KubeModelSetEquals(t, tt.want, kms)
+		})
+	}
+}

+ 59 - 0
core/pkg/model/kubemodel/daemonset.go

@@ -0,0 +1,59 @@
+package kubemodel
+
+import (
+	"fmt"
+	"time"
+)
+
+// @bingen:generate:DaemonSet
+// DaemonSet represents a Kubernetes DaemonSet resource
+type DaemonSet struct {
+	UID              string            `json:"uid"`
+	NamespaceUID     string            `json:"namespaceUid"`
+	Name             string            `json:"name"`
+	Labels           map[string]string `json:"labels,omitempty"`
+	Annotations      map[string]string `json:"annotations,omitempty"`
+	DevicePluginInfo map[string]string `json:"devicePluginInfo"` // bingen:field[ignore]
+	Start            time.Time         `json:"start,omitempty"`
+	End              time.Time         `json:"end,omitempty"`
+}
+
+func (d *DaemonSet) ValidateDaemonSet(window Window) error {
+	if d.UID == "" {
+		return fmt.Errorf("UID is missing for DaemonSet with name '%s'", d.Name)
+	}
+
+	if d.Name == "" {
+		return fmt.Errorf("Name is missing for DaemonSet '%s'", d.UID)
+	}
+
+	if d.NamespaceUID == "" {
+		return fmt.Errorf("NamespaceUID is missing for DaemonSet '%s'", d.UID)
+	}
+
+	if err := checkWindow(window, d.Start, d.End); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (kms *KubeModelSet) RegisterDaemonSet(daemonSet *DaemonSet) error {
+	if err := daemonSet.ValidateDaemonSet(kms.Window); err != nil {
+		err = fmt.Errorf("RegisterDaemonSet: invalid daemonset: %w", err)
+		kms.Error(err)
+		return err
+	}
+
+	if _, ok := kms.DaemonSets[daemonSet.UID]; !ok {
+		if kms.Cluster == nil {
+			kms.Warnf("RegisterDaemonSet: Cluster is nil")
+		}
+
+		kms.DaemonSets[daemonSet.UID] = daemonSet
+
+		kms.Metadata.ObjectCount++
+	}
+
+	return nil
+}

+ 148 - 0
core/pkg/model/kubemodel/daemonset_test.go

@@ -0,0 +1,148 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidateDaemonSet(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	tests := []struct {
+		name      string
+		daemonSet *DaemonSet
+		wantErr   string
+	}{
+		{
+			name:      "empty UID",
+			daemonSet: &DaemonSet{Name: "my-ds", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr:   "UID is missing for DaemonSet with name 'my-ds'",
+		},
+		{
+			name:      "empty Name",
+			daemonSet: &DaemonSet{UID: "ds-uid", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr:   "Name is missing for DaemonSet 'ds-uid'",
+		},
+		{
+			name:      "empty NamespaceUID",
+			daemonSet: &DaemonSet{UID: "ds-uid", Name: "my-ds", Start: start, End: end},
+			wantErr:   "NamespaceUID is missing for DaemonSet 'ds-uid'",
+		},
+		{
+			name:      "outside window",
+			daemonSet: &DaemonSet{UID: "ds-uid", Name: "my-ds", NamespaceUID: "ns-uid", Start: start.Add(-time.Hour), End: end},
+			wantErr:   checkWindow(window, start.Add(-time.Hour), end).Error(),
+		},
+		{
+			name:      "valid",
+			daemonSet: &DaemonSet{UID: "ds-uid", Name: "my-ds", NamespaceUID: "ns-uid", Start: start, End: end},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.daemonSet.ValidateDaemonSet(window)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestRegisterDaemonSet(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	newDaemonSet := func(uid, name string) *DaemonSet {
+		return &DaemonSet{UID: uid, Name: name, NamespaceUID: "ns-uid", Start: start, End: end}
+	}
+	withCluster := func(kms *KubeModelSet) {
+		kms.RegisterCluster(&Cluster{UID: "cluster-uid", Start: start, End: end})
+	}
+
+	tests := []struct {
+		name      string
+		setup     func(*KubeModelSet)
+		daemonSet *DaemonSet
+		wantErr   string
+		want      *KubeModelSet
+	}{
+		{
+			name:      "validation failure",
+			daemonSet: &DaemonSet{UID: "", Name: "my-ds", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr:   "RegisterDaemonSet: invalid daemonset: UID is missing for DaemonSet with name 'my-ds'",
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelError, Message: "RegisterDaemonSet: invalid daemonset: UID is missing for DaemonSet with name 'my-ds'"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:      "warns when cluster is nil",
+			daemonSet: newDaemonSet("ds-uid", "my-ds"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.DaemonSets["ds-uid"] = newDaemonSet("ds-uid", "my-ds")
+				kms.Metadata.ObjectCount = 1
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelWarning, Message: "RegisterDaemonSet: Cluster is nil"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:      "registers daemonset with cluster",
+			setup:     withCluster,
+			daemonSet: newDaemonSet("ds-uid", "my-ds"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.DaemonSets["ds-uid"] = newDaemonSet("ds-uid", "my-ds")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+		{
+			name: "duplicate registration is a no-op",
+			setup: func(kms *KubeModelSet) {
+				withCluster(kms)
+				kms.RegisterDaemonSet(newDaemonSet("ds-uid", "original"))
+			},
+			daemonSet: newDaemonSet("ds-uid", "duplicate"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.DaemonSets["ds-uid"] = newDaemonSet("ds-uid", "original")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			kms := NewKubeModelSet(start, end)
+			if tt.setup != nil {
+				tt.setup(kms)
+			}
+
+			err := kms.RegisterDaemonSet(tt.daemonSet)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+
+			KubeModelSetEquals(t, tt.want, kms)
+		})
+	}
+}

+ 66 - 0
core/pkg/model/kubemodel/dcgm.go

@@ -0,0 +1,66 @@
+package kubemodel
+
+import (
+	"fmt"
+	"time"
+)
+
+// DCGMDevice holds recording from the DCGM exporter which provides identification and usage metrics for
+// Nvidia gpu. These Nvidia devices can be incorporated into the cluster via k8s Device Plugin API or DRAs.
+// While the DCGM exporter does provide unique identifiers for the containers that it is reporting metrics on,
+// It is split out here to provide some isolation from the rest of the KubeModel which represent universal structures
+// from the k8s API. It is left to the end user to interpret the relationships to the rest of the cluster based on
+// container unique identifiers
+// @bingen:generate:DCGMDevice
+type DCGMDevice struct {
+	UUID      string             `json:"uuid"`
+	Start     time.Time          `json:"start"`
+	End       time.Time          `json:"end"`
+	Device    string             `json:"device"`
+	ModelName string             `json:"modelName"`
+	PodUsages map[string]DCGMPod `json:"podUsages"`
+}
+
+// @bingen:generate:DCGMPod
+type DCGMPod struct {
+	ContainerUsages map[string]DCGMContainer `json:"container-usages"`
+}
+
+// @bingen:generate:DCGMContainer
+type DCGMContainer struct {
+	UsageAvg float64 `json:"usageAvg"`
+	UsageMax float64 `json:"usageMax"`
+}
+
+func (d *DCGMDevice) ValidateDCGMDevice(window Window) error {
+	if d.UUID == "" {
+		return fmt.Errorf("UUID is missing for DCGMDevice with device '%s'", d.Device)
+	}
+
+	if err := checkWindow(window, d.Start, d.End); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// RegisterDCGMDevice validates and adds a DCGMDevice to the set, keyed by UUID.
+func (kms *KubeModelSet) RegisterDCGMDevice(device *DCGMDevice) error {
+	if err := device.ValidateDCGMDevice(kms.Window); err != nil {
+		err = fmt.Errorf("RegisterDCGMDevice: invalid dcgm device: %w", err)
+		kms.Error(err)
+		return err
+	}
+
+	if _, ok := kms.DCGMDevices[device.UUID]; !ok {
+		if kms.Cluster == nil {
+			kms.Warnf("RegisterDCGMDevice: Cluster is nil")
+		}
+
+		kms.DCGMDevices[device.UUID] = device
+
+		kms.Metadata.ObjectCount++
+	}
+
+	return nil
+}

+ 138 - 0
core/pkg/model/kubemodel/dcgm_test.go

@@ -0,0 +1,138 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidateDCGMDevice(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	tests := []struct {
+		name    string
+		device  *DCGMDevice
+		wantErr string
+	}{
+		{
+			name:    "empty UUID",
+			device:  &DCGMDevice{Device: "GPU-0", Start: start, End: end},
+			wantErr: "UUID is missing for DCGMDevice with device 'GPU-0'",
+		},
+		{
+			name:    "outside window",
+			device:  &DCGMDevice{UUID: "gpu-uuid", Device: "GPU-0", Start: start.Add(-time.Hour), End: end},
+			wantErr: checkWindow(window, start.Add(-time.Hour), end).Error(),
+		},
+		{
+			name:   "valid",
+			device: &DCGMDevice{UUID: "gpu-uuid", Device: "GPU-0", Start: start, End: end},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.device.ValidateDCGMDevice(window)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestRegisterDCGMDevice(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	newDevice := func(uuid, device string) *DCGMDevice {
+		return &DCGMDevice{UUID: uuid, Device: device, Start: start, End: end}
+	}
+	withCluster := func(kms *KubeModelSet) {
+		kms.RegisterCluster(&Cluster{UID: "cluster-uid", Start: start, End: end})
+	}
+
+	tests := []struct {
+		name    string
+		setup   func(*KubeModelSet)
+		device  *DCGMDevice
+		wantErr string
+		want    *KubeModelSet
+	}{
+		{
+			name:    "validation failure",
+			device:  &DCGMDevice{UUID: "", Device: "GPU-0", Start: start, End: end},
+			wantErr: "RegisterDCGMDevice: invalid dcgm device: UUID is missing for DCGMDevice with device 'GPU-0'",
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelError, Message: "RegisterDCGMDevice: invalid dcgm device: UUID is missing for DCGMDevice with device 'GPU-0'"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:   "warns when cluster is nil",
+			device: newDevice("gpu-uuid", "GPU-0"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.DCGMDevices["gpu-uuid"] = newDevice("gpu-uuid", "GPU-0")
+				kms.Metadata.ObjectCount = 1
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelWarning, Message: "RegisterDCGMDevice: Cluster is nil"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:   "registers device with cluster",
+			setup:  withCluster,
+			device: newDevice("gpu-uuid", "GPU-0"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.DCGMDevices["gpu-uuid"] = newDevice("gpu-uuid", "GPU-0")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+		{
+			name: "duplicate registration is a no-op",
+			setup: func(kms *KubeModelSet) {
+				withCluster(kms)
+				kms.RegisterDCGMDevice(newDevice("gpu-uuid", "GPU-0"))
+			},
+			device: newDevice("gpu-uuid", "GPU-1"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.DCGMDevices["gpu-uuid"] = newDevice("gpu-uuid", "GPU-0")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			kms := NewKubeModelSet(start, end)
+			if tt.setup != nil {
+				tt.setup(kms)
+			}
+
+			err := kms.RegisterDCGMDevice(tt.device)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+
+			KubeModelSetEquals(t, tt.want, kms)
+		})
+	}
+}

+ 59 - 0
core/pkg/model/kubemodel/deployment.go

@@ -0,0 +1,59 @@
+package kubemodel
+
+import (
+	"fmt"
+	"time"
+)
+
+// @bingen:generate:Deployment
+// Deployment represents a Kubernetes Deployment resource
+type Deployment struct {
+	UID          string            `json:"uid"`
+	NamespaceUID string            `json:"namespaceUid"`
+	Name         string            `json:"name"`
+	Labels       map[string]string `json:"labels,omitempty"`
+	Annotations  map[string]string `json:"annotations,omitempty"`
+	MatchLabels  map[string]string `json:"matchLabels"`
+	Start        time.Time         `json:"start"`
+	End          time.Time         `json:"end"`
+}
+
+func (d *Deployment) ValidateDeployment(window Window) error {
+	if d.UID == "" {
+		return fmt.Errorf("UID is missing for Deployment with name '%s'", d.Name)
+	}
+
+	if d.Name == "" {
+		return fmt.Errorf("Name is missing for Deployment '%s'", d.UID)
+	}
+
+	if d.NamespaceUID == "" {
+		return fmt.Errorf("NamespaceUID is missing for Deployment '%s'", d.UID)
+	}
+
+	if err := checkWindow(window, d.Start, d.End); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (kms *KubeModelSet) RegisterDeployment(deployment *Deployment) error {
+	if err := deployment.ValidateDeployment(kms.Window); err != nil {
+		err = fmt.Errorf("RegisterDeployment: invalid deployment: %w", err)
+		kms.Error(err)
+		return err
+	}
+
+	if _, ok := kms.Deployments[deployment.UID]; !ok {
+		if kms.Cluster == nil {
+			kms.Warnf("RegisterDeployment: Cluster is nil")
+		}
+
+		kms.Deployments[deployment.UID] = deployment
+
+		kms.Metadata.ObjectCount++
+	}
+
+	return nil
+}

+ 148 - 0
core/pkg/model/kubemodel/deployment_test.go

@@ -0,0 +1,148 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidateDeployment(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	tests := []struct {
+		name       string
+		deployment *Deployment
+		wantErr    string
+	}{
+		{
+			name:       "empty UID",
+			deployment: &Deployment{Name: "my-deployment", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr:    "UID is missing for Deployment with name 'my-deployment'",
+		},
+		{
+			name:       "empty Name",
+			deployment: &Deployment{UID: "dep-uid", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr:    "Name is missing for Deployment 'dep-uid'",
+		},
+		{
+			name:       "empty NamespaceUID",
+			deployment: &Deployment{UID: "dep-uid", Name: "my-deployment", Start: start, End: end},
+			wantErr:    "NamespaceUID is missing for Deployment 'dep-uid'",
+		},
+		{
+			name:       "outside window",
+			deployment: &Deployment{UID: "dep-uid", Name: "my-deployment", NamespaceUID: "ns-uid", Start: start.Add(-time.Hour), End: end},
+			wantErr:    checkWindow(window, start.Add(-time.Hour), end).Error(),
+		},
+		{
+			name:       "valid",
+			deployment: &Deployment{UID: "dep-uid", Name: "my-deployment", NamespaceUID: "ns-uid", Start: start, End: end},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.deployment.ValidateDeployment(window)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestRegisterDeployment(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	newDeployment := func(uid, name string) *Deployment {
+		return &Deployment{UID: uid, Name: name, NamespaceUID: "ns-uid", Start: start, End: end}
+	}
+	withCluster := func(kms *KubeModelSet) {
+		kms.RegisterCluster(&Cluster{UID: "cluster-uid", Start: start, End: end})
+	}
+
+	tests := []struct {
+		name       string
+		setup      func(*KubeModelSet)
+		deployment *Deployment
+		wantErr    string
+		want       *KubeModelSet
+	}{
+		{
+			name:       "validation failure",
+			deployment: &Deployment{UID: "", Name: "my-deployment", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr:    "RegisterDeployment: invalid deployment: UID is missing for Deployment with name 'my-deployment'",
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelError, Message: "RegisterDeployment: invalid deployment: UID is missing for Deployment with name 'my-deployment'"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:       "warns when cluster is nil",
+			deployment: newDeployment("dep-uid", "my-deployment"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Deployments["dep-uid"] = newDeployment("dep-uid", "my-deployment")
+				kms.Metadata.ObjectCount = 1
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelWarning, Message: "RegisterDeployment: Cluster is nil"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:       "registers deployment with cluster",
+			setup:      withCluster,
+			deployment: newDeployment("dep-uid", "my-deployment"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.Deployments["dep-uid"] = newDeployment("dep-uid", "my-deployment")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+		{
+			name: "duplicate registration is a no-op",
+			setup: func(kms *KubeModelSet) {
+				withCluster(kms)
+				kms.RegisterDeployment(newDeployment("dep-uid", "original"))
+			},
+			deployment: newDeployment("dep-uid", "duplicate"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.Deployments["dep-uid"] = newDeployment("dep-uid", "original")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			kms := NewKubeModelSet(start, end)
+			if tt.setup != nil {
+				tt.setup(kms)
+			}
+
+			err := kms.RegisterDeployment(tt.deployment)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+
+			KubeModelSetEquals(t, tt.want, kms)
+		})
+	}
+}

+ 0 - 91
core/pkg/model/kubemodel/device.go

@@ -1,91 +0,0 @@
-package kubemodel
-
-import (
-	"errors"
-	"fmt"
-	"time"
-)
-
-type Device struct {
-	UID               string      `json:"uid"`            // Device UUID (hardware identifier)
-	Type              string      `json:"type,omitempty"` // Device type (e.g., "device", "tpu")
-	NodeUID           string      `json:"nodeUid"`        // Node hosting this device
-	DeviceNumber      int32       `json:"deviceNumber"`
-	ModelName         string      `json:"modelName"`
-	IsShared          bool        `json:"isShared"` // Device sharing information
-	SharePercentage   float64     `json:"sharePercentage"`
-	UsageSeconds      float64     `json:"usageSeconds"`      // Device seconds available
-	MemoryByteSeconds Measurement `json:"memoryByteSeconds"` // Device memory capacity in Byte-seconds
-	PowerWattSeconds  float64     `json:"powerWattSeconds"`  // Device power consumption in watt-seconds (Joules)
-	PowerWattMax      float64     `json:"powerWattMax"`      // Device max power consumption in watts
-	// Version 2 fields - Lifecycle tracking
-	Start           time.Time   `json:"start,omitempty"` // Device availability start
-	End             time.Time   `json:"end,omitempty"`   // Device availability end
-	DurationSeconds Measurement `json:"durationSeconds"` // Duration device was available
-}
-
-// Validate validates the Device fields
-func (d *Device) Validate() error {
-	if d.UID == "" {
-		return errors.New("UID is required")
-	}
-	if d.NodeUID == "" {
-		return errors.New("NodeUID is required")
-	}
-	if d.SharePercentage < 0 || d.SharePercentage > 100 {
-		return fmt.Errorf("SharePercentage must be 0-100, got %.2f", d.SharePercentage)
-	}
-	if d.PowerWattSeconds < 0 {
-		return fmt.Errorf("PowerWattSeconds cannot be negative, got %.2f", d.PowerWattSeconds)
-	}
-	if d.PowerWattMax < 0 {
-		return fmt.Errorf("PowerWattMax cannot be negative, got %.2f", d.PowerWattMax)
-	}
-	return nil
-}
-
-// Clone creates a deep copy of the Device
-func (d *Device) Clone() *Device {
-	if d == nil {
-		return nil
-	}
-
-	cloned := &Device{
-		UID:               d.UID,
-		Type:              d.Type,
-		NodeUID:           d.NodeUID,
-		DeviceNumber:      d.DeviceNumber,
-		ModelName:         d.ModelName,
-		IsShared:          d.IsShared,
-		SharePercentage:   d.SharePercentage,
-		UsageSeconds:      d.UsageSeconds,
-		MemoryByteSeconds: d.MemoryByteSeconds,
-		PowerWattSeconds:  d.PowerWattSeconds,
-		PowerWattMax:      d.PowerWattMax,
-		DurationSeconds:   d.DurationSeconds,
-	}
-
-	cloned.Start = d.Start
-	cloned.End = d.End
-
-	return cloned
-}
-
-func (kms *KubeModelSet) RegisterDevice(uid, nodeUID string) error {
-	if uid == "" {
-		err := fmt.Errorf("UID is nil for Device")
-		kms.Error(err)
-		return err
-	}
-
-	if _, ok := kms.Devices[uid]; !ok {
-		kms.Devices[uid] = &Device{
-			UID:     uid,
-			NodeUID: nodeUID,
-		}
-
-		kms.Metadata.ObjectCount++
-	}
-
-	return nil
-}

+ 0 - 85
core/pkg/model/kubemodel/device_usage.go

@@ -1,85 +0,0 @@
-package kubemodel
-
-import (
-	"errors"
-	"fmt"
-	"time"
-)
-
-type DeviceUsage struct {
-	ContainerUID          string      `json:"containerUid"`
-	DeviceUID             string      `json:"deviceUid"`
-	UsageSeconds          Measurement `json:"usageSeconds"`
-	UsagePercentageMax    float64     `json:"usagePercentageMax"`
-	MemoryByteSecondsUsed Measurement `json:"memoryByteSecondsUsed"`
-	DeviceType            string      `json:"deviceType,omitempty"`
-	DurationSeconds       Measurement `json:"durationSeconds,omitempty"`
-	Start                 time.Time   `json:"start"`
-	End                   time.Time   `json:"end"`
-}
-
-func (u *DeviceUsage) Validate() error {
-	if u.ContainerUID == "" {
-		return errors.New("ContainerUID is required")
-	}
-	if u.DeviceUID == "" {
-		return errors.New("DeviceUID is required")
-	}
-	if u.UsagePercentageMax < 0 || u.UsagePercentageMax > 100 {
-		return fmt.Errorf("UsagePercentageMax must be 0-100, got %.2f", u.UsagePercentageMax)
-	}
-	return nil
-}
-
-func (u *DeviceUsage) Clone() *DeviceUsage {
-	if u == nil {
-		return nil
-	}
-
-	cloned := &DeviceUsage{
-		ContainerUID:          u.ContainerUID,
-		DeviceUID:             u.DeviceUID,
-		UsageSeconds:          u.UsageSeconds,
-		UsagePercentageMax:    u.UsagePercentageMax,
-		MemoryByteSecondsUsed: u.MemoryByteSecondsUsed,
-		DeviceType:            u.DeviceType,
-		DurationSeconds:       u.DurationSeconds,
-		Start:                 u.Start,
-		End:                   u.End,
-	}
-
-	return cloned
-}
-
-func (u *DeviceUsage) UsageAverage() Measurement {
-	if u.DurationSeconds == 0 {
-		return 0
-	}
-	return (u.UsageSeconds / u.DurationSeconds) * 100
-}
-
-func (u *DeviceUsage) MemoryByteUsageAverage() Measurement {
-	if u.DurationSeconds == 0 {
-		return 0
-	}
-	return u.MemoryByteSecondsUsed / u.DurationSeconds
-}
-
-func (kms *KubeModelSet) RegisterUsage(id, containerID, deviceId string) error {
-	if id == "" {
-		err := fmt.Errorf("UID is nil for DeviceUsage")
-		kms.Error(err)
-		return err
-	}
-
-	if _, ok := kms.DeviceUsages[id]; !ok {
-		kms.DeviceUsages[id] = &DeviceUsage{
-			ContainerUID: containerID,
-			DeviceUID:    deviceId,
-		}
-
-		kms.Metadata.ObjectCount++
-	}
-
-	return nil
-}

+ 58 - 0
core/pkg/model/kubemodel/job.go

@@ -0,0 +1,58 @@
+package kubemodel
+
+import (
+	"fmt"
+	"time"
+)
+
+// @bingen:generate:Job
+// Job represents a Kubernetes Job resource
+type Job struct {
+	UID          string            `json:"uid"`
+	NamespaceUID string            `json:"namespaceUid"`
+	Name         string            `json:"name"`
+	Labels       map[string]string `json:"labels,omitempty"`
+	Annotations  map[string]string `json:"annotations,omitempty"`
+	Start        time.Time         `json:"start,omitempty"`
+	End          time.Time         `json:"end,omitempty"`
+}
+
+func (j *Job) ValidateJob(window Window) error {
+	if j.UID == "" {
+		return fmt.Errorf("UID is missing for Job with name '%s'", j.Name)
+	}
+
+	if j.Name == "" {
+		return fmt.Errorf("Name is missing for Job '%s'", j.UID)
+	}
+
+	if j.NamespaceUID == "" {
+		return fmt.Errorf("NamespaceUID is missing for Job '%s'", j.UID)
+	}
+
+	if err := checkWindow(window, j.Start, j.End); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (kms *KubeModelSet) RegisterJob(job *Job) error {
+	if err := job.ValidateJob(kms.Window); err != nil {
+		err = fmt.Errorf("RegisterJob: invalid job: %w", err)
+		kms.Error(err)
+		return err
+	}
+
+	if _, ok := kms.Jobs[job.UID]; !ok {
+		if kms.Cluster == nil {
+			kms.Warnf("RegisterJob: Cluster is nil")
+		}
+
+		kms.Jobs[job.UID] = job
+
+		kms.Metadata.ObjectCount++
+	}
+
+	return nil
+}

+ 148 - 0
core/pkg/model/kubemodel/job_test.go

@@ -0,0 +1,148 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidateJob(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	tests := []struct {
+		name    string
+		job     *Job
+		wantErr string
+	}{
+		{
+			name:    "empty UID",
+			job:     &Job{Name: "my-job", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr: "UID is missing for Job with name 'my-job'",
+		},
+		{
+			name:    "empty Name",
+			job:     &Job{UID: "job-uid", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr: "Name is missing for Job 'job-uid'",
+		},
+		{
+			name:    "empty NamespaceUID",
+			job:     &Job{UID: "job-uid", Name: "my-job", Start: start, End: end},
+			wantErr: "NamespaceUID is missing for Job 'job-uid'",
+		},
+		{
+			name:    "outside window",
+			job:     &Job{UID: "job-uid", Name: "my-job", NamespaceUID: "ns-uid", Start: start.Add(-time.Hour), End: end},
+			wantErr: checkWindow(window, start.Add(-time.Hour), end).Error(),
+		},
+		{
+			name: "valid",
+			job:  &Job{UID: "job-uid", Name: "my-job", NamespaceUID: "ns-uid", Start: start, End: end},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.job.ValidateJob(window)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestRegisterJob(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	newJob := func(uid, name string) *Job {
+		return &Job{UID: uid, Name: name, NamespaceUID: "ns-uid", Start: start, End: end}
+	}
+	withCluster := func(kms *KubeModelSet) {
+		kms.RegisterCluster(&Cluster{UID: "cluster-uid", Start: start, End: end})
+	}
+
+	tests := []struct {
+		name    string
+		setup   func(*KubeModelSet)
+		job     *Job
+		wantErr string
+		want    *KubeModelSet
+	}{
+		{
+			name:    "validation failure",
+			job:     &Job{UID: "", Name: "my-job", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr: "RegisterJob: invalid job: UID is missing for Job with name 'my-job'",
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelError, Message: "RegisterJob: invalid job: UID is missing for Job with name 'my-job'"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name: "warns when cluster is nil",
+			job:  newJob("job-uid", "my-job"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Jobs["job-uid"] = newJob("job-uid", "my-job")
+				kms.Metadata.ObjectCount = 1
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelWarning, Message: "RegisterJob: Cluster is nil"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:  "registers job with cluster",
+			setup: withCluster,
+			job:   newJob("job-uid", "my-job"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.Jobs["job-uid"] = newJob("job-uid", "my-job")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+		{
+			name: "duplicate registration is a no-op",
+			setup: func(kms *KubeModelSet) {
+				withCluster(kms)
+				kms.RegisterJob(newJob("job-uid", "original"))
+			},
+			job: newJob("job-uid", "duplicate"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.Jobs["job-uid"] = newJob("job-uid", "original")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			kms := NewKubeModelSet(start, end)
+			if tt.setup != nil {
+				tt.setup(kms)
+			}
+
+			err := kms.RegisterJob(tt.job)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+
+			KubeModelSetEquals(t, tt.want, kms)
+		})
+	}
+}

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

@@ -1,28 +1,30 @@
 package kubemodel
 
 import (
+	"fmt"
 	"time"
 )
 
-// TODO: should we add a lock so that we can safely modify KubeModelSet in parallel?
-
 // @bingen:generate[streamable,stringtable]:KubeModelSet
 type KubeModelSet struct {
-	Metadata               *Metadata                         `json:"meta"`                   // @bingen:field[version=1]
-	Window                 Window                            `json:"window"`                 // @bingen:field[version=1]
-	Cluster                *Cluster                          `json:"cluster"`                // @bingen:field[version=1]
-	Namespaces             map[string]*Namespace             `json:"namespaces"`             // @bingen:field[version=1]
-	ResourceQuotas         map[string]*ResourceQuota         `json:"resourceQuotas"`         // @bingen:field[version=1]
-	Containers             map[string]*Container             `json:"containers,omitempty"`   // @bingen:field[ignore]
-	Owners                 map[string]*Owner                 `json:"owners,omitempty"`       // @bingen:field[ignore]
-	Devices                map[string]*Device                `json:"devices,omitempty"`      // @bingen:field[ignore]
-	DeviceUsages           map[string]*DeviceUsage           `json:"deviceUsages,omitempty"` // @bingen:field[ignore]
-	Nodes                  map[string]*Node                  `json:"nodes,omitempty"`        // @bingen:field[ignore]
-	Pods                   map[string]*Pod                   `json:"pods,omitempty"`         // @bingen:field[ignore]
-	PersistentVolumeClaims map[string]*PersistentVolumeClaim `json:"pvcs,omitempty"`         // @bingen:field[ignore]
-	Services               map[string]*Service               `json:"services,omitempty"`     // @bingen:field[ignore]
-	Volumes                map[string]*PersistentVolume      `json:"volumes,omitempty"`      // @bingen:field[ignore]
-	idx                    *kubeModelSetIndexes              // @bingen:field[ignore]
+	Metadata               *Metadata                         `json:"meta"`              // @bingen:field[version=1]
+	Window                 Window                            `json:"window"`            // @bingen:field[version=1]
+	Cluster                *Cluster                          `json:"cluster"`           // @bingen:field[version=1]
+	Namespaces             map[string]*Namespace             `json:"namespaces"`        // @bingen:field[version=1]
+	ResourceQuotas         map[string]*ResourceQuota         `json:"resourceQuotas"`    // @bingen:field[version=1]
+	Services               map[string]*Service               `json:"services"`          // @bingen:field[version=2]
+	Deployments            map[string]*Deployment            `json:"deployments"`       // @bingen:field[version=2]
+	StatefulSets           map[string]*StatefulSet           `json:"statefulSets"`      // @bingen:field[version=2]
+	DaemonSets             map[string]*DaemonSet             `json:"daemonSets"`        // @bingen:field[version=2]
+	Jobs                   map[string]*Job                   `json:"jobs"`              // @bingen:field[version=2]
+	CronJobs               map[string]*CronJob               `json:"cronJobs"`          // @bingen:field[version=2]
+	ReplicaSets            map[string]*ReplicaSet            `json:"replicaSets"`       // @bingen:field[version=2]
+	Nodes                  map[string]*Node                  `json:"nodes"`             // @bingen:field[version=2]
+	PersistentVolumes      map[string]*PersistentVolume      `json:"persistentVolumes"` // @bingen:field[version=2]
+	PersistentVolumeClaims map[string]*PersistentVolumeClaim `json:"pvcs"`              // @bingen:field[version=2]
+	Pods                   map[string]*Pod                   `json:"pods"`              // @bingen:field[version=2]
+	Containers             map[string]*Container             `json:"containers"`        // @bingen:field[version=2]
+	DCGMDevices            map[string]*DCGMDevice            `json:"dcgmDevices"`       // @bingen:field[ignore]
 }
 
 func NewKubeModelSet(start time.Time, end time.Time) *KubeModelSet {
@@ -38,36 +40,24 @@ func NewKubeModelSet(start time.Time, end time.Time) *KubeModelSet {
 			End:   end,
 		},
 		Containers:             map[string]*Container{},
-		Owners:                 map[string]*Owner{},
-		Devices:                map[string]*Device{},
-		DeviceUsages:           map[string]*DeviceUsage{},
+		Deployments:            map[string]*Deployment{},
+		StatefulSets:           map[string]*StatefulSet{},
+		DaemonSets:             map[string]*DaemonSet{},
+		Jobs:                   map[string]*Job{},
+		CronJobs:               map[string]*CronJob{},
+		ReplicaSets:            map[string]*ReplicaSet{},
 		Namespaces:             map[string]*Namespace{},
 		Nodes:                  map[string]*Node{},
+		DCGMDevices:            map[string]*DCGMDevice{},
 		Pods:                   map[string]*Pod{},
 		PersistentVolumeClaims: map[string]*PersistentVolumeClaim{},
 		ResourceQuotas:         map[string]*ResourceQuota{},
 		Services:               map[string]*Service{},
-		Volumes:                map[string]*PersistentVolume{},
-		idx:                    newKubeModelSetIndexes(),
+		PersistentVolumes:      map[string]*PersistentVolume{},
 	}
 	return kms
 }
 
-// GetNamespaceByName retrieves a namespace by its name using the index
-func (kms *KubeModelSet) GetNamespaceByName(name string) (*Namespace, bool) {
-	if kms.idx == nil {
-		return nil, false
-	}
-
-	uid, ok := kms.idx.namespaceNameToID[name]
-	if !ok {
-		return nil, false
-	}
-
-	ns, ok := kms.Namespaces[uid]
-	return ns, ok
-}
-
 // IsEmpty returns true if the KubeModelSet is nil, has no cluster, or contains no resources
 func (kms *KubeModelSet) IsEmpty() bool {
 	if kms == nil || kms.Cluster == nil {
@@ -76,26 +66,36 @@ func (kms *KubeModelSet) IsEmpty() bool {
 
 	// Check if all resource maps are empty
 	return len(kms.Containers) == 0 &&
-		len(kms.Owners) == 0 &&
-		len(kms.Devices) == 0 &&
-		len(kms.DeviceUsages) == 0 &&
+		len(kms.Deployments) == 0 &&
+		len(kms.StatefulSets) == 0 &&
+		len(kms.DaemonSets) == 0 &&
+		len(kms.Jobs) == 0 &&
+		len(kms.CronJobs) == 0 &&
+		len(kms.ReplicaSets) == 0 &&
 		len(kms.Namespaces) == 0 &&
 		len(kms.Nodes) == 0 &&
+		len(kms.DCGMDevices) == 0 &&
 		len(kms.Pods) == 0 &&
 		len(kms.PersistentVolumeClaims) == 0 &&
 		len(kms.ResourceQuotas) == 0 &&
 		len(kms.Services) == 0 &&
-		len(kms.Volumes) == 0
-}
-
-type kubeModelSetIndexes struct {
-	namespaceNameToID map[string]string
-	namespaceByName   map[string]*Namespace
+		len(kms.PersistentVolumes) == 0
 }
 
-func newKubeModelSetIndexes() *kubeModelSetIndexes {
-	return &kubeModelSetIndexes{
-		namespaceNameToID: make(map[string]string),
-		namespaceByName:   make(map[string]*Namespace),
+// checkWindow validates that the given start/end times are fully contained within
+// the KubeModelSet window. It records and returns an error if they are not.
+func checkWindow(window Window, start, end time.Time) error {
+	if window.Start.After(start) ||
+		window.Start.After(end) ||
+		window.End.Before(start) ||
+		window.End.Before(end) {
+		return fmt.Errorf(
+			"start or end time (%s-%s) is outside of the window %s-%s",
+			start.Format(time.RFC3339),
+			end.Format(time.RFC3339),
+			window.Start.Format(time.RFC3339),
+			window.End.Format(time.RFC3339),
+		)
 	}
+	return nil
 }

Plik diff jest za duży
+ 7457 - 557
core/pkg/model/kubemodel/kubemodel_codecs.go


+ 32 - 452
core/pkg/model/kubemodel/kubemodel_codecs_test.go

@@ -1,474 +1,54 @@
 package kubemodel
 
 import (
-	"errors"
+	"bytes"
 	"testing"
 	"time"
 
 	"github.com/stretchr/testify/require"
 )
 
-func TestKubeModelMarshalBinary(t *testing.T) {
-	s := time.Now().UTC().Truncate(time.Hour)
-	e := s.Add(time.Hour)
+func TestKubeModelSetCodecRoundTrip(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
 
-	// Test empty KubeModelSet
+	t.Run("empty KubeModelSet", func(t *testing.T) {
+		kms := NewKubeModelSet(start, end)
 
-	kms := NewKubeModelSet(s, e)
+		b, err := kms.MarshalBinary()
+		require.NoError(t, err)
 
-	b, err := kms.MarshalBinary()
-	require.NoError(t, err)
+		act := new(KubeModelSet)
+		err = act.UnmarshalBinary(b)
+		require.NoError(t, err)
 
-	var act = new(KubeModelSet)
-	err = act.UnmarshalBinary(b)
-	require.NoError(t, err)
+		KubeModelSetEquals(t, kms, act)
+	})
 
-	require.Equal(t, kms.Metadata, act.Metadata)
-	require.Equal(t, kms.Window, act.Window)
-	require.Equal(t, kms.Cluster, act.Cluster)
-	require.Equal(t, kms.Namespaces, act.Namespaces)
-	require.Equal(t, kms.ResourceQuotas, act.ResourceQuotas)
+	t.Run("full KubeModelSet", func(t *testing.T) {
+		kms := NewMockKubeModelSet(start, end)
 
-	// Test non-empty KubeModelSet
+		b, err := kms.MarshalBinary()
+		require.NoError(t, err)
 
-	kms = NewKubeModelSet(s, e)
+		act := new(KubeModelSet)
+		err = act.UnmarshalBinary(b)
+		require.NoError(t, err)
 
-	kms.Metadata.CreatedAt = time.Now().UTC()
+		KubeModelSetEquals(t, kms, act)
+	})
 
-	kms.RegisterCluster("cluster")
-	kms.Cluster.Start = s
-	kms.Cluster.End = e
+	t.Run("full KubeModelSet streaming (MarshalBinaryTo/UnmarshalBinaryFromReader)", func(t *testing.T) {
+		kms := NewMockKubeModelSet(start, end)
 
-	kms.RegisterNamespace("ns1", "ns1")
-	kms.Namespaces["ns1"].Start = s
-	kms.Namespaces["ns1"].End = e
-	kms.Namespaces["ns1"].Labels = map[string]string{"label1": "label1"}
-	kms.Namespaces["ns1"].Annotations = map[string]string{"anno1": "anno1"}
+		var buf bytes.Buffer
+		err := kms.MarshalBinaryTo(&buf)
+		require.NoError(t, err)
 
-	kms.RegisterNamespace("ns2", "ns2")
-	kms.Namespaces["ns2"].Start = s
-	kms.Namespaces["ns2"].End = e
-	kms.Namespaces["ns2"].Labels = map[string]string{"label2": "label2"}
-	kms.Namespaces["ns2"].Annotations = map[string]string{"anno2": "anno2"}
+		act := new(KubeModelSet)
+		err = act.UnmarshalBinaryFromReader(&buf)
+		require.NoError(t, err)
 
-	kms.RegisterResourceQuota("rq1", "rq1", "ns1")
-	kms.ResourceQuotas["rq1"].Start = s
-	kms.ResourceQuotas["rq1"].End = e
-	kms.ResourceQuotas["rq1"].Spec = &ResourceQuotaSpec{
-		Hard: &ResourceQuotaSpecHard{
-			Requests: ResourceQuantities{
-				ResourceCPU: ResourceQuantity{
-					Resource: ResourceCPU,
-					Unit:     UnitMillicore,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-				ResourceMemory: ResourceQuantity{
-					Resource: ResourceMemory,
-					Unit:     UnitByte,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-			},
-			Limits: ResourceQuantities{
-				ResourceCPU: ResourceQuantity{
-					Resource: ResourceCPU,
-					Unit:     UnitMillicore,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-				ResourceMemory: ResourceQuantity{
-					Resource: ResourceMemory,
-					Unit:     UnitByte,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-			},
-		},
-	}
-	kms.ResourceQuotas["rq1"].Status = &ResourceQuotaStatus{
-		Used: &ResourceQuotaStatusUsed{
-			Requests: ResourceQuantities{
-				ResourceCPU: ResourceQuantity{
-					Resource: ResourceCPU,
-					Unit:     UnitMillicore,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-				ResourceMemory: ResourceQuantity{
-					Resource: ResourceMemory,
-					Unit:     UnitByte,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-			},
-			Limits: ResourceQuantities{
-				ResourceCPU: ResourceQuantity{
-					Resource: ResourceCPU,
-					Unit:     UnitMillicore,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-				ResourceMemory: ResourceQuantity{
-					Resource: ResourceMemory,
-					Unit:     UnitByte,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-			},
-		},
-	}
-
-	kms.RegisterResourceQuota("rq2", "rq2", "ns1")
-	kms.ResourceQuotas["rq2"].Start = s
-	kms.ResourceQuotas["rq2"].End = e
-	kms.ResourceQuotas["rq2"].Spec = &ResourceQuotaSpec{
-		Hard: &ResourceQuotaSpecHard{
-			Requests: ResourceQuantities{
-				ResourceCPU: ResourceQuantity{
-					Resource: ResourceCPU,
-					Unit:     UnitMillicore,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-				ResourceMemory: ResourceQuantity{
-					Resource: ResourceMemory,
-					Unit:     UnitByte,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-			},
-			Limits: ResourceQuantities{
-				ResourceCPU: ResourceQuantity{
-					Resource: ResourceCPU,
-					Unit:     UnitMillicore,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-				ResourceMemory: ResourceQuantity{
-					Resource: ResourceMemory,
-					Unit:     UnitByte,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-			},
-		},
-	}
-	kms.ResourceQuotas["rq2"].Status = &ResourceQuotaStatus{
-		Used: &ResourceQuotaStatusUsed{
-			Requests: ResourceQuantities{
-				ResourceCPU: ResourceQuantity{
-					Resource: ResourceCPU,
-					Unit:     UnitMillicore,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-				ResourceMemory: ResourceQuantity{
-					Resource: ResourceMemory,
-					Unit:     UnitByte,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-			},
-			Limits: ResourceQuantities{
-				ResourceCPU: ResourceQuantity{
-					Resource: ResourceCPU,
-					Unit:     UnitMillicore,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-				ResourceMemory: ResourceQuantity{
-					Resource: ResourceMemory,
-					Unit:     UnitByte,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-			},
-		},
-	}
-
-	kms.RegisterResourceQuota("rq3", "rq3", "ns2")
-	kms.ResourceQuotas["rq3"].Start = s
-	kms.ResourceQuotas["rq3"].End = e
-	kms.ResourceQuotas["rq3"].Spec = &ResourceQuotaSpec{
-		Hard: &ResourceQuotaSpecHard{
-			Requests: ResourceQuantities{
-				ResourceCPU: ResourceQuantity{
-					Resource: ResourceCPU,
-					Unit:     UnitMillicore,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-				ResourceMemory: ResourceQuantity{
-					Resource: ResourceMemory,
-					Unit:     UnitByte,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-			},
-			Limits: ResourceQuantities{
-				ResourceCPU: ResourceQuantity{
-					Resource: ResourceCPU,
-					Unit:     UnitMillicore,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-				ResourceMemory: ResourceQuantity{
-					Resource: ResourceMemory,
-					Unit:     UnitByte,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-			},
-		},
-	}
-	kms.ResourceQuotas["rq3"].Status = &ResourceQuotaStatus{
-		Used: &ResourceQuotaStatusUsed{
-			Requests: ResourceQuantities{
-				ResourceCPU: ResourceQuantity{
-					Resource: ResourceCPU,
-					Unit:     UnitMillicore,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-				ResourceMemory: ResourceQuantity{
-					Resource: ResourceMemory,
-					Unit:     UnitByte,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-			},
-			Limits: ResourceQuantities{
-				ResourceCPU: ResourceQuantity{
-					Resource: ResourceCPU,
-					Unit:     UnitMillicore,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-				ResourceMemory: ResourceQuantity{
-					Resource: ResourceMemory,
-					Unit:     UnitByte,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-			},
-		},
-	}
-
-	kms.RegisterResourceQuota("rq4", "rq4", "ns2")
-	kms.ResourceQuotas["rq4"].Start = s
-	kms.ResourceQuotas["rq4"].End = e
-	kms.ResourceQuotas["rq4"].Spec = &ResourceQuotaSpec{
-		Hard: &ResourceQuotaSpecHard{
-			Requests: ResourceQuantities{
-				ResourceCPU: ResourceQuantity{
-					Resource: ResourceCPU,
-					Unit:     UnitMillicore,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-				ResourceMemory: ResourceQuantity{
-					Resource: ResourceMemory,
-					Unit:     UnitByte,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-			},
-			Limits: ResourceQuantities{
-				ResourceCPU: ResourceQuantity{
-					Resource: ResourceCPU,
-					Unit:     UnitMillicore,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-				ResourceMemory: ResourceQuantity{
-					Resource: ResourceMemory,
-					Unit:     UnitByte,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-			},
-		},
-	}
-	kms.ResourceQuotas["rq4"].Status = &ResourceQuotaStatus{
-		Used: &ResourceQuotaStatusUsed{
-			Requests: ResourceQuantities{
-				ResourceCPU: ResourceQuantity{
-					Resource: ResourceCPU,
-					Unit:     UnitMillicore,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-				ResourceMemory: ResourceQuantity{
-					Resource: ResourceMemory,
-					Unit:     UnitByte,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-			},
-			Limits: ResourceQuantities{
-				ResourceCPU: ResourceQuantity{
-					Resource: ResourceCPU,
-					Unit:     UnitMillicore,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-				ResourceMemory: ResourceQuantity{
-					Resource: ResourceMemory,
-					Unit:     UnitByte,
-					Values: Stats{
-						StatAvg: 1,
-						StatMax: 1,
-						StatP85: 1,
-						StatP95: 1,
-					},
-				},
-			},
-		},
-	}
-
-	kms.Error(errors.New("test error"))
-	kms.Warn("test warning")
-	kms.Info("test info")
-	kms.Debug("test debug")
-	kms.Trace("test trace")
-
-	kms.Metadata.CompletedAt = time.Now().UTC()
-
-	b, err = kms.MarshalBinary()
-	require.NoError(t, err)
-
-	act = new(KubeModelSet)
-	err = act.UnmarshalBinary(b)
-	require.NoError(t, err)
-
-	require.Equal(t, kms.Metadata, act.Metadata)
-	require.Equal(t, kms.Window, act.Window)
-	require.Equal(t, kms.Cluster, act.Cluster)
-	require.Equal(t, kms.Namespaces, act.Namespaces)
-	require.Equal(t, kms.ResourceQuotas, act.ResourceQuotas)
+		KubeModelSetEquals(t, kms, act)
+	})
 }

+ 64 - 0
core/pkg/model/kubemodel/kubemodel_helpers_test.go

@@ -0,0 +1,64 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+// requireWindowEqual compares two start/end time pairs using time.Time.Equal,
+// which is insensitive to monotonic clock readings and timezone representation.
+func requireWindowEqual(t *testing.T, thisStart, thatStart, thisEnd, thatEnd time.Time) {
+	t.Helper()
+	require.True(t, thisStart.Equal(thatStart), "Start mismatch: %v != %v", thisStart, thatStart)
+	require.True(t, thisEnd.Equal(thatEnd), "End mismatch: %v != %v", thisEnd, thatEnd)
+}
+
+// DiagnosticEquals asserts that two Diagnostics have identical state.
+// Timestamp is excluded from comparison since it is volatile across instances.
+func DiagnosticEquals(t *testing.T, this, that Diagnostic) {
+	t.Helper()
+	require.Equal(t, this.Level, that.Level)
+	require.Equal(t, this.Message, that.Message)
+	require.Equal(t, this.Details, that.Details)
+}
+
+// MetadataEquals asserts that two Metadata structs have identical state.
+// Timestamps (CreatedAt, CompletedAt, Diagnostic.Timestamp) are excluded
+// from comparison since they are volatile across instances.
+func MetadataEquals(t *testing.T, this, that *Metadata) {
+	t.Helper()
+	require.Equal(t, this.ObjectCount, that.ObjectCount)
+	require.Equal(t, this.DiagnosticLevel, that.DiagnosticLevel)
+	require.Equal(t, len(this.Diagnostics), len(that.Diagnostics))
+	for i := range this.Diagnostics {
+		DiagnosticEquals(t, this.Diagnostics[i], that.Diagnostics[i])
+	}
+}
+
+// KubeModelSetEquals asserts that two KubeModelSets have identical state.
+// Metadata timestamps (CreatedAt, CompletedAt, Diagnostic.Timestamp) are
+// excluded from comparison since they are volatile across instances.
+func KubeModelSetEquals(t *testing.T, this, that *KubeModelSet) {
+	t.Helper()
+	require.Equal(t, this.Window, that.Window)
+	require.Equal(t, this.Cluster, that.Cluster)
+	MetadataEquals(t, this.Metadata, that.Metadata)
+	require.Equal(t, this.Namespaces, that.Namespaces)
+	require.Equal(t, this.ResourceQuotas, that.ResourceQuotas)
+	require.Equal(t, this.Containers, that.Containers)
+	require.Equal(t, this.Deployments, that.Deployments)
+	require.Equal(t, this.StatefulSets, that.StatefulSets)
+	require.Equal(t, this.DaemonSets, that.DaemonSets)
+	require.Equal(t, this.Jobs, that.Jobs)
+	require.Equal(t, this.CronJobs, that.CronJobs)
+	require.Equal(t, this.ReplicaSets, that.ReplicaSets)
+	require.Equal(t, this.Nodes, that.Nodes)
+	require.Equal(t, this.Pods, that.Pods)
+	require.Equal(t, this.PersistentVolumeClaims, that.PersistentVolumeClaims)
+	require.Equal(t, this.Services, that.Services)
+	require.Equal(t, this.PersistentVolumes, that.PersistentVolumes)
+	// DCGM is ignored by bingen
+	// require.Equal(t, this.DCGMDevices, that.DCGMDevices)
+}

+ 109 - 69
core/pkg/model/kubemodel/kubemodel_test.go

@@ -2,12 +2,76 @@ package kubemodel
 
 import (
 	"errors"
+	"fmt"
 	"testing"
 	"time"
 
 	"github.com/stretchr/testify/require"
 )
 
+func TestCheckWindow(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	windowErrMsg := func(s, e time.Time) string {
+		return fmt.Sprintf(
+			"start or end time (%s-%s) is outside of the window %s-%s",
+			s.Format(time.RFC3339),
+			e.Format(time.RFC3339),
+			start.Format(time.RFC3339),
+			end.Format(time.RFC3339),
+		)
+	}
+
+	tests := []struct {
+		name    string
+		start   time.Time
+		end     time.Time
+		wantErr string
+	}{
+		{
+			name:    "start before window",
+			start:   start.Add(-time.Minute),
+			end:     end,
+			wantErr: windowErrMsg(start.Add(-time.Minute), end),
+		},
+		{
+			name:    "end after window",
+			start:   start,
+			end:     end.Add(time.Minute),
+			wantErr: windowErrMsg(start, end.Add(time.Minute)),
+		},
+		{
+			name:    "entirely outside window",
+			start:   end.Add(time.Hour),
+			end:     end.Add(2 * time.Hour),
+			wantErr: windowErrMsg(end.Add(time.Hour), end.Add(2*time.Hour)),
+		},
+		{
+			name:  "exact window boundaries",
+			start: start,
+			end:   end,
+		},
+		{
+			name:  "within window",
+			start: start.Add(15 * time.Minute),
+			end:   end.Add(-15 * time.Minute),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := checkWindow(window, tt.start, tt.end)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
 func TestKubeModel(t *testing.T) {
 	start := time.Now().UTC().Truncate(time.Hour)
 	end := start.Add(time.Hour)
@@ -33,11 +97,11 @@ func TestKubeModel(t *testing.T) {
 
 			kms := NewKubeModelSet(start, end)
 
-			err = kms.RegisterCluster("")
+			err = kms.RegisterCluster(&Cluster{UID: ""})
 			require.NotNil(t, err)
 
 			require.Len(t, kms.GetErrors(), 1)
-			require.Equal(t, "RegisterCluster: uid is nil", kms.GetErrors()[0].Message)
+			require.Equal(t, "RegisterCluster: invalid cluster: UID is missing for Cluster with name ''", kms.GetErrors()[0].Message)
 			require.Nil(t, kms.Cluster)
 		})
 
@@ -47,7 +111,7 @@ func TestKubeModel(t *testing.T) {
 
 			kms := NewKubeModelSet(start, end)
 
-			err = kms.RegisterCluster(clusterUID)
+			err = kms.RegisterCluster(&Cluster{UID: clusterUID, Start: start, End: end})
 			require.Nil(t, err)
 
 			require.Len(t, kms.GetErrors(), 0)
@@ -61,7 +125,7 @@ func TestKubeModel(t *testing.T) {
 
 			kms := NewKubeModelSet(start, end)
 
-			err = kms.RegisterCluster(clusterUID)
+			err = kms.RegisterCluster(&Cluster{UID: clusterUID, Start: start, End: end})
 			require.Nil(t, err)
 
 			require.Len(t, kms.GetErrors(), 0)
@@ -69,7 +133,7 @@ func TestKubeModel(t *testing.T) {
 			require.Equal(t, clusterUID, kms.Cluster.UID)
 
 			// Register cluster with same UID, expect no-op on second try
-			err = kms.RegisterCluster(clusterUID)
+			err = kms.RegisterCluster(&Cluster{UID: clusterUID, Start: start, End: end})
 			require.Nil(t, err)
 
 			require.Len(t, kms.GetErrors(), 0)
@@ -77,7 +141,7 @@ func TestKubeModel(t *testing.T) {
 			require.Equal(t, clusterUID, kms.Cluster.UID)
 
 			// Register cluster with another UID (should not happen), expect no-op
-			err = kms.RegisterCluster("another-uid")
+			err = kms.RegisterCluster(&Cluster{UID: "another-uid", Start: start, End: end})
 			require.Nil(t, err)
 
 			require.Len(t, kms.GetWarnings(), 1)
@@ -93,11 +157,11 @@ func TestKubeModel(t *testing.T) {
 
 			kms := NewKubeModelSet(start, end)
 
-			err = kms.RegisterNamespace("", "")
+			err = kms.RegisterNamespace(&Namespace{UID: "", Name: ""})
 			require.NotNil(t, err)
 
 			require.Len(t, kms.GetErrors(), 1)
-			require.Equal(t, "UID is nil for Namespace ''", kms.GetErrors()[0].Message)
+			require.Equal(t, "RegisterNamespace: invalid namespace: UID is missing for Namespace with name ''", kms.GetErrors()[0].Message)
 			require.Len(t, kms.Namespaces, 0)
 		})
 
@@ -109,18 +173,16 @@ func TestKubeModel(t *testing.T) {
 			testUID := "uid"
 			testName := "name"
 
-			err = kms.RegisterNamespace(testUID, testName)
+			err = kms.RegisterNamespace(&Namespace{UID: testUID, Name: testName, Start: start, End: end})
 			require.Nil(t, err)
 
 			require.Len(t, kms.GetWarnings(), 1)
-			require.Equal(t, "RegisterNamespace(uid, name): Cluster is nil", kms.GetWarnings()[0].Message)
+			require.Equal(t, "RegisterNamespace: Cluster is nil", kms.GetWarnings()[0].Message)
 
-			testNamespace := &Namespace{UID: testUID, ClusterUID: "", Name: testName}
+			testNamespace := &Namespace{UID: testUID, Name: testName, Start: start, End: end}
 
 			require.NotNil(t, kms.Namespaces[testUID])
 			require.Equal(t, testNamespace, kms.Namespaces[testUID])
-			require.NotNil(t, kms.idx.namespaceByName[testName])
-			require.Equal(t, testNamespace, kms.idx.namespaceByName[testName])
 			require.Equal(t, 1, kms.Metadata.ObjectCount)
 		})
 
@@ -128,7 +190,7 @@ func TestKubeModel(t *testing.T) {
 			var err error
 
 			kms := NewKubeModelSet(start, end)
-			err = kms.RegisterCluster("cluster-uid")
+			err = kms.RegisterCluster(&Cluster{UID: "cluster-uid", Start: start, End: end})
 			require.Nil(t, err)
 
 			// At this point we have a KMS with a cluster registered
@@ -136,26 +198,24 @@ func TestKubeModel(t *testing.T) {
 			testUID := "uid"
 			testName := "name"
 
-			err = kms.RegisterNamespace(testUID, testName)
+			err = kms.RegisterNamespace(&Namespace{UID: testUID, Name: testName, Start: start, End: end})
 			require.Nil(t, err)
 
 			require.Len(t, kms.GetErrors(), 0)
 			require.NotNil(t, kms.Namespaces[testUID])
 
-			testNamespace := &Namespace{UID: testUID, ClusterUID: "cluster-uid", Name: testName}
+			testNamespace := &Namespace{UID: testUID, Name: testName, Start: start, End: end}
 
 			require.Equal(t, testNamespace, kms.Namespaces[testUID])
-			require.Equal(t, testNamespace, kms.idx.namespaceByName[testName])
 			require.Equal(t, 1, kms.Metadata.ObjectCount)
 
 			// Register same namespace again, expect no-op on second try
-			err = kms.RegisterNamespace(testUID, testName)
+			err = kms.RegisterNamespace(&Namespace{UID: testUID, Name: testName, Start: start, End: end})
 			require.Nil(t, err)
 
 			require.Len(t, kms.GetErrors(), 0)
 			require.NotNil(t, kms.Namespaces[testUID])
 			require.Equal(t, testNamespace, kms.Namespaces[testUID])
-			require.Equal(t, testNamespace, kms.idx.namespaceByName[testName])
 			require.Equal(t, 1, kms.Metadata.ObjectCount) // remains 1
 		})
 	})
@@ -166,55 +226,42 @@ func TestKubeModel(t *testing.T) {
 
 			kms := NewKubeModelSet(start, end)
 
-			err = kms.RegisterResourceQuota("", "test", "")
+			err = kms.RegisterResourceQuota(&ResourceQuota{UID: "", Name: "test"})
 			require.NotNil(t, err)
 			require.Len(t, kms.GetErrors(), 1)
-			require.Equal(t, "UID is nil for ResourceQuota 'test'", kms.GetErrors()[0].Message)
+			require.Equal(t, "RegisterResourceQuota: invalid resource quota: UID is missing for ResourceQuota with name 'test'", kms.GetErrors()[0].Message)
 			require.Len(t, kms.ResourceQuotas, 0)
 		})
 
-		t.Run("register resource quota on KMS w/o namespace", func(t *testing.T) {
+		t.Run("register resource quota with empty NamespaceUID", func(t *testing.T) {
 			var err error
 
 			kms := NewKubeModelSet(start, end)
 
-			testUID := "uid"
-			testName := "name"
-
-			err = kms.RegisterResourceQuota(testUID, testName, "unregistered-namespace")
-			require.Nil(t, err)
-			require.Len(t, kms.GetWarnings(), 1)
-			require.Equal(t, "RegisterResourceQuota(uid, name, unregistered-namespace): missing namespace", kms.GetWarnings()[0].Message)
-
-			testRQ := &ResourceQuota{
-				UID:          "uid",
-				NamespaceUID: "",
-				Name:         "name",
-				Spec:         &ResourceQuotaSpec{Hard: &ResourceQuotaSpecHard{}},
-				Status:       &ResourceQuotaStatus{Used: &ResourceQuotaStatusUsed{}},
-			}
-
-			require.NotNil(t, kms.ResourceQuotas[testUID])
-			require.Equal(t, testRQ, kms.ResourceQuotas[testUID])
-			require.Equal(t, 1, kms.Metadata.ObjectCount)
+			err = kms.RegisterResourceQuota(&ResourceQuota{UID: "uid", Name: "name", NamespaceUID: ""})
+			require.NotNil(t, err)
+			require.Len(t, kms.GetErrors(), 1)
+			require.Equal(t, "RegisterResourceQuota: invalid resource quota: NamespaceUID is missing for ResourceQuota 'uid'", kms.GetErrors()[0].Message)
+			require.Len(t, kms.ResourceQuotas, 0)
 		})
 
 		t.Run("register resource quota on KMS w/ namespace", func(t *testing.T) {
 			kms := NewKubeModelSet(start, end)
-			kms.RegisterCluster("cluster-uid")
-			kms.RegisterNamespace("namespace-uid", "namespace")
+			kms.RegisterCluster(&Cluster{UID: "cluster-uid", Start: start, End: end})
+			kms.RegisterNamespace(&Namespace{UID: "namespace-uid", Name: "namespace", Start: start, End: end})
 			// At this point we have a KMS with a cluster and namespace registered
 
 			testUID := "uid"
 			testName := "name"
-			testNamespace := "namespace" // Register RQ in namespace that was already registered
 
-			kms.RegisterResourceQuota(testUID, testName, testNamespace)
+			kms.RegisterResourceQuota(&ResourceQuota{UID: testUID, Name: testName, NamespaceUID: "namespace-uid", Start: start, End: end})
 
 			testRQ := &ResourceQuota{
 				UID:          "uid",
 				NamespaceUID: "namespace-uid",
 				Name:         "name",
+				Start:        start,
+				End:          end,
 				Spec:         &ResourceQuotaSpec{Hard: &ResourceQuotaSpecHard{}},
 				Status:       &ResourceQuotaStatus{Used: &ResourceQuotaStatusUsed{}},
 			}
@@ -225,7 +272,7 @@ func TestKubeModel(t *testing.T) {
 			require.Equal(t, 2, kms.Metadata.ObjectCount) // 1 namespace and 1 RQ
 
 			// Register same RQ again, expect no-op on second try
-			kms.RegisterResourceQuota(testUID, testName, testNamespace)
+			kms.RegisterResourceQuota(&ResourceQuota{UID: testUID, Name: testName, NamespaceUID: "namespace-uid", Start: start, End: end})
 			require.Len(t, kms.GetErrors(), 0)
 			require.NotNil(t, kms.ResourceQuotas[testUID])
 			require.Equal(t, testRQ, kms.ResourceQuotas[testUID])
@@ -234,12 +281,12 @@ func TestKubeModel(t *testing.T) {
 
 		t.Run("register multiple RQs in multiple namespaces", func(t *testing.T) {
 			kms := NewKubeModelSet(start, end)
-			kms.RegisterCluster("cluster-uid")
-			kms.RegisterNamespace("namespace-1-uid", "namespace-1")
-			kms.RegisterNamespace("namespace-2-uid", "namespace-2")
+			kms.RegisterCluster(&Cluster{UID: "cluster-uid", Start: start, End: end})
+			kms.RegisterNamespace(&Namespace{UID: "namespace-1-uid", Name: "namespace-1", Start: start, End: end})
+			kms.RegisterNamespace(&Namespace{UID: "namespace-2-uid", Name: "namespace-2", Start: start, End: end})
 
-			kms.RegisterResourceQuota("uid-1", "name-1", "namespace-1")
-			kms.RegisterResourceQuota("uid-2", "name-2", "namespace-2")
+			kms.RegisterResourceQuota(&ResourceQuota{UID: "uid-1", Name: "name-1", NamespaceUID: "namespace-1-uid", Start: start, End: end})
+			kms.RegisterResourceQuota(&ResourceQuota{UID: "uid-2", Name: "name-2", NamespaceUID: "namespace-2-uid", Start: start, End: end})
 
 			require.Len(t, kms.GetErrors(), 0)
 			require.NotNil(t, kms.ResourceQuotas)
@@ -249,6 +296,8 @@ func TestKubeModel(t *testing.T) {
 				UID:          "uid-1",
 				NamespaceUID: "namespace-1-uid",
 				Name:         "name-1",
+				Start:        start,
+				End:          end,
 				Spec:         &ResourceQuotaSpec{Hard: &ResourceQuotaSpecHard{}},
 				Status:       &ResourceQuotaStatus{Used: &ResourceQuotaStatusUsed{}},
 			}
@@ -256,6 +305,8 @@ func TestKubeModel(t *testing.T) {
 				UID:          "uid-2",
 				NamespaceUID: "namespace-2-uid",
 				Name:         "name-2",
+				Start:        start,
+				End:          end,
 				Spec:         &ResourceQuotaSpec{Hard: &ResourceQuotaSpecHard{}},
 				Status:       &ResourceQuotaStatus{Used: &ResourceQuotaStatusUsed{}},
 			}
@@ -264,24 +315,13 @@ func TestKubeModel(t *testing.T) {
 			require.Equal(t, testRQ2, kms.ResourceQuotas["uid-2"])
 			require.Equal(t, 4, kms.Metadata.ObjectCount) // 2 namespaces and 2 RQs
 
-			// Register a third RQ with an invalid namespace
-			kms.RegisterResourceQuota("uid-3", "name-3", "namespace-3")
-
-			require.Len(t, kms.GetWarnings(), 1)
-			require.Equal(t, "RegisterResourceQuota(uid-3, name-3, namespace-3): missing namespace", kms.GetWarnings()[0].Message)
-
-			testRQ3 := &ResourceQuota{
-				UID:          "uid-3",
-				NamespaceUID: "",
-				Name:         "name-3",
-				Spec:         &ResourceQuotaSpec{Hard: &ResourceQuotaSpecHard{}},
-				Status:       &ResourceQuotaStatus{Used: &ResourceQuotaStatusUsed{}},
-			}
-
-			require.Len(t, kms.ResourceQuotas, 3)
-			require.NotNil(t, kms.ResourceQuotas["uid-3"])
-			require.Equal(t, testRQ3, kms.ResourceQuotas["uid-3"])
-			require.Equal(t, 5, kms.Metadata.ObjectCount) // 2 namespaces and 3 RQs
+			// Register a third RQ with empty NamespaceUID — expect error, not registered
+			err := kms.RegisterResourceQuota(&ResourceQuota{UID: "uid-3", Name: "name-3", NamespaceUID: ""})
+			require.NotNil(t, err)
+			require.Len(t, kms.GetErrors(), 1)
+			require.Equal(t, "RegisterResourceQuota: invalid resource quota: NamespaceUID is missing for ResourceQuota 'uid-3'", kms.GetErrors()[0].Message)
+			require.Len(t, kms.ResourceQuotas, 2)         // still 2
+			require.Equal(t, 4, kms.Metadata.ObjectCount) // unchanged
 		})
 	})
 }

+ 0 - 627
core/pkg/model/kubemodel/merge.go

@@ -1,627 +0,0 @@
-package kubemodel
-
-import (
-	"fmt"
-	"maps"
-	"math"
-	"slices"
-)
-
-func Merge(kms1, kms2 *KubeModelSet) (*KubeModelSet, error) {
-	if kms1 == nil && kms2 == nil {
-		return nil, fmt.Errorf("both KubeModelSets are nil")
-	}
-	if kms1 == nil {
-		return kms2, nil
-	}
-	if kms2 == nil {
-		return kms1, nil
-	}
-
-	if kms1.Cluster != nil && kms2.Cluster != nil && kms1.Cluster.UID != kms2.Cluster.UID {
-		return nil, fmt.Errorf(
-			"cannot merge KubeModelSets from different clusters: %s vs %s",
-			kms1.Cluster.UID, kms2.Cluster.UID)
-	}
-
-	windowStart := kms1.Window.Start
-	if kms2.Window.Start.Before(windowStart) {
-		windowStart = kms2.Window.Start
-	}
-	windowEnd := kms1.Window.End
-	if kms2.Window.End.After(windowEnd) {
-		windowEnd = kms2.Window.End
-	}
-
-	merged := NewKubeModelSet(windowStart, windowEnd)
-
-	if kms1.Metadata != nil && kms2.Metadata != nil {
-		if kms2.Metadata.CreatedAt.Before(kms1.Metadata.CreatedAt) {
-			merged.Metadata.CreatedAt = kms2.Metadata.CreatedAt
-		} else {
-			merged.Metadata.CreatedAt = kms1.Metadata.CreatedAt
-		}
-		if kms2.Metadata.CompletedAt.After(kms1.Metadata.CompletedAt) {
-			merged.Metadata.CompletedAt = kms2.Metadata.CompletedAt
-		} else {
-			merged.Metadata.CompletedAt = kms1.Metadata.CompletedAt
-		}
-		merged.Metadata.ObjectCount = kms1.Metadata.ObjectCount + kms2.Metadata.ObjectCount
-		merged.Metadata.Diagnostics = append(
-			append([]Diagnostic{}, kms1.Metadata.Diagnostics...),
-			kms2.Metadata.Diagnostics...,
-		)
-	} else if kms1.Metadata != nil {
-		merged.Metadata.CreatedAt = kms1.Metadata.CreatedAt
-		merged.Metadata.CompletedAt = kms1.Metadata.CompletedAt
-		merged.Metadata.ObjectCount = kms1.Metadata.ObjectCount
-		merged.Metadata.Diagnostics = append([]Diagnostic{}, kms1.Metadata.Diagnostics...)
-	} else if kms2.Metadata != nil {
-		merged.Metadata.CreatedAt = kms2.Metadata.CreatedAt
-		merged.Metadata.CompletedAt = kms2.Metadata.CompletedAt
-		merged.Metadata.ObjectCount = kms2.Metadata.ObjectCount
-		merged.Metadata.Diagnostics = append([]Diagnostic{}, kms2.Metadata.Diagnostics...)
-	}
-
-	merged.Cluster = kms1.Cluster
-	if merged.Cluster == nil {
-		merged.Cluster = kms2.Cluster
-	}
-
-	mergeNamespaces(merged, kms1, kms2)
-	mergeResourceQuotas(merged, kms1, kms2)
-	mergeNodes(merged, kms1, kms2)
-	mergePods(merged, kms1, kms2)
-	mergeContainers(merged, kms1, kms2)
-	mergeOwners(merged, kms1, kms2)
-	mergeServices(merged, kms1, kms2)
-	mergeVolumes(merged, kms1, kms2)
-	mergePVCs(merged, kms1, kms2)
-	mergeDevices(merged, kms1, kms2)
-	mergeDeviceUsages(merged, kms1, kms2)
-
-	return merged, nil
-}
-
-func mergeNamespaces(merged, kms1, kms2 *KubeModelSet) {
-	for uid, ns := range kms1.Namespaces {
-		merged.Namespaces[uid] = copyNamespace(ns)
-		merged.idx.namespaceNameToID[ns.Name] = ns.UID
-		merged.Metadata.ObjectCount++
-	}
-	for uid, ns2 := range kms2.Namespaces {
-		if ns1, exists := merged.Namespaces[uid]; exists {
-			// Merge Start/End timestamps for existing namespace
-			if ns2.Start.Before(ns1.Start) {
-				ns1.Start = ns2.Start
-			}
-			if ns2.End.After(ns1.End) {
-				ns1.End = ns2.End
-			}
-		} else {
-			merged.Namespaces[uid] = copyNamespace(ns2)
-			merged.idx.namespaceNameToID[ns2.Name] = ns2.UID
-			merged.Metadata.ObjectCount++
-		}
-	}
-}
-
-func mergeResourceQuotas(merged, kms1, kms2 *KubeModelSet) {
-	for uid, rq := range kms1.ResourceQuotas {
-		merged.ResourceQuotas[uid] = copyResourceQuota(rq)
-		merged.Metadata.ObjectCount++
-	}
-	for uid, rq2 := range kms2.ResourceQuotas {
-		if rq1, exists := merged.ResourceQuotas[uid]; exists {
-			// Merge Start/End timestamps for existing resource quota
-			if rq2.Start.Before(rq1.Start) {
-				rq1.Start = rq2.Start
-			}
-			if rq2.End.After(rq1.End) {
-				rq1.End = rq2.End
-			}
-		} else {
-			merged.ResourceQuotas[uid] = copyResourceQuota(rq2)
-			merged.Metadata.ObjectCount++
-		}
-	}
-}
-
-func mergeNodes(merged, kms1, kms2 *KubeModelSet) {
-	for uid, node := range kms1.Nodes {
-		merged.Nodes[uid] = copyNode(node)
-		merged.Metadata.ObjectCount++
-	}
-	for uid, node2 := range kms2.Nodes {
-		if node1, exists := merged.Nodes[uid]; exists {
-			node1.CpuMillicoreSeconds += node2.CpuMillicoreSeconds
-			node1.RAMByteSeconds += node2.RAMByteSeconds
-			node1.CpuMillicoreUsageMax = max(node1.CpuMillicoreUsageMax, node2.CpuMillicoreUsageMax)
-			node1.RAMByteUsageMax = max(node1.RAMByteUsageMax, node2.RAMByteUsageMax)
-			node1.DurationSeconds += node2.DurationSeconds
-
-			if node2.Start.Before(node1.Start) {
-				node1.Start = node2.Start
-			}
-			if node2.End.After(node1.End) {
-				node1.End = node2.End
-			}
-
-			for volumeUID, volume2 := range node2.AttachedVolumes {
-				if volume1, exists := node1.AttachedVolumes[volumeUID]; exists {
-					volume1.UsageByteSeconds += volume2.UsageByteSeconds
-					volume1.DurationSeconds += volume2.DurationSeconds
-					if volume2.CapacityBytes > volume1.CapacityBytes {
-						volume1.CapacityBytes = volume2.CapacityBytes
-					}
-				} else {
-					node1.AttachedVolumes[volumeUID] = &NodeVolumeUsage{
-						VolumeUID:        volume2.VolumeUID,
-						CapacityBytes:    volume2.CapacityBytes,
-						UsageByteSeconds: volume2.UsageByteSeconds,
-						VolumeType:       volume2.VolumeType,
-						ProviderID:       volume2.ProviderID,
-						DurationSeconds:  volume2.DurationSeconds,
-					}
-				}
-			}
-		} else {
-			merged.Nodes[uid] = copyNode(node2)
-			merged.Metadata.ObjectCount++
-		}
-	}
-}
-
-func mergePods(merged, kms1, kms2 *KubeModelSet) {
-	for uid, pod := range kms1.Pods {
-		merged.Pods[uid] = copyPod(pod)
-		merged.Metadata.ObjectCount++
-	}
-	for uid, pod2 := range kms2.Pods {
-		if pod1, exists := merged.Pods[uid]; exists {
-			pod1.NetworkReceiveBytes += pod2.NetworkReceiveBytes
-			pod1.NetworkTransferBytes += pod2.NetworkTransferBytes
-			pod1.DurationSeconds += pod2.DurationSeconds
-
-			if pod2.Start.Before(pod1.Start) {
-				pod1.Start = pod2.Start
-			}
-			if pod2.End.After(pod1.End) {
-				pod1.End = pod2.End
-			}
-		} else {
-			merged.Pods[uid] = copyPod(pod2)
-			merged.Metadata.ObjectCount++
-		}
-	}
-}
-
-func mergeContainers(merged, kms1, kms2 *KubeModelSet) {
-	for uid, container := range kms1.Containers {
-		merged.Containers[uid] = copyContainer(container)
-		merged.Metadata.ObjectCount++
-	}
-	for uid, container2 := range kms2.Containers {
-		if container1, exists := merged.Containers[uid]; exists {
-			container1.CpuMillicoreSeconds += container2.CpuMillicoreSeconds
-			container1.RAMByteSeconds += container2.RAMByteSeconds
-			container1.CpuMillicoreUsageMax = max(container1.CpuMillicoreUsageMax, container2.CpuMillicoreUsageMax)
-			container1.RAMByteUsageMax = max(container1.RAMByteUsageMax, container2.RAMByteUsageMax)
-
-			for volumeUID, ByteSeconds := range container2.VolumeStorageByteSeconds {
-				container1.VolumeStorageByteSeconds[volumeUID] += ByteSeconds
-			}
-			for volumeUID, usageMax := range container2.VolumeStorageByteUsageMax {
-				if currentMax, exists := container1.VolumeStorageByteUsageMax[volumeUID]; exists {
-					container1.VolumeStorageByteUsageMax[volumeUID] = max(currentMax, usageMax)
-				} else {
-					container1.VolumeStorageByteUsageMax[volumeUID] = usageMax
-				}
-			}
-
-			container1.CpuMillicoreRequestSeconds += container2.CpuMillicoreRequestSeconds
-			container1.RAMByteSecondRequest += container2.RAMByteSecondRequest
-			container1.CpuMillicoreLimitSeconds += container2.CpuMillicoreLimitSeconds
-			container1.RAMByteSecondsLimit += container2.RAMByteSecondsLimit
-
-			container1.DurationSeconds += container2.DurationSeconds
-
-			// Merge Start/End timestamps
-			if container2.Start.Before(container1.Start) {
-				container1.Start = container2.Start
-			}
-			if container2.End.After(container1.End) {
-				container1.End = container2.End
-			}
-		} else {
-			merged.Containers[uid] = copyContainer(container2)
-			merged.Metadata.ObjectCount++
-		}
-	}
-}
-
-func mergeOwners(merged, kms1, kms2 *KubeModelSet) {
-	for uid, owner := range kms1.Owners {
-		merged.Owners[uid] = copyOwner(owner)
-		merged.Metadata.ObjectCount++
-	}
-	for uid, owner2 := range kms2.Owners {
-		if owner1, exists := merged.Owners[uid]; exists {
-			if owner2.Start.Before(owner1.Start) {
-				owner1.Start = owner2.Start
-			}
-			if owner2.End.After(owner1.End) {
-				owner1.End = owner2.End
-			}
-		} else {
-			merged.Owners[uid] = copyOwner(owner2)
-			merged.Metadata.ObjectCount++
-		}
-	}
-}
-
-func mergeServices(merged, kms1, kms2 *KubeModelSet) {
-	for uid, svc := range kms1.Services {
-		merged.Services[uid] = copyService(svc)
-		merged.Metadata.ObjectCount++
-	}
-	for uid, svc2 := range kms2.Services {
-		if svc1, exists := merged.Services[uid]; exists {
-			svc1.NetworkTransferBytes += svc2.NetworkTransferBytes
-			svc1.NetworkReceiveBytes += svc2.NetworkReceiveBytes
-			svc1.DurationSeconds += svc2.DurationSeconds
-
-			if svc2.Start.Before(svc1.Start) {
-				svc1.Start = svc2.Start
-			}
-			if svc2.End.After(svc1.End) {
-				svc1.End = svc2.End
-			}
-		} else {
-			merged.Services[uid] = copyService(svc2)
-			merged.Metadata.ObjectCount++
-		}
-	}
-}
-
-func mergeVolumes(merged, kms1, kms2 *KubeModelSet) {
-	for uid, vol := range kms1.Volumes {
-		merged.Volumes[uid] = copyVolume(vol)
-		merged.Metadata.ObjectCount++
-	}
-	for uid, vol2 := range kms2.Volumes {
-		if vol1, exists := merged.Volumes[uid]; exists {
-			if vol2.Start.Before(vol1.Start) {
-				vol1.Start = vol2.Start
-			}
-			if vol2.End.After(vol1.End) {
-				vol1.End = vol2.End
-			}
-			vol1.DurationSeconds += vol2.DurationSeconds
-		} else {
-			merged.Volumes[uid] = copyVolume(vol2)
-			merged.Metadata.ObjectCount++
-		}
-	}
-}
-
-func mergePVCs(merged, kms1, kms2 *KubeModelSet) {
-	for uid, pvc := range kms1.PersistentVolumeClaims {
-		merged.PersistentVolumeClaims[uid] = copyPVC(pvc)
-		merged.Metadata.ObjectCount++
-	}
-	for uid, pvc2 := range kms2.PersistentVolumeClaims {
-		if pvc1, exists := merged.PersistentVolumeClaims[uid]; exists {
-			pvc1.StorageByteSeconds += pvc2.StorageByteSeconds
-			pvc1.ActualUsedByteSeconds += pvc2.ActualUsedByteSeconds
-			pvc1.DurationSeconds += pvc2.DurationSeconds
-
-			if pvc2.Start.Before(pvc1.Start) {
-				pvc1.Start = pvc2.Start
-			}
-			if pvc2.End.After(pvc1.End) {
-				pvc1.End = pvc2.End
-			}
-			if pvc2.BoundAt.After(pvc1.BoundAt) {
-				pvc1.BoundAt = pvc2.BoundAt
-			}
-		} else {
-			merged.PersistentVolumeClaims[uid] = copyPVC(pvc2)
-			merged.Metadata.ObjectCount++
-		}
-	}
-}
-
-func mergeDevices(merged, kms1, kms2 *KubeModelSet) {
-	for uid, dev := range kms1.Devices {
-		merged.Devices[uid] = copyDevice(dev)
-		merged.Metadata.ObjectCount++
-	}
-	for uid, dev2 := range kms2.Devices {
-		if dev1, exists := merged.Devices[uid]; exists {
-			dev1.UsageSeconds += dev2.UsageSeconds
-			dev1.MemoryByteSeconds += dev2.MemoryByteSeconds
-			dev1.PowerWattSeconds += dev2.PowerWattSeconds
-			dev1.PowerWattMax = math.Max(dev1.PowerWattMax, dev2.PowerWattMax)
-			dev1.DurationSeconds += dev2.DurationSeconds
-
-			if dev2.Start.Before(dev1.Start) {
-				dev1.Start = dev2.Start
-			}
-			if dev2.End.After(dev1.End) {
-				dev1.End = dev2.End
-			}
-		} else {
-			merged.Devices[uid] = copyDevice(dev2)
-			merged.Metadata.ObjectCount++
-		}
-	}
-}
-
-func mergeDeviceUsages(merged, kms1, kms2 *KubeModelSet) {
-	for uid, usage := range kms1.DeviceUsages {
-		merged.DeviceUsages[uid] = copyDeviceUsage(usage)
-		merged.Metadata.ObjectCount++
-	}
-	for uid, usage2 := range kms2.DeviceUsages {
-		if usage1, exists := merged.DeviceUsages[uid]; exists {
-			usage1.UsageSeconds += usage2.UsageSeconds
-			usage1.MemoryByteSecondsUsed += usage2.MemoryByteSecondsUsed
-			usage1.UsagePercentageMax = math.Max(usage1.UsagePercentageMax, usage2.UsagePercentageMax)
-			usage1.DurationSeconds += usage2.DurationSeconds
-
-			// Merge Start/End timestamps
-			if usage2.Start.Before(usage1.Start) {
-				usage1.Start = usage2.Start
-			}
-			if usage2.End.After(usage1.End) {
-				usage1.End = usage2.End
-			}
-		} else {
-			merged.DeviceUsages[uid] = copyDeviceUsage(usage2)
-			merged.Metadata.ObjectCount++
-		}
-	}
-}
-
-func copyNamespace(ns *Namespace) *Namespace {
-	return &Namespace{
-		ClusterUID:  ns.ClusterUID,
-		UID:         ns.UID,
-		Name:        ns.Name,
-		Labels:      maps.Clone(ns.Labels),
-		Annotations: maps.Clone(ns.Annotations),
-		Start:       ns.Start,
-		End:         ns.End,
-	}
-}
-
-func copyResourceQuota(rq *ResourceQuota) *ResourceQuota {
-	copied := &ResourceQuota{
-		UID:          rq.UID,
-		Name:         rq.Name,
-		NamespaceUID: rq.NamespaceUID,
-		Start:        rq.Start,
-		End:          rq.End,
-	}
-	if rq.Spec != nil {
-		copied.Spec = &ResourceQuotaSpec{}
-		if rq.Spec.Hard != nil {
-			copied.Spec.Hard = &ResourceQuotaSpecHard{
-				Requests: copyResourceQuantities(rq.Spec.Hard.Requests),
-				Limits:   copyResourceQuantities(rq.Spec.Hard.Limits),
-			}
-		}
-	}
-	if rq.Status != nil {
-		copied.Status = &ResourceQuotaStatus{}
-		if rq.Status.Used != nil {
-			copied.Status.Used = &ResourceQuotaStatusUsed{
-				Requests: copyResourceQuantities(rq.Status.Used.Requests),
-				Limits:   copyResourceQuantities(rq.Status.Used.Limits),
-			}
-		}
-	}
-	return copied
-}
-
-func copyResourceQuantities(rq ResourceQuantities) ResourceQuantities {
-	if rq == nil {
-		return nil
-	}
-	copied := make(ResourceQuantities, len(rq))
-	for k, v := range rq {
-		copied[k] = v
-	}
-	return copied
-}
-
-func copyNode(node *Node) *Node {
-	copied := &Node{
-		UID:                  node.UID,
-		Name:                 node.Name,
-		ProviderResourceUID:  node.ProviderResourceUID,
-		Labels:               maps.Clone(node.Labels),
-		Annotations:          maps.Clone(node.Annotations),
-		CpuMillicoreSeconds:  node.CpuMillicoreSeconds,
-		RAMByteSeconds:       node.RAMByteSeconds,
-		CpuMillicoreUsageMax: node.CpuMillicoreUsageMax,
-		RAMByteUsageMax:      node.RAMByteUsageMax,
-		DurationSeconds:      node.DurationSeconds,
-		AttachedVolumes:      make(map[string]*NodeVolumeUsage),
-		Start:                node.Start,
-		End:                  node.End,
-	}
-
-	for volumeUID, volume := range node.AttachedVolumes {
-		copied.AttachedVolumes[volumeUID] = &NodeVolumeUsage{
-			VolumeUID:        volume.VolumeUID,
-			CapacityBytes:    volume.CapacityBytes,
-			UsageByteSeconds: volume.UsageByteSeconds,
-			VolumeType:       volume.VolumeType,
-			ProviderID:       volume.ProviderID,
-			DurationSeconds:  volume.DurationSeconds,
-		}
-	}
-
-	return copied
-}
-
-func copyPod(pod *Pod) *Pod {
-	return &Pod{
-		UID:                  pod.UID,
-		Name:                 pod.Name,
-		NamespaceUID:         pod.NamespaceUID,
-		OwnerUID:             pod.OwnerUID,
-		NodeUID:              pod.NodeUID,
-		Labels:               maps.Clone(pod.Labels),
-		Annotations:          maps.Clone(pod.Annotations),
-		NetworkReceiveBytes:  pod.NetworkReceiveBytes,
-		NetworkTransferBytes: pod.NetworkTransferBytes,
-		DurationSeconds:      pod.DurationSeconds,
-		Start:                pod.Start,
-		End:                  pod.End,
-	}
-}
-
-func copyContainer(container *Container) *Container {
-	return &Container{
-		PodUID:                     container.PodUID,
-		Name:                       container.Name,
-		CpuMillicoreSeconds:        container.CpuMillicoreSeconds,
-		RAMByteSeconds:             container.RAMByteSeconds,
-		CpuMillicoreUsageMax:       container.CpuMillicoreUsageMax,
-		RAMByteUsageMax:            container.RAMByteUsageMax,
-		VolumeStorageByteSeconds:   maps.Clone(container.VolumeStorageByteSeconds),
-		VolumeStorageByteUsageMax:  maps.Clone(container.VolumeStorageByteUsageMax),
-		DurationSeconds:            container.DurationSeconds,
-		CpuMillicoreRequestSeconds: container.CpuMillicoreRequestSeconds,
-		RAMByteSecondRequest:       container.RAMByteSecondRequest,
-		CpuMillicoreLimitSeconds:   container.CpuMillicoreLimitSeconds,
-		RAMByteSecondsLimit:        container.RAMByteSecondsLimit,
-		Start:                      container.Start,
-		End:                        container.End,
-	}
-}
-
-func copyOwner(owner *Owner) *Owner {
-	return &Owner{
-		UID:          owner.UID,
-		Name:         owner.Name,
-		NamespaceUID: owner.NamespaceUID,
-		Kind:         owner.Kind,
-		Labels:       maps.Clone(owner.Labels),
-		Annotations:  maps.Clone(owner.Annotations),
-		Start:        owner.Start,
-		End:          owner.End,
-	}
-}
-
-func copyService(svc *Service) *Service {
-	return &Service{
-		UID:                  svc.UID,
-		NamespaceUID:         svc.NamespaceUID,
-		Name:                 svc.Name,
-		Type:                 svc.Type,
-		Hostname:             svc.Hostname,
-		Labels:               maps.Clone(svc.Labels),
-		Annotations:          maps.Clone(svc.Annotations),
-		NetworkTransferBytes: svc.NetworkTransferBytes,
-		NetworkReceiveBytes:  svc.NetworkReceiveBytes,
-		DurationSeconds:      svc.DurationSeconds,
-		Selector:             maps.Clone(svc.Selector),
-		Ports:                slices.Clone(svc.Ports),
-		Start:                svc.Start,
-		End:                  svc.End,
-	}
-}
-
-func copyVolume(vol *PersistentVolume) *PersistentVolume {
-	return &PersistentVolume{
-		UID:                   vol.UID,
-		ClusterUID:            vol.ClusterUID,
-		Name:                  vol.Name,
-		Namespace:             vol.Namespace,
-		Labels:                maps.Clone(vol.Labels),
-		Annotations:           maps.Clone(vol.Annotations),
-		StorageClass:          vol.StorageClass,
-		SizeBytes:             vol.SizeBytes,
-		Type:                  vol.Type,
-		CSIDriver:             vol.CSIDriver,
-		ProviderVolumeID:      vol.ProviderVolumeID,
-		AccessModes:           slices.Clone(vol.AccessModes),
-		ReclaimPolicy:         vol.ReclaimPolicy,
-		Region:                vol.Region,
-		Zone:                  vol.Zone,
-		Start:                 vol.Start,
-		End:                   vol.End,
-		DurationSeconds:       vol.DurationSeconds,
-		NodeAffinity:          vol.NodeAffinity,
-		ProvisionedIOPS:       vol.ProvisionedIOPS,
-		ProvisionedThroughput: vol.ProvisionedThroughput,
-		PerformanceMode:       vol.PerformanceMode,
-	}
-}
-
-func copyPVC(pvc *PersistentVolumeClaim) *PersistentVolumeClaim {
-	copied := &PersistentVolumeClaim{
-		UID:                   pvc.UID,
-		NamespaceUID:          pvc.NamespaceUID,
-		Name:                  pvc.Name,
-		Labels:                maps.Clone(pvc.Labels),
-		Annotations:           maps.Clone(pvc.Annotations),
-		StorageClass:          pvc.StorageClass,
-		StorageByteSeconds:    pvc.StorageByteSeconds,
-		RequestedBytes:        pvc.RequestedBytes,
-		Size:                  pvc.Size,
-		VolumeName:            pvc.VolumeName,
-		AccessModes:           slices.Clone(pvc.AccessModes),
-		Start:                 pvc.Start,
-		End:                   pvc.End,
-		BoundAt:               pvc.BoundAt,
-		DurationSeconds:       pvc.DurationSeconds,
-		ActualUsedByteSeconds: pvc.ActualUsedByteSeconds,
-	}
-	if pvc.VolumeUID != nil {
-		volumeUID := *pvc.VolumeUID
-		copied.VolumeUID = &volumeUID
-	}
-	if pvc.PodUID != nil {
-		podUID := *pvc.PodUID
-		copied.PodUID = &podUID
-	}
-	return copied
-}
-
-func copyDevice(dev *Device) *Device {
-	return &Device{
-		UID:               dev.UID,
-		Type:              dev.Type,
-		NodeUID:           dev.NodeUID,
-		DeviceNumber:      dev.DeviceNumber,
-		ModelName:         dev.ModelName,
-		IsShared:          dev.IsShared,
-		SharePercentage:   dev.SharePercentage,
-		UsageSeconds:      dev.UsageSeconds,
-		MemoryByteSeconds: dev.MemoryByteSeconds,
-		PowerWattSeconds:  dev.PowerWattSeconds,
-		PowerWattMax:      dev.PowerWattMax,
-		DurationSeconds:   dev.DurationSeconds,
-		Start:             dev.Start,
-		End:               dev.End,
-	}
-}
-
-func copyDeviceUsage(usage *DeviceUsage) *DeviceUsage {
-	return &DeviceUsage{
-		ContainerUID:          usage.ContainerUID,
-		DeviceUID:             usage.DeviceUID,
-		UsageSeconds:          usage.UsageSeconds,
-		UsagePercentageMax:    usage.UsagePercentageMax,
-		MemoryByteSecondsUsed: usage.MemoryByteSecondsUsed,
-		DeviceType:            usage.DeviceType,
-		DurationSeconds:       usage.DurationSeconds,
-		Start:                 usage.Start,
-		End:                   usage.End,
-	}
-}

+ 266 - 0
core/pkg/model/kubemodel/mock.go

@@ -0,0 +1,266 @@
+package kubemodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/model/shared"
+)
+
+// NewMockKubeModelSet returns a KubeModelSet populated with one instance of every
+// type. All time values are derived from start/end so the caller controls
+// monotonic-clock safety (use Truncate before passing in).
+func NewMockKubeModelSet(start, end time.Time) *KubeModelSet {
+	kms := NewKubeModelSet(start, end)
+
+	// --- Cluster ---
+	kms.RegisterCluster(&Cluster{
+		UID:      "cluster-uid",
+		Provider: shared.ProviderAWS,
+		Account:  "123456789012",
+		Name:     "test-cluster",
+		Region:   "us-east-1",
+		Start:    start,
+		End:      end,
+	})
+
+	// --- Namespace ---
+	kms.RegisterNamespace(&Namespace{
+		UID:         "ns-uid",
+		Name:        "default",
+		Labels:      map[string]string{"env": "test"},
+		Annotations: map[string]string{"note": "mock"},
+		Start:       start,
+		End:         end,
+	})
+
+	// --- ResourceQuota ---
+	kms.RegisterResourceQuota(&ResourceQuota{
+		UID:          "rq-uid",
+		NamespaceUID: "ns-uid",
+		Name:         "default-quota",
+		Spec: &ResourceQuotaSpec{
+			Hard: &ResourceQuotaSpecHard{
+				Requests: ResourceQuantities{
+					ResourceCPU:    {Resource: ResourceCPU, Unit: UnitMillicore, Values: Stats{StatAvg: 4000, StatMax: 4000}},
+					ResourceMemory: {Resource: ResourceMemory, Unit: UnitByte, Values: Stats{StatAvg: 8e9, StatMax: 8e9}},
+				},
+				Limits: ResourceQuantities{
+					ResourceCPU:    {Resource: ResourceCPU, Unit: UnitMillicore, Values: Stats{StatAvg: 8000, StatMax: 8000}},
+					ResourceMemory: {Resource: ResourceMemory, Unit: UnitByte, Values: Stats{StatAvg: 16e9, StatMax: 16e9}},
+				},
+			},
+		},
+		Status: &ResourceQuotaStatus{
+			Used: &ResourceQuotaStatusUsed{
+				Requests: ResourceQuantities{
+					ResourceCPU:    {Resource: ResourceCPU, Unit: UnitMillicore, Values: Stats{StatAvg: 500, StatMax: 800}},
+					ResourceMemory: {Resource: ResourceMemory, Unit: UnitByte, Values: Stats{StatAvg: 1e9, StatMax: 2e9}},
+				},
+			},
+		},
+		Start: start,
+		End:   end,
+	})
+
+	// --- Node ---
+	kms.RegisterNode(&Node{
+		UID:        "node-uid",
+		ProviderID: "aws:///us-east-1a/i-0abc123def456",
+		Name:       "node-1",
+		Labels:     map[string]string{"node.kubernetes.io/instance-type": "m5.large"},
+		ResourceCapacities: ResourceQuantities{
+			ResourceCPU:    {Resource: ResourceCPU, Unit: UnitMillicore, Values: Stats{StatAvg: 2000, StatMax: 2000}},
+			ResourceMemory: {Resource: ResourceMemory, Unit: UnitByte, Values: Stats{StatAvg: 8e9, StatMax: 8e9}},
+		},
+		ResourcesAllocatable: ResourceQuantities{
+			ResourceCPU:    {Resource: ResourceCPU, Unit: UnitMillicore, Values: Stats{StatAvg: 1900, StatMax: 1900}},
+			ResourceMemory: {Resource: ResourceMemory, Unit: UnitByte, Values: Stats{StatAvg: 7e9, StatMax: 7e9}},
+		},
+		FileSystem: FileSystem{
+			CapacityBytes: 100e9,
+			UsageByteAvg:  40e9,
+			UsageByteMax:  55e9,
+		},
+		Start: start,
+		End:   end,
+	})
+
+	// --- Pod ---
+	kms.RegisterPod(&Pod{
+		UID:          "pod-uid",
+		NamespaceUID: "ns-uid",
+		NodeUID:      "node-uid",
+		Name:         "my-pod-abc12",
+		Owners:       []Owner{{UID: "dep-uid", Kind: OwnerKindDeployment, Controller: true}},
+		PVCVolumes:   []PodPVCVolume{{Name: "data", PersistentVolumeClaimUID: "pvc-uid"}},
+		Labels:       map[string]string{"app": "my-app", "version": "v1"},
+		Annotations:  map[string]string{"prometheus.io/scrape": "true"},
+		NetworkTrafficDetails: []NetworkTrafficDetail{
+			{
+				PodUID:           "pod-uid",
+				Endpoint:         "10.0.0.5:443",
+				TrafficDirection: TrafficDirectionEgress,
+				TrafficType:      TrafficTypeCrossZone,
+				IsNatGateway:     false,
+				Bytes:            1024,
+			},
+		},
+		Start: start,
+		End:   end,
+	})
+
+	// --- Container ---
+	kms.RegisterContainer(&Container{
+		PodUID: "pod-uid",
+		Name:   "app",
+		ResourceRequests: ResourceQuantities{
+			ResourceCPU:    {Resource: ResourceCPU, Unit: UnitMillicore, Values: Stats{StatAvg: 250, StatMax: 250}},
+			ResourceMemory: {Resource: ResourceMemory, Unit: UnitByte, Values: Stats{StatAvg: 512e6, StatMax: 512e6}},
+		},
+		ResourceLimits: ResourceQuantities{
+			ResourceCPU:    {Resource: ResourceCPU, Unit: UnitMillicore, Values: Stats{StatAvg: 500, StatMax: 500}},
+			ResourceMemory: {Resource: ResourceMemory, Unit: UnitByte, Values: Stats{StatAvg: 1e9, StatMax: 1e9}},
+		},
+		CPUCoreAllocationAvg:  0.25,
+		CPUCoreUsageAvg:       0.18,
+		CPUCoreUsageMax:       0.42,
+		RAMBytesAllocationAvg: 512e6,
+		RAMBytesUsageAvg:      300e6,
+		RAMBytesUsageMax:      480e6,
+		Start:                 start,
+		End:                   end,
+	})
+
+	// --- Deployment ---
+	kms.RegisterDeployment(&Deployment{
+		UID:          "dep-uid",
+		NamespaceUID: "ns-uid",
+		Name:         "my-app",
+		Labels:       map[string]string{"app": "my-app"},
+		Annotations:  map[string]string{"deployment.kubernetes.io/revision": "3"},
+		MatchLabels:  map[string]string{"app": "my-app"},
+		Start:        start,
+		End:          end,
+	})
+
+	// --- StatefulSet ---
+	kms.RegisterStatefulSet(&StatefulSet{
+		UID:          "sts-uid",
+		NamespaceUID: "ns-uid",
+		Name:         "my-statefulset",
+		Labels:       map[string]string{"app": "my-statefulset"},
+		Annotations:  map[string]string{"note": "test"},
+		MatchLabels:  map[string]string{"app": "my-statefulset"},
+		Start:        start,
+		End:          end,
+	})
+
+	// --- DaemonSet ---
+	kms.RegisterDaemonSet(&DaemonSet{
+		UID:          "ds-uid",
+		NamespaceUID: "ns-uid",
+		Name:         "my-daemonset",
+		Labels:       map[string]string{"app": "my-daemonset"},
+		Annotations:  map[string]string{"note": "test"},
+		Start:        start,
+		End:          end,
+	})
+
+	// --- Job ---
+	kms.RegisterJob(&Job{
+		UID:          "job-uid",
+		NamespaceUID: "ns-uid",
+		Name:         "my-job",
+		Labels:       map[string]string{"app": "my-job"},
+		Annotations:  map[string]string{"note": "test"},
+		Start:        start,
+		End:          end,
+	})
+
+	// --- CronJob ---
+	kms.RegisterCronJob(&CronJob{
+		UID:          "cj-uid",
+		NamespaceUID: "ns-uid",
+		Name:         "my-cronjob",
+		Labels:       map[string]string{"app": "my-cronjob"},
+		Annotations:  map[string]string{"note": "test"},
+		Start:        start,
+		End:          end,
+	})
+
+	// --- ReplicaSet ---
+	kms.RegisterReplicaSet(&ReplicaSet{
+		UID:          "rs-uid",
+		NamespaceUID: "ns-uid",
+		Name:         "my-app-7d9f8b",
+		Owners:       []Owner{{UID: "dep-uid", Kind: OwnerKindDeployment, Controller: true}},
+		Labels:       map[string]string{"app": "my-app", "pod-template-hash": "7d9f8b"},
+		Annotations:  map[string]string{"note": "test"},
+		Start:        start,
+		End:          end,
+	})
+
+	// --- Service ---
+	kms.RegisterService(&Service{
+		UID:          "svc-uid",
+		NamespaceUID: "ns-uid",
+		Name:         "my-app",
+		Type:         ServiceTypeClusterIP,
+		Selector:     map[string]string{"app": "my-app"},
+		Start:        start,
+		End:          end,
+	})
+
+	// --- PersistentVolumeClaim ---
+	kms.RegisterPVC(&PersistentVolumeClaim{
+		UID:                 "pvc-uid",
+		NamespaceUID:        "ns-uid",
+		Name:                "data",
+		PersistentVolumeUID: "pv-uid",
+		StorageClass:        "gp2",
+		RequestedBytes:      50e9,
+		UsageBytesAvg:       20e9,
+		UsageBytesMax:       35e9,
+		Start:               start,
+		End:                 end,
+	})
+
+	// --- PersistentVolume ---
+	kms.RegisterPersistentVolume(&PersistentVolume{
+		UID:             "pv-uid",
+		Name:            "pvc-abc123",
+		StorageClass:    "gp2",
+		CSIVolumeHandle: "vol-0abc123def456789",
+		SizeBytes:       50e9,
+		Start:           start,
+		End:             end,
+	})
+
+	// --- DCGMDevice ---
+	kms.RegisterDCGMDevice(&DCGMDevice{
+		UUID:      "GPU-abc123def-456-789",
+		Device:    "0",
+		ModelName: "Tesla T4",
+		PodUsages: map[string]DCGMPod{
+			"pod-uid": {
+				ContainerUsages: map[string]DCGMContainer{
+					"app": {UsageAvg: 0.65, UsageMax: 0.92},
+				},
+			},
+		},
+		Start: start,
+		End:   end,
+	})
+
+	// --- Diagnostics ---
+	kms.Error(errMock("mock error"))
+	kms.Warn("mock warning")
+	kms.Info("mock info")
+
+	return kms
+}
+
+// errMock is a minimal error implementation that avoids importing errors/fmt in the mock.
+type errMock string
+
+func (e errMock) Error() string { return string(e) }

+ 31 - 24
core/pkg/model/kubemodel/namespace.go

@@ -7,39 +7,46 @@ import (
 
 // @bingen:generate:Namespace
 type Namespace struct {
-	UID         string            `json:"uid"`             // @bingen:field[version=1]
-	ClusterUID  string            `json:"clusterUID"`      // @bingen:field[version=1]
-	Name        string            `json:"name"`            // @bingen:field[version=1]
-	Labels      map[string]string `json:"labels"`          // @bingen:field[version=1]
-	Annotations map[string]string `json:"annotations"`     // @bingen:field[version=1]
-	Start       time.Time         `json:"start,omitempty"` // @bingen:field[version=1]
-	End         time.Time         `json:"end,omitempty"`   // @bingen:field[version=1]
+	UID string `json:"uid"` // @bingen:field[version=1]
+	// This field was included in the initial bigen codec but was never populated. If you need to add a new string
+	// field to this structure rename this field and delete this comment
+	bingenPlaceHolder string            // @bingen:field[version=1]
+	Name              string            `json:"name"`        // @bingen:field[version=1]
+	Labels            map[string]string `json:"labels"`      // @bingen:field[version=1]
+	Annotations       map[string]string `json:"annotations"` // @bingen:field[version=1]
+	Start             time.Time         `json:"start"`       // @bingen:field[version=1]
+	End               time.Time         `json:"end"`         // @bingen:field[version=1]
 }
 
-func (kms *KubeModelSet) RegisterNamespace(uid, name string) error {
-	if uid == "" {
-		err := fmt.Errorf("UID is nil for Namespace '%s'", name)
-		kms.Error(err)
+func (n *Namespace) ValidateNamespace(window Window) error {
+	if n.UID == "" {
+		return fmt.Errorf("UID is missing for Namespace with name '%s'", n.Name)
+	}
+
+	if n.Name == "" {
+		return fmt.Errorf("Name is missing for Namespace '%s'", n.UID)
+	}
+
+	if err := checkWindow(window, n.Start, n.End); err != nil {
 		return err
 	}
 
-	if _, ok := kms.Namespaces[uid]; !ok {
-		clusterUID := ""
+	return nil
+}
+
+func (kms *KubeModelSet) RegisterNamespace(namespace *Namespace) error {
+	if err := namespace.ValidateNamespace(kms.Window); err != nil {
+		err = fmt.Errorf("RegisterNamespace: invalid namespace: %w", err)
+		kms.Error(err)
+		return err
+	}
 
+	if _, ok := kms.Namespaces[namespace.UID]; !ok {
 		if kms.Cluster == nil {
-			kms.Warnf("RegisterNamespace(%s, %s): Cluster is nil", uid, name)
-		} else {
-			clusterUID = kms.Cluster.UID
+			kms.Warnf("RegisterNamespace: Cluster is nil")
 		}
 
-		kms.Namespaces[uid] = &Namespace{
-			UID:        uid,
-			ClusterUID: clusterUID,
-			Name:       name,
-		}
-
-		kms.idx.namespaceByName[name] = kms.Namespaces[uid]
-
+		kms.Namespaces[namespace.UID] = namespace
 		kms.Metadata.ObjectCount++
 	}
 

+ 143 - 0
core/pkg/model/kubemodel/namespace_test.go

@@ -0,0 +1,143 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidateNamespace(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	tests := []struct {
+		name      string
+		namespace *Namespace
+		wantErr   string
+	}{
+		{
+			name:      "empty UID",
+			namespace: &Namespace{Name: "my-ns", Start: start, End: end},
+			wantErr:   "UID is missing for Namespace with name 'my-ns'",
+		},
+		{
+			name:      "empty Name",
+			namespace: &Namespace{UID: "ns-uid", Start: start, End: end},
+			wantErr:   "Name is missing for Namespace 'ns-uid'",
+		},
+		{
+			name:      "outside window",
+			namespace: &Namespace{UID: "ns-uid", Name: "my-ns", Start: start.Add(-time.Hour), End: end},
+			wantErr:   checkWindow(window, start.Add(-time.Hour), end).Error(),
+		},
+		{
+			name:      "valid",
+			namespace: &Namespace{UID: "ns-uid", Name: "my-ns", Start: start, End: end},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.namespace.ValidateNamespace(window)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestRegisterNamespace(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	newNamespace := func(uid, name string) *Namespace {
+		return &Namespace{UID: uid, Name: name, Start: start, End: end}
+	}
+	withCluster := func(kms *KubeModelSet) {
+		kms.RegisterCluster(&Cluster{UID: "cluster-uid", Start: start, End: end})
+	}
+
+	tests := []struct {
+		name      string
+		setup     func(*KubeModelSet)
+		namespace *Namespace
+		wantErr   string
+		want      *KubeModelSet
+	}{
+		{
+			name:      "validation failure",
+			namespace: &Namespace{UID: "", Name: "my-ns", Start: start, End: end},
+			wantErr:   "RegisterNamespace: invalid namespace: UID is missing for Namespace with name 'my-ns'",
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelError, Message: "RegisterNamespace: invalid namespace: UID is missing for Namespace with name 'my-ns'"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:      "warns when cluster is nil",
+			namespace: newNamespace("ns-uid", "my-ns"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Namespaces["ns-uid"] = newNamespace("ns-uid", "my-ns")
+				kms.Metadata.ObjectCount = 1
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelWarning, Message: "RegisterNamespace: Cluster is nil"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:      "registers namespace with cluster",
+			setup:     withCluster,
+			namespace: newNamespace("ns-uid", "my-ns"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.Namespaces["ns-uid"] = newNamespace("ns-uid", "my-ns")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+		{
+			name: "duplicate registration is a no-op",
+			setup: func(kms *KubeModelSet) {
+				withCluster(kms)
+				kms.RegisterNamespace(newNamespace("ns-uid", "original"))
+			},
+			namespace: newNamespace("ns-uid", "duplicate"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.Namespaces["ns-uid"] = newNamespace("ns-uid", "original")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			kms := NewKubeModelSet(start, end)
+			if tt.setup != nil {
+				tt.setup(kms)
+			}
+
+			err := kms.RegisterNamespace(tt.namespace)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+
+			KubeModelSetEquals(t, tt.want, kms)
+		})
+	}
+}

+ 28 - 0
core/pkg/model/kubemodel/networktrafficdetail.go

@@ -0,0 +1,28 @@
+package kubemodel
+
+// @bingen:generate:TrafficDirection
+type TrafficDirection string
+
+const (
+	TrafficDirectionEgress  TrafficDirection = "Egress"
+	TrafficDirectionIngress TrafficDirection = "Ingress"
+)
+
+// @bingen:generate:TrafficType
+type TrafficType string
+
+const (
+	TrafficTypeCrossZone   TrafficType = "CrossZone"
+	TrafficTypeCrossRegion TrafficType = "CrossRegion"
+	TrafficTypeInternet    TrafficType = "Internet"
+)
+
+// @bingen:generate:NetworkTrafficDetail
+type NetworkTrafficDetail struct {
+	PodUID           string           `json:"podUid"`
+	Endpoint         string           `json:"endpoint,omitempty"`
+	TrafficDirection TrafficDirection `json:"trafficDirection"`
+	TrafficType      TrafficType      `json:"trafficType"`
+	IsNatGateway     bool             `json:"isNatGateway"`
+	Bytes            float64          `json:"bytes"`
+}

+ 31 - 70
core/pkg/model/kubemodel/node.go

@@ -5,99 +5,60 @@ import (
 	"time"
 )
 
+// @bingen:generate:Node
 // Node represents a Kubernetes node with capacity-based resource tracking.
 // All resource measures (CPU, RAM) represent node capacity, not requests or limits.
 // This aligns with the principle that cost allocation should be based on provisioned capacity.
 type Node struct {
-	UID                  string                      `json:"uid"`
-	ProviderResourceUID  string                      `json:"providerResourceUid"`
-	Name                 string                      `json:"name"`
-	Labels               map[string]string           `json:"labels,omitempty"`
-	Annotations          map[string]string           `json:"annotations,omitempty"`
-	DurationSeconds      Measurement                 `json:"durationSeconds"`
-	CpuMillicoreSeconds  Measurement                 `json:"cpuMillicoreSeconds"` // Node CPU capacity in millicore-seconds
-	RAMByteSeconds       Measurement                 `json:"ramByteSeconds"`      // Node RAM capacity in Byte-seconds
-	AttachedVolumes      map[string]*NodeVolumeUsage `json:"attachedVolumes,omitempty"`
-	CpuMillicoreUsageMax Measurement                 `json:"cpuMillicoreUsageMax"` // Peak CPU usage observed
-	RAMByteUsageMax      Measurement                 `json:"ramByteUsageMax"`      // Peak RAM usage observed
-	Start                time.Time                   `json:"start,omitempty"`      // Node creation/start timestamp
-	End                  time.Time                   `json:"end,omitempty"`        // Node deletion/end timestamp (nil if still running)
+	UID                  string             `json:"uid"`
+	ProviderID           string             `json:"providerId"`
+	Name                 string             `json:"name"`
+	Labels               map[string]string  `json:"labels"`
+	ResourceCapacities   ResourceQuantities `json:"resourceCapacities"`
+	ResourcesAllocatable ResourceQuantities `json:"resourcesAllocatable"`
+	FileSystem           FileSystem         `json:"fileSystem"`
+	Start                time.Time          `json:"start"`
+	End                  time.Time          `json:"end"`
 }
 
-// NodeVolumeUsage tracks storage usage for a disk volume attached to a node.
-// Used for cost allocation of cloud storage resources (e.g., AWS EBS volumes).
-type NodeVolumeUsage struct {
-	VolumeUID        string      `json:"volumeUid"`        // "root" for primary disk, or actual volume UID for additional volumes
-	CapacityBytes    Measurement `json:"capacityBytes"`    // Total capacity of the volume in bytes
-	UsageByteSeconds Measurement `json:"usageByteSeconds"` // Cumulative usage (Byte × seconds) over measurement window
-	VolumeType       string      `json:"volumeType"`       // "root" for primary disk, "persistent" for additional PVs
-	ProviderID       string      `json:"providerId"`       // Cloud provider volume ID (e.g., "vol-xxxxx" for AWS EBS)
-	DurationSeconds  Measurement `json:"durationSeconds"`  // Duration the volume was attached during measurement window in seconds
+// @bingen:generate:FileSystem
+// FileSystem records information for a nodes local storage
+type FileSystem struct {
+	CapacityBytes float64 `json:"capacityBytes"` // Total capacity of the volume in bytes
+	UsageByteAvg  float64 `json:"usageByteAvg"`
+	UsageByteMax  float64 `json:"usageByteMax"`
 }
 
-// CpuMillicoreUsageAverage calculates the average CPU usage in millicores over the uptime period.
-// Returns 0 if uptime is 0 to avoid division by zero.
-func (n *Node) CpuMillicoreUsageAverage() Measurement {
-	if n.DurationSeconds == 0 {
-		return 0
+func (n *Node) ValidateNode(window Window) error {
+	if n.UID == "" {
+		return fmt.Errorf("UID is missing for Node with name '%s'", n.Name)
 	}
-	return n.CpuMillicoreSeconds / n.DurationSeconds
-}
 
-// RAMByteUsageAverage calculates the average RAM usage in bytes over the uptime period.
-// Returns 0 if uptime is 0 to avoid division by zero.
-func (n *Node) RAMByteUsageAverage() Measurement {
-	if n.DurationSeconds == 0 {
-		return 0
+	if n.Name == "" {
+		return fmt.Errorf("Name is missing for Node '%s'", n.UID)
 	}
-	return n.RAMByteSeconds / n.DurationSeconds
-}
-
-// TotalVolumeUsageByteSeconds returns the sum of all volume usage Byte-seconds across all attached volumes.
-func (n *Node) TotalVolumeUsageByteSeconds() Measurement {
-	var total Measurement
-	for _, volume := range n.AttachedVolumes {
-		total += volume.UsageByteSeconds
-	}
-	return total
-}
 
-// TotalVolumeCapacityBytes returns the sum of all volume capacities across all attached volumes.
-func (n *Node) TotalVolumeCapacityBytes() Measurement {
-	var total Measurement
-	for _, volume := range n.AttachedVolumes {
-		total += volume.CapacityBytes
+	if err := checkWindow(window, n.Start, n.End); err != nil {
+		return err
 	}
-	return total
-}
 
-// GetVolumeUsageAverage calculates the average storage usage in bytes for a specific volume over the uptime period.
-// Returns 0 if uptime is 0 or volume doesn't exist.
-func (n *Node) GetVolumeUsageAverage(volumeUID string) Measurement {
-	volume, exists := n.AttachedVolumes[volumeUID]
-	if !exists || n.DurationSeconds == 0 {
-		return 0
-	}
-	return volume.UsageByteSeconds / n.DurationSeconds
+	return nil
 }
 
-func (kms *KubeModelSet) RegisterNode(uid, name string) error {
-	if uid == "" {
-		err := fmt.Errorf("UID is nil for Node '%s'", name)
+// RegisterNode validates and adds a node to the set
+func (kms *KubeModelSet) RegisterNode(node *Node) error {
+	if err := node.ValidateNode(kms.Window); err != nil {
+		err = fmt.Errorf("RegisterNode: invalid node: %w", err)
 		kms.Error(err)
 		return err
 	}
 
-	if _, ok := kms.Nodes[uid]; !ok {
+	if _, ok := kms.Nodes[node.UID]; !ok {
 		if kms.Cluster == nil {
-			kms.Warnf("RegisterNode(%s, %s): Cluster is nil", uid, name)
+			kms.Warnf("RegisterNode: Cluster is nil")
 		}
 
-		kms.Nodes[uid] = &Node{
-			UID:             uid,
-			Name:            name,
-			AttachedVolumes: make(map[string]*NodeVolumeUsage),
-		}
+		kms.Nodes[node.UID] = node
 
 		kms.Metadata.ObjectCount++
 	}

+ 143 - 0
core/pkg/model/kubemodel/node_test.go

@@ -0,0 +1,143 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidateNode(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	tests := []struct {
+		name    string
+		node    *Node
+		wantErr string
+	}{
+		{
+			name:    "empty UID",
+			node:    &Node{Name: "my-node", Start: start, End: end},
+			wantErr: "UID is missing for Node with name 'my-node'",
+		},
+		{
+			name:    "empty Name",
+			node:    &Node{UID: "node-uid", Start: start, End: end},
+			wantErr: "Name is missing for Node 'node-uid'",
+		},
+		{
+			name:    "outside window",
+			node:    &Node{UID: "node-uid", Name: "my-node", Start: start.Add(-time.Hour), End: end},
+			wantErr: checkWindow(window, start.Add(-time.Hour), end).Error(),
+		},
+		{
+			name: "valid",
+			node: &Node{UID: "node-uid", Name: "my-node", Start: start, End: end},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.node.ValidateNode(window)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestRegisterNode(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	newNode := func(uid, name string) *Node {
+		return &Node{UID: uid, Name: name, Start: start, End: end}
+	}
+	withCluster := func(kms *KubeModelSet) {
+		kms.RegisterCluster(&Cluster{UID: "cluster-uid", Start: start, End: end})
+	}
+
+	tests := []struct {
+		name    string
+		setup   func(*KubeModelSet)
+		node    *Node
+		wantErr string
+		want    *KubeModelSet
+	}{
+		{
+			name:    "validation failure",
+			node:    &Node{UID: "", Name: "my-node", Start: start, End: end},
+			wantErr: "RegisterNode: invalid node: UID is missing for Node with name 'my-node'",
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelError, Message: "RegisterNode: invalid node: UID is missing for Node with name 'my-node'"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name: "warns when cluster is nil",
+			node: newNode("node-uid", "my-node"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Nodes["node-uid"] = newNode("node-uid", "my-node")
+				kms.Metadata.ObjectCount = 1
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelWarning, Message: "RegisterNode: Cluster is nil"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:  "registers node with cluster",
+			setup: withCluster,
+			node:  newNode("node-uid", "my-node"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.Nodes["node-uid"] = newNode("node-uid", "my-node")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+		{
+			name: "duplicate registration is a no-op",
+			setup: func(kms *KubeModelSet) {
+				withCluster(kms)
+				kms.RegisterNode(newNode("node-uid", "original"))
+			},
+			node: newNode("node-uid", "duplicate"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.Nodes["node-uid"] = newNode("node-uid", "original")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			kms := NewKubeModelSet(start, end)
+			if tt.setup != nil {
+				tt.setup(kms)
+			}
+
+			err := kms.RegisterNode(tt.node)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+
+			KubeModelSetEquals(t, tt.want, kms)
+		})
+	}
+}

+ 28 - 39
core/pkg/model/kubemodel/owner.go

@@ -1,10 +1,12 @@
 package kubemodel
 
 import (
-	"fmt"
-	"time"
+	"strings"
+
+	"github.com/opencost/opencost/core/pkg/log"
 )
 
+// @bingen:generate:OwnerKind
 type OwnerKind string
 
 const (
@@ -16,43 +18,30 @@ const (
 	OwnerKindReplicaSet  OwnerKind = "replicaset"
 )
 
-// Owner represents a Kubernetes resource owner (workload controller)
-type Owner struct {
-	UID          string            `json:"uid"`
-	NamespaceUID string            `json:"namespaceUid"`
-	Name         string            `json:"name"`
-	Kind         OwnerKind         `json:"kind"`
-	Labels       map[string]string `json:"labels,omitempty"`
-	Annotations  map[string]string `json:"annotations,omitempty"`
-	Start        time.Time         `json:"start,omitempty"`
-	End          time.Time         `json:"end,omitempty"`
-}
-
-func (kms *KubeModelSet) RegisterOwner(uid, name, namespace, kind string) error {
-	if uid == "" {
-		err := fmt.Errorf("UID is nil for Owner '%s'", name)
-		kms.Error(err)
-		return err
-	}
-
-	if _, ok := kms.Owners[uid]; !ok {
-		namespaceUID := ""
-
-		if ns, ok := kms.idx.namespaceByName[namespace]; !ok {
-			kms.Warnf("RegisterOwner(%s, %s, %s, %s): missing namespace '%s'", uid, name, namespace, kind, namespace)
-		} else {
-			namespaceUID = ns.UID
-		}
-
-		kms.Owners[uid] = &Owner{
-			UID:          uid,
-			Name:         name,
-			NamespaceUID: namespaceUID,
-			Kind:         OwnerKind(kind),
-		}
-
-		kms.Metadata.ObjectCount++
+func ParseOwnerKind(kind string) OwnerKind {
+	switch strings.ToLower(kind) {
+	case "deployment":
+		return OwnerKindDeployment
+	case "statefulset":
+		return OwnerKindStatefulSet
+	case "daemonset":
+		return OwnerKindDaemonSet
+	case "job":
+		return OwnerKindJob
+	case "cronjob":
+		return OwnerKindCronJob
+	case "replicaset":
+		return OwnerKindReplicaSet
+	default:
+		log.Warnf("failed to find owner kind for '%s'", kind)
+		return OwnerKind(strings.ToLower(kind))
 	}
+}
 
-	return nil
+// @bingen:generate:Owner
+// Owner represents a Kubernetes resource owner (workload controller)
+type Owner struct {
+	UID        string    `json:"uid"`
+	Controller bool      `json:"controller"`
+	Kind       OwnerKind `json:"kind"`
 }

+ 41 - 0
core/pkg/model/kubemodel/owner_test.go

@@ -0,0 +1,41 @@
+package kubemodel
+
+import (
+	"testing"
+)
+
+func TestParseOwnerKind(t *testing.T) {
+	tests := []struct {
+		input    string
+		expected OwnerKind
+	}{
+		// Exact canonical values
+		{"deployment", OwnerKindDeployment},
+		{"statefulset", OwnerKindStatefulSet},
+		{"daemonset", OwnerKindDaemonSet},
+		{"job", OwnerKindJob},
+		{"cronjob", OwnerKindCronJob},
+		{"replicaset", OwnerKindReplicaSet},
+		// Case-insensitive
+		{"Deployment", OwnerKindDeployment},
+		{"StatefulSet", OwnerKindStatefulSet},
+		{"DaemonSet", OwnerKindDaemonSet},
+		{"Job", OwnerKindJob},
+		{"CronJob", OwnerKindCronJob},
+		{"ReplicaSet", OwnerKindReplicaSet},
+		{"DEPLOYMENT", OwnerKindDeployment},
+		// Unknown input: lowercased passthrough (no mapping)
+		{"Rollout", OwnerKind("rollout")},
+		{"CustomController", OwnerKind("customcontroller")},
+		{"", OwnerKind("")},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.input, func(t *testing.T) {
+			got := ParseOwnerKind(tt.input)
+			if got != tt.expected {
+				t.Errorf("ParseOwnerKind(%q) = %q, want %q", tt.input, got, tt.expected)
+			}
+		})
+	}
+}

+ 45 - 27
core/pkg/model/kubemodel/pod.go

@@ -5,43 +5,61 @@ import (
 	"time"
 )
 
+// @bingen:generate:PodPVCVolume
+type PodPVCVolume struct {
+	Name                     string `json:"name"`
+	PersistentVolumeClaimUID string `json:"persistentVolumeClaimUid"`
+}
+
+// @bingen:generate:Pod
 type Pod struct {
-	UID                  string            `json:"uid"`
-	NamespaceUID         string            `json:"namespaceUid"`
-	OwnerUID             string            `json:"ownerUid"` // Reference to Owner (Deployment, StatefulSet, etc.)
-	NodeUID              string            `json:"nodeUid"`
-	Name                 string            `json:"name"`
-	Labels               map[string]string `json:"labels,omitempty"`
-	Annotations          map[string]string `json:"annotations,omitempty"`
-	DurationSeconds      Measurement       `json:"durationSeconds"`
-	NetworkTransferBytes Measurement       `json:"networkTransferBytes"`
-	NetworkReceiveBytes  Measurement       `json:"networkReceiveBytes"`
-	Start                time.Time         `json:"start,omitempty"` // Pod creation/start timestamp
-	End                  time.Time         `json:"end,omitempty"`   // Pod deletion/end timestamp (nil if still running)
+	UID                   string                 `json:"uid"`
+	NamespaceUID          string                 `json:"namespaceUid"`
+	NodeUID               string                 `json:"nodeUid"`
+	Name                  string                 `json:"name"`
+	Owners                []Owner                `json:"owners"`
+	PVCVolumes            []PodPVCVolume         `json:"pvcVolumes,omitempty"`
+	Labels                map[string]string      `json:"labels,omitempty"`
+	Annotations           map[string]string      `json:"annotations,omitempty"`
+	NetworkTrafficDetails []NetworkTrafficDetail `json:"networkTrafficDetails,omitempty"`
+	Start                 time.Time              `json:"start"`
+	End                   time.Time              `json:"end"`
 }
 
-func (kms *KubeModelSet) RegisterPod(uid, name, namespace string) error {
-	if uid == "" {
-		err := fmt.Errorf("UID is nil for Pod '%s'", name)
-		kms.Error(err)
+func (p *Pod) ValidatePod(window Window) error {
+	if p.UID == "" {
+		return fmt.Errorf("UID is missing for Pod with name '%s'", p.Name)
+	}
+
+	if p.Name == "" {
+		return fmt.Errorf("Name is missing for Pod '%s'", p.UID)
+	}
+
+	if p.NamespaceUID == "" {
+		return fmt.Errorf("NamespaceUID is missing for Pod '%s'", p.UID)
+	}
+
+	if err := checkWindow(window, p.Start, p.End); err != nil {
 		return err
 	}
 
-	if _, ok := kms.Pods[uid]; !ok {
-		namespaceUID := ""
+	return nil
+}
 
-		if ns, ok := kms.idx.namespaceByName[namespace]; !ok {
-			kms.Warnf("RegisterPod(%s, %s, %s): missing namespace '%s'", uid, name, namespace, namespace)
-		} else {
-			namespaceUID = ns.UID
-		}
+func (kms *KubeModelSet) RegisterPod(pod *Pod) error {
+	if err := pod.ValidatePod(kms.Window); err != nil {
+		err = fmt.Errorf("RegisterPod: invalid pod: %w", err)
+		kms.Error(err)
+		return err
+	}
 
-		kms.Pods[uid] = &Pod{
-			UID:          uid,
-			Name:         name,
-			NamespaceUID: namespaceUID,
+	if _, ok := kms.Pods[pod.UID]; !ok {
+		if kms.Cluster == nil {
+			kms.Warnf("RegisterPod: Cluster is nil")
 		}
 
+		kms.Pods[pod.UID] = pod
+
 		kms.Metadata.ObjectCount++
 	}
 

+ 148 - 0
core/pkg/model/kubemodel/pod_test.go

@@ -0,0 +1,148 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidatePod(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	tests := []struct {
+		name    string
+		pod     *Pod
+		wantErr string
+	}{
+		{
+			name:    "empty UID",
+			pod:     &Pod{Name: "my-pod", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr: "UID is missing for Pod with name 'my-pod'",
+		},
+		{
+			name:    "empty Name",
+			pod:     &Pod{UID: "pod-uid", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr: "Name is missing for Pod 'pod-uid'",
+		},
+		{
+			name:    "empty NamespaceUID",
+			pod:     &Pod{UID: "pod-uid", Name: "my-pod", Start: start, End: end},
+			wantErr: "NamespaceUID is missing for Pod 'pod-uid'",
+		},
+		{
+			name:    "outside window",
+			pod:     &Pod{UID: "pod-uid", Name: "my-pod", NamespaceUID: "ns-uid", Start: start.Add(-time.Hour), End: end},
+			wantErr: checkWindow(window, start.Add(-time.Hour), end).Error(),
+		},
+		{
+			name: "valid",
+			pod:  &Pod{UID: "pod-uid", Name: "my-pod", NamespaceUID: "ns-uid", Start: start, End: end},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.pod.ValidatePod(window)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestRegisterPod(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	newPod := func(uid, name string) *Pod {
+		return &Pod{UID: uid, Name: name, NamespaceUID: "ns-uid", Start: start, End: end}
+	}
+	withCluster := func(kms *KubeModelSet) {
+		kms.RegisterCluster(&Cluster{UID: "cluster-uid", Start: start, End: end})
+	}
+
+	tests := []struct {
+		name    string
+		setup   func(*KubeModelSet)
+		pod     *Pod
+		wantErr string
+		want    *KubeModelSet
+	}{
+		{
+			name:    "validation failure",
+			pod:     &Pod{UID: "", Name: "my-pod", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr: "RegisterPod: invalid pod: UID is missing for Pod with name 'my-pod'",
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelError, Message: "RegisterPod: invalid pod: UID is missing for Pod with name 'my-pod'"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name: "warns when cluster is nil",
+			pod:  newPod("pod-uid", "my-pod"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Pods["pod-uid"] = newPod("pod-uid", "my-pod")
+				kms.Metadata.ObjectCount = 1
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelWarning, Message: "RegisterPod: Cluster is nil"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:  "registers pod with cluster",
+			setup: withCluster,
+			pod:   newPod("pod-uid", "my-pod"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.Pods["pod-uid"] = newPod("pod-uid", "my-pod")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+		{
+			name: "duplicate registration is a no-op",
+			setup: func(kms *KubeModelSet) {
+				withCluster(kms)
+				kms.RegisterPod(newPod("pod-uid", "original"))
+			},
+			pod: newPod("pod-uid", "duplicate"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.Pods["pod-uid"] = newPod("pod-uid", "original")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			kms := NewKubeModelSet(start, end)
+			if tt.setup != nil {
+				tt.setup(kms)
+			}
+
+			err := kms.RegisterPod(tt.pod)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+
+			KubeModelSetEquals(t, tt.want, kms)
+		})
+	}
+}

+ 0 - 14
core/pkg/model/kubemodel/provider.go

@@ -1,14 +0,0 @@
-package kubemodel
-
-// @bingen:generate:Provider
-type Provider string
-
-const (
-	ProviderEmpty        Provider = ""
-	ProviderAWS          Provider = "AWS"
-	ProviderGCP          Provider = "GCP"
-	ProviderAzure        Provider = "Azure"
-	ProviderAlibaba      Provider = "Alibaba"
-	ProviderDigitalOcean Provider = "DigitalOcean"
-	ProviderOracle       Provider = "Oracle"
-)

+ 28 - 50
core/pkg/model/kubemodel/pv.go

@@ -5,64 +5,42 @@ import (
 	"time"
 )
 
+// @bingen:generate:PersistentVolume
 type PersistentVolume struct {
-	// Version 1 fields
-	UID          string            `json:"uid"`
-	ClusterUID   string            `json:"clusterUid"`
-	Name         string            `json:"name"`
-	Namespace    string            `json:"namespace"`
-	Labels       map[string]string `json:"labels,omitempty"`
-	Annotations  map[string]string `json:"annotations,omitempty"`
-	StorageClass string            `json:"storageClass"`
-	SizeBytes    Measurement       `json:"size"`
-	// awsElasticBlockStore, azureDisk, gcePersistentDisk, csi, nfs, local, etc.
-	Type string `json:"type,omitempty"`
-	// ebs.csi.aws.com, disk.csi.azure.com, etc.
-	CSIDriver string `json:"csiDriver,omitempty"`
-	// Cloud provider's volume identifier
-	ProviderVolumeID string `json:"providerVolumeId,omitempty"`
-	// ReadWriteOnce, ReadWriteMany, ReadOnlyMany
-	AccessModes []string `json:"accessModes,omitempty"`
-	// Retain, Delete, Recycle
-	ReclaimPolicy string `json:"reclaimPolicy,omitempty"`
-	// Cloud region for cross-region cost tracking
-	Region string `json:"region,omitempty"`
-	// Availability zone for cross-AZ cost tracking
-	Zone string `json:"zone,omitempty"`
-	// Volume lifecycle timestamps
-	Start time.Time `json:"start"`         // Volume creation timestamp
-	End   time.Time `json:"end,omitempty"` // Volume deletion timestamp (nil if still active)
-	// Duration volume existed within measurement window
-	DurationSeconds Measurement `json:"durationSeconds"`
-	// JSON-encoded node affinity for local volumes
-	NodeAffinity string `json:"nodeAffinity,omitempty"`
-	// Storage performance characteristics
-	ProvisionedIOPS       Measurement `json:"provisionedIops,omitempty"`       // Provisioned IOPS (AWS io1/io2, Azure Premium)
-	ProvisionedThroughput Measurement `json:"provisionedThroughput,omitempty"` // Provisioned throughput in MB/s
-	PerformanceMode       string      `json:"performanceMode,omitempty"`       // "generalPurpose", "maxIO", "provisioned"
+	UID             string    `json:"uid"`
+	Name            string    `json:"name"`
+	StorageClass    string    `json:"storageClass"`
+	CSIVolumeHandle string    `json:"csiVolumeHandle,omitempty"`
+	SizeBytes       float64   `json:"size"`
+	Start           time.Time `json:"start"`
+	End             time.Time `json:"end"`
 }
 
-func (kms *KubeModelSet) RegisterVolume(uid, name string) error {
-	if uid == "" {
-		err := fmt.Errorf("UID is nil for PersistentVolume '%s'", name)
-		kms.Error(err)
+func (p *PersistentVolume) ValidatePersistentVolume(window Window) error {
+	if p.UID == "" {
+		return fmt.Errorf("UID is missing for PersistentVolume with name '%s'", p.Name)
+	}
+
+	if p.Name == "" {
+		return fmt.Errorf("Name is missing for PersistentVolume '%s'", p.UID)
+	}
+
+	if err := checkWindow(window, p.Start, p.End); err != nil {
 		return err
 	}
 
-	if _, ok := kms.Volumes[uid]; !ok {
-		clusterUID := ""
+	return nil
+}
 
-		if kms.Cluster == nil {
-			kms.Warnf("RegisterVolume(%s, %s): Cluster is nil", uid, name)
-		} else {
-			clusterUID = kms.Cluster.UID
-		}
+func (kms *KubeModelSet) RegisterPersistentVolume(pv *PersistentVolume) error {
+	if err := pv.ValidatePersistentVolume(kms.Window); err != nil {
+		err = fmt.Errorf("RegisterPersistentVolume: invalid persistent volume: %w", err)
+		kms.Error(err)
+		return err
+	}
 
-		kms.Volumes[uid] = &PersistentVolume{
-			UID:        uid,
-			ClusterUID: clusterUID,
-			Name:       name,
-		}
+	if _, ok := kms.PersistentVolumes[pv.UID]; !ok {
+		kms.PersistentVolumes[pv.UID] = pv
 
 		kms.Metadata.ObjectCount++
 	}

+ 123 - 0
core/pkg/model/kubemodel/pv_test.go

@@ -0,0 +1,123 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidatePersistentVolume(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	tests := []struct {
+		name    string
+		pv      *PersistentVolume
+		wantErr string
+	}{
+		{
+			name:    "empty UID",
+			pv:      &PersistentVolume{Name: "my-pv", Start: start, End: end},
+			wantErr: "UID is missing for PersistentVolume with name 'my-pv'",
+		},
+		{
+			name:    "empty Name",
+			pv:      &PersistentVolume{UID: "pv-uid", Start: start, End: end},
+			wantErr: "Name is missing for PersistentVolume 'pv-uid'",
+		},
+		{
+			name:    "outside window",
+			pv:      &PersistentVolume{UID: "pv-uid", Name: "my-pv", Start: start.Add(-time.Hour), End: end},
+			wantErr: checkWindow(window, start.Add(-time.Hour), end).Error(),
+		},
+		{
+			name: "valid",
+			pv:   &PersistentVolume{UID: "pv-uid", Name: "my-pv", Start: start, End: end},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.pv.ValidatePersistentVolume(window)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestRegisterPersistentVolume(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	newPV := func(uid, name string) *PersistentVolume {
+		return &PersistentVolume{UID: uid, Name: name, Start: start, End: end}
+	}
+
+	tests := []struct {
+		name    string
+		setup   func(*KubeModelSet)
+		pv      *PersistentVolume
+		wantErr string
+		want    *KubeModelSet
+	}{
+		{
+			name:    "validation failure",
+			pv:      &PersistentVolume{UID: "", Name: "my-pv", Start: start, End: end},
+			wantErr: "RegisterPersistentVolume: invalid persistent volume: UID is missing for PersistentVolume with name 'my-pv'",
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelError, Message: "RegisterPersistentVolume: invalid persistent volume: UID is missing for PersistentVolume with name 'my-pv'"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name: "registers persistent volume",
+			pv:   newPV("pv-uid", "my-pv"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.PersistentVolumes["pv-uid"] = newPV("pv-uid", "my-pv")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+		{
+			name: "duplicate registration is a no-op",
+			setup: func(kms *KubeModelSet) {
+				kms.RegisterPersistentVolume(newPV("pv-uid", "original"))
+			},
+			pv: newPV("pv-uid", "duplicate"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.PersistentVolumes["pv-uid"] = newPV("pv-uid", "original")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			kms := NewKubeModelSet(start, end)
+			if tt.setup != nil {
+				tt.setup(kms)
+			}
+
+			err := kms.RegisterPersistentVolume(tt.pv)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+
+			KubeModelSetEquals(t, tt.want, kms)
+		})
+	}
+}

+ 35 - 36
core/pkg/model/kubemodel/pvc.go

@@ -5,50 +5,49 @@ import (
 	"time"
 )
 
+// @bingen:generate:PersistentVolumeClaim
 type PersistentVolumeClaim struct {
-	// Version 1 fields
-	UID                string            `json:"uid"`
-	NamespaceUID       string            `json:"namespaceUid"`
-	VolumeUID          *string           `json:"volumeUid,omitempty"`
-	PodUID             *string           `json:"podUid,omitempty"`
-	Name               string            `json:"name"`
-	Labels             map[string]string `json:"labels,omitempty"`
-	Annotations        map[string]string `json:"annotations,omitempty"`
-	StorageClass       string            `json:"storageClass"`
-	StorageByteSeconds Measurement       `json:"storageByteSeconds"`
-	RequestedBytes     Measurement       `json:"requestedBytes"`
-	Size               Measurement       `json:"size"` // Size in bytes
-	VolumeName         string            `json:"volumeName"`
-	// ReadWriteOnce, ReadWriteMany, ReadOnlyMany
-	AccessModes           []string    `json:"accessModes,omitempty"`
-	ActualUsedByteSeconds Measurement `json:"actualUsedByteSeconds,omitempty"`
-	Start                 time.Time   `json:"start"`         // PVC creation timestamp
-	End                   time.Time   `json:"end,omitempty"` // PVC deletion timestamp (nil if still active)
-	BoundAt               time.Time   `json:"boundAt,omitempty"`
-	DurationSeconds       Measurement `json:"durationSeconds,omitempty"`
+	UID                 string    `json:"uid"`
+	NamespaceUID        string    `json:"namespaceUid"`
+	Name                string    `json:"name"`
+	PersistentVolumeUID string    `json:"persistentVolumeUID,omitempty"`
+	StorageClass        string    `json:"storageClass"`
+	Start               time.Time `json:"start"`
+	End                 time.Time `json:"end"`
+	RequestedBytes      float64   `json:"requestedBytes"`
+	UsageBytesAvg       float64   `json:"usageBytesAvg"`
+	UsageBytesMax       float64   `json:"usageBytesMax"`
 }
 
-func (kms *KubeModelSet) RegisterPVC(uid, name, namespace string) error {
-	if uid == "" {
-		err := fmt.Errorf("UID is nil for PVC '%s'", name)
-		kms.Error(err)
+func (p *PersistentVolumeClaim) ValidatePVC(window Window) error {
+	if p.UID == "" {
+		return fmt.Errorf("UID is missing for PVC with name '%s'", p.Name)
+	}
+
+	if p.Name == "" {
+		return fmt.Errorf("Name is missing for PVC '%s'", p.UID)
+	}
+
+	if p.NamespaceUID == "" {
+		return fmt.Errorf("NamespaceUID is missing for PVC '%s'", p.UID)
+	}
+
+	if err := checkWindow(window, p.Start, p.End); err != nil {
 		return err
 	}
 
-	if _, ok := kms.PersistentVolumeClaims[uid]; !ok {
-		namespaceUID := ""
+	return nil
+}
 
-		if ns, ok := kms.idx.namespaceByName[namespace]; !ok {
-			kms.Warnf("RegisterPVC(%s, %s, %s): missing namespace '%s'", uid, name, namespace, namespace)
-		} else {
-			namespaceUID = ns.UID
-		}
+func (kms *KubeModelSet) RegisterPVC(pvc *PersistentVolumeClaim) error {
+	if err := pvc.ValidatePVC(kms.Window); err != nil {
+		err = fmt.Errorf("RegisterPVC: invalid pvc: %w", err)
+		kms.Error(err)
+		return err
+	}
 
-		kms.PersistentVolumeClaims[uid] = &PersistentVolumeClaim{
-			UID:          uid,
-			Name:         name,
-			NamespaceUID: namespaceUID,
-		}
+	if _, ok := kms.PersistentVolumeClaims[pvc.UID]; !ok {
+		kms.PersistentVolumeClaims[pvc.UID] = pvc
 
 		kms.Metadata.ObjectCount++
 	}

+ 128 - 0
core/pkg/model/kubemodel/pvc_test.go

@@ -0,0 +1,128 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidatePVC(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	tests := []struct {
+		name    string
+		pvc     *PersistentVolumeClaim
+		wantErr string
+	}{
+		{
+			name:    "empty UID",
+			pvc:     &PersistentVolumeClaim{Name: "my-pvc", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr: "UID is missing for PVC with name 'my-pvc'",
+		},
+		{
+			name:    "empty Name",
+			pvc:     &PersistentVolumeClaim{UID: "pvc-uid", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr: "Name is missing for PVC 'pvc-uid'",
+		},
+		{
+			name:    "empty NamespaceUID",
+			pvc:     &PersistentVolumeClaim{UID: "pvc-uid", Name: "my-pvc", Start: start, End: end},
+			wantErr: "NamespaceUID is missing for PVC 'pvc-uid'",
+		},
+		{
+			name:    "outside window",
+			pvc:     &PersistentVolumeClaim{UID: "pvc-uid", Name: "my-pvc", NamespaceUID: "ns-uid", Start: start.Add(-time.Hour), End: end},
+			wantErr: checkWindow(window, start.Add(-time.Hour), end).Error(),
+		},
+		{
+			name: "valid",
+			pvc:  &PersistentVolumeClaim{UID: "pvc-uid", Name: "my-pvc", NamespaceUID: "ns-uid", Start: start, End: end},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.pvc.ValidatePVC(window)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestRegisterPVC(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	newPVC := func(uid, name string) *PersistentVolumeClaim {
+		return &PersistentVolumeClaim{UID: uid, Name: name, NamespaceUID: "ns-uid", Start: start, End: end}
+	}
+
+	tests := []struct {
+		name    string
+		setup   func(*KubeModelSet)
+		pvc     *PersistentVolumeClaim
+		wantErr string
+		want    *KubeModelSet
+	}{
+		{
+			name:    "validation failure",
+			pvc:     &PersistentVolumeClaim{UID: "", Name: "my-pvc", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr: "RegisterPVC: invalid pvc: UID is missing for PVC with name 'my-pvc'",
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelError, Message: "RegisterPVC: invalid pvc: UID is missing for PVC with name 'my-pvc'"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name: "registers pvc",
+			pvc:  newPVC("pvc-uid", "my-pvc"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.PersistentVolumeClaims["pvc-uid"] = newPVC("pvc-uid", "my-pvc")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+		{
+			name: "duplicate registration is a no-op",
+			setup: func(kms *KubeModelSet) {
+				kms.RegisterPVC(newPVC("pvc-uid", "original"))
+			},
+			pvc: newPVC("pvc-uid", "duplicate"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.PersistentVolumeClaims["pvc-uid"] = newPVC("pvc-uid", "original")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			kms := NewKubeModelSet(start, end)
+			if tt.setup != nil {
+				tt.setup(kms)
+			}
+
+			err := kms.RegisterPVC(tt.pvc)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+
+			KubeModelSetEquals(t, tt.want, kms)
+		})
+	}
+}

+ 59 - 0
core/pkg/model/kubemodel/replicaset.go

@@ -0,0 +1,59 @@
+package kubemodel
+
+import (
+	"fmt"
+	"time"
+)
+
+// @bingen:generate:ReplicaSet
+// ReplicaSet represents a Kubernetes ReplicaSet resource
+type ReplicaSet struct {
+	UID          string            `json:"uid"`
+	NamespaceUID string            `json:"namespaceUid"`
+	Name         string            `json:"name"`
+	Owners       []Owner           `json:"owners"`
+	Labels       map[string]string `json:"labels,omitempty"`
+	Annotations  map[string]string `json:"annotations,omitempty"`
+	Start        time.Time         `json:"start,omitempty"`
+	End          time.Time         `json:"end,omitempty"`
+}
+
+func (r *ReplicaSet) ValidateReplicaSet(window Window) error {
+	if r.UID == "" {
+		return fmt.Errorf("UID is missing for ReplicaSet with name '%s'", r.Name)
+	}
+
+	if r.Name == "" {
+		return fmt.Errorf("Name is missing for ReplicaSet '%s'", r.UID)
+	}
+
+	if r.NamespaceUID == "" {
+		return fmt.Errorf("NamespaceUID is missing for ReplicaSet '%s'", r.UID)
+	}
+
+	if err := checkWindow(window, r.Start, r.End); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (kms *KubeModelSet) RegisterReplicaSet(replicaSet *ReplicaSet) error {
+	if err := replicaSet.ValidateReplicaSet(kms.Window); err != nil {
+		err = fmt.Errorf("RegisterReplicaSet: invalid replicaset: %w", err)
+		kms.Error(err)
+		return err
+	}
+
+	if _, ok := kms.ReplicaSets[replicaSet.UID]; !ok {
+		if kms.Cluster == nil {
+			kms.Warnf("RegisterReplicaSet: Cluster is nil")
+		}
+
+		kms.ReplicaSets[replicaSet.UID] = replicaSet
+
+		kms.Metadata.ObjectCount++
+	}
+
+	return nil
+}

+ 148 - 0
core/pkg/model/kubemodel/replicaset_test.go

@@ -0,0 +1,148 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidateReplicaSet(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	tests := []struct {
+		name       string
+		replicaSet *ReplicaSet
+		wantErr    string
+	}{
+		{
+			name:       "empty UID",
+			replicaSet: &ReplicaSet{Name: "my-rs", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr:    "UID is missing for ReplicaSet with name 'my-rs'",
+		},
+		{
+			name:       "empty Name",
+			replicaSet: &ReplicaSet{UID: "rs-uid", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr:    "Name is missing for ReplicaSet 'rs-uid'",
+		},
+		{
+			name:       "empty NamespaceUID",
+			replicaSet: &ReplicaSet{UID: "rs-uid", Name: "my-rs", Start: start, End: end},
+			wantErr:    "NamespaceUID is missing for ReplicaSet 'rs-uid'",
+		},
+		{
+			name:       "outside window",
+			replicaSet: &ReplicaSet{UID: "rs-uid", Name: "my-rs", NamespaceUID: "ns-uid", Start: start.Add(-time.Hour), End: end},
+			wantErr:    checkWindow(window, start.Add(-time.Hour), end).Error(),
+		},
+		{
+			name:       "valid",
+			replicaSet: &ReplicaSet{UID: "rs-uid", Name: "my-rs", NamespaceUID: "ns-uid", Start: start, End: end},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.replicaSet.ValidateReplicaSet(window)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestRegisterReplicaSet(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	newReplicaSet := func(uid, name string) *ReplicaSet {
+		return &ReplicaSet{UID: uid, Name: name, NamespaceUID: "ns-uid", Start: start, End: end}
+	}
+	withCluster := func(kms *KubeModelSet) {
+		kms.RegisterCluster(&Cluster{UID: "cluster-uid", Start: start, End: end})
+	}
+
+	tests := []struct {
+		name       string
+		setup      func(*KubeModelSet)
+		replicaSet *ReplicaSet
+		wantErr    string
+		want       *KubeModelSet
+	}{
+		{
+			name:       "validation failure",
+			replicaSet: &ReplicaSet{UID: "", Name: "my-rs", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr:    "RegisterReplicaSet: invalid replicaset: UID is missing for ReplicaSet with name 'my-rs'",
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelError, Message: "RegisterReplicaSet: invalid replicaset: UID is missing for ReplicaSet with name 'my-rs'"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:       "warns when cluster is nil",
+			replicaSet: newReplicaSet("rs-uid", "my-rs"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.ReplicaSets["rs-uid"] = newReplicaSet("rs-uid", "my-rs")
+				kms.Metadata.ObjectCount = 1
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelWarning, Message: "RegisterReplicaSet: Cluster is nil"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:       "registers replicaset with cluster",
+			setup:      withCluster,
+			replicaSet: newReplicaSet("rs-uid", "my-rs"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.ReplicaSets["rs-uid"] = newReplicaSet("rs-uid", "my-rs")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+		{
+			name: "duplicate registration is a no-op",
+			setup: func(kms *KubeModelSet) {
+				withCluster(kms)
+				kms.RegisterReplicaSet(newReplicaSet("rs-uid", "original"))
+			},
+			replicaSet: newReplicaSet("rs-uid", "duplicate"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.ReplicaSets["rs-uid"] = newReplicaSet("rs-uid", "original")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			kms := NewKubeModelSet(start, end)
+			if tt.setup != nil {
+				tt.setup(kms)
+			}
+
+			err := kms.RegisterReplicaSet(tt.replicaSet)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+
+			KubeModelSetEquals(t, tt.want, kms)
+		})
+	}
+}

+ 5 - 4
core/pkg/model/kubemodel/resource.go

@@ -4,10 +4,11 @@ package kubemodel
 type Resource string
 
 const (
-	ResourceCPU     Resource = "cpu"
-	ResourceMemory  Resource = "memory"
-	ResourceGPU     Resource = "gpu"
-	ResourceStorage Resource = "storage"
+	ResourceCPU          Resource = "cpu"
+	ResourceMemory       Resource = "memory"
+	ResourceNvidia       Resource = "nvidia.com/gpu"
+	ResourceNvidiaShared Resource = "nvidia.com/gpu.shared"
+	ResourceAWSVGPU      Resource = "k8s.amazonaws.com/vgpu"
 )
 
 // @bingen:generate:ResourceQuantity

+ 35 - 16
core/pkg/model/kubemodel/resourcequota.go

@@ -70,30 +70,49 @@ func (stat *ResourceQuotaStatusUsed) SetLimit(resource Resource, unit Unit, stat
 	stat.Limits.Set(resource, unit, statType, value)
 }
 
-func (kms *KubeModelSet) RegisterResourceQuota(uid, name, namespace string) error {
-	if uid == "" {
-		err := fmt.Errorf("UID is nil for ResourceQuota '%s'", name)
-		kms.Error(err)
+func (rq *ResourceQuota) ValidateResourceQuota(window Window) error {
+	if rq.UID == "" {
+		return fmt.Errorf("UID is missing for ResourceQuota with name '%s'", rq.Name)
+	}
+
+	if rq.Name == "" {
+		return fmt.Errorf("Name is missing for ResourceQuota '%s'", rq.UID)
+	}
+
+	if rq.NamespaceUID == "" {
+		return fmt.Errorf("NamespaceUID is missing for ResourceQuota '%s'", rq.UID)
+	}
+
+	if err := checkWindow(window, rq.Start, rq.End); err != nil {
 		return err
 	}
 
-	if _, ok := kms.ResourceQuotas[uid]; !ok {
-		namespaceUID := ""
+	return nil
+}
 
-		if _, ok := kms.idx.namespaceByName[namespace]; !ok {
-			kms.Warnf("RegisterResourceQuota(%s, %s, %s): missing namespace", uid, name, namespace)
-		} else {
-			namespaceUID = kms.idx.namespaceByName[namespace].UID
+func (kms *KubeModelSet) RegisterResourceQuota(resourceQuota *ResourceQuota) error {
+	if err := resourceQuota.ValidateResourceQuota(kms.Window); err != nil {
+		err = fmt.Errorf("RegisterResourceQuota: invalid resource quota: %w", err)
+		kms.Error(err)
+		return err
+	}
+
+	if _, ok := kms.ResourceQuotas[resourceQuota.UID]; !ok {
+		// Initialize Spec and Status if they're nil
+		if resourceQuota.Spec == nil {
+			resourceQuota.Spec = &ResourceQuotaSpec{Hard: &ResourceQuotaSpecHard{}}
+		} else if resourceQuota.Spec.Hard == nil {
+			resourceQuota.Spec.Hard = &ResourceQuotaSpecHard{}
 		}
 
-		kms.ResourceQuotas[uid] = &ResourceQuota{
-			UID:          uid,
-			Name:         name,
-			NamespaceUID: namespaceUID,
-			Spec:         &ResourceQuotaSpec{Hard: &ResourceQuotaSpecHard{}},
-			Status:       &ResourceQuotaStatus{Used: &ResourceQuotaStatusUsed{}},
+		if resourceQuota.Status == nil {
+			resourceQuota.Status = &ResourceQuotaStatus{Used: &ResourceQuotaStatusUsed{}}
+		} else if resourceQuota.Status.Used == nil {
+			resourceQuota.Status.Used = &ResourceQuotaStatusUsed{}
 		}
 
+		kms.ResourceQuotas[resourceQuota.UID] = resourceQuota
+
 		kms.Metadata.ObjectCount++
 	}
 

+ 135 - 0
core/pkg/model/kubemodel/resourcequota_test.go

@@ -0,0 +1,135 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidateResourceQuota(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	tests := []struct {
+		name          string
+		resourceQuota *ResourceQuota
+		wantErr       string
+	}{
+		{
+			name:          "empty UID",
+			resourceQuota: &ResourceQuota{Name: "my-rq", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr:       "UID is missing for ResourceQuota with name 'my-rq'",
+		},
+		{
+			name:          "empty Name",
+			resourceQuota: &ResourceQuota{UID: "rq-uid", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr:       "Name is missing for ResourceQuota 'rq-uid'",
+		},
+		{
+			name:          "empty NamespaceUID",
+			resourceQuota: &ResourceQuota{UID: "rq-uid", Name: "my-rq", Start: start, End: end},
+			wantErr:       "NamespaceUID is missing for ResourceQuota 'rq-uid'",
+		},
+		{
+			name:          "outside window",
+			resourceQuota: &ResourceQuota{UID: "rq-uid", Name: "my-rq", NamespaceUID: "ns-uid", Start: start.Add(-time.Hour), End: end},
+			wantErr:       checkWindow(window, start.Add(-time.Hour), end).Error(),
+		},
+		{
+			name:          "valid",
+			resourceQuota: &ResourceQuota{UID: "rq-uid", Name: "my-rq", NamespaceUID: "ns-uid", Start: start, End: end},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.resourceQuota.ValidateResourceQuota(window)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestRegisterResourceQuota(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	newRQ := func(uid, name string) *ResourceQuota {
+		return &ResourceQuota{UID: uid, Name: name, NamespaceUID: "ns-uid", Start: start, End: end}
+	}
+	// RegisterResourceQuota initializes nil Spec/Status on registration.
+	newRegisteredRQ := func(uid, name string) *ResourceQuota {
+		rq := newRQ(uid, name)
+		rq.Spec = &ResourceQuotaSpec{Hard: &ResourceQuotaSpecHard{}}
+		rq.Status = &ResourceQuotaStatus{Used: &ResourceQuotaStatusUsed{}}
+		return rq
+	}
+
+	tests := []struct {
+		name          string
+		setup         func(*KubeModelSet)
+		resourceQuota *ResourceQuota
+		wantErr       string
+		want          *KubeModelSet
+	}{
+		{
+			name:          "validation failure",
+			resourceQuota: &ResourceQuota{UID: "", Name: "my-rq", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr:       "RegisterResourceQuota: invalid resource quota: UID is missing for ResourceQuota with name 'my-rq'",
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelError, Message: "RegisterResourceQuota: invalid resource quota: UID is missing for ResourceQuota with name 'my-rq'"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:          "registers resource quota",
+			resourceQuota: newRQ("rq-uid", "my-rq"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.ResourceQuotas["rq-uid"] = newRegisteredRQ("rq-uid", "my-rq")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+		{
+			name: "duplicate registration is a no-op",
+			setup: func(kms *KubeModelSet) {
+				kms.RegisterResourceQuota(newRQ("rq-uid", "original"))
+			},
+			resourceQuota: newRQ("rq-uid", "duplicate"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.ResourceQuotas["rq-uid"] = newRegisteredRQ("rq-uid", "original")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			kms := NewKubeModelSet(start, end)
+			if tt.setup != nil {
+				tt.setup(kms)
+			}
+
+			err := kms.RegisterResourceQuota(tt.resourceQuota)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+
+			KubeModelSetEquals(t, tt.want, kms)
+		})
+	}
+}

+ 65 - 22
core/pkg/model/kubemodel/service.go

@@ -1,7 +1,12 @@
 package kubemodel
 
-import "time"
+import (
+	"fmt"
+	"strings"
+	"time"
+)
 
+// @bingen:generate:ServiceType
 type ServiceType string
 
 const (
@@ -11,14 +16,24 @@ const (
 	ServiceTypeExternalName ServiceType = "ExternalName"
 )
 
-type ServicePort struct {
-	Name       string `json:"name"`
-	Port       uint16 `json:"port"`
-	TargetPort uint16 `json:"targetPort"`
-	NodePort   uint16 `json:"nodePort"`
-	Protocol   string `json:"protocol"`
+// ParseServiceType converts a string to a ServiceType, performing case-insensitive matching.
+// Returns ServiceTypeClusterIP (the default) if the service type string is not recognized.
+func ParseServiceType(serviceType string) ServiceType {
+	switch strings.ToLower(serviceType) {
+	case "clusterip":
+		return ServiceTypeClusterIP
+	case "nodeport":
+		return ServiceTypeNodePort
+	case "loadbalancer", "lb":
+		return ServiceTypeLoadBalancer
+	case "externalname":
+		return ServiceTypeExternalName
+	default:
+		return ServiceTypeClusterIP
+	}
 }
 
+// @bingen:generate:Service
 // Service represents a Kubernetes Service with network traffic tracking for cost allocation.
 //
 // Network Cost Allocation Strategy:
@@ -39,22 +54,50 @@ type ServicePort struct {
 // - Data transfer: $0.09/GB for internet egress
 // Total Service Cost = (LB hours × hourly rate) + (LCU hours × LCU rate) + (NetworkTransferBytes × transfer rate)
 type Service struct {
-	UID                  string            `json:"uid"`
-	NamespaceUID         string            `json:"namespaceUid"`
-	Name                 string            `json:"name"`
-	Type                 ServiceType       `json:"type"`
-	Hostname             string            `json:"hostname,omitempty"`
-	Labels               map[string]string `json:"labels,omitempty"`
-	Annotations          map[string]string `json:"annotations,omitempty"`
-	Ports                []ServicePort     `json:"ports,omitempty"`
-	Start                time.Time         `json:"start"`
-	End                  time.Time         `json:"end"`
-	NetworkTransferBytes Measurement       `json:"networkTransferBytes"`
-	NetworkReceiveBytes  Measurement       `json:"networkReceiveBytes"`
+	UID          string      `json:"uid"`
+	NamespaceUID string      `json:"namespaceUid"`
+	Name         string      `json:"name"`
+	Type         ServiceType `json:"type"`
+	Start        time.Time   `json:"start"`
+	End          time.Time   `json:"end"`
 	// Label selector to identify pods/containers targeted by this service
 	// Maps label keys to values (e.g., {"app": "nginx", "tier": "frontend"})
 	// Pods with matching labels will receive traffic from this service
-	Selector map[string]string `json:"selector,omitempty"`
-	// Lifecycle tracking
-	DurationSeconds Measurement `json:"durationSeconds"` // Duration service existed within measurement window
+	Selector         map[string]string `json:"selector,omitempty"`
+	LBIngressAddress string            `json:"lbIngressAddress,omitempty"`
+}
+
+func (s *Service) ValidateService(window Window) error {
+	if s.UID == "" {
+		return fmt.Errorf("UID is missing for Service with name '%s'", s.Name)
+	}
+
+	if s.Name == "" {
+		return fmt.Errorf("Name is missing for Service '%s'", s.UID)
+	}
+
+	if s.NamespaceUID == "" {
+		return fmt.Errorf("NamespaceUID is missing for Service '%s'", s.UID)
+	}
+
+	if err := checkWindow(window, s.Start, s.End); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (kms *KubeModelSet) RegisterService(service *Service) error {
+	if err := service.ValidateService(kms.Window); err != nil {
+		err = fmt.Errorf("RegisterService: invalid service: %w", err)
+		kms.Error(err)
+		return err
+	}
+
+	if _, ok := kms.Services[service.UID]; !ok {
+		kms.Services[service.UID] = service
+		kms.Metadata.ObjectCount++
+	}
+
+	return nil
 }

+ 164 - 0
core/pkg/model/kubemodel/service_test.go

@@ -0,0 +1,164 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidateService(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	tests := []struct {
+		name    string
+		service *Service
+		wantErr string
+	}{
+		{
+			name:    "empty UID",
+			service: &Service{Name: "my-svc", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr: "UID is missing for Service with name 'my-svc'",
+		},
+		{
+			name:    "empty Name",
+			service: &Service{UID: "svc-uid", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr: "Name is missing for Service 'svc-uid'",
+		},
+		{
+			name:    "empty NamespaceUID",
+			service: &Service{UID: "svc-uid", Name: "my-svc", Start: start, End: end},
+			wantErr: "NamespaceUID is missing for Service 'svc-uid'",
+		},
+		{
+			name:    "outside window",
+			service: &Service{UID: "svc-uid", Name: "my-svc", NamespaceUID: "ns-uid", Start: start.Add(-time.Hour), End: end},
+			wantErr: checkWindow(window, start.Add(-time.Hour), end).Error(),
+		},
+		{
+			name:    "valid",
+			service: &Service{UID: "svc-uid", Name: "my-svc", NamespaceUID: "ns-uid", Start: start, End: end},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.service.ValidateService(window)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestRegisterService(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	newService := func(uid, name string) *Service {
+		return &Service{UID: uid, Name: name, NamespaceUID: "ns-uid", Start: start, End: end}
+	}
+
+	tests := []struct {
+		name    string
+		setup   func(*KubeModelSet)
+		service *Service
+		wantErr string
+		want    *KubeModelSet
+	}{
+		{
+			name:    "validation failure",
+			service: &Service{UID: "", Name: "my-svc", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr: "RegisterService: invalid service: UID is missing for Service with name 'my-svc'",
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelError, Message: "RegisterService: invalid service: UID is missing for Service with name 'my-svc'"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:    "registers service",
+			service: newService("svc-uid", "my-svc"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Services["svc-uid"] = newService("svc-uid", "my-svc")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+		{
+			name: "duplicate registration is a no-op",
+			setup: func(kms *KubeModelSet) {
+				kms.RegisterService(newService("svc-uid", "original"))
+			},
+			service: newService("svc-uid", "duplicate"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Services["svc-uid"] = newService("svc-uid", "original")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			kms := NewKubeModelSet(start, end)
+			if tt.setup != nil {
+				tt.setup(kms)
+			}
+
+			err := kms.RegisterService(tt.service)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+
+			KubeModelSetEquals(t, tt.want, kms)
+		})
+	}
+}
+
+func TestParseServiceType(t *testing.T) {
+	tests := []struct {
+		input    string
+		expected ServiceType
+	}{
+		// Exact canonical values
+		{"ClusterIP", ServiceTypeClusterIP},
+		{"NodePort", ServiceTypeNodePort},
+		{"LoadBalancer", ServiceTypeLoadBalancer},
+		{"ExternalName", ServiceTypeExternalName},
+		// Case-insensitive
+		{"clusterip", ServiceTypeClusterIP},
+		{"nodeport", ServiceTypeNodePort},
+		{"loadbalancer", ServiceTypeLoadBalancer},
+		{"externalname", ServiceTypeExternalName},
+		{"CLUSTERIP", ServiceTypeClusterIP},
+		{"LOADBALANCER", ServiceTypeLoadBalancer},
+		// "lb" alias
+		{"lb", ServiceTypeLoadBalancer},
+		{"LB", ServiceTypeLoadBalancer},
+		// Unknown input defaults to ClusterIP
+		{"", ServiceTypeClusterIP},
+		{"unknown", ServiceTypeClusterIP},
+		{"ingress", ServiceTypeClusterIP},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.input, func(t *testing.T) {
+			got := ParseServiceType(tt.input)
+			if got != tt.expected {
+				t.Errorf("ParseServiceType(%q) = %q, want %q", tt.input, got, tt.expected)
+			}
+		})
+	}
+}

+ 59 - 0
core/pkg/model/kubemodel/statefulset.go

@@ -0,0 +1,59 @@
+package kubemodel
+
+import (
+	"fmt"
+	"time"
+)
+
+// @bingen:generate:StatefulSet
+// StatefulSet represents a Kubernetes StatefulSet resource
+type StatefulSet struct {
+	UID          string            `json:"uid"`
+	NamespaceUID string            `json:"namespaceUid"`
+	Name         string            `json:"name"`
+	Labels       map[string]string `json:"labels,omitempty"`
+	Annotations  map[string]string `json:"annotations,omitempty"`
+	MatchLabels  map[string]string `json:"matchLabels,omitempty"`
+	Start        time.Time         `json:"start,omitempty"`
+	End          time.Time         `json:"end,omitempty"`
+}
+
+func (s *StatefulSet) ValidateStatefulSet(window Window) error {
+	if s.UID == "" {
+		return fmt.Errorf("UID is missing for StatefulSet with name '%s'", s.Name)
+	}
+
+	if s.Name == "" {
+		return fmt.Errorf("Name is missing for StatefulSet '%s'", s.UID)
+	}
+
+	if s.NamespaceUID == "" {
+		return fmt.Errorf("NamespaceUID is missing for StatefulSet '%s'", s.UID)
+	}
+
+	if err := checkWindow(window, s.Start, s.End); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (kms *KubeModelSet) RegisterStatefulSet(statefulSet *StatefulSet) error {
+	if err := statefulSet.ValidateStatefulSet(kms.Window); err != nil {
+		err = fmt.Errorf("RegisterStatefulSet: invalid statefulset: %w", err)
+		kms.Error(err)
+		return err
+	}
+
+	if _, ok := kms.StatefulSets[statefulSet.UID]; !ok {
+		if kms.Cluster == nil {
+			kms.Warnf("RegisterStatefulSet: Cluster is nil")
+		}
+
+		kms.StatefulSets[statefulSet.UID] = statefulSet
+
+		kms.Metadata.ObjectCount++
+	}
+
+	return nil
+}

+ 148 - 0
core/pkg/model/kubemodel/statefulset_test.go

@@ -0,0 +1,148 @@
+package kubemodel
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidateStatefulSet(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+	window := Window{Start: start, End: end}
+
+	tests := []struct {
+		name        string
+		statefulSet *StatefulSet
+		wantErr     string
+	}{
+		{
+			name:        "empty UID",
+			statefulSet: &StatefulSet{Name: "my-sts", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr:     "UID is missing for StatefulSet with name 'my-sts'",
+		},
+		{
+			name:        "empty Name",
+			statefulSet: &StatefulSet{UID: "sts-uid", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr:     "Name is missing for StatefulSet 'sts-uid'",
+		},
+		{
+			name:        "empty NamespaceUID",
+			statefulSet: &StatefulSet{UID: "sts-uid", Name: "my-sts", Start: start, End: end},
+			wantErr:     "NamespaceUID is missing for StatefulSet 'sts-uid'",
+		},
+		{
+			name:        "outside window",
+			statefulSet: &StatefulSet{UID: "sts-uid", Name: "my-sts", NamespaceUID: "ns-uid", Start: start.Add(-time.Hour), End: end},
+			wantErr:     checkWindow(window, start.Add(-time.Hour), end).Error(),
+		},
+		{
+			name:        "valid",
+			statefulSet: &StatefulSet{UID: "sts-uid", Name: "my-sts", NamespaceUID: "ns-uid", Start: start, End: end},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := tt.statefulSet.ValidateStatefulSet(window)
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestRegisterStatefulSet(t *testing.T) {
+	start := time.Now().UTC().Truncate(time.Hour)
+	end := start.Add(time.Hour)
+
+	newStatefulSet := func(uid, name string) *StatefulSet {
+		return &StatefulSet{UID: uid, Name: name, NamespaceUID: "ns-uid", Start: start, End: end}
+	}
+	withCluster := func(kms *KubeModelSet) {
+		kms.RegisterCluster(&Cluster{UID: "cluster-uid", Start: start, End: end})
+	}
+
+	tests := []struct {
+		name        string
+		setup       func(*KubeModelSet)
+		statefulSet *StatefulSet
+		wantErr     string
+		want        *KubeModelSet
+	}{
+		{
+			name:        "validation failure",
+			statefulSet: &StatefulSet{UID: "", Name: "my-sts", NamespaceUID: "ns-uid", Start: start, End: end},
+			wantErr:     "RegisterStatefulSet: invalid statefulset: UID is missing for StatefulSet with name 'my-sts'",
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelError, Message: "RegisterStatefulSet: invalid statefulset: UID is missing for StatefulSet with name 'my-sts'"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:        "warns when cluster is nil",
+			statefulSet: newStatefulSet("sts-uid", "my-sts"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				kms.StatefulSets["sts-uid"] = newStatefulSet("sts-uid", "my-sts")
+				kms.Metadata.ObjectCount = 1
+				kms.Metadata.Diagnostics = []Diagnostic{
+					{Level: DiagnosticLevelWarning, Message: "RegisterStatefulSet: Cluster is nil"},
+				}
+				return kms
+			}(),
+		},
+		{
+			name:        "registers statefulset with cluster",
+			setup:       withCluster,
+			statefulSet: newStatefulSet("sts-uid", "my-sts"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.StatefulSets["sts-uid"] = newStatefulSet("sts-uid", "my-sts")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+		{
+			name: "duplicate registration is a no-op",
+			setup: func(kms *KubeModelSet) {
+				withCluster(kms)
+				kms.RegisterStatefulSet(newStatefulSet("sts-uid", "original"))
+			},
+			statefulSet: newStatefulSet("sts-uid", "duplicate"),
+			want: func() *KubeModelSet {
+				kms := NewKubeModelSet(start, end)
+				withCluster(kms)
+				kms.StatefulSets["sts-uid"] = newStatefulSet("sts-uid", "original")
+				kms.Metadata.ObjectCount = 1
+				return kms
+			}(),
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			kms := NewKubeModelSet(start, end)
+			if tt.setup != nil {
+				tt.setup(kms)
+			}
+
+			err := kms.RegisterStatefulSet(tt.statefulSet)
+
+			if tt.wantErr != "" {
+				require.EqualError(t, err, tt.wantErr)
+			} else {
+				require.NoError(t, err)
+			}
+
+			KubeModelSetEquals(t, tt.want, kms)
+		})
+	}
+}

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

@@ -3,9 +3,8 @@ package kubemodel
 // @bingen:generate:Unit
 type Unit string
 
-type Measurement = float64
-
 const (
+	UnitCore            = "c"
 	UnitMillicore       = "m"
 	UnitByte            = "B"
 	UnitSecond          = "s"

+ 0 - 246
core/pkg/model/pb/kubemodel/cluster.pb.go

@@ -1,246 +0,0 @@
-// Code generated by protoc-gen-go. DO NOT EDIT.
-// versions:
-// 	protoc-gen-go v1.36.9
-// 	protoc        v6.32.1
-// source: kubemodel/cluster.proto
-
-package kubemodel
-
-import (
-	pb "github.com/opencost/opencost/core/pkg/model/pb"
-	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
-	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
-	reflect "reflect"
-	sync "sync"
-	unsafe "unsafe"
-)
-
-const (
-	// Verify that this generated code is sufficiently up-to-date.
-	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
-	// Verify that runtime/protoimpl is sufficiently up-to-date.
-	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
-)
-
-// Provider represents the cloud provider or infrastructure type
-type Provider int32
-
-const (
-	Provider_PROVIDER_UNSPECIFIED  Provider = 0
-	Provider_PROVIDER_AWS          Provider = 1
-	Provider_PROVIDER_GCP          Provider = 2
-	Provider_PROVIDER_AZURE        Provider = 3
-	Provider_PROVIDER_ON_PREMISES  Provider = 4
-	Provider_PROVIDER_ALIBABA      Provider = 5
-	Provider_PROVIDER_DIGITALOCEAN Provider = 6
-	Provider_PROVIDER_ORACLE       Provider = 7
-)
-
-// Enum value maps for Provider.
-var (
-	Provider_name = map[int32]string{
-		0: "PROVIDER_UNSPECIFIED",
-		1: "PROVIDER_AWS",
-		2: "PROVIDER_GCP",
-		3: "PROVIDER_AZURE",
-		4: "PROVIDER_ON_PREMISES",
-		5: "PROVIDER_ALIBABA",
-		6: "PROVIDER_DIGITALOCEAN",
-		7: "PROVIDER_ORACLE",
-	}
-	Provider_value = map[string]int32{
-		"PROVIDER_UNSPECIFIED":  0,
-		"PROVIDER_AWS":          1,
-		"PROVIDER_GCP":          2,
-		"PROVIDER_AZURE":        3,
-		"PROVIDER_ON_PREMISES":  4,
-		"PROVIDER_ALIBABA":      5,
-		"PROVIDER_DIGITALOCEAN": 6,
-		"PROVIDER_ORACLE":       7,
-	}
-)
-
-func (x Provider) Enum() *Provider {
-	p := new(Provider)
-	*p = x
-	return p
-}
-
-func (x Provider) String() string {
-	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
-}
-
-func (Provider) Descriptor() protoreflect.EnumDescriptor {
-	return file_kubemodel_cluster_proto_enumTypes[0].Descriptor()
-}
-
-func (Provider) Type() protoreflect.EnumType {
-	return &file_kubemodel_cluster_proto_enumTypes[0]
-}
-
-func (x Provider) Number() protoreflect.EnumNumber {
-	return protoreflect.EnumNumber(x)
-}
-
-// Deprecated: Use Provider.Descriptor instead.
-func (Provider) EnumDescriptor() ([]byte, []int) {
-	return file_kubemodel_cluster_proto_rawDescGZIP(), []int{0}
-}
-
-// Cluster represents the top-level Kubernetes cluster
-type Cluster struct {
-	state protoimpl.MessageState `protogen:"open.v1"`
-	// Identification
-	// User-configured cluster identifier, defaults to kube-system namespace UID
-	// The kube-system namespace UID is unique per cluster and stable across its lifetime
-	ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"`
-	// Properties
-	Provider Provider `protobuf:"varint,2,opt,name=provider,proto3,enum=kubemodel.Provider" json:"provider,omitempty"`
-	Account  string   `protobuf:"bytes,3,opt,name=account,proto3" json:"account,omitempty"`
-	Name     string   `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"`
-	// Centralized window definition for entire cluster
-	// All resources inherit this window unless they specify their own duration
-	Window        *pb.Window `protobuf:"bytes,5,opt,name=window,proto3" json:"window,omitempty"`
-	unknownFields protoimpl.UnknownFields
-	sizeCache     protoimpl.SizeCache
-}
-
-func (x *Cluster) Reset() {
-	*x = Cluster{}
-	mi := &file_kubemodel_cluster_proto_msgTypes[0]
-	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
-	ms.StoreMessageInfo(mi)
-}
-
-func (x *Cluster) String() string {
-	return protoimpl.X.MessageStringOf(x)
-}
-
-func (*Cluster) ProtoMessage() {}
-
-func (x *Cluster) ProtoReflect() protoreflect.Message {
-	mi := &file_kubemodel_cluster_proto_msgTypes[0]
-	if x != nil {
-		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
-		if ms.LoadMessageInfo() == nil {
-			ms.StoreMessageInfo(mi)
-		}
-		return ms
-	}
-	return mi.MessageOf(x)
-}
-
-// Deprecated: Use Cluster.ProtoReflect.Descriptor instead.
-func (*Cluster) Descriptor() ([]byte, []int) {
-	return file_kubemodel_cluster_proto_rawDescGZIP(), []int{0}
-}
-
-func (x *Cluster) GetID() string {
-	if x != nil {
-		return x.ID
-	}
-	return ""
-}
-
-func (x *Cluster) GetProvider() Provider {
-	if x != nil {
-		return x.Provider
-	}
-	return Provider_PROVIDER_UNSPECIFIED
-}
-
-func (x *Cluster) GetAccount() string {
-	if x != nil {
-		return x.Account
-	}
-	return ""
-}
-
-func (x *Cluster) GetName() string {
-	if x != nil {
-		return x.Name
-	}
-	return ""
-}
-
-func (x *Cluster) GetWindow() *pb.Window {
-	if x != nil {
-		return x.Window
-	}
-	return nil
-}
-
-var File_kubemodel_cluster_proto protoreflect.FileDescriptor
-
-const file_kubemodel_cluster_proto_rawDesc = "" +
-	"\n" +
-	"\x17kubemodel/cluster.proto\x12\tkubemodel\x1a\x12model/window.proto\"\x9f\x01\n" +
-	"\aCluster\x12\x0e\n" +
-	"\x02ID\x18\x01 \x01(\tR\x02ID\x12/\n" +
-	"\bprovider\x18\x02 \x01(\x0e2\x13.kubemodel.ProviderR\bprovider\x12\x18\n" +
-	"\aaccount\x18\x03 \x01(\tR\aaccount\x12\x12\n" +
-	"\x04name\x18\x04 \x01(\tR\x04name\x12%\n" +
-	"\x06window\x18\x05 \x01(\v2\r.model.WindowR\x06window*\xbc\x01\n" +
-	"\bProvider\x12\x18\n" +
-	"\x14PROVIDER_UNSPECIFIED\x10\x00\x12\x10\n" +
-	"\fPROVIDER_AWS\x10\x01\x12\x10\n" +
-	"\fPROVIDER_GCP\x10\x02\x12\x12\n" +
-	"\x0ePROVIDER_AZURE\x10\x03\x12\x18\n" +
-	"\x14PROVIDER_ON_PREMISES\x10\x04\x12\x14\n" +
-	"\x10PROVIDER_ALIBABA\x10\x05\x12\x19\n" +
-	"\x15PROVIDER_DIGITALOCEAN\x10\x06\x12\x13\n" +
-	"\x0fPROVIDER_ORACLE\x10\aB:Z8github.com/opencost/opencost/core/pkg/model/pb/kubemodelb\x06proto3"
-
-var (
-	file_kubemodel_cluster_proto_rawDescOnce sync.Once
-	file_kubemodel_cluster_proto_rawDescData []byte
-)
-
-func file_kubemodel_cluster_proto_rawDescGZIP() []byte {
-	file_kubemodel_cluster_proto_rawDescOnce.Do(func() {
-		file_kubemodel_cluster_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_kubemodel_cluster_proto_rawDesc), len(file_kubemodel_cluster_proto_rawDesc)))
-	})
-	return file_kubemodel_cluster_proto_rawDescData
-}
-
-var file_kubemodel_cluster_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
-var file_kubemodel_cluster_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
-var file_kubemodel_cluster_proto_goTypes = []any{
-	(Provider)(0),     // 0: kubemodel.Provider
-	(*Cluster)(nil),   // 1: kubemodel.Cluster
-	(*pb.Window)(nil), // 2: model.Window
-}
-var file_kubemodel_cluster_proto_depIdxs = []int32{
-	0, // 0: kubemodel.Cluster.provider:type_name -> kubemodel.Provider
-	2, // 1: kubemodel.Cluster.window:type_name -> model.Window
-	2, // [2:2] is the sub-list for method output_type
-	2, // [2:2] is the sub-list for method input_type
-	2, // [2:2] is the sub-list for extension type_name
-	2, // [2:2] is the sub-list for extension extendee
-	0, // [0:2] is the sub-list for field type_name
-}
-
-func init() { file_kubemodel_cluster_proto_init() }
-func file_kubemodel_cluster_proto_init() {
-	if File_kubemodel_cluster_proto != nil {
-		return
-	}
-	type x struct{}
-	out := protoimpl.TypeBuilder{
-		File: protoimpl.DescBuilder{
-			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
-			RawDescriptor: unsafe.Slice(unsafe.StringData(file_kubemodel_cluster_proto_rawDesc), len(file_kubemodel_cluster_proto_rawDesc)),
-			NumEnums:      1,
-			NumMessages:   1,
-			NumExtensions: 0,
-			NumServices:   0,
-		},
-		GoTypes:           file_kubemodel_cluster_proto_goTypes,
-		DependencyIndexes: file_kubemodel_cluster_proto_depIdxs,
-		EnumInfos:         file_kubemodel_cluster_proto_enumTypes,
-		MessageInfos:      file_kubemodel_cluster_proto_msgTypes,
-	}.Build()
-	File_kubemodel_cluster_proto = out.File
-	file_kubemodel_cluster_proto_goTypes = nil
-	file_kubemodel_cluster_proto_depIdxs = nil
-}

+ 0 - 258
core/pkg/model/pb/kubemodel/container.pb.go

@@ -1,258 +0,0 @@
-// Code generated by protoc-gen-go. DO NOT EDIT.
-// versions:
-// 	protoc-gen-go v1.36.9
-// 	protoc        v6.32.1
-// source: kubemodel/container.proto
-
-package kubemodel
-
-import (
-	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
-	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
-	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
-	reflect "reflect"
-	sync "sync"
-	unsafe "unsafe"
-)
-
-const (
-	// Verify that this generated code is sufficiently up-to-date.
-	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
-	// Verify that runtime/protoimpl is sufficiently up-to-date.
-	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
-)
-
-// Container represents a container within a pod (allocated resource)
-type Container struct {
-	state protoimpl.MessageState `protogen:"open.v1"`
-	// Identification
-	PodID string `protobuf:"bytes,1,opt,name=podID,proto3" json:"podID,omitempty"`
-	// Properties
-	Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
-	// Resource lifecycle (only when different from cluster window)
-	CreationTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=creationTime,proto3,oneof" json:"creationTime,omitempty"`
-	DeletionTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=deletionTime,proto3,oneof" json:"deletionTime,omitempty"`
-	// Usage metrics
-	// CPU usage in core-hours
-	CpuCoreHours float32 `protobuf:"fixed32,5,opt,name=cpuCoreHours,proto3" json:"cpuCoreHours,omitempty"`
-	// CPU request average in cores
-	CpuCoreRequestAverage float32 `protobuf:"fixed32,6,opt,name=cpuCoreRequestAverage,proto3" json:"cpuCoreRequestAverage,omitempty"`
-	// CPU usage average in cores
-	CpuCoreUsageAverage float32 `protobuf:"fixed32,7,opt,name=cpuCoreUsageAverage,proto3" json:"cpuCoreUsageAverage,omitempty"`
-	// CPU usage max in cores
-	CpuCoreUsageMax float32 `protobuf:"fixed32,8,opt,name=cpuCoreUsageMax,proto3" json:"cpuCoreUsageMax,omitempty"`
-	// RAM usage in byte-hours
-	RamByteHours int64 `protobuf:"varint,9,opt,name=ramByteHours,proto3" json:"ramByteHours,omitempty"`
-	// RAM request average in bytes
-	RamBytesRequestAverage int64 `protobuf:"varint,10,opt,name=ramBytesRequestAverage,proto3" json:"ramBytesRequestAverage,omitempty"`
-	// RAM usage average in bytes
-	RamBytesUsageAverage int64 `protobuf:"varint,11,opt,name=ramBytesUsageAverage,proto3" json:"ramBytesUsageAverage,omitempty"`
-	// RAM usage max in bytes
-	RamBytesUsageMax int64 `protobuf:"varint,12,opt,name=ramBytesUsageMax,proto3" json:"ramBytesUsageMax,omitempty"`
-	// Diagnostic information about this resource
-	Diagnostic    *DiagnosticResult `protobuf:"bytes,99,opt,name=diagnostic,proto3,oneof" json:"diagnostic,omitempty"`
-	unknownFields protoimpl.UnknownFields
-	sizeCache     protoimpl.SizeCache
-}
-
-func (x *Container) Reset() {
-	*x = Container{}
-	mi := &file_kubemodel_container_proto_msgTypes[0]
-	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
-	ms.StoreMessageInfo(mi)
-}
-
-func (x *Container) String() string {
-	return protoimpl.X.MessageStringOf(x)
-}
-
-func (*Container) ProtoMessage() {}
-
-func (x *Container) ProtoReflect() protoreflect.Message {
-	mi := &file_kubemodel_container_proto_msgTypes[0]
-	if x != nil {
-		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
-		if ms.LoadMessageInfo() == nil {
-			ms.StoreMessageInfo(mi)
-		}
-		return ms
-	}
-	return mi.MessageOf(x)
-}
-
-// Deprecated: Use Container.ProtoReflect.Descriptor instead.
-func (*Container) Descriptor() ([]byte, []int) {
-	return file_kubemodel_container_proto_rawDescGZIP(), []int{0}
-}
-
-func (x *Container) GetPodID() string {
-	if x != nil {
-		return x.PodID
-	}
-	return ""
-}
-
-func (x *Container) GetName() string {
-	if x != nil {
-		return x.Name
-	}
-	return ""
-}
-
-func (x *Container) GetCreationTime() *timestamppb.Timestamp {
-	if x != nil {
-		return x.CreationTime
-	}
-	return nil
-}
-
-func (x *Container) GetDeletionTime() *timestamppb.Timestamp {
-	if x != nil {
-		return x.DeletionTime
-	}
-	return nil
-}
-
-func (x *Container) GetCpuCoreHours() float32 {
-	if x != nil {
-		return x.CpuCoreHours
-	}
-	return 0
-}
-
-func (x *Container) GetCpuCoreRequestAverage() float32 {
-	if x != nil {
-		return x.CpuCoreRequestAverage
-	}
-	return 0
-}
-
-func (x *Container) GetCpuCoreUsageAverage() float32 {
-	if x != nil {
-		return x.CpuCoreUsageAverage
-	}
-	return 0
-}
-
-func (x *Container) GetCpuCoreUsageMax() float32 {
-	if x != nil {
-		return x.CpuCoreUsageMax
-	}
-	return 0
-}
-
-func (x *Container) GetRamByteHours() int64 {
-	if x != nil {
-		return x.RamByteHours
-	}
-	return 0
-}
-
-func (x *Container) GetRamBytesRequestAverage() int64 {
-	if x != nil {
-		return x.RamBytesRequestAverage
-	}
-	return 0
-}
-
-func (x *Container) GetRamBytesUsageAverage() int64 {
-	if x != nil {
-		return x.RamBytesUsageAverage
-	}
-	return 0
-}
-
-func (x *Container) GetRamBytesUsageMax() int64 {
-	if x != nil {
-		return x.RamBytesUsageMax
-	}
-	return 0
-}
-
-func (x *Container) GetDiagnostic() *DiagnosticResult {
-	if x != nil {
-		return x.Diagnostic
-	}
-	return nil
-}
-
-var File_kubemodel_container_proto protoreflect.FileDescriptor
-
-const file_kubemodel_container_proto_rawDesc = "" +
-	"\n" +
-	"\x19kubemodel/container.proto\x12\tkubemodel\x1a\x1akubemodel/diagnostic.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa4\x05\n" +
-	"\tContainer\x12\x14\n" +
-	"\x05podID\x18\x01 \x01(\tR\x05podID\x12\x12\n" +
-	"\x04name\x18\x02 \x01(\tR\x04name\x12C\n" +
-	"\fcreationTime\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampH\x00R\fcreationTime\x88\x01\x01\x12C\n" +
-	"\fdeletionTime\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampH\x01R\fdeletionTime\x88\x01\x01\x12\"\n" +
-	"\fcpuCoreHours\x18\x05 \x01(\x02R\fcpuCoreHours\x124\n" +
-	"\x15cpuCoreRequestAverage\x18\x06 \x01(\x02R\x15cpuCoreRequestAverage\x120\n" +
-	"\x13cpuCoreUsageAverage\x18\a \x01(\x02R\x13cpuCoreUsageAverage\x12(\n" +
-	"\x0fcpuCoreUsageMax\x18\b \x01(\x02R\x0fcpuCoreUsageMax\x12\"\n" +
-	"\framByteHours\x18\t \x01(\x03R\framByteHours\x126\n" +
-	"\x16ramBytesRequestAverage\x18\n" +
-	" \x01(\x03R\x16ramBytesRequestAverage\x122\n" +
-	"\x14ramBytesUsageAverage\x18\v \x01(\x03R\x14ramBytesUsageAverage\x12*\n" +
-	"\x10ramBytesUsageMax\x18\f \x01(\x03R\x10ramBytesUsageMax\x12@\n" +
-	"\n" +
-	"diagnostic\x18c \x01(\v2\x1b.kubemodel.DiagnosticResultH\x02R\n" +
-	"diagnostic\x88\x01\x01B\x0f\n" +
-	"\r_creationTimeB\x0f\n" +
-	"\r_deletionTimeB\r\n" +
-	"\v_diagnosticB:Z8github.com/opencost/opencost/core/pkg/model/pb/kubemodelb\x06proto3"
-
-var (
-	file_kubemodel_container_proto_rawDescOnce sync.Once
-	file_kubemodel_container_proto_rawDescData []byte
-)
-
-func file_kubemodel_container_proto_rawDescGZIP() []byte {
-	file_kubemodel_container_proto_rawDescOnce.Do(func() {
-		file_kubemodel_container_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_kubemodel_container_proto_rawDesc), len(file_kubemodel_container_proto_rawDesc)))
-	})
-	return file_kubemodel_container_proto_rawDescData
-}
-
-var file_kubemodel_container_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
-var file_kubemodel_container_proto_goTypes = []any{
-	(*Container)(nil),             // 0: kubemodel.Container
-	(*timestamppb.Timestamp)(nil), // 1: google.protobuf.Timestamp
-	(*DiagnosticResult)(nil),      // 2: kubemodel.DiagnosticResult
-}
-var file_kubemodel_container_proto_depIdxs = []int32{
-	1, // 0: kubemodel.Container.creationTime:type_name -> google.protobuf.Timestamp
-	1, // 1: kubemodel.Container.deletionTime:type_name -> google.protobuf.Timestamp
-	2, // 2: kubemodel.Container.diagnostic:type_name -> kubemodel.DiagnosticResult
-	3, // [3:3] is the sub-list for method output_type
-	3, // [3:3] is the sub-list for method input_type
-	3, // [3:3] is the sub-list for extension type_name
-	3, // [3:3] is the sub-list for extension extendee
-	0, // [0:3] is the sub-list for field type_name
-}
-
-func init() { file_kubemodel_container_proto_init() }
-func file_kubemodel_container_proto_init() {
-	if File_kubemodel_container_proto != nil {
-		return
-	}
-	file_kubemodel_diagnostic_proto_init()
-	file_kubemodel_container_proto_msgTypes[0].OneofWrappers = []any{}
-	type x struct{}
-	out := protoimpl.TypeBuilder{
-		File: protoimpl.DescBuilder{
-			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
-			RawDescriptor: unsafe.Slice(unsafe.StringData(file_kubemodel_container_proto_rawDesc), len(file_kubemodel_container_proto_rawDesc)),
-			NumEnums:      0,
-			NumMessages:   1,
-			NumExtensions: 0,
-			NumServices:   0,
-		},
-		GoTypes:           file_kubemodel_container_proto_goTypes,
-		DependencyIndexes: file_kubemodel_container_proto_depIdxs,
-		MessageInfos:      file_kubemodel_container_proto_msgTypes,
-	}.Build()
-	File_kubemodel_container_proto = out.File
-	file_kubemodel_container_proto_goTypes = nil
-	file_kubemodel_container_proto_depIdxs = nil
-}

+ 0 - 298
core/pkg/model/pb/kubemodel/controller.pb.go

@@ -1,298 +0,0 @@
-// Code generated by protoc-gen-go. DO NOT EDIT.
-// versions:
-// 	protoc-gen-go v1.36.9
-// 	protoc        v6.32.1
-// source: kubemodel/controller.proto
-
-package kubemodel
-
-import (
-	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
-	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
-	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
-	reflect "reflect"
-	sync "sync"
-	unsafe "unsafe"
-)
-
-const (
-	// Verify that this generated code is sufficiently up-to-date.
-	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
-	// Verify that runtime/protoimpl is sufficiently up-to-date.
-	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
-)
-
-type ControllerKind int32
-
-const (
-	ControllerKind_KIND_UNSPECIFIED ControllerKind = 0
-	ControllerKind_DEPLOYMENT       ControllerKind = 1
-	ControllerKind_STATEFULSET      ControllerKind = 2
-	ControllerKind_DAEMONSET        ControllerKind = 3
-	ControllerKind_JOB              ControllerKind = 4
-	ControllerKind_CRONJOB          ControllerKind = 5
-	ControllerKind_REPLICASET       ControllerKind = 6
-)
-
-// Enum value maps for ControllerKind.
-var (
-	ControllerKind_name = map[int32]string{
-		0: "KIND_UNSPECIFIED",
-		1: "DEPLOYMENT",
-		2: "STATEFULSET",
-		3: "DAEMONSET",
-		4: "JOB",
-		5: "CRONJOB",
-		6: "REPLICASET",
-	}
-	ControllerKind_value = map[string]int32{
-		"KIND_UNSPECIFIED": 0,
-		"DEPLOYMENT":       1,
-		"STATEFULSET":      2,
-		"DAEMONSET":        3,
-		"JOB":              4,
-		"CRONJOB":          5,
-		"REPLICASET":       6,
-	}
-)
-
-func (x ControllerKind) Enum() *ControllerKind {
-	p := new(ControllerKind)
-	*p = x
-	return p
-}
-
-func (x ControllerKind) String() string {
-	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
-}
-
-func (ControllerKind) Descriptor() protoreflect.EnumDescriptor {
-	return file_kubemodel_controller_proto_enumTypes[0].Descriptor()
-}
-
-func (ControllerKind) Type() protoreflect.EnumType {
-	return &file_kubemodel_controller_proto_enumTypes[0]
-}
-
-func (x ControllerKind) Number() protoreflect.EnumNumber {
-	return protoreflect.EnumNumber(x)
-}
-
-// Deprecated: Use ControllerKind.Descriptor instead.
-func (ControllerKind) EnumDescriptor() ([]byte, []int) {
-	return file_kubemodel_controller_proto_rawDescGZIP(), []int{0}
-}
-
-// Controller represents a Kubernetes workload controller
-type Controller struct {
-	state protoimpl.MessageState `protogen:"open.v1"`
-	// Identification
-	ID          string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"`
-	NamespaceID string `protobuf:"bytes,2,opt,name=namespaceID,proto3" json:"namespaceID,omitempty"`
-	// Properties
-	Name        string            `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
-	Kind        ControllerKind    `protobuf:"varint,4,opt,name=kind,proto3,enum=kubemodel.ControllerKind" json:"kind,omitempty"`
-	Labels      map[string]string `protobuf:"bytes,5,rep,name=labels,proto3" json:"labels,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
-	Annotations map[string]string `protobuf:"bytes,6,rep,name=annotations,proto3" json:"annotations,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
-	// Resource lifecycle (only when different from cluster window)
-	CreationTime *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=creationTime,proto3,oneof" json:"creationTime,omitempty"`
-	DeletionTime *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=deletionTime,proto3,oneof" json:"deletionTime,omitempty"`
-	// Diagnostic information about this resource
-	Diagnostic    *DiagnosticResult `protobuf:"bytes,99,opt,name=diagnostic,proto3,oneof" json:"diagnostic,omitempty"`
-	unknownFields protoimpl.UnknownFields
-	sizeCache     protoimpl.SizeCache
-}
-
-func (x *Controller) Reset() {
-	*x = Controller{}
-	mi := &file_kubemodel_controller_proto_msgTypes[0]
-	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
-	ms.StoreMessageInfo(mi)
-}
-
-func (x *Controller) String() string {
-	return protoimpl.X.MessageStringOf(x)
-}
-
-func (*Controller) ProtoMessage() {}
-
-func (x *Controller) ProtoReflect() protoreflect.Message {
-	mi := &file_kubemodel_controller_proto_msgTypes[0]
-	if x != nil {
-		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
-		if ms.LoadMessageInfo() == nil {
-			ms.StoreMessageInfo(mi)
-		}
-		return ms
-	}
-	return mi.MessageOf(x)
-}
-
-// Deprecated: Use Controller.ProtoReflect.Descriptor instead.
-func (*Controller) Descriptor() ([]byte, []int) {
-	return file_kubemodel_controller_proto_rawDescGZIP(), []int{0}
-}
-
-func (x *Controller) GetID() string {
-	if x != nil {
-		return x.ID
-	}
-	return ""
-}
-
-func (x *Controller) GetNamespaceID() string {
-	if x != nil {
-		return x.NamespaceID
-	}
-	return ""
-}
-
-func (x *Controller) GetName() string {
-	if x != nil {
-		return x.Name
-	}
-	return ""
-}
-
-func (x *Controller) GetKind() ControllerKind {
-	if x != nil {
-		return x.Kind
-	}
-	return ControllerKind_KIND_UNSPECIFIED
-}
-
-func (x *Controller) GetLabels() map[string]string {
-	if x != nil {
-		return x.Labels
-	}
-	return nil
-}
-
-func (x *Controller) GetAnnotations() map[string]string {
-	if x != nil {
-		return x.Annotations
-	}
-	return nil
-}
-
-func (x *Controller) GetCreationTime() *timestamppb.Timestamp {
-	if x != nil {
-		return x.CreationTime
-	}
-	return nil
-}
-
-func (x *Controller) GetDeletionTime() *timestamppb.Timestamp {
-	if x != nil {
-		return x.DeletionTime
-	}
-	return nil
-}
-
-func (x *Controller) GetDiagnostic() *DiagnosticResult {
-	if x != nil {
-		return x.Diagnostic
-	}
-	return nil
-}
-
-var File_kubemodel_controller_proto protoreflect.FileDescriptor
-
-const file_kubemodel_controller_proto_rawDesc = "" +
-	"\n" +
-	"\x1akubemodel/controller.proto\x12\tkubemodel\x1a\x1akubemodel/diagnostic.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xfe\x04\n" +
-	"\n" +
-	"Controller\x12\x0e\n" +
-	"\x02ID\x18\x01 \x01(\tR\x02ID\x12 \n" +
-	"\vnamespaceID\x18\x02 \x01(\tR\vnamespaceID\x12\x12\n" +
-	"\x04name\x18\x03 \x01(\tR\x04name\x12-\n" +
-	"\x04kind\x18\x04 \x01(\x0e2\x19.kubemodel.ControllerKindR\x04kind\x129\n" +
-	"\x06labels\x18\x05 \x03(\v2!.kubemodel.Controller.LabelsEntryR\x06labels\x12H\n" +
-	"\vannotations\x18\x06 \x03(\v2&.kubemodel.Controller.AnnotationsEntryR\vannotations\x12C\n" +
-	"\fcreationTime\x18\a \x01(\v2\x1a.google.protobuf.TimestampH\x00R\fcreationTime\x88\x01\x01\x12C\n" +
-	"\fdeletionTime\x18\b \x01(\v2\x1a.google.protobuf.TimestampH\x01R\fdeletionTime\x88\x01\x01\x12@\n" +
-	"\n" +
-	"diagnostic\x18c \x01(\v2\x1b.kubemodel.DiagnosticResultH\x02R\n" +
-	"diagnostic\x88\x01\x01\x1a9\n" +
-	"\vLabelsEntry\x12\x10\n" +
-	"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
-	"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a>\n" +
-	"\x10AnnotationsEntry\x12\x10\n" +
-	"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
-	"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x0f\n" +
-	"\r_creationTimeB\x0f\n" +
-	"\r_deletionTimeB\r\n" +
-	"\v_diagnostic*|\n" +
-	"\x0eControllerKind\x12\x14\n" +
-	"\x10KIND_UNSPECIFIED\x10\x00\x12\x0e\n" +
-	"\n" +
-	"DEPLOYMENT\x10\x01\x12\x0f\n" +
-	"\vSTATEFULSET\x10\x02\x12\r\n" +
-	"\tDAEMONSET\x10\x03\x12\a\n" +
-	"\x03JOB\x10\x04\x12\v\n" +
-	"\aCRONJOB\x10\x05\x12\x0e\n" +
-	"\n" +
-	"REPLICASET\x10\x06B:Z8github.com/opencost/opencost/core/pkg/model/pb/kubemodelb\x06proto3"
-
-var (
-	file_kubemodel_controller_proto_rawDescOnce sync.Once
-	file_kubemodel_controller_proto_rawDescData []byte
-)
-
-func file_kubemodel_controller_proto_rawDescGZIP() []byte {
-	file_kubemodel_controller_proto_rawDescOnce.Do(func() {
-		file_kubemodel_controller_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_kubemodel_controller_proto_rawDesc), len(file_kubemodel_controller_proto_rawDesc)))
-	})
-	return file_kubemodel_controller_proto_rawDescData
-}
-
-var file_kubemodel_controller_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
-var file_kubemodel_controller_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
-var file_kubemodel_controller_proto_goTypes = []any{
-	(ControllerKind)(0),           // 0: kubemodel.ControllerKind
-	(*Controller)(nil),            // 1: kubemodel.Controller
-	nil,                           // 2: kubemodel.Controller.LabelsEntry
-	nil,                           // 3: kubemodel.Controller.AnnotationsEntry
-	(*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp
-	(*DiagnosticResult)(nil),      // 5: kubemodel.DiagnosticResult
-}
-var file_kubemodel_controller_proto_depIdxs = []int32{
-	0, // 0: kubemodel.Controller.kind:type_name -> kubemodel.ControllerKind
-	2, // 1: kubemodel.Controller.labels:type_name -> kubemodel.Controller.LabelsEntry
-	3, // 2: kubemodel.Controller.annotations:type_name -> kubemodel.Controller.AnnotationsEntry
-	4, // 3: kubemodel.Controller.creationTime:type_name -> google.protobuf.Timestamp
-	4, // 4: kubemodel.Controller.deletionTime:type_name -> google.protobuf.Timestamp
-	5, // 5: kubemodel.Controller.diagnostic:type_name -> kubemodel.DiagnosticResult
-	6, // [6:6] is the sub-list for method output_type
-	6, // [6:6] is the sub-list for method input_type
-	6, // [6:6] is the sub-list for extension type_name
-	6, // [6:6] is the sub-list for extension extendee
-	0, // [0:6] is the sub-list for field type_name
-}
-
-func init() { file_kubemodel_controller_proto_init() }
-func file_kubemodel_controller_proto_init() {
-	if File_kubemodel_controller_proto != nil {
-		return
-	}
-	file_kubemodel_diagnostic_proto_init()
-	file_kubemodel_controller_proto_msgTypes[0].OneofWrappers = []any{}
-	type x struct{}
-	out := protoimpl.TypeBuilder{
-		File: protoimpl.DescBuilder{
-			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
-			RawDescriptor: unsafe.Slice(unsafe.StringData(file_kubemodel_controller_proto_rawDesc), len(file_kubemodel_controller_proto_rawDesc)),
-			NumEnums:      1,
-			NumMessages:   3,
-			NumExtensions: 0,
-			NumServices:   0,
-		},
-		GoTypes:           file_kubemodel_controller_proto_goTypes,
-		DependencyIndexes: file_kubemodel_controller_proto_depIdxs,
-		EnumInfos:         file_kubemodel_controller_proto_enumTypes,
-		MessageInfos:      file_kubemodel_controller_proto_msgTypes,
-	}.Build()
-	File_kubemodel_controller_proto = out.File
-	file_kubemodel_controller_proto_goTypes = nil
-	file_kubemodel_controller_proto_depIdxs = nil
-}

+ 0 - 266
core/pkg/model/pb/kubemodel/diagnostic.pb.go

@@ -1,266 +0,0 @@
-// Code generated by protoc-gen-go. DO NOT EDIT.
-// versions:
-// 	protoc-gen-go v1.36.9
-// 	protoc        v6.32.1
-// source: kubemodel/diagnostic.proto
-
-package kubemodel
-
-import (
-	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
-	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
-	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
-	reflect "reflect"
-	sync "sync"
-	unsafe "unsafe"
-)
-
-const (
-	// Verify that this generated code is sufficiently up-to-date.
-	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
-	// Verify that runtime/protoimpl is sufficiently up-to-date.
-	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
-)
-
-// DiagnosticResult represents the result of a diagnostic run
-// This matches the JSON structure from core/pkg/diagnostics/diagnostics.go
-type DiagnosticResult struct {
-	state protoimpl.MessageState `protogen:"open.v1"`
-	// Unique Identifier for the diagnostic run result
-	Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
-	// Name of the diagnostic that ran
-	Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
-	// Description of the diagnostic run, human readable description
-	Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
-	// Category of the diagnostic run, used to group similar diagnostics
-	Category string `protobuf:"bytes,4,opt,name=category,proto3" json:"category,omitempty"`
-	// Timestamp when the diagnostic run was executed
-	Timestamp *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=timestamp,proto3" json:"timestamp,omitempty"`
-	// Error message if the diagnostic run failed (optional)
-	Error string `protobuf:"bytes,6,opt,name=error,proto3" json:"error,omitempty"`
-	// Additional custom information about the diagnostic run
-	// Using string values to match map[string]any from JSON
-	Details       map[string]string `protobuf:"bytes,7,rep,name=details,proto3" json:"details,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
-	unknownFields protoimpl.UnknownFields
-	sizeCache     protoimpl.SizeCache
-}
-
-func (x *DiagnosticResult) Reset() {
-	*x = DiagnosticResult{}
-	mi := &file_kubemodel_diagnostic_proto_msgTypes[0]
-	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
-	ms.StoreMessageInfo(mi)
-}
-
-func (x *DiagnosticResult) String() string {
-	return protoimpl.X.MessageStringOf(x)
-}
-
-func (*DiagnosticResult) ProtoMessage() {}
-
-func (x *DiagnosticResult) ProtoReflect() protoreflect.Message {
-	mi := &file_kubemodel_diagnostic_proto_msgTypes[0]
-	if x != nil {
-		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
-		if ms.LoadMessageInfo() == nil {
-			ms.StoreMessageInfo(mi)
-		}
-		return ms
-	}
-	return mi.MessageOf(x)
-}
-
-// Deprecated: Use DiagnosticResult.ProtoReflect.Descriptor instead.
-func (*DiagnosticResult) Descriptor() ([]byte, []int) {
-	return file_kubemodel_diagnostic_proto_rawDescGZIP(), []int{0}
-}
-
-func (x *DiagnosticResult) GetId() string {
-	if x != nil {
-		return x.Id
-	}
-	return ""
-}
-
-func (x *DiagnosticResult) GetName() string {
-	if x != nil {
-		return x.Name
-	}
-	return ""
-}
-
-func (x *DiagnosticResult) GetDescription() string {
-	if x != nil {
-		return x.Description
-	}
-	return ""
-}
-
-func (x *DiagnosticResult) GetCategory() string {
-	if x != nil {
-		return x.Category
-	}
-	return ""
-}
-
-func (x *DiagnosticResult) GetTimestamp() *timestamppb.Timestamp {
-	if x != nil {
-		return x.Timestamp
-	}
-	return nil
-}
-
-func (x *DiagnosticResult) GetError() string {
-	if x != nil {
-		return x.Error
-	}
-	return ""
-}
-
-func (x *DiagnosticResult) GetDetails() map[string]string {
-	if x != nil {
-		return x.Details
-	}
-	return nil
-}
-
-// DiagnosticsRunReport contains the start time and all diagnostic results
-// This matches the JSON structure from core/pkg/diagnostics/diagnostics.go
-type DiagnosticsRunReport struct {
-	state protoimpl.MessageState `protogen:"open.v1"`
-	// Application name that the diagnostics run belongs to
-	Application string `protobuf:"bytes,1,opt,name=application,proto3" json:"application,omitempty"`
-	// Time when the full diagnostics run started
-	StartTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=startTime,proto3" json:"startTime,omitempty"`
-	// All results of the diagnostics run
-	Results       []*DiagnosticResult `protobuf:"bytes,3,rep,name=results,proto3" json:"results,omitempty"`
-	unknownFields protoimpl.UnknownFields
-	sizeCache     protoimpl.SizeCache
-}
-
-func (x *DiagnosticsRunReport) Reset() {
-	*x = DiagnosticsRunReport{}
-	mi := &file_kubemodel_diagnostic_proto_msgTypes[1]
-	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
-	ms.StoreMessageInfo(mi)
-}
-
-func (x *DiagnosticsRunReport) String() string {
-	return protoimpl.X.MessageStringOf(x)
-}
-
-func (*DiagnosticsRunReport) ProtoMessage() {}
-
-func (x *DiagnosticsRunReport) ProtoReflect() protoreflect.Message {
-	mi := &file_kubemodel_diagnostic_proto_msgTypes[1]
-	if x != nil {
-		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
-		if ms.LoadMessageInfo() == nil {
-			ms.StoreMessageInfo(mi)
-		}
-		return ms
-	}
-	return mi.MessageOf(x)
-}
-
-// Deprecated: Use DiagnosticsRunReport.ProtoReflect.Descriptor instead.
-func (*DiagnosticsRunReport) Descriptor() ([]byte, []int) {
-	return file_kubemodel_diagnostic_proto_rawDescGZIP(), []int{1}
-}
-
-func (x *DiagnosticsRunReport) GetApplication() string {
-	if x != nil {
-		return x.Application
-	}
-	return ""
-}
-
-func (x *DiagnosticsRunReport) GetStartTime() *timestamppb.Timestamp {
-	if x != nil {
-		return x.StartTime
-	}
-	return nil
-}
-
-func (x *DiagnosticsRunReport) GetResults() []*DiagnosticResult {
-	if x != nil {
-		return x.Results
-	}
-	return nil
-}
-
-var File_kubemodel_diagnostic_proto protoreflect.FileDescriptor
-
-const file_kubemodel_diagnostic_proto_rawDesc = "" +
-	"\n" +
-	"\x1akubemodel/diagnostic.proto\x12\tkubemodel\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc4\x02\n" +
-	"\x10DiagnosticResult\x12\x0e\n" +
-	"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
-	"\x04name\x18\x02 \x01(\tR\x04name\x12 \n" +
-	"\vdescription\x18\x03 \x01(\tR\vdescription\x12\x1a\n" +
-	"\bcategory\x18\x04 \x01(\tR\bcategory\x128\n" +
-	"\ttimestamp\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12\x14\n" +
-	"\x05error\x18\x06 \x01(\tR\x05error\x12B\n" +
-	"\adetails\x18\a \x03(\v2(.kubemodel.DiagnosticResult.DetailsEntryR\adetails\x1a:\n" +
-	"\fDetailsEntry\x12\x10\n" +
-	"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
-	"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xa9\x01\n" +
-	"\x14DiagnosticsRunReport\x12 \n" +
-	"\vapplication\x18\x01 \x01(\tR\vapplication\x128\n" +
-	"\tstartTime\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tstartTime\x125\n" +
-	"\aresults\x18\x03 \x03(\v2\x1b.kubemodel.DiagnosticResultR\aresultsB:Z8github.com/opencost/opencost/core/pkg/model/pb/kubemodelb\x06proto3"
-
-var (
-	file_kubemodel_diagnostic_proto_rawDescOnce sync.Once
-	file_kubemodel_diagnostic_proto_rawDescData []byte
-)
-
-func file_kubemodel_diagnostic_proto_rawDescGZIP() []byte {
-	file_kubemodel_diagnostic_proto_rawDescOnce.Do(func() {
-		file_kubemodel_diagnostic_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_kubemodel_diagnostic_proto_rawDesc), len(file_kubemodel_diagnostic_proto_rawDesc)))
-	})
-	return file_kubemodel_diagnostic_proto_rawDescData
-}
-
-var file_kubemodel_diagnostic_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
-var file_kubemodel_diagnostic_proto_goTypes = []any{
-	(*DiagnosticResult)(nil),      // 0: kubemodel.DiagnosticResult
-	(*DiagnosticsRunReport)(nil),  // 1: kubemodel.DiagnosticsRunReport
-	nil,                           // 2: kubemodel.DiagnosticResult.DetailsEntry
-	(*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp
-}
-var file_kubemodel_diagnostic_proto_depIdxs = []int32{
-	3, // 0: kubemodel.DiagnosticResult.timestamp:type_name -> google.protobuf.Timestamp
-	2, // 1: kubemodel.DiagnosticResult.details:type_name -> kubemodel.DiagnosticResult.DetailsEntry
-	3, // 2: kubemodel.DiagnosticsRunReport.startTime:type_name -> google.protobuf.Timestamp
-	0, // 3: kubemodel.DiagnosticsRunReport.results:type_name -> kubemodel.DiagnosticResult
-	4, // [4:4] is the sub-list for method output_type
-	4, // [4:4] is the sub-list for method input_type
-	4, // [4:4] is the sub-list for extension type_name
-	4, // [4:4] is the sub-list for extension extendee
-	0, // [0:4] is the sub-list for field type_name
-}
-
-func init() { file_kubemodel_diagnostic_proto_init() }
-func file_kubemodel_diagnostic_proto_init() {
-	if File_kubemodel_diagnostic_proto != nil {
-		return
-	}
-	type x struct{}
-	out := protoimpl.TypeBuilder{
-		File: protoimpl.DescBuilder{
-			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
-			RawDescriptor: unsafe.Slice(unsafe.StringData(file_kubemodel_diagnostic_proto_rawDesc), len(file_kubemodel_diagnostic_proto_rawDesc)),
-			NumEnums:      0,
-			NumMessages:   3,
-			NumExtensions: 0,
-			NumServices:   0,
-		},
-		GoTypes:           file_kubemodel_diagnostic_proto_goTypes,
-		DependencyIndexes: file_kubemodel_diagnostic_proto_depIdxs,
-		MessageInfos:      file_kubemodel_diagnostic_proto_msgTypes,
-	}.Build()
-	File_kubemodel_diagnostic_proto = out.File
-	file_kubemodel_diagnostic_proto_goTypes = nil
-	file_kubemodel_diagnostic_proto_depIdxs = nil
-}

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików