Niko Kovacevic 5 дней назад
Родитель
Сommit
58d13a4f13
43 измененных файлов с 2354 добавлено и 1974 удалено
  1. 1 1
      core/go.mod
  2. 1 0
      core/pkg/model/kubemodel/networktrafficdetail.go
  3. 4 0
      core/pkg/model/shared/provider.go
  4. 42 0
      core/pkg/pricing/cluster.go
  5. 149 38
      core/pkg/pricing/mock.go
  6. 223 24
      core/pkg/pricing/mock_test.go
  7. 8 0
      core/pkg/pricing/module.go
  8. 52 0
      core/pkg/pricing/network.go
  9. 61 12
      core/pkg/pricing/node.go
  10. 82 0
      core/pkg/pricing/persistentvolume.go
  11. 30 33
      core/pkg/pricing/price.go
  12. 0 368
      core/pkg/pricing/price_test.go
  13. 110 0
      core/pkg/pricing/pricingset.go
  14. 139 0
      core/pkg/pricing/pricingset_test.go
  15. 0 12
      core/pkg/pricing/provider.go
  16. 0 22
      core/pkg/pricing/repository.go
  17. 43 0
      core/pkg/pricing/resource.go
  18. 47 0
      core/pkg/pricing/service.go
  19. 0 110
      core/pkg/pricing/set.go
  20. 0 179
      core/pkg/pricing/set_test.go
  21. 44 0
      core/pkg/pricing/source.go
  22. 97 74
      core/pkg/pricing/test/aws.yaml
  23. 56 74
      core/pkg/pricing/test/azure.yaml
  24. 11 13
      core/pkg/pricing/test/default.yaml
  25. 107 128
      core/pkg/pricing/test/gcp.yaml
  26. 0 35
      core/pkg/pricing/volume.go
  27. 4 0
      core/pkg/reader/reader.go
  28. 17 35
      core/pkg/unit/unit.go
  29. 2 13
      core/pkg/unit/unit_test.go
  30. 140 33
      modules/pricing/basic/default.go
  31. 11 0
      modules/pricing/basic/go.mod
  32. 24 0
      modules/pricing/basic/go.sum
  33. 253 156
      modules/pricing/basic/module.go
  34. 69 258
      modules/pricing/basic/module_test.go
  35. 23 0
      modules/pricing/basic/store.go
  36. 25 29
      modules/pricing/public/aws/awspricingsource.go
  37. 29 33
      modules/pricing/public/azure/azurepricingsource.go
  38. 7 7
      modules/pricing/public/cmd/main.go
  39. 23 22
      modules/pricing/public/generator.go
  40. 75 63
      modules/pricing/public/go.mod
  41. 177 148
      modules/pricing/public/go.sum
  42. 157 36
      modules/pricing/public/module.go
  43. 11 18
      modules/pricing/public/module_test.go

+ 1 - 1
core/go.mod

@@ -32,6 +32,7 @@ require (
 	google.golang.org/grpc v1.79.3
 	google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af
 	gopkg.in/yaml.v2 v2.4.0
+	gopkg.in/yaml.v3 v3.0.1
 	k8s.io/api v0.36.0
 	k8s.io/apimachinery v0.36.0
 	k8s.io/client-go v0.36.0
@@ -150,7 +151,6 @@ require (
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
 	gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
-	gopkg.in/yaml.v3 v3.0.1 // indirect
 	k8s.io/klog/v2 v2.140.0 // indirect
 	k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
 	sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect

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

@@ -12,6 +12,7 @@ const (
 type TrafficType string
 
 const (
+	TrafficTypeLocal       TrafficType = "Local"
 	TrafficTypeCrossZone   TrafficType = "CrossZone"
 	TrafficTypeCrossRegion TrafficType = "CrossRegion"
 	TrafficTypeInternet    TrafficType = "Internet"

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

@@ -2,6 +2,10 @@ package shared
 
 import "strings"
 
+// TODO: reconsider "shared" as a package name
+// TODO: for this file, maybe core/pkg/model/cloud?
+// TODO: maybe even core/pkg/cloud?
+
 type Provider string
 
 const (

+ 42 - 0
core/pkg/pricing/cluster.go

@@ -0,0 +1,42 @@
+package pricing
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/model/shared"
+)
+
+type ClusterPricing struct {
+	Properties ClusterPricingProperties `json:"properties" yaml:"properties"`
+	Prices     Prices                   `json:"prices" yaml:"prices"`
+}
+
+func (sp *ClusterPricing) String() string {
+	return sp.Properties.String() + "|" + sp.Prices.canonical()
+}
+
+type ClusterPricingProperties struct {
+	Provider shared.Provider `json:"provider,omitempty" yaml:"provider,omitempty"`
+	Start    *time.Time      `json:"start,omitempty" yaml:"start,omitempty"`
+	End      *time.Time      `json:"end,omitempty" yaml:"end,omitempty"`
+}
+
+func (sp *ClusterPricingProperties) String() string {
+	return fmt.Sprintf("%s:%s", sp.Provider, sp.timeKey())
+}
+
+func (sp *ClusterPricingProperties) timeKey() string {
+	s := "nil"
+	e := "nil"
+
+	if sp.Start != nil {
+		s = sp.Start.UTC().Format(time.RFC3339Nano)
+	}
+
+	if sp.End != nil {
+		e = sp.End.UTC().Format(time.RFC3339Nano)
+	}
+
+	return fmt.Sprintf("%s:%s", s, e)
+}

+ 149 - 38
core/pkg/pricing/mock.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"embed"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"path/filepath"
 	"strings"
@@ -12,75 +13,123 @@ import (
 	"gopkg.in/yaml.v3"
 )
 
-type MockPricingRepository struct {
-	NodePricing   []*NodePricing
-	VolumePricing []*VolumePricing
+// MockPricingModule must satisfy the PricingModule interface
+var _ PricingModule = (*MockPricingModule)(nil)
+
+type MockPricingModule struct {
+	ClusterPricing          []*ClusterPricing
+	NetworkPricing          []*NetworkPricing
+	NodePricing             []*NodePricing
+	PersistentVolumePricing []*PersistentVolumePricing
+	ServicePricing          []*ServicePricing
 }
 
-func NewMockPricingRepository() (*MockPricingRepository, error) {
-	repo := &MockPricingRepository{
-		NodePricing:   []*NodePricing{},
-		VolumePricing: []*VolumePricing{},
+func NewMockPricingModule() (*MockPricingModule, error) {
+	mpm := &MockPricingModule{
+		ClusterPricing:          []*ClusterPricing{},
+		NetworkPricing:          []*NetworkPricing{},
+		NodePricing:             []*NodePricing{},
+		PersistentVolumePricing: []*PersistentVolumePricing{},
+		ServicePricing:          []*ServicePricing{},
 	}
 
 	// Default
-	defaultPricingSet, err := loadTestFile("default.yaml")
+	err := mpm.loadTestFile("default.yaml")
 	if err != nil {
 		return nil, fmt.Errorf("error loading test default pricing: %w", err)
 	}
-	repo.NodePricing = append(repo.NodePricing, defaultPricingSet.Nodes...)
-	repo.VolumePricing = append(repo.VolumePricing, defaultPricingSet.Volumes...)
 
 	// AWS
-	awsPricingSet, err := loadTestFile("aws.yaml")
+	err = mpm.loadTestFile("aws.yaml")
 	if err != nil {
 		return nil, fmt.Errorf("error loading test AWS pricing: %w", err)
 	}
-	repo.NodePricing = append(repo.NodePricing, awsPricingSet.Nodes...)
-	repo.VolumePricing = append(repo.VolumePricing, awsPricingSet.Volumes...)
 
 	// Azure
-	azurePricingSet, err := loadTestFile("azure.yaml")
+	err = mpm.loadTestFile("azure.yaml")
 	if err != nil {
 		return nil, fmt.Errorf("error loading test Azure pricing: %w", err)
 	}
-	repo.NodePricing = append(repo.NodePricing, azurePricingSet.Nodes...)
-	repo.VolumePricing = append(repo.VolumePricing, azurePricingSet.Volumes...)
 
 	// GCP
-	gcpPricingSet, err := loadTestFile("gcp.yaml")
+	err = mpm.loadTestFile("gcp.yaml")
 	if err != nil {
 		return nil, fmt.Errorf("error loading test GCP pricing: %w", err)
 	}
-	repo.NodePricing = append(repo.NodePricing, gcpPricingSet.Nodes...)
-	repo.VolumePricing = append(repo.VolumePricing, gcpPricingSet.Volumes...)
 
-	return repo, nil
+	return mpm, nil
+}
+
+func (mpm *MockPricingModule) GetClusterPricing(ctx context.Context, props ClusterPricingProperties) (*ClusterPricing, error) {
+	if err := ctx.Err(); err != nil {
+		return nil, err
+	}
+
+	// Search through the mock data for a matching cluster pricing entry
+	for _, cp := range mpm.ClusterPricing {
+		if cp.Properties.Provider == props.Provider {
+			return cp, nil
+		}
+	}
+	return nil, fmt.Errorf("cluster pricing not found for provider=%s", props.Provider)
 }
 
-func (repo *MockPricingRepository) NewNodePricingReader(ctx context.Context) (reader.Reader[*NodePricing], error) {
-	return reader.NewSliceReader(repo.NodePricing), nil
+func (mpm *MockPricingModule) NewClusterPricingReader(ctx context.Context) (reader.Reader[*ClusterPricing], error) {
+	return reader.NewSliceReader(mpm.ClusterPricing), nil
 }
 
-func (repo *MockPricingRepository) GetNodePricing(provider Provider, instanceType string, region string) (*NodePricing, error) {
+func (mpm *MockPricingModule) GetNetworkPricing(ctx context.Context, props NetworkPricingProperties) (*NetworkPricing, error) {
+	if err := ctx.Err(); err != nil {
+		return nil, err
+	}
+
+	// Search through the mock data for a matching network pricing entry
+	for _, np := range mpm.NetworkPricing {
+		if np.Properties.Provider == props.Provider &&
+			np.Properties.TrafficDirection == props.TrafficDirection &&
+			np.Properties.TrafficType == props.TrafficType &&
+			np.Properties.IsNatGateway == props.IsNatGateway {
+			return np, nil
+		}
+	}
+	return nil, fmt.Errorf("network pricing not found for provider=%s, trafficDirection=%s, trafficType=%s, isNatGateway=%t",
+		props.Provider, props.TrafficDirection, props.TrafficType, props.IsNatGateway)
+}
+
+func (mpm *MockPricingModule) NewNetworkPricingReader(ctx context.Context) (reader.Reader[*NetworkPricing], error) {
+	return reader.NewSliceReader(mpm.NetworkPricing), nil
+}
+
+func (mpm *MockPricingModule) GetNodePricing(ctx context.Context, props NodePricingProperties) (*NodePricing, error) {
+	if err := ctx.Err(); err != nil {
+		return nil, err
+	}
+
 	// Search through the mock data for a matching node pricing entry
-	for _, np := range repo.NodePricing {
-		if np.Properties.Provider == provider &&
-			np.Properties.InstanceType == instanceType &&
-			np.Properties.Region == region {
+	for _, np := range mpm.NodePricing {
+		if np.Properties.Provider == props.Provider &&
+			np.Properties.Region == props.Region &&
+			np.Properties.InstanceType == props.InstanceType &&
+			np.Properties.Provisioning == props.Provisioning &&
+			np.Properties.Commitment == props.Commitment {
 			return np, nil
 		}
 	}
-	return nil, fmt.Errorf("node pricing not found for provider=%s, instanceType=%s, region=%s", provider, instanceType, region)
+	return nil, fmt.Errorf("node pricing not found for provider=%s, region=%s, instanceType=%s, provisioning=%s, commitment=%s",
+		props.Provider, props.Region, props.InstanceType, props.Provisioning, props.Commitment)
 }
 
-func (repo *MockPricingRepository) NewVolumePricingReader(ctx context.Context) (reader.Reader[*VolumePricing], error) {
-	return reader.NewSliceReader(repo.VolumePricing), nil
+func (mpm *MockPricingModule) NewNodePricingReader(ctx context.Context) (reader.Reader[*NodePricing], error) {
+	return reader.NewSliceReader(mpm.NodePricing), nil
 }
 
-func (repo *MockPricingRepository) GetVolumePricing(props VolumePricingProperties) (*VolumePricing, error) {
+func (mpm *MockPricingModule) GetPersistentVolumePricing(ctx context.Context, props PersistentVolumePricingProperties) (*PersistentVolumePricing, error) {
+	if err := ctx.Err(); err != nil {
+		return nil, err
+	}
+
 	// Search through the mock data for a matching volume pricing entry
-	for _, vp := range repo.VolumePricing {
+	for _, vp := range mpm.PersistentVolumePricing {
 		if vp.Properties.Provider == props.Provider &&
 			vp.Properties.Region == props.Region &&
 			vp.Properties.VolumeType == props.VolumeType {
@@ -90,14 +139,66 @@ func (repo *MockPricingRepository) GetVolumePricing(props VolumePricingPropertie
 	return nil, fmt.Errorf("volume pricing not found for provider=%s, region=%s, volumeType=%s", props.Provider, props.Region, props.VolumeType)
 }
 
+func (mpm *MockPricingModule) NewPersistentVolumePricingReader(ctx context.Context) (reader.Reader[*PersistentVolumePricing], error) {
+	return reader.NewSliceReader(mpm.PersistentVolumePricing), nil
+}
+
+func (mpm *MockPricingModule) GetServicePricing(ctx context.Context, props ServicePricingProperties) (*ServicePricing, error) {
+	if err := ctx.Err(); err != nil {
+		return nil, err
+	}
+
+	// Search through the mock data for a matching service pricing entry
+	for _, sp := range mpm.ServicePricing {
+		if sp.Properties.Provider == props.Provider &&
+			sp.Properties.Region == props.Region {
+			return sp, nil
+		}
+	}
+	return nil, fmt.Errorf("service pricing not found for provider=%s, region=%s", props.Provider, props.Region)
+}
+
+func (mpm *MockPricingModule) NewServicePricingReader(ctx context.Context) (reader.Reader[*ServicePricing], error) {
+	return reader.NewSliceReader(mpm.ServicePricing), nil
+}
+
+func (mpm *MockPricingModule) GetPricingSet(ctx context.Context) (*PricingSet, error) {
+	ps := &PricingSet{
+		ClusterPricing:          mpm.ClusterPricing,
+		NetworkPricing:          mpm.NetworkPricing,
+		NodePricing:             mpm.NodePricing,
+		PersistentVolumePricing: mpm.PersistentVolumePricing,
+		ServicePricing:          mpm.ServicePricing,
+	}
+
+	return ps, nil
+}
+
+func (mpm *MockPricingModule) SourceKind() string {
+	return "test"
+}
+
+func (mpm *MockPricingModule) SourceName() string {
+	return "mock"
+}
+
+func (mpm *MockPricingModule) Checksum(ctx context.Context) (string, error) {
+	ps, err := mpm.GetPricingSet(ctx)
+	if err != nil {
+		return "", fmt.Errorf("getting pricing set: %w", err)
+	}
+
+	return ps.Checksum()
+}
+
 //go:embed test/*
 var pricingTestFS embed.FS
 
-func loadTestFile(filename string) (*PricingSet, error) {
+func (mpm *MockPricingModule) loadTestFile(filename string) error {
 	path := filepath.Join("test", filename)
 	bs, err := pricingTestFS.ReadFile(path)
 	if err != nil {
-		panic(fmt.Errorf("failed to read embedded pricing file: %w", err))
+		return fmt.Errorf("failed to read embedded pricing file: %w", err)
 	}
 
 	var set *PricingSet
@@ -108,16 +209,26 @@ func loadTestFile(filename string) (*PricingSet, error) {
 	case ".json":
 		err = json.Unmarshal(bs, &set)
 		if err != nil {
-			return nil, fmt.Errorf("failed to parse json: %w", err)
+			return fmt.Errorf("failed to parse json: %w", err)
 		}
 	case ".yaml", ".yml":
 		err = yaml.Unmarshal(bs, &set)
 		if err != nil {
-			return nil, fmt.Errorf("failed to parse yaml: %w", err)
+			return fmt.Errorf("failed to parse yaml: %w", err)
 		}
 	default:
-		return nil, fmt.Errorf("unsupported file format: %s (expected .json, .yaml, or .yml)", ext)
+		return fmt.Errorf("unsupported file format: %s (expected .json, .yaml, or .yml)", ext)
 	}
 
-	return set, nil
+	if set == nil {
+		return errors.New("nil set")
+	}
+
+	mpm.ClusterPricing = append(mpm.ClusterPricing, set.ClusterPricing...)
+	mpm.NetworkPricing = append(mpm.NetworkPricing, set.NetworkPricing...)
+	mpm.NodePricing = append(mpm.NodePricing, set.NodePricing...)
+	mpm.PersistentVolumePricing = append(mpm.PersistentVolumePricing, set.PersistentVolumePricing...)
+	mpm.ServicePricing = append(mpm.ServicePricing, set.ServicePricing...)
+
+	return nil
 }

+ 223 - 24
core/pkg/pricing/mock_test.go

@@ -6,18 +6,20 @@ import (
 	"fmt"
 	"testing"
 
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/model/shared"
 	"github.com/opencost/opencost/core/pkg/reader"
 )
 
-func TestMockPricingRepository(t *testing.T) {
-	var repo PricingRepository
+func TestMockPricingModule(t *testing.T) {
+	var source PricingSource
 
-	mockRepo, err := NewMockPricingRepository()
+	pricingModule, err := NewMockPricingModule()
 	if err != nil {
 		t.Fatalf("unexpected error initializing mock repository: %s", err)
 	}
 
-	repo = mockRepo
+	source = pricingModule
 
 	// Simple example of a sink for pricing data (will be database tables in reality)
 	bufferSize := 10
@@ -25,12 +27,12 @@ func TestMockPricingRepository(t *testing.T) {
 
 	// Test ingestion of mock node reader
 
-	nodePricingReader, err := repo.NewNodePricingReader(t.Context())
+	nodePricingReader, err := source.NewNodePricingReader(t.Context())
 	if err != nil {
 		t.Errorf("unexpected error initializing node reader: %s", err)
 	}
 
-	n, err := ingestor.IngestNodePricing(context.Background(), nodePricingReader)
+	n, err := ingestor.ingestNodePricing(context.Background(), nodePricingReader)
 	if err != nil {
 		t.Errorf("unexpected error ingesting node pricing: %s", err)
 	}
@@ -38,19 +40,19 @@ func TestMockPricingRepository(t *testing.T) {
 		t.Errorf("expected to ingest %d node pricing records; ingested %d", 39, n)
 	}
 
-	nodePricingCount := ingestor.CountNodePricing()
+	nodePricingCount := ingestor.countNodePricing()
 	if nodePricingCount != 39 {
 		t.Errorf("expected %d node pricing records; received %d", 39, nodePricingCount)
 	}
 
-	// Test ingestion of mock volume reader
+	// Test ingestion of mock persistent volume reader
 
-	volumePricingReader, err := repo.NewVolumePricingReader(t.Context())
+	volumePricingReader, err := source.NewPersistentVolumePricingReader(t.Context())
 	if err != nil {
 		t.Errorf("unexpected error initializing volume reader: %s", err)
 	}
 
-	n, err = ingestor.IngestVolumePricing(context.Background(), volumePricingReader)
+	n, err = ingestor.ingestPersistentVolumePricing(context.Background(), volumePricingReader)
 	if err != nil {
 		t.Errorf("unexpected error ingesting volume pricing: %s", err)
 	}
@@ -58,16 +60,210 @@ func TestMockPricingRepository(t *testing.T) {
 		t.Errorf("expected to ingest %d volume pricing records; ingested %d", 20, n)
 	}
 
-	volumePricingCount := ingestor.CountVolumePricing()
+	volumePricingCount := ingestor.countVolumePricing()
 	if volumePricingCount != 20 {
 		t.Errorf("expected %d volume pricing records; received %d", 20, volumePricingCount)
 	}
 }
 
+// TestMockGetNodePricing verifies node lookup by properties, that the matching
+// entry carries the prices loaded from YAML, and that a missing entry errors.
+func TestMockGetNodePricing(t *testing.T) {
+	mpm := newMock(t)
+
+	np, err := mpm.GetNodePricing(t.Context(), NodePricingProperties{
+		Provider:     shared.ProviderAWS,
+		Region:       "us-east-1",
+		InstanceType: "m5.large",
+		Provisioning: ProvisioningOnDemand,
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	// Guards against the YAML tag regression: prices must actually load.
+	price, ok := np.Prices[ResourceNode]
+	if !ok {
+		t.Fatalf("expected node price to be present, prices=%v", np.Prices)
+	}
+	if price.Price != 0.096 {
+		t.Errorf("expected on-demand price 0.096, got %v", price.Price)
+	}
+
+	// Missing entry should error rather than return a zero value.
+	if _, err := mpm.GetNodePricing(t.Context(), NodePricingProperties{
+		Provider:     shared.ProviderAWS,
+		Region:       "eu-west-1",
+		InstanceType: "m5.large",
+		Provisioning: ProvisioningOnDemand,
+	}); err == nil {
+		t.Errorf("expected error for unknown region, got nil")
+	}
+}
+
+// TestMockGetNodePricingProvisioningDiscriminates verifies that on-demand and
+// spot entries with otherwise identical properties are not conflated.
+func TestMockGetNodePricingProvisioningDiscriminates(t *testing.T) {
+	mpm := newMock(t)
+
+	base := NodePricingProperties{
+		Provider:     shared.ProviderAWS,
+		Region:       "us-east-1",
+		InstanceType: "m5.large",
+	}
+
+	onDemand := base
+	onDemand.Provisioning = ProvisioningOnDemand
+	spot := base
+	spot.Provisioning = ProvisioningSpot
+
+	od, err := mpm.GetNodePricing(t.Context(), onDemand)
+	if err != nil {
+		t.Fatalf("unexpected error (on-demand): %v", err)
+	}
+	sp, err := mpm.GetNodePricing(t.Context(), spot)
+	if err != nil {
+		t.Fatalf("unexpected error (spot): %v", err)
+	}
+
+	if od.Prices[ResourceNode].Price == sp.Prices[ResourceNode].Price {
+		t.Errorf("expected on-demand and spot to differ, both = %v", od.Prices[ResourceNode].Price)
+	}
+	if od.Prices[ResourceNode].Price != 0.096 {
+		t.Errorf("expected on-demand 0.096, got %v", od.Prices[ResourceNode].Price)
+	}
+	if sp.Prices[ResourceNode].Price != 0.043 {
+		t.Errorf("expected spot 0.043, got %v", sp.Prices[ResourceNode].Price)
+	}
+}
+
+// TestMockGetPersistentVolumePricing verifies volume lookup, that prices load,
+// and that a missing entry errors.
+func TestMockGetPersistentVolumePricing(t *testing.T) {
+	mpm := newMock(t)
+
+	pv, err := mpm.GetPersistentVolumePricing(t.Context(), PersistentVolumePricingProperties{
+		Provider:   shared.ProviderAWS,
+		Region:     "us-east-1",
+		VolumeType: VolumeTypeGP3,
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if price, ok := pv.Prices[ResourceStorage]; !ok || price.Price != 0.0001096 {
+		t.Errorf("expected gp3 storage price 0.0001096, got %v (ok=%t)", pv.Prices[ResourceStorage].Price, ok)
+	}
+
+	if _, err := mpm.GetPersistentVolumePricing(t.Context(), PersistentVolumePricingProperties{
+		Provider:   shared.ProviderAWS,
+		Region:     "us-east-1",
+		VolumeType: VolumeTypeIO2,
+	}); err == nil {
+		t.Errorf("expected error for unknown volume type, got nil")
+	}
+}
+
+// TestMockGetClusterPricing verifies cluster lookup by provider.
+func TestMockGetClusterPricing(t *testing.T) {
+	mpm := newMock(t)
+
+	cp, err := mpm.GetClusterPricing(t.Context(), ClusterPricingProperties{Provider: shared.ProviderAWS})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if price, ok := cp.Prices[ResourceCluster]; !ok || price.Price != 0.10 {
+		t.Errorf("expected cluster price 0.10, got %v (ok=%t)", cp.Prices[ResourceCluster].Price, ok)
+	}
+
+	if _, err := mpm.GetClusterPricing(t.Context(), ClusterPricingProperties{Provider: shared.ProviderOracle}); err == nil {
+		t.Errorf("expected error for unknown provider, got nil")
+	}
+}
+
+// TestMockGetNetworkPricing verifies network lookup, including that the NAT
+// gateway flag discriminates between otherwise-identical entries.
+func TestMockGetNetworkPricing(t *testing.T) {
+	mpm := newMock(t)
+
+	internet, err := mpm.GetNetworkPricing(t.Context(), NetworkPricingProperties{
+		Provider:         shared.ProviderAWS,
+		TrafficDirection: kubemodel.TrafficDirectionEgress,
+		TrafficType:      kubemodel.TrafficTypeInternet,
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if internet.Prices[ResourceNetworkTraffic].Price != 0.09 {
+		t.Errorf("expected internet egress price 0.09, got %v", internet.Prices[ResourceNetworkTraffic].Price)
+	}
+
+	nat, err := mpm.GetNetworkPricing(t.Context(), NetworkPricingProperties{
+		Provider:         shared.ProviderAWS,
+		TrafficDirection: kubemodel.TrafficDirectionEgress,
+		TrafficType:      kubemodel.TrafficTypeInternet,
+		IsNatGateway:     true,
+	})
+	if err != nil {
+		t.Fatalf("unexpected error (nat): %v", err)
+	}
+	if nat.Prices[ResourceNetworkTraffic].Price != 0.045 {
+		t.Errorf("expected NAT gateway price 0.045, got %v", nat.Prices[ResourceNetworkTraffic].Price)
+	}
+
+	if internet.Prices[ResourceNetworkTraffic].Price == nat.Prices[ResourceNetworkTraffic].Price {
+		t.Errorf("expected NAT gateway flag to discriminate pricing")
+	}
+
+	if _, err := mpm.GetNetworkPricing(t.Context(), NetworkPricingProperties{
+		Provider:         shared.ProviderAWS,
+		TrafficDirection: kubemodel.TrafficDirectionIngress,
+		TrafficType:      kubemodel.TrafficTypeInternet,
+	}); err == nil {
+		t.Errorf("expected error for unknown traffic direction, got nil")
+	}
+}
+
+// TestMockGetServicePricing verifies service lookup by provider and region.
+func TestMockGetServicePricing(t *testing.T) {
+	mpm := newMock(t)
+
+	sp, err := mpm.GetServicePricing(t.Context(), ServicePricingProperties{
+		Provider: shared.ProviderAWS,
+		Region:   "us-east-1",
+	})
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if price, ok := sp.Prices[ResourceService]; !ok || price.Price != 0.025 {
+		t.Errorf("expected service price 0.025, got %v (ok=%t)", sp.Prices[ResourceService].Price, ok)
+	}
+
+	if _, err := mpm.GetServicePricing(t.Context(), ServicePricingProperties{
+		Provider: shared.ProviderAWS,
+		Region:   "us-west-2",
+	}); err == nil {
+		t.Errorf("expected error for unknown region, got nil")
+	}
+}
+
+// newMock is a helper that constructs a fresh MockPricingModule and fails the
+// test if construction errors.
+func newMock(t *testing.T) *MockPricingModule {
+	t.Helper()
+	mpm, err := NewMockPricingModule()
+	if err != nil {
+		t.Fatalf("unexpected error initializing mock pricing module: %v", err)
+	}
+	return mpm
+}
+
 type mockPricingIngestor struct {
-	bufferSize    int
-	nodePricing   []*NodePricing
-	volumePricing []*VolumePricing
+	bufferSize              int
+	clusterPricing          []*ClusterPricing
+	networkPricing          []*NetworkPricing
+	nodePricing             []*NodePricing
+	persistentVolumePricing []*PersistentVolumePricing
+	servicePricing          []*ServicePricing
 }
 
 func newMockIngestor(bufferSize int) *mockPricingIngestor {
@@ -76,17 +272,20 @@ func newMockIngestor(bufferSize int) *mockPricingIngestor {
 	}
 
 	return &mockPricingIngestor{
-		bufferSize:    bufferSize,
-		nodePricing:   []*NodePricing{},
-		volumePricing: []*VolumePricing{},
+		bufferSize:              bufferSize,
+		clusterPricing:          []*ClusterPricing{},
+		networkPricing:          []*NetworkPricing{},
+		nodePricing:             []*NodePricing{},
+		persistentVolumePricing: []*PersistentVolumePricing{},
+		servicePricing:          []*ServicePricing{},
 	}
 }
 
-func (ing *mockPricingIngestor) CountNodePricing() int {
+func (ing *mockPricingIngestor) countNodePricing() int {
 	return len(ing.nodePricing)
 }
 
-func (ing *mockPricingIngestor) IngestNodePricing(ctx context.Context, pricingReader reader.Reader[*NodePricing]) (int, error) {
+func (ing *mockPricingIngestor) ingestNodePricing(ctx context.Context, pricingReader reader.Reader[*NodePricing]) (int, error) {
 	defer pricingReader.Close()
 
 	nodeBuf := make([]*NodePricing, ing.bufferSize)
@@ -114,14 +313,14 @@ func (ing *mockPricingIngestor) IngestNodePricing(ctx context.Context, pricingRe
 	return totalCount, nil
 }
 
-func (ing *mockPricingIngestor) CountVolumePricing() int {
-	return len(ing.volumePricing)
+func (ing *mockPricingIngestor) countVolumePricing() int {
+	return len(ing.persistentVolumePricing)
 }
 
-func (ing *mockPricingIngestor) IngestVolumePricing(ctx context.Context, pricingReader reader.Reader[*VolumePricing]) (int, error) {
+func (ing *mockPricingIngestor) ingestPersistentVolumePricing(ctx context.Context, pricingReader reader.Reader[*PersistentVolumePricing]) (int, error) {
 	defer pricingReader.Close()
 
-	volBuf := make([]*VolumePricing, ing.bufferSize)
+	volBuf := make([]*PersistentVolumePricing, ing.bufferSize)
 
 	totalCount := 0
 
@@ -129,7 +328,7 @@ func (ing *mockPricingIngestor) IngestVolumePricing(ctx context.Context, pricing
 		n, err := pricingReader.Read(ctx, volBuf)
 
 		if n > 0 {
-			ing.volumePricing = append(ing.volumePricing, volBuf[:n]...)
+			ing.persistentVolumePricing = append(ing.persistentVolumePricing, volBuf[:n]...)
 		}
 
 		if errors.Is(err, reader.Done) {

+ 8 - 0
core/pkg/pricing/module.go

@@ -0,0 +1,8 @@
+package pricing
+
+import "context"
+
+type PricingModule interface {
+	PricingSource
+	Checksum(context.Context) (string, error)
+}

+ 52 - 0
core/pkg/pricing/network.go

@@ -0,0 +1,52 @@
+package pricing
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
+	"github.com/opencost/opencost/core/pkg/model/shared"
+)
+
+type NetworkPricing struct {
+	Properties NetworkPricingProperties `json:"properties" yaml:"properties"`
+	Prices     Prices                   `json:"prices" yaml:"prices"`
+}
+
+func (sp *NetworkPricing) String() string {
+	return sp.Properties.String() + "|" + sp.Prices.canonical()
+}
+
+type NetworkPricingProperties struct {
+	Provider         shared.Provider            `json:"provider,omitempty" yaml:"provider,omitempty"`
+	TrafficDirection kubemodel.TrafficDirection `json:"trafficDirection,omitempty" yaml:"trafficDirection,omitempty"`
+	TrafficType      kubemodel.TrafficType      `json:"trafficType,omitempty" yaml:"trafficType,omitempty"`
+	IsNatGateway     bool                       `json:"isNatGateway,omitempty" yaml:"isNatGateway,omitempty"`
+	Start            *time.Time                 `json:"start,omitempty" yaml:"start,omitempty"`
+	End              *time.Time                 `json:"end,omitempty" yaml:"end,omitempty"`
+}
+
+func (sp *NetworkPricingProperties) String() string {
+	return fmt.Sprintf("%s:%s:%s:nat=%t:%s",
+		sp.Provider,
+		sp.TrafficDirection,
+		sp.TrafficType,
+		sp.IsNatGateway,
+		sp.timeKey(),
+	)
+}
+
+func (sp *NetworkPricingProperties) timeKey() string {
+	s := "nil"
+	e := "nil"
+
+	if sp.Start != nil {
+		s = sp.Start.UTC().Format(time.RFC3339Nano)
+	}
+
+	if sp.End != nil {
+		e = sp.End.UTC().Format(time.RFC3339Nano)
+	}
+
+	return fmt.Sprintf("%s:%s", s, e)
+}

+ 61 - 12
core/pkg/pricing/node.go

@@ -1,15 +1,25 @@
 package pricing
 
 import (
-	"maps"
-	"slices"
+	"fmt"
+	"sort"
+	"strings"
 	"time"
 
-	"github.com/opencost/opencost/core/pkg/unit"
+	"github.com/opencost/opencost/core/pkg/model/shared"
 )
 
+type NodePricing struct {
+	Properties NodePricingProperties `json:"properties" yaml:"properties"`
+	Prices     Prices                `json:"prices" yaml:"prices"`
+}
+
+func (np *NodePricing) String() string {
+	return np.Properties.String() + "|" + np.Prices.canonical()
+}
+
 type NodePricingProperties struct {
-	Provider     Provider          `json:"provider,omitempty" yaml:"provider,omitempty"`
+	Provider     shared.Provider   `json:"provider,omitempty" yaml:"provider,omitempty"`
 	Region       string            `json:"region,omitempty" yaml:"region,omitempty"`
 	InstanceType string            `json:"instanceType,omitempty" yaml:"instanceType,omitempty"`
 	Provisioning ProvisioningType  `json:"provisioning,omitempty" yaml:"provisioning,omitempty"`
@@ -21,17 +31,56 @@ type NodePricingProperties struct {
 	End          *time.Time        `json:"end,omitempty" yaml:"end,omitempty"`
 }
 
-type NodePricing struct {
-	Properties NodePricingProperties `json:"properties" yaml:"properties"`
-	Prices     Prices                `json:"prices" yaml:"pricing"`
+func (np *NodePricingProperties) String() string {
+	return fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s:%s:%s",
+		np.Provider,
+		np.Region,
+		np.InstanceType,
+		np.Provisioning,
+		np.Commitment,
+		np.Cluster,
+		np.ProviderID,
+		np.labelsKey(),
+		np.timeKey(),
+	)
 }
 
-func (np *NodePricing) GetCurrencies() []unit.Currency {
-	currencies := map[unit.Currency]struct{}{}
+func (np *NodePricingProperties) labelsKey() string {
+	if len(np.Labels) == 0 {
+		return ""
+	}
+
+	keys := make([]string, 0, len(np.Labels))
+	for k := range np.Labels {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+
+	var b strings.Builder
+	for i, k := range keys {
+		if i > 0 {
+			b.WriteByte(':')
+		}
+
+		b.WriteString(k)
+		b.WriteByte('=')
+		b.WriteString(np.Labels[k])
+	}
+
+	return b.String()
+}
+
+func (np *NodePricingProperties) timeKey() string {
+	s := "nil"
+	e := "nil"
+
+	if np.Start != nil {
+		s = np.Start.UTC().Format(time.RFC3339Nano)
+	}
 
-	for currency := range np.Prices {
-		currencies[currency] = struct{}{}
+	if np.End != nil {
+		e = np.End.UTC().Format(time.RFC3339Nano)
 	}
 
-	return slices.Collect(maps.Keys(currencies))
+	return fmt.Sprintf("%s:%s", s, e)
 }

+ 82 - 0
core/pkg/pricing/persistentvolume.go

@@ -0,0 +1,82 @@
+package pricing
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/model/shared"
+)
+
+type PersistentVolumePricing struct {
+	Properties PersistentVolumePricingProperties `json:"properties" yaml:"properties"`
+	Prices     Prices                            `json:"prices" yaml:"prices"`
+}
+
+func (vp *PersistentVolumePricing) String() string {
+	return vp.Properties.String() + "|" + vp.Prices.canonical()
+}
+
+type PersistentVolumePricingProperties struct {
+	Provider   shared.Provider   `json:"provider,omitempty" yaml:"provider,omitempty"`
+	Region     string            `json:"region,omitempty" yaml:"region,omitempty"`
+	VolumeType VolumeType        `json:"volumeType,omitempty" yaml:"volumeType,omitempty"`
+	Cluster    string            `json:"cluster,omitempty" yaml:"cluster,omitempty"`
+	ProviderID string            `json:"providerID,omitempty" yaml:"providerID,omitempty"`
+	Labels     map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"`
+	Start      *time.Time        `json:"start,omitempty" yaml:"start,omitempty"`
+	End        *time.Time        `json:"end,omitempty" yaml:"end,omitempty"`
+}
+
+func (vp *PersistentVolumePricingProperties) String() string {
+	return fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s",
+		vp.Provider,
+		vp.Region,
+		vp.VolumeType,
+		vp.Cluster,
+		vp.ProviderID,
+		vp.labelsKey(),
+		vp.timeKey(),
+	)
+}
+
+func (vp *PersistentVolumePricingProperties) labelsKey() string {
+	if len(vp.Labels) == 0 {
+		return ""
+	}
+
+	keys := make([]string, 0, len(vp.Labels))
+	for k := range vp.Labels {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+
+	var b strings.Builder
+	for i, k := range keys {
+		if i > 0 {
+			b.WriteByte(':')
+		}
+
+		b.WriteString(k)
+		b.WriteByte('=')
+		b.WriteString(vp.Labels[k])
+	}
+
+	return b.String()
+}
+
+func (vp *PersistentVolumePricingProperties) timeKey() string {
+	s := "nil"
+	e := "nil"
+
+	if vp.Start != nil {
+		s = vp.Start.UTC().Format(time.RFC3339Nano)
+	}
+
+	if vp.End != nil {
+		e = vp.End.UTC().Format(time.RFC3339Nano)
+	}
+
+	return fmt.Sprintf("%s:%s", s, e)
+}

+ 30 - 33
core/pkg/pricing/price.go

@@ -1,52 +1,49 @@
 package pricing
 
 import (
-	"errors"
+	"sort"
+	"strconv"
+	"strings"
 
 	"github.com/opencost/opencost/core/pkg/unit"
 )
 
-var NotFound = errors.New("Not found")
-
 type Price struct {
-	Currency unit.Currency `json:"currency" yaml:"currency"`
-	Unit     unit.Unit     `json:"unit" yaml:"unit"`
-	Price    float64       `json:"price" yaml:"price"`
+	Unit  unit.Unit `json:"unit" yaml:"unit"`
+	Price float64   `json:"price" yaml:"price"`
 }
 
-type Prices map[unit.Currency][]Price
-
-func (p Prices) GetPrices() []Price {
-	prices := make([]Price, 0, len(p))
-
-	for _, price := range p {
-		prices = append(prices, price...)
-	}
+type Prices map[Resource]Price
 
-	return prices
+func (p Prices) String() string {
+	return p.canonical()
 }
 
-func (p Prices) GetPricesInCurrency(currency unit.Currency) ([]Price, error) {
-	result := []Price{}
-
-	for curr, prices := range p {
-		if curr == currency {
-			result = append(result, prices...)
-		}
+// canonical returns a deterministic string representation of the prices,
+// independent of map iteration order. It is used to make pricing checksums
+// sensitive to price values (not just properties).
+func (p Prices) canonical() string {
+	if len(p) == 0 {
+		return ""
 	}
 
-	if len(result) == 0 {
-		return nil, NotFound
+	resources := make([]string, 0, len(p))
+	for r := range p {
+		resources = append(resources, string(r))
 	}
-
-	return result, nil
-}
-
-func (p Prices) GetPricesInCurrencyWithDefault(currency, defaultCurrency unit.Currency) ([]Price, error) {
-	prices, err := p.GetPricesInCurrency(currency)
-	if len(prices) > 0 && err == nil {
-		return prices, nil
+	sort.Strings(resources)
+
+	var b strings.Builder
+	for _, r := range resources {
+		price := p[Resource(r)]
+		b.WriteString(r)
+		b.WriteByte('[')
+		b.WriteString(string(price.Unit))
+		b.WriteByte(']')
+		b.WriteByte('=')
+		b.WriteString(strconv.FormatFloat(price.Price, 'f', -1, 64))
+		b.WriteByte(';')
 	}
 
-	return p.GetPricesInCurrency(defaultCurrency)
+	return b.String()
 }

+ 0 - 368
core/pkg/pricing/price_test.go

@@ -1,368 +0,0 @@
-package pricing
-
-import (
-	"errors"
-	"testing"
-
-	"github.com/opencost/opencost/core/pkg/unit"
-)
-
-func TestGetPrices(t *testing.T) {
-	testCases := []struct {
-		name   string
-		prices Prices
-	}{
-		{
-			name:   "empty Prices",
-			prices: Prices{},
-		},
-		{
-			name: "single hourly price",
-			prices: Prices{
-				unit.USD: []Price{
-					{
-						Currency: unit.USD,
-						Unit:     unit.Hour,
-						Price:    0.096,
-					},
-				},
-			},
-		},
-		{
-			name: "single set of per-resource prices",
-			prices: Prices{
-				unit.USD: []Price{
-					{
-						Currency: unit.USD,
-						Unit:     unit.VCPUHour,
-						Price:    0.031611,
-					},
-					{
-						Currency: unit.USD,
-						Unit:     unit.RAMGiBHour,
-						Price:    0.004237,
-					},
-				},
-			},
-		},
-		{
-			name: "prices for multiple currencies",
-			prices: Prices{
-				unit.USD: []Price{
-					{
-						Currency: unit.USD,
-						Unit:     unit.VCPUHour,
-						Price:    0.031611,
-					},
-					{
-						Currency: unit.USD,
-						Unit:     unit.RAMGiBHour,
-						Price:    0.004237,
-					},
-				},
-				unit.CNY: []Price{
-					{
-						Currency: unit.CNY,
-						Unit:     unit.VCPUHour,
-						Price:    3.1611,
-					},
-					{
-						Currency: unit.CNY,
-						Unit:     unit.RAMGiBHour,
-						Price:    0.4237,
-					},
-				},
-			},
-		},
-	}
-
-	for _, tt := range testCases {
-		t.Run(tt.name, func(t *testing.T) {
-			result := tt.prices.GetPrices()
-
-			expectedLen := 0
-			for _, prices := range tt.prices {
-				expectedLen += len(prices)
-			}
-
-			if len(result) != expectedLen {
-				t.Errorf("expected %d prices, got %d", expectedLen, len(result))
-			}
-		})
-	}
-}
-
-func TestGetPricesInCurrency(t *testing.T) {
-	tests := []struct {
-		name          string
-		prices        Prices
-		currency      unit.Currency
-		expectedCount int
-		expectError   bool
-		errorType     error
-	}{
-		{
-			name:          "empty Prices - should return NotFound error",
-			prices:        Prices{},
-			currency:      unit.USD,
-			expectedCount: 0,
-			expectError:   true,
-			errorType:     NotFound,
-		},
-		{
-			name: "currency not found - should return NotFound error",
-			prices: Prices{
-				unit.EUR: []Price{
-					{
-						Currency: unit.EUR,
-						Unit:     unit.VCPUHour,
-						Price:    0.028,
-					},
-				},
-			},
-			currency:      unit.USD,
-			expectedCount: 0,
-			expectError:   true,
-			errorType:     NotFound,
-		},
-		{
-			name: "single matching currency",
-			prices: Prices{
-				unit.USD: []Price{
-					{
-						Currency: unit.USD,
-						Unit:     unit.VCPUHour,
-						Price:    0.031611,
-					},
-				},
-			},
-			currency:      unit.USD,
-			expectedCount: 1,
-			expectError:   false,
-		},
-		{
-			name: "multiple currencies - find USD",
-			prices: Prices{
-				unit.USD: []Price{
-					{
-						Currency: unit.USD,
-						Unit:     unit.VCPUHour,
-						Price:    0.031611,
-					},
-				},
-				unit.EUR: []Price{
-					{
-						Currency: unit.EUR,
-						Unit:     unit.VCPUHour,
-						Price:    0.028,
-					},
-				},
-				unit.GBP: []Price{
-					{
-						Currency: unit.GBP,
-						Unit:     unit.RAMGiBHour,
-						Price:    0.025,
-					},
-				},
-			},
-			currency:      unit.USD,
-			expectedCount: 1,
-			expectError:   false,
-		},
-		{
-			name: "multiple currencies - find EUR",
-			prices: Prices{
-				unit.USD: []Price{
-					{
-						Currency: unit.USD,
-						Unit:     unit.VCPUHour,
-						Price:    0.031611,
-					},
-				},
-				unit.EUR: []Price{
-					{
-						Currency: unit.EUR,
-						Unit:     unit.VCPUHour,
-						Price:    0.028,
-					},
-				},
-			},
-			currency:      unit.EUR,
-			expectedCount: 1,
-			expectError:   false,
-		},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			result, err := tt.prices.GetPricesInCurrency(tt.currency)
-
-			if tt.expectError {
-				if err == nil {
-					t.Error("expected error but got none")
-				}
-				if !errors.Is(err, tt.errorType) {
-					t.Errorf("expected error type %v, got %v", tt.errorType, err)
-				}
-				if len(result) != 0 {
-					t.Errorf("expected nil or empty result on error, got %d prices", len(result))
-				}
-			} else {
-				if err != nil {
-					t.Errorf("unexpected error: %v", err)
-				}
-				if len(result) != tt.expectedCount {
-					t.Errorf("expected %d prices, got %d", tt.expectedCount, len(result))
-				}
-
-				// Verify all returned prices have the requested currency
-				for _, price := range result {
-					if price.Currency != tt.currency {
-						t.Errorf("expected currency %v, got %v", tt.currency, price.Currency)
-					}
-				}
-			}
-		})
-	}
-}
-
-func TestGetPricesInCurrencyWithDefault(t *testing.T) {
-	testCases := []struct {
-		name            string
-		prices          Prices
-		currency        unit.Currency
-		defaultCurrency unit.Currency
-		expectedCount   int
-		expectError     bool
-		expectedCurr    unit.Currency
-	}{
-		{
-			name:            "empty Prices - should return NotFound error",
-			prices:          Prices{},
-			currency:        unit.USD,
-			defaultCurrency: unit.EUR,
-			expectedCount:   0,
-			expectError:     true,
-		},
-		{
-			name: "currency found - should return requested currency",
-			prices: Prices{
-				unit.USD: []Price{
-					{
-						Currency: unit.USD,
-						Unit:     unit.VCPUHour,
-						Price:    0.031611,
-					},
-				},
-			},
-			currency:        unit.USD,
-			defaultCurrency: unit.EUR,
-			expectedCount:   1,
-			expectError:     false,
-			expectedCurr:    unit.USD,
-		},
-		{
-			name: "currency not found - should fallback to default",
-			prices: Prices{
-				unit.EUR: []Price{
-					{
-						Currency: unit.EUR,
-						Unit:     unit.VCPUHour,
-						Price:    0.028,
-					},
-				},
-			},
-			currency:        unit.USD,
-			defaultCurrency: unit.EUR,
-			expectedCount:   1,
-			expectError:     false,
-			expectedCurr:    unit.EUR,
-		},
-		{
-			name: "neither currency nor default found - should return NotFound error",
-			prices: Prices{
-				unit.GBP: []Price{
-					{
-						Currency: unit.GBP,
-						Unit:     unit.VCPUHour,
-						Price:    0.025,
-					},
-				},
-			},
-			currency:        unit.USD,
-			defaultCurrency: unit.EUR,
-			expectedCount:   0,
-			expectError:     true,
-		},
-		{
-			name: "multiple currencies - prefer requested over default",
-			prices: Prices{
-				unit.USD: []Price{
-					{
-						Currency: unit.USD,
-						Unit:     unit.VCPUHour,
-						Price:    0.031611,
-					},
-				},
-				unit.EUR: []Price{
-					{
-						Currency: unit.EUR,
-						Unit:     unit.VCPUHour,
-						Price:    0.028,
-					},
-				},
-			},
-			currency:        unit.USD,
-			defaultCurrency: unit.EUR,
-			expectedCount:   1,
-			expectError:     false,
-			expectedCurr:    unit.USD,
-		},
-		{
-			name: "same currency and default - should return requested",
-			prices: Prices{
-				unit.USD: []Price{
-					{
-						Currency: unit.USD,
-						Unit:     unit.VCPUHour,
-						Price:    0.031611,
-					},
-				},
-			},
-			currency:        unit.USD,
-			defaultCurrency: unit.USD,
-			expectedCount:   1,
-			expectError:     false,
-			expectedCurr:    unit.USD,
-		},
-	}
-
-	for _, tt := range testCases {
-		t.Run(tt.name, func(t *testing.T) {
-			result, err := tt.prices.GetPricesInCurrencyWithDefault(tt.currency, tt.defaultCurrency)
-
-			if tt.expectError {
-				if err == nil {
-					t.Error("expected error but got none")
-				}
-				if !errors.Is(err, NotFound) {
-					t.Errorf("expected NotFound error, got %v", err)
-				}
-			} else {
-				if err != nil {
-					t.Errorf("unexpected error: %v", err)
-				}
-				if len(result) != tt.expectedCount {
-					t.Errorf("expected %d prices, got %d", tt.expectedCount, len(result))
-				}
-
-				// Verify all returned prices have the expected currency
-				for _, price := range result {
-					if price.Currency != tt.expectedCurr {
-						t.Errorf("expected currency %v, got %v", tt.expectedCurr, price.Currency)
-					}
-				}
-			}
-		})
-	}
-}

+ 110 - 0
core/pkg/pricing/pricingset.go

@@ -0,0 +1,110 @@
+package pricing
+
+import (
+	"cmp"
+	"encoding/hex"
+	"fmt"
+	"hash/fnv"
+	"slices"
+)
+
+type PricingSet struct {
+	ClusterPricing          []*ClusterPricing          `json:"clusterPricing" yaml:"clusterPricing"`
+	NetworkPricing          []*NetworkPricing          `json:"networkPricing" yaml:"networkPricing"`
+	NodePricing             []*NodePricing             `json:"nodePricing" yaml:"nodePricing"`
+	PersistentVolumePricing []*PersistentVolumePricing `json:"persistentVolumePricing" yaml:"persistentVolumePricing"`
+	ServicePricing          []*ServicePricing          `json:"servicePricing" yaml:"servicePricing"`
+}
+
+func (ps *PricingSet) IsEmpty() bool {
+	if ps == nil {
+		return true
+	}
+
+	return len(ps.ClusterPricing) == 0 &&
+		len(ps.NetworkPricing) == 0 &&
+		len(ps.NodePricing) == 0 &&
+		len(ps.PersistentVolumePricing) == 0 &&
+		len(ps.ServicePricing) == 0
+}
+
+// Checksum returns a hash that is stable across map and slice ordering and
+// sensitive to both pricing properties and price values.
+//
+// TODO: Consider commutative hash folding via a multiset hashing algorithm
+// if the string-based Checksum() implementation is too resource intensive
+// for large pricing sets. For now, this string version is more readable.
+func (ps *PricingSet) Checksum() (string, error) {
+	if ps == nil {
+		ps = &PricingSet{}
+	}
+
+	// Each item's String() is prefixed with its kind so that items of
+	// different kinds cannot collide, then all keys are sorted to make the
+	// hash independent of input ordering.
+	keys := make([]string, 0,
+		len(ps.ClusterPricing)+
+			len(ps.NetworkPricing)+
+			len(ps.NodePricing)+
+			len(ps.PersistentVolumePricing)+
+			len(ps.ServicePricing))
+
+	for _, cp := range ps.ClusterPricing {
+		keys = append(keys, "cluster:"+cp.String())
+	}
+	for _, np := range ps.NetworkPricing {
+		keys = append(keys, "network:"+np.String())
+	}
+	for _, np := range ps.NodePricing {
+		keys = append(keys, "node:"+np.String())
+	}
+	for _, pvp := range ps.PersistentVolumePricing {
+		keys = append(keys, "persistentvolume:"+pvp.String())
+	}
+	for _, sp := range ps.ServicePricing {
+		keys = append(keys, "service:"+sp.String())
+	}
+
+	slices.Sort(keys)
+
+	hasher := fnv.New64a()
+	for _, key := range keys {
+		if _, err := hasher.Write([]byte(key)); err != nil {
+			return "", fmt.Errorf("fnv hash: %w", err)
+		}
+	}
+
+	return hex.EncodeToString(hasher.Sum(nil)), nil
+}
+
+// Sort sorts the pricing data to ensure deterministic serialization.
+func (ps *PricingSet) Sort() {
+	if ps == nil {
+		return
+	}
+
+	// Sort clusters
+	slices.SortFunc(ps.ClusterPricing, func(a, b *ClusterPricing) int {
+		return cmp.Compare(a.String(), b.String())
+	})
+
+	// Sort network
+	slices.SortFunc(ps.NetworkPricing, func(a, b *NetworkPricing) int {
+		return cmp.Compare(a.String(), b.String())
+	})
+
+	// Sort nodes
+	slices.SortFunc(ps.NodePricing, func(a, b *NodePricing) int {
+		return cmp.Compare(a.String(), b.String())
+	})
+
+	// Sort persistent volumes
+	slices.SortFunc(ps.PersistentVolumePricing, func(a, b *PersistentVolumePricing) int {
+		return cmp.Compare(a.String(), b.String())
+	})
+
+	// Sort services
+	slices.SortFunc(ps.ServicePricing, func(a, b *ServicePricing) int {
+		return cmp.Compare(a.String(), b.String())
+	})
+}

+ 139 - 0
core/pkg/pricing/pricingset_test.go

@@ -0,0 +1,139 @@
+package pricing
+
+import (
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/model/shared"
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+func nodePricing(instanceType string, price float64) *NodePricing {
+	return &NodePricing{
+		Properties: NodePricingProperties{
+			Provider:     shared.Provider("AWS"),
+			Region:       "us-east-1",
+			InstanceType: instanceType,
+		},
+		Prices: Prices{
+			ResourceNode: {Unit: unit.Hour, Price: price},
+		},
+	}
+}
+
+func pvPricing(volumeType VolumeType, price float64) *PersistentVolumePricing {
+	return &PersistentVolumePricing{
+		Properties: PersistentVolumePricingProperties{
+			Provider:   shared.Provider("AWS"),
+			Region:     "us-east-1",
+			VolumeType: volumeType,
+		},
+		Prices: Prices{
+			ResourceStorage: {Unit: unit.GiBHour, Price: price},
+		},
+	}
+}
+
+// TestChecksumPriceSensitivity verifies that the checksum changes when only a
+// price value changes, even if all properties are identical.
+func TestChecksumPriceSensitivity(t *testing.T) {
+	a := &PricingSet{NodePricing: []*NodePricing{nodePricing("m5.large", 0.096)}}
+	b := &PricingSet{NodePricing: []*NodePricing{nodePricing("m5.large", 0.192)}}
+
+	csA, err := a.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	csB, err := b.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if csA == csB {
+		t.Errorf("expected differing checksums for differing prices, got %q for both", csA)
+	}
+}
+
+// TestChecksumOrderStability verifies that the checksum is independent of the
+// ordering of pricing slices.
+func TestChecksumOrderStability(t *testing.T) {
+	n1 := nodePricing("m5.large", 0.096)
+	n2 := nodePricing("m5.xlarge", 0.192)
+	n3 := nodePricing("m5.2xlarge", 0.384)
+
+	forward := &PricingSet{NodePricing: []*NodePricing{n1, n2, n3}}
+	reverse := &PricingSet{NodePricing: []*NodePricing{n3, n2, n1}}
+
+	csForward, err := forward.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	csReverse, err := reverse.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if csForward != csReverse {
+		t.Errorf("expected checksum to be order-independent, got %q vs %q", csForward, csReverse)
+	}
+}
+
+// TestChecksumNilReceiver verifies that Checksum handles a nil receiver like
+// IsEmpty and Currencies do, rather than panicking.
+func TestChecksumNilReceiver(t *testing.T) {
+	var ps *PricingSet
+	if _, err := ps.Checksum(); err != nil {
+		t.Errorf("unexpected error on nil receiver: %v", err)
+	}
+}
+
+// TestIsEmptyAllKinds verifies that a set holding only Cluster/Network/Service
+// pricing is not reported empty.
+func TestIsEmptyAllKinds(t *testing.T) {
+	if !(&PricingSet{}).IsEmpty() {
+		t.Errorf("expected empty set to report empty")
+	}
+
+	cases := map[string]*PricingSet{
+		"cluster": {ClusterPricing: []*ClusterPricing{{Properties: ClusterPricingProperties{Provider: shared.Provider("AWS")}}}},
+		"network": {NetworkPricing: []*NetworkPricing{{Properties: NetworkPricingProperties{Provider: shared.Provider("AWS")}}}},
+		"node":    {NodePricing: []*NodePricing{nodePricing("m5.large", 0.096)}},
+		"volume":  {PersistentVolumePricing: []*PersistentVolumePricing{pvPricing(VolumeTypeGP3, 0.0001)}},
+		"service": {ServicePricing: []*ServicePricing{{Properties: ServicePricingProperties{Provider: shared.Provider("AWS")}}}},
+	}
+
+	for name, ps := range cases {
+		if ps.IsEmpty() {
+			t.Errorf("set with only %s pricing should not be empty", name)
+		}
+	}
+}
+
+// TestMockGetPricingSetAllKinds verifies that the mock's GetPricingSet exposes
+// the same kinds as its readers, not just node + persistent volume.
+func TestMockGetPricingSetAllKinds(t *testing.T) {
+	mpm, err := NewMockPricingModule()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	ps, err := mpm.GetPricingSet(t.Context())
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if len(ps.NodePricing) != len(mpm.NodePricing) {
+		t.Errorf("expected %d node pricing, got %d", len(mpm.NodePricing), len(ps.NodePricing))
+	}
+	if len(ps.PersistentVolumePricing) != len(mpm.PersistentVolumePricing) {
+		t.Errorf("expected %d volume pricing, got %d", len(mpm.PersistentVolumePricing), len(ps.PersistentVolumePricing))
+	}
+	if len(ps.ClusterPricing) != len(mpm.ClusterPricing) {
+		t.Errorf("expected %d cluster pricing, got %d", len(mpm.ClusterPricing), len(ps.ClusterPricing))
+	}
+	if len(ps.NetworkPricing) != len(mpm.NetworkPricing) {
+		t.Errorf("expected %d network pricing, got %d", len(mpm.NetworkPricing), len(ps.NetworkPricing))
+	}
+	if len(ps.ServicePricing) != len(mpm.ServicePricing) {
+		t.Errorf("expected %d service pricing, got %d", len(mpm.ServicePricing), len(ps.ServicePricing))
+	}
+}

+ 0 - 12
core/pkg/pricing/provider.go

@@ -1,12 +0,0 @@
-package pricing
-
-type Provider string
-
-const (
-	NilProvider    Provider = ""
-	AllProvider    Provider = "all"
-	AWSProvider    Provider = "aws"
-	AzureProvider  Provider = "azure"
-	CustomProvider Provider = "custom"
-	GCPProvider    Provider = "gcp"
-)

+ 0 - 22
core/pkg/pricing/repository.go

@@ -1,22 +0,0 @@
-package pricing
-
-import (
-	"context"
-
-	"github.com/opencost/opencost/core/pkg/reader"
-)
-
-type PricingRepository interface {
-	NodePricingRepository
-	VolumePricingRepository
-}
-
-type NodePricingRepository interface {
-	NewNodePricingReader(ctx context.Context) (reader.Reader[*NodePricing], error)
-	GetNodePricing(provider Provider, instanceType string, region string) (*NodePricing, error)
-}
-
-type VolumePricingRepository interface {
-	NewVolumePricingReader(ctx context.Context) (reader.Reader[*VolumePricing], error)
-	GetVolumePricing(VolumePricingProperties) (*VolumePricing, error)
-}

+ 43 - 0
core/pkg/pricing/resource.go

@@ -0,0 +1,43 @@
+package pricing
+
+import (
+	"fmt"
+	"strings"
+)
+
+type Resource string
+
+const (
+	ResourceNil            Resource = ""
+	ResourceNode           Resource = "node"
+	ResourceCPU            Resource = "cpu"
+	ResourceRAM            Resource = "ram"
+	ResourceGPU            Resource = "gpu"
+	ResourceStorage        Resource = "storage"
+	ResourceCluster        Resource = "cluster"
+	ResourceService        Resource = "service"
+	ResourceNetworkTraffic Resource = "networktraffic"
+)
+
+func ParseResource(str string) (Resource, error) {
+	switch strings.ToLower(str) {
+	case string(ResourceNode):
+		return ResourceNode, nil
+	case string(ResourceCPU):
+		return ResourceCPU, nil
+	case string(ResourceRAM):
+		return ResourceRAM, nil
+	case string(ResourceGPU):
+		return ResourceGPU, nil
+	case string(ResourceStorage):
+		return ResourceStorage, nil
+	case string(ResourceCluster):
+		return ResourceCluster, nil
+	case string(ResourceService):
+		return ResourceService, nil
+	case string(ResourceNetworkTraffic):
+		return ResourceNetworkTraffic, nil
+	default:
+		return ResourceNil, fmt.Errorf("unknown resource %q", str)
+	}
+}

+ 47 - 0
core/pkg/pricing/service.go

@@ -0,0 +1,47 @@
+package pricing
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/model/shared"
+)
+
+type ServicePricing struct {
+	Properties ServicePricingProperties `json:"properties" yaml:"properties"`
+	Prices     Prices                   `json:"prices" yaml:"prices"`
+}
+
+func (sp *ServicePricing) String() string {
+	return sp.Properties.String() + "|" + sp.Prices.canonical()
+}
+
+type ServicePricingProperties struct {
+	Provider shared.Provider `json:"provider,omitempty" yaml:"provider,omitempty"`
+	Region   string          `json:"region,omitempty" yaml:"region,omitempty"`
+	Start    *time.Time      `json:"start,omitempty" yaml:"start,omitempty"`
+	End      *time.Time      `json:"end,omitempty" yaml:"end,omitempty"`
+}
+
+func (sp *ServicePricingProperties) String() string {
+	return fmt.Sprintf("%s:%s:%s",
+		sp.Provider,
+		sp.Region,
+		sp.timeKey(),
+	)
+}
+
+func (sp *ServicePricingProperties) timeKey() string {
+	s := "nil"
+	e := "nil"
+
+	if sp.Start != nil {
+		s = sp.Start.UTC().Format(time.RFC3339Nano)
+	}
+
+	if sp.End != nil {
+		e = sp.End.UTC().Format(time.RFC3339Nano)
+	}
+
+	return fmt.Sprintf("%s:%s", s, e)
+}

+ 0 - 110
core/pkg/pricing/set.go

@@ -1,110 +0,0 @@
-package pricing
-
-import (
-	"maps"
-	"slices"
-
-	"github.com/opencost/opencost/core/pkg/unit"
-)
-
-type PricingSet struct {
-	Nodes   []*NodePricing   `json:"nodes" yaml:"nodes"`
-	Volumes []*VolumePricing `json:"volumes" yaml:"volumes"`
-}
-
-func (ps *PricingSet) IsEmpty() bool {
-	if ps == nil {
-		return true
-	}
-
-	return len(ps.Nodes) == 0 && len(ps.Volumes) == 0
-}
-
-func (ps *PricingSet) Currencies() []unit.Currency {
-	if ps == nil {
-		return []unit.Currency{}
-	}
-
-	currencies := map[unit.Currency]struct{}{}
-
-	for _, np := range ps.Nodes {
-		for _, curr := range np.GetCurrencies() {
-			currencies[curr] = struct{}{}
-		}
-	}
-
-	for _, vp := range ps.Volumes {
-		for _, curr := range vp.GetCurrencies() {
-			currencies[curr] = struct{}{}
-		}
-	}
-
-	return slices.Collect(maps.Keys(currencies))
-}
-
-
-// Sort sorts the pricing data to ensure deterministic serialization.
-// Sorted by: Provider, Region, <Instance/Volume>Type
-func (ps *PricingSet) Sort() {
-	if ps == nil {
-		return
-	}
-
-	// Sort nodes
-	slices.SortFunc(ps.Nodes, func(a, b *NodePricing) int {
-		// Compare by Provider
-		if a.Properties.Provider != b.Properties.Provider {
-			if a.Properties.Provider < b.Properties.Provider {
-				return -1
-			}
-			return 1
-		}
-
-		// Compare by Region
-		if a.Properties.Region != b.Properties.Region {
-			if a.Properties.Region < b.Properties.Region {
-				return -1
-			}
-			return 1
-		}
-
-		// Compare by InstanceType
-		if a.Properties.InstanceType != b.Properties.InstanceType {
-			if a.Properties.InstanceType < b.Properties.InstanceType {
-				return -1
-			}
-			return 1
-		}
-
-		return 0
-	})
-
-	// Sort volumes
-	slices.SortFunc(ps.Volumes, func(a, b *VolumePricing) int {
-		// Compare by Provider
-		if a.Properties.Provider != b.Properties.Provider {
-			if a.Properties.Provider < b.Properties.Provider {
-				return -1
-			}
-			return 1
-		}
-
-		// Compare by Region
-		if a.Properties.Region != b.Properties.Region {
-			if a.Properties.Region < b.Properties.Region {
-				return -1
-			}
-			return 1
-		}
-
-		// Compare by VolumeType
-		if a.Properties.VolumeType < b.Properties.VolumeType {
-			return -1
-		}
-		if a.Properties.VolumeType > b.Properties.VolumeType {
-			return 1
-		}
-
-		return 0
-	})
-}

+ 0 - 179
core/pkg/pricing/set_test.go

@@ -1,179 +0,0 @@
-package pricing
-
-import (
-	"encoding/json"
-	"testing"
-
-	"github.com/opencost/opencost/core/pkg/unit"
-)
-
-func TestPricingSetSort(t *testing.T) {
-	// Create a pricing set with items in non-deterministic order
-	ps1 := &PricingSet{
-		Nodes: []*NodePricing{
-			{
-				Properties: NodePricingProperties{
-					Provider:     AzureProvider,
-					Region:       "eastus",
-					InstanceType: "Standard_D2s_v3",
-				},
-				Prices: Prices{
-					unit.USD: []Price{{Currency: unit.USD, Unit: unit.Hour, Price: 0.096}},
-				},
-			},
-			{
-				Properties: NodePricingProperties{
-					Provider:     AWSProvider,
-					Region:       "us-east-1",
-					InstanceType: "t3.medium",
-				},
-				Prices: Prices{
-					unit.USD: []Price{{Currency: unit.USD, Unit: unit.Hour, Price: 0.0416}},
-				},
-			},
-			{
-				Properties: NodePricingProperties{
-					Provider:     AWSProvider,
-					Region:       "us-east-1",
-					InstanceType: "t3.large",
-				},
-				Prices: Prices{
-					unit.USD: []Price{{Currency: unit.USD, Unit: unit.Hour, Price: 0.0832}},
-				},
-			},
-		},
-		Volumes: []*VolumePricing{
-			{
-				Properties: VolumePricingProperties{
-					Provider:   AzureProvider,
-					Region:     "eastus",
-					VolumeType: VolumeTypePremiumLRS,
-				},
-				Prices: Prices{
-					unit.USD: []Price{{Currency: unit.USD, Unit: unit.Hour, Price: 0.15}},
-				},
-			},
-			{
-				Properties: VolumePricingProperties{
-					Provider:   AWSProvider,
-					Region:     "us-east-1",
-					VolumeType: VolumeTypeGP3,
-				},
-				Prices: Prices{
-					unit.USD: []Price{{Currency: unit.USD, Unit: unit.Hour, Price: 0.08}},
-				},
-			},
-		},
-	}
-
-	// Create a second pricing set with the same items in different order
-	ps2 := &PricingSet{
-		Nodes: []*NodePricing{
-			{
-				Properties: NodePricingProperties{
-					Provider:     AWSProvider,
-					Region:       "us-east-1",
-					InstanceType: "t3.large",
-				},
-				Prices: Prices{
-					unit.USD: []Price{{Currency: unit.USD, Unit: unit.Hour, Price: 0.0832}},
-				},
-			},
-			{
-				Properties: NodePricingProperties{
-					Provider:     AWSProvider,
-					Region:       "us-east-1",
-					InstanceType: "t3.medium",
-				},
-				Prices: Prices{
-					unit.USD: []Price{{Currency: unit.USD, Unit: unit.Hour, Price: 0.0416}},
-				},
-			},
-			{
-				Properties: NodePricingProperties{
-					Provider:     AzureProvider,
-					Region:       "eastus",
-					InstanceType: "Standard_D2s_v3",
-				},
-				Prices: Prices{
-					unit.USD: []Price{{Currency: unit.USD, Unit: unit.Hour, Price: 0.096}},
-				},
-			},
-		},
-		Volumes: []*VolumePricing{
-			{
-				Properties: VolumePricingProperties{
-					Provider:   AWSProvider,
-					Region:     "us-east-1",
-					VolumeType: VolumeTypeGP3,
-				},
-				Prices: Prices{
-					unit.USD: []Price{{Currency: unit.USD, Unit: unit.Hour, Price: 0.08}},
-				},
-			},
-			{
-				Properties: VolumePricingProperties{
-					Provider:   AzureProvider,
-					Region:     "eastus",
-					VolumeType: VolumeTypePremiumLRS,
-				},
-				Prices: Prices{
-					unit.USD: []Price{{Currency: unit.USD, Unit: unit.Hour, Price: 0.15}},
-				},
-			},
-		},
-	}
-
-	// Sort both pricing sets
-	ps1.Sort()
-	ps2.Sort()
-
-	// Serialize both to JSON
-	json1, err := json.Marshal(ps1)
-	if err != nil {
-		t.Fatalf("Failed to marshal ps1: %v", err)
-	}
-
-	json2, err := json.Marshal(ps2)
-	if err != nil {
-		t.Fatalf("Failed to marshal ps2: %v", err)
-	}
-
-	// They should produce identical JSON
-	if string(json1) != string(json2) {
-		t.Errorf("Sorted pricing sets produced different JSON output.\nps1: %s\nps2: %s", string(json1), string(json2))
-	}
-
-	// Verify the sort order is correct (AWS before Azure alphabetically)
-	if ps1.Nodes[0].Properties.Provider != AWSProvider {
-		t.Errorf("Expected first node to be AWS, got %s", ps1.Nodes[0].Properties.Provider)
-	}
-	if ps1.Nodes[2].Properties.Provider != AzureProvider {
-		t.Errorf("Expected third node to be Azure, got %s", ps1.Nodes[2].Properties.Provider)
-	}
-
-	// Verify instance types are sorted within same provider/region
-	if ps1.Nodes[0].Properties.InstanceType != "t3.large" {
-		t.Errorf("Expected first AWS node to be t3.large, got %s", ps1.Nodes[0].Properties.InstanceType)
-	}
-	if ps1.Nodes[1].Properties.InstanceType != "t3.medium" {
-		t.Errorf("Expected second AWS node to be t3.medium, got %s", ps1.Nodes[1].Properties.InstanceType)
-	}
-}
-
-func TestPricingSetSortNil(t *testing.T) {
-	var ps *PricingSet
-	// Should not panic
-	ps.Sort()
-}
-
-func TestPricingSetSortEmpty(t *testing.T) {
-	ps := &PricingSet{
-		Nodes:   []*NodePricing{},
-		Volumes: []*VolumePricing{},
-	}
-	// Should not panic
-	ps.Sort()
-}
-
-// Made with Bob

+ 44 - 0
core/pkg/pricing/source.go

@@ -0,0 +1,44 @@
+package pricing
+
+import (
+	"context"
+
+	"github.com/opencost/opencost/core/pkg/reader"
+)
+
+type PricingSource interface {
+	ClusterPricingSource
+	NetworkPricingSource
+	NodePricingSource
+	PersistentVolumePricingSource
+	ServicePricingSource
+
+	GetPricingSet(context.Context) (*PricingSet, error)
+	SourceKind() string
+	SourceName() string
+}
+
+type ClusterPricingSource interface {
+	GetClusterPricing(ctx context.Context, props ClusterPricingProperties) (*ClusterPricing, error)
+	NewClusterPricingReader(ctx context.Context) (reader.Reader[*ClusterPricing], error)
+}
+
+type NetworkPricingSource interface {
+	GetNetworkPricing(ctx context.Context, props NetworkPricingProperties) (*NetworkPricing, error)
+	NewNetworkPricingReader(ctx context.Context) (reader.Reader[*NetworkPricing], error)
+}
+
+type NodePricingSource interface {
+	NewNodePricingReader(ctx context.Context) (reader.Reader[*NodePricing], error)
+	GetNodePricing(ctx context.Context, props NodePricingProperties) (*NodePricing, error)
+}
+
+type PersistentVolumePricingSource interface {
+	NewPersistentVolumePricingReader(ctx context.Context) (reader.Reader[*PersistentVolumePricing], error)
+	GetPersistentVolumePricing(ctx context.Context, props PersistentVolumePricingProperties) (*PersistentVolumePricing, error)
+}
+
+type ServicePricingSource interface {
+	GetServicePricing(ctx context.Context, props ServicePricingProperties) (*ServicePricing, error)
+	NewServicePricingReader(ctx context.Context) (reader.Reader[*ServicePricing], error)
+}

+ 97 - 74
core/pkg/pricing/test/aws.yaml

@@ -1,176 +1,199 @@
-nodes:
+nodePricing:
   - properties:
       provider: AWS
       region: us-east-1
       instanceType: m5.large
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.096
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.096
   - properties:
       provider: AWS
       region: us-east-1
       instanceType: m5.large
       provisioning: spot
     prices:
-      USD:
-        - price: 0.043
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.043
   - properties:
       provider: AWS
       region: us-east-1
       instanceType: m5.xlarge
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.192
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.192
   - properties:
       provider: AWS
       region: us-east-1
       instanceType: m5.xlarge
       provisioning: spot
     prices:
-      USD:
-        - price: 0.192
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.192
   - properties:
       provider: AWS
       region: us-east-1
       instanceType: m5.2xlarge
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.384
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.384
   - properties:
       provider: AWS
       region: us-east-1
       instanceType: m5.2xlarge
       provisioning: spot
     prices:
-      USD:
-        - price: 0.189
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.189
   - properties:
       provider: AWS
       region: us-west-1
       instanceType: m5.large
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.112
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.112
   - properties:
       provider: AWS
       region: us-west-1
       instanceType: m5.large
       provisioning: spot
     prices:
-      USD:
-        - price: 0.037
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.037
   - properties:
       provider: AWS
       region: us-west-1
       instanceType: m5.xlarge
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.224
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.224
   - properties:
       provider: AWS
       region: us-west-1
       instanceType: m5.xlarge
       provisioning: spot
     prices:
-      USD:
-        - price: 0.069
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.069
   - properties:
       provider: AWS
       region: us-west-1
       instanceType: m5.2xlarge
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.448
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.448
   - properties:
       provider: AWS
       region: us-west-1
       instanceType: m5.2xlarge
       provisioning: spot
     prices:
-      USD:
-        - price: 0.152
-          currency: USD
-          unit: hr
-volumes:
+      node:
+        unit: hr
+        price: 0.152
+persistentVolumePricing:
   - properties:
       provider: AWS
       region: us-east-1
       volumeType: gp3
     prices:
-      USD:
-        - price: 0.0001096
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0001096
   - properties:
       provider: AWS
       region: us-east-1
       volumeType: gp2
     prices:
-      USD:
-        - price: 0.000137
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.000137
   - properties:
       provider: AWS
       region: us-east-1
       volumeType: standard
     prices:
-      USD:
-        - price: 0.0000205
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0000205
   - properties:
       provider: AWS
       region: us-west-1
       volumeType: gp3
     prices:
-      USD:
-        - price: 0.0001315
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0001315
   - properties:
       provider: AWS
       region: us-west-1
       volumeType: gp2
     prices:
-      USD:
-        - price: 0.0001644
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0001644
   - properties:
       provider: AWS
       region: us-west-1
       volumeType: standard
     prices:
-      USD:
-        - price: 0.0000247
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0000247
+clusterPricing:
+  - properties:
+      provider: AWS
+    prices:
+      cluster:
+        unit: hr
+        price: 0.10
+networkPricing:
+  - properties:
+      provider: AWS
+      trafficDirection: Egress
+      trafficType: Internet
+    prices:
+      networktraffic:
+        unit: GB
+        price: 0.09
+  - properties:
+      provider: AWS
+      trafficDirection: Egress
+      trafficType: CrossRegion
+    prices:
+      networktraffic:
+        unit: GB
+        price: 0.02
+  - properties:
+      provider: AWS
+      trafficDirection: Egress
+      trafficType: Internet
+      isNatGateway: true
+    prices:
+      networktraffic:
+        unit: GB
+        price: 0.045
+servicePricing:
+  - properties:
+      provider: AWS
+      region: us-east-1
+    prices:
+      service:
+        unit: hr
+        price: 0.025

+ 56 - 74
core/pkg/pricing/test/azure.yaml

@@ -1,176 +1,158 @@
-nodes:
+nodePricing:
   - properties:
       provider: Azure
       region: eastus
       instanceType: Standard_D2s_v5
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.096
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.096
   - properties:
       provider: Azure
       region: eastus
       instanceType: Standard_D2s_v5
       provisioning: spot
     prices:
-      USD:
-        - price: 0.0288
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.0288
   - properties:
       provider: Azure
       region: eastus
       instanceType: Standard_D4s_v5
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.192
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.192
   - properties:
       provider: Azure
       region: eastus
       instanceType: Standard_D4s_v5
       provisioning: spot
     prices:
-      USD:
-        - price: 0.0576
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.0576
   - properties:
       provider: Azure
       region: eastus
       instanceType: Standard_E2s_v5
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.126
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.126
   - properties:
       provider: Azure
       region: eastus
       instanceType: Standard_E2s_v5
       provisioning: spot
     prices:
-      USD:
-        - price: 0.0378
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.0378
   - properties:
       provider: Azure
       region: westus2
       instanceType: Standard_D2s_v5
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.109
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.109
   - properties:
       provider: Azure
       region: westus2
       instanceType: Standard_D2s_v5
       provisioning: spot
     prices:
-      USD:
-        - price: 0.0327
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.0327
   - properties:
       provider: Azure
       region: westus2
       instanceType: Standard_D4s_v5
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.218
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.218
   - properties:
       provider: Azure
       region: westus2
       instanceType: Standard_D4s_v5
       provisioning: spot
     prices:
-      USD:
-        - price: 0.0654
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.0654
   - properties:
       provider: Azure
       region: westus2
       instanceType: Standard_E2s_v5
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.143
-          currency: USD
-          unit: hr
+      node:
+        unit: hr
+        price: 0.143
   - properties:
       provider: Azure
       region: westus2
       instanceType: Standard_E2s_v5
       provisioning: spot
     prices:
-      USD:
-        - price: 0.0429
-          currency: USD
-          unit: hr
-volumes:
+      node:
+        unit: hr
+        price: 0.0429
+persistentVolumePricing:
   - properties:
       provider: Azure
       region: eastus
       volumeType: Premium_LRS
     prices:
-      USD:
-        - price: 0.0002192
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0002192
   - properties:
       provider: Azure
       region: eastus
       volumeType: StandardSSD_LRS
     prices:
-      USD:
-        - price: 0.0001096
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0001096
   - properties:
       provider: Azure
       region: eastus
       volumeType: Standard_LRS
     prices:
-      USD:
-        - price: 0.0000685
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0000685
   - properties:
       provider: Azure
       region: westus2
       volumeType: Premium_LRS
     prices:
-      USD:
-        - price: 0.0002466
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0002466
   - properties:
       provider: Azure
       region: westus2
       volumeType: StandardSSD_LRS
     prices:
-      USD:
-        - price: 0.0001233
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0001233
   - properties:
       provider: Azure
       region: westus2
       volumeType: Standard_LRS
     prices:
-      USD:
-        - price: 0.0000767
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0000767

+ 11 - 13
core/pkg/pricing/test/default.yaml

@@ -1,17 +1,15 @@
-nodes:
+nodePricing:
   - properties: {}
     prices:
-      USD:
-        - price: 0.031611
-          currency: USD
-          unit: vCPU-hr
-        - price: 0.004237
-          currency: USD
-          unit: RAM-GiB-hr
-volumes:
+      cpu:
+        unit: vCPU-hr
+        price: 0.031611
+      ram:
+        unit: GiB-hr
+        price: 0.004237
+persistentVolumePricing:
   - properties: {}
     prices:
-      USD:
-        - price: 0.0000581
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0000581

+ 107 - 128
core/pkg/pricing/test/gcp.yaml

@@ -1,240 +1,219 @@
-nodes:
+nodePricing:
   - properties:
       provider: GCP
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.031611
-          currency: USD
-          unit: vCPU-hr
-        - price: 0.004237
-          currency: USD
-          unit: RAM-GiB-hr
+      cpu:
+        unit: vCPU-hr
+        price: 0.031611
+      ram:
+        unit: GiB-hr
+        price: 0.004237
   - properties:
       provider: GCP
       provisioning: spot
     prices:
-      USD:
-        - price: 0.006655
-          currency: USD
-          unit: vCPU-hr
-        - price: 0.000892
-          currency: USD
-          unit: RAM-GiB-hr
+      cpu:
+        unit: vCPU-hr
+        price: 0.006655
+      ram:
+        unit: GiB-hr
+        price: 0.000892
   - properties:
       provider: GCP
       region: us-central1
       instanceType: n2-standard-2
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.031611
-          currency: USD
-          unit: vCPU-hr
-        - price: 0.004237
-          currency: USD
-          unit: RAM-GiB-hr
+      cpu:
+        unit: vCPU-hr
+        price: 0.031611
+      ram:
+        unit: GiB-hr
+        price: 0.004237
   - properties:
       provider: GCP
       region: us-central1
       instanceType: n2-standard-2
       provisioning: spot
     prices:
-      USD:
-        - price: 0.009483
-          currency: USD
-          unit: vCPU-hr
-        - price: 0.001271
-          currency: USD
-          unit: RAM-GiB-hr
+      cpu:
+        unit: vCPU-hr
+        price: 0.009483
+      ram:
+        unit: GiB-hr
+        price: 0.001271
   - properties:
       provider: GCP
       region: us-central1
       instanceType: n2-standard-4
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.031611
-          currency: USD
-          unit: vCPU-hr
-        - price: 0.004237
-          currency: USD
-          unit: RAM-GiB-hr
+      cpu:
+        unit: vCPU-hr
+        price: 0.031611
+      ram:
+        unit: GiB-hr
+        price: 0.004237
   - properties:
       provider: GCP
       region: us-central1
       instanceType: n2-standard-4
       provisioning: spot
     prices:
-      USD:
-        - price: 0.009483
-          currency: USD
-          unit: vCPU-hr
-        - price: 0.001271
-          currency: USD
-          unit: RAM-GiB-hr
+      cpu:
+        unit: vCPU-hr
+        price: 0.009483
+      ram:
+        unit: GiB-hr
+        price: 0.001271
   - properties:
       provider: GCP
       region: us-central1
       instanceType: e2-standard-2
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.031611
-          currency: USD
-          unit: vCPU-hr
-        - price: 0.004237
-          currency: USD
-          unit: RAM-GiB-hr
+      cpu:
+        unit: vCPU-hr
+        price: 0.031611
+      ram:
+        unit: GiB-hr
+        price: 0.004237
   - properties:
       provider: GCP
       region: us-central1
       instanceType: e2-standard-2
       provisioning: spot
     prices:
-      USD:
-        - price: 0.009483
-          currency: USD
-          unit: vCPU-hr
-        - price: 0.001271
-          currency: USD
-          unit: RAM-GiB-hr
+      cpu:
+        unit: vCPU-hr
+        price: 0.009483
+      ram:
+        unit: GiB-hr
+        price: 0.001271
   - properties:
       provider: GCP
       region: us-west1
       instanceType: n2-standard-2
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.033646
-          currency: USD
-          unit: vCPU-hr
-        - price: 0.004511
-          currency: USD
-          unit: RAM-GiB-hr
+      cpu:
+        unit: vCPU-hr
+        price: 0.033646
+      ram:
+        unit: GiB-hr
+        price: 0.004511
   - properties:
       provider: GCP
       region: us-west1
       instanceType: n2-standard-2
       provisioning: spot
     prices:
-      USD:
-        - price: 0.010094
-          currency: USD
-          unit: vCPU-hr
-        - price: 0.001353
-          currency: USD
-          unit: RAM-GiB-hr
+      cpu:
+        unit: vCPU-hr
+        price: 0.010094
+      ram:
+        unit: GiB-hr
+        price: 0.001353
   - properties:
       provider: GCP
       region: us-west1
       instanceType: n2-standard-4
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.033646
-          currency: USD
-          unit: vCPU-hr
-        - price: 0.004511
-          currency: USD
-          unit: RAM-GiB-hr
+      cpu:
+        unit: vCPU-hr
+        price: 0.033646
+      ram:
+        unit: GiB-hr
+        price: 0.004511
   - properties:
       provider: GCP
       region: us-west1
       instanceType: n2-standard-4
       provisioning: spot
     prices:
-      USD:
-        - price: 0.010094
-          currency: USD
-          unit: vCPU-hr
-        - price: 0.001353
-          currency: USD
-          unit: RAM-GiB-hr
+      cpu:
+        unit: vCPU-hr
+        price: 0.010094
+      ram:
+        unit: GiB-hr
+        price: 0.001353
   - properties:
       provider: GCP
       region: us-west1
       instanceType: e2-standard-2
       provisioning: on-demand
     prices:
-      USD:
-        - price: 0.033646
-          currency: USD
-          unit: vCPU-hr
-        - price: 0.004511
-          currency: USD
-          unit: RAM-GiB-hr
+      cpu:
+        unit: vCPU-hr
+        price: 0.033646
+      ram:
+        unit: GiB-hr
+        price: 0.004511
   - properties:
       provider: GCP
       region: us-west1
       instanceType: e2-standard-2
       provisioning: spot
     prices:
-      USD:
-        - price: 0.010094
-          currency: USD
-          unit: vCPU-hr
-        - price: 0.001353
-          currency: USD
-          unit: RAM-GiB-hr
-volumes:
+      cpu:
+        unit: vCPU-hr
+        price: 0.010094
+      ram:
+        unit: GiB-hr
+        price: 0.001353
+persistentVolumePricing:
   - properties: {}
     prices:
-      USD:
-        - price: 0.0000581
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0000581
   - properties:
       provider: GCP
       region: us-central1
       volumeType: pd-ssd
     prices:
-      USD:
-        - price: 0.0002329
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0002329
   - properties:
       provider: GCP
       region: us-central1
       volumeType: pd-balanced
     prices:
-      USD:
-        - price: 0.000137
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.000137
   - properties:
       provider: GCP
       region: us-central1
       volumeType: pd-standard
     prices:
-      USD:
-        - price: 0.0000548
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0000548
   - properties:
       provider: GCP
       region: us-west1
       volumeType: pd-ssd
     prices:
-      USD:
-        - price: 0.0002466
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0002466
   - properties:
       provider: GCP
       region: us-west1
       volumeType: pd-balanced
     prices:
-      USD:
-        - price: 0.0001452
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0001452
   - properties:
       provider: GCP
       region: us-west1
       volumeType: pd-standard
     prices:
-      USD:
-        - price: 0.0000581
-          currency: USD
-          unit: storage-GiB-hr
+      storage:
+        unit: GiB-hr
+        price: 0.0000581

+ 0 - 35
core/pkg/pricing/volume.go

@@ -1,35 +0,0 @@
-package pricing
-
-import (
-	"maps"
-	"slices"
-	"time"
-
-	"github.com/opencost/opencost/core/pkg/unit"
-)
-
-type VolumePricingProperties struct {
-	Provider   Provider          `json:"provider,omitempty" yaml:"provider,omitempty"`
-	Region     string            `json:"region,omitempty" yaml:"region,omitempty"`
-	VolumeType VolumeType        `json:"volumeType,omitempty" yaml:"volumeType,omitempty"`
-	Cluster    string            `json:"cluster,omitempty" yaml:"cluster,omitempty"`
-	ProviderID string            `json:"providerID,omitempty" yaml:"providerID,omitempty"`
-	Labels     map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"`
-	Start      *time.Time        `json:"start,omitempty" yaml:"start,omitempty"`
-	End        *time.Time        `json:"end,omitempty" yaml:"end,omitempty"`
-}
-
-type VolumePricing struct {
-	Properties VolumePricingProperties `json:"properties" yaml:"properties"`
-	Prices     Prices                  `json:"prices" yaml:"pricing"`
-}
-
-func (vp *VolumePricing) GetCurrencies() []unit.Currency {
-	currencies := map[unit.Currency]struct{}{}
-
-	for currency := range vp.Prices {
-		currencies[currency] = struct{}{}
-	}
-
-	return slices.Collect(maps.Keys(currencies))
-}

+ 4 - 0
core/pkg/reader/reader.go

@@ -25,6 +25,10 @@ func NewSliceReader[T any](items []T) *SliceReader[T] {
 }
 
 func (r *SliceReader[T]) Read(ctx context.Context, dst []T) (int, error) {
+	if err := ctx.Err(); err != nil {
+		return 0, err
+	}
+
 	if r.pos >= len(r.items) {
 		return 0, Done
 	}

+ 17 - 35
core/pkg/unit/unit.go

@@ -16,16 +16,9 @@ const (
 
 	// Data storage and transfer
 	Byte Unit = "B"
-	KB   Unit = "KB"
 	KiB  Unit = "KiB"
-	MB   Unit = "MB"
 	MiB  Unit = "MiB"
-	GB   Unit = "GB"
 	GiB  Unit = "GiB"
-	TB   Unit = "TB"
-	TiB  Unit = "TiB"
-	PB   Unit = "PB"
-	PiB  Unit = "PiB"
 
 	// Compute resources
 	MCPU Unit = "mCPU"
@@ -33,38 +26,27 @@ const (
 	GPU  Unit = "GPU"
 
 	// Compute resources cumulative over time
-	VCPUHour   Unit = "vCPU-hr"
-	RAMGiBHour Unit = "RAM-GiB-hr"
-	GPUHour    Unit = "GPU-hr"
-
-	// Storage resources cumulative over time
-	StorageGiBHour Unit = "storage-GiB-hr"
+	GiBHour  Unit = "GiB-hr"
+	GPUHour  Unit = "GPU-hr"
+	VCPUHour Unit = "vCPU-hr"
 )
 
 // validUnits is a map of all valid unit strings for quick lookup
 var validUnits = map[string]Unit{
-	string(Millisecond):    Millisecond,
-	string(Second):         Second,
-	string(Minute):         Minute,
-	string(Hour):           Hour,
-	string(Byte):           Byte,
-	string(KB):             KB,
-	string(KiB):            KiB,
-	string(MB):             MB,
-	string(MiB):            MiB,
-	string(GB):             GB,
-	string(GiB):            GiB,
-	string(TB):             TB,
-	string(TiB):            TiB,
-	string(PB):             PB,
-	string(PiB):            PiB,
-	string(MCPU):           MCPU,
-	string(VCPU):           VCPU,
-	string(GPU):            GPU,
-	string(VCPUHour):       VCPUHour,
-	string(RAMGiBHour):     RAMGiBHour,
-	string(GPUHour):        GPUHour,
-	string(StorageGiBHour): StorageGiBHour,
+	string(Millisecond): Millisecond,
+	string(Second):      Second,
+	string(Minute):      Minute,
+	string(Hour):        Hour,
+	string(Byte):        Byte,
+	string(KiB):         KiB,
+	string(MiB):         MiB,
+	string(GiB):         GiB,
+	string(MCPU):        MCPU,
+	string(VCPU):        VCPU,
+	string(GPU):         GPU,
+	string(GiBHour):     GiBHour,
+	string(VCPUHour):    VCPUHour,
+	string(GPUHour):     GPUHour,
 }
 
 // ParseUnit parses a string into a Unit type.

+ 2 - 13
core/pkg/unit/unit_test.go

@@ -17,20 +17,11 @@ func TestParseUnit_Strings(t *testing.T) {
 		{name: "minute", input: "min", expect: Minute, expectErr: false},
 		{name: "hour", input: "hr", expect: Hour, expectErr: false},
 
-		// Data storage units - decimal
+		// Data storage units
 		{name: "byte", input: "B", expect: Byte, expectErr: false},
-		{name: "kilobyte", input: "KB", expect: KB, expectErr: false},
-		{name: "megabyte", input: "MB", expect: MB, expectErr: false},
-		{name: "gigabyte", input: "GB", expect: GB, expectErr: false},
-		{name: "terabyte", input: "TB", expect: TB, expectErr: false},
-		{name: "petabyte", input: "PB", expect: PB, expectErr: false},
-
-		// Data storage units - binary
 		{name: "kibibyte", input: "KiB", expect: KiB, expectErr: false},
 		{name: "mebibyte", input: "MiB", expect: MiB, expectErr: false},
 		{name: "gibibyte", input: "GiB", expect: GiB, expectErr: false},
-		{name: "tebibyte", input: "TiB", expect: TiB, expectErr: false},
-		{name: "pebibyte", input: "PiB", expect: PiB, expectErr: false},
 
 		// Compute resources
 		{name: "mCPU", input: "mCPU", expect: MCPU, expectErr: false},
@@ -38,14 +29,12 @@ func TestParseUnit_Strings(t *testing.T) {
 		{name: "GPU", input: "GPU", expect: GPU, expectErr: false},
 
 		// Compute resources over time
+		{name: "GiB-hr", input: "GiB-hr", expect: GiBHour, expectErr: false},
 		{name: "vCPU-hr", input: "vCPU-hr", expect: VCPUHour, expectErr: false},
-		{name: "RAM-GiB-hr", input: "RAM-GiB-hr", expect: RAMGiBHour, expectErr: false},
 		{name: "GPU-hr", input: "GPU-hr", expect: GPUHour, expectErr: false},
-		{name: "storage-GiB-hr", input: "storage-GiB-hr", expect: StorageGiBHour, expectErr: false},
 
 		// Case insensitive tests
 		{name: "uppercase ms", input: "MS", expect: Millisecond, expectErr: false},
-		{name: "uppercase kb", input: "kb", expect: KB, expectErr: false},
 		{name: "mixed case GiB", input: "gib", expect: GiB, expectErr: false},
 		{name: "mixed case mCPU", input: "Mcpu", expect: MCPU, expectErr: false},
 		{name: "mixed case vCPU-hr", input: "VCPU-HR", expect: VCPUHour, expectErr: false},

+ 140 - 33
modules/pricing/basic/default.go

@@ -1,63 +1,170 @@
 package basic
 
 import (
+	"github.com/opencost/opencost/core/pkg/model/kubemodel"
 	"github.com/opencost/opencost/core/pkg/pricing"
 	"github.com/opencost/opencost/core/pkg/unit"
 )
 
+const DefaultClusterPricePerHour float64 = 0.0
+
+const DefaultNetworkLocalPricePerGiB float64 = 0.0
+const DefaultNetworkCrossZonePricePerGiB float64 = 0.01
+const DefaultNetworkCrossRegionPricePerGiB float64 = 0.01
+const DefaultNetworkInternetPricePerGiB float64 = 0.143
+const DefaultNetworkNATPricePerGiB float64 = 0.045
+
 const DefaultNodePricePerVCPUHour float64 = 0.031611
 const DefaultNodePricePerRAMGiBHour float64 = 0.004237
 const DefaultNodePricePerGPUHour float64 = 0.95
 const DefaultNodePricePerLocalDiskGiBHour float64 = 0.0001096
 
-const DefaultVolumePricePerGiBHour float64 = 0.00005479452
+const DefaultPersistentVolumePricePerGiBHour float64 = 0.00005479452
+
+const DefaultServicePricePerHour float64 = 0.025
 
 func GetDefaultPricingSet() *pricing.PricingSet {
 	return &pricing.PricingSet{
-		Nodes:   []*pricing.NodePricing{GetDefaultNodePricing()},
-		Volumes: []*pricing.VolumePricing{GetDefaultVolumePricing()},
+		ClusterPricing:          GetDefaultClusterPricing(),
+		NetworkPricing:          GetDefaultNetworkPricing(),
+		NodePricing:             GetDefaultNodePricing(),
+		PersistentVolumePricing: GetDefaultPersistentVolumePricing(),
+		ServicePricing:          GetDefaultServicePricing(),
+	}
+}
+
+func GetDefaultClusterPricing() []*pricing.ClusterPricing {
+	return []*pricing.ClusterPricing{
+		{
+			Properties: pricing.ClusterPricingProperties{},
+			Prices: pricing.Prices{
+				pricing.ResourceCluster: {
+					Unit:  unit.Hour,
+					Price: DefaultClusterPricePerHour,
+				},
+			},
+		},
+	}
+}
+
+func GetDefaultNetworkPricing() []*pricing.NetworkPricing {
+	return []*pricing.NetworkPricing{
+		{
+			Properties: pricing.NetworkPricingProperties{
+				TrafficDirection: kubemodel.TrafficDirectionEgress,
+				TrafficType:      kubemodel.TrafficTypeLocal,
+				IsNatGateway:     false,
+			},
+			Prices: pricing.Prices{
+				pricing.ResourceNetworkTraffic: {
+					Unit:  unit.GiB,
+					Price: DefaultNetworkLocalPricePerGiB,
+				},
+			},
+		},
+		{
+			Properties: pricing.NetworkPricingProperties{
+				TrafficDirection: kubemodel.TrafficDirectionEgress,
+				TrafficType:      kubemodel.TrafficTypeCrossZone,
+				IsNatGateway:     false,
+			},
+			Prices: pricing.Prices{
+				pricing.ResourceNetworkTraffic: {
+					Unit:  unit.GiB,
+					Price: DefaultNetworkCrossZonePricePerGiB,
+				},
+			},
+		},
+		{
+			Properties: pricing.NetworkPricingProperties{
+				TrafficDirection: kubemodel.TrafficDirectionEgress,
+				TrafficType:      kubemodel.TrafficTypeCrossRegion,
+				IsNatGateway:     false,
+			},
+			Prices: pricing.Prices{
+				pricing.ResourceNetworkTraffic: {
+					Unit:  unit.GiB,
+					Price: DefaultNetworkCrossRegionPricePerGiB,
+				},
+			},
+		},
+		{
+			Properties: pricing.NetworkPricingProperties{
+				TrafficDirection: kubemodel.TrafficDirectionEgress,
+				TrafficType:      kubemodel.TrafficTypeInternet,
+				IsNatGateway:     false,
+			},
+			Prices: pricing.Prices{
+				pricing.ResourceNetworkTraffic: {
+					Unit:  unit.GiB,
+					Price: DefaultNetworkInternetPricePerGiB,
+				},
+			},
+		},
+		{
+			Properties: pricing.NetworkPricingProperties{
+				TrafficDirection: kubemodel.TrafficDirectionEgress,
+				TrafficType:      kubemodel.TrafficTypeInternet,
+				IsNatGateway:     true,
+			},
+			Prices: pricing.Prices{
+				pricing.ResourceNetworkTraffic: {
+					Unit:  unit.GiB,
+					Price: DefaultNetworkInternetPricePerGiB + DefaultNetworkNATPricePerGiB,
+				},
+			},
+		},
 	}
 }
 
-func GetDefaultNodePricing() *pricing.NodePricing {
-	return &pricing.NodePricing{
-		Properties: pricing.NodePricingProperties{},
-		Prices: pricing.Prices{
-			unit.USD: []pricing.Price{
-				{
-					Currency: unit.USD,
-					Unit:     unit.VCPUHour,
-					Price:    DefaultNodePricePerVCPUHour,
+func GetDefaultNodePricing() []*pricing.NodePricing {
+	return []*pricing.NodePricing{
+		{
+			Properties: pricing.NodePricingProperties{},
+			Prices: pricing.Prices{
+				pricing.ResourceCPU: {
+					Unit:  unit.VCPUHour,
+					Price: DefaultNodePricePerVCPUHour,
 				},
-				{
-					Currency: unit.USD,
-					Unit:     unit.RAMGiBHour,
-					Price:    DefaultNodePricePerRAMGiBHour,
+				pricing.ResourceRAM: {
+					Unit:  unit.GiBHour,
+					Price: DefaultNodePricePerRAMGiBHour,
 				},
-				{
-					Currency: unit.USD,
-					Unit:     unit.GPUHour,
-					Price:    DefaultNodePricePerGPUHour,
+				pricing.ResourceGPU: {
+					Unit:  unit.GPUHour,
+					Price: DefaultNodePricePerGPUHour,
 				},
-				{
-					Currency: unit.USD,
-					Unit:     unit.StorageGiBHour,
-					Price:    DefaultNodePricePerLocalDiskGiBHour,
+				pricing.ResourceStorage: {
+					Unit:  unit.GiBHour,
+					Price: DefaultNodePricePerLocalDiskGiBHour,
+				},
+			},
+		},
+	}
+}
+
+func GetDefaultPersistentVolumePricing() []*pricing.PersistentVolumePricing {
+	return []*pricing.PersistentVolumePricing{
+		{
+			Properties: pricing.PersistentVolumePricingProperties{},
+			Prices: pricing.Prices{
+				pricing.ResourceStorage: {
+					Unit:  unit.GiBHour,
+					Price: DefaultPersistentVolumePricePerGiBHour,
 				},
 			},
 		},
 	}
 }
 
-func GetDefaultVolumePricing() *pricing.VolumePricing {
-	return &pricing.VolumePricing{
-		Properties: pricing.VolumePricingProperties{},
-		Prices: pricing.Prices{
-			unit.USD: []pricing.Price{
-				{
-					Currency: unit.USD,
-					Unit:     unit.StorageGiBHour,
-					Price:    DefaultVolumePricePerGiBHour,
+func GetDefaultServicePricing() []*pricing.ServicePricing {
+	return []*pricing.ServicePricing{
+		{
+			Properties: pricing.ServicePricingProperties{},
+			Prices: pricing.Prices{
+				pricing.ResourceService: {
+					Unit:  unit.Hour,
+					Price: DefaultServicePricePerHour,
 				},
 			},
 		},

+ 11 - 0
modules/pricing/basic/go.mod

@@ -45,6 +45,7 @@ require (
 	github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/fsnotify/fsnotify v1.9.0 // indirect
+	github.com/fxamacker/cbor/v2 v2.9.0 // indirect
 	github.com/go-ini/ini v1.67.0 // indirect
 	github.com/go-jose/go-jose/v4 v4.1.4 // indirect
 	github.com/go-logr/logr v1.4.3 // indirect
@@ -86,6 +87,7 @@ require (
 	github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
 	github.com/subosito/gotenv v1.6.0 // indirect
 	github.com/tinylib/msgp v1.6.3 // indirect
+	github.com/x448/float16 v0.8.4 // indirect
 	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
 	go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect
 	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect
@@ -110,8 +112,17 @@ require (
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
 	google.golang.org/grpc v1.79.3 // indirect
 	google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
+	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
+	k8s.io/api v0.36.0 // indirect
+	k8s.io/apimachinery v0.36.0 // indirect
+	k8s.io/klog/v2 v2.140.0 // indirect
+	k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
+	k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
+	sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
+	sigs.k8s.io/randfill v1.0.0 // indirect
+	sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
 )
 
 go 1.26.3

+ 24 - 0
modules/pricing/basic/go.sum

@@ -97,6 +97,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
 github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
+github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
 github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
 github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
 github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
@@ -207,6 +209,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
 github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
 github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
 go.opentelemetry.io/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA=
@@ -266,7 +270,27 @@ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80=
+k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34=
+k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=
+k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=
+k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
+k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
+k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
+k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
+k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
+k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
+sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
+sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
+sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
+sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
+sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=

+ 253 - 156
modules/pricing/basic/module.go

@@ -4,16 +4,20 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"slices"
+	"sync"
 
-	"github.com/opencost/opencost/core/pkg/log"
 	"github.com/opencost/opencost/core/pkg/pricing"
 	"github.com/opencost/opencost/core/pkg/reader"
 	"github.com/opencost/opencost/core/pkg/unit"
 )
 
+// PricingModule must satisfy the pricing.PricingModule interface
+var _ pricing.PricingModule = (*PricingModule)(nil)
+
 type PricingModule struct {
-	currency unit.Currency
-	store    pricing.PricingStore
+	mu    sync.RWMutex
+	store pricing.PricingStore
 }
 
 func NewBasicPricingModule(store pricing.PricingStore) (*PricingModule, error) {
@@ -39,166 +43,238 @@ func NewBasicPricingModule(store pricing.PricingStore) (*PricingModule, error) {
 		}
 	}
 
-	currencies := pricingSet.Currencies()
-	if len(currencies) > 0 {
-		log.Warnf("detected multiple currencies in basic pricing module (%v): defaulting to %s", currencies, currencies[0])
-	}
-
 	pm := &PricingModule{
-		currency: currencies[0],
-		store:    store,
+		store: store,
 	}
 
 	return pm, nil
 }
 
-func (pm *PricingModule) GetCurrency() unit.Currency {
-	return pm.currency
+func (pm *PricingModule) GetClusterPricing(ctx context.Context, props pricing.ClusterPricingProperties) (*pricing.ClusterPricing, error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	cp, err := pm.getClusterPricing(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	if cp != nil {
+		return cp, nil
+	}
+
+	return nil, errors.New("no cluster pricing")
 }
 
-func (pm *PricingModule) SetCurrency(ctx context.Context, currency unit.Currency) error {
-	prevCurrency := pm.currency
-	if currency == prevCurrency {
-		return nil
+func (pm *PricingModule) NewClusterPricingReader(ctx context.Context) (reader.Reader[*pricing.ClusterPricing], error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	cp, err := pm.getClusterPricing(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting node pricing: %w", err)
 	}
 
-	// 1. Convert existing node pricing to new currency
-	np, err := pm.getNodePricing(ctx)
+	return reader.NewSliceReader([]*pricing.ClusterPricing{cp}), nil
+}
+
+func (pm *PricingModule) GetNetworkPricing(ctx context.Context, props pricing.NetworkPricingProperties) (*pricing.NetworkPricing, error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	nps, err := pm.getNetworkPricing(ctx)
 	if err != nil {
-		return fmt.Errorf("getting node pricing: %w", err)
+		return nil, err
+	}
+
+	// Search through the mock data for a matching network pricing entry
+	for _, np := range nps {
+		if np.Properties.Provider == props.Provider &&
+			np.Properties.TrafficDirection == props.TrafficDirection &&
+			np.Properties.TrafficType == props.TrafficType &&
+			np.Properties.IsNatGateway == props.IsNatGateway {
+			return np, nil
+		}
 	}
+	return nil, fmt.Errorf("network pricing not found for provider=%s, trafficDirection=%s, trafficType=%s, isNatGateway=%t",
+		props.Provider, props.TrafficDirection, props.TrafficType, props.IsNatGateway)
+}
+
+func (pm *PricingModule) NewNetworkPricingReader(ctx context.Context) (reader.Reader[*pricing.NetworkPricing], error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	np, err := pm.getNetworkPricing(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting node pricing: %w", err)
+	}
+
+	return reader.NewSliceReader(slices.Clone(np)), nil
+}
 
-	// Set up new Prices for the new currency
-	newPrices := []pricing.Price{}
+func (pm *PricingModule) GetNodePricing(ctx context.Context, props pricing.NodePricingProperties) (*pricing.NodePricing, error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
 
-	// Convert all existing prices to the new currency
-	oldPrices, ok := np.Prices[prevCurrency]
-	if !ok {
-		log.Warnf("setting currency to '%s': no node prices found for existing currency '%s'", currency, pm.currency)
-		// There are no prices for the current currency.
-		// Set default prices using the new currency.
-		newPrices = GetDefaultNodePricing().Prices[unit.USD]
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		return nil, err
 	}
 
-	for _, price := range oldPrices {
-		newPrices = append(newPrices, pricing.Price{
-			Currency: currency,
-			Unit:     price.Unit,
-			Price:    price.Price,
-		})
+	if np != nil {
+		return np, nil
 	}
 
-	// Set new prices under new currency
-	np.Prices = make(pricing.Prices, 1)
-	np.Prices[currency] = newPrices
+	return nil, errors.New("no node pricing")
+}
 
-	// Set node pricing on the module
-	err = pm.setNodePricing(ctx, np)
+func (pm *PricingModule) NewNodePricingReader(ctx context.Context) (reader.Reader[*pricing.NodePricing], error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	np, err := pm.getNodePricing(ctx)
 	if err != nil {
-		return fmt.Errorf("setting node pricing: %w", err)
+		return nil, fmt.Errorf("getting node pricing: %w", err)
 	}
 
-	// 2. Convert existing volume pricing to new currency
-	vp, err := pm.getVolumePricing(ctx)
+	return reader.NewSliceReader([]*pricing.NodePricing{np}), nil
+}
+
+func (pm *PricingModule) GetPersistentVolumePricing(ctx context.Context, props pricing.PersistentVolumePricingProperties) (*pricing.PersistentVolumePricing, error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	pvp, err := pm.getPersistentVolumePricing(ctx)
 	if err != nil {
-		return fmt.Errorf("getting node pricing: %w", err)
+		return nil, err
+	}
+
+	if pvp != nil {
+		return pvp, nil
 	}
 
-	// Set up new Prices for the new currency
-	newPrices = []pricing.Price{}
+	return nil, errors.New("no persistent volume pricing")
+}
+
+func (pm *PricingModule) NewPersistentVolumePricingReader(ctx context.Context) (reader.Reader[*pricing.PersistentVolumePricing], error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
 
-	// Convert all existing prices to the new currency
-	oldPrices, ok = vp.Prices[prevCurrency]
-	if !ok {
-		log.Warnf("setting currency to '%s': no node prices found for existing currency '%s'", currency, pm.currency)
-		// There are no prices for the current currency.
-		// Set default prices using the new currency.
-		newPrices = GetDefaultVolumePricing().Prices[unit.USD]
+	pvp, err := pm.getPersistentVolumePricing(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting volume pricing: %w", err)
 	}
 
-	for _, price := range oldPrices {
-		newPrices = append(newPrices, pricing.Price{
-			Currency: currency,
-			Unit:     price.Unit,
-			Price:    price.Price,
-		})
+	return reader.NewSliceReader([]*pricing.PersistentVolumePricing{pvp}), nil
+}
+
+func (pm *PricingModule) GetServicePricing(ctx context.Context, props pricing.ServicePricingProperties) (*pricing.ServicePricing, error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	sp, err := pm.getServicePricing(ctx)
+	if err != nil {
+		return nil, err
 	}
 
-	// Set new prices under new currency
-	vp.Prices = make(pricing.Prices, 1)
-	vp.Prices[currency] = newPrices
+	if sp != nil {
+		return sp, nil
+	}
 
-	// Set node pricing on the module
-	err = pm.setVolumePricing(ctx, vp)
+	return nil, errors.New("no service pricing")
+}
+
+func (pm *PricingModule) NewServicePricingReader(ctx context.Context) (reader.Reader[*pricing.ServicePricing], error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	sp, err := pm.getServicePricing(ctx)
 	if err != nil {
-		return fmt.Errorf("setting node pricing: %w", err)
+		return nil, fmt.Errorf("getting service pricing: %w", err)
 	}
 
-	return nil
+	return reader.NewSliceReader([]*pricing.ServicePricing{sp}), nil
+}
+
+func (pm *PricingModule) GetPricingSet(ctx context.Context) (*pricing.PricingSet, error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	return pm.store.GetPricingSet(ctx)
+}
+
+func (pm *PricingModule) SourceKind() string {
+	return "basic"
 }
 
+func (pm *PricingModule) SourceName() string {
+	return "basic"
+}
+
+func (pm *PricingModule) Checksum(ctx context.Context) (string, error) {
+	pricingSet, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return "", fmt.Errorf("basic pricing module: error getting pricing set: %w", err)
+	}
+
+	checksum, err := pricingSet.Checksum()
+	if err != nil {
+		return "", fmt.Errorf("basic pricing module: error computing checksum: %s", err)
+	}
+
+	return checksum, nil
+}
+
+// Public CRUD functions
+
 func (pm *PricingModule) SetNodePricePerCPUCoreHour(ctx context.Context, price float64) error {
-	return pm.setNodePrice(ctx, unit.VCPUHour, price)
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setNodePrice(ctx, pricing.ResourceCPU, unit.VCPUHour, price)
 }
 
 func (pm *PricingModule) SetNodePricePerRAMGiBHour(ctx context.Context, price float64) error {
-	return pm.setNodePrice(ctx, unit.RAMGiBHour, price)
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setNodePrice(ctx, pricing.ResourceRAM, unit.GiBHour, price)
 }
 
 func (pm *PricingModule) SetNodePricePerGPUHour(ctx context.Context, price float64) error {
-	return pm.setNodePrice(ctx, unit.GPUHour, price)
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setNodePrice(ctx, pricing.ResourceGPU, unit.GPUHour, price)
 }
 
 func (pm *PricingModule) SetNodePricePerLocalDiskGiBHour(ctx context.Context, price float64) error {
-	return pm.setNodePrice(ctx, unit.StorageGiBHour, price)
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setNodePrice(ctx, pricing.ResourceStorage, unit.GiBHour, price)
 }
 
 func (pm *PricingModule) SetVolumePricePerStorageGiBHour(ctx context.Context, price float64) error {
-	return pm.setVolumePrice(ctx, unit.StorageGiBHour, price)
-}
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
 
-func (pm *PricingModule) NewNodePricingReader(ctx context.Context) (reader.Reader[*pricing.NodePricing], error) {
-	np, err := pm.getNodePricing(ctx)
-	if err != nil {
-		return nil, fmt.Errorf("getting node pricing: %w", err)
-	}
-	return reader.NewSliceReader([]*pricing.NodePricing{np}), nil
+	return pm.setVolumePrice(ctx, pricing.ResourceStorage, unit.GiBHour, price)
 }
 
-func (pm *PricingModule) NewVolumePricingReader(ctx context.Context) (reader.Reader[*pricing.VolumePricing], error) {
-	vp, err := pm.getVolumePricing(ctx)
-	if err != nil {
-		return nil, fmt.Errorf("getting volume pricing: %w", err)
-	}
-	return reader.NewSliceReader([]*pricing.VolumePricing{vp}), nil
-}
+// Private functions to set a price by resource and unit
 
-func (pm *PricingModule) setNodePrice(ctx context.Context, unit unit.Unit, price float64) error {
+func (pm *PricingModule) setNodePrice(ctx context.Context, resource pricing.Resource, unit unit.Unit, price float64) error {
 	np, err := pm.getNodePricing(ctx)
 	if err != nil {
 		return fmt.Errorf("getting node pricing: %w", err)
 	}
 
-	prices, ok := np.Prices[pm.currency]
-	if !ok {
-		log.Warnf("setting price per %s to '%f': no node prices found for existing currency '%s'", unit, price, pm.currency)
-		// There are no prices for the current currency.
-		// Set default prices using the new currency.
-		np = GetDefaultNodePricing()
+	if np.Prices == nil {
+		np.Prices = pricing.Prices{}
 	}
+	np.Prices[resource] = pricing.Price{Unit: unit, Price: price}
 
-	// Set the price with unit GiBHour to the given price
-	for i, p := range prices {
-		if p.Unit == unit {
-			prices[i] = pricing.Price{
-				Currency: p.Currency,
-				Unit:     p.Unit,
-				Price:    price,
-			}
-		}
-	}
-
-	// Set the new node pricing
 	err = pm.setNodePricing(ctx, np)
 	if err != nil {
 		return fmt.Errorf("setting node pricing: %w", err)
@@ -207,38 +283,63 @@ func (pm *PricingModule) setNodePrice(ctx context.Context, unit unit.Unit, price
 	return nil
 }
 
-func (pm *PricingModule) setVolumePrice(ctx context.Context, unit unit.Unit, price float64) error {
-	vp, err := pm.getVolumePricing(ctx)
+func (pm *PricingModule) setVolumePrice(ctx context.Context, resource pricing.Resource, unit unit.Unit, price float64) error {
+	vp, err := pm.getPersistentVolumePricing(ctx)
 	if err != nil {
 		return fmt.Errorf("getting volume pricing: %w", err)
 	}
 
-	prices, ok := vp.Prices[pm.currency]
-	if !ok {
-		log.Warnf("setting price per %s to '%f': no volume prices found for existing currency '%s'", unit, price, pm.currency)
-		// There are no prices for the current currency.
-		// Set default prices using the new currency.
-		vp = GetDefaultVolumePricing()
+	if vp.Prices == nil {
+		vp.Prices = pricing.Prices{}
 	}
+	vp.Prices[resource] = pricing.Price{Unit: unit, Price: price}
 
-	// Set the price with unit GiBHour to the given price
-	for i, p := range prices {
-		if p.Unit == unit {
-			prices[i] = pricing.Price{
-				Currency: p.Currency,
-				Unit:     p.Unit,
-				Price:    price,
-			}
-		}
+	err = pm.setPersistentVolumePricing(ctx, vp)
+	if err != nil {
+		return fmt.Errorf("setting volume pricing: %w", err)
 	}
 
-	// Set the new volume pricing
-	err = pm.setVolumePricing(ctx, vp)
+	return nil
+}
+
+// Private functions to get and set pricing
+
+func (pm *PricingModule) getClusterPricing(ctx context.Context) (*pricing.ClusterPricing, error) {
+	ps, err := pm.store.GetPricingSet(ctx)
 	if err != nil {
-		return fmt.Errorf("setting node pricing: %w", err)
+		return nil, fmt.Errorf("getting pricing: %w", err)
 	}
 
-	return nil
+	if len(ps.ClusterPricing) == 0 {
+		return nil, errors.New("not found")
+	}
+
+	// Only one default ClusterPricing is allowed in basic pricing.
+	// If multiple exist, return only the first one.
+	return ps.ClusterPricing[0], nil
+}
+
+func (pm *PricingModule) setClusterPricing(ctx context.Context, cp *pricing.ClusterPricing) error {
+	// TODO
+	return errors.New("not implemented")
+}
+
+func (pm *PricingModule) getNetworkPricing(ctx context.Context) ([]*pricing.NetworkPricing, error) {
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting pricing: %w", err)
+	}
+
+	if len(ps.NetworkPricing) == 0 {
+		return nil, errors.New("not found")
+	}
+
+	return ps.NetworkPricing, nil
+}
+
+func (pm *PricingModule) setNetworkPricing(ctx context.Context, np *pricing.NetworkPricing) error {
+	// TODO
+	return errors.New("not implemented")
 }
 
 func (pm *PricingModule) getNodePricing(ctx context.Context) (*pricing.NodePricing, error) {
@@ -247,13 +348,13 @@ func (pm *PricingModule) getNodePricing(ctx context.Context) (*pricing.NodePrici
 		return nil, fmt.Errorf("getting pricing: %w", err)
 	}
 
-	if len(ps.Nodes) == 0 {
+	if len(ps.NodePricing) == 0 {
 		return nil, errors.New("not found")
 	}
 
 	// Only one default NodePricing is allowed in basic pricing.
 	// If multiple exist, return only the first one.
-	return ps.Nodes[0], nil
+	return ps.NodePricing[0], nil
 }
 
 func (pm *PricingModule) setNodePricing(ctx context.Context, np *pricing.NodePricing) error {
@@ -261,18 +362,6 @@ func (pm *PricingModule) setNodePricing(ctx context.Context, np *pricing.NodePri
 		return errors.New("nil node pricing")
 	}
 
-	// Make sure precisely one currency is set
-	currs := np.GetCurrencies()
-	if len(currs) == 0 {
-		return errors.New("pricing is empty")
-	}
-	if len(currs) > 1 {
-		return fmt.Errorf("setting multiple currencies: %v", currs)
-	}
-
-	// Update PricingModule to use given currency
-	pm.currency = currs[0]
-
 	// Get the pricing set
 	ps, err := pm.store.GetPricingSet(ctx)
 	if err != nil {
@@ -280,7 +369,7 @@ func (pm *PricingModule) setNodePricing(ctx context.Context, np *pricing.NodePri
 	}
 
 	// Only one default NodePricing is allowed in basic pricing.
-	ps.Nodes = []*pricing.NodePricing{np}
+	ps.NodePricing = []*pricing.NodePricing{np}
 
 	// Set the new pricing set
 	err = pm.store.SetPricingSet(ctx, ps)
@@ -291,38 +380,26 @@ func (pm *PricingModule) setNodePricing(ctx context.Context, np *pricing.NodePri
 	return nil
 }
 
-func (pm *PricingModule) getVolumePricing(ctx context.Context) (*pricing.VolumePricing, error) {
+func (pm *PricingModule) getPersistentVolumePricing(ctx context.Context) (*pricing.PersistentVolumePricing, error) {
 	ps, err := pm.store.GetPricingSet(ctx)
 	if err != nil {
 		return nil, fmt.Errorf("getting pricing: %w", err)
 	}
 
-	if len(ps.Volumes) == 0 {
+	if len(ps.PersistentVolumePricing) == 0 {
 		return nil, errors.New("not found")
 	}
 
 	// Only one default VolumePricing is allowed in basic pricing.
 	// If multiple exist, return only the first one.
-	return ps.Volumes[0], nil
+	return ps.PersistentVolumePricing[0], nil
 }
 
-func (pm *PricingModule) setVolumePricing(ctx context.Context, vp *pricing.VolumePricing) error {
+func (pm *PricingModule) setPersistentVolumePricing(ctx context.Context, vp *pricing.PersistentVolumePricing) error {
 	if vp == nil {
 		return errors.New("nil volume pricing")
 	}
 
-	// Make sure precisely one currency is set
-	currs := vp.GetCurrencies()
-	if len(currs) == 0 {
-		return errors.New("pricing is empty")
-	}
-	if len(currs) > 1 {
-		return fmt.Errorf("setting multiple currencies: %v", currs)
-	}
-
-	// Update PricingModule to use given currency
-	pm.currency = currs[0]
-
 	// Get the pricing set
 	ps, err := pm.store.GetPricingSet(ctx)
 	if err != nil {
@@ -330,7 +407,7 @@ func (pm *PricingModule) setVolumePricing(ctx context.Context, vp *pricing.Volum
 	}
 
 	// Only one default VolumePricing is allowed in basic pricing.
-	ps.Volumes = []*pricing.VolumePricing{vp}
+	ps.PersistentVolumePricing = []*pricing.PersistentVolumePricing{vp}
 
 	// Set the new pricing set
 	err = pm.store.SetPricingSet(ctx, ps)
@@ -340,3 +417,23 @@ func (pm *PricingModule) setVolumePricing(ctx context.Context, vp *pricing.Volum
 
 	return nil
 }
+
+func (pm *PricingModule) getServicePricing(ctx context.Context) (*pricing.ServicePricing, error) {
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting pricing: %w", err)
+	}
+
+	if len(ps.ServicePricing) == 0 {
+		return nil, errors.New("not found")
+	}
+
+	// Only one default ServicePricing is allowed in basic pricing.
+	// If multiple exist, return only the first one.
+	return ps.ServicePricing[0], nil
+}
+
+func (pm *PricingModule) setServicePricing(ctx context.Context, sp *pricing.ServicePricing) error {
+	// TODO
+	return errors.New("not implemented")
+}

+ 69 - 258
modules/pricing/basic/module_test.go

@@ -8,10 +8,26 @@ import (
 	"github.com/opencost/opencost/core/pkg/pricing"
 	"github.com/opencost/opencost/core/pkg/reader"
 	"github.com/opencost/opencost/core/pkg/storage"
-	"github.com/opencost/opencost/core/pkg/unit"
 	"github.com/stretchr/testify/require"
 )
 
+// TestNewBasicPricingModuleEmptyStore verifies the constructor populates a
+// default pricing set when given an empty store.
+func TestNewBasicPricingModuleEmptyStore(t *testing.T) {
+	store := pricing.NewMemoryPricingStore()
+
+	pm, err := NewBasicPricingModule(store)
+	require.NoError(t, err)
+
+	ps, err := store.GetPricingSet(t.Context())
+	require.NoError(t, err)
+	require.False(t, ps.IsEmpty())
+
+	np, err := pm.getNodePricing(t.Context())
+	require.NoError(t, err)
+	require.NotNil(t, np)
+}
+
 func TestPricingModule(t *testing.T) {
 	memoryPricingStore := pricing.NewMemoryPricingStore()
 
@@ -39,10 +55,6 @@ func testPricingModuleWithStore(store pricing.PricingStore) func(t *testing.T) {
 			testDefaultPricing(t, ctx, pm)
 		})
 
-		t.Run("SetCurrency", func(t *testing.T) {
-			testSetCurrency(t, ctx, pm)
-		})
-
 		t.Run("SetNodePricePerCPUCoreHour", func(t *testing.T) {
 			testSetNodePricePerCPUCoreHour(t, ctx, pm)
 		})
@@ -91,12 +103,6 @@ func testPricingModuleWithStore(store pricing.PricingStore) func(t *testing.T) {
 
 // testDefaultPricing verifies that a freshly created PricingModule contains default pricing
 func testDefaultPricing(t *testing.T, ctx context.Context, pm *PricingModule) {
-	// Test default currency
-	currency := pm.GetCurrency()
-	if currency != unit.USD {
-		t.Errorf("Expected default currency to be USD, got %s", currency)
-	}
-
 	// Test default node pricing
 	np, err := pm.getNodePricing(ctx)
 	if err != nil {
@@ -107,48 +113,30 @@ func testDefaultPricing(t *testing.T, ctx context.Context, pm *PricingModule) {
 		t.Fatal("Expected node pricing to exist")
 	}
 
-	prices, err := np.Prices.GetPricesInCurrency(unit.USD)
-	if err != nil {
-		t.Fatalf("Failed to get prices in USD: %v", err)
+	// Prices are keyed by Resource. RAM and local disk share the GiB-hr unit,
+	// so the Resource key is what distinguishes them.
+	nodeChecks := []struct {
+		resource pricing.Resource
+		want     float64
+	}{
+		{pricing.ResourceCPU, DefaultNodePricePerVCPUHour},
+		{pricing.ResourceRAM, DefaultNodePricePerRAMGiBHour},
+		{pricing.ResourceGPU, DefaultNodePricePerGPUHour},
+		{pricing.ResourceStorage, DefaultNodePricePerLocalDiskGiBHour},
 	}
-
-	// Verify default prices exist
-	foundCPU := false
-	foundRAM := false
-	foundGPU := false
-
-	for _, price := range prices {
-		switch price.Unit {
-		case unit.VCPUHour:
-			foundCPU = true
-			if price.Price != DefaultNodePricePerVCPUHour {
-				t.Errorf("Expected CPU price to be %f, got %f", DefaultNodePricePerVCPUHour, price.Price)
-			}
-		case unit.RAMGiBHour:
-			foundRAM = true
-			if price.Price != DefaultNodePricePerRAMGiBHour {
-				t.Errorf("Expected RAM price to be %f, got %f", DefaultNodePricePerRAMGiBHour, price.Price)
-			}
-		case unit.GPUHour:
-			foundGPU = true
-			if price.Price != DefaultNodePricePerGPUHour {
-				t.Errorf("Expected GPU price to be %f, got %f", DefaultNodePricePerGPUHour, price.Price)
-			}
+	for _, c := range nodeChecks {
+		price, ok := np.Prices[c.resource]
+		if !ok {
+			t.Errorf("Expected to find %s pricing", c.resource)
+			continue
+		}
+		if price.Price != c.want {
+			t.Errorf("Expected %s price to be %f, got %f", c.resource, c.want, price.Price)
 		}
-	}
-
-	if !foundCPU {
-		t.Error("Expected to find CPU pricing")
-	}
-	if !foundRAM {
-		t.Error("Expected to find RAM pricing")
-	}
-	if !foundGPU {
-		t.Error("Expected to find GPU pricing")
 	}
 
 	// Test default volume pricing
-	vp, err := pm.getVolumePricing(ctx)
+	vp, err := pm.getPersistentVolumePricing(ctx)
 	if err != nil {
 		t.Fatalf("Failed to get volume pricing: %v", err)
 	}
@@ -157,134 +145,12 @@ func testDefaultPricing(t *testing.T, ctx context.Context, pm *PricingModule) {
 		t.Fatal("Expected volume pricing to exist")
 	}
 
-	volumePrices, err := vp.Prices.GetPricesInCurrency(unit.USD)
-	if err != nil {
-		t.Fatalf("Failed to get volume prices in USD: %v", err)
-	}
-
-	foundVolume := false
-	for _, price := range volumePrices {
-		if price.Unit == unit.StorageGiBHour {
-			foundVolume = true
-			if price.Price != DefaultVolumePricePerGiBHour {
-				t.Errorf("Expected volume price to be %f, got %f", DefaultVolumePricePerGiBHour, price.Price)
-			}
-		}
-	}
-
-	if !foundVolume {
-		t.Error("Expected to find volume pricing")
-	}
-}
-
-// testSetCurrency tests the SetCurrency function
-func testSetCurrency(t *testing.T, ctx context.Context, pm *PricingModule) {
-	// Get current pricing to compare later
-	npBefore, err := pm.getNodePricing(ctx)
-	if err != nil {
-		t.Fatalf("Failed to get node pricing before currency change: %v", err)
-	}
-
-	pricesBefore, err := npBefore.Prices.GetPricesInCurrency(pm.GetCurrency())
-	if err != nil {
-		t.Fatalf("Failed to get prices before currency change: %v", err)
-	}
-
-	vpBefore, err := pm.getVolumePricing(ctx)
-	if err != nil {
-		t.Fatalf("Failed to get volume pricing before currency change: %v", err)
-	}
-
-	volumePricesBefore, err := vpBefore.Prices.GetPricesInCurrency(pm.GetCurrency())
-	if err != nil {
-		t.Fatalf("Failed to get volume prices before currency change: %v", err)
-	}
-
-	// Change currency to EUR
-	err = pm.SetCurrency(ctx, unit.EUR)
-	if err != nil {
-		t.Fatalf("Failed to set currency: %v", err)
-	}
-
-	// Verify currency changed
-	currency := pm.GetCurrency()
-	if currency != unit.EUR {
-		t.Errorf("Expected currency to be EUR, got %s", currency)
-	}
-
-	// Verify node pricing units and prices remain the same, only currency changed
-	npAfter, err := pm.getNodePricing(ctx)
-	if err != nil {
-		t.Fatalf("Failed to get node pricing after currency change: %v", err)
-	}
-
-	pricesAfter, err := npAfter.Prices.GetPricesInCurrency(unit.EUR)
-	if err != nil {
-		t.Fatalf("Failed to get prices after currency change: %v", err)
-	}
-
-	if len(pricesBefore) != len(pricesAfter) {
-		t.Errorf("Expected same number of prices, got %d before and %d after", len(pricesBefore), len(pricesAfter))
-	}
-
-	// Create maps for easier comparison
-	beforeMap := make(map[unit.Unit]float64)
-	for _, p := range pricesBefore {
-		beforeMap[p.Unit] = p.Price
+	volumePrice, ok := vp.Prices[pricing.ResourceStorage]
+	if !ok {
+		t.Fatal("Expected to find volume storage pricing")
 	}
-
-	afterMap := make(map[unit.Unit]float64)
-	for _, p := range pricesAfter {
-		afterMap[p.Unit] = p.Price
-		if p.Currency != unit.EUR {
-			t.Errorf("Expected currency to be EUR, got %s", p.Currency)
-		}
-	}
-
-	// Verify units and prices match
-	for unit, priceBefore := range beforeMap {
-		priceAfter, ok := afterMap[unit]
-		if !ok {
-			t.Errorf("Unit %s not found after currency change", unit)
-			continue
-		}
-		if priceBefore != priceAfter {
-			t.Errorf("Price for unit %s changed from %f to %f", unit, priceBefore, priceAfter)
-		}
-	}
-
-	// Verify volume pricing units and prices remain the same
-	vpAfter, err := pm.getVolumePricing(ctx)
-	if err != nil {
-		t.Fatalf("Failed to get volume pricing after currency change: %v", err)
-	}
-
-	volumePricesAfter, err := vpAfter.Prices.GetPricesInCurrency(unit.EUR)
-	if err != nil {
-		t.Fatalf("Failed to get volume prices after currency change: %v", err)
-	}
-
-	if len(volumePricesBefore) != len(volumePricesAfter) {
-		t.Errorf("Expected same number of volume prices, got %d before and %d after", len(volumePricesBefore), len(volumePricesAfter))
-	}
-
-	for i, priceBefore := range volumePricesBefore {
-		priceAfter := volumePricesAfter[i]
-		if priceAfter.Currency != unit.EUR {
-			t.Errorf("Expected currency to be EUR, got %s", priceAfter.Currency)
-		}
-		if priceBefore.Unit != priceAfter.Unit {
-			t.Errorf("Unit changed from %s to %s", priceBefore.Unit, priceAfter.Unit)
-		}
-		if priceBefore.Price != priceAfter.Price {
-			t.Errorf("Price changed from %f to %f", priceBefore.Price, priceAfter.Price)
-		}
-	}
-
-	// Change back to USD for other tests
-	err = pm.SetCurrency(ctx, unit.USD)
-	if err != nil {
-		t.Fatalf("Failed to set currency back to USD: %v", err)
+	if volumePrice.Price != DefaultPersistentVolumePricePerGiBHour {
+		t.Errorf("Expected volume price to be %f, got %f", DefaultPersistentVolumePricePerGiBHour, volumePrice.Price)
 	}
 }
 
@@ -303,23 +169,12 @@ func testSetNodePricePerCPUCoreHour(t *testing.T, ctx context.Context, pm *Prici
 		t.Fatalf("Failed to get node pricing: %v", err)
 	}
 
-	prices, err := np.Prices.GetPricesInCurrency(pm.GetCurrency())
-	if err != nil {
-		t.Fatalf("Failed to get prices: %v", err)
+	price, ok := np.Prices[pricing.ResourceCPU]
+	if !ok {
+		t.Fatal("Expected to find CPU pricing")
 	}
-
-	found := false
-	for _, price := range prices {
-		if price.Unit == unit.VCPUHour {
-			found = true
-			if price.Price != newPrice {
-				t.Errorf("Expected CPU price to be %f, got %f", newPrice, price.Price)
-			}
-		}
-	}
-
-	if !found {
-		t.Error("Expected to find CPU pricing")
+	if price.Price != newPrice {
+		t.Errorf("Expected CPU price to be %f, got %f", newPrice, price.Price)
 	}
 }
 
@@ -338,23 +193,12 @@ func testSetNodePricePerRAMGiBHour(t *testing.T, ctx context.Context, pm *Pricin
 		t.Fatalf("Failed to get node pricing: %v", err)
 	}
 
-	prices, err := np.Prices.GetPricesInCurrency(pm.GetCurrency())
-	if err != nil {
-		t.Fatalf("Failed to get prices: %v", err)
-	}
-
-	found := false
-	for _, price := range prices {
-		if price.Unit == unit.RAMGiBHour {
-			found = true
-			if price.Price != newPrice {
-				t.Errorf("Expected RAM price to be %f, got %f", newPrice, price.Price)
-			}
-		}
+	price, ok := np.Prices[pricing.ResourceRAM]
+	if !ok {
+		t.Fatal("Expected to find RAM pricing")
 	}
-
-	if !found {
-		t.Error("Expected to find RAM pricing")
+	if price.Price != newPrice {
+		t.Errorf("Expected RAM price to be %f, got %f", newPrice, price.Price)
 	}
 }
 
@@ -373,23 +217,12 @@ func testSetNodePricePerGPUHour(t *testing.T, ctx context.Context, pm *PricingMo
 		t.Fatalf("Failed to get node pricing: %v", err)
 	}
 
-	prices, err := np.Prices.GetPricesInCurrency(pm.GetCurrency())
-	if err != nil {
-		t.Fatalf("Failed to get prices: %v", err)
-	}
-
-	found := false
-	for _, price := range prices {
-		if price.Unit == unit.GPUHour {
-			found = true
-			if price.Price != newPrice {
-				t.Errorf("Expected GPU price to be %f, got %f", newPrice, price.Price)
-			}
-		}
+	price, ok := np.Prices[pricing.ResourceGPU]
+	if !ok {
+		t.Fatal("Expected to find GPU pricing")
 	}
-
-	if !found {
-		t.Error("Expected to find GPU pricing")
+	if price.Price != newPrice {
+		t.Errorf("Expected GPU price to be %f, got %f", newPrice, price.Price)
 	}
 }
 
@@ -408,23 +241,12 @@ func testSetNodePricePerLocalDiskGiBHour(t *testing.T, ctx context.Context, pm *
 		t.Fatalf("Failed to get node pricing: %v", err)
 	}
 
-	prices, err := np.Prices.GetPricesInCurrency(pm.GetCurrency())
-	if err != nil {
-		t.Fatalf("Failed to get prices: %v", err)
-	}
-
-	found := false
-	for _, price := range prices {
-		if price.Unit == unit.StorageGiBHour {
-			found = true
-			if price.Price != newPrice {
-				t.Errorf("Expected local disk price to be %f, got %f", newPrice, price.Price)
-			}
-		}
+	price, ok := np.Prices[pricing.ResourceStorage]
+	if !ok {
+		t.Fatal("Expected to find local disk pricing")
 	}
-
-	if !found {
-		t.Error("Expected to find local disk pricing")
+	if price.Price != newPrice {
+		t.Errorf("Expected local disk price to be %f, got %f", newPrice, price.Price)
 	}
 }
 
@@ -438,28 +260,17 @@ func testSetVolumePricePerStorageGiBHour(t *testing.T, ctx context.Context, pm *
 	}
 
 	// Verify the price was set
-	vp, err := pm.getVolumePricing(ctx)
+	vp, err := pm.getPersistentVolumePricing(ctx)
 	if err != nil {
 		t.Fatalf("Failed to get volume pricing: %v", err)
 	}
 
-	prices, err := vp.Prices.GetPricesInCurrency(pm.GetCurrency())
-	if err != nil {
-		t.Fatalf("Failed to get prices: %v", err)
+	price, ok := vp.Prices[pricing.ResourceStorage]
+	if !ok {
+		t.Fatal("Expected to find volume storage pricing")
 	}
-
-	found := false
-	for _, price := range prices {
-		if price.Unit == unit.StorageGiBHour {
-			found = true
-			if price.Price != newPrice {
-				t.Errorf("Expected volume storage price to be %f, got %f", newPrice, price.Price)
-			}
-		}
-	}
-
-	if !found {
-		t.Error("Expected to find volume storage pricing")
+	if price.Price != newPrice {
+		t.Errorf("Expected volume storage price to be %f, got %f", newPrice, price.Price)
 	}
 }
 
@@ -511,7 +322,7 @@ func testNewNodePricingReader(t *testing.T, ctx context.Context, pm *PricingModu
 // testNewVolumePricingReader tests the NewVolumePricingReader function
 func testNewVolumePricingReader(t *testing.T, ctx context.Context, pm *PricingModule) {
 	// Test that NewVolumePricingReader always produces a reader
-	rdr, err := pm.NewVolumePricingReader(ctx)
+	rdr, err := pm.NewPersistentVolumePricingReader(ctx)
 	if err != nil {
 		t.Fatalf("Failed to create volume pricing reader: %v", err)
 	}
@@ -521,7 +332,7 @@ func testNewVolumePricingReader(t *testing.T, ctx context.Context, pm *PricingMo
 	}
 
 	// Test that the reader produces precisely one *VolumePricing struct
-	dst := make([]*pricing.VolumePricing, 10) // Buffer larger than expected
+	dst := make([]*pricing.PersistentVolumePricing, 10) // Buffer larger than expected
 	count := 0
 
 	for {

+ 23 - 0
modules/pricing/basic/store.go

@@ -0,0 +1,23 @@
+package basic
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/opencost/opencost/core/pkg/env"
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/storage"
+)
+
+const basicPricingFilePath = "basic-pricing.json"
+
+func DefaultBasicPricingStore(ctx context.Context) (pricing.PricingStore, error) {
+	storage := storage.NewFileStorage(env.GetConfigPath())
+
+	store, err := pricing.NewStoragePricingStore(ctx, storage, basicPricingFilePath)
+	if err != nil {
+		return nil, fmt.Errorf("creating store at %q: %w", env.GetPathFromConfig(basicPricingFilePath), err)
+	}
+
+	return store, nil
+}

+ 25 - 29
modules/pricing/public/aws/awspricingsource.go

@@ -7,6 +7,7 @@ import (
 	"time"
 
 	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/shared"
 	"github.com/opencost/opencost/core/pkg/pricing"
 	"github.com/opencost/opencost/core/pkg/unit"
 )
@@ -28,8 +29,8 @@ func (p *AWSPricingSource) GetPricing() (*pricing.PricingSet, error) {
 	start := time.Now()
 
 	ps := &pricing.PricingSet{
-		Nodes:   []*pricing.NodePricing{},
-		Volumes: []*pricing.VolumePricing{},
+		NodePricing:             []*pricing.NodePricing{},
+		PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
 	}
 	skuToNodeKey := make(map[string]nodeKey)
 	skuToVolumeKey := make(map[string]volumeKey)
@@ -106,7 +107,7 @@ func (p *AWSPricingSource) GetPricing() (*pricing.PricingSet, error) {
 		termCount++
 		if termCount%logInterval == 0 {
 			log.Infof("PricingSource (AWS): processed %d terms, %d node pricing, %d volume pricing so far...",
-				termCount, len(ps.Nodes), len(ps.Volumes))
+				termCount, len(ps.NodePricing), len(ps.PersistentVolumePricing))
 		}
 
 		// Check if this SKU is for a node or volume we're tracking
@@ -140,34 +141,32 @@ func (p *AWSPricingSource) GetPricing() (*pricing.PricingSet, error) {
 			return
 		}
 
+		// TODO: handle currency?
 		// Parse the currency from config, default to USD if invalid
-		currency, err := unit.ParseCurrency(p.config.CurrencyCode)
-		if err != nil {
-			log.Warnf("invalid currency code '%s', defaulting to USD: %s", p.config.CurrencyCode, err.Error())
-			currency = unit.USD
-		}
+		// currency, err := unit.ParseCurrency(p.config.CurrencyCode)
+		// if err != nil {
+		// 	log.Warnf("invalid currency code '%s', defaulting to USD: %s", p.config.CurrencyCode, err.Error())
+		// 	currency = unit.USD
+		// }
 
 		// Handle node pricing
 		if isNode {
-			priceObj := pricing.Price{
-				Currency: currency,
-				Unit:     unit.Hour,
-				Price:    price,
-			}
-
 			nodePricing := &pricing.NodePricing{
 				Properties: pricing.NodePricingProperties{
-					Provider:     pricing.AWSProvider,
+					Provider:     shared.ProviderAWS,
 					Region:       nk.Region,
 					InstanceType: nk.InstanceType,
 					Provisioning: pricing.ProvisioningOnDemand,
 				},
 				Prices: pricing.Prices{
-					currency: []pricing.Price{priceObj},
+					pricing.ResourceNode: pricing.Price{
+						Unit:  unit.Hour,
+						Price: price,
+					},
 				},
 			}
 
-			ps.Nodes = append(ps.Nodes, nodePricing)
+			ps.NodePricing = append(ps.NodePricing, nodePricing)
 		}
 
 		// Handle volume pricing
@@ -175,24 +174,21 @@ func (p *AWSPricingSource) GetPricing() (*pricing.PricingSet, error) {
 			// AWS volume pricing is per GB-month, convert to per GB-hour
 			hourlyPrice := price / 730.0
 
-			priceObj := pricing.Price{
-				Currency: currency,
-				Unit:     unit.Hour,
-				Price:    hourlyPrice,
-			}
-
-			volumePricing := &pricing.VolumePricing{
-				Properties: pricing.VolumePricingProperties{
-					Provider:   pricing.AWSProvider,
+			volumePricing := &pricing.PersistentVolumePricing{
+				Properties: pricing.PersistentVolumePricingProperties{
+					Provider:   shared.ProviderAWS,
 					Region:     vk.Region,
 					VolumeType: vk.VolumeType,
 				},
 				Prices: pricing.Prices{
-					currency: []pricing.Price{priceObj},
+					pricing.ResourceStorage: pricing.Price{
+						Unit:  unit.Hour,
+						Price: hourlyPrice,
+					},
 				},
 			}
 
-			ps.Volumes = append(ps.Volumes, volumePricing)
+			ps.PersistentVolumePricing = append(ps.PersistentVolumePricing, volumePricing)
 		}
 	}
 
@@ -202,7 +198,7 @@ func (p *AWSPricingSource) GetPricing() (*pricing.PricingSet, error) {
 	}
 
 	log.Infof("PricingSource (AWS): completed in %s — %d products, %d terms, %d node pricing, %d volume pricing",
-		time.Since(start).Round(time.Second), productCount, termCount, len(ps.Nodes), len(ps.Volumes))
+		time.Since(start).Round(time.Second), productCount, termCount, len(ps.NodePricing), len(ps.PersistentVolumePricing))
 
 	return ps, nil
 }

+ 29 - 33
modules/pricing/public/azure/azurepricingsource.go

@@ -43,8 +43,8 @@ func (a *AzurePricingSource) GetPricing() (*pricing.PricingSet, error) {
 	start := time.Now()
 
 	ps := &pricing.PricingSet{
-		Nodes:   []*pricing.NodePricing{},
-		Volumes: []*pricing.VolumePricing{},
+		NodePricing:             []*pricing.NodePricing{},
+		PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
 	}
 
 	// Fetch VM pricing
@@ -80,7 +80,7 @@ func (a *AzurePricingSource) GetPricing() (*pricing.PricingSet, error) {
 		log.Debugf("PricingSource (Azure): fetched VM page %d, next: %s", pageCount, url)
 	}
 
-	log.Infof("PricingSource (Azure): fetched %d VM pricing entries across %d pages", len(ps.Nodes), pageCount)
+	log.Infof("PricingSource (Azure): fetched %d VM pricing entries across %d pages", len(ps.NodePricing), pageCount)
 
 	// Fetch disk pricing
 	url = a.buildDiskURL()
@@ -119,7 +119,7 @@ func (a *AzurePricingSource) GetPricing() (*pricing.PricingSet, error) {
 	}
 
 	log.Infof("PricingSource (Azure): completed in %s — %d node pricing, %d volume pricing",
-		time.Since(start).Round(time.Second), len(ps.Nodes), len(ps.Volumes))
+		time.Since(start).Round(time.Second), len(ps.NodePricing), len(ps.PersistentVolumePricing))
 
 	return ps, nil
 }
@@ -156,34 +156,30 @@ func (a *AzurePricingSource) parseVMPage(body io.Reader, ps *pricing.PricingSet)
 			continue
 		}
 
+		// TODO: handle currency?
 		// Parse the currency from config, default to USD if invalid
-		currency, err := unit.ParseCurrency(a.config.CurrencyCode)
-		if err != nil {
-			log.Warnf("invalid currency code '%s', defaulting to USD: %s", a.config.CurrencyCode, err.Error())
-			currency = unit.USD
-		}
-
-		priceObj := pricing.Price{
-			Currency: currency,
-			Unit:     unit.Hour,
-			Price:    float64(item.RetailPrice),
-		}
+		// currency, err := unit.ParseCurrency(a.config.CurrencyCode)
+		// if err != nil {
+		// 	log.Warnf("invalid currency code '%s', defaulting to USD: %s", a.config.CurrencyCode, err.Error())
+		// 	currency = unit.USD
+		// }
 
 		nodePricing := &pricing.NodePricing{
 			Properties: pricing.NodePricingProperties{
-				Provider:     pricing.Provider(shared.ProviderAzure),
+				Provider:     shared.ProviderAzure,
 				Region:       item.ArmRegionName,
 				InstanceType: item.ArmSkuName,
 				Provisioning: pricing.ProvisioningOnDemand,
 			},
 			Prices: pricing.Prices{
-				currency: []pricing.Price{
-					priceObj,
+				pricing.ResourceNode: pricing.Price{
+					Unit:  unit.Hour,
+					Price: float64(item.RetailPrice),
 				},
 			},
 		}
 
-		ps.Nodes = append(ps.Nodes, nodePricing)
+		ps.NodePricing = append(ps.NodePricing, nodePricing)
 	}
 
 	return page.NextPageLink, nil
@@ -210,31 +206,31 @@ func (a *AzurePricingSource) parseDiskPage(body io.Reader, ps *pricing.PricingSe
 			continue
 		}
 
-		currency, err := unit.ParseCurrency(a.config.CurrencyCode)
-		if err != nil {
-			log.Warnf("invalid currency code '%s', defaulting to USD: %s", a.config.CurrencyCode, err.Error())
-			currency = unit.USD
-		}
+		// TODO: handle currency?
+		// currency, err := unit.ParseCurrency(a.config.CurrencyCode)
+		// if err != nil {
+		// 	log.Warnf("invalid currency code '%s', defaulting to USD: %s", a.config.CurrencyCode, err.Error())
+		// 	currency = unit.USD
+		// }
 
 		// Azure disk pricing is per GB-month, convert to per GB-hour
 		hourlyPrice := float64(item.RetailPrice) / 730.0
 
-		volumePricing := &pricing.VolumePricing{
-			Properties: pricing.VolumePricingProperties{
-				Provider:   pricing.AzureProvider,
+		volumePricing := &pricing.PersistentVolumePricing{
+			Properties: pricing.PersistentVolumePricingProperties{
+				Provider:   shared.ProviderAzure,
 				Region:     item.ArmRegionName,
 				VolumeType: volumeType,
 			},
 			Prices: pricing.Prices{
-				currency: []pricing.Price{{
-					Currency: currency,
-					Unit:     unit.Hour,
-					Price:    hourlyPrice,
-				}},
+				pricing.ResourceStorage: pricing.Price{
+					Unit:  unit.Hour,
+					Price: hourlyPrice,
+				},
 			},
 		}
 
-		ps.Volumes = append(ps.Volumes, volumePricing)
+		ps.PersistentVolumePricing = append(ps.PersistentVolumePricing, volumePricing)
 	}
 
 	return page.NextPageLink, nil

+ 7 - 7
modules/pricing/public/cmd/main.go

@@ -6,7 +6,7 @@ import (
 	"os"
 
 	"github.com/opencost/opencost/core/pkg/log"
-	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/model/shared"
 	"github.com/opencost/opencost/core/pkg/unit"
 	"github.com/opencost/opencost/modules/pricing/public"
 	"github.com/spf13/cobra"
@@ -44,16 +44,16 @@ func run(cmd *cobra.Command, args []string) error {
 		return fmt.Errorf("invalid currency '%s': %w", currency, err)
 	}
 
-	var prov pricing.Provider
+	var prov shared.Provider
 	switch provider {
 	case "all":
-		prov = pricing.AllProvider
+		prov = shared.ProviderEmpty
 	case "aws":
-		prov = pricing.AWSProvider
+		prov = shared.ProviderAWS
 	case "azure":
-		prov = pricing.AzureProvider
+		prov = shared.ProviderAzure
 	case "gcp":
-		prov = pricing.GCPProvider
+		prov = shared.ProviderGCP
 	default:
 		return fmt.Errorf("unsupported provider: %s", provider)
 	}
@@ -70,7 +70,7 @@ func run(cmd *cobra.Command, args []string) error {
 	}
 
 	log.Infof("Generated %d node pricing entries and %d volume pricing entries",
-		len(pricingSet.Nodes), len(pricingSet.Volumes))
+		len(pricingSet.NodePricing), len(pricingSet.PersistentVolumePricing))
 
 	// Set default output path if not specified
 	if output == "" {

+ 23 - 22
modules/pricing/public/generator.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 
 	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/shared"
 	"github.com/opencost/opencost/core/pkg/pricing"
 	"github.com/opencost/opencost/core/pkg/unit"
 	"github.com/opencost/opencost/modules/pricing/public/aws"
@@ -26,7 +27,7 @@ func GenerateAWSPricing(currency unit.Currency) (*pricing.PricingSet, error) {
 	// Sort to ensure deterministic output for checksums
 	pricingSet.Sort()
 
-	log.Infof("Generated %d AWS node pricing entries", len(pricingSet.Nodes))
+	log.Infof("Generated %d AWS node pricing entries", len(pricingSet.NodePricing))
 	return pricingSet, nil
 }
 
@@ -46,7 +47,7 @@ func GenerateAzurePricing(currency unit.Currency) (*pricing.PricingSet, error) {
 	// Sort to ensure deterministic output for checksums
 	pricingSet.Sort()
 
-	log.Infof("Generated %d Azure node pricing entries", len(pricingSet.Nodes))
+	log.Infof("Generated %d Azure node pricing entries", len(pricingSet.NodePricing))
 	return pricingSet, nil
 }
 
@@ -54,53 +55,53 @@ func GenerateAzurePricing(currency unit.Currency) (*pricing.PricingSet, error) {
 // and combines them into a single PricingSet
 func GenerateAllProvidersPricing(currency unit.Currency) (*pricing.PricingSet, error) {
 	log.Infof("Generating pricing for all providers in currency: %s", currency)
-	
+
 	// Create a combined pricing set
 	combinedSet := &pricing.PricingSet{
-		Nodes:   []*pricing.NodePricing{},
-		Volumes: []*pricing.VolumePricing{},
+		NodePricing:             []*pricing.NodePricing{},
+		PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
 	}
-	
+
 	// Fetch AWS pricing
 	awsSet, err := GenerateAWSPricing(currency)
 	if err != nil {
 		log.Warnf("Failed to get AWS pricing: %v", err)
 	} else {
-		combinedSet.Nodes = append(combinedSet.Nodes, awsSet.Nodes...)
-		combinedSet.Volumes = append(combinedSet.Volumes, awsSet.Volumes...)
-		log.Infof("Added %d AWS node pricing entries", len(awsSet.Nodes))
+		combinedSet.NodePricing = append(combinedSet.NodePricing, awsSet.NodePricing...)
+		combinedSet.PersistentVolumePricing = append(combinedSet.PersistentVolumePricing, awsSet.PersistentVolumePricing...)
+		log.Infof("Added %d AWS node pricing entries", len(awsSet.NodePricing))
 	}
-	
+
 	// Fetch Azure pricing
 	azureSet, err := GenerateAzurePricing(currency)
 	if err != nil {
 		log.Warnf("Failed to get Azure pricing: %v", err)
 	} else {
-		combinedSet.Nodes = append(combinedSet.Nodes, azureSet.Nodes...)
-		combinedSet.Volumes = append(combinedSet.Volumes, azureSet.Volumes...)
-		log.Infof("Added %d Azure node pricing entries", len(azureSet.Nodes))
+		combinedSet.NodePricing = append(combinedSet.NodePricing, azureSet.NodePricing...)
+		combinedSet.PersistentVolumePricing = append(combinedSet.PersistentVolumePricing, azureSet.PersistentVolumePricing...)
+		log.Infof("Added %d Azure node pricing entries", len(azureSet.NodePricing))
 	}
-	
+
 	// Sort the combined set to ensure deterministic output
 	combinedSet.Sort()
-	
+
 	log.Infof("Generated combined pricing set with %d total node entries and %d volume entries",
-		len(combinedSet.Nodes), len(combinedSet.Volumes))
-	
+		len(combinedSet.NodePricing), len(combinedSet.PersistentVolumePricing))
+
 	return combinedSet, nil
 }
 
 // GeneratePricingForProvider fetches pricing data for a specific provider
 // in the specified currency
-func GeneratePricingForProvider(provider pricing.Provider, currency unit.Currency) (*pricing.PricingSet, error) {
+func GeneratePricingForProvider(provider shared.Provider, currency unit.Currency) (*pricing.PricingSet, error) {
 	switch provider {
-	case pricing.AllProvider:
+	case shared.ProviderEmpty:
 		return GenerateAllProvidersPricing(currency)
-	case pricing.AWSProvider:
+	case shared.ProviderAWS:
 		return GenerateAWSPricing(currency)
-	case pricing.AzureProvider:
+	case shared.ProviderAzure:
 		return GenerateAzurePricing(currency)
-	case pricing.GCPProvider:
+	case shared.ProviderGCP:
 		return nil, fmt.Errorf("not implemented")
 		// return GenerateGCPPricing(currency)
 	default:

+ 75 - 63
modules/pricing/public/go.mod

@@ -10,110 +10,122 @@ require (
 )
 
 require (
-	cel.dev/expr v0.25.1 // indirect
+	cel.dev/expr v0.25.2 // indirect
 	cloud.google.com/go v0.123.0 // indirect
-	cloud.google.com/go/auth v0.18.2 // indirect
+	cloud.google.com/go/auth v0.20.0 // indirect
 	cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
 	cloud.google.com/go/compute/metadata v0.9.0 // indirect
-	cloud.google.com/go/iam v1.5.3 // indirect
-	cloud.google.com/go/monitoring v1.24.3 // indirect
-	cloud.google.com/go/storage v1.60.0 // indirect
-	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
-	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
-	github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
-	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 // indirect
-	github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
-	github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
-	github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
-	github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
-	github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
-	github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect
-	github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
-	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
-	github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
-	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
-	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
-	github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
-	github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
-	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
-	github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
-	github.com/aws/smithy-go v1.25.1 // indirect
+	cloud.google.com/go/iam v1.11.0 // indirect
+	cloud.google.com/go/monitoring v1.29.0 // indirect
+	cloud.google.com/go/storage v1.62.3 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0 // indirect
+	github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect
+	github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
+	github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
+	github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
+	github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect
+	github.com/aws/aws-sdk-go-v2/config v1.32.25 // indirect
+	github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
+	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
+	github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
+	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
+	github.com/aws/smithy-go v1.27.2 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
 	github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
-	github.com/felixge/httpsnoop v1.0.4 // indirect
-	github.com/fsnotify/fsnotify v1.9.0 // indirect
-	github.com/go-ini/ini v1.67.0 // indirect
+	github.com/felixge/httpsnoop v1.1.0 // indirect
+	github.com/fsnotify/fsnotify v1.10.1 // indirect
+	github.com/fxamacker/cbor/v2 v2.9.2 // indirect
 	github.com/go-jose/go-jose/v4 v4.1.4 // indirect
 	github.com/go-logr/logr v1.4.3 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
-	github.com/goccy/go-json v0.10.5 // indirect
+	github.com/goccy/go-json v0.10.6 // indirect
 	github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
 	github.com/google/s2a-go v0.1.9 // indirect
 	github.com/google/uuid v1.6.0 // indirect
-	github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
-	github.com/googleapis/gax-go/v2 v2.17.0 // indirect
+	github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect
+	github.com/googleapis/gax-go/v2 v2.22.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
-	github.com/klauspost/compress v1.18.4 // indirect
+	github.com/klauspost/compress v1.18.6 // indirect
 	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
 	github.com/klauspost/crc32 v1.3.0 // indirect
 	github.com/kylelemons/godebug v1.1.0 // indirect
-	github.com/mattn/go-colorable v0.1.14 // indirect
-	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/mattn/go-colorable v0.1.15 // indirect
+	github.com/mattn/go-isatty v0.0.22 // indirect
 	github.com/minio/crc64nvme v1.1.1 // indirect
 	github.com/minio/md5-simd v1.1.2 // indirect
-	github.com/minio/minio-go/v7 v7.0.98 // indirect
+	github.com/minio/minio-go/v7 v7.2.0 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
-	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+	github.com/pelletier/go-toml/v2 v2.4.2 // indirect
 	github.com/philhofer/fwd v1.2.0 // indirect
 	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
 	github.com/prometheus/client_model v0.6.2 // indirect
-	github.com/prometheus/common v0.67.5 // indirect
+	github.com/prometheus/common v0.69.0 // indirect
 	github.com/rs/xid v1.6.0 // indirect
-	github.com/rs/zerolog v1.34.0 // indirect
+	github.com/rs/zerolog v1.35.1 // indirect
 	github.com/sagikazarmark/locafero v0.12.0 // indirect
 	github.com/spf13/afero v1.15.0 // indirect
 	github.com/spf13/cast v1.10.0 // indirect
 	github.com/spf13/pflag v1.0.10 // indirect
 	github.com/spf13/viper v1.21.0 // indirect
-	github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
+	github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
 	github.com/subosito/gotenv v1.6.0 // indirect
-	github.com/tinylib/msgp v1.6.3 // indirect
+	github.com/tinylib/msgp v1.6.4 // indirect
+	github.com/x448/float16 v0.8.4 // indirect
+	github.com/zeebo/xxh3 v1.1.0 // indirect
 	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
-	go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect
-	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect
-	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
-	go.opentelemetry.io/otel v1.41.0 // indirect
-	go.opentelemetry.io/otel/metric v1.41.0 // indirect
-	go.opentelemetry.io/otel/sdk v1.41.0 // indirect
-	go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
-	go.opentelemetry.io/otel/trace v1.41.0 // indirect
-	go.yaml.in/yaml/v2 v2.4.3 // indirect
+	go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
+	go.opentelemetry.io/otel v1.44.0 // indirect
+	go.opentelemetry.io/otel/metric v1.44.0 // indirect
+	go.opentelemetry.io/otel/sdk v1.44.0 // indirect
+	go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
+	go.opentelemetry.io/otel/trace v1.44.0 // indirect
+	go.yaml.in/yaml/v2 v2.4.4 // indirect
 	go.yaml.in/yaml/v3 v3.0.4 // indirect
-	golang.org/x/crypto v0.49.0 // indirect
-	golang.org/x/net v0.52.0 // indirect
-	golang.org/x/oauth2 v0.35.0 // indirect
-	golang.org/x/sync v0.20.0 // indirect
-	golang.org/x/sys v0.42.0 // indirect
-	golang.org/x/text v0.35.0 // indirect
-	golang.org/x/time v0.14.0 // indirect
-	google.golang.org/api v0.269.0 // indirect
-	google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect
-	google.golang.org/grpc v1.79.3 // indirect
+	golang.org/x/crypto v0.53.0 // indirect
+	golang.org/x/net v0.56.0 // indirect
+	golang.org/x/oauth2 v0.36.0 // indirect
+	golang.org/x/sync v0.21.0 // indirect
+	golang.org/x/sys v0.46.0 // indirect
+	golang.org/x/text v0.38.0 // indirect
+	golang.org/x/time v0.15.0 // indirect
+	google.golang.org/api v0.286.0 // indirect
+	google.golang.org/genproto v0.0.0-20260622175928-b703f567277d // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect
+	google.golang.org/grpc v1.81.1 // indirect
 	google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
+	gopkg.in/inf.v0 v0.9.1 // indirect
+	gopkg.in/ini.v1 v1.67.3 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
+	k8s.io/api v0.36.2 // indirect
+	k8s.io/apimachinery v0.36.2 // indirect
+	k8s.io/klog/v2 v2.140.0 // indirect
+	k8s.io/kube-openapi v0.0.0-20260624041617-8f3fa4921821 // indirect
+	k8s.io/utils v0.0.0-20260617174310-a95e086a2553 // indirect
+	sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
+	sigs.k8s.io/randfill v1.0.0 // indirect
+	sigs.k8s.io/structured-merge-diff/v6 v6.4.0 // indirect
 )
 
 go 1.26.3

+ 177 - 148
modules/pricing/public/go.sum

@@ -1,83 +1,82 @@
-cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
-cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
+cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
+cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
 cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
 cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
-cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
-cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
+cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
+cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
 cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
 cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
 cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
 cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
-cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
-cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
-cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA=
-cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak=
-cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
-cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
-cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
-cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
-cloud.google.com/go/storage v1.60.0 h1:oBfZrSOCimggVNz9Y/bXY35uUcts7OViubeddTTVzQ8=
-cloud.google.com/go/storage v1.60.0/go.mod h1:q+5196hXfejkctrnx+VYU8RKQr/L3c0cBIlrjmiAKE0=
-cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
-cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
+cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
+cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
+cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k=
+cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
+cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY=
+cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
+cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
+cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
+cloud.google.com/go/storage v1.62.3 h1:SZq1t23NCI+e96dH77Dg3PEfsNNEjqO8zE5AnD8gVD0=
+cloud.google.com/go/storage v1.62.3/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
+cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
+cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
 github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
-github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
-github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
-github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
-github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
-github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM=
-github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0 h1:irsmOWwkp0KCTTNS5e2hdFeIvSQClQo2No3IaNmL3Vw=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0/go.mod h1:GWcBkQj3MqN7ozHKLaCCAuNLiXoIGv2RtanfAwSjY/Y=
 github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
 github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
-github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
-github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
-github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
-github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
-github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
-github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
-github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
-github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
-github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
-github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
-github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
-github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
-github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
-github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
-github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
-github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
+github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
+github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
+github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
+github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
+github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
+github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
+github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
+github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
+github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
+github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
+github.com/aws/smithy-go v1.27.2 h1:y9NPmSE6am6LjEFPfqHqG/jJk7AauQvhCJONKh7kpzk=
+github.com/aws/smithy-go v1.27.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
 github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
-github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -93,14 +92,14 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an
 github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
 github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
 github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
-github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
-github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
+github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
-github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
-github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
-github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
+github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
+github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
+github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
+github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
 github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
 github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -110,9 +109,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
 github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
-github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
-github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
-github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
+github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
 github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -127,18 +125,18 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
 github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
-github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
-github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
-github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
+github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg=
+github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
+github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
+github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
 github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
-github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
-github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
+github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
 github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
 github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@@ -150,19 +148,16 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
-github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
+github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
+github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
 github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
 github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
 github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
 github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
-github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
-github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
+github.com/minio/minio-go/v7 v7.2.0 h1:RCJM0R1XOsRs+A3x3UCaf3ZYbByDaLjFeAi+YCQEPhs=
+github.com/minio/minio-go/v7 v7.2.0/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0=
 github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -171,8 +166,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd
 github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
 github.com/opencost/opencost v1.120.3 h1:1pm6dMfcETyCuorV0p5EazlIUwegCWBVj/R/H/LJSI8=
 github.com/opencost/opencost v1.120.3/go.mod h1:KcZZ7KVW7JPQxZgUk6pJaF0/p1dMUrVdB+rtO2fNRfQ=
-github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
-github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
+github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
 github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
 github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -186,14 +181,14 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
 github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
-github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
-github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
+github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
+github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
 github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
 github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
-github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
-github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
+github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
+github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
 github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
@@ -208,76 +203,110 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
 github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
 github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
-github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
-github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
+github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E=
+github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
-github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
-github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
+github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
+github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
+github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
+github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
-go.opentelemetry.io/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA=
-go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM=
-go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
-go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
-go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
-go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8=
-go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI=
-go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
-go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
-go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
-go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
-go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
-go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
-go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
-go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
-go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
-go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
+go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw=
+go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
+go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
 go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
-golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
-golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
-golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
-golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
-golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
-golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
-golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
-golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
-golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
-gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
-gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
-google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
-google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
-google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 h1:RxhCsti413yL0IjU9dVvuTbCISo8gs3RW1jPMStck+4=
-google.golang.org/genproto v0.0.0-20260226221140-a57be14db171/go.mod h1:uhvzakVEqAuXU3TC2JCsxIRe5f77l+JySE3EqPoMyqM=
-google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
-google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
-google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
-google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/api v0.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI=
+google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ=
+google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY=
+google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA=
+google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs=
+google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d/go.mod h1:O0ZOWSrfWfJ+Z5HbwZ+wNtHsg/vk1k2C/w67eww8PfQ=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
+google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
+gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
+gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
+gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY=
+k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg=
+k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ=
+k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4=
+k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
+k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
+k8s.io/kube-openapi v0.0.0-20260624041617-8f3fa4921821 h1:m2wZhD5+vJZyCVkTvUHIfaiXc/mdt3Pxyx3vUnGsKzU=
+k8s.io/kube-openapi v0.0.0-20260624041617-8f3fa4921821/go.mod h1:V/QaCUYDa+0QpcHhVVc5l99Uz56wEMEXBSj9oCDkNDY=
+k8s.io/utils v0.0.0-20260617174310-a95e086a2553 h1:hmGqDecjc8d7HVzWzRFl0QD9bYuYKbBEG7t8xwnVxfI=
+k8s.io/utils v0.0.0-20260617174310-a95e086a2553/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
+sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
+sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
+sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
+sigs.k8s.io/structured-merge-diff/v6 v6.4.0 h1:qmp2e3ZfFi1/jJbDGpD4mt3wyp6PE1NfKHCYLqgNQJo=
+sigs.k8s.io/structured-merge-diff/v6 v6.4.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
+sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
+sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=

+ 157 - 36
modules/pricing/public/module.go

@@ -2,19 +2,22 @@ package public
 
 import (
 	"context"
-	"encoding/json"
 	"fmt"
 	"sync"
 	"time"
 
 	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/shared"
 	"github.com/opencost/opencost/core/pkg/pricing"
 	"github.com/opencost/opencost/core/pkg/reader"
 	"github.com/opencost/opencost/core/pkg/unit"
 )
 
+// PricingModule must satisfy the pricing.PricingModule interface
+var _ pricing.PricingModule = (*PricingModule)(nil)
+
 type PricingModuleConfig struct {
-	Provider        pricing.Provider
+	Provider        shared.Provider
 	Currency        unit.Currency
 	RefreshInterval time.Duration
 }
@@ -61,7 +64,7 @@ func NewPricingModule(config PricingModuleConfig) (*PricingModule, error) {
 	return pm, nil
 }
 
-type ProviderPricing map[pricing.Provider]*InstanceTypePricing
+type ProviderPricing map[shared.Provider]*InstanceTypePricing
 
 type InstanceTypePricing map[string]*RegionPricing
 
@@ -71,7 +74,7 @@ func (pm *PricingModule) indexPricingSet(_ context.Context, pricingSet *pricing.
 	providers := make(ProviderPricing)
 
 	// Index nodes
-	for _, node := range pricingSet.Nodes {
+	for _, node := range pricingSet.NodePricing {
 		provider := node.Properties.Provider
 		instanceType := node.Properties.InstanceType
 		region := node.Properties.Region
@@ -91,7 +94,7 @@ func (pm *PricingModule) indexPricingSet(_ context.Context, pricingSet *pricing.
 	}
 
 	// Index volumes
-	for _, volume := range pricingSet.Volumes {
+	for _, volume := range pricingSet.PersistentVolumePricing {
 		provider := volume.Properties.Provider
 		volumeType := string(volume.Properties.VolumeType)
 		region := volume.Properties.Region
@@ -112,13 +115,17 @@ func (pm *PricingModule) indexPricingSet(_ context.Context, pricingSet *pricing.
 
 	pm.Providers = &providers
 	log.Infof("Indexed %d node pricing records and %d volume pricing records for provider %s (%s)",
-		len(pricingSet.Nodes), len(pricingSet.Volumes), pm.config.Provider, pm.config.Currency)
+		len(pricingSet.NodePricing), len(pricingSet.PersistentVolumePricing), pm.config.Provider, pm.config.Currency)
 
 	return nil
 }
 
 // GetNodePricing provides fast lookup for node pricing by provider, instance type, and region
-func (pm *PricingModule) GetNodePricing(provider pricing.Provider, instanceType string, region string) (*pricing.NodePricing, error) {
+func (pm *PricingModule) GetNodePricing(ctx context.Context, props pricing.NodePricingProperties) (*pricing.NodePricing, error) {
+	if err := ctx.Err(); err != nil {
+		return nil, err
+	}
+
 	pm.mu.RLock()
 	defer pm.mu.RUnlock()
 
@@ -126,6 +133,10 @@ func (pm *PricingModule) GetNodePricing(provider pricing.Provider, instanceType
 		return nil, fmt.Errorf("pricing not loaded")
 	}
 
+	provider := props.Provider
+	instanceType := props.InstanceType
+	region := props.Region
+
 	providerPricing := (*pm.Providers)[provider]
 	if providerPricing == nil {
 		return nil, fmt.Errorf("provider %s not found", provider)
@@ -152,8 +163,12 @@ func (pm *PricingModule) GetNodePricing(provider pricing.Provider, instanceType
 	}, nil
 }
 
-// GetVolumePricing provides fast lookup for node pricing by provider, instance type, and region
-func (pm *PricingModule) GetVolumePricing(provider pricing.Provider, volumeType string, region string) (*pricing.VolumePricing, error) {
+// GetPersistentVolumePricing provides fast lookup for volume pricing by provider, volume type, and region
+func (pm *PricingModule) GetPersistentVolumePricing(ctx context.Context, props pricing.PersistentVolumePricingProperties) (*pricing.PersistentVolumePricing, error) {
+	if err := ctx.Err(); err != nil {
+		return nil, err
+	}
+
 	pm.mu.RLock()
 	defer pm.mu.RUnlock()
 
@@ -161,6 +176,10 @@ func (pm *PricingModule) GetVolumePricing(provider pricing.Provider, volumeType
 		return nil, fmt.Errorf("pricing not loaded")
 	}
 
+	provider := props.Provider
+	volumeType := string(props.VolumeType)
+	region := props.Region
+
 	providerPricing := (*pm.Providers)[provider]
 	if providerPricing == nil {
 		return nil, fmt.Errorf("provider %s not found", provider)
@@ -177,8 +196,8 @@ func (pm *PricingModule) GetVolumePricing(provider pricing.Provider, volumeType
 	}
 
 	// Reconstruct NodePricing from Prices
-	return &pricing.VolumePricing{
-		Properties: pricing.VolumePricingProperties{
+	return &pricing.PersistentVolumePricing{
+		Properties: pricing.PersistentVolumePricingProperties{
 			Provider:   provider,
 			VolumeType: pricing.VolumeType(volumeType),
 			Region:     region,
@@ -190,47 +209,154 @@ func (pm *PricingModule) GetVolumePricing(provider pricing.Provider, volumeType
 func (pm *PricingModule) NewNodePricingReader(ctx context.Context) (reader.Reader[*pricing.NodePricing], error) {
 	pm.mu.RLock()
 	defer pm.mu.RUnlock()
-	return reader.NewSliceReader(pm.pricingSet.Nodes), nil
+	return reader.NewSliceReader(pm.pricingSet.NodePricing), nil
 }
 
-func (pm *PricingModule) NewVolumePricingReader(ctx context.Context) (reader.Reader[*pricing.VolumePricing], error) {
+func (pm *PricingModule) NewPersistentVolumePricingReader(ctx context.Context) (reader.Reader[*pricing.PersistentVolumePricing], error) {
 	pm.mu.RLock()
 	defer pm.mu.RUnlock()
-	return reader.NewSliceReader(pm.pricingSet.Volumes), nil
+	return reader.NewSliceReader(pm.pricingSet.PersistentVolumePricing), nil
 }
 
-// GetPricingSet returns the current in-memory pricing set
-func (pm *PricingModule) GetPricingSet() *pricing.PricingSet {
+// GetClusterPricing returns cluster pricing matching the given provider.
+func (pm *PricingModule) GetClusterPricing(ctx context.Context, props pricing.ClusterPricingProperties) (*pricing.ClusterPricing, error) {
+	if err := ctx.Err(); err != nil {
+		return nil, err
+	}
+
 	pm.mu.RLock()
 	defer pm.mu.RUnlock()
-	return pm.pricingSet
+
+	if pm.pricingSet == nil {
+		return nil, fmt.Errorf("pricing not loaded")
+	}
+
+	for _, cp := range pm.pricingSet.ClusterPricing {
+		if cp.Properties.Provider == props.Provider {
+			return cp, nil
+		}
+	}
+
+	return nil, fmt.Errorf("cluster pricing not found for provider=%s", props.Provider)
 }
 
-// ComparePricingSet compares the current in-memory pricing set with a new one
-// Returns true if they are identical, false if different
-func (pm *PricingModule) ComparePricingSet(newPricingSet *pricing.PricingSet) (bool, error) {
+func (pm *PricingModule) NewClusterPricingReader(ctx context.Context) (reader.Reader[*pricing.ClusterPricing], error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+	return reader.NewSliceReader(pm.pricingSet.ClusterPricing), nil
+}
+
+// GetNetworkPricing returns network pricing matching the given provider, traffic
+// direction, traffic type, and NAT gateway flag.
+func (pm *PricingModule) GetNetworkPricing(ctx context.Context, props pricing.NetworkPricingProperties) (*pricing.NetworkPricing, error) {
+	if err := ctx.Err(); err != nil {
+		return nil, err
+	}
+
 	pm.mu.RLock()
 	defer pm.mu.RUnlock()
 
 	if pm.pricingSet == nil {
-		return false, fmt.Errorf("current pricing set is nil")
+		return nil, fmt.Errorf("pricing not loaded")
 	}
-	if newPricingSet == nil {
-		return false, fmt.Errorf("new pricing set is nil")
+
+	for _, np := range pm.pricingSet.NetworkPricing {
+		if np.Properties.Provider == props.Provider &&
+			np.Properties.TrafficDirection == props.TrafficDirection &&
+			np.Properties.TrafficType == props.TrafficType &&
+			np.Properties.IsNatGateway == props.IsNatGateway {
+			return np, nil
+		}
+	}
+
+	return nil, fmt.Errorf("network pricing not found for provider=%s, trafficDirection=%s, trafficType=%s, isNatGateway=%t",
+		props.Provider, props.TrafficDirection, props.TrafficType, props.IsNatGateway)
+}
+
+func (pm *PricingModule) NewNetworkPricingReader(ctx context.Context) (reader.Reader[*pricing.NetworkPricing], error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+	return reader.NewSliceReader(pm.pricingSet.NetworkPricing), nil
+}
+
+// GetServicePricing returns service pricing matching the given provider and region.
+func (pm *PricingModule) GetServicePricing(ctx context.Context, props pricing.ServicePricingProperties) (*pricing.ServicePricing, error) {
+	if err := ctx.Err(); err != nil {
+		return nil, err
+	}
+
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	if pm.pricingSet == nil {
+		return nil, fmt.Errorf("pricing not loaded")
+	}
+
+	for _, sp := range pm.pricingSet.ServicePricing {
+		if sp.Properties.Provider == props.Provider &&
+			sp.Properties.Region == props.Region {
+			return sp, nil
+		}
+	}
+
+	return nil, fmt.Errorf("service pricing not found for provider=%s, region=%s", props.Provider, props.Region)
+}
+
+func (pm *PricingModule) NewServicePricingReader(ctx context.Context) (reader.Reader[*pricing.ServicePricing], error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+	return reader.NewSliceReader(pm.pricingSet.ServicePricing), nil
+}
+
+// GetPricingSet returns the current in-memory pricing set
+func (pm *PricingModule) GetPricingSet(ctx context.Context) (*pricing.PricingSet, error) {
+	if err := ctx.Err(); err != nil {
+		return nil, err
+	}
+
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+	return pm.pricingSet, nil
+}
+
+// TODO: Make this a const? This string is correct, but is also defined in KCM.
+func (pm *PricingModule) SourceKind() string {
+	return "public"
+}
+
+// TODO: This seems like a reasonable choice for a source name... but let's think about it a bit more.
+func (pm *PricingModule) SourceName() string {
+	return string(pm.config.Provider)
+}
+
+func (pm *PricingModule) Checksum(ctx context.Context) (string, error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	if pm.pricingSet == nil {
+		return "", fmt.Errorf("pricing not loaded")
 	}
 
-	// Compare by serializing both to JSON and computing checksums
-	currentJSON, err := pm.serializePricingSet(pm.pricingSet)
+	return pm.pricingSet.Checksum()
+}
+
+// ComparePricingSet compares the current in-memory pricing set with a new one
+// Returns true if they are identical, false if different
+func (pm *PricingModule) ComparePricingSet(newPricingSet *pricing.PricingSet) (bool, error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	sum, err := pm.pricingSet.Checksum()
 	if err != nil {
-		return false, fmt.Errorf("failed to serialize current pricing set: %w", err)
+		return false, fmt.Errorf("failed to checksum current pricing set: %w", err)
 	}
 
-	newJSON, err := pm.serializePricingSet(newPricingSet)
+	newSum, err := newPricingSet.Checksum()
 	if err != nil {
 		return false, fmt.Errorf("failed to serialize new pricing set: %w", err)
 	}
 
-	return string(currentJSON) == string(newJSON), nil
+	return sum == newSum, nil
 }
 
 // UpdatePricingSet replaces the current pricing set with a new one and re-indexes it
@@ -252,20 +378,15 @@ func (pm *PricingModule) UpdatePricingSet(ctx context.Context, newPricingSet *pr
 	}
 
 	log.Infof("Updated pricing set: %d node pricing records and %d volume pricing records",
-		len(newPricingSet.Nodes), len(newPricingSet.Volumes))
+		len(newPricingSet.NodePricing), len(newPricingSet.PersistentVolumePricing))
 
 	return nil
 }
 
-// serializePricingSet converts a pricing set to JSON bytes for comparison
-func (pm *PricingModule) serializePricingSet(ps *pricing.PricingSet) ([]byte, error) {
-	return json.Marshal(ps)
-}
-
 // backgroundRefresh periodically fetches new pricing data and updates the module
 func (pm *PricingModule) backgroundRefresh() {
 	defer close(pm.doneCh)
-	
+
 	ticker := time.NewTicker(pm.config.RefreshInterval)
 	defer ticker.Stop()
 
@@ -273,7 +394,7 @@ func (pm *PricingModule) backgroundRefresh() {
 		select {
 		case <-ticker.C:
 			log.Infof("Starting scheduled pricing refresh for %s (%s)", pm.config.Provider, pm.config.Currency)
-			
+
 			// Fetch new pricing data
 			newPricingSet, err := GeneratePricingForProvider(pm.config.Provider, pm.config.Currency)
 			if err != nil {

+ 11 - 18
modules/pricing/public/module_test.go

@@ -3,6 +3,7 @@ package public
 import (
 	"testing"
 
+	"github.com/opencost/opencost/core/pkg/model/shared"
 	"github.com/opencost/opencost/core/pkg/pricing"
 	"github.com/opencost/opencost/core/pkg/unit"
 )
@@ -10,12 +11,12 @@ import (
 // TestPricingModuleConfig tests that the config struct is properly defined
 func TestPricingModuleConfig(t *testing.T) {
 	config := PricingModuleConfig{
-		Provider: pricing.AWSProvider,
+		Provider: shared.ProviderAWS,
 		Currency: unit.USD,
 	}
 
-	if config.Provider != pricing.AWSProvider {
-		t.Errorf("Provider = %v, want %v", config.Provider, pricing.AWSProvider)
+	if config.Provider != shared.ProviderAWS {
+		t.Errorf("Provider = %v, want %v", config.Provider, shared.ProviderAWS)
 	}
 	if config.Currency != unit.USD {
 		t.Errorf("Currency = %v, want %v", config.Currency, unit.USD)
@@ -31,36 +32,28 @@ func TestProviderPricingStructure(t *testing.T) {
 	regionMap := make(RegionPricing)
 
 	prices := &pricing.Prices{
-		unit.USD: []pricing.Price{
-			{Currency: unit.USD, Unit: unit.Hour, Price: 0.0416},
+		pricing.ResourceNode: pricing.Price{
+			Unit:  unit.Hour,
+			Price: 0.0416,
 		},
 	}
 
 	regionMap["us-east-1"] = prices
 	instanceMap["t3.medium"] = &regionMap
-	providers[pricing.AWSProvider] = &instanceMap
+	providers[shared.ProviderAWS] = &instanceMap
 
 	// Verify structure
-	if providers[pricing.AWSProvider] == nil {
+	if providers[shared.ProviderAWS] == nil {
 		t.Fatal("AWS provider not found")
 	}
 
-	if (*providers[pricing.AWSProvider])["t3.medium"] == nil {
+	if (*providers[shared.ProviderAWS])["t3.medium"] == nil {
 		t.Fatal("t3.medium instance type not found")
 	}
 
-	if (*(*providers[pricing.AWSProvider])["t3.medium"])["us-east-1"] == nil {
+	if (*(*providers[shared.ProviderAWS])["t3.medium"])["us-east-1"] == nil {
 		t.Fatal("us-east-1 region not found")
 	}
-
-	retrievedPrices := (*(*providers[pricing.AWSProvider])["t3.medium"])["us-east-1"]
-	if len((*retrievedPrices)[unit.USD]) == 0 {
-		t.Fatal("No USD prices found")
-	}
-
-	if (*retrievedPrices)[unit.USD][0].Price != 0.0416 {
-		t.Errorf("Price = %v, want %v", (*retrievedPrices)[unit.USD][0].Price, 0.0416)
-	}
 }
 
 // Made with Bob