Browse Source

Squashed Kubemodel Hydration

Signed-off-by: Sean Holcomb <seanholcomb@gmail.com>
Sean Holcomb 2 months ago
parent
commit
bd9e05ba54
88 changed files with 15221 additions and 2204 deletions
  1. 41 8
      core/pkg/clustercache/clustercache.go
  2. 22 6
      core/pkg/exporter/encoder.go
  3. 53 0
      core/pkg/exporter/pathing/staticpath.go
  4. 70 0
      core/pkg/exporter/pathing/staticpath_test.go
  5. 1 1
      core/pkg/model/kubemodel/bingen.go
  6. 15 12
      core/pkg/model/kubemodel/cluster.go
  7. 28 92
      core/pkg/model/kubemodel/container.go
  8. 61 0
      core/pkg/model/kubemodel/cronjob.go
  9. 61 0
      core/pkg/model/kubemodel/daemonset.go
  10. 54 0
      core/pkg/model/kubemodel/dcgm.go
  11. 62 0
      core/pkg/model/kubemodel/deployment.go
  12. 0 92
      core/pkg/model/kubemodel/device.go
  13. 0 86
      core/pkg/model/kubemodel/device_usage.go
  14. 61 0
      core/pkg/model/kubemodel/job.go
  15. 29 17
      core/pkg/model/kubemodel/kubemodel.go
  16. 6236 221
      core/pkg/model/kubemodel/kubemodel_codecs.go
  17. 7 7
      core/pkg/model/kubemodel/kubemodel_codecs_test.go
  18. 38 64
      core/pkg/model/kubemodel/kubemodel_test.go
  19. 0 627
      core/pkg/model/kubemodel/merge.go
  20. 22 21
      core/pkg/model/kubemodel/namespace.go
  21. 28 0
      core/pkg/model/kubemodel/networktrafficdetail.go
  22. 43 74
      core/pkg/model/kubemodel/node.go
  23. 27 40
      core/pkg/model/kubemodel/owner.go
  24. 47 26
      core/pkg/model/kubemodel/pod.go
  25. 0 14
      core/pkg/model/kubemodel/provider.go
  26. 12 51
      core/pkg/model/kubemodel/pv.go
  27. 15 37
      core/pkg/model/kubemodel/pvc.go
  28. 62 0
      core/pkg/model/kubemodel/replicaset.go
  29. 5 4
      core/pkg/model/kubemodel/resource.go
  30. 27 15
      core/pkg/model/kubemodel/resourcequota.go
  31. 55 40
      core/pkg/model/kubemodel/service.go
  32. 62 0
      core/pkg/model/kubemodel/statefulset.go
  33. 1 2
      core/pkg/model/kubemodel/unit.go
  34. 23 0
      core/pkg/model/pricingmodel/bingen.go
  35. 31 0
      core/pkg/model/pricingmodel/node.go
  36. 101 0
      core/pkg/model/pricingmodel/node_test.go
  37. 26 0
      core/pkg/model/pricingmodel/pricingmodel.go
  38. 770 0
      core/pkg/model/pricingmodel/pricingmodel_codecs.go
  39. 13 0
      core/pkg/model/pricingmodel/pricingsource.go
  40. 23 0
      core/pkg/model/shared/bingen.go
  41. 37 0
      core/pkg/model/shared/provider.go
  42. 220 0
      core/pkg/model/shared/shared_codecs.go
  43. 10 0
      core/pkg/model/shared/usagetype.go
  44. 3 0
      core/pkg/nodestats/nodes_test.go
  45. 20 13
      core/pkg/opencost/exporter/exporter_test.go
  46. 9 1
      core/pkg/opencost/exporter/exporters.go
  47. 28 6
      core/pkg/opencost/mock.go
  48. 4 0
      core/pkg/pipelines/name.go
  49. 92 24
      core/pkg/source/datasource.go
  50. 554 182
      core/pkg/source/decoders.go
  51. 669 14
      modules/collector-source/pkg/collector/collector.go
  52. 237 41
      modules/collector-source/pkg/collector/metricsquerier.go
  53. 3 0
      modules/collector-source/pkg/event/scrape.go
  54. 50 1
      modules/collector-source/pkg/metric/collector.go
  55. 25 1
      modules/collector-source/pkg/metric/metrics.go
  56. 614 132
      modules/collector-source/pkg/scrape/clustercache.go
  57. 4 3
      modules/collector-source/pkg/scrape/statsummary.go
  58. 907 31
      modules/prometheus-source/pkg/prom/metricsquerier.go
  59. 3 3
      modules/prometheus-source/pkg/prom/metricsquerier_test.go
  60. 3 3
      pkg/cloud/aws/fargate.go
  61. 3 3
      pkg/cloud/aws/fargate_test.go
  62. 227 0
      pkg/cloud/aws/pricelistapi.go
  63. 58 0
      pkg/cloud/aws/pricelistapi_test.go
  64. 193 0
      pkg/cloud/aws/pricinglistpricingsource.go
  65. 4 91
      pkg/cloud/aws/provider.go
  66. 24 20
      pkg/cloud/aws/provider_test.go
  67. 148 0
      pkg/cloud/azure/retailpricingsource.go
  68. 190 0
      pkg/cloud/azure/retailpricingsource_test.go
  69. 249 0
      pkg/cloud/gcp/billingpricingsource.go
  70. 297 0
      pkg/cloud/gcp/billingpricingsource_test.go
  71. 1 0
      pkg/cloud/gcp/provider_test.go
  72. 14 1
      pkg/clustercache/clustercache.go
  73. 8 1
      pkg/clustercache/clustercache2.go
  74. 16 16
      pkg/costmodel/allocation.go
  75. 5 5
      pkg/costmodel/allocation_helpers.go
  76. 1 1
      pkg/costmodel/cluster.go
  77. 3 7
      pkg/costmodel/cluster_helpers.go
  78. 1 1
      pkg/costmodel/metrics.go
  79. 2 0
      pkg/env/cloudcost.go
  80. 975 45
      pkg/kubemodel/kubemodel.go
  81. 13 1
      pkg/metrics/kubemetrics.go
  82. 546 0
      pkg/metrics/kubemodel.go
  83. 53 0
      pkg/pricingmodel/config.go
  84. 194 0
      pkg/pricingmodel/pipeline.go
  85. 47 0
      pkg/pricingmodel/pipelineservice.go
  86. 140 0
      pkg/pricingmodel/runner.go
  87. 14 0
      pkg/pricingmodel/status.go
  88. 71 0
      pkg/pricingmodel/storage.go

+ 41 - 8
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,6 +174,14 @@ type ResourceQuota struct {
 	Status    v1.ResourceQuotaStatus
 }
 
+type CronJob struct {
+	UID         types.UID
+	Name        string
+	Namespace   string
+	Labels      map[string]string
+	Annotations map[string]string
+}
+
 type Volume struct {
 }
 
@@ -304,9 +318,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 +392,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 +433,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 +496,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
 

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

@@ -11,6 +11,14 @@ import (
 	"google.golang.org/protobuf/proto"
 )
 
+const (
+	BingenExt           = "bin"
+	BingenVersionExtFMT = "v%d.bin"
+	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)
@@ -30,7 +38,9 @@ type BinaryMarshalerPtr[T any] interface {
 
 // BingenEncoder[T, U] is a generic encoder that uses the BinaryMarshaler interface to encode data.
 // It supports any type T that implements the encoding.BinaryMarshaler interface.
-type BingenEncoder[T any, U BinaryMarshalerPtr[T]] struct{}
+type BingenEncoder[T any, U BinaryMarshalerPtr[T]] struct {
+	fileExt string
+}
 
 // NewBingenEncoder creates an `Encoder[T]` implementation which supports binary encoding for the `T`
 // type.
@@ -38,6 +48,12 @@ func NewBingenEncoder[T any, U BinaryMarshalerPtr[T]]() Encoder[T] {
 	return new(BingenEncoder[T, U])
 }
 
+func NewVersionBingenEncoder[T any, U BinaryMarshalerPtr[T]](version uint8) Encoder[T] {
+	be := new(BingenEncoder[T, U])
+	be.fileExt = fmt.Sprintf(BingenVersionExtFMT, version)
+	return be
+}
+
 // Encode encodes the provided data of type T into a byte slice using the BinaryMarshaler interface.
 func (b *BingenEncoder[T, U]) Encode(data *T) ([]byte, error) {
 	var bingenData U = data
@@ -47,7 +63,7 @@ func (b *BingenEncoder[T, U]) Encode(data *T) ([]byte, 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 (b *BingenEncoder[T, U]) FileExt() string {
-	return ""
+	return b.fileExt
 }
 
 // JSONEncoder[T] is a generic encoder that uses the JSON encoding format to encode data.
@@ -67,7 +83,7 @@ func (j *JSONEncoder[T]) Encode(data *T) ([]byte, 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 {
@@ -113,7 +129,7 @@ func gZipEncode(data []byte) ([]byte, error) {
 // FileExt returns the file extension for the encoded data. In this case, it returns the wrapped encoder's
 // file extension with ".gz" appended to indicate that the data is compressed with GZip.
 func (gz *GZipEncoder[T]) FileExt() string {
-	return gz.encoder.FileExt() + ".gz"
+	return gz.encoder.FileExt() + GZipExt
 }
 
 // ProtoMessagePtr [T] is a generic constraint to ensure types passed to the encoder implement
@@ -146,7 +162,7 @@ func (p *ProtobufEncoder[T, U]) Encode(data *T) ([]byte, 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.
@@ -172,5 +188,5 @@ func (p *ProtoJsonEncoder[T, U]) Encode(data *T) ([]byte, 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
 }

+ 53 - 0
core/pkg/exporter/pathing/staticpath.go

@@ -0,0 +1,53 @@
+package pathing
+
+import (
+	"fmt"
+	"path"
+)
+
+// StaticFileStoragePathFormatter is an implementation of the StoragePathFormatter interface
+// for static files whose path does not vary with time. The format is:
+//
+//	<rootDir>/<pipeline>/<prefix>.<in>.<fileExt>
+//
+// If prefix is empty the leading dot is omitted. If fileExt is empty the trailing dot is omitted.
+type StaticFileStoragePathFormatter struct {
+	rootDir  string
+	pipeline string
+}
+
+// NewStaticFileStoragePathFormatter creates a StaticFileStoragePathFormatter with the given
+// root directory and pipeline name.
+func NewStaticFileStoragePathFormatter(rootDir, pipeline string) (*StaticFileStoragePathFormatter, error) {
+	if rootDir == "" {
+		return nil, fmt.Errorf("rootDir cannot be empty")
+	}
+	if pipeline == "" {
+		return nil, fmt.Errorf("pipeline cannot be empty")
+	}
+	return &StaticFileStoragePathFormatter{
+		rootDir:  rootDir,
+		pipeline: pipeline,
+	}, nil
+}
+
+// Dir returns the directory where static files are placed.
+func (s *StaticFileStoragePathFormatter) Dir() string {
+	return path.Join(s.rootDir, s.pipeline)
+}
+
+// ToFullPath returns the full path for a static file. The in parameter is used as the
+// file name and may include subdirectory segments. prefix and fileExt are optional and
+// apply only to the base file name component of in.
+func (s *StaticFileStoragePathFormatter) ToFullPath(prefix string, in string, fileExt string) string {
+	dir, base := path.Split(in)
+
+	name := base
+	if prefix != "" {
+		name = fmt.Sprintf("%s.%s", prefix, base)
+	}
+	if fileExt != "" {
+		name = fmt.Sprintf("%s.%s", name, fileExt)
+	}
+	return path.Join(s.rootDir, s.pipeline, dir, name)
+}

+ 70 - 0
core/pkg/exporter/pathing/staticpath_test.go

@@ -0,0 +1,70 @@
+package pathing
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestStaticFilePathFormatter(t *testing.T) {
+	type testCase struct {
+		name     string
+		rootDir  string
+		pipeline string
+		prefix   string
+		in       string
+		fileExt  string
+		expected string
+	}
+
+	testCases := []testCase{
+		{
+			name:     "no prefix no ext",
+			rootDir:  "cloud-agent",
+			pipeline: "pricingmodel",
+			prefix:   "",
+			in:       "aws_list_pricing_api",
+			fileExt:  "",
+			expected: "cloud-agent/pricingmodel/aws_list_pricing_api",
+		},
+		{
+			name:     "no prefix with ext",
+			rootDir:  "cloud-agent",
+			pipeline: "pricingmodel",
+			prefix:   "",
+			in:       "aws_list_pricing_api",
+			fileExt:  "bin",
+			expected: "cloud-agent/pricingmodel/aws_list_pricing_api.bin",
+		},
+		{
+			name:     "with prefix with ext",
+			rootDir:  "cloud-agent",
+			pipeline: "pricingmodel",
+			prefix:   "v1",
+			in:       "aws_list_pricing_api",
+			fileExt:  "bin",
+			expected: "cloud-agent/pricingmodel/v1.aws_list_pricing_api.bin",
+		},
+		{
+			name:     "with prefix no ext",
+			rootDir:  "cloud-agent",
+			pipeline: "pricingmodel",
+			prefix:   "v1",
+			in:       "aws_list_pricing_api",
+			fileExt:  "",
+			expected: "cloud-agent/pricingmodel/v1.aws_list_pricing_api",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			pathing, err := NewStaticFileStoragePathFormatter(tc.rootDir, tc.pipeline)
+			if err != nil {
+				t.Fatalf("Unexpected error: %v", err)
+			}
+
+			result := pathing.ToFullPath(tc.prefix, tc.in, tc.fileExt)
+			require.Equal(t, tc.expected, result)
+		})
+	}
+}

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

@@ -20,4 +20,4 @@ package kubemodel
 //
 ////////////////////////////////////////////////////////////////////////////////
 
-//go:generate bingen -package=kubemodel -version=1 -buffer=github.com/opencost/opencost/core/pkg/util
+//go:generate bingen -package=kubemodel -version=2 -buffer=github.com/opencost/opencost/core/pkg/util

+ 15 - 12
core/pkg/model/kubemodel/cluster.go

@@ -3,31 +3,34 @@ package kubemodel
 import (
 	"errors"
 	"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 (kms *KubeModelSet) RegisterCluster(uid string) error {
-	if uid == "" {
+func (kms *KubeModelSet) RegisterCluster(cluster *Cluster) error {
+	if cluster.UID == "" {
 		err := errors.New("RegisterCluster: uid is nil")
 		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

+ 28 - 92
core/pkg/model/kubemodel/container.go

@@ -5,105 +5,41 @@ 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"`
-}
-
-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) StorageByteUsageAverage() Measurement {
-	if c.DurationSeconds == 0 {
-		return 0
-	}
-	totalByteSeconds := c.TotalStorageByteSeconds()
-	return totalByteSeconds / c.DurationSeconds
-}
-
-func (c *Container) CpuMillicoreRequestAverage() Measurement {
-	if c.DurationSeconds == 0 {
-		return 0
-	}
-	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
-	}
-	return c.CpuMillicoreLimitSeconds / c.DurationSeconds
-}
-
-func (c *Container) RAMByteLimitAverage() Measurement {
-	if c.DurationSeconds == 0 {
-		return 0
+	PodUID           string             `json:"podUid"`
+	Name             string             `json:"name"`
+	ResourceRequests ResourceQuantities `json:"resourceRequests"`
+	ResourceLimits   ResourceQuantities `json:"ResourceLimits"`
+	CPUCoreUsageAvg  float64            `json:"cpuCoreUsageAvg"`
+	CPUCoreUsageMax  float64            `json:"cpuCoreUsageMax"`
+	RAMBytesUsageAvg float64            `json:"ramBytesUsageAvg"`
+	RAMBytesUsageMax float64            `json:"ramBytesUsageMax"`
+	Start            time.Time          `json:"start"`
+	End              time.Time          `json:"end"`
+}
+
+func (c *Container) GetKey() string {
+	return fmt.Sprintf("%s/%s", c.PodUID, c.Name)
+}
+
+func (kms *KubeModelSet) RegisterContainer(container *Container) error {
+	// Check required fields
+	if container.PodUID == "" {
+		err := fmt.Errorf("PodUID is missing for Container with name '%s'", container.Name)
+		kms.Error(err)
+		return err
 	}
-	return c.RAMByteSecondsLimit / c.DurationSeconds
-}
 
-func (kms *KubeModelSet) RegisterContainer(uid, name, podUID string) error {
-	if uid == "" {
-		err := fmt.Errorf("UID is nil for Container '%s'", name)
+	if container.Name == "" {
+		err := fmt.Errorf("Name is missing for Container on pod '%s'", container.PodUID)
 		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++
 	}
 

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

@@ -0,0 +1,61 @@
+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 (kms *KubeModelSet) RegisterCronJob(cronJob *CronJob) error {
+	// Check required fields
+	if cronJob.UID == "" {
+		err := fmt.Errorf("UID is missing for CronJob with name '%s'", cronJob.Name)
+		kms.Error(err)
+		return err
+	}
+
+	if cronJob.Name == "" {
+		err := fmt.Errorf("Name is missing for CronJob '%s'", cronJob.UID)
+		kms.Error(err)
+		return err
+	}
+
+	if kms.Window.Start.After(cronJob.Start) ||
+		kms.Window.Start.After(cronJob.End) ||
+		kms.Window.End.Before(cronJob.Start) ||
+		kms.Window.End.Before(cronJob.End) {
+		err := fmt.Errorf(
+			"CronJob '%s' has a start or end time (%s-%s) outside of the window %s-%s",
+			cronJob.Name,
+			cronJob.Start.Format(time.RFC3339),
+			cronJob.End.Format(time.RFC3339),
+			kms.Window.Start.Format(time.RFC3339),
+			kms.Window.End.Format(time.RFC3339),
+		)
+		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
+}

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

@@ -0,0 +1,61 @@
+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"`
+	Start        time.Time         `json:"start,omitempty"`
+	End          time.Time         `json:"end,omitempty"`
+}
+
+func (kms *KubeModelSet) RegisterDaemonSet(daemonSet *DaemonSet) error {
+	// Check required fields
+	if daemonSet.UID == "" {
+		err := fmt.Errorf("UID is missing for DaemonSet with name '%s'", daemonSet.Name)
+		kms.Error(err)
+		return err
+	}
+
+	if daemonSet.Name == "" {
+		err := fmt.Errorf("Name is missing for DaemonSet '%s'", daemonSet.UID)
+		kms.Error(err)
+		return err
+	}
+
+	if kms.Window.Start.After(daemonSet.Start) ||
+		kms.Window.Start.After(daemonSet.End) ||
+		kms.Window.End.Before(daemonSet.Start) ||
+		kms.Window.End.Before(daemonSet.End) {
+		err := fmt.Errorf(
+			"DaemonSet '%s' has a start or end time (%s-%s) outside of the window %s-%s",
+			daemonSet.Name,
+			daemonSet.Start.Format(time.RFC3339),
+			daemonSet.End.Format(time.RFC3339),
+			kms.Window.Start.Format(time.RFC3339),
+			kms.Window.End.Format(time.RFC3339),
+		)
+		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
+}

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

@@ -0,0 +1,54 @@
+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 isolate 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
+	Start     time.Time
+	End       time.Time
+	Device    string
+	ModelName string
+	PodUsage  map[string]DCGMPod
+}
+
+// @bingen:generate:DCGMPod
+type DCGMPod struct {
+	ContainerUsage map[string]DCGMContainer
+}
+
+// @bingen:generate:DCGMContainer
+type DCGMContainer struct {
+	UsageAvg float64
+	UsageMax float64
+}
+
+// RegisterDCGMDevice validates and adds a DCGMDevice to the set, keyed by UUID.
+func (kms *KubeModelSet) RegisterDCGMDevice(device *DCGMDevice) error {
+	if device.UUID == "" {
+		err := fmt.Errorf("UUID is missing for DCGMDevice with device '%s'", device.Device)
+		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
+}

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

@@ -0,0 +1,62 @@
+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 (kms *KubeModelSet) RegisterDeployment(deployment *Deployment) error {
+	// Check required fields
+	if deployment.UID == "" {
+		err := fmt.Errorf("UID is missing for Deployment with name '%s'", deployment.Name)
+		kms.Error(err)
+		return err
+	}
+
+	if deployment.Name == "" {
+		err := fmt.Errorf("Name is missing for Deployment '%s'", deployment.UID)
+		kms.Error(err)
+		return err
+	}
+
+	if kms.Window.Start.After(deployment.Start) ||
+		kms.Window.Start.After(deployment.End) ||
+		kms.Window.End.Before(deployment.Start) ||
+		kms.Window.End.Before(deployment.End) {
+		err := fmt.Errorf(
+			"Deployment '%s' has a start or end time (%s-%s) outside of the window %s-%s",
+			deployment.Name,
+			deployment.Start.Format(time.RFC3339),
+			deployment.End.Format(time.RFC3339),
+			kms.Window.Start.Format(time.RFC3339),
+			kms.Window.End.Format(time.RFC3339),
+		)
+		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
+}

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

@@ -1,92 +0,0 @@
-package kubemodel
-
-import (
-	"errors"
-	"fmt"
-	"time"
-)
-
-// @bingen:generate:Device
-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 - 86
core/pkg/model/kubemodel/device_usage.go

@@ -1,86 +0,0 @@
-package kubemodel
-
-import (
-	"errors"
-	"fmt"
-	"time"
-)
-
-// @bingen:generate:DeviceUsage
-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
-}

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

@@ -0,0 +1,61 @@
+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 (kms *KubeModelSet) RegisterJob(job *Job) error {
+	// Check required fields
+	if job.UID == "" {
+		err := fmt.Errorf("UID is missing for Job with name '%s'", job.Name)
+		kms.Error(err)
+		return err
+	}
+
+	if job.Name == "" {
+		err := fmt.Errorf("Name is missing for Job '%s'", job.UID)
+		kms.Error(err)
+		return err
+	}
+
+	if kms.Window.Start.After(job.Start) ||
+		kms.Window.Start.After(job.End) ||
+		kms.Window.End.Before(job.Start) ||
+		kms.Window.End.Before(job.End) {
+		err := fmt.Errorf(
+			"Job '%s' has a start or end time (%s-%s) outside of the window %s-%s",
+			job.Name,
+			job.Start.Format(time.RFC3339),
+			job.End.Format(time.RFC3339),
+			kms.Window.Start.Format(time.RFC3339),
+			kms.Window.End.Format(time.RFC3339),
+		)
+		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
+}

+ 29 - 17
core/pkg/model/kubemodel/kubemodel.go

@@ -13,15 +13,19 @@ type KubeModelSet struct {
 	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]
+	Containers             map[string]*Container             `json:"containers,omitempty"`   // @bingen:field[version=2]
+	Deployments            map[string]*Deployment            `json:"deployments,omitempty"`  // @bingen:field[version=2]
+	StatefulSets           map[string]*StatefulSet           `json:"statefulSets,omitempty"` // @bingen:field[version=2]
+	DaemonSets             map[string]*DaemonSet             `json:"daemonSets,omitempty"`   // @bingen:field[version=2]
+	Jobs                   map[string]*Job                   `json:"jobs,omitempty"`         // @bingen:field[version=2]
+	CronJobs               map[string]*CronJob               `json:"cronJobs,omitempty"`     // @bingen:field[version=2]
+	ReplicaSets            map[string]*ReplicaSet            `json:"replicaSets,omitempty"`  // @bingen:field[version=2]
+	Nodes                  map[string]*Node                  `json:"nodes,omitempty"`        // @bingen:field[version=2]
+	Pods                   map[string]*Pod                   `json:"pods,omitempty"`         // @bingen:field[version=2]
+	PersistentVolumeClaims map[string]*PersistentVolumeClaim `json:"pvcs,omitempty"`         // @bingen:field[version=2]
+	Services               map[string]*Service               `json:"services,omitempty"`     // @bingen:field[version=2]
+	PersistentVolumes      map[string]*PersistentVolume      `json:"volumes,omitempty"`      // @bingen:field[version=2]
+	DCGMDevices            map[string]*DCGMDevice            `json:"dcgmDevices,omitempty"`  // @bingen:field[version=2]
 	idx                    *kubeModelSetIndexes              // @bingen:field[ignore]
 }
 
@@ -38,16 +42,20 @@ 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{},
+		PersistentVolumes:      map[string]*PersistentVolume{},
 		idx:                    newKubeModelSetIndexes(),
 	}
 	return kms
@@ -76,16 +84,20 @@ 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
+		len(kms.PersistentVolumes) == 0
 }
 
 type kubeModelSetIndexes struct {

File diff suppressed because it is too large
+ 6236 - 221
core/pkg/model/kubemodel/kubemodel_codecs.go


+ 7 - 7
core/pkg/model/kubemodel/kubemodel_codecs_test.go

@@ -35,23 +35,23 @@ func TestKubeModelMarshalBinary(t *testing.T) {
 
 	kms.Metadata.CreatedAt = time.Now().UTC()
 
-	kms.RegisterCluster("cluster")
+	kms.RegisterCluster(&Cluster{UID: "cluster"})
 	kms.Cluster.Start = s
 	kms.Cluster.End = e
 
-	kms.RegisterNamespace("ns1", "ns1")
+	kms.RegisterNamespace(&Namespace{UID: "ns1", Name: "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"}
 
-	kms.RegisterNamespace("ns2", "ns2")
+	kms.RegisterNamespace(&Namespace{UID: "ns2", Name: "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"}
 
-	kms.RegisterResourceQuota("rq1", "rq1", "ns1")
+	kms.RegisterResourceQuota(&ResourceQuota{UID: "rq1", Name: "rq1", NamespaceUID: "ns1"})
 	kms.ResourceQuotas["rq1"].Start = s
 	kms.ResourceQuotas["rq1"].End = e
 	kms.ResourceQuotas["rq1"].Spec = &ResourceQuotaSpec{
@@ -151,7 +151,7 @@ func TestKubeModelMarshalBinary(t *testing.T) {
 		},
 	}
 
-	kms.RegisterResourceQuota("rq2", "rq2", "ns1")
+	kms.RegisterResourceQuota(&ResourceQuota{UID: "rq2", Name: "rq2", NamespaceUID: "ns1"})
 	kms.ResourceQuotas["rq2"].Start = s
 	kms.ResourceQuotas["rq2"].End = e
 	kms.ResourceQuotas["rq2"].Spec = &ResourceQuotaSpec{
@@ -251,7 +251,7 @@ func TestKubeModelMarshalBinary(t *testing.T) {
 		},
 	}
 
-	kms.RegisterResourceQuota("rq3", "rq3", "ns2")
+	kms.RegisterResourceQuota(&ResourceQuota{UID: "rq3", Name: "rq3", NamespaceUID: "ns2"})
 	kms.ResourceQuotas["rq3"].Start = s
 	kms.ResourceQuotas["rq3"].End = e
 	kms.ResourceQuotas["rq3"].Spec = &ResourceQuotaSpec{
@@ -351,7 +351,7 @@ func TestKubeModelMarshalBinary(t *testing.T) {
 		},
 	}
 
-	kms.RegisterResourceQuota("rq4", "rq4", "ns2")
+	kms.RegisterResourceQuota(&ResourceQuota{UID: "rq4", Name: "rq4", NamespaceUID: "ns2"})
 	kms.ResourceQuotas["rq4"].Start = s
 	kms.ResourceQuotas["rq4"].End = e
 	kms.ResourceQuotas["rq4"].Spec = &ResourceQuotaSpec{

+ 38 - 64
core/pkg/model/kubemodel/kubemodel_test.go

@@ -33,7 +33,7 @@ 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)
@@ -47,7 +47,7 @@ func TestKubeModel(t *testing.T) {
 
 			kms := NewKubeModelSet(start, end)
 
-			err = kms.RegisterCluster(clusterUID)
+			err = kms.RegisterCluster(&Cluster{UID: clusterUID})
 			require.Nil(t, err)
 
 			require.Len(t, kms.GetErrors(), 0)
@@ -61,7 +61,7 @@ func TestKubeModel(t *testing.T) {
 
 			kms := NewKubeModelSet(start, end)
 
-			err = kms.RegisterCluster(clusterUID)
+			err = kms.RegisterCluster(&Cluster{UID: clusterUID})
 			require.Nil(t, err)
 
 			require.Len(t, kms.GetErrors(), 0)
@@ -69,7 +69,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})
 			require.Nil(t, err)
 
 			require.Len(t, kms.GetErrors(), 0)
@@ -77,7 +77,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"})
 			require.Nil(t, err)
 
 			require.Len(t, kms.GetWarnings(), 1)
@@ -93,11 +93,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, "UID is missing for Namespace with name ''", kms.GetErrors()[0].Message)
 			require.Len(t, kms.Namespaces, 0)
 		})
 
@@ -109,13 +109,13 @@ func TestKubeModel(t *testing.T) {
 			testUID := "uid"
 			testName := "name"
 
-			err = kms.RegisterNamespace(testUID, testName)
+			err = kms.RegisterNamespace(&Namespace{UID: testUID, Name: testName})
 			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}
 
 			require.NotNil(t, kms.Namespaces[testUID])
 			require.Equal(t, testNamespace, kms.Namespaces[testUID])
@@ -128,7 +128,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"})
 			require.Nil(t, err)
 
 			// At this point we have a KMS with a cluster registered
@@ -136,20 +136,20 @@ func TestKubeModel(t *testing.T) {
 			testUID := "uid"
 			testName := "name"
 
-			err = kms.RegisterNamespace(testUID, testName)
+			err = kms.RegisterNamespace(&Namespace{UID: testUID, Name: testName})
 			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}
 
 			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})
 			require.Nil(t, err)
 
 			require.Len(t, kms.GetErrors(), 0)
@@ -166,50 +166,35 @@ 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, "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, "Namespace 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"})
+			kms.RegisterNamespace(&Namespace{UID: "namespace-uid", Name: "namespace"})
 			// 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"})
 
 			testRQ := &ResourceQuota{
 				UID:          "uid",
@@ -225,7 +210,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"})
 			require.Len(t, kms.GetErrors(), 0)
 			require.NotNil(t, kms.ResourceQuotas[testUID])
 			require.Equal(t, testRQ, kms.ResourceQuotas[testUID])
@@ -234,12 +219,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"})
+			kms.RegisterNamespace(&Namespace{UID: "namespace-1-uid", Name: "namespace-1"})
+			kms.RegisterNamespace(&Namespace{UID: "namespace-2-uid", Name: "namespace-2"})
 
-			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"})
+			kms.RegisterResourceQuota(&ResourceQuota{UID: "uid-2", Name: "name-2", NamespaceUID: "namespace-2-uid"})
 
 			require.Len(t, kms.GetErrors(), 0)
 			require.NotNil(t, kms.ResourceQuotas)
@@ -264,24 +249,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, "Namespace 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,
-	}
-}

+ 22 - 21
core/pkg/model/kubemodel/namespace.go

@@ -7,38 +7,39 @@ 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,omitempty"` // @bingen:field[version=1]
+	End               time.Time         `json:"end,omitempty"`   // @bingen:field[version=1]
 }
 
-func (kms *KubeModelSet) RegisterNamespace(uid, name string) error {
-	if uid == "" {
-		err := fmt.Errorf("UID is nil for Namespace '%s'", name)
+func (kms *KubeModelSet) RegisterNamespace(namespace *Namespace) error {
+	// Check required fields
+	if namespace.UID == "" {
+		err := fmt.Errorf("UID is missing for Namespace with name '%s'", namespace.Name)
 		kms.Error(err)
 		return err
 	}
 
-	if _, ok := kms.Namespaces[uid]; !ok {
-		clusterUID := ""
+	if namespace.Name == "" {
+		err := fmt.Errorf("Name is missing for Namespace '%s'", namespace.UID)
+		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.Namespaces[namespace.UID] = namespace
 
-		kms.idx.namespaceByName[name] = kms.Namespaces[uid]
+		kms.idx.namespaceByName[namespace.Name] = namespace
 
 		kms.Metadata.ObjectCount++
 	}

+ 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"`
+}

+ 43 - 74
core/pkg/model/kubemodel/node.go

@@ -10,95 +10,64 @@ import (
 // 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"`
+	InstanceType         string             `json:"instanceType"`
+	Preemptible          bool               `json:"preemptible"` // TODO unpopulated
+	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
-	}
-	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
-	}
-	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
+// RegisterNode validates and adds a node to the set
+func (kms *KubeModelSet) RegisterNode(node *Node) error {
+	// Check required fields
+	if node.UID == "" {
+		err := fmt.Errorf("UID is missing for Node with name '%s'", node.Name)
+		kms.Error(err)
+		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
+	if node.Name == "" {
+		err := fmt.Errorf("Name is missing for Node '%s'", node.UID)
+		kms.Error(err)
+		return err
 	}
-	return volume.UsageByteSeconds / n.DurationSeconds
-}
 
-func (kms *KubeModelSet) RegisterNode(uid, name string) error {
-	if uid == "" {
-		err := fmt.Errorf("UID is nil for Node '%s'", name)
+	if kms.Window.Start.After(node.Start) ||
+		kms.Window.Start.After(node.End) ||
+		kms.Window.End.Before(node.Start) ||
+		kms.Window.End.Before(node.End) {
+		err := fmt.Errorf(
+			"Node '%s' has a start or end time (%s-%s) outside of the window %s-%s",
+			node.UID,
+			node.Start.Format(time.RFC3339),
+			node.End.Format(time.RFC3339),
+			kms.Window.Start.Format(time.RFC3339),
+			kms.Window.End.Format(time.RFC3339),
+		)
 		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++
 	}

+ 27 - 40
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,44 +18,29 @@ const (
 	OwnerKindReplicaSet  OwnerKind = "replicaset"
 )
 
-// Owner represents a Kubernetes resource owner (workload controller)
-// @bingen:generate:Owner
-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
+type Owner struct {
+	UID        string
+	Controller bool
+	Kind       OwnerKind
 }

+ 47 - 26
core/pkg/model/kubemodel/pod.go

@@ -5,42 +5,63 @@ import (
 	"time"
 )
 
+// @bingen:generate:PodPVCVolumes
+type PodPVCVolumes 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            []PodPVCVolumes        `json:"pvcVolumes"`
+	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)
+func (kms *KubeModelSet) RegisterPod(pod *Pod) error {
+	// Check required fields
+	if pod.UID == "" {
+		err := fmt.Errorf("UID is missing for Pod with name '%s'", pod.Name)
+		kms.Error(err)
+		return err
+	}
+
+	if pod.Name == "" {
+		err := fmt.Errorf("Name is missing for Pod '%s'", pod.UID)
 		kms.Error(err)
 		return err
 	}
 
-	if _, ok := kms.Pods[uid]; !ok {
-		namespaceUID := ""
+	if kms.Window.Start.After(pod.Start) ||
+		kms.Window.Start.After(pod.End) ||
+		kms.Window.End.Before(pod.Start) ||
+		kms.Window.End.Before(pod.End) {
+		err := fmt.Errorf(
+			"Pod '%s' has a start or end time (%s-%s) outside of the window %s-%s",
+			pod.UID,
+			pod.Start.Format(time.RFC3339),
+			pod.End.Format(time.RFC3339),
+			kms.Window.Start.Format(time.RFC3339),
+			kms.Window.End.Format(time.RFC3339),
+		)
+		kms.Error(err)
+		return err
+	}
 
-		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
+	if _, ok := kms.Pods[pod.UID]; !ok {
+		if kms.Cluster == nil {
+			kms.Warnf("RegisterPod: Cluster is nil")
 		}
 
-		kms.Pods[uid] = &Pod{
-			UID:          uid,
-			Name:         name,
-			NamespaceUID: namespaceUID,
-		}
+		kms.Pods[pod.UID] = pod
 
 		kms.Metadata.ObjectCount++
 	}

+ 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"
-)

+ 12 - 51
core/pkg/model/kubemodel/pv.go

@@ -7,63 +7,24 @@ import (
 
 // @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)
+func (kms *KubeModelSet) RegisterPersistentVolume(pv *PersistentVolume) error {
+	if pv.UID == "" {
+		err := fmt.Errorf("UID is nil for PersistentVolume '%s'", pv.Name)
 		kms.Error(err)
 		return err
 	}
 
-	if _, ok := kms.Volumes[uid]; !ok {
-		clusterUID := ""
-
-		if kms.Cluster == nil {
-			kms.Warnf("RegisterVolume(%s, %s): Cluster is nil", uid, name)
-		} else {
-			clusterUID = kms.Cluster.UID
-		}
-
-		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++
 	}

+ 15 - 37
core/pkg/model/kubemodel/pvc.go

@@ -7,49 +7,27 @@ import (
 
 // @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)
+func (kms *KubeModelSet) RegisterPVC(pvc *PersistentVolumeClaim) error {
+	if pvc.UID == "" {
+		err := fmt.Errorf("UID is nil for PVC '%s'", pvc.Name)
 		kms.Error(err)
 		return err
 	}
 
-	if _, ok := kms.PersistentVolumeClaims[uid]; !ok {
-		namespaceUID := ""
-
-		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
-		}
-
-		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++
 	}

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

@@ -0,0 +1,62 @@
+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 (kms *KubeModelSet) RegisterReplicaSet(replicaSet *ReplicaSet) error {
+	// Check required fields
+	if replicaSet.UID == "" {
+		err := fmt.Errorf("UID is missing for ReplicaSet with name '%s'", replicaSet.Name)
+		kms.Error(err)
+		return err
+	}
+
+	if replicaSet.Name == "" {
+		err := fmt.Errorf("Name is missing for ReplicaSet '%s'", replicaSet.UID)
+		kms.Error(err)
+		return err
+	}
+
+	if kms.Window.Start.After(replicaSet.Start) ||
+		kms.Window.Start.After(replicaSet.End) ||
+		kms.Window.End.Before(replicaSet.Start) ||
+		kms.Window.End.Before(replicaSet.End) {
+		err := fmt.Errorf(
+			"ReplicaSet '%s' has a start or end time (%s-%s) outside of the window %s-%s",
+			replicaSet.Name,
+			replicaSet.Start.Format(time.RFC3339),
+			replicaSet.End.Format(time.RFC3339),
+			kms.Window.Start.Format(time.RFC3339),
+			kms.Window.End.Format(time.RFC3339),
+		)
+		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
+}

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

@@ -4,12 +4,13 @@ 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"
 )
 
+var GPUResources = []Resource{ResourceNvidia}
+
 // @bingen:generate:ResourceQuantity
 type ResourceQuantity struct {
 	Resource Resource `json:"resource"` // @bingen:field[version=1]

+ 27 - 15
core/pkg/model/kubemodel/resourcequota.go

@@ -70,30 +70,42 @@ 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)
+func (kms *KubeModelSet) RegisterResourceQuota(resourceQuota *ResourceQuota) error {
+	// Check required fields
+	if resourceQuota.UID == "" {
+		err := fmt.Errorf("UID is missing for ResourceQuota with name '%s'", resourceQuota.Name)
 		kms.Error(err)
 		return err
 	}
 
-	if _, ok := kms.ResourceQuotas[uid]; !ok {
-		namespaceUID := ""
+	if resourceQuota.Name == "" {
+		err := fmt.Errorf("Name is missing for ResourceQuota '%s'", resourceQuota.UID)
+		kms.Error(err)
+		return err
+	}
+
+	if resourceQuota.NamespaceUID == "" {
+		err := fmt.Errorf("Namespace is missing for ResourceQuota '%s'", resourceQuota.UID)
+		kms.Error(err)
+		return err
+	}
 
-		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
+	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++
 	}
 

+ 55 - 40
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,51 +16,61 @@ 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:
-// Services expose applications and route traffic, incurring costs for:
-// 1. Load Balancers (LoadBalancer type) - Cloud provider LB hourly cost + data transfer
-// 2. Data Transfer - Egress charges based on NetworkTransferBytes
-// 3. Public IPs (for LoadBalancer/NodePort with external IPs)
-//
-// Cost Attribution Flow:
-// - LoadBalancer Services: Direct cloud resource cost (e.g., AWS ELB, GCP LB) allocated to service
-// - Data Transfer: NetworkTransferBytes × cloud provider egress rate (varies by region/destination)
-// - NetworkReceiveBytes: Typically free (ingress), tracked for visibility
-// - Use Selector to map service costs to backing pods/containers proportionally
-//
-// Example: AWS Application Load Balancer
-// - Fixed hourly cost: $0.0225/hour
-// - LCU cost: $0.008/hour per LCU (based on connections, requests, bandwidth)
-// - 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
+}
+
+func (kms *KubeModelSet) RegisterService(service *Service) error {
+	// Check required fields
+	if service.UID == "" {
+		err := fmt.Errorf("UID is missing for Service with name '%s'", service.Name)
+		kms.Error(err)
+		return err
+	}
+
+	if service.Name == "" {
+		err := fmt.Errorf("Name is missing for Service '%s'", service.UID)
+		kms.Error(err)
+		return err
+	}
+
+	if service.NamespaceUID == "" {
+		err := fmt.Errorf("NamespaceUID is missing for Service '%s'", service.UID)
+		kms.Error(err)
+		return err
+	}
+
+	if _, ok := kms.Services[service.UID]; !ok {
+		kms.Services[service.UID] = service
+		kms.Metadata.ObjectCount++
+	}
+
+	return nil
 }

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

@@ -0,0 +1,62 @@
+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 (kms *KubeModelSet) RegisterStatefulSet(statefulSet *StatefulSet) error {
+	// Check required fields
+	if statefulSet.UID == "" {
+		err := fmt.Errorf("UID is missing for StatefulSet with name '%s'", statefulSet.Name)
+		kms.Error(err)
+		return err
+	}
+
+	if statefulSet.Name == "" {
+		err := fmt.Errorf("Name is missing for StatefulSet '%s'", statefulSet.UID)
+		kms.Error(err)
+		return err
+	}
+
+	if kms.Window.Start.After(statefulSet.Start) ||
+		kms.Window.Start.After(statefulSet.End) ||
+		kms.Window.End.Before(statefulSet.Start) ||
+		kms.Window.End.Before(statefulSet.End) {
+		err := fmt.Errorf(
+			"StatefulSet '%s' has a start or end time (%s-%s) outside of the window %s-%s",
+			statefulSet.Name,
+			statefulSet.Start.Format(time.RFC3339),
+			statefulSet.End.Format(time.RFC3339),
+			kms.Window.Start.Format(time.RFC3339),
+			kms.Window.End.Format(time.RFC3339),
+		)
+		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
+}

+ 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"

+ 23 - 0
core/pkg/model/pricingmodel/bingen.go

@@ -0,0 +1,23 @@
+package pricingmodel
+
+////////////////////////////////////////////////////////////////////////////////
+// NOTE: If you add fields to _any_ struct that is serialized by bingen, please
+// make sure to add those fields to the END of the struct definition. This is
+// required for backwards-compatibility. So:
+//
+// type Foo struct {
+//     ExistingField1 string
+//     ExistingField2 int
+// }
+//
+// becomes:
+//
+// type Foo struct {
+//     ExistingField1 string
+//     ExistingField2 int
+//     NewField       float64 // @bingen: <- annotation ref: bingen README
+// }
+//
+////////////////////////////////////////////////////////////////////////////////
+
+//go:generate bingen -package=pricingmodel -version=1 -buffer=github.com/opencost/opencost/core/pkg/util

+ 31 - 0
core/pkg/model/pricingmodel/node.go

@@ -0,0 +1,31 @@
+package pricingmodel
+
+import (
+	"github.com/opencost/opencost/core/pkg/model/shared"
+)
+
+// @bingen:generate:NodePricingType
+type NodePricingType string
+
+const (
+	NodePricingTypeTotal   NodePricingType = "Total"
+	NodePricingTypeCPUCore NodePricingType = "CPUCore"
+	NodePricingTypeRamGB   NodePricingType = "RamGB"
+	NodePricingTypeDevice  NodePricingType = "Device"
+)
+
+// @bingen:generate:NodeKey
+type NodeKey struct {
+	Provider    shared.Provider
+	PricingType NodePricingType
+	UsageType   shared.UsageType
+	Region      string
+	NodeType    string
+	Family      string
+	DeviceType  string
+}
+
+// @bingen:generate:NodePricing
+type NodePricing struct {
+	HourlyRate float64
+}

+ 101 - 0
core/pkg/model/pricingmodel/node_test.go

@@ -0,0 +1,101 @@
+package pricingmodel
+
+import (
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/model/shared"
+)
+
+func TestNodeKeyRoundtrip(t *testing.T) {
+	cases := []struct {
+		name string
+		key  NodeKey
+	}{
+		{
+			name: "full GPU key",
+			key: NodeKey{
+				Provider:   shared.ProviderGCP,
+				Region:     "us-central1",
+				NodeType:   "n2-standard-4",
+				UsageType:  shared.UsageTypeOnDemand,
+				Family:     "n2",
+				DeviceType: "nvidia-tesla-t4",
+				PricingType:       NodePricingTypeDevice,
+			},
+		},
+		{
+			name: "on-demand CPU key",
+			key: NodeKey{
+				Provider:  shared.ProviderAWS,
+				Region:    "us-east-1",
+				NodeType:  "m5.xlarge",
+				UsageType: shared.UsageTypeOnDemand,
+				Family:    "m5",
+				PricingType:      NodePricingTypeCPUCore,
+			},
+		},
+		{
+			name: "spot total key",
+			key: NodeKey{
+				Provider:  shared.ProviderAzure,
+				Region:    "eastus",
+				NodeType:  "Standard_D4s_v3",
+				UsageType: shared.UsageTypeSpot,
+				PricingType:      NodePricingTypeTotal,
+			},
+		},
+		{
+			name: "RAM key",
+			key: NodeKey{
+				Provider:  shared.ProviderGCP,
+				Region:    "europe-west1",
+				Family:    "n1",
+				UsageType: shared.UsageTypeOnDemand,
+				PricingType:      NodePricingTypeRamGB,
+			},
+		},
+		{
+			name: "empty key",
+			key:  NodeKey{},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			data, err := tc.key.MarshalBinary()
+			if err != nil {
+				t.Fatalf("MarshalBinary() error: %v", err)
+			}
+
+			var got NodeKey
+			if err := got.UnmarshalBinary(data); err != nil {
+				t.Fatalf("UnmarshalBinary() error: %v", err)
+			}
+
+			if got != tc.key {
+				t.Errorf("roundtrip mismatch:\n  got  %+v\n  want %+v", got, tc.key)
+			}
+		})
+	}
+}
+
+func TestNodePricingRoundtrip(t *testing.T) {
+	cases := []float64{0, 0.048, 0.192, 1.5, 2.0, 99.99}
+
+	for _, rate := range cases {
+		np := NodePricing{HourlyRate: rate}
+		data, err := np.MarshalBinary()
+		if err != nil {
+			t.Fatalf("MarshalBinary(%v) error: %v", rate, err)
+		}
+
+		var got NodePricing
+		if err := got.UnmarshalBinary(data); err != nil {
+			t.Fatalf("UnmarshalBinary(%v) error: %v", rate, err)
+		}
+
+		if got.HourlyRate != np.HourlyRate {
+			t.Errorf("HourlyRate roundtrip: got %v, want %v", got.HourlyRate, np.HourlyRate)
+		}
+	}
+}

+ 26 - 0
core/pkg/model/pricingmodel/pricingmodel.go

@@ -0,0 +1,26 @@
+package pricingmodel
+
+import (
+	"time"
+)
+
+// @bingen:generate:PricingSourceType
+type PricingSourceType string
+
+// @bingen:generate:PricingModelSet
+type PricingModelSet struct {
+	TimeStamp   time.Time
+	SourceType  PricingSourceType
+	SourceKey   string
+	NodePricing map[NodeKey]NodePricing
+}
+
+// NewPricingModelSet creates a PricingModelSet with SourceKey initialized to sourceType.
+func NewPricingModelSet(timeStamp time.Time, sourceType PricingSourceType, sourceKey string) *PricingModelSet {
+	return &PricingModelSet{
+		TimeStamp:   timeStamp,
+		SourceType:  sourceType,
+		SourceKey:   sourceKey,
+		NodePricing: make(map[NodeKey]NodePricing),
+	}
+}

+ 770 - 0
core/pkg/model/pricingmodel/pricingmodel_codecs.go

@@ -0,0 +1,770 @@
+////////////////////////////////////////////////////////////////////////////////
+//
+//                             DO NOT MODIFY
+//
+//                          ┻━┻ ︵ヽ(`Д´)ノ︵ ┻━┻
+//
+//
+//            This source file was automatically generated by bingen.
+//
+////////////////////////////////////////////////////////////////////////////////
+
+package pricingmodel
+
+import (
+	"fmt"
+	"reflect"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/model/shared"
+	"github.com/opencost/opencost/core/pkg/util"
+)
+
+const (
+	// GeneratorPackageName is the package the generator is targetting
+	GeneratorPackageName string = "pricingmodel"
+)
+
+// BinaryTags represent the formatting tag used for specific optimization features
+const (
+	// BinaryTagStringTable is written and/or read prior to the existence of a string
+	// table (where each index is encoded as a string entry in the resource
+	BinaryTagStringTable string = "BGST"
+)
+
+const (
+	// DefaultCodecVersion is used for any resources listed in the Default version set
+	DefaultCodecVersion uint8 = 1
+)
+
+//--------------------------------------------------------------------------
+//  Type Map
+//--------------------------------------------------------------------------
+
+// Generated type map for resolving interface implementations to
+// to concrete types
+var typeMap map[string]reflect.Type = map[string]reflect.Type{
+	"NodeKey":         reflect.TypeOf((*NodeKey)(nil)).Elem(),
+	"NodePricing":     reflect.TypeOf((*NodePricing)(nil)).Elem(),
+	"PricingModelSet": reflect.TypeOf((*PricingModelSet)(nil)).Elem(),
+}
+
+//--------------------------------------------------------------------------
+//  Type Helpers
+//--------------------------------------------------------------------------
+
+// isBinaryTag returns true when the first bytes in the provided binary matches the tag
+func isBinaryTag(data []byte, tag string) bool {
+	return string(data[:len(tag)]) == tag
+}
+
+// appendBytes combines a and b into a new byte array
+func appendBytes(a []byte, b []byte) []byte {
+	al := len(a)
+	bl := len(b)
+	tl := al + bl
+
+	// allocate a new byte array for the combined
+	// use native copy for speedy byte copying
+	result := make([]byte, tl, tl)
+	copy(result, a)
+	copy(result[al:], b)
+
+	return result
+}
+
+// typeToString determines the basic properties of the type, the qualifier, package path, and
+// type name, and returns the qualified type
+func typeToString(f interface{}) string {
+	qual := ""
+	t := reflect.TypeOf(f)
+	if t.Kind() == reflect.Ptr {
+		t = t.Elem()
+		qual = "*"
+	}
+
+	return fmt.Sprintf("%s%s.%s", qual, t.PkgPath(), t.Name())
+}
+
+// resolveType uses the name of a type and returns the package, base type name, and whether
+// or not it's a pointer.
+func resolveType(t string) (pkg string, name string, isPtr bool) {
+	isPtr = t[:1] == "*"
+	if isPtr {
+		t = t[1:]
+	}
+
+	slashIndex := strings.LastIndex(t, "/")
+	if slashIndex >= 0 {
+		t = t[slashIndex+1:]
+	}
+	parts := strings.Split(t, ".")
+	if parts[0] == GeneratorPackageName {
+		parts[0] = ""
+	}
+
+	pkg = parts[0]
+	name = parts[1]
+	return
+}
+
+//--------------------------------------------------------------------------
+//  StringTable
+//--------------------------------------------------------------------------
+
+// StringTable maps strings to specific indices for encoding
+type StringTable struct {
+	l       *sync.Mutex
+	indices map[string]int
+	next    int
+}
+
+// NewStringTable Creates a new StringTable instance with provided contents
+func NewStringTable(contents ...string) *StringTable {
+	st := &StringTable{
+		l:       new(sync.Mutex),
+		indices: make(map[string]int),
+		next:    len(contents),
+	}
+
+	for i, entry := range contents {
+		st.indices[entry] = i
+	}
+
+	return st
+}
+
+// AddOrGet atomically retrieves a string entry's index if it exist. Otherwise, it will
+// add the entry and return the index.
+func (st *StringTable) AddOrGet(s string) int {
+	st.l.Lock()
+	defer st.l.Unlock()
+
+	if ind, ok := st.indices[s]; ok {
+		return ind
+	}
+
+	current := st.next
+	st.next++
+
+	st.indices[s] = current
+	return current
+}
+
+// ToSlice Converts the contents to a string array for encoding.
+func (st *StringTable) ToSlice() []string {
+	st.l.Lock()
+	defer st.l.Unlock()
+
+	if st.next == 0 {
+		return []string{}
+	}
+
+	sl := make([]string, st.next, st.next)
+	for s, i := range st.indices {
+		sl[i] = s
+	}
+	return sl
+}
+
+// ToBytes Converts the contents to a binary encoded representation
+func (st *StringTable) ToBytes() []byte {
+	buff := util.NewBuffer()
+	buff.WriteBytes([]byte(BinaryTagStringTable)) // bingen table header
+
+	strs := st.ToSlice()
+
+	buff.WriteInt(len(strs)) // table length
+	for _, s := range strs {
+		buff.WriteString(s)
+	}
+
+	return buff.Bytes()
+}
+
+//--------------------------------------------------------------------------
+//  Codec Context
+//--------------------------------------------------------------------------
+
+// EncodingContext is a context object passed to the encoders to ensure reuse of buffer
+// and table data
+type EncodingContext struct {
+	Buffer *util.Buffer
+	Table  *StringTable
+}
+
+// IsStringTable returns true if the table is available
+func (ec *EncodingContext) IsStringTable() bool {
+	return ec.Table != nil
+}
+
+// DecodingContext is a context object passed to the decoders to ensure parent objects
+// reuse as much data as possible
+type DecodingContext struct {
+	Buffer *util.Buffer
+	Table  []string
+}
+
+// IsStringTable returns true if the table is available
+func (dc *DecodingContext) IsStringTable() bool {
+	return len(dc.Table) > 0
+}
+
+//--------------------------------------------------------------------------
+//  Binary Codec
+//--------------------------------------------------------------------------
+
+// BinEncoder is an encoding interface which defines a context based marshal contract.
+type BinEncoder interface {
+	MarshalBinaryWithContext(*EncodingContext) error
+}
+
+// BinDecoder is a decoding interface which defines a context based unmarshal contract.
+type BinDecoder interface {
+	UnmarshalBinaryWithContext(*DecodingContext) error
+}
+
+//--------------------------------------------------------------------------
+//  NodeKey
+//--------------------------------------------------------------------------
+
+// MarshalBinary serializes the internal properties of this NodeKey instance
+// into a byte array
+func (target *NodeKey) MarshalBinary() (data []byte, err error) {
+	ctx := &EncodingContext{
+		Buffer: util.NewBuffer(),
+		Table:  nil,
+	}
+
+	e := target.MarshalBinaryWithContext(ctx)
+	if e != nil {
+		return nil, e
+	}
+
+	encBytes := ctx.Buffer.Bytes()
+	return encBytes, nil
+}
+
+// MarshalBinaryWithContext serializes the internal properties of this NodeKey instance
+// into a byte array leveraging a predefined context.
+func (target *NodeKey) MarshalBinaryWithContext(ctx *EncodingContext) (err error) {
+	// panics are recovered and propagated as errors
+	defer func() {
+		if r := recover(); r != nil {
+			if e, ok := r.(error); ok {
+				err = e
+			} else if s, ok := r.(string); ok {
+				err = fmt.Errorf("Unexpected panic: %s", s)
+			} else {
+				err = fmt.Errorf("Unexpected panic: %+v", r)
+			}
+		}
+	}()
+
+	buff := ctx.Buffer
+	buff.WriteUInt8(DefaultCodecVersion) // version
+
+	// --- [begin][write][alias](shared.Provider) ---
+	if ctx.IsStringTable() {
+		a := ctx.Table.AddOrGet(string(target.Provider))
+		buff.WriteInt(a) // write table index
+	} else {
+		buff.WriteString(string(target.Provider)) // write string
+	}
+	// --- [end][write][alias](shared.Provider) ---
+
+	if ctx.IsStringTable() {
+		b := ctx.Table.AddOrGet(target.Region)
+		buff.WriteInt(b) // write table index
+	} else {
+		buff.WriteString(target.Region) // write string
+	}
+	if ctx.IsStringTable() {
+		c := ctx.Table.AddOrGet(target.NodeType)
+		buff.WriteInt(c) // write table index
+	} else {
+		buff.WriteString(target.NodeType) // write string
+	}
+	// --- [begin][write][alias](shared.UsageType) ---
+	if ctx.IsStringTable() {
+		d := ctx.Table.AddOrGet(string(target.UsageType))
+		buff.WriteInt(d) // write table index
+	} else {
+		buff.WriteString(string(target.UsageType)) // write string
+	}
+	// --- [end][write][alias](shared.UsageType) ---
+
+	if ctx.IsStringTable() {
+		e := ctx.Table.AddOrGet(target.Family)
+		buff.WriteInt(e) // write table index
+	} else {
+		buff.WriteString(target.Family) // write string
+	}
+	if ctx.IsStringTable() {
+		f := ctx.Table.AddOrGet(target.DeviceType)
+		buff.WriteInt(f) // write table index
+	} else {
+		buff.WriteString(target.DeviceType) // write string
+	}
+	// --- [begin][write][alias](NodePricingType) ---
+	if ctx.IsStringTable() {
+		g := ctx.Table.AddOrGet(string(target.PricingType))
+		buff.WriteInt(g) // write table index
+	} else {
+		buff.WriteString(string(target.PricingType)) // write string
+	}
+	// --- [end][write][alias](NodePricingType) ---
+
+	return nil
+}
+
+// UnmarshalBinary uses the data passed byte array to set all the internal properties of
+// the NodeKey type
+func (target *NodeKey) UnmarshalBinary(data []byte) error {
+	var table []string
+	buff := util.NewBufferFromBytes(data)
+
+	// string table header validation
+	if isBinaryTag(data, BinaryTagStringTable) {
+		buff.ReadBytes(len(BinaryTagStringTable)) // strip tag length
+		tl := buff.ReadInt()                      // table length
+		if tl > 0 {
+			table = make([]string, tl, tl)
+			for i := 0; i < tl; i++ {
+				table[i] = buff.ReadString()
+			}
+		}
+	}
+
+	ctx := &DecodingContext{
+		Buffer: buff,
+		Table:  table,
+	}
+
+	err := target.UnmarshalBinaryWithContext(ctx)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// UnmarshalBinaryWithContext uses the context containing a string table and binary buffer to set all the internal properties of
+// the NodeKey type
+func (target *NodeKey) UnmarshalBinaryWithContext(ctx *DecodingContext) (err error) {
+	// panics are recovered and propagated as errors
+	defer func() {
+		if r := recover(); r != nil {
+			if e, ok := r.(error); ok {
+				err = e
+			} else if s, ok := r.(string); ok {
+				err = fmt.Errorf("Unexpected panic: %s", s)
+			} else {
+				err = fmt.Errorf("Unexpected panic: %+v", r)
+			}
+		}
+	}()
+
+	buff := ctx.Buffer
+	version := buff.ReadUInt8()
+
+	if version > DefaultCodecVersion {
+		return fmt.Errorf("Invalid Version Unmarshaling NodeKey. Expected %d or less, got %d", DefaultCodecVersion, version)
+	}
+
+	// --- [begin][read][alias](shared.Provider) ---
+	var a string
+	if ctx.IsStringTable() {
+		b := buff.ReadInt() // read string index
+		a = ctx.Table[b]
+	} else {
+		a = buff.ReadString() // read string
+	}
+	target.Provider = shared.Provider(a)
+	// --- [end][read][alias](shared.Provider) ---
+
+	var e string
+	if ctx.IsStringTable() {
+		f := buff.ReadInt() // read string index
+		e = ctx.Table[f]
+	} else {
+		e = buff.ReadString() // read string
+	}
+	d := e
+	target.Region = d
+
+	var h string
+	if ctx.IsStringTable() {
+		k := buff.ReadInt() // read string index
+		h = ctx.Table[k]
+	} else {
+		h = buff.ReadString() // read string
+	}
+	g := h
+	target.NodeType = g
+
+	// --- [begin][read][alias](shared.UsageType) ---
+	var l string
+	if ctx.IsStringTable() {
+		m := buff.ReadInt() // read string index
+		l = ctx.Table[m]
+	} else {
+		l = buff.ReadString() // read string
+	}
+	target.UsageType = shared.UsageType(l)
+	// --- [end][read][alias](shared.UsageType) ---
+
+	var p string
+	if ctx.IsStringTable() {
+		q := buff.ReadInt() // read string index
+		p = ctx.Table[q]
+	} else {
+		p = buff.ReadString() // read string
+	}
+	o := p
+	target.Family = o
+
+	var s string
+	if ctx.IsStringTable() {
+		t := buff.ReadInt() // read string index
+		s = ctx.Table[t]
+	} else {
+		s = buff.ReadString() // read string
+	}
+	r := s
+	target.DeviceType = r
+
+	// --- [begin][read][alias](NodePricingType) ---
+	var u string
+	var x string
+	if ctx.IsStringTable() {
+		y := buff.ReadInt() // read string index
+		x = ctx.Table[y]
+	} else {
+		x = buff.ReadString() // read string
+	}
+	w := x
+	u = w
+
+	target.PricingType = NodePricingType(u)
+	// --- [end][read][alias](NodePricingType) ---
+
+	return nil
+}
+
+//--------------------------------------------------------------------------
+//  NodePricing
+//--------------------------------------------------------------------------
+
+// MarshalBinary serializes the internal properties of this NodePricing instance
+// into a byte array
+func (target *NodePricing) MarshalBinary() (data []byte, err error) {
+	ctx := &EncodingContext{
+		Buffer: util.NewBuffer(),
+		Table:  nil,
+	}
+
+	e := target.MarshalBinaryWithContext(ctx)
+	if e != nil {
+		return nil, e
+	}
+
+	encBytes := ctx.Buffer.Bytes()
+	return encBytes, nil
+}
+
+// MarshalBinaryWithContext serializes the internal properties of this NodePricing instance
+// into a byte array leveraging a predefined context.
+func (target *NodePricing) MarshalBinaryWithContext(ctx *EncodingContext) (err error) {
+	// panics are recovered and propagated as errors
+	defer func() {
+		if r := recover(); r != nil {
+			if e, ok := r.(error); ok {
+				err = e
+			} else if s, ok := r.(string); ok {
+				err = fmt.Errorf("Unexpected panic: %s", s)
+			} else {
+				err = fmt.Errorf("Unexpected panic: %+v", r)
+			}
+		}
+	}()
+
+	buff := ctx.Buffer
+	buff.WriteUInt8(DefaultCodecVersion) // version
+
+	buff.WriteFloat64(target.HourlyRate) // write float64
+	return nil
+}
+
+// UnmarshalBinary uses the data passed byte array to set all the internal properties of
+// the NodePricing type
+func (target *NodePricing) UnmarshalBinary(data []byte) error {
+	var table []string
+	buff := util.NewBufferFromBytes(data)
+
+	// string table header validation
+	if isBinaryTag(data, BinaryTagStringTable) {
+		buff.ReadBytes(len(BinaryTagStringTable)) // strip tag length
+		tl := buff.ReadInt()                      // table length
+		if tl > 0 {
+			table = make([]string, tl, tl)
+			for i := 0; i < tl; i++ {
+				table[i] = buff.ReadString()
+			}
+		}
+	}
+
+	ctx := &DecodingContext{
+		Buffer: buff,
+		Table:  table,
+	}
+
+	err := target.UnmarshalBinaryWithContext(ctx)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// UnmarshalBinaryWithContext uses the context containing a string table and binary buffer to set all the internal properties of
+// the NodePricing type
+func (target *NodePricing) UnmarshalBinaryWithContext(ctx *DecodingContext) (err error) {
+	// panics are recovered and propagated as errors
+	defer func() {
+		if r := recover(); r != nil {
+			if e, ok := r.(error); ok {
+				err = e
+			} else if s, ok := r.(string); ok {
+				err = fmt.Errorf("Unexpected panic: %s", s)
+			} else {
+				err = fmt.Errorf("Unexpected panic: %+v", r)
+			}
+		}
+	}()
+
+	buff := ctx.Buffer
+	version := buff.ReadUInt8()
+
+	if version > DefaultCodecVersion {
+		return fmt.Errorf("Invalid Version Unmarshaling NodePricing. Expected %d or less, got %d", DefaultCodecVersion, version)
+	}
+
+	a := buff.ReadFloat64() // read float64
+	target.HourlyRate = a
+
+	return nil
+}
+
+//--------------------------------------------------------------------------
+//  PricingModelSet
+//--------------------------------------------------------------------------
+
+// MarshalBinary serializes the internal properties of this PricingModelSet instance
+// into a byte array
+func (target *PricingModelSet) MarshalBinary() (data []byte, err error) {
+	ctx := &EncodingContext{
+		Buffer: util.NewBuffer(),
+		Table:  nil,
+	}
+
+	e := target.MarshalBinaryWithContext(ctx)
+	if e != nil {
+		return nil, e
+	}
+
+	encBytes := ctx.Buffer.Bytes()
+	return encBytes, nil
+}
+
+// MarshalBinaryWithContext serializes the internal properties of this PricingModelSet instance
+// into a byte array leveraging a predefined context.
+func (target *PricingModelSet) MarshalBinaryWithContext(ctx *EncodingContext) (err error) {
+	// panics are recovered and propagated as errors
+	defer func() {
+		if r := recover(); r != nil {
+			if e, ok := r.(error); ok {
+				err = e
+			} else if s, ok := r.(string); ok {
+				err = fmt.Errorf("Unexpected panic: %s", s)
+			} else {
+				err = fmt.Errorf("Unexpected panic: %+v", r)
+			}
+		}
+	}()
+
+	buff := ctx.Buffer
+	buff.WriteUInt8(DefaultCodecVersion) // version
+
+	// --- [begin][write][reference](time.Time) ---
+	a, errA := target.TimeStamp.MarshalBinary()
+	if errA != nil {
+		return errA
+	}
+	buff.WriteInt(len(a))
+	buff.WriteBytes(a)
+	// --- [end][write][reference](time.Time) ---
+
+	if ctx.IsStringTable() {
+		b := ctx.Table.AddOrGet(string(target.SourceType))
+		buff.WriteInt(b) // write table index
+	} else {
+		buff.WriteString(string(target.SourceType)) // write string
+	}
+	if target.NodePricing == nil {
+		buff.WriteUInt8(uint8(0)) // write nil byte
+	} else {
+		buff.WriteUInt8(uint8(1)) // write non-nil byte
+
+		// --- [begin][write][map](map[NodeKey]NodePricing) ---
+		buff.WriteInt(len(target.NodePricing)) // map length
+		for v, z := range target.NodePricing {
+			// --- [begin][write][struct](NodeKey) ---
+			buff.WriteInt(0) // [compatibility, unused]
+			errB := v.MarshalBinaryWithContext(ctx)
+			if errB != nil {
+				return errB
+			}
+			// --- [end][write][struct](NodeKey) ---
+
+			// --- [begin][write][struct](NodePricing) ---
+			buff.WriteInt(0) // [compatibility, unused]
+			errC := z.MarshalBinaryWithContext(ctx)
+			if errC != nil {
+				return errC
+			}
+			// --- [end][write][struct](NodePricing) ---
+
+		}
+		// --- [end][write][map](map[NodeKey]NodePricing) ---
+
+	}
+	if ctx.IsStringTable() {
+		i := ctx.Table.AddOrGet(target.SourceKey)
+		buff.WriteInt(i) // write table index
+	} else {
+		buff.WriteString(target.SourceKey) // write string
+	}
+	return nil
+}
+
+// UnmarshalBinary uses the data passed byte array to set all the internal properties of
+// the PricingModelSet type
+func (target *PricingModelSet) UnmarshalBinary(data []byte) error {
+	var table []string
+	buff := util.NewBufferFromBytes(data)
+
+	// string table header validation
+	if isBinaryTag(data, BinaryTagStringTable) {
+		buff.ReadBytes(len(BinaryTagStringTable)) // strip tag length
+		tl := buff.ReadInt()                      // table length
+		if tl > 0 {
+			table = make([]string, tl, tl)
+			for i := 0; i < tl; i++ {
+				table[i] = buff.ReadString()
+			}
+		}
+	}
+
+	ctx := &DecodingContext{
+		Buffer: buff,
+		Table:  table,
+	}
+
+	err := target.UnmarshalBinaryWithContext(ctx)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// UnmarshalBinaryWithContext uses the context containing a string table and binary buffer to set all the internal properties of
+// the PricingModelSet type
+func (target *PricingModelSet) UnmarshalBinaryWithContext(ctx *DecodingContext) (err error) {
+	// panics are recovered and propagated as errors
+	defer func() {
+		if r := recover(); r != nil {
+			if e, ok := r.(error); ok {
+				err = e
+			} else if s, ok := r.(string); ok {
+				err = fmt.Errorf("Unexpected panic: %s", s)
+			} else {
+				err = fmt.Errorf("Unexpected panic: %+v", r)
+			}
+		}
+	}()
+
+	buff := ctx.Buffer
+	version := buff.ReadUInt8()
+
+	if version > DefaultCodecVersion {
+		return fmt.Errorf("Invalid Version Unmarshaling PricingModelSet. Expected %d or less, got %d", DefaultCodecVersion, version)
+	}
+
+	// --- [begin][read][reference](time.Time) ---
+	a := &time.Time{}
+	b := buff.ReadInt()    // byte array length
+	c := buff.ReadBytes(b) // byte array
+	errA := a.UnmarshalBinary(c)
+	if errA != nil {
+		return errA
+	}
+	target.TimeStamp = *a
+	// --- [end][read][reference](time.Time) ---
+
+	var e string
+	if ctx.IsStringTable() {
+		f := buff.ReadInt() // read string index
+		e = ctx.Table[f]
+	} else {
+		e = buff.ReadString() // read string
+	}
+	d := e
+	target.SourceType = PricingSourceType(d)
+
+	if buff.ReadUInt8() == uint8(0) {
+		target.NodePricing = nil
+	} else {
+		// --- [begin][read][map](map[NodeKey]NodePricing) ---
+		h := buff.ReadInt() // map len
+		g := make(map[NodeKey]NodePricing, h)
+		for i := 0; i < h; i++ {
+			// --- [begin][read][struct](NodeKey) ---
+			k := &NodeKey{}
+			buff.ReadInt() // [compatibility, unused]
+			errB := k.UnmarshalBinaryWithContext(ctx)
+			if errB != nil {
+				return errB
+			}
+			v := *k
+			// --- [end][read][struct](NodeKey) ---
+
+			// --- [begin][read][struct](NodePricing) ---
+			l := &NodePricing{}
+			buff.ReadInt() // [compatibility, unused]
+			errC := l.UnmarshalBinaryWithContext(ctx)
+			if errC != nil {
+				return errC
+			}
+			z := *l
+			// --- [end][read][struct](NodePricing) ---
+
+			g[v] = z
+		}
+		target.NodePricing = g
+		// --- [end][read][map](map[NodeKey]NodePricing) ---
+
+	}
+	var m string
+	if ctx.IsStringTable() {
+		n := buff.ReadInt() // read string index
+		m = ctx.Table[n]
+	} else {
+		m = buff.ReadString() // read string
+	}
+	target.SourceKey = m
+	return nil
+}

+ 13 - 0
core/pkg/model/pricingmodel/pricingsource.go

@@ -0,0 +1,13 @@
+package pricingmodel
+
+type PricingSource interface {
+	// PricingSourceType returns the instance type of the PricingSource, each implementation of this interface should
+	// provide a unique type that all instances should return
+	PricingSourceType() PricingSourceType
+	// PricingSourceKey returns the unique key of the PricingSource instance. In PricingSource implementation that may
+	// have multiple instances running side by side this key (derived from some configuration will) will Identify each
+	// instance. In PricingSource implementations where there will only be a single instance (ex Provider List Pricing)
+	// The PricingSourceKey should match the PricingSourceType
+	PricingSourceKey() string
+	GetPricing() (*PricingModelSet, error)
+}

+ 23 - 0
core/pkg/model/shared/bingen.go

@@ -0,0 +1,23 @@
+package shared
+
+////////////////////////////////////////////////////////////////////////////////
+// NOTE: If you add fields to _any_ struct that is serialized by bingen, please
+// make sure to add those fields to the END of the struct definition. This is
+// required for backwards-compatibility. So:
+//
+// type Foo struct {
+//     ExistingField1 string
+//     ExistingField2 int
+// }
+//
+// becomes:
+//
+// type Foo struct {
+//     ExistingField1 string
+//     ExistingField2 int
+//     NewField       float64 // @bingen: <- annotation ref: bingen README
+// }
+//
+////////////////////////////////////////////////////////////////////////////////
+
+//go:generate bingen -package=shared -version=1 -buffer=github.com/opencost/opencost/core/pkg/util

+ 37 - 0
core/pkg/model/shared/provider.go

@@ -0,0 +1,37 @@
+package shared
+
+import "strings"
+
+// @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"
+)
+
+// ParseProvider converts a string to a Provider type, performing case-insensitive matching.
+// Returns ProviderEmpty if the provider string is not recognized.
+func ParseProvider(provider string) Provider {
+	switch strings.ToLower(provider) {
+	case "aws", "amazon":
+		return ProviderAWS
+	case "gcp", "gce", "google":
+		return ProviderGCP
+	case "azure", "microsoft":
+		return ProviderAzure
+	case "alibaba":
+		return ProviderAlibaba
+	case "digitalocean", "do":
+		return ProviderDigitalOcean
+	case "oracle", "oci":
+		return ProviderOracle
+	default:
+		return ProviderEmpty
+	}
+}

+ 220 - 0
core/pkg/model/shared/shared_codecs.go

@@ -0,0 +1,220 @@
+////////////////////////////////////////////////////////////////////////////////
+//
+//                             DO NOT MODIFY
+//
+//                          ┻━┻ ︵ヽ(`Д´)ノ︵ ┻━┻
+//
+//
+//            This source file was automatically generated by bingen.
+//
+////////////////////////////////////////////////////////////////////////////////
+
+package shared
+
+import (
+	"fmt"
+	util "github.com/opencost/opencost/core/pkg/util"
+	"reflect"
+	"strings"
+	"sync"
+)
+
+const (
+	// GeneratorPackageName is the package the generator is targetting
+	GeneratorPackageName string = "shared"
+)
+
+// BinaryTags represent the formatting tag used for specific optimization features
+const (
+	// BinaryTagStringTable is written and/or read prior to the existence of a string
+	// table (where each index is encoded as a string entry in the resource
+	BinaryTagStringTable string = "BGST"
+)
+
+const (
+	// DefaultCodecVersion is used for any resources listed in the Default version set
+	DefaultCodecVersion uint8 = 1
+)
+
+//--------------------------------------------------------------------------
+//  Type Map
+//--------------------------------------------------------------------------
+
+// Generated type map for resolving interface implementations to
+// to concrete types
+var typeMap map[string]reflect.Type = map[string]reflect.Type{}
+
+//--------------------------------------------------------------------------
+//  Type Helpers
+//--------------------------------------------------------------------------
+
+// isBinaryTag returns true when the first bytes in the provided binary matches the tag
+func isBinaryTag(data []byte, tag string) bool {
+	return string(data[:len(tag)]) == tag
+}
+
+// appendBytes combines a and b into a new byte array
+func appendBytes(a []byte, b []byte) []byte {
+	al := len(a)
+	bl := len(b)
+	tl := al + bl
+
+	// allocate a new byte array for the combined
+	// use native copy for speedy byte copying
+	result := make([]byte, tl, tl)
+	copy(result, a)
+	copy(result[al:], b)
+
+	return result
+}
+
+// typeToString determines the basic properties of the type, the qualifier, package path, and
+// type name, and returns the qualified type
+func typeToString(f interface{}) string {
+	qual := ""
+	t := reflect.TypeOf(f)
+	if t.Kind() == reflect.Ptr {
+		t = t.Elem()
+		qual = "*"
+	}
+
+	return fmt.Sprintf("%s%s.%s", qual, t.PkgPath(), t.Name())
+}
+
+// resolveType uses the name of a type and returns the package, base type name, and whether
+// or not it's a pointer.
+func resolveType(t string) (pkg string, name string, isPtr bool) {
+	isPtr = t[:1] == "*"
+	if isPtr {
+		t = t[1:]
+	}
+
+	slashIndex := strings.LastIndex(t, "/")
+	if slashIndex >= 0 {
+		t = t[slashIndex+1:]
+	}
+	parts := strings.Split(t, ".")
+	if parts[0] == GeneratorPackageName {
+		parts[0] = ""
+	}
+
+	pkg = parts[0]
+	name = parts[1]
+	return
+}
+
+//--------------------------------------------------------------------------
+//  StringTable
+//--------------------------------------------------------------------------
+
+// StringTable maps strings to specific indices for encoding
+type StringTable struct {
+	l       *sync.Mutex
+	indices map[string]int
+	next    int
+}
+
+// NewStringTable Creates a new StringTable instance with provided contents
+func NewStringTable(contents ...string) *StringTable {
+	st := &StringTable{
+		l:       new(sync.Mutex),
+		indices: make(map[string]int),
+		next:    len(contents),
+	}
+
+	for i, entry := range contents {
+		st.indices[entry] = i
+	}
+
+	return st
+}
+
+// AddOrGet atomically retrieves a string entry's index if it exist. Otherwise, it will
+// add the entry and return the index.
+func (st *StringTable) AddOrGet(s string) int {
+	st.l.Lock()
+	defer st.l.Unlock()
+
+	if ind, ok := st.indices[s]; ok {
+		return ind
+	}
+
+	current := st.next
+	st.next++
+
+	st.indices[s] = current
+	return current
+}
+
+// ToSlice Converts the contents to a string array for encoding.
+func (st *StringTable) ToSlice() []string {
+	st.l.Lock()
+	defer st.l.Unlock()
+
+	if st.next == 0 {
+		return []string{}
+	}
+
+	sl := make([]string, st.next, st.next)
+	for s, i := range st.indices {
+		sl[i] = s
+	}
+	return sl
+}
+
+// ToBytes Converts the contents to a binary encoded representation
+func (st *StringTable) ToBytes() []byte {
+	buff := util.NewBuffer()
+	buff.WriteBytes([]byte(BinaryTagStringTable)) // bingen table header
+
+	strs := st.ToSlice()
+
+	buff.WriteInt(len(strs)) // table length
+	for _, s := range strs {
+		buff.WriteString(s)
+	}
+
+	return buff.Bytes()
+}
+
+//--------------------------------------------------------------------------
+//  Codec Context
+//--------------------------------------------------------------------------
+
+// EncodingContext is a context object passed to the encoders to ensure reuse of buffer
+// and table data
+type EncodingContext struct {
+	Buffer *util.Buffer
+	Table  *StringTable
+}
+
+// IsStringTable returns true if the table is available
+func (ec *EncodingContext) IsStringTable() bool {
+	return ec.Table != nil
+}
+
+// DecodingContext is a context object passed to the decoders to ensure parent objects
+// reuse as much data as possible
+type DecodingContext struct {
+	Buffer *util.Buffer
+	Table  []string
+}
+
+// IsStringTable returns true if the table is available
+func (dc *DecodingContext) IsStringTable() bool {
+	return len(dc.Table) > 0
+}
+
+//--------------------------------------------------------------------------
+//  Binary Codec
+//--------------------------------------------------------------------------
+
+// BinEncoder is an encoding interface which defines a context based marshal contract.
+type BinEncoder interface {
+	MarshalBinaryWithContext(*EncodingContext) error
+}
+
+// BinDecoder is a decoding interface which defines a context based unmarshal contract.
+type BinDecoder interface {
+	UnmarshalBinaryWithContext(*DecodingContext) error
+}

+ 10 - 0
core/pkg/model/shared/usagetype.go

@@ -0,0 +1,10 @@
+package shared
+
+// @bingen:generate:UsageType
+type UsageType string
+
+const (
+	UsageTypeEmpty    UsageType = ""
+	UsageTypeOnDemand UsageType = "OnDemand"
+	UsageTypeSpot     UsageType = "Spot"
+)

+ 3 - 0
core/pkg/nodestats/nodes_test.go

@@ -125,6 +125,9 @@ func (tcc *NodesOnlyClusterCache) GetAllStorageClasses() []*clustercache.Storage
 // GetAllJobs returns all the cached jobs
 func (tcc *NodesOnlyClusterCache) GetAllJobs() []*clustercache.Job { return nil }
 
+// GetAllJobs returns all the cached cronjobs
+func (tcc *NodesOnlyClusterCache) GetAllCronJobs() []*clustercache.CronJob { return nil }
+
 // GetAllPodDisruptionBudgets returns all cached pod disruption budgets
 func (tcc *NodesOnlyClusterCache) GetAllPodDisruptionBudgets() []*clustercache.PodDisruptionBudget {
 	return nil

+ 20 - 13
core/pkg/opencost/exporter/exporter_test.go

@@ -1,6 +1,7 @@
 package exporter
 
 import (
+	"fmt"
 	"testing"
 	"time"
 
@@ -182,7 +183,7 @@ func TestExporters(t *testing.T) {
 			t.Fatalf("failed to export allocation data: %v", err)
 		}
 
-		validateFileCreation[opencost.AllocationSet](t, memStore, p, start, end)
+		validateFileCreation[opencost.AllocationSet](t, memStore, p, "", start, end)
 	})
 
 	t.Run("asset exporter", func(t *testing.T) {
@@ -211,7 +212,7 @@ func TestExporters(t *testing.T) {
 			t.Fatalf("failed to export asset data: %v", err)
 		}
 
-		validateFileCreation[opencost.AssetSet](t, memStore, p, start, end)
+		validateFileCreation[opencost.AssetSet](t, memStore, p, "", start, end)
 	})
 
 	t.Run("network insight exporter", func(t *testing.T) {
@@ -240,7 +241,7 @@ func TestExporters(t *testing.T) {
 			t.Fatalf("failed to export net insights data: %v", err)
 		}
 
-		validateFileCreation[opencost.NetworkInsightSet](t, memStore, p, start, end)
+		validateFileCreation[opencost.NetworkInsightSet](t, memStore, p, "", start, end)
 	})
 
 	t.Run("KubeModel exporter", func(t *testing.T) {
@@ -268,8 +269,9 @@ func TestExporters(t *testing.T) {
 		if err != nil {
 			t.Fatalf("failed to export KubeModel data: %v", err)
 		}
-
-		validateFileCreation[kubemodel.KubeModelSet](t, memStore, p, start, end)
+		
+		ext := fmt.Sprintf(exporter.BingenVersionExtFMT, kubemodel.DefaultCodecVersion)
+		validateFileCreation[kubemodel.KubeModelSet](t, memStore, p, ext, start, end)
 	})
 
 	t.Run("unknown exporter", func(t *testing.T) {
@@ -323,9 +325,9 @@ func TestPipelineExportControllers(t *testing.T) {
 			t.Fatalf("failed to create net insights path formatter: %v", err)
 		}
 
-		validateFileCreation[opencost.AllocationSet](t, memStore, allocPath, start, end)
-		validateFileCreation[opencost.AssetSet](t, memStore, assetPath, start, end)
-		validateFileCreation[opencost.NetworkInsightSet](t, memStore, netPath, start, end)
+		validateFileCreation[opencost.AllocationSet](t, memStore, allocPath, "", start, end)
+		validateFileCreation[opencost.AssetSet](t, memStore, assetPath, "", start, end)
+		validateFileCreation[opencost.NetworkInsightSet](t, memStore, netPath, "", start, end)
 	})
 
 	t.Run("with auto-set to minute resolution", func(t *testing.T) {
@@ -361,9 +363,9 @@ func TestPipelineExportControllers(t *testing.T) {
 			t.Fatalf("failed to create net insights path formatter: %v", err)
 		}
 
-		validateFileCreation[opencost.AllocationSet](t, memStore, allocPath, start, end)
-		validateFileCreation[opencost.AssetSet](t, memStore, assetPath, start, end)
-		validateFileCreation[opencost.NetworkInsightSet](t, memStore, netPath, start, end)
+		validateFileCreation[opencost.AllocationSet](t, memStore, allocPath, "", start, end)
+		validateFileCreation[opencost.AssetSet](t, memStore, assetPath, "", start, end)
+		validateFileCreation[opencost.NetworkInsightSet](t, memStore, netPath, "", start, end)
 	})
 
 	t.Run("with default export config", func(t *testing.T) {
@@ -420,10 +422,15 @@ func TestPipelineExportControllers(t *testing.T) {
 }
 
 // test helper function that will load a path from a storage implementation and ensure that the file is not empty and can be decoded, etc...
-func validateFileCreation[T any, U PipelineData[T]](t *testing.T, memStore storage.Storage, p pathing.StoragePathFormatter[opencost.Window], start, end time.Time) {
+func validateFileCreation[T any, U PipelineData[T]](
+	t *testing.T,
+	memStore storage.Storage,
+	p pathing.StoragePathFormatter[opencost.Window],
+	ext string,
+	start, end time.Time) {
 	t.Helper()
 
-	expectedPath := p.ToFullPath("", opencost.NewClosedWindow(start, end), "")
+	expectedPath := p.ToFullPath("", opencost.NewClosedWindow(start, end), ext)
 
 	fileContents, err := memStore.Read(expectedPath)
 	if err != nil {

+ 9 - 1
core/pkg/opencost/exporter/exporters.go

@@ -7,6 +7,7 @@ import (
 	export "github.com/opencost/opencost/core/pkg/exporter"
 	"github.com/opencost/opencost/core/pkg/exporter/pathing"
 	"github.com/opencost/opencost/core/pkg/exporter/validator"
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
 	"github.com/opencost/opencost/core/pkg/pipelines"
 	"github.com/opencost/opencost/core/pkg/storage"
 	"github.com/opencost/opencost/core/pkg/util/typeutil"
@@ -29,9 +30,16 @@ func NewComputePipelineExporter[T any, U export.BinaryMarshalerPtr[T], S validat
 		return nil, fmt.Errorf("failed to create path formatter: %w", err)
 	}
 
+	var encoder export.Encoder[T]
+	if pipelineName == pipelines.KubeModelPipelineName {
+		encoder = export.NewVersionBingenEncoder[T, U](kubemodel.DefaultCodecVersion)
+	} else {
+		encoder = export.NewBingenEncoder[T, U]()
+	}
+
 	return export.NewComputeStorageExporter(
 		pathing,
-		export.NewBingenEncoder[T, U](),
+		encoder,
 		store,
 		validator.NewSetValidator[T, S](resolution),
 	), nil

+ 28 - 6
core/pkg/opencost/mock.go

@@ -1025,13 +1025,35 @@ func GenerateMockKubeModelSet(start, end time.Time) *kubemodel.KubeModelSet {
 		Name: "cluster",
 	}
 
-	kms.RegisterNamespace("namespace-1", "namespace-1")
-	kms.RegisterNamespace("namespace-2", "namespace-2")
+	kms.RegisterNamespace(&kubemodel.Namespace{
+		UID:  "namespace-1",
+		Name: "namespace-1",
+	})
+	kms.RegisterNamespace(&kubemodel.Namespace{
+		UID:  "namespace-2",
+		Name: "namespace-2",
+	})
 
-	kms.RegisterResourceQuota("resourcequota-1", "resourcequota-1", "namespace-1")
-	kms.RegisterResourceQuota("resourcequota-2", "resourcequota-2", "namespace-1")
-	kms.RegisterResourceQuota("resourcequota-3", "resourcequota-3", "namespace-2")
-	kms.RegisterResourceQuota("resourcequota-4", "resourcequota-4", "namespace-2")
+	kms.RegisterResourceQuota(&kubemodel.ResourceQuota{
+		UID:          "resourcequota-1",
+		NamespaceUID: "resourcequota-1",
+		Name:         "namespace-1",
+	})
+	kms.RegisterResourceQuota(&kubemodel.ResourceQuota{
+		UID:          "resourcequota-2",
+		NamespaceUID: "resourcequota-2",
+		Name:         "namespace-1",
+	})
+	kms.RegisterResourceQuota(&kubemodel.ResourceQuota{
+		UID:          "resourcequota-3",
+		NamespaceUID: "resourcequota-3",
+		Name:         "namespace-2",
+	})
+	kms.RegisterResourceQuota(&kubemodel.ResourceQuota{
+		UID:          "resourcequota-4",
+		NamespaceUID: "resourcequota-4",
+		Name:         "namespace-2",
+	})
 
 	return kms
 }

+ 4 - 0
core/pkg/pipelines/name.go

@@ -4,6 +4,7 @@ import (
 	"github.com/opencost/opencost/core/pkg/diagnostics"
 	"github.com/opencost/opencost/core/pkg/heartbeat"
 	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/model/pricingmodel"
 	"github.com/opencost/opencost/core/pkg/opencost"
 	"github.com/opencost/opencost/core/pkg/util/typeutil"
 )
@@ -18,6 +19,7 @@ const (
 	HeartbeatPipelineName         string = "heartbeat"
 	DiagnosticsPipelineName       string = "diagnostics"
 	KubeModelPipelineName         string = "kubemodel"
+	PricingModelPipelineName      string = "pricingmodel"
 )
 
 var nameByType map[string]string
@@ -40,6 +42,7 @@ func init() {
 	diagnosticsKey := typeutil.TypeOf[diagnostics.DiagnosticsRunReport]()
 
 	kubeModelSetKey := typeutil.TypeOf[kubemodel.KubeModelSet]()
+	pricingModelSetKey := typeutil.TypeOf[pricingmodel.PricingModelSet]()
 
 	nameByType = map[string]string{
 		allocSetKey:          AllocationPipelineName,
@@ -53,6 +56,7 @@ func init() {
 		heartbeatKey:         HeartbeatPipelineName,
 		diagnosticsKey:       DiagnosticsPipelineName,
 		kubeModelSetKey:      KubeModelPipelineName,
+		pricingModelSetKey:   PricingModelPipelineName,
 	}
 }
 

+ 92 - 24
core/pkg/source/datasource.go

@@ -10,9 +10,6 @@ import (
 
 type MetricsQuerier interface {
 	// Cluster Disks
-	QueryPVActiveMinutes(start, end time.Time) *Future[PVActiveMinutesResult]
-	QueryPVUsedAverage(start, end time.Time) *Future[PVUsedAvgResult]
-	QueryPVUsedMax(start, end time.Time) *Future[PVUsedMaxResult]
 
 	// Local Cluster Disks
 	QueryLocalStorageActiveMinutes(start, end time.Time) *Future[LocalStorageActiveMinutesResult]
@@ -23,6 +20,8 @@ type MetricsQuerier interface {
 	QueryLocalStorageBytes(start, end time.Time) *Future[LocalStorageBytesResult]
 
 	// Nodes
+	QueryNodeInfo(start, end time.Time) *Future[NodeInfoResult]
+	QueryNodeUptime(start, end time.Time) *Future[UptimeResult]
 	QueryNodeActiveMinutes(start, end time.Time) *Future[NodeActiveMinutesResult]
 	QueryNodeCPUCoresCapacity(start, end time.Time) *Future[NodeCPUCoresCapacityResult]
 	QueryNodeCPUCoresAllocatable(start, end time.Time) *Future[NodeCPUCoresAllocatableResult]
@@ -33,12 +32,15 @@ type MetricsQuerier interface {
 	QueryNodeIsSpot(start, end time.Time) *Future[NodeIsSpotResult]
 	QueryNodeRAMSystemPercent(start, end time.Time) *Future[NodeRAMSystemPercentResult]
 	QueryNodeRAMUserPercent(start, end time.Time) *Future[NodeRAMUserPercentResult]
+	QueryNodeResourceCapacities(start, end time.Time) *Future[ResourceResult]
+	QueryNodeResourcesAllocatable(start, end time.Time) *Future[ResourceResult]
 
 	// Load Balancers
 	QueryLBActiveMinutes(start, end time.Time) *Future[LBActiveMinutesResult]
 	QueryLBPricePerHr(start, end time.Time) *Future[LBPricePerHrResult]
 
 	// Cluster Management
+	QueryClusterInfo(start, end time.Time) *Future[ClusterInfoResult]
 	QueryClusterUptime(start, end time.Time) *Future[UptimeResult]
 	QueryClusterManagementDuration(start, end time.Time) *Future[ClusterManagementDurationResult]
 	QueryClusterManagementPricePerHr(start, end time.Time) *Future[ClusterManagementPricePerHrResult]
@@ -46,6 +48,17 @@ type MetricsQuerier interface {
 	// Pods
 	QueryPods(start, end time.Time) *Future[PodsResult]
 	QueryPodsUID(start, end time.Time) *Future[PodsResult]
+	QueryPodInfo(start, end time.Time) *Future[PodInfoResult]
+	QueryPodUptime(start, end time.Time) *Future[UptimeResult]
+	QueryPodOwners(start, end time.Time) *Future[OwnerResult]
+	QueryPodPVCVolumes(start, end time.Time) *Future[PodPVCVolumeResult]
+	QueryPodNetworkEgressBytes(start, end time.Time) *Future[PodNetworkBytesResult]
+	QueryPodNetworkIngressBytes(start, end time.Time) *Future[PodNetworkBytesResult]
+
+	// Container
+	QueryContainerUptime(start, end time.Time) *Future[ContainerUptimeResult]
+	QueryContainerResourceRequests(start, end time.Time) *Future[ContainerResourceResult]
+	QueryContainerResourceLimits(start, end time.Time) *Future[ContainerResourceResult]
 
 	// RAM
 	QueryRAMBytesAllocated(start, end time.Time) *Future[RAMBytesAllocatedResult]
@@ -72,19 +85,75 @@ type MetricsQuerier interface {
 	QueryGPUInfo(start, end time.Time) *Future[GPUInfoResult]
 	QueryIsGPUShared(start, end time.Time) *Future[IsGPUSharedResult]
 
+	// Device
+	QueryDCGMDeviceInfo(start, end time.Time) *Future[DCGMDeviceInfoResult]
+	QueryDCGMDeviceUptime(start, end time.Time) *Future[DCGMDeviceUptimeResult]
+	QueryDCGMContainerUsageAvg(start, end time.Time) *Future[DCGMDeviceContainerUsageResult]
+	QueryDCGMContainerUsageMax(start, end time.Time) *Future[DCGMDeviceContainerUsageResult]
+
 	// PVC
 	QueryPodPVCAllocation(start, end time.Time) *Future[PodPVCAllocationResult]
 	QueryPVCBytesRequested(start, end time.Time) *Future[PVCBytesRequestedResult]
 	QueryPVCInfo(start, end time.Time) *Future[PVCInfoResult]
+	QueryPVCUptime(start, end time.Time) *Future[UptimeResult]
 
 	// PV
 	QueryPVBytes(start, end time.Time) *Future[PVBytesResult]
 	QueryPVPricePerGiBHour(start, end time.Time) *Future[PVPricePerGiBHourResult]
 	QueryPVInfo(start, end time.Time) *Future[PVInfoResult]
+	QueryPVUptime(start, end time.Time) *Future[UptimeResult]
+	QueryPVActiveMinutes(start, end time.Time) *Future[PVActiveMinutesResult]
+	QueryPVUsedAverage(start, end time.Time) *Future[PVUsedAvgResult]
+	QueryPVUsedMax(start, end time.Time) *Future[PVUsedMaxResult]
+
+	// Deployment
+	QueryDeploymentInfo(start, end time.Time) *Future[DeploymentInfoResult]
+	QueryDeploymentUptime(start, end time.Time) *Future[UptimeResult]
+	QueryDeploymentLabels(start, end time.Time) *Future[LabelsResult]
+	QueryDeploymentAnnotations(start, end time.Time) *Future[AnnotationsResult]
+	QueryDeploymentMatchLabels(start, end time.Time) *Future[DeploymentLabelsResult]
+
+	// StatefulSet
+	QueryStatefulSetInfo(start, end time.Time) *Future[StatefulSetInfoResult]
+	QueryStatefulSetUptime(start, end time.Time) *Future[UptimeResult]
+	QueryStatefulSetLabels(start, end time.Time) *Future[LabelsResult]
+	QueryStatefulSetAnnotations(start, end time.Time) *Future[AnnotationsResult]
+	QueryStatefulSetMatchLabels(start, end time.Time) *Future[StatefulSetLabelsResult]
+
+	// DaemonSet
+	QueryDaemonSetInfo(start, end time.Time) *Future[DaemonSetInfoResult]
+	QueryDaemonSetUptime(start, end time.Time) *Future[UptimeResult]
+	QueryDaemonSetLabels(start, end time.Time) *Future[LabelsResult]
+	QueryDaemonSetAnnotations(start, end time.Time) *Future[AnnotationsResult]
+
+	// Job
+	QueryJobInfo(start, end time.Time) *Future[JobInfoResult]
+	QueryJobUptime(start, end time.Time) *Future[UptimeResult]
+	QueryJobLabels(start, end time.Time) *Future[LabelsResult]
+	QueryJobAnnotations(start, end time.Time) *Future[AnnotationsResult]
+
+	// CronJob
+	QueryCronJobInfo(start, end time.Time) *Future[CronJobInfoResult]
+	QueryCronJobUptime(start, end time.Time) *Future[UptimeResult]
+	QueryCronJobLabels(start, end time.Time) *Future[LabelsResult]
+	QueryCronJobAnnotations(start, end time.Time) *Future[AnnotationsResult]
+
+	// ReplicaSet
+	QueryReplicaSetInfo(start, end time.Time) *Future[ReplicaSetInfoResult]
+	QueryReplicaSetUptime(start, end time.Time) *Future[UptimeResult]
+	QueryReplicaSetLabels(start, end time.Time) *Future[LabelsResult]
+	QueryReplicaSetAnnotations(start, end time.Time) *Future[AnnotationsResult]
+	QueryReplicaSetOwners(start, end time.Time) *Future[OwnerResult]
 
 	// Namespace
+	QueryNamespaceInfo(start, end time.Time) *Future[NamespaceInfoResult]
 	QueryNamespaceUptime(start, end time.Time) *Future[UptimeResult]
 
+	// Service
+	QueryServiceInfo(start, end time.Time) *Future[ServiceInfoResult]
+	QueryServiceUptime(start, end time.Time) *Future[UptimeResult]
+	QueryServiceSelectorLabels(start, end time.Time) *Future[ServiceLabelsResult]
+
 	// Network Egress
 	QueryNetZoneGiB(start, end time.Time) *Future[NetZoneGiBResult]
 	QueryNetZonePricePerGiB(start, end time.Time) *Future[NetZonePricePerGiBResult]
@@ -114,11 +183,9 @@ type MetricsQuerier interface {
 	QueryNodeLabels(start, end time.Time) *Future[NodeLabelsResult]
 	QueryNamespaceLabels(start, end time.Time) *Future[NamespaceLabelsResult]
 	QueryPodLabels(start, end time.Time) *Future[PodLabelsResult]
-	QueryServiceLabels(start, end time.Time) *Future[ServiceLabelsResult]
-	QueryDeploymentLabels(start, end time.Time) *Future[DeploymentLabelsResult]
-	QueryStatefulSetLabels(start, end time.Time) *Future[StatefulSetLabelsResult]
-	QueryDaemonSetLabels(start, end time.Time) *Future[DaemonSetLabelsResult]
-	QueryJobLabels(start, end time.Time) *Future[JobLabelsResult]
+
+	QueryPodsWithDaemonSetOwner(start, end time.Time) *Future[PodsWithDaemonSetOwnerResult]
+	QueryPodsWithJobOwner(start, end time.Time) *Future[PodsWithJobOwnerResult]
 
 	// ReplicaSet -> Controller mapping
 	QueryPodsWithReplicaSetOwner(start, end time.Time) *Future[PodsWithReplicaSetOwnerResult]
@@ -126,23 +193,24 @@ type MetricsQuerier interface {
 	QueryReplicaSetsWithRollout(start, end time.Time) *Future[ReplicaSetsWithRolloutResult]
 
 	// ResourceQuotas
+	QueryResourceQuotaInfo(start, end time.Time) *Future[ResourceQuotaInfoResult]
 	QueryResourceQuotaUptime(start, end time.Time) *Future[UptimeResult]
-	QueryResourceQuotaSpecCPURequestAverage(start, end time.Time) *Future[ResourceQuotaSpecCPURequestAvgResult]
-	QueryResourceQuotaSpecCPURequestMax(start, end time.Time) *Future[ResourceQuotaSpecCPURequestMaxResult]
-	QueryResourceQuotaSpecRAMRequestAverage(start, end time.Time) *Future[ResourceQuotaSpecRAMRequestAvgResult]
-	QueryResourceQuotaSpecRAMRequestMax(start, end time.Time) *Future[ResourceQuotaSpecRAMRequestMaxResult]
-	QueryResourceQuotaSpecCPULimitAverage(start, end time.Time) *Future[ResourceQuotaSpecCPULimitAvgResult]
-	QueryResourceQuotaSpecCPULimitMax(start, end time.Time) *Future[ResourceQuotaSpecCPULimitMaxResult]
-	QueryResourceQuotaSpecRAMLimitAverage(start, end time.Time) *Future[ResourceQuotaSpecRAMLimitAvgResult]
-	QueryResourceQuotaSpecRAMLimitMax(start, end time.Time) *Future[ResourceQuotaSpecRAMLimitMaxResult]
-	QueryResourceQuotaStatusUsedCPURequestAverage(start, end time.Time) *Future[ResourceQuotaStatusUsedCPURequestAvgResult]
-	QueryResourceQuotaStatusUsedCPURequestMax(start, end time.Time) *Future[ResourceQuotaStatusUsedCPURequestMaxResult]
-	QueryResourceQuotaStatusUsedRAMRequestAverage(start, end time.Time) *Future[ResourceQuotaStatusUsedRAMRequestAvgResult]
-	QueryResourceQuotaStatusUsedRAMRequestMax(start, end time.Time) *Future[ResourceQuotaStatusUsedRAMRequestMaxResult]
-	QueryResourceQuotaStatusUsedCPULimitAverage(start, end time.Time) *Future[ResourceQuotaStatusUsedCPULimitAvgResult]
-	QueryResourceQuotaStatusUsedCPULimitMax(start, end time.Time) *Future[ResourceQuotaStatusUsedCPULimitMaxResult]
-	QueryResourceQuotaStatusUsedRAMLimitAverage(start, end time.Time) *Future[ResourceQuotaStatusUsedRAMLimitAvgResult]
-	QueryResourceQuotaStatusUsedRAMLimitMax(start, end time.Time) *Future[ResourceQuotaStatusUsedRAMLimitMaxResult]
+	QueryResourceQuotaSpecCPURequestAverage(start, end time.Time) *Future[ResourceResult]
+	QueryResourceQuotaSpecCPURequestMax(start, end time.Time) *Future[ResourceResult]
+	QueryResourceQuotaSpecRAMRequestAverage(start, end time.Time) *Future[ResourceResult]
+	QueryResourceQuotaSpecRAMRequestMax(start, end time.Time) *Future[ResourceResult]
+	QueryResourceQuotaSpecCPULimitAverage(start, end time.Time) *Future[ResourceResult]
+	QueryResourceQuotaSpecCPULimitMax(start, end time.Time) *Future[ResourceResult]
+	QueryResourceQuotaSpecRAMLimitAverage(start, end time.Time) *Future[ResourceResult]
+	QueryResourceQuotaSpecRAMLimitMax(start, end time.Time) *Future[ResourceResult]
+	QueryResourceQuotaStatusUsedCPURequestAverage(start, end time.Time) *Future[ResourceResult]
+	QueryResourceQuotaStatusUsedCPURequestMax(start, end time.Time) *Future[ResourceResult]
+	QueryResourceQuotaStatusUsedRAMRequestAverage(start, end time.Time) *Future[ResourceResult]
+	QueryResourceQuotaStatusUsedRAMRequestMax(start, end time.Time) *Future[ResourceResult]
+	QueryResourceQuotaStatusUsedCPULimitAverage(start, end time.Time) *Future[ResourceResult]
+	QueryResourceQuotaStatusUsedCPULimitMax(start, end time.Time) *Future[ResourceResult]
+	QueryResourceQuotaStatusUsedRAMLimitAverage(start, end time.Time) *Future[ResourceResult]
+	QueryResourceQuotaStatusUsedRAMLimitMax(start, end time.Time) *Future[ResourceResult]
 
 	// Data Coverage Query
 	QueryDataCoverage(limitDays int) (time.Time, time.Time, error)

+ 554 - 182
core/pkg/source/decoders.go

@@ -13,34 +13,48 @@ const (
 	RegionLabel          = "region"
 	ClusterIDLabel       = "cluster_id"
 	NamespaceLabel       = "namespace"
+	NamespaceUIDLabel    = "namespace_uid"
 	NodeLabel            = "node"
+	NodeUIDLabel         = "node_uid"
 	InstanceLabel        = "instance"
 	InstanceTypeLabel    = "instance_type"
 	ContainerLabel       = "container"
 	PodLabel             = "pod"
+	PodUIDLabel          = "pod_uid"
 	PodNameLabel         = "pod_name"
+	PodVolumeNameLabel   = "pod_volume_name"
 	ProviderIDLabel      = "provider_id"
 	DeviceLabel          = "device"
 	PVCLabel             = "persistentvolumeclaim"
+	PVCUIDLabel          = "persistentvolumeclaim_uid"
 	PVLabel              = "persistentvolume"
+	CSIVolumeHandleLabel = "csi_volume_handle"
 	StorageClassLabel    = "storageclass"
 	VolumeNameLabel      = "volumename"
+	PVUIDLabel           = "persistentvolume_uid"
 	ServiceLabel         = "service"
 	ServiceNameLabel     = "service_name"
+	ServiceTypeLabel     = "service_type"
 	IngressIPLabel       = "ingress_ip"
 	ProvisionerNameLabel = "provisioner_name"
 	UIDLabel             = "uid"
 	KubernetesNodeLabel  = "kubernetes_node"
 	ModeLabel            = "mode"
 	ModelNameLabel       = "modelName"
+	HostNameLabel        = "Hostname"
 	UUIDLabel            = "UUID"
 	ResourceLabel        = "resource"
 	DeploymentLabel      = "deployment"
 	StatefulSetLabel     = "statefulSet"
+	DaemonSetLabel       = "daemonset"
+	JobLabel             = "job"
+	CronJobLabel         = "cronjob"
 	ReplicaSetLabel      = "replicaset"
 	ResourceQuotaLabel   = "resourcequota"
 	OwnerNameLabel       = "owner_name"
 	OwnerKindLabel       = "owner_kind"
+	OwnerUIDLabel        = "owner_uid"
+	ControllerLabel      = "controller"
 	UnitLabel            = "unit"
 	InternetLabel        = "internet"
 	SameZoneLabel        = "same_zone"
@@ -60,30 +74,7 @@ type UptimeResult struct {
 }
 
 func (res *UptimeResult) GetStartEnd(windowStart, windowEnd time.Time, resolution time.Duration) (time.Time, time.Time) {
-	first := res.First
-	last := res.Last
-	// The only corner-case here is what to do if you only get one timestamp.
-	// This dilemma still requires the use of the resolution, and can be
-	// clamped using the window. In this case, we want to honor the existence
-	// of the pod by giving "one resolution" worth of duration, half on each
-	// side of the given timestamp.
-	if first.Equal(last) {
-		first = first.Add(-1 * resolution / time.Duration(2))
-		last = last.Add(resolution / time.Duration(2))
-	}
-	if first.Before(windowStart) {
-		first = windowStart
-	}
-	if last.After(windowEnd) {
-		last = windowEnd
-	}
-	// prevent end times in the future
-	now := time.Now().UTC()
-	if last.After(now) {
-		last = now
-	}
-
-	return first, last
+	return getStartEnd(res.First, res.Last, windowStart, windowEnd, resolution)
 }
 
 func DecodeUptimeResult(result *QueryResult) *UptimeResult {
@@ -98,14 +89,78 @@ func DecodeUptimeResult(result *QueryResult) *UptimeResult {
 	}
 }
 
+// Container requires some special results because container name and pod UID is required to uniqly identify it
+
+type ContainerUptimeResult struct {
+	UptimeResult
+	Container string
+}
+
+func DecodeContainerUptimeResult(result *QueryResult) *ContainerUptimeResult {
+	container, _ := result.GetString(ContainerLabel)
+	ur := DecodeUptimeResult(result)
+	return &ContainerUptimeResult{
+		UptimeResult: *ur,
+		Container:    container,
+	}
+}
+
+type ContainerResourceResult struct {
+	ResourceResult
+	Container string
+}
+
+func DecodeContainerResourceResult(result *QueryResult) *ContainerResourceResult {
+	container, _ := result.GetString(ContainerLabel)
+	rr := DecodeResourceResult(result)
+	return &ContainerResourceResult{
+		ResourceResult: *rr,
+		Container:      container,
+	}
+}
+
+type LabelsResult struct {
+	UID     string
+	Cluster string
+	Labels  map[string]string
+}
+
+func DecodeLabelsResult(result *QueryResult) *LabelsResult {
+	uid, _ := result.GetString(UIDLabel)
+	cluster, _ := result.GetCluster()
+	labels := result.GetLabels()
+
+	return &LabelsResult{
+		UID:     uid,
+		Cluster: cluster,
+		Labels:  labels,
+	}
+}
+
+type AnnotationsResult struct {
+	UID         string
+	Cluster     string
+	Annotations map[string]string
+}
+
+func DecodeAnnotationsResult(result *QueryResult) *AnnotationsResult {
+	uid, _ := result.GetString(UIDLabel)
+	cluster, _ := result.GetCluster()
+	annotations := result.GetAnnotations()
+
+	return &AnnotationsResult{
+		UID:         uid,
+		Cluster:     cluster,
+		Annotations: annotations,
+	}
+}
+
 type PVResult struct {
-	UID              string
 	Cluster          string
 	PersistentVolume string
 }
 
 type PVUsedAvgResult struct {
-	UID                   string
 	Cluster               string
 	Namespace             string
 	PersistentVolumeClaim string
@@ -114,13 +169,11 @@ type PVUsedAvgResult struct {
 }
 
 func DecodePVUsedAvgResult(result *QueryResult) *PVUsedAvgResult {
-	uid, _ := result.GetString(UIDLabel)
 	cluster, _ := result.GetCluster()
 	namespace, _ := result.GetNamespace()
 	pvc, _ := result.GetString(PVCLabel)
 
 	return &PVUsedAvgResult{
-		UID:                   uid,
 		Cluster:               cluster,
 		Namespace:             namespace,
 		PersistentVolumeClaim: pvc,
@@ -150,7 +203,6 @@ func DecodePVActiveMinutesResult(result *QueryResult) *PVActiveMinutesResult {
 }
 
 type PVUsedMaxResult struct {
-	UID                   string
 	Cluster               string
 	Namespace             string
 	PersistentVolumeClaim string
@@ -158,13 +210,11 @@ type PVUsedMaxResult struct {
 }
 
 func DecodePVUsedMaxResult(result *QueryResult) *PVUsedMaxResult {
-	uid, _ := result.GetString(UIDLabel)
 	cluster, _ := result.GetCluster()
 	namespace, _ := result.GetNamespace()
 	pvc, _ := result.GetString(PVCLabel)
 
 	return &PVUsedMaxResult{
-		UID:                   uid,
 		Cluster:               cluster,
 		Namespace:             namespace,
 		PersistentVolumeClaim: pvc,
@@ -173,7 +223,6 @@ func DecodePVUsedMaxResult(result *QueryResult) *PVUsedMaxResult {
 }
 
 type LocalStorageActiveMinutesResult struct {
-	UID        string
 	Cluster    string
 	Node       string
 	ProviderID string
@@ -182,7 +231,6 @@ type LocalStorageActiveMinutesResult struct {
 }
 
 func DecodeLocalStorageActiveMinutesResult(result *QueryResult) *LocalStorageActiveMinutesResult {
-	uid, _ := result.GetString(UIDLabel)
 	cluster, _ := result.GetCluster()
 	node, _ := result.GetNode()
 	if node == "" {
@@ -191,7 +239,6 @@ func DecodeLocalStorageActiveMinutesResult(result *QueryResult) *LocalStorageAct
 	providerId, _ := result.GetProviderID()
 
 	return &LocalStorageActiveMinutesResult{
-		UID:        uid,
 		Cluster:    cluster,
 		Node:       node,
 		ProviderID: providerId,
@@ -200,7 +247,6 @@ func DecodeLocalStorageActiveMinutesResult(result *QueryResult) *LocalStorageAct
 }
 
 type LocalStorageCostResult struct {
-	UID      string
 	Cluster  string
 	Instance string
 	Device   string
@@ -209,13 +255,11 @@ type LocalStorageCostResult struct {
 }
 
 func DecodeLocalStorageCostResult(result *QueryResult) *LocalStorageCostResult {
-	uid, _ := result.GetString(UIDLabel)
 	cluster, _ := result.GetCluster()
 	instance, _ := result.GetInstance()
 	device, _ := result.GetDevice()
 
 	return &LocalStorageCostResult{
-		UID:      uid,
 		Cluster:  cluster,
 		Instance: instance,
 		Device:   device,
@@ -224,7 +268,6 @@ func DecodeLocalStorageCostResult(result *QueryResult) *LocalStorageCostResult {
 }
 
 type LocalStorageUsedCostResult struct {
-	UID      string
 	Cluster  string
 	Instance string
 	Device   string
@@ -232,13 +275,11 @@ type LocalStorageUsedCostResult struct {
 }
 
 func DecodeLocalStorageUsedCostResult(result *QueryResult) *LocalStorageUsedCostResult {
-	uid, _ := result.GetString(UIDLabel)
 	cluster, _ := result.GetCluster()
 	instance, _ := result.GetInstance()
 	device, _ := result.GetDevice()
 
 	return &LocalStorageUsedCostResult{
-		UID:      uid,
 		Cluster:  cluster,
 		Instance: instance,
 		Device:   device,
@@ -247,7 +288,6 @@ func DecodeLocalStorageUsedCostResult(result *QueryResult) *LocalStorageUsedCost
 }
 
 type LocalStorageUsedAvgResult struct {
-	UID      string
 	Cluster  string
 	Instance string
 	Device   string
@@ -255,13 +295,11 @@ type LocalStorageUsedAvgResult struct {
 }
 
 func DecodeLocalStorageUsedAvgResult(result *QueryResult) *LocalStorageUsedAvgResult {
-	uid, _ := result.GetString(UIDLabel)
 	cluster, _ := result.GetCluster()
 	instance, _ := result.GetInstance()
 	device, _ := result.GetDevice()
 
 	return &LocalStorageUsedAvgResult{
-		UID:      uid,
 		Cluster:  cluster,
 		Instance: instance,
 		Device:   device,
@@ -270,7 +308,6 @@ func DecodeLocalStorageUsedAvgResult(result *QueryResult) *LocalStorageUsedAvgRe
 }
 
 type LocalStorageUsedMaxResult struct {
-	UID      string
 	Cluster  string
 	Instance string
 	Device   string
@@ -278,13 +315,11 @@ type LocalStorageUsedMaxResult struct {
 }
 
 func DecodeLocalStorageUsedMaxResult(result *QueryResult) *LocalStorageUsedMaxResult {
-	uid, _ := result.GetString(UIDLabel)
 	cluster, _ := result.GetCluster()
 	instance, _ := result.GetInstance()
 	device, _ := result.GetDevice()
 
 	return &LocalStorageUsedMaxResult{
-		UID:      uid,
 		Cluster:  cluster,
 		Instance: instance,
 		Device:   device,
@@ -293,7 +328,6 @@ func DecodeLocalStorageUsedMaxResult(result *QueryResult) *LocalStorageUsedMaxRe
 }
 
 type LocalStorageBytesResult struct {
-	UID      string
 	Cluster  string
 	Instance string
 	Device   string
@@ -301,13 +335,11 @@ type LocalStorageBytesResult struct {
 }
 
 func DecodeLocalStorageBytesResult(result *QueryResult) *LocalStorageBytesResult {
-	uid, _ := result.GetString(UIDLabel)
 	cluster, _ := result.GetCluster()
 	instance, _ := result.GetInstance()
 	device, _ := result.GetDevice()
 
 	return &LocalStorageBytesResult{
-		UID:      uid,
 		Cluster:  cluster,
 		Instance: instance,
 		Device:   device,
@@ -315,6 +347,30 @@ func DecodeLocalStorageBytesResult(result *QueryResult) *LocalStorageBytesResult
 	}
 }
 
+type NodeInfoResult struct {
+	UID          string
+	Cluster      string
+	Node         string
+	ProviderID   string
+	InstanceType string
+}
+
+func DecodeNodeInfoResult(result *QueryResult) *NodeInfoResult {
+	uid, _ := result.GetString(UIDLabel)
+	cluster, _ := result.GetCluster()
+	node, _ := result.GetNode()
+	providerId, _ := result.GetProviderID()
+	instanceType, _ := result.GetInstanceType()
+
+	return &NodeInfoResult{
+		UID:          uid,
+		Cluster:      cluster,
+		Node:         node,
+		ProviderID:   providerId,
+		InstanceType: instanceType,
+	}
+}
+
 type NodeActiveMinutesResult struct {
 	UID        string
 	Cluster    string
@@ -339,10 +395,10 @@ func DecodeNodeActiveMinutesResult(result *QueryResult) *NodeActiveMinutesResult
 }
 
 type NodeCPUCoresCapacityResult struct {
-	UID     string
-	Cluster string
-	Node    string
-	Data    []*util.Vector
+	UID      string
+	Cluster  string
+	Node     string
+	CPUCores float64
 }
 
 func DecodeNodeCPUCoresCapacityResult(result *QueryResult) *NodeCPUCoresCapacityResult {
@@ -351,10 +407,10 @@ func DecodeNodeCPUCoresCapacityResult(result *QueryResult) *NodeCPUCoresCapacity
 	node, _ := result.GetNode()
 
 	return &NodeCPUCoresCapacityResult{
-		UID:     uid,
-		Cluster: cluster,
-		Node:    node,
-		Data:    result.Values,
+		UID:      uid,
+		Cluster:  cluster,
+		Node:     node,
+		CPUCores: result.Values[0].Value,
 	}
 }
 
@@ -365,22 +421,23 @@ func DecodeNodeCPUCoresAllocatableResult(result *QueryResult) *NodeCPUCoresAlloc
 }
 
 type NodeRAMBytesCapacityResult struct {
-	UID     string
-	Cluster string
-	Node    string
-	Data    []*util.Vector
+	UID      string
+	Cluster  string
+	Node     string
+	RAMBytes float64
 }
 
 func DecodeNodeRAMBytesCapacityResult(result *QueryResult) *NodeRAMBytesCapacityResult {
 	uid, _ := result.GetString(UIDLabel)
 	cluster, _ := result.GetCluster()
 	node, _ := result.GetNode()
+	bytes := result.Values[0].Value
 
 	return &NodeRAMBytesCapacityResult{
-		UID:     uid,
-		Cluster: cluster,
-		Node:    node,
-		Data:    result.Values,
+		UID:      uid,
+		Cluster:  cluster,
+		Node:     node,
+		RAMBytes: bytes,
 	}
 }
 
@@ -395,8 +452,7 @@ type NodeGPUCountResult struct {
 	Cluster    string
 	Node       string
 	ProviderID string
-
-	Data []*util.Vector
+	GPUCount   float64
 }
 
 func DecodeNodeGPUCountResult(result *QueryResult) *NodeGPUCountResult {
@@ -410,7 +466,7 @@ func DecodeNodeGPUCountResult(result *QueryResult) *NodeGPUCountResult {
 		Cluster:    cluster,
 		Node:       node,
 		ProviderID: providerId,
-		Data:       result.Values,
+		GPUCount:   result.Values[0].Value,
 	}
 }
 
@@ -519,6 +575,33 @@ func DecodeLBPricePerHrResult(result *QueryResult) *LBPricePerHrResult {
 	return DecodeLBActiveMinutesResult(result)
 }
 
+type ClusterInfoResult struct {
+	UID         string
+	Cluster     string
+	Provider    string
+	AccountID   string
+	Provisioner string
+	Region      string
+}
+
+func DecodeClusterInfoResult(result *QueryResult) *ClusterInfoResult {
+	uid, _ := result.GetString(UIDLabel)
+	cluster, _ := result.GetString(ClusterNameLabel)
+	provider, _ := result.GetString(ProviderLabel)
+	accountID, _ := result.GetString(AccountIDLabel)
+	provisioner, _ := result.GetString(ProvisionerNameLabel)
+	region, _ := result.GetString(RegionLabel)
+
+	return &ClusterInfoResult{
+		UID:         uid,
+		Cluster:     cluster,
+		Provider:    provider,
+		AccountID:   accountID,
+		Provisioner: provisioner,
+		Region:      region,
+	}
+}
+
 type ClusterManagementDurationResult struct {
 	UID         string
 	Cluster     string
@@ -545,6 +628,79 @@ func DecodeClusterManagementPricePerHrResult(result *QueryResult) *ClusterManage
 	return DecodeClusterManagementDurationResult(result)
 }
 
+type PodInfoResult struct {
+	UID          string
+	Cluster      string
+	Pod          string
+	NamespaceUID string
+	NodeUID      string
+}
+
+func DecodePodInfoResult(result *QueryResult) *PodInfoResult {
+	uid, _ := result.GetString(UIDLabel)
+	cluster, _ := result.GetCluster()
+	pod, _ := result.GetPod()
+	namespaceUID, _ := result.GetString(NamespaceUIDLabel)
+	nodeUID, _ := result.GetString(NodeUIDLabel)
+
+	return &PodInfoResult{
+		UID:          uid,
+		Cluster:      cluster,
+		Pod:          pod,
+		NamespaceUID: namespaceUID,
+		NodeUID:      nodeUID,
+	}
+}
+
+type PodPVCVolumeResult struct {
+	UID           string
+	Cluster       string
+	PVCUID        string
+	PodVolumeName string
+}
+
+func DecodePodPVCVolumeResult(result *QueryResult) *PodPVCVolumeResult {
+	uid, _ := result.GetString(UIDLabel)
+	cluster, _ := result.GetCluster()
+	pvcUID, _ := result.GetString(PVCUIDLabel)
+	podVolumeName, _ := result.GetString(PodVolumeNameLabel)
+
+	return &PodPVCVolumeResult{
+		UID:           uid,
+		Cluster:       cluster,
+		PVCUID:        pvcUID,
+		PodVolumeName: podVolumeName,
+	}
+}
+
+type OwnerResult struct {
+	UID        string
+	Cluster    string
+	OwnerUID   string
+	OwnerKind  string
+	Controller bool
+}
+
+func DecodeOwnerResult(result *QueryResult) *OwnerResult {
+	uid, _ := result.GetString(UIDLabel)
+	cluster, _ := result.GetCluster()
+	ownerUID, _ := result.GetString(OwnerUIDLabel)
+	ownerKind, _ := result.GetString(OwnerKindLabel)
+	controllerStr, _ := result.GetString(ControllerLabel)
+	controller := false
+	if controllerStr == "true" {
+		controller = true
+	}
+
+	return &OwnerResult{
+		UID:        uid,
+		Cluster:    cluster,
+		OwnerUID:   ownerUID,
+		OwnerKind:  ownerKind,
+		Controller: controller,
+	}
+}
+
 type PodsResult struct {
 	UID       string
 	Cluster   string
@@ -847,7 +1003,7 @@ type GPUInfoResult struct {
 }
 
 func DecodeGPUInfoResult(result *QueryResult) *GPUInfoResult {
-	uid, _ := result.GetString(UIDLabel)
+	uid, _ := result.GetString(PodUIDLabel)
 	cluster, _ := result.GetCluster()
 	namespace, _ := result.GetNamespace()
 	pod, _ := result.GetPod()
@@ -954,18 +1110,21 @@ func DecodePVCBytesRequestedResult(result *QueryResult) *PVCBytesRequestedResult
 type PVCInfoResult struct {
 	UID                   string
 	Cluster               string
+	NamespaceUID          string
 	Namespace             string
 	VolumeName            string
+	PVUID                 string
 	PersistentVolumeClaim string
 	StorageClass          string
-
-	Data []*util.Vector
+	Data                  []*util.Vector
 }
 
 func DecodePVCInfoResult(result *QueryResult) *PVCInfoResult {
 	uid, _ := result.GetString(UIDLabel)
 	cluster, _ := result.GetCluster()
+	namespaceUID, _ := result.GetString(NamespaceUIDLabel)
 	namespace, _ := result.GetNamespace()
+	pvUID, _ := result.GetString(PVUIDLabel)
 	volumeName, _ := result.GetString(VolumeNameLabel)
 	pvc, _ := result.GetString(PVCLabel)
 	storageClass, _ := result.GetString(StorageClassLabel)
@@ -973,11 +1132,14 @@ func DecodePVCInfoResult(result *QueryResult) *PVCInfoResult {
 	return &PVCInfoResult{
 		UID:                   uid,
 		Cluster:               cluster,
+		NamespaceUID:          namespaceUID,
 		Namespace:             namespace,
+		PVUID:                 pvUID,
 		VolumeName:            volumeName,
 		PersistentVolumeClaim: pvc,
 		StorageClass:          storageClass,
-		Data:                  result.Values,
+
+		Data: result.Values,
 	}
 }
 
@@ -985,20 +1147,23 @@ type PVBytesResult struct {
 	UID              string
 	Cluster          string
 	PersistentVolume string
-
-	Data []*util.Vector
+	Value            float64
 }
 
 func DecodePVBytesResult(result *QueryResult) *PVBytesResult {
 	uid, _ := result.GetString(UIDLabel)
 	cluster, _ := result.GetCluster()
 	pv, _ := result.GetString(PVLabel)
+	var value float64
+	if len(result.Values) > 0 {
+		value = result.Values[0].Value
+	}
 
 	return &PVBytesResult{
 		UID:              uid,
 		Cluster:          cluster,
 		PersistentVolume: pv,
-		Data:             result.Values,
+		Value:            value,
 	}
 }
 
@@ -1036,8 +1201,8 @@ type PVInfoResult struct {
 	PersistentVolume string
 	StorageClass     string
 	ProviderID       string
-
-	Data []*util.Vector
+	CSIVolumeHandle  string
+	Data             []*util.Vector
 }
 
 func DecodePVInfoResult(result *QueryResult) *PVInfoResult {
@@ -1046,6 +1211,7 @@ func DecodePVInfoResult(result *QueryResult) *PVInfoResult {
 	storageClass, _ := result.GetString(StorageClassLabel)
 	providerId, _ := result.GetProviderID()
 	pv, _ := result.GetString(PVLabel)
+	csiVolumeHandle, _ := result.GetString(CSIVolumeHandleLabel)
 
 	return &PVInfoResult{
 		UID:              uid,
@@ -1053,7 +1219,45 @@ func DecodePVInfoResult(result *QueryResult) *PVInfoResult {
 		PersistentVolume: pv,
 		StorageClass:     storageClass,
 		ProviderID:       providerId,
-		Data:             result.Values,
+		CSIVolumeHandle:  csiVolumeHandle,
+
+		Data: result.Values,
+	}
+}
+
+type PodNetworkBytesResult struct {
+	UID        string
+	Cluster    string
+	Service    string
+	Internet   bool
+	SameRegion bool
+	SameZone   bool
+	NatGateway bool
+	Value      float64
+}
+
+func DecodePodNetworkBytesResult(result *QueryResult) *PodNetworkBytesResult {
+	uid, _ := result.GetString(UIDLabel)
+	cluster, _ := result.GetCluster()
+	service, _ := result.GetString(ServiceLabel)
+	internet, _ := result.GetString(InternetLabel)
+	sameRegion, _ := result.GetString(SameRegionLabel)
+	sameZone, _ := result.GetString(SameZoneLabel)
+	natGateway, _ := result.GetString(NatGatewayLabel)
+	var value float64
+	if len(result.Values) > 0 {
+		value = result.Values[0].Value
+	}
+
+	return &PodNetworkBytesResult{
+		UID:        uid,
+		Cluster:    cluster,
+		Service:    service,
+		Internet:   internet == "true",
+		SameRegion: sameRegion == "true",
+		SameZone:   sameZone == "true",
+		NatGateway: natGateway == "true",
+		Value:      value,
 	}
 }
 
@@ -1118,7 +1322,6 @@ type NetInternetServiceGiBResult = NetworkGiBResult
 
 type NetNatGatewayPricePerGiBResult = NetworkPricePerGiBResult
 type NetNatGatewayGiBResult = NetworkGiBResult
-
 type NetZoneIngressGiBResult = NetworkGiBResult
 type NetRegionIngressGiBResult = NetworkGiBResult
 type NetInternetIngressGiBResult = NetworkGiBResult
@@ -1290,7 +1493,6 @@ type NodeLabelsResult struct {
 	Cluster string
 	Node    string
 	Labels  map[string]string
-	Data    []*util.Vector
 }
 
 func DecodeNodeLabelsResult(result *QueryResult) *NodeLabelsResult {
@@ -1304,7 +1506,24 @@ func DecodeNodeLabelsResult(result *QueryResult) *NodeLabelsResult {
 		Cluster: cluster,
 		Node:    node,
 		Labels:  labels,
-		Data:    result.Values,
+	}
+}
+
+type NamespaceInfoResult struct {
+	UID       string
+	Cluster   string
+	Namespace string
+}
+
+func DecodeNamespaceInfoResult(result *QueryResult) *NamespaceInfoResult {
+	uid, _ := result.GetString(UIDLabel)
+	cluster, _ := result.GetCluster()
+	namespace, _ := result.GetNamespace()
+
+	return &NamespaceInfoResult{
+		UID:       uid,
+		Cluster:   cluster,
+		Namespace: namespace,
 	}
 }
 
@@ -1384,6 +1603,51 @@ func DecodeServiceLabelsResult(result *QueryResult) *ServiceLabelsResult {
 	}
 }
 
+type ServiceInfoResult struct {
+	UID          string
+	Cluster      string
+	NamespaceUID string
+	Service      string
+	ServiceType  string
+}
+
+func DecodeServiceInfoResult(result *QueryResult) *ServiceInfoResult {
+	uid, _ := result.GetString(UIDLabel)
+	cluster, _ := result.GetCluster()
+	namespaceUID, _ := result.GetString(NamespaceUIDLabel)
+	service, _ := result.GetString(ServiceLabel)
+	serviceType, _ := result.GetString(ServiceTypeLabel)
+
+	return &ServiceInfoResult{
+		UID:          uid,
+		Cluster:      cluster,
+		NamespaceUID: namespaceUID,
+		Service:      service,
+		ServiceType:  serviceType,
+	}
+}
+
+type DeploymentInfoResult struct {
+	UID          string
+	Cluster      string
+	NamespaceUID string
+	Deployment   string
+}
+
+func DecodeDeploymentInfoResult(result *QueryResult) *DeploymentInfoResult {
+	uid, _ := result.GetString(UIDLabel)
+	cluster, _ := result.GetCluster()
+	namespaceUID, _ := result.GetString(NamespaceUIDLabel)
+	deployment, _ := result.GetString(DeploymentLabel)
+
+	return &DeploymentInfoResult{
+		UID:          uid,
+		Cluster:      cluster,
+		NamespaceUID: namespaceUID,
+		Deployment:   deployment,
+	}
+}
+
 type DeploymentLabelsResult struct {
 	UID        string
 	Cluster    string
@@ -1410,6 +1674,111 @@ func DecodeDeploymentLabelsResult(result *QueryResult) *DeploymentLabelsResult {
 	}
 }
 
+type StatefulSetInfoResult struct {
+	UID          string
+	Cluster      string
+	NamespaceUID string
+	StatefulSet  string
+}
+
+func DecodeStatefulSetInfoResult(result *QueryResult) *StatefulSetInfoResult {
+	uid, _ := result.GetString(UIDLabel)
+	cluster, _ := result.GetCluster()
+	namespaceUID, _ := result.GetString(NamespaceUIDLabel)
+	statefulSet, _ := result.GetString(StatefulSetLabel)
+
+	return &StatefulSetInfoResult{
+		UID:          uid,
+		Cluster:      cluster,
+		NamespaceUID: namespaceUID,
+		StatefulSet:  statefulSet,
+	}
+}
+
+type DaemonSetInfoResult struct {
+	UID          string
+	Cluster      string
+	NamespaceUID string
+	DaemonSet    string
+}
+
+func DecodeDaemonSetInfoResult(result *QueryResult) *DaemonSetInfoResult {
+	uid, _ := result.GetString(UIDLabel)
+	cluster, _ := result.GetCluster()
+	namespaceUID, _ := result.GetString(NamespaceUIDLabel)
+	daemonSet, _ := result.GetString(DaemonSetLabel)
+
+	return &DaemonSetInfoResult{
+		UID:          uid,
+		Cluster:      cluster,
+		NamespaceUID: namespaceUID,
+		DaemonSet:    daemonSet,
+	}
+}
+
+type JobInfoResult struct {
+	UID          string
+	Cluster      string
+	NamespaceUID string
+	Job          string
+}
+
+func DecodeJobInfoResult(result *QueryResult) *JobInfoResult {
+	uid, _ := result.GetString(UIDLabel)
+	cluster, _ := result.GetCluster()
+	namespaceUID, _ := result.GetString(NamespaceUIDLabel)
+	job, _ := result.GetString(JobLabel)
+
+	return &JobInfoResult{
+		UID:          uid,
+		Cluster:      cluster,
+		NamespaceUID: namespaceUID,
+		Job:          job,
+	}
+}
+
+type CronJobInfoResult struct {
+	UID          string
+	Cluster      string
+	NamespaceUID string
+	CronJob      string
+}
+
+func DecodeCronJobInfoResult(result *QueryResult) *CronJobInfoResult {
+	uid, _ := result.GetString(UIDLabel)
+	cluster, _ := result.GetCluster()
+	namespaceUID, _ := result.GetString(NamespaceUIDLabel)
+	cronJob, _ := result.GetString(CronJobLabel)
+
+	return &CronJobInfoResult{
+		UID:          uid,
+		Cluster:      cluster,
+		NamespaceUID: namespaceUID,
+		CronJob:      cronJob,
+	}
+}
+
+type ReplicaSetInfoResult struct {
+	UID          string
+	Cluster      string
+	NamespaceUID string
+	ReplicaSet   string
+}
+
+func DecodeReplicaSetInfoResult(result *QueryResult) *ReplicaSetInfoResult {
+	uid, _ := result.GetString(UIDLabel)
+	cluster, _ := result.GetCluster()
+	namespaceUID, _ := result.GetString(NamespaceUIDLabel)
+	replicaSet, _ := result.GetString(ReplicaSetLabel)
+
+	return &ReplicaSetInfoResult{
+		UID:          uid,
+		Cluster:      cluster,
+		NamespaceUID: namespaceUID,
+		ReplicaSet:   replicaSet,
+	}
+}
+
 type StatefulSetLabelsResult struct {
 	UID         string
 	Cluster     string
@@ -1436,61 +1805,51 @@ func DecodeStatefulSetLabelsResult(result *QueryResult) *StatefulSetLabelsResult
 	}
 }
 
-type DaemonSetLabelsResult struct {
+type PodsWithDaemonSetOwnerResult struct {
 	UID       string
 	Cluster   string
 	Namespace string
 	Pod       string
 	DaemonSet string
-	Labels    map[string]string
-	Data      []*util.Vector
 }
 
-func DecodeDaemonSetLabelsResult(result *QueryResult) *DaemonSetLabelsResult {
+func DecodePodsWithDaemonSetOwnerResult(result *QueryResult) *PodsWithDaemonSetOwnerResult {
 	uid, _ := result.GetString(UIDLabel)
 	cluster, _ := result.GetCluster()
 	namespace, _ := result.GetNamespace()
 	pod, _ := result.GetPod()
 	daemonSet, _ := result.GetString(OwnerNameLabel)
-	labels := result.GetLabels()
 
-	return &DaemonSetLabelsResult{
+	return &PodsWithDaemonSetOwnerResult{
 		UID:       uid,
 		Cluster:   cluster,
 		Namespace: namespace,
 		Pod:       pod,
 		DaemonSet: daemonSet,
-		Labels:    labels,
-		Data:      result.Values,
 	}
 }
 
-type JobLabelsResult struct {
+type PodsWithJobOwnerResult struct {
 	UID       string
 	Cluster   string
 	Namespace string
 	Pod       string
 	Job       string
-	Labels    map[string]string
-	Data      []*util.Vector
 }
 
-func DecodeJobLabelsResult(result *QueryResult) *JobLabelsResult {
+func DecodePodsWithJobOwnerResult(result *QueryResult) *PodsWithJobOwnerResult {
 	uid, _ := result.GetString(UIDLabel)
 	cluster, _ := result.GetCluster()
 	namespace, _ := result.GetNamespace()
 	pod, _ := result.GetPod()
 	job, _ := result.GetString(OwnerNameLabel)
-	labels := result.GetLabels()
 
-	return &JobLabelsResult{
+	return &PodsWithJobOwnerResult{
 		UID:       uid,
 		Cluster:   cluster,
 		Namespace: namespace,
 		Pod:       pod,
 		Job:       job,
-		Labels:    labels,
-		Data:      result.Values,
 	}
 }
 
@@ -1567,126 +1926,139 @@ func DecodeReplicaSetsWithRolloutResult(result *QueryResult) *ReplicaSetsWithRol
 	}
 }
 
-type ResourceQuotaMetricResult struct {
+type ResourceQuotaInfoResult struct {
 	UID           string
-	Namespace     string
+	NamespaceUID  string
 	ResourceQuota string
-	Resource      string
-	Unit          string
-	Data          []*util.Vector
 }
 
-func DecodeResourceQuotaMetricResult(result *QueryResult) *ResourceQuotaMetricResult {
+func DecodeResourceQuotaInfoResult(result *QueryResult) *ResourceQuotaInfoResult {
 	uid, _ := result.GetString(UIDLabel)
-	namespace, _ := result.GetNamespace()
+	namespaceUID, _ := result.GetString(NamespaceUIDLabel)
 	resourceQuota, _ := result.GetString(ResourceQuotaLabel)
-	resource, _ := result.GetString(ResourceLabel)
-	unit, _ := result.GetString(UnitLabel)
 
-	return &ResourceQuotaMetricResult{
+	return &ResourceQuotaInfoResult{
 		UID:           uid,
-		Namespace:     namespace,
+		NamespaceUID:  namespaceUID,
 		ResourceQuota: resourceQuota,
-		Resource:      resource,
-		Unit:          unit,
-		Data:          result.Values,
 	}
 }
 
-type ResourceQuotaSpecCPURequestAvgResult = ResourceQuotaMetricResult
-
-func DecodeResourceQuotaSpecCPURequestAvgResult(result *QueryResult) *ResourceQuotaSpecCPURequestAvgResult {
-	return DecodeResourceQuotaMetricResult(result)
-}
-
-type ResourceQuotaSpecCPURequestMaxResult = ResourceQuotaMetricResult
-
-func DecodeResourceQuotaSpecCPURequestMaxResult(result *QueryResult) *ResourceQuotaSpecCPURequestMaxResult {
-	return DecodeResourceQuotaMetricResult(result)
-}
-
-type ResourceQuotaSpecRAMRequestAvgResult = ResourceQuotaMetricResult
-
-func DecodeResourceQuotaSpecRAMRequestAvgResult(result *QueryResult) *ResourceQuotaSpecRAMRequestAvgResult {
-	return DecodeResourceQuotaMetricResult(result)
-}
-
-type ResourceQuotaSpecRAMRequestMaxResult = ResourceQuotaMetricResult
-
-func DecodeResourceQuotaSpecRAMRequestMaxResult(result *QueryResult) *ResourceQuotaSpecRAMRequestMaxResult {
-	return DecodeResourceQuotaMetricResult(result)
-}
-
-type ResourceQuotaSpecCPULimitAvgResult = ResourceQuotaMetricResult
-
-func DecodeResourceQuotaSpecCPULimitAvgResult(result *QueryResult) *ResourceQuotaSpecCPULimitAvgResult {
-	return DecodeResourceQuotaMetricResult(result)
-}
-
-type ResourceQuotaSpecCPULimitMaxResult = ResourceQuotaMetricResult
-
-func DecodeResourceQuotaSpecCPULimitMaxResult(result *QueryResult) *ResourceQuotaSpecCPULimitMaxResult {
-	return DecodeResourceQuotaMetricResult(result)
-}
-
-type ResourceQuotaSpecRAMLimitAvgResult = ResourceQuotaMetricResult
-
-func DecodeResourceQuotaSpecRAMLimitAvgResult(result *QueryResult) *ResourceQuotaSpecRAMLimitAvgResult {
-	return DecodeResourceQuotaMetricResult(result)
+type ResourceResult struct {
+	UID      string
+	Resource string
+	Unit     string
+	Value    float64
 }
 
-type ResourceQuotaSpecRAMLimitMaxResult = ResourceQuotaMetricResult
+func DecodeResourceResult(result *QueryResult) *ResourceResult {
+	uid, _ := result.GetString(UIDLabel)
+	resource, _ := result.GetString(ResourceLabel)
+	unit, _ := result.GetString(UnitLabel)
+	var value float64
+	if len(result.Values) > 0 {
+		value = result.Values[0].Value
+	}
 
-func DecodeResourceQuotaSpecRAMLimitMaxResult(result *QueryResult) *ResourceQuotaSpecRAMLimitMaxResult {
-	return DecodeResourceQuotaMetricResult(result)
+	return &ResourceResult{
+		UID:      uid,
+		Resource: resource,
+		Unit:     unit,
+		Value:    value,
+	}
 }
 
-type ResourceQuotaStatusUsedCPURequestAvgResult = ResourceQuotaMetricResult
-
-func DecodeResourceQuotaStatusUsedCPURequestAvgResult(result *QueryResult) *ResourceQuotaStatusUsedCPURequestAvgResult {
-	return DecodeResourceQuotaMetricResult(result)
+// DCGM needs specialized results because it uses UUID instead of the uid label that we use.
+type DCGMDeviceInfoResult struct {
+	UUID      string
+	Device    string
+	ModelName string
+	HostName  string
 }
 
-type ResourceQuotaStatusUsedCPURequestMaxResult = ResourceQuotaMetricResult
+func DecodeDCGMDeviceInfoResult(result *QueryResult) *DCGMDeviceInfoResult {
+	uuid, _ := result.GetString(UUIDLabel)
+	device, _ := result.GetString(DeviceLabel)
+	modelName, _ := result.GetString(ModelNameLabel)
+	hostName, _ := result.GetString(HostNameLabel)
 
-func DecodeResourceQuotaStatusUsedCPURequestMaxResult(result *QueryResult) *ResourceQuotaStatusUsedCPURequestMaxResult {
-	return DecodeResourceQuotaMetricResult(result)
+	return &DCGMDeviceInfoResult{
+		UUID:      uuid,
+		Device:    device,
+		ModelName: modelName,
+		HostName:  hostName,
+	}
 }
 
-type ResourceQuotaStatusUsedRAMRequestAvgResult = ResourceQuotaMetricResult
-
-func DecodeResourceQuotaStatusUsedRAMRequestAvgResult(result *QueryResult) *ResourceQuotaStatusUsedRAMRequestAvgResult {
-	return DecodeResourceQuotaMetricResult(result)
+type DCGMDeviceUptimeResult struct {
+	UUID  string
+	First time.Time
+	Last  time.Time
 }
 
-type ResourceQuotaStatusUsedRAMRequestMaxResult = ResourceQuotaMetricResult
-
-func DecodeResourceQuotaStatusUsedRAMRequestMaxResult(result *QueryResult) *ResourceQuotaStatusUsedRAMRequestMaxResult {
-	return DecodeResourceQuotaMetricResult(result)
+func (res *DCGMDeviceUptimeResult) GetStartEnd(windowStart, windowEnd time.Time, resolution time.Duration) (time.Time, time.Time) {
+	return getStartEnd(res.First, res.Last, windowStart, windowEnd, resolution)
 }
 
-type ResourceQuotaStatusUsedCPULimitAvgResult = ResourceQuotaMetricResult
+func getStartEnd(first, last, windowStart, windowEnd time.Time, resolution time.Duration) (time.Time, time.Time) {
+	// The only corner-case here is what to do if you only get one timestamp.
+	// This dilemma still requires the use of the resolution, and can be
+	// clamped using the window. In this case, we want to honor the existence
+	// of the pod by giving "one resolution" worth of duration, half on each
+	// side of the given timestamp.
+	if first.Equal(last) {
+		first = first.Add(-1 * resolution / time.Duration(2))
+		last = last.Add(resolution / time.Duration(2))
+	}
+	if first.Before(windowStart) {
+		first = windowStart
+	}
+	if last.After(windowEnd) {
+		last = windowEnd
+	}
+	// prevent end times in the future
+	now := time.Now().UTC()
+	if last.After(now) {
+		last = now
+	}
 
-func DecodeResourceQuotaStatusUsedCPULimitAvgResult(result *QueryResult) *ResourceQuotaStatusUsedCPULimitAvgResult {
-	return DecodeResourceQuotaMetricResult(result)
+	return first, last
 }
 
-type ResourceQuotaStatusUsedCPULimitMaxResult = ResourceQuotaMetricResult
+func DecodeDCGMDeviceUptimeResult(result *QueryResult) *DCGMDeviceUptimeResult {
+	uuid, _ := result.GetString(UUIDLabel)
+	first := time.Unix(int64(result.Values[0].Timestamp), 0).UTC()
+	last := time.Unix(int64(result.Values[len(result.Values)-1].Timestamp), 0).UTC()
 
-func DecodeResourceQuotaStatusUsedCPULimitMaxResult(result *QueryResult) *ResourceQuotaStatusUsedCPULimitMaxResult {
-	return DecodeResourceQuotaMetricResult(result)
+	return &DCGMDeviceUptimeResult{
+		UUID:  uuid,
+		First: first,
+		Last:  last,
+	}
 }
 
-type ResourceQuotaStatusUsedRAMLimitAvgResult = ResourceQuotaMetricResult
-
-func DecodeResourceQuotaStatusUsedRAMLimitAvgResult(result *QueryResult) *ResourceQuotaStatusUsedRAMLimitAvgResult {
-	return DecodeResourceQuotaMetricResult(result)
+type DCGMDeviceContainerUsageResult struct {
+	UUID      string
+	PodUID    string
+	Container string
+	Value     float64
 }
 
-type ResourceQuotaStatusUsedRAMLimitMaxResult = ResourceQuotaMetricResult
+func DecodeDCGMDeviceContainerUsageResult(result *QueryResult) *DCGMDeviceContainerUsageResult {
+	uuid, _ := result.GetString(UUIDLabel)
+	podUID, _ := result.GetString(PodUIDLabel)
+	container, _ := result.GetString(ContainerLabel)
+	var value float64
+	if len(result.Values) > 0 {
+		value = result.Values[0].Value
+	}
 
-func DecodeResourceQuotaStatusUsedRAMLimitMaxResult(result *QueryResult) *ResourceQuotaStatusUsedRAMLimitMaxResult {
-	return DecodeResourceQuotaMetricResult(result)
+	return &DCGMDeviceContainerUsageResult{
+		UUID:      uuid,
+		PodUID:    podUID,
+		Container: container,
+		Value:     value,
+	}
 }
 
 func DecodeAll[T any](results []*QueryResult, decode ResultDecoder[T]) []*T {

+ 669 - 14
modules/collector-source/pkg/collector/collector.go

@@ -16,12 +16,19 @@ func NewOpenCostMetricStore() metric.MetricStore {
 	memStore.Register(NewPVUsedAverageMetricCollector())
 	memStore.Register(NewPVUsedMaxMetricCollector())
 	memStore.Register(NewPVCInfoMetricCollector())
+	memStore.Register(NewPVInfoMetricCollector())
+	memStore.Register(NewPVUptimeMetricCollector())
 	memStore.Register(NewPVActiveMinutesMetricCollector())
+	memStore.Register(NewPVBytesMetricCollector())
 	memStore.Register(NewLocalStorageUsedActiveMinutesMetricCollector())
 	memStore.Register(NewLocalStorageUsedAverageMetricCollector())
 	memStore.Register(NewLocalStorageUsedMaxMetricCollector())
 	memStore.Register(NewLocalStorageBytesMetricCollector())
 	memStore.Register(NewLocalStorageActiveMinutesMetricCollector())
+	memStore.Register(NewNodeInfoMetricCollector())
+	memStore.Register(NewNodeUptimeMetricCollector())
+	memStore.Register(NewNodeResourceCapacitiesMetricCollector())
+	memStore.Register(NewNodeResourcesAllocatableMetricCollector())
 	memStore.Register(NewNodeCPUCoresCapacityMetricCollector())
 	memStore.Register(NewNodeCPUCoresAllocatableMetricCollector())
 	memStore.Register(NewNodeRAMBytesCapacityMetricCollector())
@@ -34,6 +41,7 @@ func NewOpenCostMetricStore() metric.MetricStore {
 	memStore.Register(NewNodeRAMUserUsageAverageMetricCollector())
 	memStore.Register(NewLBPricePerHourMetricCollector())
 	memStore.Register(NewLBActiveMinutesMetricCollector())
+	memStore.Register(NewClusterInfoMetricCollector())
 	memStore.Register(NewClusterUptimeMetricCollector())
 	memStore.Register(NewClusterManagementDurationMetricCollector())
 	memStore.Register(NewClusterManagementPricePerHourMetricCollector())
@@ -54,14 +62,16 @@ func NewOpenCostMetricStore() metric.MetricStore {
 	memStore.Register(NewGPUsAllocatedMetricCollector())
 	memStore.Register(NewIsGPUSharedMetricCollector())
 	memStore.Register(NewGPUInfoMetricCollector())
+	memStore.Register(NewDCGMInfoMetricCollector())
+	memStore.Register(NewDCGMUptimeMetricCollector())
+	memStore.Register(NewDCGMContainerUsageAvgMetricCollector())
+	memStore.Register(NewDCGMContainerUsageMaxMetricCollector())
 	memStore.Register(NewNodeCPUPricePerHourMetricCollector())
 	memStore.Register(NewNodeRAMPricePerGiBHourMetricCollector())
 	memStore.Register(NewNodeGPUPricePerHourMetricCollector())
 	memStore.Register(NewNodeIsSpotMetricCollector())
 	memStore.Register(NewPodPVCAllocationMetricCollector())
 	memStore.Register(NewPVCBytesRequestedMetricCollector())
-	memStore.Register(NewPVBytesMetricCollector())
-	memStore.Register(NewPVInfoMetricCollector())
 	memStore.Register(NewNetZoneGiBMetricCollector())
 	memStore.Register(NewNetZonePricePerGiBMetricCollector())
 	memStore.Register(NewNetRegionGiBMetricCollector())
@@ -79,19 +89,55 @@ func NewOpenCostMetricStore() metric.MetricStore {
 	memStore.Register(NewNetNatGatewayIngressPricePerGiBMetricCollector())
 	memStore.Register(NewNetNatGatewayIngressGiBMetricCollector())
 	memStore.Register(NewNetTransferBytesMetricCollector())
+	memStore.Register(NewNamespaceInfoMetricCollector())
 	memStore.Register(NewNamespaceUptimeMetricCollector())
 	memStore.Register(NewNamespaceLabelsMetricCollector())
 	memStore.Register(NewNamespaceAnnotationsMetricCollector())
 	memStore.Register(NewPodLabelsMetricCollector())
 	memStore.Register(NewPodAnnotationsMetricCollector())
 	memStore.Register(NewServiceLabelsMetricCollector())
+	memStore.Register(NewDeploymentInfoMetricCollector())
+	memStore.Register(NewDeploymentUptimeMetricCollector())
 	memStore.Register(NewDeploymentLabelsMetricCollector())
+	memStore.Register(NewDeploymentAnnotationsMetricCollector())
+	memStore.Register(NewDeploymentMatchLabelsMetricCollector())
+	memStore.Register(NewStatefulSetInfoMetricCollector())
+	memStore.Register(NewStatefulSetUptimeMetricCollector())
 	memStore.Register(NewStatefulSetLabelsMetricCollector())
+	memStore.Register(NewStatefulSetAnnotationsMetricCollector())
+	memStore.Register(NewStatefulSetMatchLabelsMetricCollector())
+	memStore.Register(NewDaemonSetInfoMetricCollector())
+	memStore.Register(NewDaemonSetUptimeMetricCollector())
 	memStore.Register(NewDaemonSetLabelsMetricCollector())
+	memStore.Register(NewDaemonSetAnnotationsMetricCollector())
+	memStore.Register(NewJobInfoMetricCollector())
+	memStore.Register(NewJobUptimeMetricCollector())
 	memStore.Register(NewJobLabelsMetricCollector())
+	memStore.Register(NewJobAnnotationsMetricCollector())
+	memStore.Register(NewCronJobInfoMetricCollector())
+	memStore.Register(NewCronJobUptimeMetricCollector())
+	memStore.Register(NewCronJobLabelsMetricCollector())
+	memStore.Register(NewCronJobAnnotationsMetricCollector())
+	memStore.Register(NewReplicaSetInfoMetricCollector())
+	memStore.Register(NewReplicaSetUptimeMetricCollector())
+	memStore.Register(NewReplicaSetLabelsMetricCollector())
+	memStore.Register(NewReplicaSetAnnotationsMetricCollector())
+	memStore.Register(NewReplicaSetOwnerMetricCollector())
+	memStore.Register(NewPodsWithDaemonSetOwnerMetricCollector())
+	memStore.Register(NewPodsWithJobOwnerMetricCollector())
 	memStore.Register(NewPodsWithReplicaSetOwnerMetricCollector())
+	memStore.Register(NewPodInfoMetricCollector())
+	memStore.Register(NewPodUptimeMetricCollector())
+	memStore.Register(NewPodOwnerMetricCollector())
+	memStore.Register(NewPodPVCVolumeMetricCollector())
+	memStore.Register(NewPodNetworkEgressBytesMetricCollector())
+	memStore.Register(NewPodNetworkIngressBytesMetricCollector())
+	memStore.Register(NewContainerUptimeMetricCollector())
+	memStore.Register(NewContainerResourceRequestsMetricCollector())
+	memStore.Register(NewContainerResourceLimitsMetricCollector())
 	memStore.Register(NewReplicaSetsWithoutOwnersMetricCollector())
 	memStore.Register(NewReplicaSetsWithRolloutMetricCollector())
+	memStore.Register(NewResourceQuotaInfoMetricCollector())
 	memStore.Register(NewResourceQuotaUptimeMetricCollector())
 	memStore.Register(NewResourceQuotaSpecCPURequestAverageMetricCollector())
 	memStore.Register(NewResourceQuotaSpecCPURequestMaxMetricCollector())
@@ -205,6 +251,18 @@ func NewPVCInfoMetricCollector() *metric.MetricCollector {
 	)
 }
 
+func NewPVUptimeMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.PVUptimeID,
+		metric.KubecostPVInfo,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Uptime,
+		nil,
+	)
+}
+
 //	avg(
 //		kube_persistentvolume_capacity_bytes{
 //			<some_custom_filter>
@@ -216,8 +274,8 @@ func NewPVActiveMinutesMetricCollector() *metric.MetricCollector {
 		metric.PVActiveMinutesID,
 		metric.KubePersistentVolumeCapacityBytes,
 		[]string{
-			source.PVLabel,
 			source.UIDLabel,
+			source.PVLabel,
 		},
 		aggregator.Uptime,
 		nil,
@@ -244,7 +302,6 @@ func NewLocalStorageUsedActiveMinutesMetricCollector() *metric.MetricCollector {
 		[]string{
 			source.InstanceLabel,
 			source.DeviceLabel,
-			source.UIDLabel,
 		},
 		aggregator.Uptime,
 		nil, // filter not required here because only container root file system is being scraped
@@ -270,7 +327,6 @@ func NewLocalStorageUsedAverageMetricCollector() *metric.MetricCollector {
 		[]string{
 			source.InstanceLabel,
 			source.DeviceLabel,
-			source.UIDLabel,
 		},
 		aggregator.AverageOverTime,
 		nil, // filter not required here because only container root file system is being scraped
@@ -297,7 +353,6 @@ func NewLocalStorageUsedMaxMetricCollector() *metric.MetricCollector {
 		[]string{
 			source.InstanceLabel,
 			source.DeviceLabel,
-			source.UIDLabel,
 		},
 		aggregator.MaxOverTime,
 		nil, // filter not required here because only container root file system is being scraped
@@ -350,6 +405,61 @@ func NewLocalStorageActiveMinutesMetricCollector() *metric.MetricCollector {
 	)
 }
 
+func NewNodeInfoMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.NodeInfoID,
+		metric.NodeInfo,
+		[]string{
+			source.NodeLabel,
+			source.UIDLabel,
+			source.ProviderIDLabel,
+			source.InstanceTypeLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewNodeUptimeMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.NodeUptimeID,
+		metric.NodeInfo,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Uptime,
+		nil,
+	)
+}
+
+func NewNodeResourceCapacitiesMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.NodeResourceCapacitiesID,
+		metric.NodeResourceCapacities,
+		[]string{
+			source.UIDLabel,
+			source.ResourceLabel,
+			source.UnitLabel,
+		},
+		aggregator.AverageOverTime,
+		nil,
+	)
+}
+
+func NewNodeResourcesAllocatableMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.NodeResourcesAllocatableID,
+		metric.NodeResourcesAllocatable,
+		[]string{
+			source.UIDLabel,
+			source.ResourceLabel,
+			source.UnitLabel,
+		},
+		aggregator.AverageOverTime,
+		nil,
+	)
+}
+
 // avg(
 //
 //	avg_over_time(
@@ -614,6 +724,18 @@ func NewLBActiveMinutesMetricCollector() *metric.MetricCollector {
 	)
 }
 
+func NewClusterInfoMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.ClusterInfoID,
+		metric.ClusterInfo,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
 //	avg(
 //		cluster_info{
 //			<some_custom_filter>
@@ -692,6 +814,109 @@ func NewPodActiveMinutesMetricCollector() *metric.MetricCollector {
 	)
 }
 
+func NewPodInfoMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.PodInfoID,
+		metric.PodInfo,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewPodUptimeMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.PodUptimeID,
+		metric.PodInfo,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Uptime,
+		nil,
+	)
+}
+
+func NewPodOwnerMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.PodOwnerID,
+		metric.KubePodOwner,
+		[]string{
+			source.UIDLabel,
+			source.OwnerUIDLabel,
+			source.OwnerKindLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewPodPVCVolumeMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.PodPVCVolumeID,
+		metric.PodPVCVolume,
+		[]string{
+			source.UIDLabel,
+			source.PVCUIDLabel,
+			source.PodVolumeNameLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewPodNetworkEgressBytesMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.PodNetworkEgressBytesID,
+		metric.KubecostPodNetworkEgressBytesTotal,
+		[]string{
+			source.UIDLabel,
+			source.ServiceLabel,
+			source.InternetLabel,
+			source.SameRegionLabel,
+			source.SameZoneLabel,
+			source.NatGatewayLabel,
+		},
+		aggregator.Increase,
+		func(labels map[string]string) bool {
+			return labels[source.UIDLabel] != ""
+		},
+	)
+}
+
+func NewPodNetworkIngressBytesMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.PodNetworkIngressBytesID,
+		metric.KubecostPodNetworkIngressBytesTotal,
+		[]string{
+			source.UIDLabel,
+			source.NatGatewayLabel,
+			source.ServiceLabel,
+			source.SameZoneLabel,
+			source.SameRegionLabel,
+			source.InternetLabel,
+		},
+		aggregator.Increase,
+		func(labels map[string]string) bool {
+			return labels[source.UIDLabel] != ""
+		},
+	)
+}
+
+func NewContainerUptimeMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.ContainerUptimeID,
+		metric.KubePodContainerStatusRunning,
+		[]string{
+			source.UIDLabel,
+			source.ContainerLabel,
+		},
+		aggregator.Uptime,
+		nil,
+	)
+}
+
 //	avg(
 //		avg_over_time(
 //			container_memory_allocation_bytes{
@@ -937,6 +1162,36 @@ func NewCPULimitsMetricCollector() *metric.MetricCollector {
 	)
 }
 
+func NewContainerResourceRequestsMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.ContainerResourceRequestsID,
+		metric.KubePodContainerResourceRequests,
+		[]string{
+			source.UIDLabel,
+			source.ContainerLabel,
+			source.ResourceLabel,
+			source.UnitLabel,
+		},
+		aggregator.AverageOverTime,
+		nil,
+	)
+}
+
+func NewContainerResourceLimitsMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.ContainerResourceLimitsID,
+		metric.KubePodContainerResourceLimits,
+		[]string{
+			source.UIDLabel,
+			source.ContainerLabel,
+			source.ResourceLabel,
+			source.UnitLabel,
+		},
+		aggregator.AverageOverTime,
+		nil,
+	)
+}
+
 //	avg(
 //		rate(
 //			container_cpu_usage_seconds_total{
@@ -1166,6 +1421,62 @@ func NewGPUInfoMetricCollector() *metric.MetricCollector {
 	)
 }
 
+func NewDCGMInfoMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.DCGMInfoID,
+		metric.DCGMFIDEVDECUTIL,
+		[]string{
+			source.UUIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewDCGMUptimeMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.DCGMUptimeID,
+		metric.DCGMFIDEVDECUTIL,
+		[]string{
+			source.UUIDLabel,
+		},
+		aggregator.Uptime,
+		nil,
+	)
+}
+
+func NewDCGMContainerUsageAvgMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.DCGMContainerUsageAvgID,
+		metric.DCGMFIPROFGRENGINEACTIVE,
+		[]string{
+			source.UUIDLabel,
+			source.PodUIDLabel,
+			source.ContainerLabel,
+		},
+		aggregator.AverageOverTime,
+		func(labels map[string]string) bool {
+			return labels[source.ContainerLabel] != ""
+		},
+	)
+}
+
+func NewDCGMContainerUsageMaxMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.DCGMContainerUsageMaxID,
+		metric.DCGMFIPROFGRENGINEACTIVE,
+		[]string{
+			source.UUIDLabel,
+			source.PodUIDLabel,
+			source.ContainerLabel,
+		},
+		aggregator.MaxOverTime,
+		func(labels map[string]string) bool {
+			return labels[source.ContainerLabel] != ""
+		},
+	)
+}
+
 //	avg(
 //		avg_over_time(
 //			node_cpu_hourly_cost{
@@ -1335,10 +1646,10 @@ func NewPVInfoMetricCollector() *metric.MetricCollector {
 		metric.PVInfoID,
 		metric.KubecostPVInfo,
 		[]string{
+			source.UIDLabel,
 			source.PVLabel,
 			source.StorageClassLabel,
 			source.ProviderIDLabel,
-			source.UIDLabel,
 		},
 		aggregator.AverageOverTime,
 		nil,
@@ -1755,6 +2066,18 @@ func NewNetTransferBytesMetricCollector() *metric.MetricCollector {
 	)
 }
 
+func NewNamespaceInfoMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.NamespaceInfoID,
+		metric.NamespaceInfo,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
 //	avg(
 //		namespace_info{
 //			<some_custom_filter>
@@ -1877,9 +2200,59 @@ func NewServiceLabelsMetricCollector() *metric.MetricCollector {
 //		}[1h]
 //	)
 
+func NewDeploymentInfoMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.DeploymentInfoID,
+		metric.DeploymentInfo,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewDeploymentUptimeMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.DeploymentUptimeID,
+		metric.DeploymentInfo,
+		[]string{
+			source.UIDLabel,
+			source.NamespaceUIDLabel,
+			source.DeploymentLabel,
+		},
+		aggregator.Uptime,
+		nil,
+	)
+}
+
 func NewDeploymentLabelsMetricCollector() *metric.MetricCollector {
 	return metric.NewMetricCollector(
 		metric.DeploymentLabelsID,
+		metric.DeploymentLabels,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewDeploymentAnnotationsMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.DeploymentAnnotationsID,
+		metric.DeploymentAnnotations,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewDeploymentMatchLabelsMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.DeploymentMatchLabelsID,
 		metric.DeploymentMatchLabels,
 		[]string{
 			source.NamespaceLabel,
@@ -1891,19 +2264,153 @@ func NewDeploymentLabelsMetricCollector() *metric.MetricCollector {
 	)
 }
 
+func NewStatefulSetInfoMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.StatefulSetInfoID,
+		metric.StatefulSetInfo,
+		[]string{
+			source.UIDLabel,
+			source.NamespaceUIDLabel,
+			source.StatefulSetLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewStatefulSetUptimeMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.StatefulSetUptimeID,
+		metric.StatefulSetInfo,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Uptime,
+		nil,
+	)
+}
+
+func NewStatefulSetLabelsMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.StatefulSetLabelsID,
+		metric.StatefulSetLabels,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewStatefulSetAnnotationsMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.StatefulSetAnnotationsID,
+		metric.StatefulSetAnnotations,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
 //	avg_over_time(
 //		statefulSet_match_labels{
 //			<some_custom_filter>
 //		}[1h]
 //	)
 
-func NewStatefulSetLabelsMetricCollector() *metric.MetricCollector {
+func NewStatefulSetMatchLabelsMetricCollector() *metric.MetricCollector {
 	return metric.NewMetricCollector(
-		metric.StatefulSetLabelsID,
+		metric.StatefulSetMatchLabelsID,
 		metric.StatefulSetMatchLabels,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewDaemonSetInfoMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.DaemonSetInfoID,
+		metric.DaemonSetInfo,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewDaemonSetUptimeMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.DaemonSetUptimeID,
+		metric.DaemonSetInfo,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Uptime,
+		nil,
+	)
+}
+
+func NewDaemonSetLabelsMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.DaemonSetLabelsID,
+		metric.DaemonSetLabels,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewDaemonSetAnnotationsMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.DaemonSetAnnotationsID,
+		metric.DaemonSetAnnotations,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewJobInfoMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.JobInfoID,
+		metric.JobInfo,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewJobUptimeMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.JobUptimeID,
+		metric.JobInfo,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Uptime,
+		nil,
+	)
+}
+
+func NewJobLabelsMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.JobLabelsID,
+		metric.JobLabels,
 		[]string{
 			source.NamespaceLabel,
-			source.StatefulSetLabel,
+			source.JobLabel,
 			source.UIDLabel,
 		},
 		aggregator.Info,
@@ -1911,6 +2418,142 @@ func NewStatefulSetLabelsMetricCollector() *metric.MetricCollector {
 	)
 }
 
+func NewJobAnnotationsMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.JobAnnotationsID,
+		metric.JobAnnotations,
+		[]string{
+			source.NamespaceLabel,
+			source.JobLabel,
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewCronJobInfoMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.CronJobInfoID,
+		metric.CronJobInfo,
+		[]string{
+			source.UIDLabel,
+			source.NamespaceUIDLabel,
+			source.CronJobLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewCronJobUptimeMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.CronJobUptimeID,
+		metric.CronJobInfo,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Uptime,
+		nil,
+	)
+}
+
+func NewCronJobLabelsMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.CronJobLabelsID,
+		metric.CronJobLabels,
+		[]string{
+			source.NamespaceLabel,
+			source.CronJobLabel,
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewCronJobAnnotationsMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.CronJobAnnotationsID,
+		metric.CronJobAnnotations,
+		[]string{
+			source.NamespaceLabel,
+			source.CronJobLabel,
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewReplicaSetInfoMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.ReplicaSetInfoID,
+		metric.ReplicaSetInfo,
+		[]string{
+			source.UIDLabel,
+			source.NamespaceUIDLabel,
+			source.ReplicaSetLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewReplicaSetUptimeMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.ReplicaSetUptimeID,
+		metric.ReplicaSetInfo,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Uptime,
+		nil,
+	)
+}
+
+func NewReplicaSetLabelsMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.ReplicaSetLabelsID,
+		metric.ReplicaSetLabels,
+		[]string{
+			source.NamespaceLabel,
+			source.ReplicaSetLabel,
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewReplicaSetAnnotationsMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.ReplicaSetAnnotationsID,
+		metric.ReplicaSetAnnotations,
+		[]string{
+			source.NamespaceLabel,
+			source.ReplicaSetLabel,
+			source.UIDLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
+func NewReplicaSetOwnerMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.ReplicaSetOwnerID,
+		metric.KubeReplicasetOwner,
+		[]string{
+			source.UIDLabel,
+			source.OwnerUIDLabel,
+			source.OwnerKindLabel,
+		},
+		aggregator.Info,
+		nil,
+	)
+}
+
 //	sum(
 //		avg_over_time(
 //			kube_pod_owner{
@@ -1920,9 +2563,9 @@ func NewStatefulSetLabelsMetricCollector() *metric.MetricCollector {
 //		)
 //	) by (pod, owner_name, namespace, cluster_id)
 
-func NewDaemonSetLabelsMetricCollector() *metric.MetricCollector {
+func NewPodsWithDaemonSetOwnerMetricCollector() *metric.MetricCollector {
 	return metric.NewMetricCollector(
-		metric.DaemonSetLabelsID,
+		metric.PodsWithDaemonSetOwnerID,
 		metric.KubePodOwner,
 		[]string{
 			source.NamespaceLabel,
@@ -1946,9 +2589,9 @@ func NewDaemonSetLabelsMetricCollector() *metric.MetricCollector {
 //		)
 //	) by (pod, owner_name, namespace, cluster_id)
 
-func NewJobLabelsMetricCollector() *metric.MetricCollector {
+func NewPodsWithJobOwnerMetricCollector() *metric.MetricCollector {
 	return metric.NewMetricCollector(
-		metric.JobLabelsID,
+		metric.PodsWithJobOwnerID,
 		metric.KubePodOwner,
 		[]string{
 			source.NamespaceLabel,
@@ -2042,6 +2685,18 @@ func NewReplicaSetsWithRolloutMetricCollector() *metric.MetricCollector {
 	)
 }
 
+func NewResourceQuotaInfoMetricCollector() *metric.MetricCollector {
+	return metric.NewMetricCollector(
+		metric.ResourceQuotaInfoID,
+		metric.ResourceQuotaInfo,
+		[]string{
+			source.UIDLabel,
+		},
+		aggregator.Uptime,
+		nil,
+	)
+}
+
 //	avg(
 //		resourcequota_info{
 //			<some_custom_filter>

+ 237 - 41
modules/collector-source/pkg/collector/metricsquerier.go

@@ -182,6 +182,14 @@ func (c *collectorMetricsQuerier) QueryLocalStorageBytes(start, end time.Time) *
 	return queryCollector(c, start, end, metric.LocalStorageBytesID, source.DecodeLocalStorageBytesResult)
 }
 
+func (c *collectorMetricsQuerier) QueryNodeInfo(start, end time.Time) *source.Future[source.NodeInfoResult] {
+	return queryCollector(c, start, end, metric.NodeInfoID, source.DecodeNodeInfoResult)
+}
+
+func (c *collectorMetricsQuerier) QueryNodeUptime(start, end time.Time) *source.Future[source.UptimeResult] {
+	return queryCollector(c, start, end, metric.NodeUptimeID, source.DecodeUptimeResult)
+}
+
 func (c *collectorMetricsQuerier) QueryNodeActiveMinutes(start, end time.Time) *source.Future[source.NodeActiveMinutesResult] {
 	return queryCollector(c, start, end, metric.NodeActiveMinutesID, source.DecodeNodeActiveMinutesResult)
 }
@@ -291,6 +299,14 @@ func (c *collectorMetricsQuerier) QueryNodeRAMUserPercent(start, end time.Time)
 	return f
 }
 
+func (c *collectorMetricsQuerier) QueryNodeResourceCapacities(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.NodeResourceCapacitiesID, source.DecodeResourceResult)
+}
+
+func (c *collectorMetricsQuerier) QueryNodeResourcesAllocatable(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.NodeResourcesAllocatableID, source.DecodeResourceResult)
+}
+
 func (c *collectorMetricsQuerier) QueryLBActiveMinutes(start, end time.Time) *source.Future[source.LBActiveMinutesResult] {
 	return queryCollector(c, start, end, metric.LBActiveMinutesID, source.DecodeLBActiveMinutesResult)
 }
@@ -299,6 +315,10 @@ func (c *collectorMetricsQuerier) QueryLBPricePerHr(start, end time.Time) *sourc
 	return queryCollector(c, start, end, metric.LBPricePerHourID, source.DecodeLBPricePerHrResult)
 }
 
+func (c *collectorMetricsQuerier) QueryClusterInfo(start, end time.Time) *source.Future[source.ClusterInfoResult] {
+	return queryCollector(c, start, end, metric.ClusterInfoID, source.DecodeClusterInfoResult)
+}
+
 func (c *collectorMetricsQuerier) QueryClusterUptime(start, end time.Time) *source.Future[source.UptimeResult] {
 	return queryCollector(c, start, end, metric.ClusterUptimeID, source.DecodeUptimeResult)
 }
@@ -320,6 +340,42 @@ func (c *collectorMetricsQuerier) QueryPodsUID(start, end time.Time) *source.Fut
 	return queryCollector(c, start, end, metric.PodActiveMinutesID, source.DecodePodsResult)
 }
 
+func (c *collectorMetricsQuerier) QueryPodInfo(start, end time.Time) *source.Future[source.PodInfoResult] {
+	return queryCollector(c, start, end, metric.PodInfoID, source.DecodePodInfoResult)
+}
+
+func (c *collectorMetricsQuerier) QueryPodUptime(start, end time.Time) *source.Future[source.UptimeResult] {
+	return queryCollector(c, start, end, metric.PodUptimeID, source.DecodeUptimeResult)
+}
+
+func (c *collectorMetricsQuerier) QueryPodOwners(start, end time.Time) *source.Future[source.OwnerResult] {
+	return queryCollector(c, start, end, metric.PodOwnerID, source.DecodeOwnerResult)
+}
+
+func (c *collectorMetricsQuerier) QueryPodPVCVolumes(start, end time.Time) *source.Future[source.PodPVCVolumeResult] {
+	return queryCollector(c, start, end, metric.PodPVCVolumeID, source.DecodePodPVCVolumeResult)
+}
+
+func (c *collectorMetricsQuerier) QueryPodNetworkEgressBytes(start, end time.Time) *source.Future[source.PodNetworkBytesResult] {
+	return queryCollector(c, start, end, metric.PodNetworkEgressBytesID, source.DecodePodNetworkBytesResult)
+}
+
+func (c *collectorMetricsQuerier) QueryPodNetworkIngressBytes(start, end time.Time) *source.Future[source.PodNetworkBytesResult] {
+	return queryCollector(c, start, end, metric.PodNetworkIngressBytesID, source.DecodePodNetworkBytesResult)
+}
+
+func (c *collectorMetricsQuerier) QueryContainerUptime(start, end time.Time) *source.Future[source.ContainerUptimeResult] {
+	return queryCollector(c, start, end, metric.ContainerUptimeID, source.DecodeContainerUptimeResult)
+}
+
+func (c *collectorMetricsQuerier) QueryContainerResourceRequests(start, end time.Time) *source.Future[source.ContainerResourceResult] {
+	return queryCollector(c, start, end, metric.ContainerResourceRequestsID, source.DecodeContainerResourceResult)
+}
+
+func (c *collectorMetricsQuerier) QueryContainerResourceLimits(start, end time.Time) *source.Future[source.ContainerResourceResult] {
+	return queryCollector(c, start, end, metric.ContainerResourceLimitsID, source.DecodeContainerResourceResult)
+}
+
 func (c *collectorMetricsQuerier) QueryRAMBytesAllocated(start, end time.Time) *source.Future[source.RAMBytesAllocatedResult] {
 	return queryCollector(c, start, end, metric.RAMBytesAllocatedID, source.DecodeRAMBytesAllocatedResult)
 }
@@ -396,6 +452,22 @@ func (c *collectorMetricsQuerier) QueryIsGPUShared(start, end time.Time) *source
 	return queryCollector(c, start, end, metric.IsGPUSharedID, source.DecodeIsGPUSharedResult)
 }
 
+func (c *collectorMetricsQuerier) QueryDCGMDeviceInfo(start, end time.Time) *source.Future[source.DCGMDeviceInfoResult] {
+	return queryCollector(c, start, end, metric.DCGMInfoID, source.DecodeDCGMDeviceInfoResult)
+}
+
+func (c *collectorMetricsQuerier) QueryDCGMDeviceUptime(start, end time.Time) *source.Future[source.DCGMDeviceUptimeResult] {
+	return queryCollector(c, start, end, metric.DCGMUptimeID, source.DecodeDCGMDeviceUptimeResult)
+}
+
+func (c *collectorMetricsQuerier) QueryDCGMContainerUsageAvg(start, end time.Time) *source.Future[source.DCGMDeviceContainerUsageResult] {
+	return queryCollector(c, start, end, metric.DCGMContainerUsageAvgID, source.DecodeDCGMDeviceContainerUsageResult)
+}
+
+func (c *collectorMetricsQuerier) QueryDCGMContainerUsageMax(start, end time.Time) *source.Future[source.DCGMDeviceContainerUsageResult] {
+	return queryCollector(c, start, end, metric.DCGMContainerUsageMaxID, source.DecodeDCGMDeviceContainerUsageResult)
+}
+
 func (c *collectorMetricsQuerier) QueryPodPVCAllocation(start, end time.Time) *source.Future[source.PodPVCAllocationResult] {
 	return queryCollector(c, start, end, metric.PodPVCAllocationID, source.DecodePodPVCAllocationResult)
 }
@@ -408,6 +480,10 @@ func (c *collectorMetricsQuerier) QueryPVCInfo(start, end time.Time) *source.Fut
 	return queryCollector(c, start, end, metric.PVCInfoID, source.DecodePVCInfoResult)
 }
 
+func (c *collectorMetricsQuerier) QueryPVCUptime(start, end time.Time) *source.Future[source.UptimeResult] {
+	return queryCollector(c, start, end, metric.PVCUptimeID, source.DecodeUptimeResult)
+}
+
 func (c *collectorMetricsQuerier) QueryPVBytes(start, end time.Time) *source.Future[source.PVBytesResult] {
 	return queryCollector(c, start, end, metric.PVBytesID, source.DecodePVBytesResult)
 }
@@ -420,6 +496,14 @@ func (c *collectorMetricsQuerier) QueryPVInfo(start, end time.Time) *source.Futu
 	return queryCollector(c, start, end, metric.PVInfoID, source.DecodePVInfoResult)
 }
 
+func (c *collectorMetricsQuerier) QueryPVUptime(start, end time.Time) *source.Future[source.UptimeResult] {
+	return queryCollector(c, start, end, metric.PVUptimeID, source.DecodeUptimeResult)
+}
+
+func (c *collectorMetricsQuerier) QueryNamespaceInfo(start, end time.Time) *source.Future[source.NamespaceInfoResult] {
+	return queryCollector(c, start, end, metric.NamespaceInfoID, source.DecodeNamespaceInfoResult)
+}
+
 func (c *collectorMetricsQuerier) QueryNamespaceUptime(start, end time.Time) *source.Future[source.UptimeResult] {
 	return queryCollector(c, start, end, metric.NamespaceUptimeID, source.DecodeUptimeResult)
 }
@@ -512,24 +596,132 @@ func (c *collectorMetricsQuerier) QueryPodLabels(start, end time.Time) *source.F
 	return queryCollector(c, start, end, metric.PodLabelsID, source.DecodePodLabelsResult)
 }
 
-func (c *collectorMetricsQuerier) QueryServiceLabels(start, end time.Time) *source.Future[source.ServiceLabelsResult] {
+func (c *collectorMetricsQuerier) QueryServiceInfo(start, end time.Time) *source.Future[source.ServiceInfoResult] {
+	return queryCollector(c, start, end, metric.ServiceInfoID, source.DecodeServiceInfoResult)
+}
+
+func (c *collectorMetricsQuerier) QueryServiceUptime(start, end time.Time) *source.Future[source.UptimeResult] {
+	return queryCollector(c, start, end, metric.ServiceUptimeID, source.DecodeUptimeResult)
+}
+
+func (c *collectorMetricsQuerier) QueryServiceSelectorLabels(start, end time.Time) *source.Future[source.ServiceLabelsResult] {
 	return queryCollector(c, start, end, metric.ServiceLabelsID, source.DecodeServiceLabelsResult)
 }
 
-func (c *collectorMetricsQuerier) QueryDeploymentLabels(start, end time.Time) *source.Future[source.DeploymentLabelsResult] {
-	return queryCollector(c, start, end, metric.DeploymentLabelsID, source.DecodeDeploymentLabelsResult)
+func (c *collectorMetricsQuerier) QueryDeploymentInfo(start, end time.Time) *source.Future[source.DeploymentInfoResult] {
+	return queryCollector(c, start, end, metric.DeploymentInfoID, source.DecodeDeploymentInfoResult)
+}
+
+func (c *collectorMetricsQuerier) QueryDeploymentUptime(start, end time.Time) *source.Future[source.UptimeResult] {
+	return queryCollector(c, start, end, metric.DeploymentUptimeID, source.DecodeUptimeResult)
+}
+
+func (c *collectorMetricsQuerier) QueryDeploymentLabels(start, end time.Time) *source.Future[source.LabelsResult] {
+	return queryCollector(c, start, end, metric.DeploymentLabelsID, source.DecodeLabelsResult)
+}
+
+func (c *collectorMetricsQuerier) QueryDeploymentAnnotations(start, end time.Time) *source.Future[source.AnnotationsResult] {
+	return queryCollector(c, start, end, metric.DeploymentAnnotationsID, source.DecodeAnnotationsResult)
+}
+
+func (c *collectorMetricsQuerier) QueryDeploymentMatchLabels(start, end time.Time) *source.Future[source.DeploymentLabelsResult] {
+	return queryCollector(c, start, end, metric.DeploymentMatchLabelsID, source.DecodeDeploymentLabelsResult)
+}
+
+func (c *collectorMetricsQuerier) QueryStatefulSetInfo(start, end time.Time) *source.Future[source.StatefulSetInfoResult] {
+	return queryCollector(c, start, end, metric.StatefulSetInfoID, source.DecodeStatefulSetInfoResult)
+}
+
+func (c *collectorMetricsQuerier) QueryStatefulSetUptime(start, end time.Time) *source.Future[source.UptimeResult] {
+	return queryCollector(c, start, end, metric.StatefulSetUptimeID, source.DecodeUptimeResult)
+}
+
+func (c *collectorMetricsQuerier) QueryStatefulSetLabels(start, end time.Time) *source.Future[source.LabelsResult] {
+	return queryCollector(c, start, end, metric.StatefulSetLabelsID, source.DecodeLabelsResult)
+}
+
+func (c *collectorMetricsQuerier) QueryStatefulSetAnnotations(start, end time.Time) *source.Future[source.AnnotationsResult] {
+	return queryCollector(c, start, end, metric.StatefulSetAnnotationsID, source.DecodeAnnotationsResult)
+}
+
+func (c *collectorMetricsQuerier) QueryStatefulSetMatchLabels(start, end time.Time) *source.Future[source.StatefulSetLabelsResult] {
+	return queryCollector(c, start, end, metric.StatefulSetMatchLabelsID, source.DecodeStatefulSetLabelsResult)
+}
+
+func (c *collectorMetricsQuerier) QueryDaemonSetInfo(start, end time.Time) *source.Future[source.DaemonSetInfoResult] {
+	return queryCollector(c, start, end, metric.DaemonSetInfoID, source.DecodeDaemonSetInfoResult)
+}
+
+func (c *collectorMetricsQuerier) QueryDaemonSetUptime(start, end time.Time) *source.Future[source.UptimeResult] {
+	return queryCollector(c, start, end, metric.DaemonSetUptimeID, source.DecodeUptimeResult)
+}
+
+func (c *collectorMetricsQuerier) QueryDaemonSetLabels(start, end time.Time) *source.Future[source.LabelsResult] {
+	return queryCollector(c, start, end, metric.DaemonSetLabelsID, source.DecodeLabelsResult)
+}
+
+func (c *collectorMetricsQuerier) QueryDaemonSetAnnotations(start, end time.Time) *source.Future[source.AnnotationsResult] {
+	return queryCollector(c, start, end, metric.DaemonSetAnnotationsID, source.DecodeAnnotationsResult)
+}
+
+func (c *collectorMetricsQuerier) QueryJobInfo(start, end time.Time) *source.Future[source.JobInfoResult] {
+	return queryCollector(c, start, end, metric.JobInfoID, source.DecodeJobInfoResult)
 }
 
-func (c *collectorMetricsQuerier) QueryStatefulSetLabels(start, end time.Time) *source.Future[source.StatefulSetLabelsResult] {
-	return queryCollector(c, start, end, metric.StatefulSetLabelsID, source.DecodeStatefulSetLabelsResult)
+func (c *collectorMetricsQuerier) QueryJobUptime(start, end time.Time) *source.Future[source.UptimeResult] {
+	return queryCollector(c, start, end, metric.JobUptimeID, source.DecodeUptimeResult)
 }
 
-func (c *collectorMetricsQuerier) QueryDaemonSetLabels(start, end time.Time) *source.Future[source.DaemonSetLabelsResult] {
-	return queryCollector(c, start, end, metric.DaemonSetLabelsID, source.DecodeDaemonSetLabelsResult)
+func (c *collectorMetricsQuerier) QueryJobLabels(start, end time.Time) *source.Future[source.LabelsResult] {
+	return queryCollector(c, start, end, metric.JobLabelsID, source.DecodeLabelsResult)
 }
 
-func (c *collectorMetricsQuerier) QueryJobLabels(start, end time.Time) *source.Future[source.JobLabelsResult] {
-	return queryCollector(c, start, end, metric.JobLabelsID, source.DecodeJobLabelsResult)
+func (c *collectorMetricsQuerier) QueryJobAnnotations(start, end time.Time) *source.Future[source.AnnotationsResult] {
+	return queryCollector(c, start, end, metric.JobAnnotationsID, source.DecodeAnnotationsResult)
+}
+
+func (c *collectorMetricsQuerier) QueryCronJobInfo(start, end time.Time) *source.Future[source.CronJobInfoResult] {
+	return queryCollector(c, start, end, metric.CronJobInfoID, source.DecodeCronJobInfoResult)
+}
+
+func (c *collectorMetricsQuerier) QueryCronJobUptime(start, end time.Time) *source.Future[source.UptimeResult] {
+	return queryCollector(c, start, end, metric.CronJobUptimeID, source.DecodeUptimeResult)
+}
+
+func (c *collectorMetricsQuerier) QueryCronJobLabels(start, end time.Time) *source.Future[source.LabelsResult] {
+	return queryCollector(c, start, end, metric.CronJobLabelsID, source.DecodeLabelsResult)
+}
+
+func (c *collectorMetricsQuerier) QueryCronJobAnnotations(start, end time.Time) *source.Future[source.AnnotationsResult] {
+	return queryCollector(c, start, end, metric.CronJobAnnotationsID, source.DecodeAnnotationsResult)
+}
+
+func (c *collectorMetricsQuerier) QueryReplicaSetInfo(start, end time.Time) *source.Future[source.ReplicaSetInfoResult] {
+	return queryCollector(c, start, end, metric.ReplicaSetInfoID, source.DecodeReplicaSetInfoResult)
+}
+
+func (c *collectorMetricsQuerier) QueryReplicaSetUptime(start, end time.Time) *source.Future[source.UptimeResult] {
+	return queryCollector(c, start, end, metric.ReplicaSetUptimeID, source.DecodeUptimeResult)
+}
+
+func (c *collectorMetricsQuerier) QueryReplicaSetLabels(start, end time.Time) *source.Future[source.LabelsResult] {
+	return queryCollector(c, start, end, metric.ReplicaSetLabelsID, source.DecodeLabelsResult)
+}
+
+func (c *collectorMetricsQuerier) QueryReplicaSetAnnotations(start, end time.Time) *source.Future[source.AnnotationsResult] {
+	return queryCollector(c, start, end, metric.ReplicaSetAnnotationsID, source.DecodeAnnotationsResult)
+}
+
+func (c *collectorMetricsQuerier) QueryReplicaSetOwners(start, end time.Time) *source.Future[source.OwnerResult] {
+	return queryCollector(c, start, end, metric.ReplicaSetOwnerID, source.DecodeOwnerResult)
+}
+
+func (c *collectorMetricsQuerier) QueryPodsWithDaemonSetOwner(start, end time.Time) *source.Future[source.PodsWithDaemonSetOwnerResult] {
+	return queryCollector(c, start, end, metric.PodsWithDaemonSetOwnerID, source.DecodePodsWithDaemonSetOwnerResult)
+}
+
+func (c *collectorMetricsQuerier) QueryPodsWithJobOwner(start, end time.Time) *source.Future[source.PodsWithJobOwnerResult] {
+	return queryCollector(c, start, end, metric.PodsWithJobOwnerID, source.DecodePodsWithJobOwnerResult)
 }
 
 func (c *collectorMetricsQuerier) QueryPodsWithReplicaSetOwner(start, end time.Time) *source.Future[source.PodsWithReplicaSetOwnerResult] {
@@ -544,72 +736,76 @@ func (c *collectorMetricsQuerier) QueryReplicaSetsWithRollout(start, end time.Ti
 	return queryCollector(c, start, end, metric.ReplicaSetsWithRolloutID, source.DecodeReplicaSetsWithRolloutResult)
 }
 
+func (c *collectorMetricsQuerier) QueryResourceQuotaInfo(start, end time.Time) *source.Future[source.ResourceQuotaInfoResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaInfoID, source.DecodeResourceQuotaInfoResult)
+}
+
 func (c *collectorMetricsQuerier) QueryResourceQuotaUptime(start, end time.Time) *source.Future[source.UptimeResult] {
 	return queryCollector(c, start, end, metric.ResourceQuotaUptimeID, source.DecodeUptimeResult)
 }
 
-func (c *collectorMetricsQuerier) QueryResourceQuotaSpecCPURequestAverage(start, end time.Time) *source.Future[source.ResourceQuotaSpecCPURequestAvgResult] {
-	return queryCollector(c, start, end, metric.ResourceQuotaSpecCPURequestAverageID, source.DecodeResourceQuotaSpecCPURequestAvgResult)
+func (c *collectorMetricsQuerier) QueryResourceQuotaSpecCPURequestAverage(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaSpecCPURequestAverageID, source.DecodeResourceResult)
 }
 
-func (c *collectorMetricsQuerier) QueryResourceQuotaSpecCPURequestMax(start, end time.Time) *source.Future[source.ResourceQuotaSpecCPURequestMaxResult] {
-	return queryCollector(c, start, end, metric.ResourceQuotaSpecCPURequestMaxID, source.DecodeResourceQuotaSpecCPURequestMaxResult)
+func (c *collectorMetricsQuerier) QueryResourceQuotaSpecCPURequestMax(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaSpecCPURequestMaxID, source.DecodeResourceResult)
 }
 
-func (c *collectorMetricsQuerier) QueryResourceQuotaSpecRAMRequestAverage(start, end time.Time) *source.Future[source.ResourceQuotaSpecRAMRequestAvgResult] {
-	return queryCollector(c, start, end, metric.ResourceQuotaSpecRAMRequestAverageID, source.DecodeResourceQuotaSpecRAMRequestAvgResult)
+func (c *collectorMetricsQuerier) QueryResourceQuotaSpecRAMRequestAverage(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaSpecRAMRequestAverageID, source.DecodeResourceResult)
 }
 
-func (c *collectorMetricsQuerier) QueryResourceQuotaSpecRAMRequestMax(start, end time.Time) *source.Future[source.ResourceQuotaSpecRAMRequestMaxResult] {
-	return queryCollector(c, start, end, metric.ResourceQuotaSpecRAMRequestMaxID, source.DecodeResourceQuotaSpecRAMRequestMaxResult)
+func (c *collectorMetricsQuerier) QueryResourceQuotaSpecRAMRequestMax(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaSpecRAMRequestMaxID, source.DecodeResourceResult)
 }
 
-func (c *collectorMetricsQuerier) QueryResourceQuotaSpecCPULimitAverage(start, end time.Time) *source.Future[source.ResourceQuotaSpecCPULimitAvgResult] {
-	return queryCollector(c, start, end, metric.ResourceQuotaSpecCPULimitAverageID, source.DecodeResourceQuotaSpecCPULimitAvgResult)
+func (c *collectorMetricsQuerier) QueryResourceQuotaSpecCPULimitAverage(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaSpecCPULimitAverageID, source.DecodeResourceResult)
 }
 
-func (c *collectorMetricsQuerier) QueryResourceQuotaSpecCPULimitMax(start, end time.Time) *source.Future[source.ResourceQuotaSpecCPULimitMaxResult] {
-	return queryCollector(c, start, end, metric.ResourceQuotaSpecCPULimitMaxID, source.DecodeResourceQuotaSpecCPULimitMaxResult)
+func (c *collectorMetricsQuerier) QueryResourceQuotaSpecCPULimitMax(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaSpecCPULimitMaxID, source.DecodeResourceResult)
 }
 
-func (c *collectorMetricsQuerier) QueryResourceQuotaSpecRAMLimitAverage(start, end time.Time) *source.Future[source.ResourceQuotaSpecRAMLimitAvgResult] {
-	return queryCollector(c, start, end, metric.ResourceQuotaSpecRAMLimitAverageID, source.DecodeResourceQuotaSpecRAMLimitAvgResult)
+func (c *collectorMetricsQuerier) QueryResourceQuotaSpecRAMLimitAverage(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaSpecRAMLimitAverageID, source.DecodeResourceResult)
 }
 
-func (c *collectorMetricsQuerier) QueryResourceQuotaSpecRAMLimitMax(start, end time.Time) *source.Future[source.ResourceQuotaSpecRAMLimitMaxResult] {
-	return queryCollector(c, start, end, metric.ResourceQuotaSpecRAMLimitMaxID, source.DecodeResourceQuotaSpecRAMLimitMaxResult)
+func (c *collectorMetricsQuerier) QueryResourceQuotaSpecRAMLimitMax(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaSpecRAMLimitMaxID, source.DecodeResourceResult)
 }
 
-func (c *collectorMetricsQuerier) QueryResourceQuotaStatusUsedCPURequestAverage(start, end time.Time) *source.Future[source.ResourceQuotaStatusUsedCPURequestAvgResult] {
-	return queryCollector(c, start, end, metric.ResourceQuotaStatusUsedCPURequestAverageID, source.DecodeResourceQuotaStatusUsedCPURequestAvgResult)
+func (c *collectorMetricsQuerier) QueryResourceQuotaStatusUsedCPURequestAverage(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaStatusUsedCPURequestAverageID, source.DecodeResourceResult)
 }
 
-func (c *collectorMetricsQuerier) QueryResourceQuotaStatusUsedCPURequestMax(start, end time.Time) *source.Future[source.ResourceQuotaStatusUsedCPURequestMaxResult] {
-	return queryCollector(c, start, end, metric.ResourceQuotaStatusUsedCPURequestMaxID, source.DecodeResourceQuotaStatusUsedCPURequestMaxResult)
+func (c *collectorMetricsQuerier) QueryResourceQuotaStatusUsedCPURequestMax(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaStatusUsedCPURequestMaxID, source.DecodeResourceResult)
 }
 
-func (c *collectorMetricsQuerier) QueryResourceQuotaStatusUsedRAMRequestAverage(start, end time.Time) *source.Future[source.ResourceQuotaStatusUsedRAMRequestAvgResult] {
-	return queryCollector(c, start, end, metric.ResourceQuotaStatusUsedRAMRequestAverageID, source.DecodeResourceQuotaStatusUsedRAMRequestAvgResult)
+func (c *collectorMetricsQuerier) QueryResourceQuotaStatusUsedRAMRequestAverage(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaStatusUsedRAMRequestAverageID, source.DecodeResourceResult)
 }
 
-func (c *collectorMetricsQuerier) QueryResourceQuotaStatusUsedRAMRequestMax(start, end time.Time) *source.Future[source.ResourceQuotaStatusUsedRAMRequestMaxResult] {
-	return queryCollector(c, start, end, metric.ResourceQuotaStatusUsedRAMRequestMaxID, source.DecodeResourceQuotaStatusUsedRAMRequestMaxResult)
+func (c *collectorMetricsQuerier) QueryResourceQuotaStatusUsedRAMRequestMax(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaStatusUsedRAMRequestMaxID, source.DecodeResourceResult)
 }
 
-func (c *collectorMetricsQuerier) QueryResourceQuotaStatusUsedCPULimitAverage(start, end time.Time) *source.Future[source.ResourceQuotaStatusUsedCPULimitAvgResult] {
-	return queryCollector(c, start, end, metric.ResourceQuotaStatusUsedCPULimitAverageID, source.DecodeResourceQuotaStatusUsedCPULimitAvgResult)
+func (c *collectorMetricsQuerier) QueryResourceQuotaStatusUsedCPULimitAverage(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaStatusUsedCPULimitAverageID, source.DecodeResourceResult)
 }
 
-func (c *collectorMetricsQuerier) QueryResourceQuotaStatusUsedCPULimitMax(start, end time.Time) *source.Future[source.ResourceQuotaStatusUsedCPULimitMaxResult] {
-	return queryCollector(c, start, end, metric.ResourceQuotaStatusUsedCPULimitMaxID, source.DecodeResourceQuotaStatusUsedCPULimitMaxResult)
+func (c *collectorMetricsQuerier) QueryResourceQuotaStatusUsedCPULimitMax(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaStatusUsedCPULimitMaxID, source.DecodeResourceResult)
 }
 
-func (c *collectorMetricsQuerier) QueryResourceQuotaStatusUsedRAMLimitAverage(start, end time.Time) *source.Future[source.ResourceQuotaStatusUsedRAMLimitAvgResult] {
-	return queryCollector(c, start, end, metric.ResourceQuotaStatusUsedRAMLimitAverageID, source.DecodeResourceQuotaStatusUsedRAMLimitAvgResult)
+func (c *collectorMetricsQuerier) QueryResourceQuotaStatusUsedRAMLimitAverage(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaStatusUsedRAMLimitAverageID, source.DecodeResourceResult)
 }
 
-func (c *collectorMetricsQuerier) QueryResourceQuotaStatusUsedRAMLimitMax(start, end time.Time) *source.Future[source.ResourceQuotaStatusUsedRAMLimitMaxResult] {
-	return queryCollector(c, start, end, metric.ResourceQuotaStatusUsedRAMLimitMaxID, source.DecodeResourceQuotaStatusUsedRAMLimitMaxResult)
+func (c *collectorMetricsQuerier) QueryResourceQuotaStatusUsedRAMLimitMax(start, end time.Time) *source.Future[source.ResourceResult] {
+	return queryCollector(c, start, end, metric.ResourceQuotaStatusUsedRAMLimitMaxID, source.DecodeResourceResult)
 }
 
 func (c *collectorMetricsQuerier) QueryDataCoverage(limitDays int) (time.Time, time.Time, error) {

+ 3 - 0
modules/collector-source/pkg/event/scrape.go

@@ -14,6 +14,9 @@ const (
 	ReplicaSetScraperType    = "replicasets"
 	DeploymentScraperType    = "deployments"
 	StatefulSetScraperType   = "statefulsets"
+	DaemonSetScraperType     = "daemonsets"
+	JobScraperType           = "jobs"
+	CronJobScraperType       = "cronjobs"
 	ServiceScraperType       = "services"
 	PodScraperType           = "pods"
 	PvScraperType            = "pvs"

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

@@ -19,7 +19,9 @@ const (
 	PVUsedAverageID                            MetricCollectorID = "PVUsedAverage"
 	PVUsedMaxID                                MetricCollectorID = "PVUsedMax"
 	PVCInfoID                                  MetricCollectorID = "PVCInfo"
+	PVCUptimeID                                MetricCollectorID = "PVCUptime"
 	PVActiveMinutesID                          MetricCollectorID = "PVActiveMinutes"
+	PVUptimeID                                 MetricCollectorID = "PVUptime"
 	LocalStorageUsedActiveMinutesID            MetricCollectorID = "LocalStorageUsedCost"
 	LocalStorageUsedAverageID                  MetricCollectorID = "LocalStorageUsedAverage"
 	LocalStorageUsedMaxID                      MetricCollectorID = "LocalStorageUsedMax"
@@ -31,21 +33,33 @@ const (
 	NodeRAMBytesAllocatableID                  MetricCollectorID = "NodeRAMBytesAllocatable"
 	NodeGPUCountID                             MetricCollectorID = "NodeGPUCount"
 	NodeLabelsID                               MetricCollectorID = "NodeLabels"
+	NodeInfoID                                 MetricCollectorID = "NodeInfo"
+	NodeUptimeID                               MetricCollectorID = "NodeUptime"
 	NodeActiveMinutesID                        MetricCollectorID = "NodeActiveMinutes"
 	NodeCPUModeTotalID                         MetricCollectorID = "NodeCPUModeTotal"
 	NodeRAMSystemUsageAverageID                MetricCollectorID = "NodeRAMSystemUsageAverage"
 	NodeRAMUserUsageAverageID                  MetricCollectorID = "NodeRAMUserUsageAverage"
+	NodeResourceCapacitiesID                   MetricCollectorID = "NodeResourceCapacities"
+	NodeResourcesAllocatableID                 MetricCollectorID = "NodeResourcesAllocatable"
 	LBPricePerHourID                           MetricCollectorID = "LBPricePerHour"
 	LBActiveMinutesID                          MetricCollectorID = "LBActiveMinutes"
+	ClusterInfoID                              MetricCollectorID = "ClusterInfo"
 	ClusterUptimeID                            MetricCollectorID = "ClusterUptime"
 	ClusterManagementDurationID                MetricCollectorID = "ClusterManagementDuration"
 	ClusterManagementPricePerHourID            MetricCollectorID = "ClusterManagementPricePerHour"
 	PodActiveMinutesID                         MetricCollectorID = "PodActiveMinutes"
+	PodInfoID                                  MetricCollectorID = "PodInfo"
+	PodUptimeID                                MetricCollectorID = "PodUptime"
+	PodOwnerID                                 MetricCollectorID = "PodOwner"
+	PodPVCVolumeID                             MetricCollectorID = "PodPVCVolume"
+	ContainerUptimeID                          MetricCollectorID = "ContainerUptime"
+	PodNetworkEgressBytesID                    MetricCollectorID = "PodNetworkEgressBytes"
+	PodNetworkIngressBytesID                   MetricCollectorID = "PodNetworkIngressBytes"
 	RAMBytesAllocatedID                        MetricCollectorID = "RAMBytesAllocated"
 	RAMRequestsID                              MetricCollectorID = "RAMRequests"
 	RAMLimitsID                                MetricCollectorID = "RAMLimits"
 	RAMUsageAverageID                          MetricCollectorID = "RAMUsageAverage"
-	RAMUsageMaxID                              MetricCollectorID = "RAMUsageMax"
+	RAMUsageMaxID                              MetricCollectorID = "RAMBytesUsageMax"
 	CPUCoresAllocatedID                        MetricCollectorID = "CPUCoresAllocated"
 	CPURequestsID                              MetricCollectorID = "CPURequestsID"
 	CPULimitsID                                MetricCollectorID = "CPULimitsID"
@@ -61,6 +75,10 @@ const (
 	NodeRAMPricePerGiBHourID                   MetricCollectorID = "NodeRAMPricePerGiBHour"
 	NodeGPUPricePerHourID                      MetricCollectorID = "NodeGPUPricePerHour"
 	NodeIsSpotID                               MetricCollectorID = "NodeIsSpot"
+	DCGMInfoID                                 MetricCollectorID = "DCGMInfo"
+	DCGMUptimeID                               MetricCollectorID = "DCGMUptime"
+	DCGMContainerUsageAvgID                    MetricCollectorID = "DCGMContainerUsageAvg"
+	DCGMContainerUsageMaxID                    MetricCollectorID = "DCGMContainerUsageMax"
 	PodPVCAllocationID                         MetricCollectorID = "PodPVCAllocation"
 	PVCBytesRequestedID                        MetricCollectorID = "PVCBytesRequested"
 	PVBytesID                                  MetricCollectorID = "PVBytesID"
@@ -82,19 +100,50 @@ const (
 	NetInternetServiceIngressGiBID             MetricCollectorID = "NetInternetServiceIngressGiB"
 	NetNatGatewayIngressGiBID                  MetricCollectorID = "NetNatGatewayIngressGiB"
 	NetReceiveBytesID                          MetricCollectorID = "NetReceiveBytes"
+	NamespaceInfoID                            MetricCollectorID = "NamespaceInfo"
 	NamespaceUptimeID                          MetricCollectorID = "NamespaceUptime"
 	NamespaceLabelsID                          MetricCollectorID = "NamespaceLabels"
 	NamespaceAnnotationsID                     MetricCollectorID = "NamespaceAnnotations"
 	PodLabelsID                                MetricCollectorID = "PodLabels"
 	PodAnnotationsID                           MetricCollectorID = "PodAnnotations"
 	ServiceLabelsID                            MetricCollectorID = "ServiceLabels"
+	ServiceInfoID                              MetricCollectorID = "ServiceInfo"
+	ServiceUptimeID                            MetricCollectorID = "ServiceUptime"
+	DeploymentInfoID                           MetricCollectorID = "DeploymentInfo"
+	DeploymentUptimeID                         MetricCollectorID = "DeploymentUptime"
 	DeploymentLabelsID                         MetricCollectorID = "DeploymentLabels"
+	DeploymentAnnotationsID                    MetricCollectorID = "DeploymentAnnotations"
+	DeploymentMatchLabelsID                    MetricCollectorID = "DeploymentMatchLabels"
+	StatefulSetInfoID                          MetricCollectorID = "StatefulSetInfo"
+	StatefulSetUptimeID                        MetricCollectorID = "StatefulSetUptime"
 	StatefulSetLabelsID                        MetricCollectorID = "StatefulSetLabels"
+	StatefulSetAnnotationsID                   MetricCollectorID = "StatefulSetAnnotations"
+	StatefulSetMatchLabelsID                   MetricCollectorID = "StatefulSetMatchLabels"
+	DaemonSetInfoID                            MetricCollectorID = "DaemonSetInfo"
+	DaemonSetUptimeID                          MetricCollectorID = "DaemonSetUptime"
 	DaemonSetLabelsID                          MetricCollectorID = "DaemonSetLabels"
+	DaemonSetAnnotationsID                     MetricCollectorID = "DaemonSetAnnotations"
+	JobInfoID                                  MetricCollectorID = "JobInfo"
+	JobUptimeID                                MetricCollectorID = "JobUptime"
 	JobLabelsID                                MetricCollectorID = "JobLabels"
+	JobAnnotationsID                           MetricCollectorID = "JobAnnotations"
+	CronJobInfoID                              MetricCollectorID = "CronJobInfo"
+	CronJobUptimeID                            MetricCollectorID = "CronJobUptime"
+	CronJobLabelsID                            MetricCollectorID = "CronJobLabels"
+	CronJobAnnotationsID                       MetricCollectorID = "CronJobAnnotations"
+	ReplicaSetInfoID                           MetricCollectorID = "ReplicaSetInfo"
+	ReplicaSetUptimeID                         MetricCollectorID = "ReplicaSetUptime"
+	ReplicaSetLabelsID                         MetricCollectorID = "ReplicaSetLabels"
+	ReplicaSetAnnotationsID                    MetricCollectorID = "ReplicaSetAnnotations"
+	ReplicaSetOwnerID                          MetricCollectorID = "ReplicaSetOwner"
+	PodsWithDaemonSetOwnerID                   MetricCollectorID = "PodsWithDaemonSetOwner"
+	PodsWithJobOwnerID                         MetricCollectorID = "PodsWithJobOwner"
 	PodsWithReplicaSetOwnerID                  MetricCollectorID = "PodsWithReplicaSetOwner"
 	ReplicaSetsWithoutOwnersID                 MetricCollectorID = "ReplicaSetsWithoutOwners"
 	ReplicaSetsWithRolloutID                   MetricCollectorID = "ReplicaSetsWithRollout"
+	ContainerResourceRequestsID                MetricCollectorID = "ContainerResourceRequests"
+	ContainerResourceLimitsID                  MetricCollectorID = "ContainerResourceLimits"
+	ResourceQuotaInfoID                        MetricCollectorID = "ResourceQuotaInfo"
 	ResourceQuotaUptimeID                      MetricCollectorID = "ResourceQuotaUptime"
 	ResourceQuotaSpecCPURequestAverageID       MetricCollectorID = "ResourceQuotaSpecCPURequestAverage"
 	ResourceQuotaSpecCPURequestMaxID           MetricCollectorID = "ResourceQuotaSpecCPURequestMax"

+ 25 - 1
modules/collector-source/pkg/metric/metrics.go

@@ -3,6 +3,11 @@ package metric
 const (
 	// Cluster Cache Metrics
 	ClusterInfo                                           = "cluster_info"
+	NodeInfo                                              = "node_info"
+	NodeResourceCapacities                                = "node_resource_capacities"
+	NodeResourcesAllocatable                              = "node_resources_allocatable"
+	PodInfo                                               = "pod_info"
+	PodPVCVolume                                          = "pod_pvc_volume"
 	KubeNodeStatusCapacityCPUCores                        = "kube_node_status_capacity_cpu_cores"
 	KubeNodeStatusCapacityMemoryBytes                     = "kube_node_status_capacity_memory_bytes"
 	KubeNodeStatusAllocatableCPUCores                     = "kube_node_status_allocatable_cpu_cores"
@@ -18,12 +23,31 @@ const (
 	KubePersistentVolumeClaimResourceRequestsStorageBytes = "kube_persistentvolumeclaim_resource_requests_storage_bytes"
 	KubecostPVInfo                                        = "kubecost_pv_info"
 	KubePersistentVolumeCapacityBytes                     = "kube_persistentvolume_capacity_bytes"
+	DeploymentInfo                                        = "deployment_info"
+	DeploymentLabels                                      = "deployment_labels"
+	DeploymentAnnotations                                 = "deployment_annotations"
 	DeploymentMatchLabels                                 = "deployment_match_labels"
+	StatefulSetInfo                                       = "statefulset_info"
+	StatefulSetLabels                                     = "statefulset_labels"
+	StatefulSetAnnotations                                = "statefulset_annotations"
+	StatefulSetMatchLabels                                = "statefulSet_match_labels"
+	DaemonSetInfo                                         = "daemonset_info"
+	DaemonSetLabels                                       = "daemonset_labels"
+	DaemonSetAnnotations                                  = "daemonset_annotations"
+	JobInfo                                               = "job_info"
+	JobLabels                                             = "job_labels"
+	JobAnnotations                                        = "job_annotations"
+	CronJobInfo                                           = "cronjob_info"
+	CronJobLabels                                         = "cronjob_labels"
+	CronJobAnnotations                                    = "cronjob_annotations"
+	ReplicaSetInfo                                        = "replicaset_info"
+	ReplicaSetLabels                                      = "replicaset_labels"
+	ReplicaSetAnnotations                                 = "replicaset_annotations"
 	NamespaceInfo                                         = "namespace_info"
 	KubeNamespaceLabels                                   = "kube_namespace_labels"
 	KubeNamespaceAnnotations                              = "kube_namespace_annotations"
+	ServiceInfo                                           = "service_info"
 	ServiceSelectorLabels                                 = "service_selector_labels"
-	StatefulSetMatchLabels                                = "statefulSet_match_labels"
 	KubeReplicasetOwner                                   = "kube_replicaset_owner"
 	ResourceQuotaInfo                                     = "resourcequota_info"
 	KubeResourceQuotaSpecResourceRequests                 = "resourcequota_spec_resource_requests"

+ 614 - 132
modules/collector-source/pkg/scrape/clustercache.go

@@ -4,11 +4,13 @@ import (
 	"fmt"
 	"slices"
 	"strings"
+	"sync"
 
 	"github.com/kubecost/events"
 	"github.com/opencost/opencost/core/pkg/clustercache"
 	"github.com/opencost/opencost/core/pkg/log"
 	"github.com/opencost/opencost/core/pkg/source"
+	coreutil "github.com/opencost/opencost/core/pkg/util"
 	"github.com/opencost/opencost/core/pkg/util/promutil"
 	"github.com/opencost/opencost/modules/collector-source/pkg/event"
 	"github.com/opencost/opencost/modules/collector-source/pkg/metric"
@@ -16,9 +18,38 @@ import (
 	"golang.org/x/exp/maps"
 	v1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/resource"
+	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/apimachinery/pkg/util/validation"
 )
 
+// SyncMap provides thread-safe concurrent access to a generic map
+type SyncMap[U comparable, T any] struct {
+	mu   sync.RWMutex
+	data map[U]T
+}
+
+// newSyncMap creates a new thread-safe map with the specified initial capacity
+func newSyncMap[U comparable, T any](size int) *SyncMap[U, T] {
+	return &SyncMap[U, T]{
+		data: make(map[U]T, size),
+	}
+}
+
+// Set adds or updates a key-value mapping
+func (sm *SyncMap[U, T]) Set(key U, value T) {
+	sm.mu.Lock()
+	defer sm.mu.Unlock()
+	sm.data[key] = value
+}
+
+// Get retrieves a value by key. Returns the value and a boolean indicating if it was found.
+func (sm *SyncMap[U, T]) Get(key U) (T, bool) {
+	sm.mu.RLock()
+	defer sm.mu.RUnlock()
+	value, ok := sm.data[key]
+	return value, ok
+}
+
 type ClusterCacheScraper struct {
 	clusterCache clustercache.ClusterCache
 }
@@ -29,25 +60,71 @@ func newClusterCacheScraper(clusterCache clustercache.ClusterCache) Scraper {
 	}
 }
 
+type pvcKey struct {
+	name      string
+	namespace string
+}
+
 func (ccs *ClusterCacheScraper) Scrape() []metric.Update {
+	// retrieve objects for scrape
+	nodes := ccs.clusterCache.GetAllNodes()
+	deployments := ccs.clusterCache.GetAllDeployments()
+	namespaces := ccs.clusterCache.GetAllNamespaces()
+	pods := ccs.clusterCache.GetAllPods()
+	pvcs := ccs.clusterCache.GetAllPersistentVolumeClaims()
+	pvs := ccs.clusterCache.GetAllPersistentVolumes()
+	services := ccs.clusterCache.GetAllServices()
+	statefulSets := ccs.clusterCache.GetAllStatefulSets()
+	daemonSets := ccs.clusterCache.GetAllDaemonSets()
+	jobs := ccs.clusterCache.GetAllJobs()
+	cronJobs := ccs.clusterCache.GetAllCronJobs()
+	replicaSets := ccs.clusterCache.GetAllReplicaSets()
+	resourceQuotas := ccs.clusterCache.GetAllResourceQuotas()
+
+	// create scrape indexes. While the pairs being mapped here don't have a 1 to 1 relationship in the general case,
+	// we are assuming that in the context of a single snapshot of the cluster they are 1 to 1.
+	nodeNameToUID := newSyncMap[string, types.UID](len(nodes))
+	for _, node := range nodes {
+		nodeNameToUID.Set(node.Name, node.UID)
+	}
+	namespaceNameToUID := newSyncMap[string, types.UID](len(namespaces))
+	for _, ns := range namespaces {
+		namespaceNameToUID.Set(ns.Name, ns.UID)
+	}
+	pvcNameToUID := newSyncMap[pvcKey, types.UID](len(pvcs))
+	for _, pvc := range pvcs {
+		pvcNameToUID.Set(pvcKey{
+			name:      pvc.Name,
+			namespace: pvc.Namespace,
+		}, pvc.UID)
+	}
+	pvNameToUID := newSyncMap[string, types.UID](len(pvs))
+	for _, pv := range pvs {
+		pvNameToUID.Set(pv.Name, pv.UID)
+	}
+
 	scrapeFuncs := []ScrapeFunc{
-		ccs.ScrapeNodes,
-		ccs.ScrapeDeployments,
-		ccs.ScrapeNamespaces,
-		ccs.ScrapePods,
-		ccs.ScrapePVCs,
-		ccs.ScrapePVs,
-		ccs.ScrapeServices,
-		ccs.ScrapeStatefulSets,
-		ccs.ScrapeReplicaSets,
-		ccs.ScrapeResourceQuotas,
+		ccs.GetScrapeNodes(nodes),
+		ccs.GetScrapeDeployments(deployments, namespaceNameToUID),
+		ccs.GetScrapeNamespaces(namespaces),
+		ccs.GetScrapePods(pods, nodeNameToUID, namespaceNameToUID, pvcNameToUID),
+		ccs.GetScrapePVCs(pvcs, namespaceNameToUID, pvNameToUID),
+		ccs.GetScrapePVs(pvs),
+		ccs.GetScrapeServices(services),
+		ccs.GetScrapeStatefulSets(statefulSets, namespaceNameToUID),
+		ccs.GetScrapeDaemonSets(daemonSets, namespaceNameToUID),
+		ccs.GetScrapeJobs(jobs, namespaceNameToUID),
+		ccs.GetScrapeCronJobs(cronJobs, namespaceNameToUID),
+		ccs.GetScrapeReplicaSets(replicaSets, namespaceNameToUID),
+		ccs.GetScrapeResourceQuotas(resourceQuotas, namespaceNameToUID),
 	}
 	return concurrentScrape(scrapeFuncs...)
 }
 
-func (ccs *ClusterCacheScraper) ScrapeNodes() []metric.Update {
-	nodes := ccs.clusterCache.GetAllNodes()
-	return ccs.scrapeNodes(nodes)
+func (ccs *ClusterCacheScraper) GetScrapeNodes(nodes []*clustercache.Node) ScrapeFunc {
+	return func() []metric.Update {
+		return ccs.scrapeNodes(nodes)
+	}
 }
 
 func (ccs *ClusterCacheScraper) scrapeNodes(nodes []*clustercache.Node) []metric.Update {
@@ -60,7 +137,24 @@ func (ccs *ClusterCacheScraper) scrapeNodes(nodes []*clustercache.Node) []metric
 			source.UIDLabel:        string(node.UID),
 		}
 
+		if instanceType, ok := coreutil.GetInstanceType(node.Labels); ok {
+			nodeInfo[source.InstanceTypeLabel] = instanceType
+		}
+
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.NodeInfo,
+			Labels:         nodeInfo,
+			AdditionalInfo: nodeInfo,
+		})
+
 		// Node Capacity
+		scrapeResults = scrapeResourceList(
+			metric.NodeResourceCapacities,
+			node.Status.Capacity,
+			nodeInfo,
+			scrapeResults)
+
+		// This block and metric can be removed, when we stop exporting assets and allocations
 		if node.Status.Capacity != nil {
 			if quantity, ok := node.Status.Capacity[v1.ResourceCPU]; ok {
 				_, _, value := toResourceUnitValue(v1.ResourceCPU, quantity)
@@ -82,6 +176,13 @@ func (ccs *ClusterCacheScraper) scrapeNodes(nodes []*clustercache.Node) []metric
 		}
 
 		// Node Allocatable Resources
+		scrapeResults = scrapeResourceList(
+			metric.NodeResourcesAllocatable,
+			node.Status.Allocatable,
+			nodeInfo,
+			scrapeResults)
+
+		// This block and metric can be removed, when we stop exporting assets and allocations
 		if node.Status.Allocatable != nil {
 			if quantity, ok := node.Status.Allocatable[v1.ResourceCPU]; ok {
 				_, _, value := toResourceUnitValue(v1.ResourceCPU, quantity)
@@ -125,30 +226,65 @@ func (ccs *ClusterCacheScraper) scrapeNodes(nodes []*clustercache.Node) []metric
 	return scrapeResults
 }
 
-func (ccs *ClusterCacheScraper) ScrapeDeployments() []metric.Update {
-	deployments := ccs.clusterCache.GetAllDeployments()
-	return ccs.scrapeDeployments(deployments)
+func (ccs *ClusterCacheScraper) GetScrapeDeployments(deployments []*clustercache.Deployment, namespaceIndex *SyncMap[string, types.UID]) ScrapeFunc {
+	return func() []metric.Update {
+		return ccs.scrapeDeployments(deployments, namespaceIndex)
+	}
 }
 
-func (ccs *ClusterCacheScraper) scrapeDeployments(deployments []*clustercache.Deployment) []metric.Update {
+func (ccs *ClusterCacheScraper) scrapeDeployments(deployments []*clustercache.Deployment, namespaceIndex *SyncMap[string, types.UID]) []metric.Update {
 	var scrapeResults []metric.Update
 	for _, deployment := range deployments {
+		nsUID, ok := namespaceIndex.Get(deployment.Namespace)
+		if !ok {
+			log.Debugf("deployment namespaceUID missing from index for namespace name '%s'", deployment.Namespace)
+		}
 		deploymentInfo := map[string]string{
-			source.DeploymentLabel: deployment.Name,
-			source.NamespaceLabel:  deployment.Namespace,
-			source.UIDLabel:        string(deployment.UID),
+			source.UIDLabel:          string(deployment.UID),
+			source.NamespaceUIDLabel: string(nsUID),
+			source.DeploymentLabel:   deployment.Name,
 		}
 
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.DeploymentLabels,
+			Labels:         deploymentInfo,
+			Value:          0,
+			AdditionalInfo: deploymentInfo,
+		})
+
 		// deployment labels
-		labelNames, labelValues := promutil.KubeLabelsToLabels(deployment.MatchLabels)
+		labelNames, labelValues := promutil.KubeLabelsToLabels(deployment.Labels)
 		deploymentLabels := util.ToMap(labelNames, labelValues)
 
 		scrapeResults = append(scrapeResults, metric.Update{
-			Name:           metric.DeploymentMatchLabels,
+			Name:           metric.DeploymentLabels,
 			Labels:         deploymentInfo,
 			Value:          0,
 			AdditionalInfo: deploymentLabels,
 		})
+
+		// deployment annotations
+		annoationNames, annotationValues := promutil.KubeAnnotationsToLabels(deployment.Annotations)
+		deploymentAnnotations := util.ToMap(annoationNames, annotationValues)
+
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.DeploymentAnnotations,
+			Labels:         deploymentInfo,
+			Value:          0,
+			AdditionalInfo: deploymentAnnotations,
+		})
+
+		// deployment match labels
+		deploymentInfo[source.NamespaceLabel] = deployment.Namespace
+		matchLabelNames, matchLabelValues := promutil.KubeLabelsToLabels(deployment.MatchLabels)
+		deploymentMatchLabels := util.ToMap(matchLabelNames, matchLabelValues)
+
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.DeploymentMatchLabels,
+			Labels:         deploymentInfo,
+			Value:          0,
+			AdditionalInfo: deploymentMatchLabels,
+		})
 	}
 
 	events.Dispatch(event.ScrapeEvent{
@@ -161,9 +297,10 @@ func (ccs *ClusterCacheScraper) scrapeDeployments(deployments []*clustercache.De
 	return scrapeResults
 }
 
-func (ccs *ClusterCacheScraper) ScrapeNamespaces() []metric.Update {
-	namespaces := ccs.clusterCache.GetAllNamespaces()
-	return ccs.scrapeNamespaces(namespaces)
+func (ccs *ClusterCacheScraper) GetScrapeNamespaces(namespaces []*clustercache.Namespace) ScrapeFunc {
+	return func() []metric.Update {
+		return ccs.scrapeNamespaces(namespaces)
+	}
 }
 
 func (ccs *ClusterCacheScraper) scrapeNamespaces(namespaces []*clustercache.Namespace) []metric.Update {
@@ -212,21 +349,49 @@ func (ccs *ClusterCacheScraper) scrapeNamespaces(namespaces []*clustercache.Name
 	return scrapeResults
 }
 
-func (ccs *ClusterCacheScraper) ScrapePods() []metric.Update {
-	pods := ccs.clusterCache.GetAllPods()
-	return ccs.scrapePods(pods)
+func (ccs *ClusterCacheScraper) GetScrapePods(
+	pods []*clustercache.Pod,
+	nodeIndex,
+	namespaceIndex *SyncMap[string, types.UID],
+	pvcIndex *SyncMap[pvcKey, types.UID],
+) ScrapeFunc {
+	return func() []metric.Update {
+		return ccs.scrapePods(pods, nodeIndex, namespaceIndex, pvcIndex)
+	}
 }
 
-func (ccs *ClusterCacheScraper) scrapePods(pods []*clustercache.Pod) []metric.Update {
+func (ccs *ClusterCacheScraper) scrapePods(
+	pods []*clustercache.Pod,
+	nodeIndex,
+	namespaceIndex *SyncMap[string, types.UID],
+	pvcIndex *SyncMap[pvcKey, types.UID],
+) []metric.Update {
 	var scrapeResults []metric.Update
 	for _, pod := range pods {
+		nodeUID, ok := nodeIndex.Get(pod.Spec.NodeName)
+		if !ok {
+			log.Debugf("pod nodeUID missing from index for node name '%s'", pod.Spec.NodeName)
+		}
+		nsUID, ok := namespaceIndex.Get(pod.Namespace)
+		if !ok {
+			log.Debugf("pod namespaceUID missing from index for namespace name '%s'", pod.Namespace)
+		}
 		podInfo := map[string]string{
-			source.PodLabel:       pod.Name,
-			source.NamespaceLabel: pod.Namespace,
-			source.UIDLabel:       string(pod.UID),
-			source.NodeLabel:      pod.Spec.NodeName,
-			source.InstanceLabel:  pod.Spec.NodeName,
+			source.UIDLabel:          string(pod.UID),
+			source.PodLabel:          pod.Name,
+			source.NamespaceUIDLabel: string(nsUID),
+			source.NodeUIDLabel:      string(nodeUID),
 		}
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.PodInfo,
+			Labels:         podInfo,
+			Value:          0,
+			AdditionalInfo: podInfo,
+		})
+
+		podInfo[source.NamespaceLabel] = pod.Namespace
+		podInfo[source.NodeLabel] = pod.Spec.NodeName
+		podInfo[source.InstanceLabel] = pod.Spec.NodeName
 
 		// pod labels
 		labelNames, labelValues := promutil.KubeLabelsToLabels(pod.Labels)
@@ -250,9 +415,15 @@ func (ccs *ClusterCacheScraper) scrapePods(pods []*clustercache.Pod) []metric.Up
 
 		// Pod owner metric
 		for _, owner := range pod.OwnerReferences {
+			controller := "false"
+			if owner.Controller != nil && *owner.Controller {
+				controller = "true"
+			}
 			ownerInfo := maps.Clone(podInfo)
 			ownerInfo[source.OwnerKindLabel] = owner.Kind
 			ownerInfo[source.OwnerNameLabel] = owner.Name
+			ownerInfo[source.OwnerUIDLabel] = string(owner.UID)
+			ownerInfo[source.ContainerLabel] = controller
 			scrapeResults = append(scrapeResults, metric.Update{
 				Name:   metric.KubePodOwner,
 				Labels: ownerInfo,
@@ -266,8 +437,28 @@ func (ccs *ClusterCacheScraper) scrapePods(pods []*clustercache.Pod) []metric.Up
 				containerInfo := maps.Clone(podInfo)
 				containerInfo[source.ContainerLabel] = status.Name
 				scrapeResults = append(scrapeResults, metric.Update{
-					Name:   metric.KubePodContainerStatusRunning,
-					Labels: containerInfo,
+					Name:           metric.KubePodContainerStatusRunning,
+					Labels:         containerInfo,
+					AdditionalInfo: containerInfo,
+					Value:          0,
+				})
+			}
+		}
+
+		for _, volume := range pod.Spec.Volumes {
+			if volume.PersistentVolumeClaim != nil {
+				pvcUID, _ := pvcIndex.Get(pvcKey{
+					name:      volume.PersistentVolumeClaim.ClaimName,
+					namespace: pod.Namespace,
+				})
+				podPVCVolumeInfo := map[string]string{
+					source.UIDLabel:           string(pod.UID),
+					source.PVCUIDLabel:        string(pvcUID),
+					source.PodVolumeNameLabel: volume.Name,
+				}
+				scrapeResults = append(scrapeResults, metric.Update{
+					Name:   metric.PodPVCVolume,
+					Labels: podPVCVolumeInfo,
 					Value:  0,
 				})
 			}
@@ -277,56 +468,18 @@ func (ccs *ClusterCacheScraper) scrapePods(pods []*clustercache.Pod) []metric.Up
 			containerInfo := maps.Clone(podInfo)
 			containerInfo[source.ContainerLabel] = container.Name
 			// Requests
-			if container.Resources.Requests != nil {
-				// sorting keys here for testing purposes
-				keys := maps.Keys(container.Resources.Requests)
-				slices.Sort(keys)
-				for _, resourceName := range keys {
-					quantity := container.Resources.Requests[resourceName]
-					resource, unit, value := toResourceUnitValue(resourceName, quantity)
-
-					// failed to parse the resource type
-					if resource == "" {
-						log.DedupedWarningf(5, "Failed to parse resource units and quantity for resource: %s", resourceName)
-						continue
-					}
-
-					resourceRequestInfo := maps.Clone(containerInfo)
-					resourceRequestInfo[source.ResourceLabel] = resource
-					resourceRequestInfo[source.UnitLabel] = unit
-					scrapeResults = append(scrapeResults, metric.Update{
-						Name:   metric.KubePodContainerResourceRequests,
-						Labels: resourceRequestInfo,
-						Value:  value,
-					})
-				}
-			}
+			scrapeResults = scrapeResourceList(
+				metric.KubePodContainerResourceRequests,
+				container.Resources.Requests,
+				containerInfo,
+				scrapeResults)
 
 			// Limits
-			if container.Resources.Limits != nil {
-				// sorting keys here for testing purposes
-				keys := maps.Keys(container.Resources.Limits)
-				slices.Sort(keys)
-				for _, resourceName := range keys {
-					quantity := container.Resources.Limits[resourceName]
-					resource, unit, value := toResourceUnitValue(resourceName, quantity)
-
-					// failed to parse the resource type
-					if resource == "" {
-						log.DedupedWarningf(5, "Failed to parse resource units and quantity for resource: %s", resourceName)
-						continue
-					}
-
-					resourceLimitInfo := maps.Clone(containerInfo)
-					resourceLimitInfo[source.ResourceLabel] = resource
-					resourceLimitInfo[source.UnitLabel] = unit
-					scrapeResults = append(scrapeResults, metric.Update{
-						Name:   metric.KubePodContainerResourceLimits,
-						Labels: resourceLimitInfo,
-						Value:  value,
-					})
-				}
-			}
+			scrapeResults = scrapeResourceList(
+				metric.KubePodContainerResourceLimits,
+				container.Resources.Limits,
+				containerInfo,
+				scrapeResults)
 		}
 	}
 
@@ -340,26 +493,74 @@ func (ccs *ClusterCacheScraper) scrapePods(pods []*clustercache.Pod) []metric.Up
 	return scrapeResults
 }
 
-func (ccs *ClusterCacheScraper) ScrapePVCs() []metric.Update {
-	pvcs := ccs.clusterCache.GetAllPersistentVolumeClaims()
-	return ccs.scrapePVCs(pvcs)
+func scrapeResourceList(metricName string, resourceList v1.ResourceList, baseLabels map[string]string, scrapeResults []metric.Update) []metric.Update {
+	if resourceList != nil {
+		// sorting keys here for testing purposes
+		keys := maps.Keys(resourceList)
+		slices.Sort(keys)
+		for _, resourceName := range keys {
+			quantity := resourceList[resourceName]
+			resource, unit, value := toResourceUnitValue(resourceName, quantity)
+
+			// failed to parse the resource type
+			if resource == "" {
+				log.DedupedWarningf(5, "Failed to parse resource units and quantity for resource: %s", resourceName)
+				continue
+			}
+
+			resourceRequestInfo := maps.Clone(baseLabels)
+			resourceRequestInfo[source.ResourceLabel] = resource
+			resourceRequestInfo[source.UnitLabel] = unit
+			scrapeResults = append(scrapeResults, metric.Update{
+				Name:   metricName,
+				Labels: resourceRequestInfo,
+				Value:  value,
+			})
+		}
+	}
+	return scrapeResults
 }
 
-func (ccs *ClusterCacheScraper) scrapePVCs(pvcs []*clustercache.PersistentVolumeClaim) []metric.Update {
+func (ccs *ClusterCacheScraper) GetScrapePVCs(
+	pvcs []*clustercache.PersistentVolumeClaim,
+	namespaceIndex *SyncMap[string, types.UID],
+	pvIndex *SyncMap[string, types.UID],
+) ScrapeFunc {
+	return func() []metric.Update {
+		return ccs.scrapePVCs(pvcs, namespaceIndex, pvIndex)
+	}
+}
+
+func (ccs *ClusterCacheScraper) scrapePVCs(
+	pvcs []*clustercache.PersistentVolumeClaim,
+	namespaceIndex *SyncMap[string, types.UID],
+	pvIndex *SyncMap[string, types.UID],
+) []metric.Update {
 	var scrapeResults []metric.Update
 	for _, pvc := range pvcs {
+		nsUID, ok := namespaceIndex.Get(pvc.Namespace)
+		if !ok {
+			log.Debugf("pvc namespaceUID missing from index for namespace name '%s'", pvc.Namespace)
+		}
+		pvUID, ok := pvIndex.Get(pvc.Spec.VolumeName)
+		if !ok && pvc.Spec.VolumeName != "" {
+			log.Debugf("pvc volume name missing from index for pv name '%s'", pvc.Spec.VolumeName)
+		}
 		pvcInfo := map[string]string{
+			source.UIDLabel:          string(pvc.UID),
 			source.PVCLabel:          pvc.Name,
+			source.NamespaceUIDLabel: string(nsUID),
 			source.NamespaceLabel:    pvc.Namespace,
-			source.UIDLabel:          string(pvc.UID),
 			source.VolumeNameLabel:   pvc.Spec.VolumeName,
+			source.PVUIDLabel:        string(pvUID),
 			source.StorageClassLabel: getPersistentVolumeClaimClass(pvc),
 		}
 
 		scrapeResults = append(scrapeResults, metric.Update{
-			Name:   metric.KubePersistentVolumeClaimInfo,
-			Labels: pvcInfo,
-			Value:  0,
+			Name:           metric.KubePersistentVolumeClaimInfo,
+			Labels:         pvcInfo,
+			AdditionalInfo: pvcInfo,
+			Value:          0,
 		})
 
 		if storage, ok := pvc.Spec.Resources.Requests[v1.ResourceStorage]; ok {
@@ -381,30 +582,35 @@ func (ccs *ClusterCacheScraper) scrapePVCs(pvcs []*clustercache.PersistentVolume
 	return scrapeResults
 }
 
-func (ccs *ClusterCacheScraper) ScrapePVs() []metric.Update {
-	pvs := ccs.clusterCache.GetAllPersistentVolumes()
-	return ccs.scrapePVs(pvs)
+func (ccs *ClusterCacheScraper) GetScrapePVs(pvs []*clustercache.PersistentVolume) ScrapeFunc {
+	return func() []metric.Update {
+		return ccs.scrapePVs(pvs)
+	}
 }
 
 func (ccs *ClusterCacheScraper) scrapePVs(pvs []*clustercache.PersistentVolume) []metric.Update {
 	var scrapeResults []metric.Update
 	for _, pv := range pvs {
 		providerID := pv.Name
+		var csiVolumeHandle string
 		// if a more accurate provider ID is available, use that
 		if pv.Spec.CSI != nil && pv.Spec.CSI.VolumeHandle != "" {
 			providerID = pv.Spec.CSI.VolumeHandle
+			csiVolumeHandle = pv.Spec.CSI.VolumeHandle
 		}
 		pvInfo := map[string]string{
-			source.PVLabel:           pv.Name,
-			source.UIDLabel:          string(pv.UID),
-			source.StorageClassLabel: pv.Spec.StorageClassName,
-			source.ProviderIDLabel:   providerID,
+			source.UIDLabel:             string(pv.UID),
+			source.PVLabel:              pv.Name,
+			source.StorageClassLabel:    pv.Spec.StorageClassName,
+			source.ProviderIDLabel:      providerID,
+			source.CSIVolumeHandleLabel: csiVolumeHandle,
 		}
 
 		scrapeResults = append(scrapeResults, metric.Update{
-			Name:   metric.KubecostPVInfo,
-			Labels: pvInfo,
-			Value:  0,
+			Name:           metric.KubecostPVInfo,
+			Labels:         pvInfo,
+			AdditionalInfo: pvInfo,
+			Value:          0,
 		})
 
 		if storage, ok := pv.Spec.Capacity[v1.ResourceStorage]; ok {
@@ -426,23 +632,32 @@ func (ccs *ClusterCacheScraper) scrapePVs(pvs []*clustercache.PersistentVolume)
 	return scrapeResults
 }
 
-func (ccs *ClusterCacheScraper) ScrapeServices() []metric.Update {
-	services := ccs.clusterCache.GetAllServices()
-	return ccs.scrapeServices(services)
+func (ccs *ClusterCacheScraper) GetScrapeServices(services []*clustercache.Service) ScrapeFunc {
+	return func() []metric.Update {
+		return ccs.scrapeServices(services)
+	}
 }
 
 func (ccs *ClusterCacheScraper) scrapeServices(services []*clustercache.Service) []metric.Update {
 	var scrapeResults []metric.Update
 	for _, service := range services {
 		serviceInfo := map[string]string{
-			source.ServiceLabel:   service.Name,
-			source.NamespaceLabel: service.Namespace,
-			source.UIDLabel:       string(service.UID),
+			source.UIDLabel:         string(service.UID),
+			source.ServiceLabel:     service.Name,
+			source.NamespaceLabel:   service.Namespace,
+			source.ServiceTypeLabel: string(service.Type),
 		}
 
-		// service labels
-		labelNames, labelValues := promutil.KubeLabelsToLabels(service.SpecSelector)
-		serviceLabels := util.ToMap(labelNames, labelValues)
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.ServiceInfo,
+			Labels:         serviceInfo,
+			Value:          0,
+			AdditionalInfo: serviceInfo,
+		})
+
+		// service selector labels
+		selectorNames, selectorValues := promutil.KubeLabelsToLabels(service.SpecSelector)
+		serviceLabels := util.ToMap(selectorNames, selectorValues)
 		scrapeResults = append(scrapeResults, metric.Update{
 			Name:           metric.ServiceSelectorLabels,
 			Labels:         serviceInfo,
@@ -462,29 +677,66 @@ func (ccs *ClusterCacheScraper) scrapeServices(services []*clustercache.Service)
 	return scrapeResults
 }
 
-func (ccs *ClusterCacheScraper) ScrapeStatefulSets() []metric.Update {
-	statefulSets := ccs.clusterCache.GetAllStatefulSets()
-	return ccs.scrapeStatefulSets(statefulSets)
+func (ccs *ClusterCacheScraper) GetScrapeStatefulSets(statefulSets []*clustercache.StatefulSet, namespaceIndex *SyncMap[string, types.UID]) ScrapeFunc {
+	return func() []metric.Update {
+		return ccs.scrapeStatefulSets(statefulSets, namespaceIndex)
+	}
 }
 
-func (ccs *ClusterCacheScraper) scrapeStatefulSets(statefulSets []*clustercache.StatefulSet) []metric.Update {
+func (ccs *ClusterCacheScraper) scrapeStatefulSets(statefulSets []*clustercache.StatefulSet, namespaceIndex *SyncMap[string, types.UID]) []metric.Update {
 	var scrapeResults []metric.Update
 	for _, statefulSet := range statefulSets {
+		nsUID, ok := namespaceIndex.Get(statefulSet.Namespace)
+		if !ok {
+			log.Debugf("statefulSet namespaceUID missing from index for namespace name '%s'", statefulSet.Namespace)
+		}
 		statefulSetInfo := map[string]string{
-			source.StatefulSetLabel: statefulSet.Name,
-			source.NamespaceLabel:   statefulSet.Namespace,
-			source.UIDLabel:         string(statefulSet.UID),
+			source.UIDLabel:          string(statefulSet.UID),
+			source.NamespaceUIDLabel: string(nsUID),
+			source.StatefulSetLabel:  statefulSet.Name,
 		}
 
+		// statefulSet info
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.StatefulSetInfo,
+			Labels:         statefulSetInfo,
+			Value:          0,
+			AdditionalInfo: statefulSetInfo,
+		})
+
 		// statefulSet labels
-		labelNames, labelValues := promutil.KubeLabelsToLabels(statefulSet.SpecSelector.MatchLabels)
+		labelNames, labelValues := promutil.KubeLabelsToLabels(statefulSet.Labels)
 		statefulSetLabels := util.ToMap(labelNames, labelValues)
+
 		scrapeResults = append(scrapeResults, metric.Update{
-			Name:           metric.StatefulSetMatchLabels,
+			Name:           metric.StatefulSetLabels,
 			Labels:         statefulSetInfo,
 			Value:          0,
 			AdditionalInfo: statefulSetLabels,
 		})
+
+		// statefulSet annotations
+		annotationNames, annotationValues := promutil.KubeAnnotationsToLabels(statefulSet.Annotations)
+		statefulSetAnnotations := util.ToMap(annotationNames, annotationValues)
+
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.StatefulSetAnnotations,
+			Labels:         statefulSetInfo,
+			Value:          0,
+			AdditionalInfo: statefulSetAnnotations,
+		})
+
+		// statefulSet match labels
+		statefulSetInfo[source.NamespaceLabel] = statefulSet.Namespace
+		matchLabelNames, matchLabelValues := promutil.KubeLabelsToLabels(statefulSet.SpecSelector.MatchLabels)
+		statefulSetMatchLabels := util.ToMap(matchLabelNames, matchLabelValues)
+
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.StatefulSetMatchLabels,
+			Labels:         statefulSetInfo,
+			Value:          0,
+			AdditionalInfo: statefulSetMatchLabels,
+		})
 	}
 
 	events.Dispatch(event.ScrapeEvent{
@@ -497,15 +749,237 @@ func (ccs *ClusterCacheScraper) scrapeStatefulSets(statefulSets []*clustercache.
 	return scrapeResults
 }
 
-func (ccs *ClusterCacheScraper) ScrapeReplicaSets() []metric.Update {
-	replicaSets := ccs.clusterCache.GetAllReplicaSets()
-	return ccs.scrapeReplicaSets(replicaSets)
+func (ccs *ClusterCacheScraper) GetScrapeDaemonSets(daemonSets []*clustercache.DaemonSet, namespaceIndex *SyncMap[string, types.UID]) ScrapeFunc {
+	return func() []metric.Update {
+		return ccs.scrapeDaemonSets(daemonSets, namespaceIndex)
+	}
+}
+
+func (ccs *ClusterCacheScraper) scrapeDaemonSets(daemonSets []*clustercache.DaemonSet, namespaceIndex *SyncMap[string, types.UID]) []metric.Update {
+	var scrapeResults []metric.Update
+	for _, daemonSet := range daemonSets {
+		nsUID, ok := namespaceIndex.Get(daemonSet.Namespace)
+		if !ok {
+			log.Debugf("daemonSet namespaceUID missing from index for namespace name '%s'", daemonSet.Namespace)
+		}
+		daemonSetInfo := map[string]string{
+			source.UIDLabel:          string(daemonSet.UID),
+			source.NamespaceUIDLabel: string(nsUID),
+			source.DaemonSetLabel:    daemonSet.Name,
+		}
+
+		// daemonSet info
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.DaemonSetInfo,
+			Labels:         daemonSetInfo,
+			Value:          0,
+			AdditionalInfo: daemonSetInfo,
+		})
+
+		// daemonSet labels
+		labelNames, labelValues := promutil.KubeLabelsToLabels(daemonSet.Labels)
+		daemonSetLabels := util.ToMap(labelNames, labelValues)
+
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.DaemonSetLabels,
+			Labels:         daemonSetInfo,
+			Value:          0,
+			AdditionalInfo: daemonSetLabels,
+		})
+
+		// daemonSet annotations
+		annotationNames, annotationValues := promutil.KubeAnnotationsToLabels(daemonSet.Annotations)
+		daemonSetAnnotations := util.ToMap(annotationNames, annotationValues)
+
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.DaemonSetAnnotations,
+			Labels:         daemonSetInfo,
+			Value:          0,
+			AdditionalInfo: daemonSetAnnotations,
+		})
+	}
+
+	events.Dispatch(event.ScrapeEvent{
+		ScraperName: event.KubernetesClusterScraperName,
+		ScrapeType:  event.DaemonSetScraperType,
+		Targets:     len(daemonSets),
+		Errors:      nil,
+	})
+
+	return scrapeResults
+}
+
+func (ccs *ClusterCacheScraper) GetScrapeJobs(jobs []*clustercache.Job, namespaceIndex *SyncMap[string, types.UID]) ScrapeFunc {
+	return func() []metric.Update {
+		return ccs.scrapeJobs(jobs, namespaceIndex)
+	}
+}
+
+func (ccs *ClusterCacheScraper) scrapeJobs(jobs []*clustercache.Job, namespaceIndex *SyncMap[string, types.UID]) []metric.Update {
+	var scrapeResults []metric.Update
+	for _, job := range jobs {
+		nsUID, ok := namespaceIndex.Get(job.Namespace)
+		if !ok {
+			log.Debugf("job namespaceUID missing from index for namespace name '%s'", job.Namespace)
+		}
+		jobInfo := map[string]string{
+			source.UIDLabel:          string(job.UID),
+			source.NamespaceUIDLabel: string(nsUID),
+			source.JobLabel:          job.Name,
+		}
+
+		// job info
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.JobInfo,
+			Labels:         jobInfo,
+			Value:          0,
+			AdditionalInfo: jobInfo,
+		})
+
+		// job labels
+		labelNames, labelValues := promutil.KubeLabelsToLabels(job.Labels)
+		jobLabels := util.ToMap(labelNames, labelValues)
+
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.JobLabels,
+			Labels:         jobInfo,
+			Value:          0,
+			AdditionalInfo: jobLabels,
+		})
+
+		// job annotations
+		annotationNames, annotationValues := promutil.KubeAnnotationsToLabels(job.Annotations)
+		jobAnnotations := util.ToMap(annotationNames, annotationValues)
+
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.JobAnnotations,
+			Labels:         jobInfo,
+			Value:          0,
+			AdditionalInfo: jobAnnotations,
+		})
+	}
+
+	events.Dispatch(event.ScrapeEvent{
+		ScraperName: event.KubernetesClusterScraperName,
+		ScrapeType:  event.JobScraperType,
+		Targets:     len(jobs),
+		Errors:      nil,
+	})
+
+	return scrapeResults
+}
+
+func (ccs *ClusterCacheScraper) GetScrapeCronJobs(cronJobs []*clustercache.CronJob, namespaceIndex *SyncMap[string, types.UID]) ScrapeFunc {
+	return func() []metric.Update {
+		return ccs.scrapeCronJobs(cronJobs, namespaceIndex)
+	}
+}
+
+func (ccs *ClusterCacheScraper) scrapeCronJobs(cronJobs []*clustercache.CronJob, namespaceIndex *SyncMap[string, types.UID]) []metric.Update {
+	var scrapeResults []metric.Update
+	for _, cronJob := range cronJobs {
+		nsUID, ok := namespaceIndex.Get(cronJob.Namespace)
+		if !ok {
+			log.Debugf("cronjob namespaceUID missing from index for namespace name '%s'", cronJob.Namespace)
+		}
+		cronJobInfo := map[string]string{
+			source.UIDLabel:          string(cronJob.UID),
+			source.NamespaceUIDLabel: string(nsUID),
+			source.CronJobLabel:      cronJob.Name,
+		}
+
+		// cronjob info
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.CronJobInfo,
+			Labels:         cronJobInfo,
+			Value:          0,
+			AdditionalInfo: cronJobInfo,
+		})
+
+		// cronjob labels
+		labelNames, labelValues := promutil.KubeLabelsToLabels(cronJob.Labels)
+		cronJobLabels := util.ToMap(labelNames, labelValues)
+
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.CronJobLabels,
+			Labels:         cronJobInfo,
+			Value:          0,
+			AdditionalInfo: cronJobLabels,
+		})
+
+		// cronjob annotations
+		annotationNames, annotationValues := promutil.KubeAnnotationsToLabels(cronJob.Annotations)
+		cronJobAnnotations := util.ToMap(annotationNames, annotationValues)
+
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.CronJobAnnotations,
+			Labels:         cronJobInfo,
+			Value:          0,
+			AdditionalInfo: cronJobAnnotations,
+		})
+	}
+
+	events.Dispatch(event.ScrapeEvent{
+		ScraperName: event.KubernetesClusterScraperName,
+		ScrapeType:  event.CronJobScraperType,
+		Targets:     len(cronJobs),
+		Errors:      nil,
+	})
+
+	return scrapeResults
+}
+
+func (ccs *ClusterCacheScraper) GetScrapeReplicaSets(replicaSets []*clustercache.ReplicaSet, namespaceIndex *SyncMap[string, types.UID]) ScrapeFunc {
+	return func() []metric.Update {
+		return ccs.scrapeReplicaSets(replicaSets, namespaceIndex)
+	}
 }
 
-func (ccs *ClusterCacheScraper) scrapeReplicaSets(replicaSets []*clustercache.ReplicaSet) []metric.Update {
+func (ccs *ClusterCacheScraper) scrapeReplicaSets(replicaSets []*clustercache.ReplicaSet, namespaceIndex *SyncMap[string, types.UID]) []metric.Update {
 	var scrapeResults []metric.Update
 	for _, replicaSet := range replicaSets {
+		nsUID, ok := namespaceIndex.Get(replicaSet.Namespace)
+		if !ok {
+			log.Debugf("replicaset namespaceUID missing from index for namespace name '%s'", replicaSet.Namespace)
+		}
 		replicaSetInfo := map[string]string{
+			source.UIDLabel:          string(replicaSet.UID),
+			source.NamespaceUIDLabel: string(nsUID),
+			source.ReplicaSetLabel:   replicaSet.Name,
+		}
+
+		// replicaset info
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.ReplicaSetInfo,
+			Labels:         replicaSetInfo,
+			Value:          0,
+			AdditionalInfo: replicaSetInfo,
+		})
+
+		// replicaset labels
+		labelNames, labelValues := promutil.KubeLabelsToLabels(replicaSet.Labels)
+		replicaSetLabels := util.ToMap(labelNames, labelValues)
+
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.ReplicaSetLabels,
+			Labels:         replicaSetInfo,
+			Value:          0,
+			AdditionalInfo: replicaSetLabels,
+		})
+
+		// replicaset annotations
+		annotationNames, annotationValues := promutil.KubeAnnotationsToLabels(replicaSet.Annotations)
+		replicaSetAnnotations := util.ToMap(annotationNames, annotationValues)
+
+		scrapeResults = append(scrapeResults, metric.Update{
+			Name:           metric.ReplicaSetAnnotations,
+			Labels:         replicaSetInfo,
+			Value:          0,
+			AdditionalInfo: replicaSetAnnotations,
+		})
+
+		// owner references for backward compatibility
+		replicaSetOwnerInfo := map[string]string{
 			source.ReplicaSetLabel: replicaSet.Name,
 			source.NamespaceLabel:  replicaSet.Namespace,
 			source.UIDLabel:        string(replicaSet.UID),
@@ -514,7 +988,7 @@ func (ccs *ClusterCacheScraper) scrapeReplicaSets(replicaSets []*clustercache.Re
 		// this specific metric exports a special <none> value for name and kind
 		// if there are no owners
 		if len(replicaSet.OwnerReferences) == 0 {
-			ownerInfo := maps.Clone(replicaSetInfo)
+			ownerInfo := maps.Clone(replicaSetOwnerInfo)
 			ownerInfo[source.OwnerKindLabel] = source.NoneLabelValue
 			ownerInfo[source.OwnerNameLabel] = source.NoneLabelValue
 			scrapeResults = append(scrapeResults, metric.Update{
@@ -524,9 +998,15 @@ func (ccs *ClusterCacheScraper) scrapeReplicaSets(replicaSets []*clustercache.Re
 			})
 		} else {
 			for _, owner := range replicaSet.OwnerReferences {
-				ownerInfo := maps.Clone(replicaSetInfo)
+				controller := "false"
+				if owner.Controller != nil && *owner.Controller {
+					controller = "true"
+				}
+				ownerInfo := maps.Clone(replicaSetOwnerInfo)
 				ownerInfo[source.OwnerKindLabel] = owner.Kind
 				ownerInfo[source.OwnerNameLabel] = owner.Name
+				ownerInfo[source.OwnerUIDLabel] = string(owner.UID)
+				ownerInfo[source.ControllerLabel] = controller
 				scrapeResults = append(scrapeResults, metric.Update{
 					Name:   metric.KubeReplicasetOwner,
 					Labels: ownerInfo,
@@ -546,12 +1026,13 @@ func (ccs *ClusterCacheScraper) scrapeReplicaSets(replicaSets []*clustercache.Re
 	return scrapeResults
 }
 
-func (ccs *ClusterCacheScraper) ScrapeResourceQuotas() []metric.Update {
-	resourceQuotas := ccs.clusterCache.GetAllResourceQuotas()
-	return ccs.scrapeResourceQuotas(resourceQuotas)
+func (ccs *ClusterCacheScraper) GetScrapeResourceQuotas(resourceQuotas []*clustercache.ResourceQuota, namespaceIndex *SyncMap[string, types.UID]) ScrapeFunc {
+	return func() []metric.Update {
+		return ccs.scrapeResourceQuotas(resourceQuotas, namespaceIndex)
+	}
 }
 
-func (ccs *ClusterCacheScraper) scrapeResourceQuotas(resourceQuotas []*clustercache.ResourceQuota) []metric.Update {
+func (ccs *ClusterCacheScraper) scrapeResourceQuotas(resourceQuotas []*clustercache.ResourceQuota, namespaceIndex *SyncMap[string, types.UID]) []metric.Update {
 	var scrapeResults []metric.Update
 
 	processResource := func(baseLabels map[string]string, name v1.ResourceName, quantity resource.Quantity, metricName string) metric.Update {
@@ -569,10 +1050,11 @@ func (ccs *ClusterCacheScraper) scrapeResourceQuotas(resourceQuotas []*clusterca
 	}
 
 	for _, resourceQuota := range resourceQuotas {
+		nsUID, _ := namespaceIndex.Get(resourceQuota.Namespace)
 		resourceQuotaInfo := map[string]string{
-			source.ResourceQuotaLabel: resourceQuota.Name,
-			source.NamespaceLabel:     resourceQuota.Namespace,
 			source.UIDLabel:           string(resourceQuota.UID),
+			source.NamespaceUIDLabel:  string(nsUID),
+			source.ResourceQuotaLabel: resourceQuota.Name,
 		}
 
 		scrapeResults = append(scrapeResults, metric.Update{

+ 4 - 3
modules/collector-source/pkg/scrape/statsummary.go

@@ -139,9 +139,10 @@ func (s *StatSummaryScraper) Scrape() []metric.Update {
 					scrapeResults = append(scrapeResults, metric.Update{
 						Name: metric.ContainerFSUsageBytes,
 						Labels: map[string]string{
-							source.InstanceLabel: nodeName,
-							source.DeviceLabel:   "local",
-							source.UIDLabel:      podUID,
+							source.InstanceLabel:  nodeName,
+							source.DeviceLabel:    "local",
+							source.UIDLabel:       podUID,
+							source.ContainerLabel: container.Name,
 						},
 						Value: float64(*container.Rootfs.UsedBytes),
 					})

File diff suppressed because it is too large
+ 907 - 31
modules/prometheus-source/pkg/prom/metricsquerier.go


+ 3 - 3
modules/prometheus-source/pkg/prom/metricsquerier_test.go

@@ -164,9 +164,9 @@ func TestQueryLogs(t *testing.T) {
 		"QueryNodeLabels":                               func(s, e time.Time) { querier.QueryNodeLabels(s, e) },
 		"QueryNamespaceLabels":                          func(s, e time.Time) { querier.QueryNamespaceLabels(s, e) },
 		"QueryPodLabels":                                func(s, e time.Time) { querier.QueryPodLabels(s, e) },
-		"QueryServiceLabels":                            func(s, e time.Time) { querier.QueryServiceLabels(s, e) },
-		"QueryDeploymentLabels":                         func(s, e time.Time) { querier.QueryDeploymentLabels(s, e) },
-		"QueryStatefulSetLabels":                        func(s, e time.Time) { querier.QueryStatefulSetLabels(s, e) },
+		"QueryServiceSelectorLabels":                    func(s, e time.Time) { querier.QueryServiceSelectorLabels(s, e) },
+		"QueryDeploymentMatchLabels":                    func(s, e time.Time) { querier.QueryDeploymentMatchLabels(s, e) },
+		"QueryStatefulSetMatchLabels":                   func(s, e time.Time) { querier.QueryStatefulSetMatchLabels(s, e) },
 		"QueryDaemonSetLabels":                          func(s, e time.Time) { querier.QueryDaemonSetLabels(s, e) },
 		"QueryJobLabels":                                func(s, e time.Time) { querier.QueryJobLabels(s, e) },
 		"QueryPodsWithReplicaSetOwner":                  func(s, e time.Time) { querier.QueryPodsWithReplicaSetOwner(s, e) },

+ 3 - 3
pkg/cloud/aws/fargate.go

@@ -73,7 +73,7 @@ func (f *FargatePricing) Initialize(nodeList []*clustercache.Node) error {
 		return fmt.Errorf("pricing download failed: status=%d", resp.StatusCode)
 	}
 
-	var pricing AWSPricing
+	var pricing PriceListEC2Response
 	if err := json.NewDecoder(resp.Body).Decode(&pricing); err != nil {
 		return fmt.Errorf("parsing pricing data: %w", err)
 	}
@@ -89,7 +89,7 @@ func (f *FargatePricing) getPricingURL(nodeList []*clustercache.Node) string {
 	return getPricingListURL("AmazonECS", nodeList)
 }
 
-func (f *FargatePricing) populatePricing(pricing *AWSPricing) error {
+func (f *FargatePricing) populatePricing(pricing *PriceListEC2Response) error {
 	// Populate pricing for each region
 productLoop:
 	for sku, product := range pricing.Products {
@@ -121,7 +121,7 @@ productLoop:
 	return nil
 }
 
-func (f *FargatePricing) getPricingOfSKU(sku string, allTerms *AWSPricingTerms) (float64, error) {
+func (f *FargatePricing) getPricingOfSKU(sku string, allTerms *PriceListEC2Terms) (float64, error) {
 	skuTerm, ok := allTerms.OnDemand[sku]
 	if !ok {
 		return 0, fmt.Errorf("missing pricing for sku %s", sku)

+ 3 - 3
pkg/cloud/aws/fargate_test.go

@@ -29,7 +29,7 @@ func TestFargatePricing_populatePricing(t *testing.T) {
 		t.Fatalf("Failed to read test data: %v", err)
 	}
 
-	var pricing AWSPricing
+	var pricing PriceListEC2Response
 	err = json.Unmarshal(data, &pricing)
 	if err != nil {
 		t.Fatalf("Failed to unmarshal test data: %v", err)
@@ -37,7 +37,7 @@ func TestFargatePricing_populatePricing(t *testing.T) {
 
 	tests := []struct {
 		name    string
-		pricing *AWSPricing
+		pricing *PriceListEC2Response
 		wantErr bool
 	}{
 		{
@@ -514,7 +514,7 @@ func TestFargatePricing_ValidateAWSPricingFormat(t *testing.T) {
 		t.Fatalf("Unexpected status code: %d", resp.StatusCode)
 	}
 
-	var pricing AWSPricing
+	var pricing PriceListEC2Response
 	if err := json.NewDecoder(resp.Body).Decode(&pricing); err != nil {
 		t.Fatalf("Failed to decode pricing data - AWS format may have changed: %v", err)
 	}

+ 227 - 0
pkg/cloud/aws/pricelistapi.go

@@ -0,0 +1,227 @@
+package aws
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/util/json"
+	"github.com/opencost/opencost/pkg/env"
+)
+
+// OnDemandRateCodes is are sets of identifiers for offerTermCodes matching 'On Demand' rates
+var OnDemandRateCodes = map[string]struct{}{
+	"JRTCKXETXF": {},
+}
+
+var OnDemandRateCodesCn = map[string]struct{}{
+	"99YE2YK9UR": {},
+	"5Y9WH78GDR": {},
+	"KW44MY7SZN": {},
+}
+
+// HourlyRateCode is appended to a node sku
+const (
+	HourlyRateCode   = "6YS6EN2CT7"
+	HourlyRateCodeCn = "Q7UJUT2CE6"
+)
+
+func getListPriceURL(service, region string) string {
+	if env.GetAWSPricingURL() != "" { // Allow override of pricing URL
+		return env.GetAWSPricingURL()
+	}
+	baseURL := awsPricingBaseURL
+
+	if strings.HasPrefix(region, chinaRegionPrefix) {
+		baseURL = awsChinaPricingBaseURL
+	}
+
+	baseURL += service + pricingCurrentPath
+
+	if region != "" {
+		baseURL += region + "/"
+	}
+	return baseURL + pricingIndexFile
+}
+
+func QueryEC2PriceList(
+	region string,
+	handleProduct func(*PriceListEC2Product),
+	handleTerm func(term *PriceListEC2Term),
+) error {
+	pricingURL := getListPriceURL("AmazonEC2", region)
+
+	log.Infof("starting download of \"%s\", which is quite large ...", pricingURL)
+	resp, err := http.Get(pricingURL)
+	if err != nil {
+		return fmt.Errorf("Bogus fetch of \"%s\": %w", pricingURL, err)
+	}
+
+	dec := json.NewDecoder(resp.Body)
+	for {
+		t, err := dec.Token()
+		if err == io.EOF {
+			log.Infof("done loading \"%s\"\n", resp.Request.URL.String())
+			break
+		} else if err != nil {
+			log.Errorf("error parsing response json %v", resp.Body)
+			break
+		}
+		if t == "products" {
+			_, err := dec.Token() // this should parse the opening "{""
+			if err != nil {
+				return err
+			}
+			for dec.More() {
+				_, err := dec.Token() // the sku token
+				if err != nil {
+					return err
+				}
+				product := &PriceListEC2Product{}
+
+				err = dec.Decode(&product)
+				if err != nil {
+					log.Errorf("Error parsing response from \"%s\": %v", resp.Request.URL.String(), err.Error())
+					break
+				}
+
+				handleProduct(product)
+
+			}
+		}
+		if t == "terms" {
+			_, err := dec.Token() // this should parse the opening "{""
+			if err != nil {
+				return err
+			}
+			termType, err := dec.Token()
+			if err != nil {
+				return err
+			}
+			if termType == "OnDemand" {
+				_, err := dec.Token()
+				if err != nil { // again, should parse an opening "{"
+					return err
+				}
+				for dec.More() {
+					_, err := dec.Token() // sku
+					if err != nil {
+						return err
+					}
+					_, err = dec.Token() // another opening "{"
+					if err != nil {
+						return err
+					}
+					// SKUOndemand
+					_, err = dec.Token()
+					if err != nil {
+						return err
+					}
+					offerTerm := &PriceListEC2Term{}
+					err = dec.Decode(&offerTerm)
+					if err != nil {
+						log.Errorf("Error decoding AWS Offer Term: %s", err.Error())
+					}
+
+					handleTerm(offerTerm)
+
+					_, err = dec.Token()
+					if err != nil {
+						return err
+					}
+				}
+				_, err = dec.Token()
+				if err != nil {
+					return err
+				}
+			}
+		}
+	}
+	return nil
+}
+
+// PriceListEC2Response maps a k8s node to an AWS Pricing "product"
+type PriceListEC2Response struct {
+	Products map[string]*PriceListEC2Product `json:"products"`
+	Terms    PriceListEC2Terms               `json:"terms"`
+}
+
+// PriceListEC2Product represents a purchased SKU
+type PriceListEC2Product struct {
+	Sku        string                        `json:"sku"`
+	Attributes PriceListEC2ProductAttributes `json:"attributes"`
+}
+
+// PriceListEC2ProductAttributes represents metadata about the product used to map to a node.
+type PriceListEC2ProductAttributes struct {
+	ServiceCode  string `json:"servicecode"`
+	InstanceType string `json:"instanceType"`
+	UsageType    string `json:"usagetype"`
+	Operation    string `json:"operation"`
+	Location     string `json:"location"`
+	LocationType string `json:"locationType"`
+	RegionCode   string `json:"regionCode"`
+	ServiceName  string `json:"servicename"`
+
+	// These fields do not appear to return in the api anymore
+	Memory          string `json:"memory"`
+	Storage         string `json:"storage"`
+	VCpu            string `json:"vcpu"`
+	OperatingSystem string `json:"operatingSystem"`
+	PreInstalledSw  string `json:"preInstalledSw"`
+	InstanceFamily  string `json:"instanceFamily"`
+	CapacityStatus  string `json:"capacitystatus"`
+	GPU             string `json:"gpu"` // GPU represents the number of GPU on the instance
+	MarketOption    string `json:"marketOption"`
+}
+
+// PriceListEC2Terms are how you pay for the node: OnDemand, Reserved
+type PriceListEC2Terms struct {
+	OnDemand map[string]map[string]*PriceListEC2Term `json:"OnDemand"`
+	Reserved map[string]map[string]*PriceListEC2Term `json:"Reserved"`
+}
+
+// PriceListEC2Term is a sku extension used to pay for the node.
+type PriceListEC2Term struct {
+	Sku             string                                 `json:"sku"`
+	OfferTermCode   string                                 `json:"offerTermCode"`
+	PriceDimensions map[string]*PriceListEC2PriceDimension `json:"priceDimensions"`
+}
+
+func (t *PriceListEC2Term) String() string {
+	var strs []string
+	for k, rc := range t.PriceDimensions {
+		strs = append(strs, fmt.Sprintf("%s:%s", k, rc.String()))
+	}
+	return fmt.Sprintf("%s:%s", t.Sku, strings.Join(strs, ","))
+}
+
+// PriceListEC2PriceDimension encodes data about the price of a product
+type PriceListEC2PriceDimension struct {
+	Unit         string                   `json:"unit"`
+	PricePerUnit PriceListEC2PricePerUnit `json:"pricePerUnit"`
+}
+
+func (pd *PriceListEC2PriceDimension) String() string {
+	return fmt.Sprintf("{unit: %s, pricePerUnit: %v", pd.Unit, pd.PricePerUnit)
+}
+
+// PriceListEC2PricePerUnit is the localized currency.
+type PriceListEC2PricePerUnit struct {
+	USD string `json:"USD,omitempty"`
+	CNY string `json:"CNY,omitempty"`
+}
+
+// ForCurrency returns the price string for the given currency code, falling
+// back to USD if the code is unrecognized or the field is empty.
+func (p PriceListEC2PricePerUnit) ForCurrency(code string) string {
+	switch strings.ToUpper(code) {
+	case "CNY":
+		if p.CNY != "" {
+			return p.CNY
+		}
+	}
+	return p.USD
+}

+ 58 - 0
pkg/cloud/aws/pricelistapi_test.go

@@ -0,0 +1,58 @@
+package aws
+
+import "testing"
+
+func TestForCurrency(t *testing.T) {
+	cases := []struct {
+		name string
+		unit PriceListEC2PricePerUnit
+		code string
+		want string
+	}{
+		{
+			name: "USD explicit",
+			unit: PriceListEC2PricePerUnit{USD: "0.096", CNY: "0.62"},
+			code: "USD",
+			want: "0.096",
+		},
+		{
+			name: "USD default when code is empty",
+			unit: PriceListEC2PricePerUnit{USD: "0.096"},
+			code: "",
+			want: "0.096",
+		},
+		{
+			name: "USD default for unrecognized code",
+			unit: PriceListEC2PricePerUnit{USD: "0.096"},
+			code: "EUR",
+			want: "0.096",
+		},
+		{
+			name: "CNY uppercase",
+			unit: PriceListEC2PricePerUnit{USD: "0.096", CNY: "0.62"},
+			code: "CNY",
+			want: "0.62",
+		},
+		{
+			name: "CNY lowercase",
+			unit: PriceListEC2PricePerUnit{USD: "0.096", CNY: "0.62"},
+			code: "cny",
+			want: "0.62",
+		},
+		{
+			name: "CNY empty falls back to USD",
+			unit: PriceListEC2PricePerUnit{USD: "0.096", CNY: ""},
+			code: "CNY",
+			want: "0.096",
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := tc.unit.ForCurrency(tc.code)
+			if got != tc.want {
+				t.Errorf("ForCurrency(%q) = %q, want %q", tc.code, got, tc.want)
+			}
+		})
+	}
+}

+ 193 - 0
pkg/cloud/aws/pricinglistpricingsource.go

@@ -0,0 +1,193 @@
+package aws
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/env"
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/pricingmodel"
+	"github.com/opencost/opencost/core/pkg/model/shared"
+)
+
+const pricingCacheTTL = 24 * time.Hour
+const pricingCacheDir = "pricingsource/aws"
+const pricingCacheFile = "cached_ec2_pricingmodelset"
+
+const PricingListPricingSourceType pricingmodel.PricingSourceType = "aws_pricing_list_api"
+
+type PricingListPricingSourceConfig struct {
+	CurrencyCode string
+}
+
+type PricingListPricingSource struct {
+	config PricingListPricingSourceConfig
+}
+
+func NewPricingListPricingSource(cfg PricingListPricingSourceConfig) *PricingListPricingSource {
+	return &PricingListPricingSource{config: cfg}
+}
+
+func (p *PricingListPricingSource) cacheFilePath() (string, error) {
+	dir := env.GetPathFromConfig(pricingCacheDir)
+	if _, e := os.Stat(dir); e != nil && os.IsNotExist(e) {
+		err := os.MkdirAll(dir, os.ModePerm)
+		if err != nil {
+			return "", err
+		}
+	}
+	return filepath.Join(dir, pricingCacheFile), nil
+}
+
+func (p *PricingListPricingSource) loadFromCache() (*pricingmodel.PricingModelSet, bool) {
+	path, err := p.cacheFilePath()
+	if err != nil {
+		return nil, false
+	}
+	info, err := os.Stat(path)
+	if err != nil || time.Since(info.ModTime()) > pricingCacheTTL {
+		return nil, false
+	}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil, false
+	}
+	pms := &pricingmodel.PricingModelSet{}
+	if err := pms.UnmarshalBinary(data); err != nil {
+		log.Warnf("failed to unmarshal cached pricing data: %s", err.Error())
+		return nil, false
+	}
+	return pms, true
+}
+
+func (p *PricingListPricingSource) saveToCache(pms *pricingmodel.PricingModelSet) {
+	path, err := p.cacheFilePath()
+	if err != nil {
+		log.Warnf("failed to determine pricing cache path: %s", err.Error())
+		return
+	}
+	data, err := pms.MarshalBinary()
+	if err != nil {
+		log.Warnf("failed to marshal pricing data for cache: %s", err.Error())
+		return
+	}
+	if err := os.WriteFile(path, data, 0600); err != nil {
+		log.Warnf("failed to write pricing cache: %s", err.Error())
+	}
+}
+
+func (p *PricingListPricingSource) PricingSourceType() pricingmodel.PricingSourceType {
+	return PricingListPricingSourceType
+}
+
+// PricingSourceKey returns the PricingSourceType because it is meant to run single instance.
+func (p *PricingListPricingSource) PricingSourceKey() string {
+	return string(PricingListPricingSourceType)
+}
+
+func (p *PricingListPricingSource) GetPricing() (*pricingmodel.PricingModelSet, error) {
+	if cached, ok := p.loadFromCache(); ok {
+		log.Infof("PricingListPricingSource: loaded %d pricing entries from cache", len(cached.NodePricing))
+		return cached, nil
+	}
+
+	log.Infof("PricingListPricingSource: starting AWS EC2 pricing list download (large file, this may take a while)")
+	start := time.Now()
+
+	now := time.Now().UTC()
+	pms := pricingmodel.NewPricingModelSet(now, p.PricingSourceType(), p.PricingSourceKey())
+	skuToNodeKey := make(map[string]pricingmodel.NodeKey)
+
+	var productCount, termCount int
+	const logInterval = 50000
+
+	// When parsing product we create keys based off of product attributes and link those to a SKU.
+	handleProduct := func(product *PriceListEC2Product) {
+		productCount++
+		if productCount%logInterval == 0 {
+			log.Infof("PricingListPricingSource: processed %d products...", productCount)
+		}
+		attr := product.Attributes
+		if attr.LocationType != "AWS Region" {
+			return
+		}
+
+		if !((strings.HasPrefix(attr.UsageType, "BoxUsage") || strings.Contains(attr.UsageType, "-BoxUsage")) &&
+			(attr.CapacityStatus == "Used" || attr.CapacityStatus == "") &&
+			(attr.MarketOption == "OnDemand" || attr.MarketOption == "")) {
+			return
+		}
+
+		if attr.OperatingSystem != "" && attr.OperatingSystem != "NA" && attr.OperatingSystem != "Linux" {
+			return
+		}
+
+		if attr.PreInstalledSw != "" && attr.PreInstalledSw != "NA" {
+
+		}
+
+		if attr.RegionCode == "" || attr.InstanceType == "" {
+			return
+		}
+
+		skuToNodeKey[product.Sku] = pricingmodel.NodeKey{
+			Provider:    shared.ProviderAWS,
+			Region:      attr.RegionCode,
+			NodeType:    attr.InstanceType,
+			UsageType:   shared.UsageTypeOnDemand,
+			PricingType: pricingmodel.NodePricingTypeTotal,
+		}
+	}
+
+	// Terms are used to define pricing and have the sku to look up the appropriate key.
+	handleTerm := func(term *PriceListEC2Term) {
+		termCount++
+		if termCount%logInterval == 0 {
+			log.Infof("PricingListPricingSource: processed %d terms, %d pricing entries so far...", termCount, len(pms.NodePricing))
+		}
+		nodeKey, ok := skuToNodeKey[term.Sku]
+		if !ok {
+			return
+		}
+		hourlyRateCode := HourlyRateCode
+		if _, ok = OnDemandRateCodes[term.OfferTermCode]; !ok {
+			if _, okCN := OnDemandRateCodesCn[term.OfferTermCode]; !okCN {
+				// Skip if term is not OnDemand
+				return
+			}
+			hourlyRateCode = HourlyRateCodeCn
+		}
+		priceDimensionKey := strings.Join([]string{term.Sku, term.OfferTermCode, hourlyRateCode}, ".")
+
+		pricingDimension, ok := term.PriceDimensions[priceDimensionKey]
+		if !ok {
+			return
+		}
+
+		priceStr := pricingDimension.PricePerUnit.ForCurrency(p.config.CurrencyCode)
+		price, err := strconv.ParseFloat(priceStr, 64)
+		if err != nil {
+			log.Errorf("failed to parse str to float '%s': %s", priceStr, err.Error())
+			return
+		}
+		pms.NodePricing[nodeKey] = pricingmodel.NodePricing{
+			HourlyRate: price,
+		}
+	}
+
+	err := QueryEC2PriceList("", handleProduct, handleTerm)
+	if err != nil {
+		return nil, fmt.Errorf("failed to query list pricing data %w", err)
+	}
+
+	log.Infof("PricingListPricingSource: completed in %s — %d products, %d terms, %d pricing entries",
+		time.Since(start).Round(time.Second), productCount, termCount, len(pms.NodePricing))
+
+	p.saveToCache(pms)
+
+	return pms, nil
+}

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

@@ -254,78 +254,11 @@ func (accessKey AWSAccessKey) CreateConfig(region string) (awsSDK.Config, error)
 	return cfg, nil
 }
 
-// AWSPricing maps a k8s node to an AWS Pricing "product"
-type AWSPricing struct {
-	Products map[string]*AWSProduct `json:"products"`
-	Terms    AWSPricingTerms        `json:"terms"`
-}
-
-// AWSProduct represents a purchased SKU
-type AWSProduct struct {
-	Sku        string               `json:"sku"`
-	Attributes AWSProductAttributes `json:"attributes"`
-}
-
-// AWSProductAttributes represents metadata about the product used to map to a node.
-type AWSProductAttributes struct {
-	Location        string `json:"location"`
-	RegionCode      string `json:"regionCode"`
-	Operation       string `json:"operation"`
-	InstanceType    string `json:"instanceType"`
-	Memory          string `json:"memory"`
-	Storage         string `json:"storage"`
-	VCpu            string `json:"vcpu"`
-	UsageType       string `json:"usagetype"`
-	OperatingSystem string `json:"operatingSystem"`
-	PreInstalledSw  string `json:"preInstalledSw"`
-	InstanceFamily  string `json:"instanceFamily"`
-	CapacityStatus  string `json:"capacitystatus"`
-	GPU             string `json:"gpu"` // GPU represents the number of GPU on the instance
-	MarketOption    string `json:"marketOption"`
-}
-
-// AWSPricingTerms are how you pay for the node: OnDemand, Reserved, or (TODO) Spot
-type AWSPricingTerms struct {
-	OnDemand map[string]map[string]*AWSOfferTerm `json:"OnDemand"`
-	Reserved map[string]map[string]*AWSOfferTerm `json:"Reserved"`
-}
-
-// AWSOfferTerm is a sku extension used to pay for the node.
-type AWSOfferTerm struct {
-	Sku             string                  `json:"sku"`
-	OfferTermCode   string                  `json:"offerTermCode"`
-	PriceDimensions map[string]*AWSRateCode `json:"priceDimensions"`
-}
-
-func (ot *AWSOfferTerm) String() string {
-	var strs []string
-	for k, rc := range ot.PriceDimensions {
-		strs = append(strs, fmt.Sprintf("%s:%s", k, rc.String()))
-	}
-	return fmt.Sprintf("%s:%s", ot.Sku, strings.Join(strs, ","))
-}
-
-// AWSRateCode encodes data about the price of a product
-type AWSRateCode struct {
-	Unit         string          `json:"unit"`
-	PricePerUnit AWSCurrencyCode `json:"pricePerUnit"`
-}
-
-func (rc *AWSRateCode) String() string {
-	return fmt.Sprintf("{unit: %s, pricePerUnit: %v", rc.Unit, rc.PricePerUnit)
-}
-
-// AWSCurrencyCode is the localized currency. (TODO: support non-USD)
-type AWSCurrencyCode struct {
-	USD string `json:"USD,omitempty"`
-	CNY string `json:"CNY,omitempty"`
-}
-
 // AWSProductTerms represents the full terms of the product
 type AWSProductTerms struct {
 	Sku          string               `json:"sku"`
-	OnDemand     *AWSOfferTerm        `json:"OnDemand"`
-	Reserved     *AWSOfferTerm        `json:"Reserved"`
+	OnDemand     *PriceListEC2Term    `json:"OnDemand"`
+	Reserved     *PriceListEC2Term    `json:"Reserved"`
 	Memory       string               `json:"memory"`
 	Storage      string               `json:"storage"`
 	VCpu         string               `json:"vcpu"`
@@ -334,26 +267,6 @@ type AWSProductTerms struct {
 	LoadBalancer *models.LoadBalancer `json:"load_balancer"`
 }
 
-// ClusterIdEnvVar is the environment variable in which one can manually set the ClusterId
-const ClusterIdEnvVar = "AWS_CLUSTER_ID"
-
-// OnDemandRateCodes is are sets of identifiers for offerTermCodes matching 'On Demand' rates
-var OnDemandRateCodes = map[string]struct{}{
-	"JRTCKXETXF": {},
-}
-
-var OnDemandRateCodesCn = map[string]struct{}{
-	"99YE2YK9UR": {},
-	"5Y9WH78GDR": {},
-	"KW44MY7SZN": {},
-}
-
-// HourlyRateCode is appended to a node sku
-const (
-	HourlyRateCode   = "6YS6EN2CT7"
-	HourlyRateCodeCn = "Q7UJUT2CE6"
-)
-
 // volTypes are used to map between AWS UsageTypes and
 // EBS volume types, as they would appear in K8s storage class
 // name and the EC2 API.
@@ -1069,7 +982,7 @@ func (aws *AWS) populatePricing(resp *http.Response, inputkeys map[string]bool)
 				if err != nil {
 					return err
 				}
-				product := &AWSProduct{}
+				product := &PriceListEC2Product{}
 
 				err = dec.Decode(&product)
 				if err != nil {
@@ -1161,7 +1074,7 @@ func (aws *AWS) populatePricing(resp *http.Response, inputkeys map[string]bool)
 					if err != nil {
 						return err
 					}
-					offerTerm := &AWSOfferTerm{}
+					offerTerm := &PriceListEC2Term{}
 					err = dec.Decode(&offerTerm)
 					if err != nil {
 						log.Errorf("Error decoding AWS Offer Term: %s", err.Error())

+ 24 - 20
pkg/cloud/aws/provider_test.go

@@ -131,8 +131,8 @@ func Test_PricingData_Regression(t *testing.T) {
 			t.Errorf("Failed to download pricing data for region %s: %v", region, err)
 		}
 
-		// Unmarshal pricing data into AWSPricing
-		var pricingData AWSPricing
+		// Unmarshal pricing data into PriceListEC2Response
+		var pricingData PriceListEC2Response
 		body, err := io.ReadAll(res.Body)
 		if err != nil {
 			t.Errorf("Failed to read pricing data for region %s: %v", region, err)
@@ -205,13 +205,13 @@ func Test_populate_pricing(t *testing.T) {
 		Storage: "",
 		VCpu:    "",
 		GPU:     "",
-		OnDemand: &AWSOfferTerm{
+		OnDemand: &PriceListEC2Term{
 			Sku:           "M6UGCCQ3CDJQAA37",
 			OfferTermCode: "JRTCKXETXF",
-			PriceDimensions: map[string]*AWSRateCode{
+			PriceDimensions: map[string]*PriceListEC2PriceDimension{
 				"M6UGCCQ3CDJQAA37.JRTCKXETXF.6YS6EN2CT7": {
 					Unit: "GB-Mo",
-					PricePerUnit: AWSCurrencyCode{
+					PricePerUnit: PriceListEC2PricePerUnit{
 						USD: "0.0800000000",
 						CNY: "",
 					},
@@ -234,13 +234,13 @@ func Test_populate_pricing(t *testing.T) {
 		Storage: "EBS only",
 		VCpu:    "2",
 		GPU:     "",
-		OnDemand: &AWSOfferTerm{
+		OnDemand: &PriceListEC2Term{
 			Sku:           "8D49XP354UEYTHGM",
 			OfferTermCode: "MZU6U2429S",
-			PriceDimensions: map[string]*AWSRateCode{
+			PriceDimensions: map[string]*PriceListEC2PriceDimension{
 				"8D49XP354UEYTHGM.MZU6U2429S.2TG2D8R56U": {
 					Unit: "Quantity",
-					PricePerUnit: AWSCurrencyCode{
+					PricePerUnit: PriceListEC2PricePerUnit{
 						USD: "1161",
 						CNY: "",
 					},
@@ -255,13 +255,13 @@ func Test_populate_pricing(t *testing.T) {
 		Storage: "EBS only",
 		VCpu:    "2",
 		GPU:     "",
-		OnDemand: &AWSOfferTerm{
+		OnDemand: &PriceListEC2Term{
 			Sku:           "8D49XP354UEYTHGM",
 			OfferTermCode: "MZU6U2429S",
-			PriceDimensions: map[string]*AWSRateCode{
+			PriceDimensions: map[string]*PriceListEC2PriceDimension{
 				"8D49XP354UEYTHGM.MZU6U2429S.2TG2D8R56U": {
 					Unit: "Quantity",
-					PricePerUnit: AWSCurrencyCode{
+					PricePerUnit: PriceListEC2PricePerUnit{
 						USD: "1161",
 						CNY: "",
 					},
@@ -272,13 +272,13 @@ func Test_populate_pricing(t *testing.T) {
 
 	expectedProdTermsLoadbalancer := &AWSProductTerms{
 		Sku: "Y9RYMSE644KDSV4S",
-		OnDemand: &AWSOfferTerm{
+		OnDemand: &PriceListEC2Term{
 			Sku:           "Y9RYMSE644KDSV4S",
 			OfferTermCode: "JRTCKXETXF",
-			PriceDimensions: map[string]*AWSRateCode{
+			PriceDimensions: map[string]*PriceListEC2PriceDimension{
 				"Y9RYMSE644KDSV4S.JRTCKXETXF.6YS6EN2CT7": {
 					Unit: "Hrs",
-					PricePerUnit: AWSCurrencyCode{
+					PricePerUnit: PriceListEC2PricePerUnit{
 						USD: "0.0225000000",
 						CNY: "",
 					},
@@ -335,13 +335,13 @@ func Test_populate_pricing(t *testing.T) {
 		Storage: "8 x 1000 SSD",
 		VCpu:    "96",
 		GPU:     "8",
-		OnDemand: &AWSOfferTerm{
+		OnDemand: &PriceListEC2Term{
 			Sku:           "H7NGEAC6UEHNTKSJ",
 			OfferTermCode: "JRTCKXETXF",
-			PriceDimensions: map[string]*AWSRateCode{
+			PriceDimensions: map[string]*PriceListEC2PriceDimension{
 				"H7NGEAC6UEHNTKSJ.JRTCKXETXF.6YS6EN2CT7": {
 					Unit: "Hrs",
-					PricePerUnit: AWSCurrencyCode{
+					PricePerUnit: PriceListEC2PricePerUnit{
 						USD: "32.7726000000",
 					},
 				},
@@ -390,13 +390,13 @@ func Test_populate_pricing(t *testing.T) {
 		Storage: "",
 		VCpu:    "",
 		GPU:     "",
-		OnDemand: &AWSOfferTerm{
+		OnDemand: &PriceListEC2Term{
 			Sku:           "R83VXG9NAPDASEGN",
 			OfferTermCode: "5Y9WH78GDR",
-			PriceDimensions: map[string]*AWSRateCode{
+			PriceDimensions: map[string]*PriceListEC2PriceDimension{
 				"R83VXG9NAPDASEGN.5Y9WH78GDR.Q7UJUT2CE6": {
 					Unit: "GB-Mo",
-					PricePerUnit: AWSCurrencyCode{
+					PricePerUnit: PriceListEC2PricePerUnit{
 						USD: "",
 						CNY: "0.5312000000",
 					},
@@ -746,6 +746,10 @@ func (m *mockClusterCache) GetAllJobs() []*clustercache.Job {
 	return nil
 }
 
+func (m *mockClusterCache) GetAllCronJobs() []*clustercache.CronJob {
+	return nil
+}
+
 func (m *mockClusterCache) GetAllNamespaces() []*clustercache.Namespace {
 	return nil
 }

+ 148 - 0
pkg/cloud/azure/retailpricingsource.go

@@ -0,0 +1,148 @@
+package azure
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/pricingmodel"
+	"github.com/opencost/opencost/core/pkg/model/shared"
+)
+
+const (
+	azureRetailPricingBaseURL = "https://prices.azure.com/api/retail/prices"
+	azureRetailVMFilter       = "serviceName eq 'Virtual Machines' and priceType eq 'Consumption'"
+)
+
+const AzureRetailPricingSourceType pricingmodel.PricingSourceType = "azure_retail_pricing_api"
+
+// AzureRetailPricingSourceConfig holds configuration for AzureRetailPricingSource.
+type AzureRetailPricingSourceConfig struct {
+	CurrencyCode string
+}
+
+var azureRetailHTTPClient = &http.Client{Timeout: 60 * time.Second}
+
+// AzureRetailPricingSource implements pricingmodel.PricingSource using the
+// Azure Retail Prices API (no authentication required).
+type AzureRetailPricingSource struct {
+	config AzureRetailPricingSourceConfig
+}
+
+func NewAzureRetailPricingSource(cfg AzureRetailPricingSourceConfig) *AzureRetailPricingSource {
+	return &AzureRetailPricingSource{config: cfg}
+}
+
+func (a *AzureRetailPricingSource) PricingSourceType() pricingmodel.PricingSourceType {
+	return AzureRetailPricingSourceType
+}
+
+// PricingSourceKey returns the PricingSourceType because it is meant to run single instance.
+func (a *AzureRetailPricingSource) PricingSourceKey() string {
+	return string(AzureRetailPricingSourceType)
+}
+
+func (a *AzureRetailPricingSource) GetPricing() (*pricingmodel.PricingModelSet, error) {
+	now := time.Now().UTC()
+	pms := pricingmodel.NewPricingModelSet(now, a.PricingSourceType(), a.PricingSourceKey())
+
+	url := a.buildInitialURL()
+	pageCount := 0
+
+	for url != "" {
+		resp, err := azureRetailHTTPClient.Get(url)
+		if err != nil {
+			return nil, fmt.Errorf("AzureRetailPricingSource: GET %s: %w", url, err)
+		}
+
+		if resp.StatusCode != http.StatusOK {
+			body, _ := io.ReadAll(resp.Body)
+			resp.Body.Close()
+			return nil, fmt.Errorf("AzureRetailPricingSource: unexpected status %d on page %d: %s", resp.StatusCode, pageCount, string(body))
+		}
+
+		next, err := a.parsePage(resp.Body, pms)
+		resp.Body.Close()
+		if err != nil {
+			return nil, fmt.Errorf("AzureRetailPricingSource: parsing page %d: %w", pageCount, err)
+		}
+
+		pageCount++
+		url = next
+		log.Debugf("AzureRetailPricingSource: fetched page %d, next: %s", pageCount, url)
+	}
+
+	log.Infof("AzureRetailPricingSource: loaded %d pricing entries across %d pages", len(pms.NodePricing), pageCount)
+	return pms, nil
+}
+
+func (a *AzureRetailPricingSource) buildInitialURL() string {
+	u := azureRetailPricingBaseURL + "?$filter=" + url.QueryEscape(azureRetailVMFilter)
+	if a.config.CurrencyCode != "" {
+		u += "&currencyCode=" + url.QueryEscape(a.config.CurrencyCode)
+	}
+	return u
+}
+
+func (a *AzureRetailPricingSource) parsePage(body io.Reader, pms *pricingmodel.PricingModelSet) (nextURL string, err error) {
+	data, err := io.ReadAll(body)
+	if err != nil {
+		return "", fmt.Errorf("reading response body: %w", err)
+	}
+
+	var page AzureRetailPricing
+	if err := json.Unmarshal(data, &page); err != nil {
+		return "", fmt.Errorf("unmarshalling response: %w", err)
+	}
+
+	for _, item := range page.Items {
+		if !a.includeItem(item) {
+			continue
+		}
+
+		key := pricingmodel.NodeKey{
+			Provider:    shared.ProviderAzure,
+			Region:      item.ArmRegionName,
+			NodeType:    item.ArmSkuName,
+			UsageType:   usageTypeFromSku(item.SkuName),
+			PricingType: pricingmodel.NodePricingTypeTotal,
+		}
+
+		pms.NodePricing[key] = pricingmodel.NodePricing{
+			HourlyRate: float64(item.RetailPrice),
+		}
+	}
+
+	return page.NextPageLink, nil
+}
+
+// includeItem mirrors the filtering logic in the existing Azure provider.
+func (a *AzureRetailPricingSource) includeItem(item AzureRetailPricingAttributes) bool {
+	if item.ArmSkuName == "" || item.ArmRegionName == "" {
+		return false
+	}
+	if strings.Contains(item.ProductName, "Windows") {
+		return false
+	}
+	skuLower := strings.ToLower(item.SkuName)
+	productLower := strings.ToLower(item.ProductName)
+	if strings.Contains(skuLower, "low priority") {
+		return false
+	}
+	if strings.Contains(productLower, "cloud services") || strings.Contains(productLower, "cloudservices") {
+		return false
+	}
+	return true
+}
+
+func usageTypeFromSku(skuName string) shared.UsageType {
+	if strings.Contains(strings.ToLower(skuName), " spot") {
+		return shared.UsageTypeSpot
+	}
+	return shared.UsageTypeOnDemand
+}

+ 190 - 0
pkg/cloud/azure/retailpricingsource_test.go

@@ -0,0 +1,190 @@
+package azure
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/model/pricingmodel"
+	"github.com/opencost/opencost/core/pkg/model/shared"
+)
+
+func TestAzureRetailPricingSource_BuildInitialURL(t *testing.T) {
+	cases := []struct {
+		name         string
+		currencyCode string
+	}{
+		{"no currency", ""},
+		{"USD", "USD"},
+		{"EUR", "EUR"},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			src := NewAzureRetailPricingSource(AzureRetailPricingSourceConfig{CurrencyCode: tc.currencyCode})
+			u := src.buildInitialURL()
+
+			if strings.Contains(u, " ") {
+				t.Errorf("URL contains unencoded space: %s", u)
+			}
+			if strings.Contains(u, "'") {
+				t.Errorf("URL contains unencoded single quote: %s", u)
+			}
+			if !strings.Contains(u, "$filter=") {
+				t.Errorf("URL missing $filter param: %s", u)
+			}
+			if tc.currencyCode != "" {
+				if !strings.Contains(u, "currencyCode="+tc.currencyCode) {
+					t.Errorf("URL missing or malformed currencyCode param: %s", u)
+				}
+				// ensure no single quotes wrapped around the currency value
+				if strings.Contains(u, "'"+tc.currencyCode+"'") {
+					t.Errorf("currency code is wrapped in single quotes: %s", u)
+				}
+			}
+		})
+	}
+}
+
+func TestAzureRetailPricingSource_IncludeItem(t *testing.T) {
+	src := NewAzureRetailPricingSource(AzureRetailPricingSourceConfig{})
+
+	cases := []struct {
+		name string
+		item AzureRetailPricingAttributes
+		want bool
+	}{
+		{
+			name: "valid linux VM",
+			item: AzureRetailPricingAttributes{ArmSkuName: "Standard_D4s_v3", ArmRegionName: "eastus", SkuName: "D4s v3", ProductName: "Virtual Machines D Series"},
+			want: true,
+		},
+		{
+			name: "spot SKU is included",
+			item: AzureRetailPricingAttributes{ArmSkuName: "Standard_D4s_v3", ArmRegionName: "eastus", SkuName: "D4s v3 Spot", ProductName: "Virtual Machines D Series"},
+			want: true,
+		},
+		{
+			name: "missing ArmSkuName",
+			item: AzureRetailPricingAttributes{ArmRegionName: "eastus", SkuName: "D4s v3", ProductName: "Virtual Machines D Series"},
+			want: false,
+		},
+		{
+			name: "missing ArmRegionName",
+			item: AzureRetailPricingAttributes{ArmSkuName: "Standard_D4s_v3", SkuName: "D4s v3", ProductName: "Virtual Machines D Series"},
+			want: false,
+		},
+		{
+			name: "Windows product excluded",
+			item: AzureRetailPricingAttributes{ArmSkuName: "Standard_D4s_v3", ArmRegionName: "eastus", SkuName: "D4s v3", ProductName: "Windows Virtual Machines"},
+			want: false,
+		},
+		{
+			name: "low priority excluded",
+			item: AzureRetailPricingAttributes{ArmSkuName: "Standard_D4s_v3", ArmRegionName: "eastus", SkuName: "D4s v3 Low Priority", ProductName: "Virtual Machines D Series"},
+			want: false,
+		},
+		{
+			name: "cloud services excluded",
+			item: AzureRetailPricingAttributes{ArmSkuName: "Standard_D4s_v3", ArmRegionName: "eastus", SkuName: "D4s v3", ProductName: "Cloud Services General"},
+			want: false,
+		},
+		{
+			name: "cloudservices excluded",
+			item: AzureRetailPricingAttributes{ArmSkuName: "Standard_D4s_v3", ArmRegionName: "eastus", SkuName: "D4s v3", ProductName: "CloudServices General"},
+			want: false,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := src.includeItem(tc.item)
+			if got != tc.want {
+				t.Errorf("includeItem() = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestUsageTypeFromSku(t *testing.T) {
+	cases := []struct {
+		skuName string
+		want    shared.UsageType
+	}{
+		{"D4s v3", shared.UsageTypeOnDemand},
+		{"D4s v3 Spot", shared.UsageTypeSpot},
+		{"D4s v3 spot", shared.UsageTypeSpot},
+		{"D4s V3 SPOT", shared.UsageTypeSpot},
+		{"E8s v5 Spot Extra", shared.UsageTypeSpot},
+		{"", shared.UsageTypeOnDemand},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.skuName, func(t *testing.T) {
+			got := usageTypeFromSku(tc.skuName)
+			if got != tc.want {
+				t.Errorf("usageTypeFromSku(%q) = %q, want %q", tc.skuName, got, tc.want)
+			}
+		})
+	}
+}
+
+func absf(x float64) float64 {
+	if x < 0 {
+		return -x
+	}
+	return x
+}
+
+func TestAzureRetailPricingSource_ParsePage(t *testing.T) {
+	items := []AzureRetailPricingAttributes{
+		// included: standard on-demand VM
+		{ArmSkuName: "Standard_D4s_v3", ArmRegionName: "eastus", SkuName: "D4s v3", ProductName: "Virtual Machines D Series", RetailPrice: 0.192},
+		// included: spot VM
+		{ArmSkuName: "Standard_D4s_v3", ArmRegionName: "eastus", SkuName: "D4s v3 Spot", ProductName: "Virtual Machines D Series", RetailPrice: 0.05},
+		// excluded: Windows
+		{ArmSkuName: "Standard_D4s_v3", ArmRegionName: "eastus", SkuName: "D4s v3", ProductName: "Windows Virtual Machines", RetailPrice: 0.3},
+		// excluded: missing region
+		{ArmSkuName: "Standard_D4s_v3", SkuName: "D4s v3", ProductName: "Virtual Machines D Series", RetailPrice: 0.192},
+	}
+
+	page := AzureRetailPricing{Items: items}
+	body, _ := json.Marshal(page)
+
+	src := NewAzureRetailPricingSource(AzureRetailPricingSourceConfig{})
+	pms := pricingmodel.NewPricingModelSet(time.Now().UTC(), src.PricingSourceType(), src.PricingSourceKey())
+
+	_, err := src.parsePage(strings.NewReader(string(body)), pms)
+	if err != nil {
+		t.Fatalf("parsePage error: %v", err)
+	}
+
+	if len(pms.NodePricing) != 2 {
+		t.Errorf("expected 2 pricing entries, got %d", len(pms.NodePricing))
+	}
+
+	onDemandKey := pricingmodel.NodeKey{
+		Provider:    shared.ProviderAzure,
+		Region:      "eastus",
+		NodeType:    "Standard_D4s_v3",
+		UsageType:   shared.UsageTypeOnDemand,
+		PricingType: pricingmodel.NodePricingTypeTotal,
+	}
+	if entry, ok := pms.NodePricing[onDemandKey]; !ok {
+		t.Error("missing OnDemand entry")
+	} else if absf(entry.HourlyRate-0.192) > 1e-5 {
+		t.Errorf("OnDemand HourlyRate = %v, want ~0.192", entry.HourlyRate)
+	}
+
+	spotKey := pricingmodel.NodeKey{
+		Provider:    shared.ProviderAzure,
+		Region:      "eastus",
+		NodeType:    "Standard_D4s_v3",
+		UsageType:   shared.UsageTypeSpot,
+		PricingType: pricingmodel.NodePricingTypeTotal,
+	}
+	if _, ok := pms.NodePricing[spotKey]; !ok {
+		t.Error("missing Spot entry")
+	}
+}

+ 249 - 0
pkg/cloud/gcp/billingpricingsource.go

@@ -0,0 +1,249 @@
+package gcp
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"math"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/pricingmodel"
+	"github.com/opencost/opencost/core/pkg/model/shared"
+)
+
+const (
+	gcpBillingComputeServiceID = "6F81-5844-456A"
+	gcpBillingBaseURL          = "https://cloudbilling.googleapis.com/v1/services/" + gcpBillingComputeServiceID + "/skus"
+)
+
+const GCPBillingPricingSourceType pricingmodel.PricingSourceType = "gcp_billing_catalog_api"
+
+const (
+	gcpResourceFamilyCompute = "Compute"
+	gcpResourceGroupGPU      = "GPU"
+	gcpUsageTypeOnDemand     = "OnDemand"
+	gcpUsageTypePreemptible  = "Preemptible"
+)
+
+// GCPBillingPricingSourceConfig holds configuration for GCPBillingPricingSource.
+type GCPBillingPricingSourceConfig struct {
+	APIKey       string
+	CurrencyCode string
+}
+
+var gcpBillingHTTPClient = &http.Client{Timeout: 60 * time.Second}
+
+// GCPBillingPricingSource implements pricingmodel.PricingSource using the
+// GCP Cloud Billing Catalog API. It emits per-vCPU, per-GB RAM, and per-GPU
+// hourly rates keyed by family and region, which consumers combine with
+// machine specs to compute total instance costs.
+type GCPBillingPricingSource struct {
+	apiKey       string
+	currencyCode string
+}
+
+func NewGCPBillingPricingSource(cfg GCPBillingPricingSourceConfig) (*GCPBillingPricingSource, error) {
+	if cfg.APIKey == "" {
+		return nil, fmt.Errorf("cannot initialize GCPBillingPriceSource with empty API Key")
+	}
+	return &GCPBillingPricingSource{
+		apiKey:       cfg.APIKey,
+		currencyCode: cfg.CurrencyCode,
+	}, nil
+}
+
+func (g *GCPBillingPricingSource) PricingSourceType() pricingmodel.PricingSourceType {
+	return GCPBillingPricingSourceType
+}
+
+// PricingSourceKey returns the PricingSourceType because it is meant to run single instance.
+func (g *GCPBillingPricingSource) PricingSourceKey() string {
+	return string(GCPBillingPricingSourceType)
+}
+
+func (g *GCPBillingPricingSource) GetPricing() (*pricingmodel.PricingModelSet, error) {
+	if g.apiKey == "" {
+		return nil, fmt.Errorf("GCPBillingPricingSource: api key is nil")
+	}
+	now := time.Now().UTC()
+	pms := pricingmodel.NewPricingModelSet(now, g.PricingSourceType(), g.PricingSourceKey())
+
+	url := g.buildURL("")
+	pageCount := 0
+
+	for url != "" {
+		resp, err := gcpBillingHTTPClient.Get(url)
+		if err != nil {
+			return nil, fmt.Errorf("GCPBillingPricingSource: GET: %w", err)
+		}
+
+		if resp.StatusCode != http.StatusOK {
+			body, _ := io.ReadAll(resp.Body)
+			resp.Body.Close()
+			return nil, fmt.Errorf("GCPBillingPricingSource: unexpected status %d on page %d: %s", resp.StatusCode, pageCount, string(body))
+		}
+
+		nextToken, err := g.parsePage(resp.Body, pms)
+		resp.Body.Close()
+		if err != nil {
+			return nil, fmt.Errorf("GCPBillingPricingSource: parsing page %d: %w", pageCount, err)
+		}
+
+		pageCount++
+		log.Debugf("GCPBillingPricingSource: fetched page %d", pageCount)
+
+		if nextToken == "" {
+			break
+		}
+		url = g.buildURL(nextToken)
+	}
+
+	log.Infof("GCPBillingPricingSource: loaded %d pricing entries across %d pages", len(pms.NodePricing), pageCount)
+	return pms, nil
+}
+
+func (g *GCPBillingPricingSource) buildURL(pageToken string) string {
+	url := fmt.Sprintf("%s?key=%s", gcpBillingBaseURL, g.apiKey)
+	if g.currencyCode != "" {
+		url += "&currencyCode=" + g.currencyCode
+	}
+	if pageToken != "" {
+		url += "&pageToken=" + pageToken
+	}
+	return url
+}
+
+type gcpBillingPage struct {
+	SKUs          []*GCPPricing `json:"skus"`
+	NextPageToken string        `json:"nextPageToken"`
+}
+
+func (g *GCPBillingPricingSource) parsePage(body io.Reader, pms *pricingmodel.PricingModelSet) (string, error) {
+	data, err := io.ReadAll(body)
+	if err != nil {
+		return "", fmt.Errorf("reading response body: %w", err)
+	}
+
+	var page gcpBillingPage
+	if err := json.Unmarshal(data, &page); err != nil {
+		return "", fmt.Errorf("unmarshalling response: %w", err)
+	}
+
+	for _, sku := range page.SKUs {
+		g.processSKU(sku, pms)
+	}
+
+	return page.NextPageToken, nil
+}
+
+func (g *GCPBillingPricingSource) processSKU(sku *GCPPricing, pms *pricingmodel.PricingModelSet) {
+	if sku.Category == nil || sku.Category.ResourceFamily != gcpResourceFamilyCompute {
+		return
+	}
+
+	usageType := gcpUsageType(sku.Category.UsageType)
+	if usageType == shared.UsageTypeEmpty {
+		return // skip commitments and unrecognized usage types
+	}
+
+	hourlyRate, ok := gcpExtractHourlyRate(sku)
+	if !ok || hourlyRate == 0 {
+		return
+	}
+
+	if strings.EqualFold(sku.Category.ResourceGroup, gcpResourceGroupGPU) {
+		g.processGPUSKU(sku, usageType, hourlyRate, pms)
+		return
+	}
+
+	g.processComputeSKU(sku, usageType, hourlyRate, pms)
+}
+
+func (g *GCPBillingPricingSource) processGPUSKU(sku *GCPPricing, usageType shared.UsageType, hourlyRate float64, pms *pricingmodel.PricingModelSet) {
+	accelerator := NormalizeGPULabel(sku.Description)
+	if accelerator == "" {
+		return
+	}
+	for _, region := range sku.ServiceRegions {
+		key := pricingmodel.NodeKey{
+			Provider:    shared.ProviderGCP,
+			Region:      region,
+			UsageType:   usageType,
+			DeviceType:  accelerator,
+			PricingType: pricingmodel.NodePricingTypeDevice,
+		}
+		pms.NodePricing[key] = pricingmodel.NodePricing{HourlyRate: hourlyRate}
+	}
+}
+
+func (g *GCPBillingPricingSource) processComputeSKU(sku *GCPPricing, usageType shared.UsageType, hourlyRate float64, pms *pricingmodel.PricingModelSet) {
+	// Skip legacy ambiguous resource groups — family cannot be determined without
+	// parsing the description, which is unreliable across SKU generations.
+	rg := strings.ToLower(sku.Category.ResourceGroup)
+	if rg == "ram" || rg == "cpu" {
+		return
+	}
+
+	family := gcpNormalizeFamily(sku.Category.ResourceGroup)
+	if family == "" {
+		return
+	}
+
+	pricingType := pricingmodel.NodePricingTypeCPUCore
+	if strings.Contains(strings.ToUpper(sku.Description), "RAM") {
+		pricingType = pricingmodel.NodePricingTypeRamGB
+	}
+
+	for _, region := range sku.ServiceRegions {
+		key := pricingmodel.NodeKey{
+			Provider:    shared.ProviderGCP,
+			Region:      region,
+			Family:      family,
+			UsageType:   usageType,
+			PricingType: pricingType,
+		}
+		pms.NodePricing[key] = pricingmodel.NodePricing{HourlyRate: hourlyRate}
+	}
+}
+
+// gcpNormalizeFamily maps a GCP Billing API ResourceGroup (e.g. "N1Standard",
+// "N2DStandard", "E2", "A2") to a lowercase family identifier (e.g. "n1",
+// "n2d", "e2", "a2") by lowercasing and stripping the "standard" suffix.
+func gcpNormalizeFamily(resourceGroup string) string {
+	return strings.TrimSuffix(strings.ToLower(resourceGroup), "standard")
+}
+
+// gcpUsageType maps GCP billing usage type strings to shared.UsageType.
+// Returns UsageTypeEmpty for commitment SKUs, which should be skipped.
+func gcpUsageType(gcpType string) shared.UsageType {
+	switch gcpType {
+	case gcpUsageTypeOnDemand:
+		return shared.UsageTypeOnDemand
+	case gcpUsageTypePreemptible:
+		return shared.UsageTypeSpot
+	default:
+		return shared.UsageTypeEmpty
+	}
+}
+
+// gcpExtractHourlyRate extracts the hourly rate from a SKU's pricing info.
+// Per the GCP Billing Catalog API docs, the price is units + nanos*10^-9.
+func gcpExtractHourlyRate(sku *GCPPricing) (float64, bool) {
+	if len(sku.PricingInfo) == 0 {
+		return 0, false
+	}
+	rates := sku.PricingInfo[0].PricingExpression.TieredRates
+	if len(rates) == 0 {
+		return 0, false
+	}
+	last := rates[len(rates)-1]
+	units, err := strconv.Atoi(last.UnitPrice.Units)
+	if err != nil {
+		units = 0
+	}
+	return (last.UnitPrice.Nanos * math.Pow10(-9)) + float64(units), true
+}

+ 297 - 0
pkg/cloud/gcp/billingpricingsource_test.go

@@ -0,0 +1,297 @@
+package gcp
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/model/pricingmodel"
+	"github.com/opencost/opencost/core/pkg/model/shared"
+)
+
+func TestGCPNormalizeFamily(t *testing.T) {
+	cases := []struct{ in, want string }{
+		{"N1Standard", "n1"},
+		{"N2Standard", "n2"},
+		{"N2DStandard", "n2d"},
+		{"E2", "e2"},
+		{"A2", "a2"},
+		{"A3", "a3"},
+		{"G2", "g2"},
+		{"C2Standard", "c2"},
+		{"C2DStandard", "c2d"},
+		{"C3Standard", "c3"},
+		{"C3DStandard", "c3d"},
+		{"M1", "m1"},
+		{"M2", "m2"},
+		{"M3", "m3"},
+		{"T2DStandard", "t2d"},
+		{"T2AStandard", "t2a"},
+		{"N4Standard", "n4"},
+		{"H3Standard", "h3"},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.in, func(t *testing.T) {
+			got := gcpNormalizeFamily(tc.in)
+			if got != tc.want {
+				t.Errorf("gcpNormalizeFamily(%q) = %q, want %q", tc.in, got, tc.want)
+			}
+		})
+	}
+}
+
+func TestGCPUsageType(t *testing.T) {
+	cases := []struct {
+		in   string
+		want shared.UsageType
+	}{
+		{"OnDemand", shared.UsageTypeOnDemand},
+		{"Preemptible", shared.UsageTypeSpot},
+		{"Commit1Yr", shared.UsageTypeEmpty},
+		{"Commit3Yr", shared.UsageTypeEmpty},
+		{"", shared.UsageTypeEmpty},
+		{"unknown", shared.UsageTypeEmpty},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.in, func(t *testing.T) {
+			got := gcpUsageType(tc.in)
+			if got != tc.want {
+				t.Errorf("gcpUsageType(%q) = %q, want %q", tc.in, got, tc.want)
+			}
+		})
+	}
+}
+
+func TestGCPExtractHourlyRate(t *testing.T) {
+	cases := []struct {
+		name   string
+		sku    *GCPPricing
+		want   float64
+		wantOK bool
+	}{
+		{
+			name:   "nanos only",
+			sku:    skuWithRate("0", 48000000),
+			want:   0.048,
+			wantOK: true,
+		},
+		{
+			name:   "units and nanos",
+			sku:    skuWithRate("1", 500000000),
+			want:   1.5,
+			wantOK: true,
+		},
+		{
+			name:   "whole units no nanos",
+			sku:    skuWithRate("2", 0),
+			want:   2.0,
+			wantOK: true,
+		},
+		{
+			name:   "no pricing info",
+			sku:    &GCPPricing{PricingInfo: []*PricingInfo{}},
+			want:   0,
+			wantOK: false,
+		},
+		{
+			name:   "no tiered rates",
+			sku:    skuWithRates([]*TieredRates{}),
+			want:   0,
+			wantOK: false,
+		},
+		{
+			name:   "nil pricing info",
+			sku:    &GCPPricing{},
+			want:   0,
+			wantOK: false,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got, ok := gcpExtractHourlyRate(tc.sku)
+			if ok != tc.wantOK {
+				t.Errorf("ok = %v, want %v", ok, tc.wantOK)
+			}
+			if ok && abs(got-tc.want) > 1e-9 {
+				t.Errorf("rate = %v, want %v", got, tc.want)
+			}
+		})
+	}
+}
+
+func TestGCPBillingPricingSource_ParsePage(t *testing.T) {
+	page := gcpBillingPage{
+		NextPageToken: "token-xyz",
+		SKUs: []*GCPPricing{
+			// N1 CPU OnDemand — us-central1
+			{
+				Category:       &GCPResourceInfo{ResourceFamily: "Compute", ResourceGroup: "N1Standard", UsageType: "OnDemand"},
+				ServiceRegions: []string{"us-central1"},
+				Description:    "N1 Predefined Instance Core running in Americas",
+				PricingInfo:    []*PricingInfo{pricingInfo("0", 31611000)},
+			},
+			// N1 RAM OnDemand — us-central1
+			{
+				Category:       &GCPResourceInfo{ResourceFamily: "Compute", ResourceGroup: "N1Standard", UsageType: "OnDemand"},
+				ServiceRegions: []string{"us-central1"},
+				Description:    "N1 Predefined Instance RAM running in Americas",
+				PricingInfo:    []*PricingInfo{pricingInfo("0", 4237000)},
+			},
+			// T4 GPU OnDemand — us-central1
+			{
+				Category:       &GCPResourceInfo{ResourceFamily: "Compute", ResourceGroup: "GPU", UsageType: "OnDemand"},
+				ServiceRegions: []string{"us-central1"},
+				Description:    "NVIDIA Tesla T4 running in Americas",
+				PricingInfo:    []*PricingInfo{pricingInfo("0", 400000000)},
+			},
+			// N1 CPU Commit1Yr — should be skipped
+			{
+				Category:       &GCPResourceInfo{ResourceFamily: "Compute", ResourceGroup: "N1Standard", UsageType: "Commit1Yr"},
+				ServiceRegions: []string{"us-central1"},
+				Description:    "Commitment v1: N1 in Americas for 1 year",
+				PricingInfo:    []*PricingInfo{pricingInfo("0", 20000000)},
+			},
+			// Non-Compute resourceFamily — should be skipped
+			{
+				Category:       &GCPResourceInfo{ResourceFamily: "Storage", ResourceGroup: "SSD", UsageType: "OnDemand"},
+				ServiceRegions: []string{"us-central1"},
+				Description:    "SSD backed Local Storage",
+				PricingInfo:    []*PricingInfo{pricingInfo("0", 17000000)},
+			},
+			// GPU SKU with zero rate — should be skipped (reserved)
+			{
+				Category:       &GCPResourceInfo{ResourceFamily: "Compute", ResourceGroup: "GPU", UsageType: "OnDemand"},
+				ServiceRegions: []string{"us-central1"},
+				Description:    "NVIDIA Tesla V100 running in Americas",
+				PricingInfo:    []*PricingInfo{pricingInfo("0", 0)},
+			},
+		},
+	}
+
+	body, _ := json.Marshal(page)
+	src, err := NewGCPBillingPricingSource(GCPBillingPricingSourceConfig{APIKey: "test-key"})
+	if err != nil {
+		t.Fatalf("NewGCPBillingPricingSource error: %v", err)
+	}
+	pms := pricingmodel.NewPricingModelSet(time.Now().UTC(), src.PricingSourceType(), src.PricingSourceKey())
+
+	nextToken, err := src.parsePage(strings.NewReader(string(body)), pms)
+	if err != nil {
+		t.Fatalf("parsePage error: %v", err)
+	}
+
+	if nextToken != "token-xyz" {
+		t.Errorf("nextToken = %q, want %q", nextToken, "token-xyz")
+	}
+
+	if len(pms.NodePricing) != 3 {
+		t.Errorf("expected 3 pricing entries (CPU, RAM, GPU), got %d", len(pms.NodePricing))
+	}
+
+	cpuKey := pricingmodel.NodeKey{
+		Provider:    shared.ProviderGCP,
+		Region:      "us-central1",
+		Family:      "n1",
+		UsageType:   shared.UsageTypeOnDemand,
+		PricingType: pricingmodel.NodePricingTypeCPUCore,
+	}
+	if _, ok := pms.NodePricing[cpuKey]; !ok {
+		t.Error("missing CPU entry for n1/us-central1")
+	}
+
+	ramKey := pricingmodel.NodeKey{
+		Provider:    shared.ProviderGCP,
+		Region:      "us-central1",
+		Family:      "n1",
+		UsageType:   shared.UsageTypeOnDemand,
+		PricingType: pricingmodel.NodePricingTypeRamGB,
+	}
+	if _, ok := pms.NodePricing[ramKey]; !ok {
+		t.Error("missing RAM entry for n1/us-central1")
+	}
+
+	gpuKey := pricingmodel.NodeKey{
+		Provider:    shared.ProviderGCP,
+		Region:      "us-central1",
+		UsageType:   shared.UsageTypeOnDemand,
+		DeviceType:  "nvidia-tesla-t4",
+		PricingType: pricingmodel.NodePricingTypeDevice,
+	}
+	if entry, ok := pms.NodePricing[gpuKey]; !ok {
+		t.Error("missing GPU entry for T4/us-central1")
+	} else if abs(entry.HourlyRate-0.4) > 1e-9 {
+		t.Errorf("GPU HourlyRate = %v, want 0.4", entry.HourlyRate)
+	}
+}
+
+func TestGCPBillingPricingSource_ParsePage_Preemptible(t *testing.T) {
+	page := gcpBillingPage{
+		SKUs: []*GCPPricing{
+			{
+				Category:       &GCPResourceInfo{ResourceFamily: "Compute", ResourceGroup: "N1Standard", UsageType: "Preemptible"},
+				ServiceRegions: []string{"us-east1"},
+				Description:    "Preemptible N1 Predefined Instance Core",
+				PricingInfo:    []*PricingInfo{pricingInfo("0", 6655000)},
+			},
+		},
+	}
+
+	body, _ := json.Marshal(page)
+	src, err := NewGCPBillingPricingSource(GCPBillingPricingSourceConfig{APIKey: "test-key"})
+	if err != nil {
+		t.Fatalf("NewGCPBillingPricingSource error: %v", err)
+	}
+	pms := pricingmodel.NewPricingModelSet(time.Now().UTC(), src.PricingSourceType(), src.PricingSourceKey())
+
+	if _, err := src.parsePage(strings.NewReader(string(body)), pms); err != nil {
+		t.Fatalf("parsePage error: %v", err)
+	}
+
+	key := pricingmodel.NodeKey{
+		Provider:    shared.ProviderGCP,
+		Region:      "us-east1",
+		Family:      "n1",
+		UsageType:   shared.UsageTypeSpot,
+		PricingType: pricingmodel.NodePricingTypeCPUCore,
+	}
+	if _, ok := pms.NodePricing[key]; !ok {
+		t.Error("missing Preemptible CPU entry")
+	}
+}
+
+// --- helpers ---
+
+func skuWithRate(units string, nanos float64) *GCPPricing {
+	return skuWithRates([]*TieredRates{
+		{UnitPrice: &UnitPriceInfo{Units: units, Nanos: nanos}},
+	})
+}
+
+func skuWithRates(rates []*TieredRates) *GCPPricing {
+	return &GCPPricing{
+		PricingInfo: []*PricingInfo{
+			{PricingExpression: &PricingExpression{TieredRates: rates}},
+		},
+	}
+}
+
+func pricingInfo(units string, nanos float64) *PricingInfo {
+	return &PricingInfo{
+		PricingExpression: &PricingExpression{
+			TieredRates: []*TieredRates{
+				{UnitPrice: &UnitPriceInfo{Units: units, Nanos: nanos}},
+			},
+		},
+	}
+}
+
+func abs(x float64) float64 {
+	if x < 0 {
+		return -x
+	}
+	return x
+}

+ 1 - 0
pkg/cloud/gcp/provider_test.go

@@ -1311,6 +1311,7 @@ func (m *mockClusterCache) GetAllPersistentVolumeClaims() []*clustercache.Persis
 }
 func (m *mockClusterCache) GetAllStorageClasses() []*clustercache.StorageClass { return m.scs }
 func (m *mockClusterCache) GetAllJobs() []*clustercache.Job                    { return nil }
+func (m *mockClusterCache) GetAllCronJobs() []*clustercache.CronJob            { return nil }
 func (m *mockClusterCache) GetAllPodDisruptionBudgets() []*clustercache.PodDisruptionBudget {
 	return nil
 }

+ 14 - 1
pkg/clustercache/clustercache.go

@@ -31,6 +31,7 @@ type KubernetesClusterCache struct {
 	pvcWatch                   WatchController
 	storageClassWatch          WatchController
 	jobsWatch                  WatchController
+	cronjobsWatch              WatchController
 	pdbWatch                   WatchController
 	replicationControllerWatch WatchController
 	resourceQuotasWatch        WatchController
@@ -73,6 +74,7 @@ func NewKubernetesClusterCacheV1(client kubernetes.Interface) cc.ClusterCache {
 		pvcWatch:                   NewCachingWatcher(coreRestClient, "persistentvolumeclaims", &v1.PersistentVolumeClaim{}, "", fields.Everything()),
 		storageClassWatch:          NewCachingWatcher(storageRestClient, "storageclasses", &stv1.StorageClass{}, "", fields.Everything()),
 		jobsWatch:                  NewCachingWatcher(batchClient, "jobs", &batchv1.Job{}, "", fields.Everything()),
+		cronjobsWatch:              NewCachingWatcher(batchClient, "cronjobs", &batchv1.CronJob{}, "", fields.Everything()),
 		pdbWatch:                   NewCachingWatcher(pdbClient, "poddisruptionbudgets", &policyv1.PodDisruptionBudget{}, "", fields.Everything()),
 		replicationControllerWatch: NewCachingWatcher(coreRestClient, "replicationcontrollers", &v1.ReplicationController{}, "", fields.Everything()),
 		resourceQuotasWatch:        NewCachingWatcher(coreRestClient, "resourcequotas", &v1.ResourceQuota{}, "", fields.Everything()),
@@ -82,7 +84,7 @@ func NewKubernetesClusterCacheV1(client kubernetes.Interface) cc.ClusterCache {
 	cancel := make(chan struct{})
 	var wg sync.WaitGroup
 	if env.HasKubernetesResourceAccess() {
-		wg.Add(15)
+		wg.Add(16)
 		go initializeCache(kcc.namespaceWatch, &wg, cancel)
 		go initializeCache(kcc.nodeWatch, &wg, cancel)
 		go initializeCache(kcc.podWatch, &wg, cancel)
@@ -95,6 +97,7 @@ func NewKubernetesClusterCacheV1(client kubernetes.Interface) cc.ClusterCache {
 		go initializeCache(kcc.pvcWatch, &wg, cancel)
 		go initializeCache(kcc.storageClassWatch, &wg, cancel)
 		go initializeCache(kcc.jobsWatch, &wg, cancel)
+		go initializeCache(kcc.cronjobsWatch, &wg, cancel)
 		go initializeCache(kcc.pdbWatch, &wg, cancel)
 		go initializeCache(kcc.replicationControllerWatch, &wg, cancel)
 		go initializeCache(kcc.resourceQuotasWatch, &wg, cancel)
@@ -125,6 +128,7 @@ func (kcc *KubernetesClusterCache) Run() {
 	go kcc.pvcWatch.Run(1, stopCh)
 	go kcc.storageClassWatch.Run(1, stopCh)
 	go kcc.jobsWatch.Run(1, stopCh)
+	go kcc.cronjobsWatch.Run(1, stopCh)
 	go kcc.pdbWatch.Run(1, stopCh)
 	go kcc.replicationControllerWatch.Run(1, stopCh)
 	go kcc.resourceQuotasWatch.Run(1, stopCh)
@@ -249,6 +253,15 @@ func (kcc *KubernetesClusterCache) GetAllJobs() []*cc.Job {
 	return jobs
 }
 
+func (kcc *KubernetesClusterCache) GetAllCronJobs() []*cc.CronJob {
+	var cronjobs []*cc.CronJob
+	items := kcc.cronjobsWatch.GetAll()
+	for _, cronjob := range items {
+		cronjobs = append(cronjobs, cc.TransformCronJob(cronjob.(*batchv1.CronJob)))
+	}
+	return cronjobs
+}
+
 func (kcc *KubernetesClusterCache) GetAllPodDisruptionBudgets() []*cc.PodDisruptionBudget {
 	var pdbs []*cc.PodDisruptionBudget
 	items := kcc.pdbWatch.GetAll()

+ 8 - 1
pkg/clustercache/clustercache2.go

@@ -25,6 +25,7 @@ type KubernetesClusterCacheV2 struct {
 	persistentVolumeClaimStore *GenericStore[*v1.PersistentVolumeClaim, *cc.PersistentVolumeClaim]
 	storageClassStore          *GenericStore[*stv1.StorageClass, *cc.StorageClass]
 	jobStore                   *GenericStore[*batchv1.Job, *cc.Job]
+	cronJobStore               *GenericStore[*batchv1.CronJob, *cc.CronJob]
 	replicationControllerStore *GenericStore[*v1.ReplicationController, *cc.ReplicationController]
 	replicaSetStore            *GenericStore[*appsv1.ReplicaSet, *cc.ReplicaSet]
 	pdbStore                   *GenericStore[*policyv1.PodDisruptionBudget, *cc.PodDisruptionBudget]
@@ -47,6 +48,7 @@ func NewKubernetesClusterCacheV2(clientset kubernetes.Interface) *KubernetesClus
 		statefulSetStore:           CreateStore(clientset.AppsV1().RESTClient(), "statefulsets", cc.TransformStatefulSet),
 		storageClassStore:          CreateStore(clientset.StorageV1().RESTClient(), "storageclasses", cc.TransformStorageClass),
 		jobStore:                   CreateStore(clientset.BatchV1().RESTClient(), "jobs", cc.TransformJob),
+		cronJobStore:               CreateStore(clientset.BatchV1().RESTClient(), "cronjobs", cc.TransformCronJob),
 		pdbStore:                   CreateStore(clientset.PolicyV1().RESTClient(), "poddisruptionbudgets", cc.TransformPodDisruptionBudget),
 		resourceQuotasStore:        CreateStore(clientset.CoreV1().RESTClient(), "resourcequotas", cc.TransformResourceQuota),
 		stopCh:                     make(chan struct{}),
@@ -57,7 +59,7 @@ func (kcc *KubernetesClusterCacheV2) Run() {
 	var wg sync.WaitGroup
 
 	if env.HasKubernetesResourceAccess() {
-		wg.Add(15)
+		wg.Add(16)
 		kcc.namespaceStore.Watch(kcc.stopCh, wg.Done)
 		kcc.nodeStore.Watch(kcc.stopCh, wg.Done)
 		kcc.persistentVolumeClaimStore.Watch(kcc.stopCh, wg.Done)
@@ -71,6 +73,7 @@ func (kcc *KubernetesClusterCacheV2) Run() {
 		kcc.statefulSetStore.Watch(kcc.stopCh, wg.Done)
 		kcc.storageClassStore.Watch(kcc.stopCh, wg.Done)
 		kcc.jobStore.Watch(kcc.stopCh, wg.Done)
+		kcc.cronJobStore.Watch(kcc.stopCh, wg.Done)
 		kcc.pdbStore.Watch(kcc.stopCh, wg.Done)
 		kcc.resourceQuotasStore.Watch(kcc.stopCh, wg.Done)
 	}
@@ -129,6 +132,10 @@ func (kcc *KubernetesClusterCacheV2) GetAllJobs() []*cc.Job {
 	return kcc.jobStore.GetAll()
 }
 
+func (kcc *KubernetesClusterCacheV2) GetAllCronJobs() []*cc.CronJob {
+	return kcc.cronJobStore.GetAll()
+}
+
 func (kcc *KubernetesClusterCacheV2) GetAllReplicationControllers() []*cc.ReplicationController {
 	return kcc.replicationControllerStore.GetAll()
 }

+ 16 - 16
pkg/costmodel/allocation.go

@@ -341,16 +341,16 @@ func (cm *CostModel) computeAllocation(start, end time.Time) (*opencost.Allocati
 	resChPodLabels := source.WithGroup(grp, ds.QueryPodLabels(start, end))
 	resChPodAnnotations := source.WithGroup(grp, ds.QueryPodAnnotations(start, end))
 
-	resChServiceLabels := source.WithGroup(grp, ds.QueryServiceLabels(start, end))
-	resChDeploymentLabels := source.WithGroup(grp, ds.QueryDeploymentLabels(start, end))
-	resChStatefulSetLabels := source.WithGroup(grp, ds.QueryStatefulSetLabels(start, end))
-	resChDaemonSetLabels := source.WithGroup(grp, ds.QueryDaemonSetLabels(start, end))
+	resChServiceSelectorLabels := source.WithGroup(grp, ds.QueryServiceSelectorLabels(start, end))
+	resChDeploymentMatchLabels := source.WithGroup(grp, ds.QueryDeploymentMatchLabels(start, end))
+	resChStatefulSetMatchLabels := source.WithGroup(grp, ds.QueryStatefulSetMatchLabels(start, end))
+	resChPodsWithDaemonSetOwner := source.WithGroup(grp, ds.QueryPodsWithDaemonSetOwner(start, end))
 
 	resChPodsWithReplicaSetOwner := source.WithGroup(grp, ds.QueryPodsWithReplicaSetOwner(start, end))
 	resChReplicaSetsWithoutOwners := source.WithGroup(grp, ds.QueryReplicaSetsWithoutOwners(start, end))
 	resChReplicaSetsWithRolloutOwner := source.WithGroup(grp, ds.QueryReplicaSetsWithRollout(start, end))
 
-	resChJobLabels := source.WithGroup(grp, ds.QueryJobLabels(start, end))
+	resChPodsWithJobOwner := source.WithGroup(grp, ds.QueryPodsWithJobOwner(start, end))
 
 	resChLBCostPerHr := source.WithGroup(grp, ds.QueryLBPricePerHr(start, end))
 	resChLBActiveMins := source.WithGroup(grp, ds.QueryLBActiveMinutes(start, end))
@@ -408,14 +408,14 @@ func (cm *CostModel) computeAllocation(start, end time.Time) (*opencost.Allocati
 	resNamespaceAnnotations, _ := resChNamespaceAnnotations.Await()
 	resPodLabels, _ := resChPodLabels.Await()
 	resPodAnnotations, _ := resChPodAnnotations.Await()
-	resServiceLabels, _ := resChServiceLabels.Await()
-	resDeploymentLabels, _ := resChDeploymentLabels.Await()
-	resStatefulSetLabels, _ := resChStatefulSetLabels.Await()
-	resDaemonSetLabels, _ := resChDaemonSetLabels.Await()
+	resServiceSelectorLabels, _ := resChServiceSelectorLabels.Await()
+	resDeploymentMatchLabels, _ := resChDeploymentMatchLabels.Await()
+	resStatefulSetMatchLabels, _ := resChStatefulSetMatchLabels.Await()
+	resPodsWithDaemonSetOwner, _ := resChPodsWithDaemonSetOwner.Await()
 	resPodsWithReplicaSetOwner, _ := resChPodsWithReplicaSetOwner.Await()
 	resReplicaSetsWithoutOwners, _ := resChReplicaSetsWithoutOwners.Await()
 	resReplicaSetsWithRolloutOwner, _ := resChReplicaSetsWithRolloutOwner.Await()
-	resJobLabels, _ := resChJobLabels.Await()
+	resPodsWithJobOwner, _ := resChPodsWithJobOwner.Await()
 	resLBCostPerHr, _ := resChLBCostPerHr.Await()
 	resLBActiveMins, _ := resChLBActiveMins.Await()
 
@@ -474,10 +474,10 @@ func (cm *CostModel) computeAllocation(start, end time.Time) (*opencost.Allocati
 	applyLabels(podMap, nodeLabels, namespaceLabels, podLabels)
 	applyAnnotations(podMap, namespaceAnnotations, podAnnotations)
 
-	podDeploymentMap := labelsToPodControllerMap(podLabels, resToDeploymentLabels(resDeploymentLabels))
-	podStatefulSetMap := labelsToPodControllerMap(podLabels, resToStatefulSetLabels(resStatefulSetLabels))
-	podDaemonSetMap := resToPodDaemonSetMap(resDaemonSetLabels, podUIDKeyMap, ingestPodUID)
-	podJobMap := resToPodJobMap(resJobLabels, podUIDKeyMap, ingestPodUID)
+	podDeploymentMap := labelsToPodControllerMap(podLabels, resToDeploymentLabels(resDeploymentMatchLabels))
+	podStatefulSetMap := labelsToPodControllerMap(podLabels, resToStatefulSetLabels(resStatefulSetMatchLabels))
+	podDaemonSetMap := resToPodDaemonSetMap(resPodsWithDaemonSetOwner, podUIDKeyMap, ingestPodUID)
+	podJobMap := resToPodJobMap(resPodsWithJobOwner, podUIDKeyMap, ingestPodUID)
 	podReplicaSetMap := resToPodReplicaSetMap(resPodsWithReplicaSetOwner, resReplicaSetsWithoutOwners, resReplicaSetsWithRolloutOwner, podUIDKeyMap, ingestPodUID)
 	applyControllersToPods(podMap, podDeploymentMap)
 	applyControllersToPods(podMap, podStatefulSetMap)
@@ -485,9 +485,9 @@ func (cm *CostModel) computeAllocation(start, end time.Time) (*opencost.Allocati
 	applyControllersToPods(podMap, podJobMap)
 	applyControllersToPods(podMap, podReplicaSetMap)
 
-	serviceLabels := getServiceLabels(resServiceLabels)
+	serviceSelectorLabels := getServiceSelectorLabels(resServiceSelectorLabels)
 	allocsByService := map[serviceKey][]*opencost.Allocation{}
-	applyServicesToPods(podMap, podLabels, allocsByService, serviceLabels)
+	applyServicesToPods(podMap, podLabels, allocsByService, serviceSelectorLabels)
 
 	// TODO breakdown network costs?
 

+ 5 - 5
pkg/costmodel/allocation_helpers.go

@@ -1355,7 +1355,7 @@ func labelsToPodControllerMap(podLabels map[podKey]map[string]string, controller
 	return podControllerMap
 }
 
-func resToPodDaemonSetMap(resDaemonSetLabels []*source.DaemonSetLabelsResult, podUIDKeyMap map[podKey][]podKey, ingestPodUID bool) map[podKey]controllerKey {
+func resToPodDaemonSetMap(resDaemonSetLabels []*source.PodsWithDaemonSetOwnerResult, podUIDKeyMap map[podKey][]podKey, ingestPodUID bool) map[podKey]controllerKey {
 	daemonSetLabels := map[podKey]controllerKey{}
 
 	for _, res := range resDaemonSetLabels {
@@ -1391,7 +1391,7 @@ func resToPodDaemonSetMap(resDaemonSetLabels []*source.DaemonSetLabelsResult, po
 	return daemonSetLabels
 }
 
-func resToPodJobMap(resJobLabels []*source.JobLabelsResult, podUIDKeyMap map[podKey][]podKey, ingestPodUID bool) map[podKey]controllerKey {
+func resToPodJobMap(resJobLabels []*source.PodsWithJobOwnerResult, podUIDKeyMap map[podKey][]podKey, ingestPodUID bool) map[podKey]controllerKey {
 	jobLabels := map[podKey]controllerKey{}
 
 	for _, res := range resJobLabels {
@@ -1521,10 +1521,10 @@ func applyControllersToPods(podMap map[podKey]*pod, podControllerMap map[podKey]
 
 /* Service Helpers */
 
-func getServiceLabels(resServiceLabels []*source.ServiceLabelsResult) map[serviceKey]map[string]string {
+func getServiceSelectorLabels(resServiceSelectorLabels []*source.ServiceLabelsResult) map[serviceKey]map[string]string {
 	serviceLabels := map[serviceKey]map[string]string{}
 
-	for _, res := range resServiceLabels {
+	for _, res := range resServiceSelectorLabels {
 		serviceKey, err := newResultServiceKey(res.Cluster, res.Namespace, res.Service)
 		if err != nil {
 			continue
@@ -2126,7 +2126,7 @@ func applyPVBytes(pvMap map[pvKey]*pv, resPVBytes []*source.PVBytesResult) {
 			continue
 		}
 
-		pvBytesUsed := res.Data[0].Value
+		pvBytesUsed := res.Value
 		if pvBytesUsed < PV_USAGE_SANITY_LIMIT_BYTES {
 			pvMap[key].Bytes = pvBytesUsed
 		} else {

+ 1 - 1
pkg/costmodel/cluster.go

@@ -811,7 +811,7 @@ func pvCosts(
 
 		// TODO niko/assets storage class
 
-		bytes := result.Data[0].Value
+		bytes := result.Value
 		key := DiskIdentifier{cluster, name}
 		if _, ok := diskMap[key]; !ok {
 			diskMap[key] = &Disk{

+ 3 - 7
pkg/costmodel/cluster_helpers.go

@@ -292,7 +292,7 @@ func buildGPUCountMap(resNodeGPUCount []*source.NodeGPUCountResult) map[NodeIden
 			continue
 		}
 
-		gpuCount := result.Data[0].Value
+		gpuCount := result.GPUCount
 		providerID := result.ProviderID
 
 		key := NodeIdentifier{
@@ -321,13 +321,11 @@ func buildCPUCoresMap(resNodeCPUCores []*source.NodeCPUCoresCapacityResult) map[
 			continue
 		}
 
-		cpuCores := result.Data[0].Value
-
 		key := nodeIdentifierNoProviderID{
 			Cluster: cluster,
 			Name:    name,
 		}
-		m[key] = cpuCores
+		m[key] = result.CPUCores
 	}
 
 	return m
@@ -348,13 +346,11 @@ func buildRAMBytesMap(resNodeRAMBytes []*source.NodeRAMBytesCapacityResult) map[
 			continue
 		}
 
-		ramBytes := result.Data[0].Value
-
 		key := nodeIdentifierNoProviderID{
 			Cluster: cluster,
 			Name:    name,
 		}
-		m[key] = ramBytes
+		m[key] = result.RAMBytes
 	}
 
 	return m

+ 1 - 1
pkg/costmodel/metrics.go

@@ -359,7 +359,7 @@ func NewCostModelMetricsEmitter(clusterCache clustercache.ClusterCache, provider
 	// init will only actually execute once to register the custom gauges
 	initCostModelMetrics(clusterInfo, metricsConfig)
 
-	metrics.InitKubeMetrics(clusterCache, metricsConfig, &metrics.KubeMetricsOpts{
+	metrics.InitKubeMetrics(clusterInfo, clusterCache, metricsConfig, &metrics.KubeMetricsOpts{
 		EmitKubecostControllerMetrics: true,
 		EmitNamespaceAnnotations:      env.IsEmitNamespaceAnnotationsMetric(),
 		EmitPodAnnotations:            env.IsEmitPodAnnotationsMetric(),

+ 2 - 0
pkg/env/cloudcost.go

@@ -22,6 +22,8 @@ const (
 	CustomCostEnabledEnvVar         = "CUSTOM_COST_ENABLED"
 	CustomCostQueryWindowDaysEnvVar = "CUSTOM_COST_QUERY_WINDOW_DAYS"
 
+	PricingModelEnabledEnvVar = "PRICING_MODEL_ENABLED"
+
 	PluginConfigDirEnvVar     = "PLUGIN_CONFIG_DIR"
 	PluginExecutableDirEnvVar = "PLUGIN_EXECUTABLE_DIR"
 

File diff suppressed because it is too large
+ 975 - 45
pkg/kubemodel/kubemodel.go


+ 13 - 1
pkg/metrics/kubemetrics.go

@@ -6,6 +6,7 @@ import (
 	"sync"
 
 	"github.com/opencost/opencost/core/pkg/clustercache"
+	"github.com/opencost/opencost/core/pkg/clusters"
 	"github.com/opencost/opencost/core/pkg/util/promutil"
 
 	"github.com/prometheus/client_golang/prometheus"
@@ -45,7 +46,12 @@ func DefaultKubeMetricsOpts() *KubeMetricsOpts {
 }
 
 // InitKubeMetrics initializes kubernetes metric emission using the provided options.
-func InitKubeMetrics(clusterCache clustercache.ClusterCache, metricsConfig *MetricsConfig, opts *KubeMetricsOpts) {
+func InitKubeMetrics(
+	clusterInfo clusters.ClusterInfoProvider,
+	clusterCache clustercache.ClusterCache,
+	metricsConfig *MetricsConfig,
+	opts *KubeMetricsOpts,
+) {
 	if opts == nil {
 		opts = DefaultKubeMetricsOpts()
 	}
@@ -65,6 +71,12 @@ func InitKubeMetrics(clusterCache clustercache.ClusterCache, metricsConfig *Metr
 			)
 		}
 
+		prometheus.MustRegister(KubeModelCollector{
+			KubeClusterCache: clusterCache,
+			ClusterInfo:      clusterInfo,
+			metricsConfig:    *metricsConfig,
+		})
+
 		if opts.EmitKubecostControllerMetrics {
 			prometheus.MustRegister(KubecostServiceCollector{
 				KubeClusterCache: clusterCache,

+ 546 - 0
pkg/metrics/kubemodel.go

@@ -0,0 +1,546 @@
+package metrics
+
+import (
+	"fmt"
+
+	"github.com/opencost/opencost/core/pkg/clustercache"
+	"github.com/opencost/opencost/core/pkg/clusters"
+	"github.com/opencost/opencost/core/pkg/log"
+	coreutil "github.com/opencost/opencost/core/pkg/util"
+	"github.com/opencost/opencost/core/pkg/util/promutil"
+	"github.com/prometheus/client_golang/prometheus"
+	dto "github.com/prometheus/client_model/go"
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/resource"
+	"k8s.io/apimachinery/pkg/types"
+)
+
+//--------------------------------------------------------------------------
+//  KubeModelCollector
+//--------------------------------------------------------------------------
+
+// kubeModelMetricNames lists every metric name emitted by KubeModelCollector.
+// These are checked against the disabled-metrics map in Describe/Collect.
+var kubeModelMetricNames = []string{
+	"node_info",
+	"cluster_info",
+	"pod_info",
+	"pod_pvc_volume",
+	"namespace_info",
+	"deployment_info",
+	"deployment_labels",
+	"deployment_annotations",
+	"statefulset_info",
+	"statefulset_labels",
+	"statefulset_annotations",
+	"daemonset_info",
+	"daemonset_labels",
+	"daemonset_annotations",
+	"job_info",
+	"job_labels",
+	"job_annotations",
+	"cronjob_info",
+	"cronjob_labels",
+	"cronjob_annotations",
+	"replicaset_info",
+	"replicaset_labels",
+	"replicaset_annotations",
+	"resourcequota_info",
+}
+
+// KubeModelCollector emits a unified set of info/labels/annotations metrics for
+// all Kubernetes resource types. It mirrors the collector-source ClusterCacheScraper:
+// indexes are built once per Collect call and per-resource scrapes run concurrently.
+type KubeModelCollector struct {
+	KubeClusterCache clustercache.ClusterCache
+	ClusterInfo      clusters.ClusterInfoProvider
+	metricsConfig    MetricsConfig
+}
+
+// Describe sends a generic descriptor for each metric emitted by this collector.
+func (c KubeModelCollector) Describe(ch chan<- *prometheus.Desc) {
+	disabled := c.metricsConfig.GetDisabledMetricsMap()
+	for _, name := range kubeModelMetricNames {
+		if _, ok := disabled[name]; ok {
+			continue
+		}
+		ch <- prometheus.NewDesc(name, name, []string{}, nil)
+	}
+}
+
+// Collect fetches all cluster resources, builds cross-reference indexes, then
+// emits info/labels/annotations metrics concurrently per resource type.
+func (c KubeModelCollector) Collect(ch chan<- prometheus.Metric) {
+	disabled := c.metricsConfig.GetDisabledMetricsMap()
+
+	// Fetch all resources from the cache up front.
+	nodes := c.KubeClusterCache.GetAllNodes()
+	namespaces := c.KubeClusterCache.GetAllNamespaces()
+	pods := c.KubeClusterCache.GetAllPods()
+	pvcs := c.KubeClusterCache.GetAllPersistentVolumeClaims()
+	deployments := c.KubeClusterCache.GetAllDeployments()
+	statefulSets := c.KubeClusterCache.GetAllStatefulSets()
+	daemonSets := c.KubeClusterCache.GetAllDaemonSets()
+	jobs := c.KubeClusterCache.GetAllJobs()
+	cronJobs := c.KubeClusterCache.GetAllCronJobs()
+	replicaSets := c.KubeClusterCache.GetAllReplicaSets()
+	resourceQuotas := c.KubeClusterCache.GetAllResourceQuotas()
+
+	// Build cross-reference indexes.
+	nsIndex := make(map[string]types.UID, len(namespaces))
+	for _, ns := range namespaces {
+		nsIndex[ns.Name] = ns.UID
+	}
+	nodeIndex := make(map[string]types.UID, len(nodes))
+	for _, node := range nodes {
+		nodeIndex[node.Name] = node.UID
+	}
+	pvcIndex := make(map[string]types.UID, len(pvcs))
+	for _, pvc := range pvcs {
+		pvcIndex[pvcIndexKey(pvc.Namespace, pvc.Name)] = pvc.UID
+	}
+
+	// Collect concurrently using a channel.
+	type scrapeFn func() []kubeModelMetric
+	fns := []scrapeFn{
+		func() []kubeModelMetric { return c.scrapeClusterInfo(disabled) },
+		func() []kubeModelMetric { return c.scrapeNodes(nodes, disabled) },
+		func() []kubeModelMetric { return c.scrapeNamespaces(namespaces, disabled) },
+		func() []kubeModelMetric { return c.scrapePods(pods, nsIndex, nodeIndex, pvcIndex, disabled) },
+		func() []kubeModelMetric { return c.scrapeDeployments(deployments, nsIndex, disabled) },
+		func() []kubeModelMetric { return c.scrapeStatefulSets(statefulSets, nsIndex, disabled) },
+		func() []kubeModelMetric { return c.scrapeDaemonSets(daemonSets, nsIndex, disabled) },
+		func() []kubeModelMetric { return c.scrapeJobs(jobs, nsIndex, disabled) },
+		func() []kubeModelMetric { return c.scrapeCronJobs(cronJobs, nsIndex, disabled) },
+		func() []kubeModelMetric { return c.scrapeReplicaSets(replicaSets, nsIndex, disabled) },
+		func() []kubeModelMetric { return c.scrapeResourceQuotas(resourceQuotas, nsIndex, disabled) },
+	}
+
+	results := make(chan []kubeModelMetric, len(fns))
+	for _, fn := range fns {
+		fn := fn
+		go func() { results <- fn() }()
+	}
+	for range fns {
+		for _, m := range <-results {
+			ch <- m
+		}
+	}
+}
+
+// pvcIndexKey returns a map key for a PVC by namespace+name.
+func pvcIndexKey(namespace, name string) string {
+	return fmt.Sprintf("%s/%s", namespace, name)
+}
+
+//--------------------------------------------------------------------------
+//  kubeModelMetric — generic prometheus.Metric for kube-model emissions
+//--------------------------------------------------------------------------
+
+// kubeModelMetric implements prometheus.Metric for any kube-model info/labels metric.
+// All labels are stored in a map and emitted via Write; the gauge value defaults to 1.
+type kubeModelMetric struct {
+	name   string
+	help   string
+	labels map[string]string
+	value  float64
+}
+
+func newInfoMetric(name string, labels map[string]string) kubeModelMetric {
+	return kubeModelMetric{name: name, help: name, labels: labels, value: 1}
+}
+
+func newValueMetric(name string, labels map[string]string, value float64) kubeModelMetric {
+	return kubeModelMetric{name: name, help: name, labels: labels, value: value}
+}
+
+func (m kubeModelMetric) Desc() *prometheus.Desc {
+	return prometheus.NewDesc(m.name, m.help, promutil.LabelNamesFrom(m.labels), prometheus.Labels{})
+}
+
+func (m kubeModelMetric) Write(pb *dto.Metric) error {
+	pb.Gauge = &dto.Gauge{Value: &m.value}
+	pairs := make([]*dto.LabelPair, 0, len(m.labels))
+	for k, v := range m.labels {
+		pairs = append(pairs, &dto.LabelPair{
+			Name:  toStringPtr(k),
+			Value: toStringPtr(v),
+		})
+	}
+	pb.Label = pairs
+	return nil
+}
+
+//--------------------------------------------------------------------------
+//  Per-resource scrape helpers
+//--------------------------------------------------------------------------
+
+func (c KubeModelCollector) scrapeClusterInfo(disabled map[string]struct{}) []kubeModelMetric {
+	if _, ok := disabled["cluster_info"]; ok {
+		return nil
+	}
+	if c.ClusterInfo == nil {
+		return nil
+	}
+	info := c.ClusterInfo.GetClusterInfo()
+	labels := map[string]string{
+		"uid":              info[clusters.ClusterInfoIdKey],
+		"provider":         info[clusters.ClusterInfoProviderKey],
+		"account_id":       info[clusters.ClusterInfoAccountKey],
+		"provisioner_name": info[clusters.ClusterInfoProvisionerKey],
+		"region":           info[clusters.ClusterInfoRegionKey],
+	}
+	// GCP uses "project" instead of "account"
+	if labels["account_id"] == "" {
+		labels["account_id"] = info[clusters.ClusterInfoProjectKey]
+	}
+	return []kubeModelMetric{newInfoMetric("cluster_info", labels)}
+}
+
+func (c KubeModelCollector) scrapeNodes(nodes []*clustercache.Node, disabled map[string]struct{}) []kubeModelMetric {
+	var out []kubeModelMetric
+	emitInfo := !isDisabled(disabled, "node_info")
+
+	for _, node := range nodes {
+		nodeInfo := map[string]string{
+			"node":        node.Name,
+			"uid":         string(node.UID),
+			"provider_id": node.SpecProviderID,
+		}
+		if instanceType, ok := coreutil.GetInstanceType(node.Labels); ok {
+			nodeInfo["instance_type"] = instanceType
+		}
+
+		if emitInfo {
+			out = append(out, newInfoMetric("node_info", nodeInfo))
+		}
+	}
+	return out
+}
+
+func (c KubeModelCollector) scrapeNamespaces(namespaces []*clustercache.Namespace, disabled map[string]struct{}) []kubeModelMetric {
+	var out []kubeModelMetric
+	emitInfo := !isDisabled(disabled, "namespace_info")
+
+	for _, ns := range namespaces {
+		if emitInfo {
+			out = append(out, newInfoMetric("namespace_info", map[string]string{
+				"uid":       string(ns.UID),
+				"namespace": ns.Name,
+			}))
+		}
+	}
+	return out
+}
+
+func (c KubeModelCollector) scrapePods(
+	pods []*clustercache.Pod,
+	nsIndex map[string]types.UID,
+	nodeIndex map[string]types.UID,
+	pvcIndex map[string]types.UID,
+	disabled map[string]struct{},
+) []kubeModelMetric {
+	var out []kubeModelMetric
+	emitInfo := !isDisabled(disabled, "pod_info")
+	emitPVC := !isDisabled(disabled, "pod_pvc_volume")
+
+	for _, pod := range pods {
+		nsUID, ok := nsIndex[pod.Namespace]
+		if !ok {
+			log.Debugf("KubeModelCollector: pod namespace uid missing for namespace '%s'", pod.Namespace)
+		}
+		nodeUID, ok := nodeIndex[pod.Spec.NodeName]
+		if !ok && pod.Spec.NodeName != "" {
+			log.Debugf("KubeModelCollector: pod node uid missing for node '%s'", pod.Spec.NodeName)
+		}
+
+		if emitInfo {
+			out = append(out, newInfoMetric("pod_info", map[string]string{
+				"uid":           string(pod.UID),
+				"pod":           pod.Name,
+				"namespace_uid": string(nsUID),
+				"node_uid":      string(nodeUID),
+			}))
+		}
+
+		if emitPVC {
+			for _, vol := range pod.Spec.Volumes {
+				if vol.PersistentVolumeClaim == nil {
+					continue
+				}
+				pvcUID := pvcIndex[pvcIndexKey(pod.Namespace, vol.PersistentVolumeClaim.ClaimName)]
+				out = append(out, newInfoMetric("pod_pvc_volume", map[string]string{
+					"uid":                      string(pod.UID),
+					"persistentvolumeclaim_uid": string(pvcUID),
+					"pod_volume_name":           vol.Name,
+				}))
+			}
+		}
+	}
+	return out
+}
+
+func (c KubeModelCollector) scrapeDeployments(
+	deployments []*clustercache.Deployment,
+	nsIndex map[string]types.UID,
+	disabled map[string]struct{},
+) []kubeModelMetric {
+	var out []kubeModelMetric
+	emitInfo := !isDisabled(disabled, "deployment_info")
+	emitLabels := !isDisabled(disabled, "deployment_labels")
+	emitAnno := !isDisabled(disabled, "deployment_annotations")
+
+	for _, d := range deployments {
+		nsUID, ok := nsIndex[d.Namespace]
+		if !ok {
+			log.Debugf("KubeModelCollector: deployment namespace uid missing for namespace '%s'", d.Namespace)
+		}
+
+		if emitInfo {
+			out = append(out, newInfoMetric("deployment_info", map[string]string{
+				"uid":           string(d.UID),
+				"namespace_uid": string(nsUID),
+				"deployment":    d.Name,
+			}))
+		}
+		if emitLabels {
+			out = append(out, kubeLabelsMetric("deployment_labels", string(d.UID), d.Labels))
+		}
+		if emitAnno {
+			out = append(out, kubeAnnotationsMetric("deployment_annotations", string(d.UID), d.Annotations))
+		}
+	}
+	return out
+}
+
+func (c KubeModelCollector) scrapeStatefulSets(
+	sets []*clustercache.StatefulSet,
+	nsIndex map[string]types.UID,
+	disabled map[string]struct{},
+) []kubeModelMetric {
+	var out []kubeModelMetric
+	emitInfo := !isDisabled(disabled, "statefulset_info")
+	emitLabels := !isDisabled(disabled, "statefulset_labels")
+	emitAnno := !isDisabled(disabled, "statefulset_annotations")
+
+	for _, s := range sets {
+		nsUID, ok := nsIndex[s.Namespace]
+		if !ok {
+			log.Debugf("KubeModelCollector: statefulset namespace uid missing for namespace '%s'", s.Namespace)
+		}
+
+		if emitInfo {
+			out = append(out, newInfoMetric("statefulset_info", map[string]string{
+				"uid":           string(s.UID),
+				"namespace_uid": string(nsUID),
+				"statefulSet":   s.Name,
+			}))
+		}
+		if emitLabels {
+			out = append(out, kubeLabelsMetric("statefulset_labels", string(s.UID), s.Labels))
+		}
+		if emitAnno {
+			out = append(out, kubeAnnotationsMetric("statefulset_annotations", string(s.UID), s.Annotations))
+		}
+	}
+	return out
+}
+
+func (c KubeModelCollector) scrapeDaemonSets(
+	sets []*clustercache.DaemonSet,
+	nsIndex map[string]types.UID,
+	disabled map[string]struct{},
+) []kubeModelMetric {
+	var out []kubeModelMetric
+	emitInfo := !isDisabled(disabled, "daemonset_info")
+	emitLabels := !isDisabled(disabled, "daemonset_labels")
+	emitAnno := !isDisabled(disabled, "daemonset_annotations")
+
+	for _, ds := range sets {
+		nsUID, ok := nsIndex[ds.Namespace]
+		if !ok {
+			log.Debugf("KubeModelCollector: daemonset namespace uid missing for namespace '%s'", ds.Namespace)
+		}
+
+		if emitInfo {
+			out = append(out, newInfoMetric("daemonset_info", map[string]string{
+				"uid":           string(ds.UID),
+				"namespace_uid": string(nsUID),
+				"daemonset":     ds.Name,
+			}))
+		}
+		if emitLabels {
+			out = append(out, kubeLabelsMetric("daemonset_labels", string(ds.UID), ds.Labels))
+		}
+		if emitAnno {
+			out = append(out, kubeAnnotationsMetric("daemonset_annotations", string(ds.UID), ds.Annotations))
+		}
+	}
+	return out
+}
+
+func (c KubeModelCollector) scrapeJobs(
+	jobs []*clustercache.Job,
+	nsIndex map[string]types.UID,
+	disabled map[string]struct{},
+) []kubeModelMetric {
+	var out []kubeModelMetric
+	emitInfo := !isDisabled(disabled, "job_info")
+	emitLabels := !isDisabled(disabled, "job_labels")
+	emitAnno := !isDisabled(disabled, "job_annotations")
+
+	for _, j := range jobs {
+		nsUID, ok := nsIndex[j.Namespace]
+		if !ok {
+			log.Debugf("KubeModelCollector: job namespace uid missing for namespace '%s'", j.Namespace)
+		}
+
+		if emitInfo {
+			out = append(out, newInfoMetric("job_info", map[string]string{
+				"uid":           string(j.UID),
+				"namespace_uid": string(nsUID),
+				"job":           j.Name,
+			}))
+		}
+		if emitLabels {
+			out = append(out, kubeLabelsMetric("job_labels", string(j.UID), j.Labels))
+		}
+		if emitAnno {
+			out = append(out, kubeAnnotationsMetric("job_annotations", string(j.UID), j.Annotations))
+		}
+	}
+	return out
+}
+
+func (c KubeModelCollector) scrapeCronJobs(
+	cronJobs []*clustercache.CronJob,
+	nsIndex map[string]types.UID,
+	disabled map[string]struct{},
+) []kubeModelMetric {
+	var out []kubeModelMetric
+	emitInfo := !isDisabled(disabled, "cronjob_info")
+	emitLabels := !isDisabled(disabled, "cronjob_labels")
+	emitAnno := !isDisabled(disabled, "cronjob_annotations")
+
+	for _, cj := range cronJobs {
+		nsUID, ok := nsIndex[cj.Namespace]
+		if !ok {
+			log.Debugf("KubeModelCollector: cronjob namespace uid missing for namespace '%s'", cj.Namespace)
+		}
+
+		if emitInfo {
+			out = append(out, newInfoMetric("cronjob_info", map[string]string{
+				"uid":           string(cj.UID),
+				"namespace_uid": string(nsUID),
+				"cronjob":       cj.Name,
+			}))
+		}
+		if emitLabels {
+			out = append(out, kubeLabelsMetric("cronjob_labels", string(cj.UID), cj.Labels))
+		}
+		if emitAnno {
+			out = append(out, kubeAnnotationsMetric("cronjob_annotations", string(cj.UID), cj.Annotations))
+		}
+	}
+	return out
+}
+
+func (c KubeModelCollector) scrapeReplicaSets(
+	sets []*clustercache.ReplicaSet,
+	nsIndex map[string]types.UID,
+	disabled map[string]struct{},
+) []kubeModelMetric {
+	var out []kubeModelMetric
+	emitInfo := !isDisabled(disabled, "replicaset_info")
+	emitLabels := !isDisabled(disabled, "replicaset_labels")
+	emitAnno := !isDisabled(disabled, "replicaset_annotations")
+
+	for _, rs := range sets {
+		nsUID, ok := nsIndex[rs.Namespace]
+		if !ok {
+			log.Debugf("KubeModelCollector: replicaset namespace uid missing for namespace '%s'", rs.Namespace)
+		}
+
+		if emitInfo {
+			out = append(out, newInfoMetric("replicaset_info", map[string]string{
+				"uid":           string(rs.UID),
+				"namespace_uid": string(nsUID),
+				"replicaset":    rs.Name,
+			}))
+		}
+		if emitLabels {
+			out = append(out, kubeLabelsMetric("replicaset_labels", string(rs.UID), rs.Labels))
+		}
+		if emitAnno {
+			out = append(out, kubeAnnotationsMetric("replicaset_annotations", string(rs.UID), rs.Annotations))
+		}
+	}
+	return out
+}
+
+func (c KubeModelCollector) scrapeResourceQuotas(
+	quotas []*clustercache.ResourceQuota,
+	nsIndex map[string]types.UID,
+	disabled map[string]struct{},
+) []kubeModelMetric {
+	if isDisabled(disabled, "resourcequota_info") {
+		return nil
+	}
+	var out []kubeModelMetric
+	for _, rq := range quotas {
+		nsUID, ok := nsIndex[rq.Namespace]
+		if !ok {
+			log.Debugf("KubeModelCollector: resourcequota namespace uid missing for namespace '%s'", rq.Namespace)
+		}
+		out = append(out, newInfoMetric("resourcequota_info", map[string]string{
+			"uid":           string(rq.UID),
+			"namespace_uid": string(nsUID),
+			"resourcequota": rq.Name,
+		}))
+	}
+	return out
+}
+
+//--------------------------------------------------------------------------
+//  Helpers
+//--------------------------------------------------------------------------
+
+// isDisabled returns true if the named metric appears in the disabled map.
+func isDisabled(disabled map[string]struct{}, name string) bool {
+	_, ok := disabled[name]
+	return ok
+}
+
+// kubeLabelsMetric builds a labels metric for a resource, adding the resource
+// uid as a fixed label alongside the k8s labels (prefixed with "label_").
+func kubeLabelsMetric(name, uid string, k8sLabels map[string]string) kubeModelMetric {
+	labelNames, labelValues := promutil.KubeLabelsToLabels(promutil.SanitizeLabels(k8sLabels))
+	m := make(map[string]string, len(labelNames)+1)
+	m["uid"] = uid
+	for i, k := range labelNames {
+		m[k] = labelValues[i]
+	}
+	return newInfoMetric(name, m)
+}
+
+// kubeAnnotationsMetric builds an annotations metric for a resource.
+func kubeAnnotationsMetric(name, uid string, k8sAnnotations map[string]string) kubeModelMetric {
+	annoNames, annoValues := promutil.KubeAnnotationsToLabels(k8sAnnotations)
+	m := make(map[string]string, len(annoNames)+1)
+	m["uid"] = uid
+	for i, k := range annoNames {
+		m[k] = annoValues[i]
+	}
+	return newInfoMetric(name, m)
+}
+
+// kubeModelResourceValue converts a Kubernetes resource quantity to a float64 value.
+// It mirrors the collector-source toResourceUnitValue logic for the cases we need.
+func kubeModelResourceValue(resourceName v1.ResourceName, quantity resource.Quantity) float64 {
+	switch resourceName {
+	case v1.ResourceCPU:
+		return float64(quantity.MilliValue()) / 1000.0
+	default:
+		return float64(quantity.Value())
+	}
+}

+ 53 - 0
pkg/pricingmodel/config.go

@@ -0,0 +1,53 @@
+package pricingmodel
+
+import (
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/util/timeutil"
+	"github.com/opencost/opencost/pkg/env"
+)
+
+// PipelineConfig holds configuration for the pricing model pipeline.
+type PipelineConfig struct {
+	AppName           string
+	CurrencyCode      string
+	AWSRunnerConfig   AWSRunnerConfig
+	AzureRunnerConfig AzureRunnerConfig
+	GCPRunnerConfig   GCPRunnerConfig
+}
+
+type AWSRunnerConfig struct {
+	Enabled         bool
+	RefreshInterval time.Duration
+}
+
+type AzureRunnerConfig struct {
+	Enabled         bool
+	RefreshInterval time.Duration
+}
+
+type GCPRunnerConfig struct {
+	Enabled         bool
+	RefreshInterval time.Duration
+	APIKey          string
+}
+
+func DefaultPipelineConfig(appName string) PipelineConfig {
+	return PipelineConfig{
+		AppName:      appName,
+		CurrencyCode: "USD",
+		AWSRunnerConfig: AWSRunnerConfig{
+			Enabled:         true,
+			RefreshInterval: timeutil.Day,
+		},
+		AzureRunnerConfig: AzureRunnerConfig{
+			Enabled:         true,
+			RefreshInterval: timeutil.Day,
+		},
+		GCPRunnerConfig: GCPRunnerConfig{
+			Enabled:         true,
+			RefreshInterval: timeutil.Day,
+			APIKey:          env.GetCloudProviderAPIKey(),
+		},
+	}
+}

+ 194 - 0
pkg/pricingmodel/pipeline.go

@@ -0,0 +1,194 @@
+package pricingmodel
+
+import (
+	"fmt"
+	"sync"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/pricingmodel"
+	corestorage "github.com/opencost/opencost/core/pkg/storage"
+	"github.com/opencost/opencost/pkg/cloud/aws"
+	"github.com/opencost/opencost/pkg/cloud/azure"
+	"github.com/opencost/opencost/pkg/cloud/gcp"
+)
+
+// Pipeline manages a set of runners, one per PricingSource, exporting pricing
+// model snapshots to bucket storage on a configured interval.
+//
+// Initially constructed with a fixed set of always-on sources. Additional
+// sources can be registered dynamically via AddSource to support
+// config-driven sources in the future (similar to the CloudCost ingestion
+// manager's observer pattern).
+type Pipeline struct {
+	lock    sync.Mutex
+	runners map[string]*runner
+	store   *storageWriter
+	config  PipelineConfig
+}
+
+// NewPipeline creates a Pipeline for the given sources and storage backend.
+// If cfg is nil, DefaultPipelineConfig is used.
+// The storage should be initialized by the caller via storage.InitializeStorage
+// or storage.GetDefaultStorage, matching how CloudCost storage is wired up.
+func NewPipeline(store corestorage.Storage, cfg PipelineConfig) (*Pipeline, error) {
+
+	ps, err := newStorageWriter(store, cfg.AppName)
+	if err != nil {
+		return nil, fmt.Errorf("NewPipeline: %w", err)
+	}
+
+	p := &Pipeline{
+		runners: make(map[string]*runner),
+		store:   ps,
+		config:  cfg,
+	}
+	lastUpdates, err := ps.LastUpdates()
+	if err != nil {
+		log.Warnf("NewPipeline: failed to load last update times, runners will start immediately: %s", err.Error())
+		lastUpdates = map[string]time.Time{}
+	}
+
+	if cfg.AWSRunnerConfig.Enabled {
+		src := aws.NewPricingListPricingSource(aws.PricingListPricingSourceConfig{
+			CurrencyCode: cfg.CurrencyCode,
+		})
+		rc := runnerConfig{
+			interval: cfg.AWSRunnerConfig.RefreshInterval,
+		}
+		if t, ok := lastUpdates[src.PricingSourceKey()]; ok {
+			rc.lastRun = &t
+		}
+		p.addSource(src, rc)
+	}
+
+	if cfg.AzureRunnerConfig.Enabled {
+		src := azure.NewAzureRetailPricingSource(azure.AzureRetailPricingSourceConfig{
+			CurrencyCode: cfg.CurrencyCode,
+		})
+		rc := runnerConfig{
+			interval: cfg.AzureRunnerConfig.RefreshInterval,
+		}
+		if t, ok := lastUpdates[src.PricingSourceKey()]; ok {
+			rc.lastRun = &t
+		}
+		p.addSource(src, rc)
+	}
+
+	if cfg.GCPRunnerConfig.Enabled {
+
+		src, err := gcp.NewGCPBillingPricingSource(gcp.GCPBillingPricingSourceConfig{
+			APIKey:       cfg.GCPRunnerConfig.APIKey,
+			CurrencyCode: cfg.CurrencyCode,
+		})
+		if err != nil {
+			log.Error(err.Error())
+		} else {
+			rc := runnerConfig{
+				interval: cfg.GCPRunnerConfig.RefreshInterval,
+			}
+			if t, ok := lastUpdates[src.PricingSourceKey()]; ok {
+				rc.lastRun = &t
+			}
+			p.addSource(src, rc)
+		}
+	}
+
+	return p, nil
+}
+
+// StartAll starts all registered runners.
+func (p *Pipeline) StartAll() {
+	p.lock.Lock()
+	defer p.lock.Unlock()
+	for _, r := range p.runners {
+		r.Start()
+	}
+}
+
+// StopAll stops all registered runners.
+func (p *Pipeline) StopAll() {
+	p.lock.Lock()
+	defer p.lock.Unlock()
+	var wg sync.WaitGroup
+	wg.Add(len(p.runners))
+	for _, r := range p.runners {
+		go func(r *runner) {
+			defer wg.Done()
+			r.Stop()
+		}(r)
+	}
+	wg.Wait()
+}
+
+// AddSource registers a new PricingSource and starts its runner. If a source
+// with the same key already exists it is stopped and replaced.
+func (p *Pipeline) AddSource(src pricingmodel.PricingSource, cfg runnerConfig) {
+	p.lock.Lock()
+	defer p.lock.Unlock()
+	p.addSource(src, cfg)
+}
+
+// RemoveSource stops and removes the runner for the given source key.
+func (p *Pipeline) RemoveSource(key string) {
+	p.lock.Lock()
+	defer p.lock.Unlock()
+	p.removeSource(key)
+}
+
+func (p *Pipeline) addSource(src pricingmodel.PricingSource, cfg runnerConfig) {
+	key := src.PricingSourceKey()
+	p.removeSource(key)
+	log.Infof("PricingModel: pipeline: adding source %s", key)
+	r := newRunner(src, p.store, cfg)
+	r.Start()
+	p.runners[key] = r
+}
+
+// Status returns the current status of all runners.
+func (p *Pipeline) Status() []Status {
+	p.lock.Lock()
+	defer p.lock.Unlock()
+	statuses := make([]Status, 0, len(p.runners))
+	for _, r := range p.runners {
+		statuses = append(statuses, r.Status())
+	}
+	return statuses
+}
+
+// Rebuild triggers an immediate export on all runners outside the scheduled tick.
+func (p *Pipeline) Rebuild() {
+	p.lock.Lock()
+	runners := make([]*runner, 0, len(p.runners))
+	for _, r := range p.runners {
+		runners = append(runners, r)
+	}
+	p.lock.Unlock()
+
+	for _, r := range runners {
+		go r.export()
+	}
+}
+
+// RebuildSource triggers an immediate export for the runner with the given source key.
+func (p *Pipeline) RebuildSource(sourceKey string) error {
+	p.lock.Lock()
+	r, ok := p.runners[sourceKey]
+	p.lock.Unlock()
+
+	if !ok {
+		return fmt.Errorf("PricingModel: no runner found for source key %q", sourceKey)
+	}
+	go r.export()
+	return nil
+}
+
+func (p *Pipeline) removeSource(key string) {
+	r, ok := p.runners[key]
+	if !ok {
+		return
+	}
+	log.Infof("PricingModel: pipeline: removing source %s", key)
+	r.Stop()
+	delete(p.runners, key)
+}

+ 47 - 0
pkg/pricingmodel/pipelineservice.go

@@ -0,0 +1,47 @@
+package pricingmodel
+
+import (
+	"net/http"
+
+	"github.com/julienschmidt/httprouter"
+	proto "github.com/opencost/opencost/core/pkg/protocol"
+)
+
+var protocol = proto.HTTP()
+
+// PipelineService exposes HTTP handlers for controlling and observing the pricing model pipeline.
+type PipelineService struct {
+	pipeline *Pipeline
+}
+
+// NewPipelineService creates a PipelineService wrapping the given Pipeline.
+func NewPipelineService(pipeline *Pipeline) *PipelineService {
+	return &PipelineService{pipeline: pipeline}
+}
+
+// GetStatusHandler returns an HTTP handler that serializes the status of all runners.
+func (s *PipelineService) GetStatusHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		w.Header().Set("Content-Type", "application/json")
+		protocol.WriteData(w, s.pipeline.Status())
+	}
+}
+
+// GetRebuildHandler returns an HTTP handler that triggers an immediate export
+// outside the scheduled tick. If the "sourceKey" query parameter is provided,
+// only that source is rebuilt; otherwise all sources are rebuilt.
+func (s *PipelineService) GetRebuildHandler() func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+	return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+		sourceKey := r.URL.Query().Get("sourceKey")
+		if sourceKey == "" {
+			s.pipeline.Rebuild()
+			protocol.WriteData(w, "Rebuild triggered for all pricing sources")
+			return
+		}
+		if err := s.pipeline.RebuildSource(sourceKey); err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		protocol.WriteData(w, "Rebuild triggered for source: "+sourceKey)
+	}
+}

+ 140 - 0
pkg/pricingmodel/runner.go

@@ -0,0 +1,140 @@
+package pricingmodel
+
+import (
+	"sync"
+	"sync/atomic"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/errors"
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/pricingmodel"
+	"github.com/opencost/opencost/core/pkg/util/timeutil"
+)
+
+type runnerConfig struct {
+	interval time.Duration
+	lastRun  *time.Time
+}
+
+// runner periodically fetches pricing from a PricingSource and writes it to storage.
+// The storage path is derived from PricingModelSet.Source set by the PricingSource implementation.
+type runner struct {
+	source     pricingmodel.PricingSource
+	store      *storageWriter
+	config     runnerConfig
+	isRunning  atomic.Bool
+	isStopping atomic.Bool
+	exitCh     chan struct{}
+	statusLock sync.RWMutex
+	status     Status
+}
+
+func newRunner(source pricingmodel.PricingSource, store *storageWriter, config runnerConfig) *runner {
+	status := Status{
+		SourceKey:   source.PricingSourceKey(),
+		CreatedAt:   time.Now().UTC(),
+		RefreshRate: config.interval.String(),
+	}
+
+	return &runner{
+		source: source,
+		store:  store,
+		config: config,
+		status: status,
+	}
+}
+
+// initialDelay computes how long to wait before the first tick.
+// If lastRun is set and lastRun+interval is still in the future, wait until then.
+// Otherwise run immediately.
+func (r *runner) initialDelay() time.Duration {
+	if r.config.lastRun == nil {
+		return 0
+	}
+	r.status.LastRun = *r.config.lastRun
+	next := r.config.lastRun.Add(r.config.interval)
+	delay := time.Until(next)
+	if delay <= 0 {
+		r.status.NextRun = time.Now()
+		return 0
+	}
+	r.status.NextRun = next
+	log.Infof("PricingModel[%s]: runner: previous run at '%s' next run '%s'",
+		r.source.PricingSourceKey(),
+		r.status.LastRun.Format(time.RFC3339),
+		r.status.NextRun.Format(time.RFC3339))
+	return delay
+}
+
+func (r *runner) Start() {
+	if !r.isRunning.CompareAndSwap(false, true) {
+		return
+	}
+	r.exitCh = make(chan struct{})
+	go r.run()
+}
+
+func (r *runner) Stop() {
+	if !r.isStopping.CompareAndSwap(false, true) {
+		return
+	}
+	close(r.exitCh)
+	r.isRunning.Store(false)
+	r.isStopping.Store(false)
+}
+
+func (r *runner) Status() Status {
+	r.statusLock.RLock()
+	defer r.statusLock.RUnlock()
+	return r.status
+}
+
+func (r *runner) run() {
+	defer errors.HandlePanic()
+
+	ticker := timeutil.NewJobTicker()
+	defer ticker.Close()
+	ticker.TickIn(r.initialDelay())
+
+	for {
+		select {
+		case <-r.exitCh:
+			return
+		case <-ticker.Ch:
+		}
+
+		r.export()
+
+		r.statusLock.Lock()
+		r.status.NextRun = time.Now().UTC().Add(r.config.interval)
+		r.statusLock.Unlock()
+
+		ticker.TickIn(r.config.interval)
+	}
+}
+
+func (r *runner) export() {
+	pms, err := r.source.GetPricing()
+	if err != nil {
+		log.Errorf("PricingModel: runner: failed to get pricing: %v", err)
+		r.statusLock.Lock()
+		r.status.LastError = err.Error()
+		r.statusLock.Unlock()
+		return
+	}
+
+	err = r.store.Write(pms)
+	if err != nil {
+		log.Errorf("PricingModel[%s]: runner: failed to write pricing model set to storage: %v", r.source.PricingSourceKey(), err)
+		r.statusLock.Lock()
+		r.status.LastError = err.Error()
+		r.statusLock.Unlock()
+		return
+	}
+
+	r.statusLock.Lock()
+	r.status.LastRun = time.Now().UTC()
+	r.status.Runs++
+	r.status.LastError = ""
+	r.statusLock.Unlock()
+}

+ 14 - 0
pkg/pricingmodel/status.go

@@ -0,0 +1,14 @@
+package pricingmodel
+
+import "time"
+
+// Status holds the diagnostic state of a runner and is suitable for HTTP serialization.
+type Status struct {
+	SourceKey   string    `json:"sourceKey"`
+	CreatedAt   time.Time `json:"createdAt"`
+	LastRun     time.Time `json:"lastRun"`
+	NextRun     time.Time `json:"nextRun"`
+	RefreshRate string    `json:"refreshRate"`
+	Runs        int       `json:"runs"`
+	LastError   string    `json:"lastError,omitempty"`
+}

+ 71 - 0
pkg/pricingmodel/storage.go

@@ -0,0 +1,71 @@
+package pricingmodel
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/exporter"
+	"github.com/opencost/opencost/core/pkg/exporter/pathing"
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/pricingmodel"
+	"github.com/opencost/opencost/core/pkg/pipelines"
+	"github.com/opencost/opencost/core/pkg/storage"
+)
+
+// storageWriter wraps a Storage backend with a StaticFileStoragePathFormatter,
+// translating source keys into full storage paths on write.
+type storageWriter struct {
+	store   storage.Storage
+	encoder exporter.Encoder[pricingmodel.PricingModelSet]
+	pathing *pathing.StaticFileStoragePathFormatter
+}
+
+func newStorageWriter(store storage.Storage, appName string) (*storageWriter, error) {
+	p, err := pathing.NewStaticFileStoragePathFormatter(appName, pipelines.PricingModelPipelineName)
+	if err != nil {
+		return nil, fmt.Errorf("newStorageWriter: failed to create path formatter: %w", err)
+	}
+	return &storageWriter{
+		store:   store,
+		encoder: exporter.NewVersionBingenEncoder[pricingmodel.PricingModelSet](pricingmodel.DefaultCodecVersion),
+		pathing: p,
+	}, nil
+}
+
+func (sw *storageWriter) Write(pms *pricingmodel.PricingModelSet) error {
+	fullPath := sw.pathing.ToFullPath("", pms.SourceKey, sw.encoder.FileExt())
+	data, err := sw.encoder.Encode(pms)
+	if err != nil {
+		return fmt.Errorf("failed to encode data: %w", err)
+	}
+	err = sw.store.Write(fullPath, data)
+	if err != nil {
+		return fmt.Errorf("failed to write to storage: %w", err)
+	}
+	log.Infof("PricingModel[%s]: exported pricing model set (%d bytes)", pms.SourceKey, len(data))
+	return nil
+}
+
+// LastUpdates returns a map of source key to last modified time for each file
+// found under the formatter's directory. Source keys are reconstructed as the
+// file path relative to Dir().
+func (sw *storageWriter) LastUpdates() (map[string]time.Time, error) {
+	result := make(map[string]time.Time)
+	dir := sw.pathing.Dir()
+
+	files, err := sw.store.List(dir)
+	if err != nil && !storage.IsNotExist(err) {
+		return nil, fmt.Errorf("collectModTimes: listing %s: %w", dir, err)
+	}
+	for _, f := range files {
+		nameParts := strings.Split(f.Name, ".")
+		key := nameParts[0]
+		if modTime, ok := result[key]; ok && modTime.After(f.ModTime) {
+			continue
+		}
+		result[key] = f.ModTime
+	}
+
+	return result, nil
+}

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