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

Pricing changeset

Signed-off-by: Sean Holcomb <seanholcomb@gmail.com>
Sean Holcomb 1 неделя назад
Родитель
Сommit
e498d74a80
59 измененных файлов с 7576 добавлено и 240 удалено
  1. 4 0
      core/pkg/pipelines/name.go
  2. 8 0
      core/pkg/pricing/commitment.go
  3. 30 0
      core/pkg/pricing/memory.go
  4. 123 0
      core/pkg/pricing/mock.go
  5. 147 0
      core/pkg/pricing/mock_test.go
  6. 37 0
      core/pkg/pricing/node.go
  7. 52 0
      core/pkg/pricing/price.go
  8. 368 0
      core/pkg/pricing/price_test.go
  9. 12 0
      core/pkg/pricing/provider.go
  10. 8 0
      core/pkg/pricing/provisioning.go
  11. 22 0
      core/pkg/pricing/repository.go
  12. 110 0
      core/pkg/pricing/set.go
  13. 179 0
      core/pkg/pricing/set_test.go
  14. 76 0
      core/pkg/pricing/storage.go
  15. 10 0
      core/pkg/pricing/store.go
  16. 176 0
      core/pkg/pricing/test/aws.yaml
  17. 176 0
      core/pkg/pricing/test/azure.yaml
  18. 17 0
      core/pkg/pricing/test/default.yaml
  19. 240 0
      core/pkg/pricing/test/gcp.yaml
  20. 35 0
      core/pkg/pricing/volume.go
  21. 54 0
      core/pkg/pricing/volumetype.go
  22. 40 0
      core/pkg/reader/reader.go
  23. 60 0
      core/pkg/unit/currency.go
  24. 103 0
      core/pkg/unit/currency_test.go
  25. 81 0
      core/pkg/unit/unit.go
  26. 87 0
      core/pkg/unit/unit_test.go
  27. 65 0
      modules/pricing/basic/default.go
  28. 117 0
      modules/pricing/basic/go.mod
  29. 272 0
      modules/pricing/basic/go.sum
  30. 342 0
      modules/pricing/basic/module.go
  31. 564 0
      modules/pricing/basic/module_test.go
  32. 208 0
      modules/pricing/public/aws/awspricingsource.go
  33. 330 0
      modules/pricing/public/aws/awspricingsource_test.go
  34. 241 0
      modules/pricing/public/aws/pricelistapi.go
  35. 97 0
      modules/pricing/public/aws/pricelistapi_test.go
  36. 36 0
      modules/pricing/public/aws/types.go
  37. 309 0
      modules/pricing/public/azure/azurepricingsource.go
  38. 351 0
      modules/pricing/public/azure/azurepricingsource_test.go
  39. 30 0
      modules/pricing/public/azure/types.go
  40. 93 0
      modules/pricing/public/cmd/main.go
  41. 109 0
      modules/pricing/public/generator.go
  42. 119 0
      modules/pricing/public/go.mod
  43. 283 0
      modules/pricing/public/go.sum
  44. 319 0
      modules/pricing/public/module.go
  45. 66 0
      modules/pricing/public/module_test.go
  46. 7 0
      modules/pricing/public/source.go
  47. 3 3
      pkg/cloud/aws/fargate.go
  48. 3 3
      pkg/cloud/aws/fargate_test.go
  49. 227 0
      pkg/cloud/aws/pricelistapi.go
  50. 58 0
      pkg/cloud/aws/pricelistapi_test.go
  51. 149 0
      pkg/cloud/aws/pricinglistpricingsource.go
  52. 4 91
      pkg/cloud/aws/provider.go
  53. 26 94
      pkg/cloud/aws/provider_test.go
  54. 148 0
      pkg/cloud/azure/retailpricingsource.go
  55. 190 0
      pkg/cloud/azure/retailpricingsource_test.go
  56. 249 0
      pkg/cloud/gcp/billingpricingsource.go
  57. 297 0
      pkg/cloud/gcp/billingpricingsource_test.go
  58. 7 49
      pkg/cloud/gcp/provider_test.go
  59. 2 0
      pkg/env/cloudcost.go

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

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

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

@@ -0,0 +1,8 @@
+package pricing
+
+type CommitmentType string
+
+const (
+	CommitmentNone     CommitmentType = "none"
+	CommitmentReserved CommitmentType = "reserved"
+)

+ 30 - 0
core/pkg/pricing/memory.go

@@ -0,0 +1,30 @@
+package pricing
+
+import (
+	"context"
+	"errors"
+)
+
+type MemoryPricingStore struct {
+	pricing *PricingSet
+}
+
+func NewMemoryPricingStore() *MemoryPricingStore {
+	return &MemoryPricingStore{
+		pricing: &PricingSet{},
+	}
+}
+
+func (mps *MemoryPricingStore) GetPricingSet(ctx context.Context) (*PricingSet, error) {
+	return mps.pricing, nil
+}
+
+func (mps *MemoryPricingStore) SetPricingSet(ctx context.Context, pricing *PricingSet) error {
+	if pricing == nil {
+		return errors.New("nil pricing")
+	}
+
+	mps.pricing = pricing
+
+	return nil
+}

+ 123 - 0
core/pkg/pricing/mock.go

@@ -0,0 +1,123 @@
+package pricing
+
+import (
+	"context"
+	"embed"
+	"encoding/json"
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"github.com/opencost/opencost/core/pkg/reader"
+	"gopkg.in/yaml.v3"
+)
+
+type MockPricingRepository struct {
+	NodePricing   []*NodePricing
+	VolumePricing []*VolumePricing
+}
+
+func NewMockPricingRepository() (*MockPricingRepository, error) {
+	repo := &MockPricingRepository{
+		NodePricing:   []*NodePricing{},
+		VolumePricing: []*VolumePricing{},
+	}
+
+	// Default
+	defaultPricingSet, err := 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")
+	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")
+	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")
+	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
+}
+
+func (repo *MockPricingRepository) NewNodePricingReader(ctx context.Context) (reader.Reader[*NodePricing], error) {
+	return reader.NewSliceReader(repo.NodePricing), nil
+}
+
+func (repo *MockPricingRepository) GetNodePricing(provider Provider, instanceType string, region string) (*NodePricing, error) {
+	// 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 {
+			return np, nil
+		}
+	}
+	return nil, fmt.Errorf("node pricing not found for provider=%s, instanceType=%s, region=%s", provider, instanceType, region)
+}
+
+func (repo *MockPricingRepository) NewVolumePricingReader(ctx context.Context) (reader.Reader[*VolumePricing], error) {
+	return reader.NewSliceReader(repo.VolumePricing), nil
+}
+
+func (repo *MockPricingRepository) GetVolumePricing(props VolumePricingProperties) (*VolumePricing, error) {
+	// Search through the mock data for a matching volume pricing entry
+	for _, vp := range repo.VolumePricing {
+		if vp.Properties.Provider == props.Provider &&
+			vp.Properties.Region == props.Region &&
+			vp.Properties.VolumeType == props.VolumeType {
+			return vp, nil
+		}
+	}
+	return nil, fmt.Errorf("volume pricing not found for provider=%s, region=%s, volumeType=%s", props.Provider, props.Region, props.VolumeType)
+}
+
+//go:embed test/*
+var pricingTestFS embed.FS
+
+func loadTestFile(filename string) (*PricingSet, 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))
+	}
+
+	var set *PricingSet
+
+	// Detect file format based on extension
+	ext := strings.ToLower(filepath.Ext(filename))
+	switch ext {
+	case ".json":
+		err = json.Unmarshal(bs, &set)
+		if err != nil {
+			return nil, 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)
+		}
+	default:
+		return nil, fmt.Errorf("unsupported file format: %s (expected .json, .yaml, or .yml)", ext)
+	}
+
+	return set, nil
+}

+ 147 - 0
core/pkg/pricing/mock_test.go

@@ -0,0 +1,147 @@
+package pricing
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/reader"
+)
+
+func TestMockPricingRepository(t *testing.T) {
+	var repo PricingRepository
+
+	mockRepo, err := NewMockPricingRepository()
+	if err != nil {
+		t.Fatalf("unexpected error initializing mock repository: %s", err)
+	}
+
+	repo = mockRepo
+
+	// Simple example of a sink for pricing data (will be database tables in reality)
+	bufferSize := 10
+	ingestor := newMockIngestor(bufferSize)
+
+	// Test ingestion of mock node reader
+
+	nodePricingReader, err := repo.NewNodePricingReader(t.Context())
+	if err != nil {
+		t.Errorf("unexpected error initializing node reader: %s", err)
+	}
+
+	n, err := ingestor.IngestNodePricing(context.Background(), nodePricingReader)
+	if err != nil {
+		t.Errorf("unexpected error ingesting node pricing: %s", err)
+	}
+	if n != 39 {
+		t.Errorf("expected to ingest %d node pricing records; ingested %d", 39, n)
+	}
+
+	nodePricingCount := ingestor.CountNodePricing()
+	if nodePricingCount != 39 {
+		t.Errorf("expected %d node pricing records; received %d", 39, nodePricingCount)
+	}
+
+	// Test ingestion of mock volume reader
+
+	volumePricingReader, err := repo.NewVolumePricingReader(t.Context())
+	if err != nil {
+		t.Errorf("unexpected error initializing volume reader: %s", err)
+	}
+
+	n, err = ingestor.IngestVolumePricing(context.Background(), volumePricingReader)
+	if err != nil {
+		t.Errorf("unexpected error ingesting volume pricing: %s", err)
+	}
+	if n != 20 {
+		t.Errorf("expected to ingest %d volume pricing records; ingested %d", 20, n)
+	}
+
+	volumePricingCount := ingestor.CountVolumePricing()
+	if volumePricingCount != 20 {
+		t.Errorf("expected %d volume pricing records; received %d", 20, volumePricingCount)
+	}
+}
+
+type mockPricingIngestor struct {
+	bufferSize    int
+	nodePricing   []*NodePricing
+	volumePricing []*VolumePricing
+}
+
+func newMockIngestor(bufferSize int) *mockPricingIngestor {
+	if bufferSize == 0 {
+		bufferSize = 100
+	}
+
+	return &mockPricingIngestor{
+		bufferSize:    bufferSize,
+		nodePricing:   []*NodePricing{},
+		volumePricing: []*VolumePricing{},
+	}
+}
+
+func (ing *mockPricingIngestor) CountNodePricing() int {
+	return len(ing.nodePricing)
+}
+
+func (ing *mockPricingIngestor) IngestNodePricing(ctx context.Context, pricingReader reader.Reader[*NodePricing]) (int, error) {
+	defer pricingReader.Close()
+
+	nodeBuf := make([]*NodePricing, ing.bufferSize)
+
+	totalCount := 0
+
+	for {
+		n, err := pricingReader.Read(ctx, nodeBuf)
+
+		if n > 0 {
+			ing.nodePricing = append(ing.nodePricing, nodeBuf[:n]...)
+		}
+
+		if errors.Is(err, reader.Done) {
+			break
+		}
+
+		if err != nil {
+			return totalCount, fmt.Errorf("unexpected error reading node pricing: %s", err)
+		}
+
+		totalCount += n
+	}
+
+	return totalCount, nil
+}
+
+func (ing *mockPricingIngestor) CountVolumePricing() int {
+	return len(ing.volumePricing)
+}
+
+func (ing *mockPricingIngestor) IngestVolumePricing(ctx context.Context, pricingReader reader.Reader[*VolumePricing]) (int, error) {
+	defer pricingReader.Close()
+
+	volBuf := make([]*VolumePricing, ing.bufferSize)
+
+	totalCount := 0
+
+	for {
+		n, err := pricingReader.Read(ctx, volBuf)
+
+		if n > 0 {
+			ing.volumePricing = append(ing.volumePricing, volBuf[:n]...)
+		}
+
+		if errors.Is(err, reader.Done) {
+			break
+		}
+
+		if err != nil {
+			return totalCount, fmt.Errorf("unexpected error reading volume pricing: %s", err)
+		}
+
+		totalCount += n
+	}
+
+	return totalCount, nil
+}

+ 37 - 0
core/pkg/pricing/node.go

@@ -0,0 +1,37 @@
+package pricing
+
+import (
+	"maps"
+	"slices"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+type NodePricingProperties struct {
+	Provider     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"`
+	Commitment   CommitmentType    `json:"commitment,omitempty" yaml:"commitment,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 NodePricing struct {
+	Properties NodePricingProperties `json:"properties" yaml:"properties"`
+	Prices     Prices                `json:"prices" yaml:"pricing"`
+}
+
+func (np *NodePricing) GetCurrencies() []unit.Currency {
+	currencies := map[unit.Currency]struct{}{}
+
+	for currency := range np.Prices {
+		currencies[currency] = struct{}{}
+	}
+
+	return slices.Collect(maps.Keys(currencies))
+}

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

@@ -0,0 +1,52 @@
+package pricing
+
+import (
+	"errors"
+
+	"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"`
+}
+
+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...)
+	}
+
+	return prices
+}
+
+func (p Prices) GetPricesInCurrency(currency unit.Currency) ([]Price, error) {
+	result := []Price{}
+
+	for curr, prices := range p {
+		if curr == currency {
+			result = append(result, prices...)
+		}
+	}
+
+	if len(result) == 0 {
+		return nil, NotFound
+	}
+
+	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
+	}
+
+	return p.GetPricesInCurrency(defaultCurrency)
+}

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

@@ -0,0 +1,368 @@
+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)
+					}
+				}
+			}
+		})
+	}
+}

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

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

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

@@ -0,0 +1,8 @@
+package pricing
+
+type ProvisioningType string
+
+const (
+	ProvisioningOnDemand ProvisioningType = "on-demand"
+	ProvisioningSpot     ProvisioningType = "spot"
+)

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

@@ -0,0 +1,22 @@
+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)
+}

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

@@ -0,0 +1,110 @@
+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
+	})
+}

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

@@ -0,0 +1,179 @@
+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

+ 76 - 0
core/pkg/pricing/storage.go

@@ -0,0 +1,76 @@
+package pricing
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+
+	"github.com/opencost/opencost/core/pkg/storage"
+)
+
+type StoragePricingStore struct {
+	store storage.Storage
+	path  string
+}
+
+func NewStoragePricingStore(ctx context.Context, store storage.Storage, path string) (*StoragePricingStore, error) {
+	if store == nil {
+		return nil, errors.New("nil storage")
+	}
+
+	if path == "" {
+		return nil, errors.New("empty path")
+	}
+
+	sps := &StoragePricingStore{
+		store: store,
+		path:  path,
+	}
+
+	exists, err := store.Exists(path)
+	if err != nil {
+		return nil, fmt.Errorf("checking pricing path %q: %w", path, err)
+	}
+
+	if !exists {
+		if err := sps.SetPricingSet(ctx, &PricingSet{}); err != nil {
+			return nil, fmt.Errorf("initializing empty pricing set at %q: %w", path, err)
+		}
+	}
+
+	return sps, nil
+}
+
+func (sps *StoragePricingStore) GetPricingSet(ctx context.Context) (*PricingSet, error) {
+	data, err := sps.store.Read(sps.path)
+	if err != nil {
+		return nil, fmt.Errorf("reading path '%s': %w", sps.path, err)
+	}
+
+	var pricing PricingSet
+	err = json.Unmarshal(data, &pricing)
+	if err != nil {
+		return nil, fmt.Errorf("decoding pricing: %w", err)
+	}
+
+	return &pricing, nil
+}
+
+func (sps *StoragePricingStore) SetPricingSet(ctx context.Context, pricing *PricingSet) error {
+	if pricing == nil {
+		return errors.New("nil pricing")
+	}
+
+	data, err := json.Marshal(pricing)
+	if err != nil {
+		return fmt.Errorf("encoding pricing: %w", err)
+	}
+
+	err = sps.store.Write(sps.path, data)
+	if err != nil {
+		return fmt.Errorf("writing pricing: %w", err)
+	}
+
+	return nil
+}

+ 10 - 0
core/pkg/pricing/store.go

@@ -0,0 +1,10 @@
+package pricing
+
+import (
+	"context"
+)
+
+type PricingStore interface {
+	GetPricingSet(ctx context.Context) (*PricingSet, error)
+	SetPricingSet(ctx context.Context, pricing *PricingSet) error
+}

+ 176 - 0
core/pkg/pricing/test/aws.yaml

@@ -0,0 +1,176 @@
+nodes:
+  - properties:
+      provider: AWS
+      region: us-east-1
+      instanceType: m5.large
+      provisioning: on-demand
+    prices:
+      USD:
+        - price: 0.096
+          currency: USD
+          unit: hr
+  - properties:
+      provider: AWS
+      region: us-east-1
+      instanceType: m5.large
+      provisioning: spot
+    prices:
+      USD:
+        - price: 0.043
+          currency: USD
+          unit: hr
+  - properties:
+      provider: AWS
+      region: us-east-1
+      instanceType: m5.xlarge
+      provisioning: on-demand
+    prices:
+      USD:
+        - price: 0.192
+          currency: USD
+          unit: hr
+  - properties:
+      provider: AWS
+      region: us-east-1
+      instanceType: m5.xlarge
+      provisioning: spot
+    prices:
+      USD:
+        - price: 0.192
+          currency: USD
+          unit: hr
+  - properties:
+      provider: AWS
+      region: us-east-1
+      instanceType: m5.2xlarge
+      provisioning: on-demand
+    prices:
+      USD:
+        - price: 0.384
+          currency: USD
+          unit: hr
+  - properties:
+      provider: AWS
+      region: us-east-1
+      instanceType: m5.2xlarge
+      provisioning: spot
+    prices:
+      USD:
+        - price: 0.189
+          currency: USD
+          unit: hr
+  - properties:
+      provider: AWS
+      region: us-west-1
+      instanceType: m5.large
+      provisioning: on-demand
+    prices:
+      USD:
+        - price: 0.112
+          currency: USD
+          unit: hr
+  - properties:
+      provider: AWS
+      region: us-west-1
+      instanceType: m5.large
+      provisioning: spot
+    prices:
+      USD:
+        - price: 0.037
+          currency: USD
+          unit: hr
+  - properties:
+      provider: AWS
+      region: us-west-1
+      instanceType: m5.xlarge
+      provisioning: on-demand
+    prices:
+      USD:
+        - price: 0.224
+          currency: USD
+          unit: hr
+  - properties:
+      provider: AWS
+      region: us-west-1
+      instanceType: m5.xlarge
+      provisioning: spot
+    prices:
+      USD:
+        - price: 0.069
+          currency: USD
+          unit: hr
+  - properties:
+      provider: AWS
+      region: us-west-1
+      instanceType: m5.2xlarge
+      provisioning: on-demand
+    prices:
+      USD:
+        - price: 0.448
+          currency: USD
+          unit: hr
+  - properties:
+      provider: AWS
+      region: us-west-1
+      instanceType: m5.2xlarge
+      provisioning: spot
+    prices:
+      USD:
+        - price: 0.152
+          currency: USD
+          unit: hr
+volumes:
+  - properties:
+      provider: AWS
+      region: us-east-1
+      volumeType: gp3
+    prices:
+      USD:
+        - price: 0.0001096
+          currency: USD
+          unit: storage-GiB-hr
+  - properties:
+      provider: AWS
+      region: us-east-1
+      volumeType: gp2
+    prices:
+      USD:
+        - price: 0.000137
+          currency: USD
+          unit: storage-GiB-hr
+  - properties:
+      provider: AWS
+      region: us-east-1
+      volumeType: standard
+    prices:
+      USD:
+        - price: 0.0000205
+          currency: USD
+          unit: storage-GiB-hr
+  - properties:
+      provider: AWS
+      region: us-west-1
+      volumeType: gp3
+    prices:
+      USD:
+        - price: 0.0001315
+          currency: USD
+          unit: storage-GiB-hr
+  - properties:
+      provider: AWS
+      region: us-west-1
+      volumeType: gp2
+    prices:
+      USD:
+        - price: 0.0001644
+          currency: USD
+          unit: storage-GiB-hr
+  - properties:
+      provider: AWS
+      region: us-west-1
+      volumeType: standard
+    prices:
+      USD:
+        - price: 0.0000247
+          currency: USD
+          unit: storage-GiB-hr

+ 176 - 0
core/pkg/pricing/test/azure.yaml

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

+ 17 - 0
core/pkg/pricing/test/default.yaml

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

+ 240 - 0
core/pkg/pricing/test/gcp.yaml

@@ -0,0 +1,240 @@
+nodes:
+  - 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
+  - properties:
+      provider: GCP
+      provisioning: spot
+    prices:
+      USD:
+        - price: 0.006655
+          currency: USD
+          unit: vCPU-hr
+        - price: 0.000892
+          currency: USD
+          unit: RAM-GiB-hr
+  - 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
+  - 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
+  - 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
+  - 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
+  - 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
+  - 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
+  - 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
+  - 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
+  - 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
+  - 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
+  - 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
+  - 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:
+  - properties: {}
+    prices:
+      USD:
+        - price: 0.0000581
+          currency: USD
+          unit: storage-GiB-hr
+  - properties:
+      provider: GCP
+      region: us-central1
+      volumeType: pd-ssd
+    prices:
+      USD:
+        - price: 0.0002329
+          currency: USD
+          unit: storage-GiB-hr
+  - properties:
+      provider: GCP
+      region: us-central1
+      volumeType: pd-balanced
+    prices:
+      USD:
+        - price: 0.000137
+          currency: USD
+          unit: storage-GiB-hr
+  - properties:
+      provider: GCP
+      region: us-central1
+      volumeType: pd-standard
+    prices:
+      USD:
+        - price: 0.0000548
+          currency: USD
+          unit: storage-GiB-hr
+  - properties:
+      provider: GCP
+      region: us-west1
+      volumeType: pd-ssd
+    prices:
+      USD:
+        - price: 0.0002466
+          currency: USD
+          unit: storage-GiB-hr
+  - properties:
+      provider: GCP
+      region: us-west1
+      volumeType: pd-balanced
+    prices:
+      USD:
+        - price: 0.0001452
+          currency: USD
+          unit: storage-GiB-hr
+  - properties:
+      provider: GCP
+      region: us-west1
+      volumeType: pd-standard
+    prices:
+      USD:
+        - price: 0.0000581
+          currency: USD
+          unit: storage-GiB-hr

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

@@ -0,0 +1,35 @@
+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))
+}

+ 54 - 0
core/pkg/pricing/volumetype.go

@@ -0,0 +1,54 @@
+package pricing
+
+type VolumeType string
+
+const (
+	VolumeTypeNil VolumeType = ""
+
+	// AWS
+
+	// General purpose SSD
+	VolumeTypeGP2 VolumeType = "gp2"
+	VolumeTypeGP3 VolumeType = "gp3"
+
+	// Provisioned IOPS SSD
+	VolumeTypeIO1 VolumeType = "io1"
+	VolumeTypeIO2 VolumeType = "io2"
+
+	// Throughput optimized HDD
+	VolumeTypeST1 VolumeType = "st1"
+
+	// Cold HDD
+	VolumeTypeSC1 VolumeType = "sc1"
+
+	// Magnetic (previous-generation / legacy)
+	VolumeTypeStandard VolumeType = "standard"
+
+	// Azure
+
+	// HDD
+	VolumeTypeStandardHDDLRS VolumeType = "Standard_LRS"
+
+	// Standard SSD
+	VolumeTypeStandardSSDLRS VolumeType = "StandardSSD_LRS"
+
+	// Premium SSD
+	VolumeTypePremiumLRS   VolumeType = "Premium_LRS"
+	VolumeTypePremiumV2LRS VolumeType = "PremiumV2_LRS"
+
+	// Ultra
+	VolumeTypeUltraSSDLRS VolumeType = "UltraSSD_LRS"
+
+	// GCP
+
+	// Persistent Disk
+	VolumeTypePDStandard VolumeType = "pd-standard"
+	VolumeTypePDBalanced VolumeType = "pd-balanced"
+	VolumeTypePDSSD      VolumeType = "pd-ssd"
+	VolumeTypePDExtreme  VolumeType = "pd-extreme"
+
+	// Hyperdisk
+	VolumeTypeHyperdiskBalanced   VolumeType = "hyperdisk-balanced"
+	VolumeTypeHyperdiskExtreme    VolumeType = "hyperdisk-extreme"
+	VolumeTypeHyperdiskThroughput VolumeType = "hyperdisk-throughput"
+)

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

@@ -0,0 +1,40 @@
+package reader
+
+import (
+	"context"
+	"errors"
+)
+
+type Reader[T any] interface {
+	Read(ctx context.Context, dst []T) (int, error)
+	Close() error
+}
+
+var Done = errors.New("Done")
+
+type SliceReader[T any] struct {
+	items []T
+	pos   int
+}
+
+func NewSliceReader[T any](items []T) *SliceReader[T] {
+	return &SliceReader[T]{
+		items: items,
+		pos:   0,
+	}
+}
+
+func (r *SliceReader[T]) Read(ctx context.Context, dst []T) (int, error) {
+	if r.pos >= len(r.items) {
+		return 0, Done
+	}
+
+	n := copy(dst, r.items[r.pos:])
+	r.pos += n
+
+	return n, nil
+}
+
+func (r *SliceReader[T]) Close() error {
+	return nil
+}

+ 60 - 0
core/pkg/unit/currency.go

@@ -0,0 +1,60 @@
+package unit
+
+import (
+	"fmt"
+	"strings"
+)
+
+type Currency string
+
+// TODO which of these are supported in the pricing APIs??
+// TODO if the configured currency is NOT available, default to USD!
+
+const (
+	AUD Currency = "AUD"
+	BRL Currency = "BRL"
+	CAD Currency = "CAD"
+	CHF Currency = "CHF"
+	CNY Currency = "CNY"
+	DKK Currency = "DKK"
+	EUR Currency = "EUR"
+	GBP Currency = "GBP"
+	IDR Currency = "IDR"
+	INR Currency = "INR"
+	JPY Currency = "JPY"
+	NOK Currency = "NOK"
+	PLN Currency = "PLN"
+	SEK Currency = "SEK"
+	USD Currency = "USD"
+)
+
+// validCurrencies is a map of all valid currency codes for quick lookup
+var validCurrencies = map[string]Currency{
+	string(AUD): AUD,
+	string(BRL): BRL,
+	string(CAD): CAD,
+	string(CHF): CHF,
+	string(CNY): CNY,
+	string(DKK): DKK,
+	string(EUR): EUR,
+	string(GBP): GBP,
+	string(IDR): IDR,
+	string(INR): INR,
+	string(JPY): JPY,
+	string(NOK): NOK,
+	string(PLN): PLN,
+	string(SEK): SEK,
+	string(USD): USD,
+}
+
+// ParseCurrency parses a string into a Currency type.
+// It performs case-insensitive matching and returns an error if the string
+// does not match any valid currency code.
+func ParseCurrency(s string) (Currency, error) {
+	upper := strings.ToUpper(s)
+	if currency, ok := validCurrencies[upper]; ok {
+		return currency, nil
+	}
+
+	return "", fmt.Errorf("invalid currency: %q", s)
+}

+ 103 - 0
core/pkg/unit/currency_test.go

@@ -0,0 +1,103 @@
+package unit
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestParseCurrency(t *testing.T) {
+	tests := []struct {
+		name      string
+		input     string
+		expect    Currency
+		expectErr bool
+	}{
+		// Valid currencies - exact case
+		{name: "AUD", input: "AUD", expect: AUD, expectErr: false},
+		{name: "BRL", input: "BRL", expect: BRL, expectErr: false},
+		{name: "CAD", input: "CAD", expect: CAD, expectErr: false},
+		{name: "CHF", input: "CHF", expect: CHF, expectErr: false},
+		{name: "CNY", input: "CNY", expect: CNY, expectErr: false},
+		{name: "DKK", input: "DKK", expect: DKK, expectErr: false},
+		{name: "EUR", input: "EUR", expect: EUR, expectErr: false},
+		{name: "GBP", input: "GBP", expect: GBP, expectErr: false},
+		{name: "IDR", input: "IDR", expect: IDR, expectErr: false},
+		{name: "INR", input: "INR", expect: INR, expectErr: false},
+		{name: "JPY", input: "JPY", expect: JPY, expectErr: false},
+		{name: "NOK", input: "NOK", expect: NOK, expectErr: false},
+		{name: "PLN", input: "PLN", expect: PLN, expectErr: false},
+		{name: "SEK", input: "SEK", expect: SEK, expectErr: false},
+		{name: "USD", input: "USD", expect: USD, expectErr: false},
+
+		// Case insensitive tests
+		{name: "lowercase usd", input: "usd", expect: USD, expectErr: false},
+		{name: "lowercase eur", input: "eur", expect: EUR, expectErr: false},
+		{name: "lowercase gbp", input: "gbp", expect: GBP, expectErr: false},
+		{name: "mixed case Usd", input: "Usd", expect: USD, expectErr: false},
+		{name: "mixed case eUr", input: "eUr", expect: EUR, expectErr: false},
+
+		// Invalid currencies
+		{name: "invalid empty", input: "", expect: "", expectErr: true},
+		{name: "invalid unknown", input: "XYZ", expect: "", expectErr: true},
+		{name: "invalid number", input: "123", expect: "", expectErr: true},
+		{name: "invalid partial", input: "US", expect: "", expectErr: true},
+		{name: "invalid too long", input: "USDD", expect: "", expectErr: true},
+		{name: "invalid with space", input: "U SD", expect: "", expectErr: true},
+		{name: "invalid symbol", input: "$", expect: "", expectErr: true},
+		{name: "invalid symbol euro", input: "€", expect: "", expectErr: true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := ParseCurrency(tt.input)
+			if (err != nil) != tt.expectErr {
+				t.Errorf("ParseCurrency(%q) error = %v, expectErr %v", tt.input, err, tt.expectErr)
+				return
+			}
+			if got != tt.expect {
+				t.Errorf("ParseCurrency(%q) = %v, expect %v", tt.input, got, tt.expect)
+			}
+		})
+	}
+}
+
+func TestParseCurrency_AllConstants(t *testing.T) {
+	// Ensure all defined currency constants can be parsed
+	allCurrencies := []Currency{
+		AUD, BRL, CAD, CHF, CNY, DKK, EUR, GBP,
+		IDR, INR, JPY, NOK, PLN, SEK, USD,
+	}
+
+	for _, currency := range allCurrencies {
+		t.Run(string(currency), func(t *testing.T) {
+			parsed, err := ParseCurrency(string(currency))
+			if err != nil {
+				t.Errorf("ParseCurrency(%q) unexpected error: %v", currency, err)
+			}
+			if parsed != currency {
+				t.Errorf("ParseCurrency(%q) = %v, expected %v", currency, parsed, currency)
+			}
+		})
+	}
+}
+
+func TestParseCurrency_CaseInsensitiveAllConstants(t *testing.T) {
+	// Ensure all defined currency constants can be parsed in lowercase
+	allCurrencies := []Currency{
+		AUD, BRL, CAD, CHF, CNY, DKK, EUR, GBP,
+		IDR, INR, JPY, NOK, PLN, SEK, USD,
+	}
+
+	for _, currency := range allCurrencies {
+		lowercase := strings.ToLower(string(currency))
+		t.Run(lowercase, func(t *testing.T) {
+			parsed, err := ParseCurrency(lowercase)
+			if err != nil {
+				t.Errorf("ParseCurrency(%q) unexpected error: %v", lowercase, err)
+			}
+			if parsed != currency {
+				t.Errorf("ParseCurrency(%q) = %v, expect %v", lowercase, parsed, currency)
+			}
+		})
+	}
+}

+ 81 - 0
core/pkg/unit/unit.go

@@ -0,0 +1,81 @@
+package unit
+
+import (
+	"fmt"
+	"strings"
+)
+
+type Unit string
+
+const (
+	// Durations of time
+	Millisecond Unit = "ms"
+	Second      Unit = "s"
+	Minute      Unit = "min"
+	Hour        Unit = "hr"
+
+	// 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"
+	VCPU Unit = "vCPU"
+	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"
+)
+
+// 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,
+}
+
+// ParseUnit parses a string into a Unit type.
+// It performs case-insensitive matching and returns an error if the string
+// does not match any valid unit.
+func ParseUnit(s string) (Unit, error) {
+	for key, unit := range validUnits {
+		if strings.EqualFold(key, s) {
+			return unit, nil
+		}
+	}
+
+	return "", fmt.Errorf("invalid unit: %q", s)
+}

+ 87 - 0
core/pkg/unit/unit_test.go

@@ -0,0 +1,87 @@
+package unit
+
+import (
+	"testing"
+)
+
+func TestParseUnit_Strings(t *testing.T) {
+	tests := []struct {
+		name      string
+		input     string
+		expect    Unit
+		expectErr bool
+	}{
+		// Duration units
+		{name: "millisecond", input: "ms", expect: Millisecond, expectErr: false},
+		{name: "second", input: "s", expect: Second, expectErr: false},
+		{name: "minute", input: "min", expect: Minute, expectErr: false},
+		{name: "hour", input: "hr", expect: Hour, expectErr: false},
+
+		// Data storage units - decimal
+		{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},
+		{name: "vCPU", input: "vCPU", expect: VCPU, expectErr: false},
+		{name: "GPU", input: "GPU", expect: GPU, expectErr: false},
+
+		// Compute resources over time
+		{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},
+
+		// Invalid units
+		{name: "invalid empty", input: "", expect: "", expectErr: true},
+		{name: "invalid unknown", input: "xyz", expect: "", expectErr: true},
+		{name: "invalid number", input: "123", expect: "", expectErr: true},
+		{name: "invalid partial", input: "G", expect: "", expectErr: true},
+		{name: "invalid with space", input: "G B", expect: "", expectErr: true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := ParseUnit(tt.input)
+			if (err != nil) != tt.expectErr {
+				t.Errorf("ParseUnit(%q) error = %v, expectErr %v", tt.input, err, tt.expectErr)
+				return
+			}
+			if got != tt.expect {
+				t.Errorf("ParseUnit(%q) = %v, expected %v", tt.input, got, tt.expect)
+			}
+		})
+	}
+}
+
+func TestParseUnit_Constants(t *testing.T) {
+	for _, unit := range validUnits {
+		t.Run(string(unit), func(t *testing.T) {
+			parsed, err := ParseUnit(string(unit))
+			if err != nil {
+				t.Errorf("ParseUnit(%q) unexpected error: %v", unit, err)
+			}
+			if parsed != unit {
+				t.Errorf("ParseUnit(%q) = %v, expected %v", unit, parsed, unit)
+			}
+		})
+	}
+}

+ 65 - 0
modules/pricing/basic/default.go

@@ -0,0 +1,65 @@
+package basic
+
+import (
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+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
+
+func GetDefaultPricingSet() *pricing.PricingSet {
+	return &pricing.PricingSet{
+		Nodes:   []*pricing.NodePricing{GetDefaultNodePricing()},
+		Volumes: []*pricing.VolumePricing{GetDefaultVolumePricing()},
+	}
+}
+
+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,
+				},
+				{
+					Currency: unit.USD,
+					Unit:     unit.RAMGiBHour,
+					Price:    DefaultNodePricePerRAMGiBHour,
+				},
+				{
+					Currency: unit.USD,
+					Unit:     unit.GPUHour,
+					Price:    DefaultNodePricePerGPUHour,
+				},
+				{
+					Currency: unit.USD,
+					Unit:     unit.StorageGiBHour,
+					Price:    DefaultNodePricePerLocalDiskGiBHour,
+				},
+			},
+		},
+	}
+}
+
+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,
+				},
+			},
+		},
+	}
+}

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

@@ -0,0 +1,117 @@
+module github.com/opencost/modules/pricing/basic
+
+replace github.com/opencost/opencost/core => ../../../core
+
+require github.com/opencost/opencost/core v0.0.0 // return to v1.120.2-0.20260514205745-aa41c03dc67a
+
+require github.com/stretchr/testify v1.11.1
+
+require (
+	cel.dev/expr v0.25.1 // indirect
+	cloud.google.com/go v0.123.0 // indirect
+	cloud.google.com/go/auth v0.18.2 // 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
+	github.com/cespare/xxhash/v2 v2.3.0 // indirect
+	github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
+	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 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/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/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/json-iterator/go v1.1.12 // indirect
+	github.com/klauspost/compress v1.18.4 // 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/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/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/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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+	github.com/prometheus/client_model v0.6.2 // indirect
+	github.com/prometheus/common v0.67.5 // indirect
+	github.com/rs/xid v1.6.0 // indirect
+	github.com/rs/zerolog v1.34.0 // 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/subosito/gotenv v1.6.0 // indirect
+	github.com/tinylib/msgp v1.6.3 // 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.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
+	google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
+	gopkg.in/yaml.v2 v2.4.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)
+
+go 1.26.3

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

@@ -0,0 +1,272 @@
+cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
+cel.dev/expr v0.25.1/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/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=
+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/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/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/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/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=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
+github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
+github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
+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/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/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=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+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/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=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
+github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
+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/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/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=
+github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
+github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+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/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/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=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+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/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=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+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/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/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
+github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+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=
+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.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/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=
+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/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=

+ 342 - 0
modules/pricing/basic/module.go

@@ -0,0 +1,342 @@
+package basic
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"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"
+)
+
+type PricingModule struct {
+	currency unit.Currency
+	store    pricing.PricingStore
+}
+
+func NewBasicPricingModule(store pricing.PricingStore) (*PricingModule, error) {
+	pricingSet, err := store.GetPricingSet(context.Background())
+	if err != nil {
+		return nil, fmt.Errorf("checking pricing store: %w", err)
+	}
+
+	if pricingSet.IsEmpty() {
+		// Populate store with a default pricing set.
+		err := store.SetPricingSet(context.Background(), GetDefaultPricingSet())
+		if err != nil {
+			return nil, fmt.Errorf("setting default pricing: %w", err)
+		}
+
+		pricingSet, err = store.GetPricingSet(context.Background())
+		if err != nil {
+			return nil, fmt.Errorf("checking default pricing: %w", err)
+		}
+
+		if pricingSet.IsEmpty() {
+			return nil, errors.New("unable to initialize store")
+		}
+	}
+
+	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,
+	}
+
+	return pm, nil
+}
+
+func (pm *PricingModule) GetCurrency() unit.Currency {
+	return pm.currency
+}
+
+func (pm *PricingModule) SetCurrency(ctx context.Context, currency unit.Currency) error {
+	prevCurrency := pm.currency
+	if currency == prevCurrency {
+		return nil
+	}
+
+	// 1. Convert existing node pricing to new currency
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		return fmt.Errorf("getting node pricing: %w", err)
+	}
+
+	// Set up new Prices for the new currency
+	newPrices := []pricing.Price{}
+
+	// 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]
+	}
+
+	for _, price := range oldPrices {
+		newPrices = append(newPrices, pricing.Price{
+			Currency: currency,
+			Unit:     price.Unit,
+			Price:    price.Price,
+		})
+	}
+
+	// Set new prices under new currency
+	np.Prices = make(pricing.Prices, 1)
+	np.Prices[currency] = newPrices
+
+	// Set node pricing on the module
+	err = pm.setNodePricing(ctx, np)
+	if err != nil {
+		return fmt.Errorf("setting node pricing: %w", err)
+	}
+
+	// 2. Convert existing volume pricing to new currency
+	vp, err := pm.getVolumePricing(ctx)
+	if err != nil {
+		return fmt.Errorf("getting node pricing: %w", err)
+	}
+
+	// Set up new Prices for the new currency
+	newPrices = []pricing.Price{}
+
+	// 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]
+	}
+
+	for _, price := range oldPrices {
+		newPrices = append(newPrices, pricing.Price{
+			Currency: currency,
+			Unit:     price.Unit,
+			Price:    price.Price,
+		})
+	}
+
+	// Set new prices under new currency
+	vp.Prices = make(pricing.Prices, 1)
+	vp.Prices[currency] = newPrices
+
+	// Set node pricing on the module
+	err = pm.setVolumePricing(ctx, vp)
+	if err != nil {
+		return fmt.Errorf("setting node pricing: %w", err)
+	}
+
+	return nil
+}
+
+func (pm *PricingModule) SetNodePricePerCPUCoreHour(ctx context.Context, price float64) error {
+	return pm.setNodePrice(ctx, unit.VCPUHour, price)
+}
+
+func (pm *PricingModule) SetNodePricePerRAMGiBHour(ctx context.Context, price float64) error {
+	return pm.setNodePrice(ctx, unit.RAMGiBHour, price)
+}
+
+func (pm *PricingModule) SetNodePricePerGPUHour(ctx context.Context, price float64) error {
+	return pm.setNodePrice(ctx, unit.GPUHour, price)
+}
+
+func (pm *PricingModule) SetNodePricePerLocalDiskGiBHour(ctx context.Context, price float64) error {
+	return pm.setNodePrice(ctx, unit.StorageGiBHour, price)
+}
+
+func (pm *PricingModule) SetVolumePricePerStorageGiBHour(ctx context.Context, price float64) error {
+	return pm.setVolumePrice(ctx, unit.StorageGiBHour, price)
+}
+
+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
+}
+
+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
+}
+
+func (pm *PricingModule) setNodePrice(ctx context.Context, 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()
+	}
+
+	// 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)
+	}
+
+	return nil
+}
+
+func (pm *PricingModule) setVolumePrice(ctx context.Context, unit unit.Unit, price float64) error {
+	vp, err := pm.getVolumePricing(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()
+	}
+
+	// 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 volume pricing
+	err = pm.setVolumePricing(ctx, vp)
+	if err != nil {
+		return fmt.Errorf("setting node pricing: %w", err)
+	}
+
+	return nil
+}
+
+func (pm *PricingModule) getNodePricing(ctx context.Context) (*pricing.NodePricing, error) {
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting pricing: %w", err)
+	}
+
+	if len(ps.Nodes) == 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
+}
+
+func (pm *PricingModule) setNodePricing(ctx context.Context, np *pricing.NodePricing) error {
+	if np == nil {
+		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 {
+		return fmt.Errorf("getting pricing: %w", err)
+	}
+
+	// Only one default NodePricing is allowed in basic pricing.
+	ps.Nodes = []*pricing.NodePricing{np}
+
+	// Set the new pricing set
+	err = pm.store.SetPricingSet(ctx, ps)
+	if err != nil {
+		return fmt.Errorf("setting pricing: %w", err)
+	}
+
+	return nil
+}
+
+func (pm *PricingModule) getVolumePricing(ctx context.Context) (*pricing.VolumePricing, error) {
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting pricing: %w", err)
+	}
+
+	if len(ps.Volumes) == 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
+}
+
+func (pm *PricingModule) setVolumePricing(ctx context.Context, vp *pricing.VolumePricing) 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 {
+		return fmt.Errorf("getting pricing: %w", err)
+	}
+
+	// Only one default VolumePricing is allowed in basic pricing.
+	ps.Volumes = []*pricing.VolumePricing{vp}
+
+	// Set the new pricing set
+	err = pm.store.SetPricingSet(ctx, ps)
+	if err != nil {
+		return fmt.Errorf("setting pricing: %w", err)
+	}
+
+	return nil
+}

+ 564 - 0
modules/pricing/basic/module_test.go

@@ -0,0 +1,564 @@
+package basic
+
+import (
+	"context"
+	"os"
+	"testing"
+
+	"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"
+)
+
+func TestPricingModule(t *testing.T) {
+	memoryPricingStore := pricing.NewMemoryPricingStore()
+
+	filePricingStore, err := pricing.NewStoragePricingStore(t.Context(), newFileStorage(t), "pricing.json")
+	require.NoError(t, err)
+
+	stores := map[string]pricing.PricingStore{
+		"MemoryPricingStore":  memoryPricingStore,
+		"StoragePricingStore": filePricingStore,
+	}
+
+	for name, store := range stores {
+		t.Run(name, testPricingModuleWithStore(store))
+	}
+}
+
+func testPricingModuleWithStore(store pricing.PricingStore) func(t *testing.T) {
+	return func(t *testing.T) {
+		ctx := t.Context()
+
+		pm, err := NewBasicPricingModule(store)
+		require.NoError(t, err)
+
+		t.Run("DefaultPricing", 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)
+		})
+
+		t.Run("SetNodePricePerRAMGiBHour", func(t *testing.T) {
+			testSetNodePricePerRAMGiBHour(t, ctx, pm)
+		})
+
+		t.Run("SetNodePricePerGPUHour", func(t *testing.T) {
+			testSetNodePricePerGPUHour(t, ctx, pm)
+		})
+
+		t.Run("SetNodePricePerLocalDiskGiBHour", func(t *testing.T) {
+			testSetNodePricePerLocalDiskGiBHour(t, ctx, pm)
+		})
+
+		t.Run("SetVolumePricePerStorageGiBHour", func(t *testing.T) {
+			testSetVolumePricePerStorageGiBHour(t, ctx, pm)
+		})
+
+		t.Run("NewNodePricingReader", func(t *testing.T) {
+			testNewNodePricingReader(t, ctx, pm)
+		})
+
+		t.Run("NewVolumePricingReader", func(t *testing.T) {
+			testNewVolumePricingReader(t, ctx, pm)
+		})
+
+		t.Run("ModulePersistence", func(t *testing.T) {
+			// Create a new PricingModule with the same store
+			pm2, err := NewBasicPricingModule(store)
+			require.NoError(t, err)
+
+			// Verify that pricing persists
+			np, err := pm2.getNodePricing(ctx)
+			if err != nil {
+				t.Fatalf("Failed to get node pricing: %v", err)
+			}
+
+			if np == nil {
+				t.Fatal("Expected node pricing to be persisted")
+			}
+		})
+	}
+}
+
+// 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 {
+		t.Fatalf("Failed to get node pricing: %v", err)
+	}
+
+	if np == nil {
+		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)
+	}
+
+	// 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)
+			}
+		}
+	}
+
+	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)
+	if err != nil {
+		t.Fatalf("Failed to get volume pricing: %v", err)
+	}
+
+	if vp == nil {
+		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
+	}
+
+	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)
+	}
+}
+
+// testSetNodePricePerCPUCoreHour tests the SetNodePricePerCPUCoreHour function
+func testSetNodePricePerCPUCoreHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 0.075
+
+	err := pm.SetNodePricePerCPUCoreHour(ctx, newPrice)
+	if err != nil {
+		t.Fatalf("Failed to set CPU price: %v", err)
+	}
+
+	// Verify the price was set
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		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.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")
+	}
+}
+
+// testSetNodePricePerRAMGiBHour tests the SetNodePricePerRAMGiBHour function
+func testSetNodePricePerRAMGiBHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 0.008
+
+	err := pm.SetNodePricePerRAMGiBHour(ctx, newPrice)
+	if err != nil {
+		t.Fatalf("Failed to set RAM price: %v", err)
+	}
+
+	// Verify the price was set
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		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)
+			}
+		}
+	}
+
+	if !found {
+		t.Error("Expected to find RAM pricing")
+	}
+}
+
+// testSetNodePricePerGPUHour tests the SetNodePricePerGPUHour function
+func testSetNodePricePerGPUHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 2.0
+
+	err := pm.SetNodePricePerGPUHour(ctx, newPrice)
+	if err != nil {
+		t.Fatalf("Failed to set GPU price: %v", err)
+	}
+
+	// Verify the price was set
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		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)
+			}
+		}
+	}
+
+	if !found {
+		t.Error("Expected to find GPU pricing")
+	}
+}
+
+// testSetNodePricePerLocalDiskGiBHour tests the SetNodePricePerLocalDiskGiBHour function
+func testSetNodePricePerLocalDiskGiBHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 0.0007
+
+	err := pm.SetNodePricePerLocalDiskGiBHour(ctx, newPrice)
+	if err != nil {
+		t.Fatalf("Failed to set local disk price: %v", err)
+	}
+
+	// Verify the price was set
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		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)
+			}
+		}
+	}
+
+	if !found {
+		t.Error("Expected to find local disk pricing")
+	}
+}
+
+// testSetVolumePricePerStorageGiBHour tests the SetVolumePricePerStorageGiBHour function
+func testSetVolumePricePerStorageGiBHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 0.0003
+
+	err := pm.SetVolumePricePerStorageGiBHour(ctx, newPrice)
+	if err != nil {
+		t.Fatalf("Failed to set volume storage price: %v", err)
+	}
+
+	// Verify the price was set
+	vp, err := pm.getVolumePricing(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)
+	}
+
+	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")
+	}
+}
+
+// testNewNodePricingReader tests the NewNodePricingReader function
+func testNewNodePricingReader(t *testing.T, ctx context.Context, pm *PricingModule) {
+	// Test that NewNodePricingReader always produces a reader
+	rdr, err := pm.NewNodePricingReader(ctx)
+	if err != nil {
+		t.Fatalf("Failed to create node pricing reader: %v", err)
+	}
+
+	if rdr == nil {
+		t.Fatal("Expected reader to be non-nil")
+	}
+
+	// Test that the reader produces precisely one *NodePricing struct
+	dst := make([]*pricing.NodePricing, 10) // Buffer larger than expected
+	count := 0
+
+	for {
+		n, err := rdr.Read(ctx, dst)
+		count += n
+
+		// Verify all read items are non-nil
+		for i := 0; i < n; i++ {
+			if dst[i] == nil {
+				t.Error("Expected non-nil NodePricing")
+			}
+		}
+
+		if err == reader.Done {
+			break
+		}
+		if err != nil {
+			t.Fatalf("Reader error: %v", err)
+		}
+	}
+
+	if count != 1 {
+		t.Errorf("Expected reader to produce exactly 1 NodePricing, got %d", count)
+	}
+
+	// Clean up
+	if err := rdr.Close(); err != nil {
+		t.Errorf("Failed to close reader: %v", err)
+	}
+}
+
+// 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)
+	if err != nil {
+		t.Fatalf("Failed to create volume pricing reader: %v", err)
+	}
+
+	if rdr == nil {
+		t.Fatal("Expected reader to be non-nil")
+	}
+
+	// Test that the reader produces precisely one *VolumePricing struct
+	dst := make([]*pricing.VolumePricing, 10) // Buffer larger than expected
+	count := 0
+
+	for {
+		n, err := rdr.Read(ctx, dst)
+		count += n
+
+		// Verify all read items are non-nil
+		for i := 0; i < n; i++ {
+			if dst[i] == nil {
+				t.Error("Expected non-nil VolumePricing")
+			}
+		}
+
+		if err == reader.Done {
+			break
+		}
+		if err != nil {
+			t.Fatalf("Reader error: %v", err)
+		}
+	}
+
+	if count != 1 {
+		t.Errorf("Expected reader to produce exactly 1 VolumePricing, got %d", count)
+	}
+
+	// Clean up
+	if err := rdr.Close(); err != nil {
+		t.Errorf("Failed to close reader: %v", err)
+	}
+}
+
+func newFileStorage(t *testing.T) storage.Storage {
+	tempDir, err := os.MkdirTemp("", "pricing-test-*")
+	if err != nil {
+		t.Fatalf("Failed to create temp directory: %v", err)
+	}
+	defer os.RemoveAll(tempDir)
+
+	return storage.NewFileStorage(tempDir)
+}

+ 208 - 0
modules/pricing/public/aws/awspricingsource.go

@@ -0,0 +1,208 @@
+package aws
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+type AWSPricingSourceConfig struct {
+	CurrencyCode string
+}
+
+type AWSPricingSource struct {
+	config AWSPricingSourceConfig
+}
+
+func NewAWSPricingSource(cfg AWSPricingSourceConfig) *AWSPricingSource {
+	return &AWSPricingSource{config: cfg}
+}
+
+func (p *AWSPricingSource) GetPricing() (*pricing.PricingSet, error) {
+	log.Infof("PricingSource (AWS): starting EC2 pricing list download (large file, this may take a while)")
+	start := time.Now()
+
+	ps := &pricing.PricingSet{
+		Nodes:   []*pricing.NodePricing{},
+		Volumes: []*pricing.VolumePricing{},
+	}
+	skuToNodeKey := make(map[string]nodeKey)
+	skuToVolumeKey := make(map[string]volumeKey)
+
+	var productCount, termCount int
+	const logInterval = 50000
+
+	region := ""
+	if strings.ToUpper(p.config.CurrencyCode) == "CNY" {
+		region = "cn-north-1"
+		log.Infof("PricingSource (AWS): Using China pricing endpoint for CNY currency")
+	}
+
+	// When parsing product we create keys based off of product attributes and link those to a SKU.
+	handleProduct := func(product *PriceListEC2Product) {
+		productCount++
+		if productCount%logInterval == 0 {
+			log.Infof("PricingSource (AWS): processed %d products...", productCount)
+		}
+		attr := product.Attributes
+		if attr.LocationType != "AWS Region" {
+			return
+		}
+
+		// Handle EC2 instances
+		if (strings.HasPrefix(attr.UsageType, "BoxUsage") || strings.Contains(attr.UsageType, "-BoxUsage")) &&
+			(attr.CapacityStatus == "Used" || attr.CapacityStatus == "") &&
+			(attr.MarketOption == "OnDemand" || attr.MarketOption == "") {
+
+			if attr.OperatingSystem != "" && attr.OperatingSystem != "NA" && attr.OperatingSystem != "Linux" {
+				return
+			}
+
+			if attr.RegionCode == "" || attr.InstanceType == "" {
+				return
+			}
+
+			skuToNodeKey[product.Sku] = nodeKey{
+				Region:       attr.RegionCode,
+				InstanceType: attr.InstanceType,
+			}
+			return
+		}
+
+		// Handle EBS volumes
+		if strings.Contains(attr.UsageType, "EBS:Volume") {
+			// Extract the volume type from the usage type (e.g., "USE1-EBS:VolumeUsage.gp3" -> "EBS:VolumeUsage.gp3")
+			usageTypeMatch := usageTypeRegex.FindStringSubmatch(attr.UsageType)
+			if len(usageTypeMatch) == 0 {
+				return
+			}
+			usageTypeNoRegion := usageTypeMatch[len(usageTypeMatch)-1]
+
+			// Map to volume type
+			volumeType, ok := awsVolumeTypes[usageTypeNoRegion]
+			if !ok {
+				return
+			}
+
+			if attr.RegionCode == "" {
+				return
+			}
+
+			skuToVolumeKey[product.Sku] = volumeKey{
+				Region:     attr.RegionCode,
+				VolumeType: volumeType,
+				UsageType:  usageTypeNoRegion,
+			}
+		}
+	}
+
+	// Terms are used to define pricing and have the sku to look up the appropriate key.
+	handleTerm := func(term *PriceListEC2Term) {
+		termCount++
+		if termCount%logInterval == 0 {
+			log.Infof("PricingSource (AWS): processed %d terms, %d node pricing, %d volume pricing so far...",
+				termCount, len(ps.Nodes), len(ps.Volumes))
+		}
+
+		// Check if this SKU is for a node or volume we're tracking
+		nk, isNode := skuToNodeKey[term.Sku]
+		vk, isVolume := skuToVolumeKey[term.Sku]
+
+		if !isNode && !isVolume {
+			return
+		}
+
+		// Determine the hourly rate code based on the offer term
+		hourlyRateCode := HourlyRateCode
+		if _, ok := OnDemandRateCodes[term.OfferTermCode]; !ok {
+			if _, okCN := OnDemandRateCodesCn[term.OfferTermCode]; !okCN {
+				// Skip if term is not OnDemand
+				return
+			}
+			hourlyRateCode = HourlyRateCodeCn
+		}
+
+		priceDimensionKey := strings.Join([]string{term.Sku, term.OfferTermCode, hourlyRateCode}, ".")
+		pricingDimension, ok := term.PriceDimensions[priceDimensionKey]
+		if !ok {
+			return
+		}
+
+		priceStr := pricingDimension.PricePerUnit.ForCurrency(p.config.CurrencyCode)
+		price, err := strconv.ParseFloat(priceStr, 64)
+		if err != nil {
+			log.Errorf("failed to parse price '%s': %s", priceStr, err.Error())
+			return
+		}
+
+		// 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
+		}
+
+		// Handle node pricing
+		if isNode {
+			priceObj := pricing.Price{
+				Currency: currency,
+				Unit:     unit.Hour,
+				Price:    price,
+			}
+
+			nodePricing := &pricing.NodePricing{
+				Properties: pricing.NodePricingProperties{
+					Provider:     pricing.AWSProvider,
+					Region:       nk.Region,
+					InstanceType: nk.InstanceType,
+					Provisioning: pricing.ProvisioningOnDemand,
+				},
+				Prices: pricing.Prices{
+					currency: []pricing.Price{priceObj},
+				},
+			}
+
+			ps.Nodes = append(ps.Nodes, nodePricing)
+		}
+
+		// Handle volume pricing
+		if isVolume {
+			// 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,
+					Region:     vk.Region,
+					VolumeType: vk.VolumeType,
+				},
+				Prices: pricing.Prices{
+					currency: []pricing.Price{priceObj},
+				},
+			}
+
+			ps.Volumes = append(ps.Volumes, volumePricing)
+		}
+	}
+
+	err := QueryEC2PriceList(region, handleProduct, handleTerm)
+	if err != nil {
+		return nil, fmt.Errorf("failed to query list pricing data %w", err)
+	}
+
+	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))
+
+	return ps, nil
+}

+ 330 - 0
modules/pricing/public/aws/awspricingsource_test.go

@@ -0,0 +1,330 @@
+package aws
+
+import (
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/pricing"
+)
+
+func TestNewAWSPricingSource(t *testing.T) {
+	config := AWSPricingSourceConfig{
+		CurrencyCode: "USD",
+	}
+
+	source := NewAWSPricingSource(config)
+
+	if source == nil {
+		t.Fatal("NewAWSPricingSource() returned nil")
+	}
+
+	if source.config.CurrencyCode != "USD" {
+		t.Errorf("CurrencyCode = %v, want USD", source.config.CurrencyCode)
+	}
+}
+
+func TestUsageTypeRegex(t *testing.T) {
+	tests := []struct {
+		name      string
+		usageType string
+		wantMatch bool
+		wantGroup string
+	}{
+		{
+			name:      "Standard EBS usage",
+			usageType: "USE1-EBS:VolumeUsage.gp3",
+			wantMatch: true,
+			wantGroup: "EBS:VolumeUsage.gp3",
+		},
+		{
+			name:      "GP2 volume",
+			usageType: "USW2-EBS:VolumeUsage.gp2",
+			wantMatch: true,
+			wantGroup: "EBS:VolumeUsage.gp2",
+		},
+		{
+			name:      "Standard volume",
+			usageType: "USE1-EBS:VolumeUsage",
+			wantMatch: true,
+			wantGroup: "EBS:VolumeUsage",
+		},
+		{
+			name:      "IO1 volume",
+			usageType: "USE1-EBS:VolumeUsage.piops",
+			wantMatch: true,
+			wantGroup: "EBS:VolumeUsage.piops",
+		},
+		{
+			name:      "No region prefix",
+			usageType: "EBS:VolumeUsage.gp3",
+			wantMatch: true,
+			wantGroup: "EBS:VolumeUsage.gp3",
+		},
+		{
+			name:      "Non-EBS usage",
+			usageType: "BoxUsage:t3.medium",
+			wantMatch: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			matches := usageTypeRegex.FindStringSubmatch(tt.usageType)
+			if tt.wantMatch {
+				if len(matches) == 0 {
+					t.Errorf("usageTypeRegex did not match %q", tt.usageType)
+					return
+				}
+				// The last group should contain the EBS usage type
+				actualGroup := matches[len(matches)-1]
+				if actualGroup != tt.wantGroup {
+					t.Errorf("usageTypeRegex matched %q, want %q", actualGroup, tt.wantGroup)
+				}
+			} else {
+				if len(matches) > 0 {
+					t.Errorf("usageTypeRegex unexpectedly matched %q", tt.usageType)
+				}
+			}
+		})
+	}
+}
+
+func TestAWSVolumeTypes(t *testing.T) {
+	tests := []struct {
+		usageType    string
+		expectedType pricing.VolumeType
+		shouldExist  bool
+	}{
+		{
+			usageType:    "EBS:VolumeUsage.gp2",
+			expectedType: pricing.VolumeTypeGP2,
+			shouldExist:  true,
+		},
+		{
+			usageType:    "EBS:VolumeUsage.gp3",
+			expectedType: pricing.VolumeTypeGP3,
+			shouldExist:  true,
+		},
+		{
+			usageType:    "EBS:VolumeUsage",
+			expectedType: pricing.VolumeTypeStandard,
+			shouldExist:  true,
+		},
+		{
+			usageType:    "EBS:VolumeUsage.sc1",
+			expectedType: pricing.VolumeTypeSC1,
+			shouldExist:  true,
+		},
+		{
+			usageType:    "EBS:VolumeP-IOPS.piops",
+			expectedType: pricing.VolumeTypeIO1,
+			shouldExist:  true,
+		},
+		{
+			usageType:    "EBS:VolumeUsage.st1",
+			expectedType: pricing.VolumeTypeST1,
+			shouldExist:  true,
+		},
+		{
+			usageType:    "EBS:VolumeUsage.piops",
+			expectedType: pricing.VolumeTypeIO1,
+			shouldExist:  true,
+		},
+		{
+			usageType:    "EBS:VolumeUsage.io2",
+			expectedType: pricing.VolumeTypeIO2,
+			shouldExist:  true,
+		},
+		{
+			usageType:   "EBS:VolumeUsage.unknown",
+			shouldExist: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.usageType, func(t *testing.T) {
+			volumeType, exists := awsVolumeTypes[tt.usageType]
+			if exists != tt.shouldExist {
+				t.Errorf("awsVolumeTypes[%q] exists = %v, want %v", tt.usageType, exists, tt.shouldExist)
+				return
+			}
+			if tt.shouldExist && volumeType != tt.expectedType {
+				t.Errorf("awsVolumeTypes[%q] = %v, want %v", tt.usageType, volumeType, tt.expectedType)
+			}
+		})
+	}
+}
+
+func TestOnDemandRateCodes(t *testing.T) {
+	// Test that expected rate codes exist
+	expectedCodes := []string{"JRTCKXETXF"}
+	for _, code := range expectedCodes {
+		if _, exists := OnDemandRateCodes[code]; !exists {
+			t.Errorf("OnDemandRateCodes missing expected code: %s", code)
+		}
+	}
+
+	// Test that we have at least one code
+	if len(OnDemandRateCodes) == 0 {
+		t.Error("OnDemandRateCodes is empty")
+	}
+}
+
+func TestOnDemandRateCodesCn(t *testing.T) {
+	// Test that expected China rate codes exist
+	expectedCodes := []string{"99YE2YK9UR", "5Y9WH78GDR", "KW44MY7SZN"}
+	for _, code := range expectedCodes {
+		if _, exists := OnDemandRateCodesCn[code]; !exists {
+			t.Errorf("OnDemandRateCodesCn missing expected code: %s", code)
+		}
+	}
+
+	// Test that we have at least one code
+	if len(OnDemandRateCodesCn) == 0 {
+		t.Error("OnDemandRateCodesCn is empty")
+	}
+}
+
+func TestHourlyRateCodes(t *testing.T) {
+	if HourlyRateCode == "" {
+		t.Error("HourlyRateCode is empty")
+	}
+	if HourlyRateCodeCn == "" {
+		t.Error("HourlyRateCodeCn is empty")
+	}
+	if HourlyRateCode == HourlyRateCodeCn {
+		t.Error("HourlyRateCode and HourlyRateCodeCn should be different")
+	}
+}
+
+func TestPriceListEC2PricePerUnit_ForCurrency(t *testing.T) {
+	tests := []struct {
+		name     string
+		price    PriceListEC2PricePerUnit
+		currency string
+		expected string
+	}{
+		{
+			name: "USD currency",
+			price: PriceListEC2PricePerUnit{
+				USD: "0.0416",
+				CNY: "0.2800",
+			},
+			currency: "USD",
+			expected: "0.0416",
+		},
+		{
+			name: "CNY currency",
+			price: PriceListEC2PricePerUnit{
+				USD: "0.0416",
+				CNY: "0.2800",
+			},
+			currency: "CNY",
+			expected: "0.2800",
+		},
+		{
+			name: "CNY lowercase",
+			price: PriceListEC2PricePerUnit{
+				USD: "0.0416",
+				CNY: "0.2800",
+			},
+			currency: "cny",
+			expected: "0.2800",
+		},
+		{
+			name: "Unknown currency defaults to USD",
+			price: PriceListEC2PricePerUnit{
+				USD: "0.0416",
+				CNY: "0.2800",
+			},
+			currency: "EUR",
+			expected: "0.0416",
+		},
+		{
+			name: "CNY empty falls back to USD",
+			price: PriceListEC2PricePerUnit{
+				USD: "0.0416",
+				CNY: "",
+			},
+			currency: "CNY",
+			expected: "0.0416",
+		},
+		{
+			name: "Empty currency defaults to USD",
+			price: PriceListEC2PricePerUnit{
+				USD: "0.0416",
+				CNY: "0.2800",
+			},
+			currency: "",
+			expected: "0.0416",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := tt.price.ForCurrency(tt.currency)
+			if result != tt.expected {
+				t.Errorf("ForCurrency(%q) = %v, want %v", tt.currency, result, tt.expected)
+			}
+		})
+	}
+}
+
+func TestPriceListEC2Term_String(t *testing.T) {
+	term := &PriceListEC2Term{
+		Sku:           "TEST123",
+		OfferTermCode: "JRTCKXETXF",
+		PriceDimensions: map[string]*PriceListEC2PriceDimension{
+			"TEST123.JRTCKXETXF.6YS6EN2CT7": {
+				Unit: "Hrs",
+				PricePerUnit: PriceListEC2PricePerUnit{
+					USD: "0.0416",
+				},
+			},
+		},
+	}
+
+	result := term.String()
+	if result == "" {
+		t.Error("String() returned empty string")
+	}
+	// Should contain the SKU
+	if !containsSubstring(result, "TEST123") {
+		t.Errorf("String() = %v, should contain SKU 'TEST123'", result)
+	}
+}
+
+func TestPriceListEC2PriceDimension_String(t *testing.T) {
+	pd := &PriceListEC2PriceDimension{
+		Unit: "Hrs",
+		PricePerUnit: PriceListEC2PricePerUnit{
+			USD: "0.0416",
+		},
+	}
+
+	result := pd.String()
+	if result == "" {
+		t.Error("String() returned empty string")
+	}
+	// Should contain unit
+	if !containsSubstring(result, "Hrs") {
+		t.Errorf("String() = %v, should contain unit 'Hrs'", result)
+	}
+}
+
+// Helper function to check if string contains substring
+func containsSubstring(s, substr string) bool {
+	if len(substr) == 0 {
+		return true
+	}
+	if len(s) < len(substr) {
+		return false
+	}
+	for i := 0; i <= len(s)-len(substr); i++ {
+		if s[i:i+len(substr)] == substr {
+			return true
+		}
+	}
+	return false
+}
+
+// Made with Bob

+ 241 - 0
modules/pricing/public/aws/pricelistapi.go

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

+ 97 - 0
modules/pricing/public/aws/pricelistapi_test.go

@@ -0,0 +1,97 @@
+package aws
+
+import (
+	"testing"
+
+	"github.com/opencost/opencost/pkg/env"
+)
+
+func TestGetListPriceURL(t *testing.T) {
+	t.Run("uses override when configured", func(t *testing.T) {
+		t.Setenv(env.AWSPricingURL, "https://example.com/custom.json")
+
+		got := getListPriceURL("AmazonEC2", "us-east-1")
+
+		if got != "https://example.com/custom.json" {
+			t.Fatalf("expected override URL, got %q", got)
+		}
+	})
+
+	t.Run("builds standard regional URL", func(t *testing.T) {
+		t.Setenv(env.AWSPricingURL, "")
+
+		got := getListPriceURL("AmazonEC2", "us-west-2")
+		want := "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonEC2/current/us-west-2/index.json"
+
+		if got != want {
+			t.Fatalf("expected %q, got %q", want, got)
+		}
+	})
+
+	t.Run("builds standard global URL when region empty", func(t *testing.T) {
+		t.Setenv(env.AWSPricingURL, "")
+
+		got := getListPriceURL("AmazonEC2", "")
+		want := "https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonEC2/current/index.json"
+
+		if got != want {
+			t.Fatalf("expected %q, got %q", want, got)
+		}
+	})
+
+	t.Run("uses china endpoint for china regions", func(t *testing.T) {
+		t.Setenv(env.AWSPricingURL, "")
+
+		got := getListPriceURL("AmazonEC2", "cn-north-1")
+		want := "https://pricing.cn-north-1.amazonaws.com.cn/offers/v1.0/cn/AmazonEC2/current/cn-north-1/index.json"
+
+		if got != want {
+			t.Fatalf("expected %q, got %q", want, got)
+		}
+	})
+}
+
+func TestPriceListEC2PricePerUnitForCurrency(t *testing.T) {
+	tests := []struct {
+		name string
+		unit PriceListEC2PricePerUnit
+		code string
+		want string
+	}{
+		{
+			name: "returns CNY when requested and present",
+			unit: PriceListEC2PricePerUnit{USD: "1.23", CNY: "8.88"},
+			code: "CNY",
+			want: "8.88",
+		},
+		{
+			name: "falls back to USD when CNY missing",
+			unit: PriceListEC2PricePerUnit{USD: "1.23"},
+			code: "CNY",
+			want: "1.23",
+		},
+		{
+			name: "handles lowercase currency code",
+			unit: PriceListEC2PricePerUnit{USD: "1.23", CNY: "8.88"},
+			code: "cny",
+			want: "8.88",
+		},
+		{
+			name: "falls back to USD for unknown currency",
+			unit: PriceListEC2PricePerUnit{USD: "1.23", CNY: "8.88"},
+			code: "EUR",
+			want: "1.23",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := tt.unit.ForCurrency(tt.code)
+			if got != tt.want {
+				t.Fatalf("expected %q, got %q", tt.want, got)
+			}
+		})
+	}
+}
+
+// Made with Bob

+ 36 - 0
modules/pricing/public/aws/types.go

@@ -0,0 +1,36 @@
+package aws
+
+import (
+	"regexp"
+
+	"github.com/opencost/opencost/core/pkg/pricing"
+)
+
+// usageTypeRegex extracts the EBS volume type from AWS UsageType strings
+// Example: "USE1-EBS:VolumeUsage.gp3" -> "EBS:VolumeUsage.gp3"
+var usageTypeRegex = regexp.MustCompile(".*(-|^)(EBS.+)")
+
+// awsVolumeTypes maps AWS UsageType strings to VolumeType constants
+var awsVolumeTypes = map[string]pricing.VolumeType{
+	"EBS:VolumeUsage.gp2":    pricing.VolumeTypeGP2,
+	"EBS:VolumeUsage.gp3":    pricing.VolumeTypeGP3,
+	"EBS:VolumeUsage":        pricing.VolumeTypeStandard,
+	"EBS:VolumeUsage.sc1":    pricing.VolumeTypeSC1,
+	"EBS:VolumeP-IOPS.piops": pricing.VolumeTypeIO1,
+	"EBS:VolumeUsage.st1":    pricing.VolumeTypeST1,
+	"EBS:VolumeUsage.piops":  pricing.VolumeTypeIO1,
+	"EBS:VolumeUsage.io2":    pricing.VolumeTypeIO2,
+}
+
+// nodeKey is used internally to track node metadata during product parsing
+type nodeKey struct {
+	Region       string
+	InstanceType string
+}
+
+// volumeKey is used internally to track volume metadata during product parsing
+type volumeKey struct {
+	Region     string
+	VolumeType pricing.VolumeType
+	UsageType  string // Store original usage type for special handling (e.g., io1 per-IO costs)
+}

+ 309 - 0
modules/pricing/public/azure/azurepricingsource.go

@@ -0,0 +1,309 @@
+package azure
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/model/shared"
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+const (
+	azurePricingBaseURL = "https://prices.azure.com/api/retail/prices"
+	azureVMFilter       = "serviceName eq 'Virtual Machines' and priceType eq 'Consumption'"
+	azureDiskFilter     = "serviceName eq 'Storage' and priceType eq 'Consumption'"
+)
+
+// AzurePricingSourceConfig holds configuration for AzurePricingSource.
+type AzurePricingSourceConfig struct {
+	CurrencyCode string
+}
+
+var azureHTTPClient = &http.Client{Timeout: 60 * time.Second}
+
+// AzurePricingSource implements the PricingSource interface using the
+// Azure Retail Prices API (no auth required).
+type AzurePricingSource struct {
+	config AzurePricingSourceConfig
+}
+
+func NewAzurePricingSource(cfg AzurePricingSourceConfig) *AzurePricingSource {
+	return &AzurePricingSource{config: cfg}
+}
+
+func (a *AzurePricingSource) GetPricing() (*pricing.PricingSet, error) {
+	log.Infof("PricingSource (Azure): starting pricing download")
+	start := time.Now()
+
+	ps := &pricing.PricingSet{
+		Nodes:   []*pricing.NodePricing{},
+		Volumes: []*pricing.VolumePricing{},
+	}
+
+	// Fetch VM pricing
+	url := a.buildVMURL()
+	pageCount := 0
+
+	for url != "" {
+		resp, err := azureHTTPClient.Get(url)
+		if err != nil {
+			return nil, fmt.Errorf("PricingSource (Azure): GET %s: %w", url, err)
+		}
+
+		if resp.StatusCode != http.StatusOK {
+			body, _ := io.ReadAll(resp.Body)
+			closeErr := resp.Body.Close()
+			if closeErr != nil {
+				log.Warnf("failed to close response body: %v", closeErr)
+			}
+			return nil, fmt.Errorf("PricingSource (Azure): unexpected status %d on VM page %d: %s", resp.StatusCode, pageCount, string(body))
+		}
+
+		next, err := a.parseVMPage(resp.Body, ps)
+		closeErr := resp.Body.Close()
+		if closeErr != nil {
+			log.Warnf("failed to close response body: %v", closeErr)
+		}
+		if err != nil {
+			return nil, fmt.Errorf("PricingSource (Azure): parsing VM page %d: %w", pageCount, err)
+		}
+
+		pageCount++
+		url = next
+		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)
+
+	// Fetch disk pricing
+	url = a.buildDiskURL()
+	diskPageCount := 0
+
+	for url != "" {
+		resp, err := azureHTTPClient.Get(url)
+		if err != nil {
+			log.Warnf("PricingSource (Azure): failed to fetch disk pricing: %v", err)
+			break
+		}
+
+		if resp.StatusCode != http.StatusOK {
+			body, _ := io.ReadAll(resp.Body)
+			closeErr := resp.Body.Close()
+			if closeErr != nil {
+				log.Warnf("failed to close response body: %v", closeErr)
+			}
+			log.Warnf("PricingSource (Azure): unexpected status %d on disk page %d: %s", resp.StatusCode, diskPageCount, string(body))
+			break
+		}
+
+		next, err := a.parseDiskPage(resp.Body, ps)
+		closeErr := resp.Body.Close()
+		if closeErr != nil {
+			log.Warnf("failed to close response body: %v", closeErr)
+		}
+		if err != nil {
+			log.Warnf("PricingSource (Azure): error parsing disk page %d: %v", diskPageCount, err)
+			break
+		}
+
+		diskPageCount++
+		url = next
+		log.Debugf("PricingSource (Azure): fetched disk page %d, next: %s", diskPageCount, url)
+	}
+
+	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))
+
+	return ps, nil
+}
+
+func (a *AzurePricingSource) buildVMURL() string {
+	u := azurePricingBaseURL + "?$filter=" + url.QueryEscape(azureVMFilter)
+	if a.config.CurrencyCode != "" {
+		u += "&currencyCode=" + url.QueryEscape(a.config.CurrencyCode)
+	}
+	return u
+}
+
+func (a *AzurePricingSource) buildDiskURL() string {
+	u := azurePricingBaseURL + "?$filter=" + url.QueryEscape(azureDiskFilter)
+	if a.config.CurrencyCode != "" {
+		u += "&currencyCode=" + url.QueryEscape(a.config.CurrencyCode)
+	}
+	return u
+}
+
+func (a *AzurePricingSource) parseVMPage(body io.Reader, ps *pricing.PricingSet) (nextURL string, err error) {
+	data, err := io.ReadAll(body)
+	if err != nil {
+		return "", fmt.Errorf("reading response body: %w", err)
+	}
+
+	var page AzurePricing
+	if err := json.Unmarshal(data, &page); err != nil {
+		return "", fmt.Errorf("unmarshalling response: %w", err)
+	}
+
+	for _, item := range page.Items {
+		if !a.includeItem(item) {
+			continue
+		}
+
+		// 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),
+		}
+
+		nodePricing := &pricing.NodePricing{
+			Properties: pricing.NodePricingProperties{
+				Provider:     pricing.Provider(shared.ProviderAzure),
+				Region:       item.ArmRegionName,
+				InstanceType: item.ArmSkuName,
+				Provisioning: pricing.ProvisioningOnDemand,
+			},
+			Prices: pricing.Prices{
+				currency: []pricing.Price{
+					priceObj,
+				},
+			},
+		}
+
+		ps.Nodes = append(ps.Nodes, nodePricing)
+	}
+
+	return page.NextPageLink, nil
+}
+
+func (a *AzurePricingSource) parseDiskPage(body io.Reader, ps *pricing.PricingSet) (nextURL string, err error) {
+	data, err := io.ReadAll(body)
+	if err != nil {
+		return "", fmt.Errorf("reading response body: %w", err)
+	}
+
+	var page AzurePricing
+	if err := json.Unmarshal(data, &page); err != nil {
+		return "", fmt.Errorf("unmarshalling response: %w", err)
+	}
+
+	for _, item := range page.Items {
+		if !a.includeDiskItem(item) {
+			continue
+		}
+
+		volumeType := mapAzureDiskType(item.SkuName)
+		if volumeType == pricing.VolumeTypeNil {
+			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
+		}
+
+		// 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,
+				Region:     item.ArmRegionName,
+				VolumeType: volumeType,
+			},
+			Prices: pricing.Prices{
+				currency: []pricing.Price{{
+					Currency: currency,
+					Unit:     unit.Hour,
+					Price:    hourlyPrice,
+				}},
+			},
+		}
+
+		ps.Volumes = append(ps.Volumes, volumePricing)
+	}
+
+	return page.NextPageLink, nil
+}
+
+// includeItem mirrors the filtering logic in the existing Azure provider for VMs.
+func (a *AzurePricingSource) includeItem(item AzurePricingAttributes) bool {
+	if item.ArmSkuName == "" || item.ArmRegionName == "" {
+		return false
+	}
+	if strings.Contains(item.ProductName, "Windows") {
+		return false
+	}
+	skuLower := strings.ToLower(item.SkuName)
+	productLower := strings.ToLower(item.ProductName)
+	if strings.Contains(skuLower, "low priority") {
+		return false
+	}
+	if strings.Contains(productLower, "cloud services") || strings.Contains(productLower, "cloudservices") {
+		return false
+	}
+	return true
+}
+
+// includeDiskItem filters disk items to include only managed disks.
+func (a *AzurePricingSource) includeDiskItem(item AzurePricingAttributes) bool {
+	if item.ArmRegionName == "" {
+		return false
+	}
+	productLower := strings.ToLower(item.ProductName)
+	// Exclude unmanaged disks explicitly (weird case where "Unmanaged disk" still has managed "managed disk" :\)
+	if strings.Contains(productLower, "unmanaged") {
+		return false
+	}
+	// Only include managed disks
+	return strings.Contains(productLower, "managed disk")
+}
+
+// AzurePricing represents the response from Azure Retail Prices API
+type AzurePricing struct {
+	BillingCurrency    string                   `json:"BillingCurrency"`
+	CustomerEntityId   string                   `json:"CustomerEntityId"`
+	CustomerEntityType string                   `json:"CustomerEntityType"`
+	Items              []AzurePricingAttributes `json:"Items"`
+	NextPageLink       string                   `json:"NextPageLink"`
+	Count              int                      `json:"Count"`
+}
+
+// AzurePricingAttributes represents a single pricing item from Azure Retail Prices API
+type AzurePricingAttributes struct {
+	CurrencyCode         string     `json:"currencyCode"`
+	TierMinimumUnits     float32    `json:"tierMinimumUnits"`
+	RetailPrice          float32    `json:"retailPrice"`
+	UnitPrice            float32    `json:"unitPrice"`
+	ArmRegionName        string     `json:"armRegionName"`
+	Location             string     `json:"location"`
+	EffectiveStartDate   *time.Time `json:"effectiveStartDate"`
+	EffectiveEndDate     *time.Time `json:"effectiveEndDate"`
+	MeterId              string     `json:"meterId"`
+	MeterName            string     `json:"meterName"`
+	ProductId            string     `json:"productId"`
+	SkuId                string     `json:"skuId"`
+	ProductName          string     `json:"productName"`
+	SkuName              string     `json:"skuName"`
+	ServiceName          string     `json:"serviceName"`
+	ServiceId            string     `json:"serviceId"`
+	ServiceFamily        string     `json:"serviceFamily"`
+	UnitOfMeasure        string     `json:"unitOfMeasure"`
+	Type                 string     `json:"type"`
+	IsPrimaryMeterRegion bool       `json:"isPrimaryMeterRegion"`
+	ArmSkuName           string     `json:"armSkuName"`
+}

+ 351 - 0
modules/pricing/public/azure/azurepricingsource_test.go

@@ -0,0 +1,351 @@
+package azure
+
+import (
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/pricing"
+)
+
+func TestMapAzureDiskType(t *testing.T) {
+	tests := []struct {
+		name     string
+		skuName  string
+		expected pricing.VolumeType
+	}{
+		{
+			name:     "Premium SSD V2",
+			skuName:  "Premium SSD v2 Managed Disk",
+			expected: pricing.VolumeTypePremiumV2LRS,
+		},
+		{
+			name:     "PremiumV2 variant",
+			skuName:  "PremiumV2 LRS Disk",
+			expected: pricing.VolumeTypePremiumV2LRS,
+		},
+		{
+			name:     "Premium SSD",
+			skuName:  "Premium SSD Managed Disk",
+			expected: pricing.VolumeTypePremiumLRS,
+		},
+		{
+			name:     "Standard SSD",
+			skuName:  "Standard SSD Managed Disk",
+			expected: pricing.VolumeTypeStandardSSDLRS,
+		},
+		{
+			name:     "StandardSSD variant",
+			skuName:  "StandardSSD LRS",
+			expected: pricing.VolumeTypeStandardSSDLRS,
+		},
+		{
+			name:     "Standard HDD",
+			skuName:  "Standard HDD Managed Disk",
+			expected: pricing.VolumeTypeStandardHDDLRS,
+		},
+		{
+			name:     "Ultra SSD",
+			skuName:  "Ultra SSD Managed Disk",
+			expected: pricing.VolumeTypeUltraSSDLRS,
+		},
+		{
+			name:     "Unknown type",
+			skuName:  "Some Unknown Disk Type",
+			expected: pricing.VolumeTypeNil,
+		},
+		{
+			name:     "Case insensitive Premium",
+			skuName:  "PREMIUM SSD MANAGED DISK",
+			expected: pricing.VolumeTypePremiumLRS,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := mapAzureDiskType(tt.skuName)
+			if result != tt.expected {
+				t.Errorf("mapAzureDiskType(%q) = %v, want %v", tt.skuName, result, tt.expected)
+			}
+		})
+	}
+}
+
+func TestIncludeItem(t *testing.T) {
+	source := &AzurePricingSource{
+		config: AzurePricingSourceConfig{
+			CurrencyCode: "USD",
+		},
+	}
+
+	tests := []struct {
+		name     string
+		item     AzurePricingAttributes
+		expected bool
+	}{
+		{
+			name: "valid Linux VM",
+			item: AzurePricingAttributes{
+				ArmSkuName:    "Standard_D2s_v3",
+				ArmRegionName: "eastus",
+				ProductName:   "Virtual Machines Dsv3 Series",
+				SkuName:       "D2s v3",
+			},
+			expected: true,
+		},
+		{
+			name: "Windows VM - excluded",
+			item: AzurePricingAttributes{
+				ArmSkuName:    "Standard_D2s_v3",
+				ArmRegionName: "eastus",
+				ProductName:   "Virtual Machines Dsv3 Series Windows",
+				SkuName:       "D2s v3",
+			},
+			expected: false,
+		},
+		{
+			name: "Low priority - excluded",
+			item: AzurePricingAttributes{
+				ArmSkuName:    "Standard_D2s_v3",
+				ArmRegionName: "eastus",
+				ProductName:   "Virtual Machines Dsv3 Series",
+				SkuName:       "D2s v3 Low Priority",
+			},
+			expected: false,
+		},
+		{
+			name: "Cloud Services - excluded",
+			item: AzurePricingAttributes{
+				ArmSkuName:    "Standard_D2s_v3",
+				ArmRegionName: "eastus",
+				ProductName:   "Cloud Services Dsv3 Series",
+				SkuName:       "D2s v3",
+			},
+			expected: false,
+		},
+		{
+			name: "CloudServices variant - excluded",
+			item: AzurePricingAttributes{
+				ArmSkuName:    "Standard_D2s_v3",
+				ArmRegionName: "eastus",
+				ProductName:   "CloudServices Dsv3 Series",
+				SkuName:       "D2s v3",
+			},
+			expected: false,
+		},
+		{
+			name: "Missing ArmSkuName - excluded",
+			item: AzurePricingAttributes{
+				ArmSkuName:    "",
+				ArmRegionName: "eastus",
+				ProductName:   "Virtual Machines Dsv3 Series",
+				SkuName:       "D2s v3",
+			},
+			expected: false,
+		},
+		{
+			name: "Missing ArmRegionName - excluded",
+			item: AzurePricingAttributes{
+				ArmSkuName:    "Standard_D2s_v3",
+				ArmRegionName: "",
+				ProductName:   "Virtual Machines Dsv3 Series",
+				SkuName:       "D2s v3",
+			},
+			expected: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := source.includeItem(tt.item)
+			if result != tt.expected {
+				t.Errorf("includeItem() = %v, want %v", result, tt.expected)
+			}
+		})
+	}
+}
+
+func TestIncludeDiskItem(t *testing.T) {
+	source := &AzurePricingSource{
+		config: AzurePricingSourceConfig{
+			CurrencyCode: "USD",
+		},
+	}
+
+	tests := []struct {
+		name     string
+		item     AzurePricingAttributes
+		expected bool
+	}{
+		{
+			name: "Managed disk - included",
+			item: AzurePricingAttributes{
+				ArmRegionName: "eastus",
+				ProductName:   "Premium SSD Managed Disk",
+				SkuName:       "P10 LRS",
+			},
+			expected: true,
+		},
+		{
+			name: "Managed Disk uppercase - included",
+			item: AzurePricingAttributes{
+				ArmRegionName: "eastus",
+				ProductName:   "PREMIUM SSD MANAGED DISK",
+				SkuName:       "P10 LRS",
+			},
+			expected: true,
+		},
+		{
+			name: "Unmanaged disk - excluded",
+			item: AzurePricingAttributes{
+				ArmRegionName: "eastus",
+				ProductName:   "Premium SSD Unmanaged Disk",
+				SkuName:       "P10",
+			},
+			expected: false,
+		},
+		{
+			name: "Missing region - excluded",
+			item: AzurePricingAttributes{
+				ArmRegionName: "",
+				ProductName:   "Premium SSD Managed Disk",
+				SkuName:       "P10 LRS",
+			},
+			expected: false,
+		},
+		{
+			name: "Storage account - excluded",
+			item: AzurePricingAttributes{
+				ArmRegionName: "eastus",
+				ProductName:   "Storage Account",
+				SkuName:       "Standard LRS",
+			},
+			expected: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := source.includeDiskItem(tt.item)
+			if result != tt.expected {
+				t.Errorf("includeDiskItem() = %v, want %v", result, tt.expected)
+			}
+		})
+	}
+}
+
+func TestBuildVMURL(t *testing.T) {
+	tests := []struct {
+		name         string
+		currencyCode string
+		wantContains []string
+	}{
+		{
+			name:         "USD currency",
+			currencyCode: "USD",
+			wantContains: []string{
+				"prices.azure.com",
+				"serviceName+eq+%27Virtual+Machines%27",
+				"priceType+eq+%27Consumption%27",
+				"currencyCode=USD",
+			},
+		},
+		{
+			name:         "EUR currency",
+			currencyCode: "EUR",
+			wantContains: []string{
+				"prices.azure.com",
+				"currencyCode=EUR",
+			},
+		},
+		{
+			name:         "Empty currency",
+			currencyCode: "",
+			wantContains: []string{
+				"prices.azure.com",
+				"serviceName+eq+%27Virtual+Machines%27",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			source := &AzurePricingSource{
+				config: AzurePricingSourceConfig{
+					CurrencyCode: tt.currencyCode,
+				},
+			}
+			url := source.buildVMURL()
+			for _, want := range tt.wantContains {
+				if !contains(url, want) {
+					t.Errorf("buildVMURL() = %v, want to contain %v", url, want)
+				}
+			}
+		})
+	}
+}
+
+func TestBuildDiskURL(t *testing.T) {
+	tests := []struct {
+		name         string
+		currencyCode string
+		wantContains []string
+	}{
+		{
+			name:         "USD currency",
+			currencyCode: "USD",
+			wantContains: []string{
+				"prices.azure.com",
+				"serviceName+eq+%27Storage%27",
+				"priceType+eq+%27Consumption%27",
+				"currencyCode=USD",
+			},
+		},
+		{
+			name:         "EUR currency",
+			currencyCode: "EUR",
+			wantContains: []string{
+				"prices.azure.com",
+				"currencyCode=EUR",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			source := &AzurePricingSource{
+				config: AzurePricingSourceConfig{
+					CurrencyCode: tt.currencyCode,
+				},
+			}
+			url := source.buildDiskURL()
+			for _, want := range tt.wantContains {
+				if !contains(url, want) {
+					t.Errorf("buildDiskURL() = %v, want to contain %v", url, want)
+				}
+			}
+		})
+	}
+}
+
+func TestNewAzurePricingSource(t *testing.T) {
+	config := AzurePricingSourceConfig{
+		CurrencyCode: "USD",
+	}
+
+	source := NewAzurePricingSource(config)
+
+	if source == nil {
+		t.Fatal("NewAzurePricingSource() returned nil")
+	}
+
+	if source.config.CurrencyCode != "USD" {
+		t.Errorf("CurrencyCode = %v, want USD", source.config.CurrencyCode)
+	}
+}
+
+// Helper function to check if a string contains a substring
+func contains(s, substr string) bool {
+	return len(s) >= len(substr) && (s == substr || len(substr) == 0 || 
+		(len(s) > 0 && (s[0:len(substr)] == substr || contains(s[1:], substr))))
+}
+
+// Made with Bob

+ 30 - 0
modules/pricing/public/azure/types.go

@@ -0,0 +1,30 @@
+package azure
+
+import (
+	"strings"
+
+	"github.com/opencost/opencost/core/pkg/pricing"
+)
+
+// mapAzureDiskType maps Azure disk SKU names to VolumeType constants
+func mapAzureDiskType(skuName string) pricing.VolumeType {
+	skuLower := strings.ToLower(skuName)
+
+	if strings.Contains(skuLower, "premium ssd v2") || strings.Contains(skuLower, "premiumv2") {
+		return pricing.VolumeTypePremiumV2LRS
+	}
+	if strings.Contains(skuLower, "premium") {
+		return pricing.VolumeTypePremiumLRS
+	}
+	if strings.Contains(skuLower, "standard ssd") || strings.Contains(skuLower, "standardssd") {
+		return pricing.VolumeTypeStandardSSDLRS
+	}
+	if strings.Contains(skuLower, "standard") {
+		return pricing.VolumeTypeStandardHDDLRS
+	}
+	if strings.Contains(skuLower, "ultra") {
+		return pricing.VolumeTypeUltraSSDLRS
+	}
+
+	return pricing.VolumeTypeNil
+}

+ 93 - 0
modules/pricing/public/cmd/main.go

@@ -0,0 +1,93 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/unit"
+	"github.com/opencost/opencost/modules/pricing/public"
+	"github.com/spf13/cobra"
+)
+
+var (
+	provider string
+	currency string
+	output   string
+)
+
+func main() {
+	if err := rootCmd.Execute(); err != nil {
+		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+		os.Exit(1)
+	}
+}
+
+var rootCmd = &cobra.Command{
+	Use:   "fetch-pricing",
+	Short: "Fetch cloud provider pricing data",
+	Long:  `Fetch pricing data from a cloud provider and output as JSON.`,
+	RunE:  run,
+}
+
+func init() {
+	rootCmd.Flags().StringVarP(&provider, "provider", "p", "aws", "Cloud provider (aws, azure, gcp, all). Default: aws")
+	rootCmd.Flags().StringVarP(&currency, "currency", "c", "USD", "Currency code (e.g. USD, EUR, CNY). Default: USD")
+	rootCmd.Flags().StringVarP(&output, "output", "o", "", "Output file path. Default: /pricing-data/{provider}-{currency}.json. Use 'stdout' to print to console")
+}
+
+func run(cmd *cobra.Command, args []string) error {
+	curr, err := unit.ParseCurrency(currency)
+	if err != nil {
+		return fmt.Errorf("invalid currency '%s': %w", currency, err)
+	}
+
+	var prov pricing.Provider
+	switch provider {
+	case "all":
+		prov = pricing.AllProvider
+	case "aws":
+		prov = pricing.AWSProvider
+	case "azure":
+		prov = pricing.AzureProvider
+	case "gcp":
+		prov = pricing.GCPProvider
+	default:
+		return fmt.Errorf("unsupported provider: %s", provider)
+	}
+
+	log.Infof("Generating pricing for %s in %s", prov, curr)
+	pricingSet, err := public.GeneratePricingForProvider(prov, curr)
+	if err != nil {
+		return fmt.Errorf("failed to generate pricing: %w", err)
+	}
+
+	data, err := json.MarshalIndent(pricingSet, "", "  ")
+	if err != nil {
+		return fmt.Errorf("failed to marshal JSON: %w", err)
+	}
+
+	log.Infof("Generated %d node pricing entries and %d volume pricing entries",
+		len(pricingSet.Nodes), len(pricingSet.Volumes))
+
+	// Set default output path if not specified
+	if output == "" {
+		output = fmt.Sprintf("pricing-data/%s/%s-%s.json", provider, provider, currency)
+	}
+
+	// Check if user wants stdout
+	if output == "stdout" {
+		fmt.Println(string(data))
+		return nil
+	}
+
+	// Write to file
+	if err := os.WriteFile(output, data, 0644); err != nil {
+		return fmt.Errorf("failed to write output file: %w", err)
+	}
+	log.Infof("Wrote pricing data to %s", output)
+
+	return nil
+}

+ 109 - 0
modules/pricing/public/generator.go

@@ -0,0 +1,109 @@
+package public
+
+import (
+	"fmt"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/unit"
+	"github.com/opencost/opencost/modules/pricing/public/aws"
+	"github.com/opencost/opencost/modules/pricing/public/azure"
+)
+
+// GenerateAWSPricing fetches AWS pricing data in the specified currency
+func GenerateAWSPricing(currency unit.Currency) (*pricing.PricingSet, error) {
+	log.Infof("Generating AWS pricing for currency: %s", currency)
+
+	source := aws.NewAWSPricingSource(aws.AWSPricingSourceConfig{
+		CurrencyCode: string(currency),
+	})
+
+	pricingSet, err := source.GetPricing()
+	if err != nil {
+		return nil, fmt.Errorf("failed to get AWS pricing: %w", err)
+	}
+
+	// Sort to ensure deterministic output for checksums
+	pricingSet.Sort()
+
+	log.Infof("Generated %d AWS node pricing entries", len(pricingSet.Nodes))
+	return pricingSet, nil
+}
+
+// GenerateAzurePricing fetches Azure pricing data in the specified currency
+func GenerateAzurePricing(currency unit.Currency) (*pricing.PricingSet, error) {
+	log.Infof("Generating Azure pricing for currency: %s", currency)
+
+	source := azure.NewAzurePricingSource(azure.AzurePricingSourceConfig{
+		CurrencyCode: string(currency),
+	})
+
+	pricingSet, err := source.GetPricing()
+	if err != nil {
+		return nil, fmt.Errorf("failed to get Azure pricing: %w", err)
+	}
+
+	// Sort to ensure deterministic output for checksums
+	pricingSet.Sort()
+
+	log.Infof("Generated %d Azure node pricing entries", len(pricingSet.Nodes))
+	return pricingSet, nil
+}
+
+// GenerateAllProvidersPricing fetches pricing data for all supported providers
+// 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{},
+	}
+	
+	// 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))
+	}
+	
+	// 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))
+	}
+	
+	// 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))
+	
+	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) {
+	switch provider {
+	case pricing.AllProvider:
+		return GenerateAllProvidersPricing(currency)
+	case pricing.AWSProvider:
+		return GenerateAWSPricing(currency)
+	case pricing.AzureProvider:
+		return GenerateAzurePricing(currency)
+	case pricing.GCPProvider:
+		return nil, fmt.Errorf("not implemented")
+		// return GenerateGCPPricing(currency)
+	default:
+		return nil, fmt.Errorf("unsupported provider: %s", provider)
+	}
+}

+ 119 - 0
modules/pricing/public/go.mod

@@ -0,0 +1,119 @@
+module github.com/opencost/opencost/modules/pricing/public
+
+replace github.com/opencost/opencost/core => ../../../core
+
+require github.com/opencost/opencost/core v0.0.0 // return to v1.120.2-0.20260514205745-aa41c03dc67a
+
+require (
+	github.com/opencost/opencost v1.120.3
+	github.com/spf13/cobra v1.10.2
+)
+
+require (
+	cel.dev/expr v0.25.1 // indirect
+	cloud.google.com/go v0.123.0 // indirect
+	cloud.google.com/go/auth v0.18.2 // 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
+	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/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/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/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/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/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/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/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/rs/xid v1.6.0 // indirect
+	github.com/rs/zerolog v1.34.0 // 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/subosito/gotenv v1.6.0 // indirect
+	github.com/tinylib/msgp v1.6.3 // 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.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
+	google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
+	gopkg.in/yaml.v2 v2.4.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)
+
+go 1.26.3

+ 283 - 0
modules/pricing/public/go.sum

@@ -0,0 +1,283 @@
+cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
+cel.dev/expr v0.25.1/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/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=
+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/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/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/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=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
+github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
+github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
+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/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/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=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+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/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=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
+github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
+github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
+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/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/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=
+github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
+github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+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/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/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=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
+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/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=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+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/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/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=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+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=
+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.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/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=
+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/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=

+ 319 - 0
modules/pricing/public/module.go

@@ -0,0 +1,319 @@
+package public
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"sync"
+	"time"
+
+	"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"
+)
+
+type PricingModuleConfig struct {
+	Provider        pricing.Provider
+	Currency        unit.Currency
+	RefreshInterval time.Duration
+}
+
+type PricingModule struct {
+	config     PricingModuleConfig
+	Providers  *ProviderPricing `json:"provider" yaml:"provider"`
+	pricingSet *pricing.PricingSet
+	mu         sync.RWMutex
+	stopCh     chan struct{}
+	doneCh     chan struct{}
+}
+
+func NewPricingModule(config PricingModuleConfig) (*PricingModule, error) {
+	pm := &PricingModule{
+		config:    config,
+		Providers: &ProviderPricing{},
+		stopCh:    make(chan struct{}),
+		doneCh:    make(chan struct{}),
+	}
+
+	ctx := context.Background()
+
+	// Generate pricing data directly from the provider API
+	pricingSet, err := GeneratePricingForProvider(config.Provider, config.Currency)
+	if err != nil {
+		return nil, fmt.Errorf("failed to generate pricing: %w", err)
+	}
+
+	// Store the pricing set for reader access
+	pm.pricingSet = pricingSet
+
+	err = pm.indexPricingSet(ctx, pricingSet)
+	if err != nil {
+		return nil, fmt.Errorf("failed to index pricing: %w", err)
+	}
+
+	// Start background refresh if configured
+	if config.RefreshInterval > 0 {
+		go pm.backgroundRefresh()
+		log.Infof("Started background pricing refresh with interval: %v", config.RefreshInterval)
+	}
+
+	return pm, nil
+}
+
+type ProviderPricing map[pricing.Provider]*InstanceTypePricing
+
+type InstanceTypePricing map[string]*RegionPricing
+
+type RegionPricing map[string]*pricing.Prices
+
+func (pm *PricingModule) indexPricingSet(_ context.Context, pricingSet *pricing.PricingSet) error {
+	providers := make(ProviderPricing)
+
+	// Index nodes
+	for _, node := range pricingSet.Nodes {
+		provider := node.Properties.Provider
+		instanceType := node.Properties.InstanceType
+		region := node.Properties.Region
+
+		// Instance type map
+		if providers[provider] == nil {
+			instanceMap := make(InstanceTypePricing)
+			providers[provider] = &instanceMap
+		}
+		// Region map
+		if (*providers[provider])[instanceType] == nil {
+			regionMap := make(RegionPricing)
+			(*providers[provider])[instanceType] = &regionMap
+		}
+
+		(*(*providers[provider])[instanceType])[region] = &node.Prices
+	}
+
+	// Index volumes
+	for _, volume := range pricingSet.Volumes {
+		provider := volume.Properties.Provider
+		volumeType := string(volume.Properties.VolumeType)
+		region := volume.Properties.Region
+
+		// Instance type map
+		if providers[provider] == nil {
+			instanceMap := make(InstanceTypePricing)
+			providers[provider] = &instanceMap
+		}
+		// Region map
+		if (*providers[provider])[volumeType] == nil {
+			regionMap := make(RegionPricing)
+			(*providers[provider])[volumeType] = &regionMap
+		}
+
+		(*(*providers[provider])[volumeType])[region] = &volume.Prices
+	}
+
+	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)
+
+	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) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	if pm.Providers == nil {
+		return nil, fmt.Errorf("pricing not loaded")
+	}
+
+	providerPricing := (*pm.Providers)[provider]
+	if providerPricing == nil {
+		return nil, fmt.Errorf("provider %s not found", provider)
+	}
+
+	instancePricing := (*providerPricing)[instanceType]
+	if instancePricing == nil {
+		return nil, fmt.Errorf("instance type %s not found for provider %s", instanceType, provider)
+	}
+
+	regionPricing := (*instancePricing)[region]
+	if regionPricing == nil {
+		return nil, fmt.Errorf("region %s not found for instance type %s in provider %s", region, instanceType, provider)
+	}
+
+	// Reconstruct NodePricing from Prices
+	return &pricing.NodePricing{
+		Properties: pricing.NodePricingProperties{
+			Provider:     provider,
+			InstanceType: instanceType,
+			Region:       region,
+		},
+		Prices: *regionPricing,
+	}, 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) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	if pm.Providers == nil {
+		return nil, fmt.Errorf("pricing not loaded")
+	}
+
+	providerPricing := (*pm.Providers)[provider]
+	if providerPricing == nil {
+		return nil, fmt.Errorf("provider %s not found", provider)
+	}
+
+	instancePricing := (*providerPricing)[volumeType]
+	if instancePricing == nil {
+		return nil, fmt.Errorf("volume type %s not found for provider %s", volumeType, provider)
+	}
+
+	regionPricing := (*instancePricing)[region]
+	if regionPricing == nil {
+		return nil, fmt.Errorf("region %s not found for volume type %s in provider %s", region, volumeType, provider)
+	}
+
+	// Reconstruct NodePricing from Prices
+	return &pricing.VolumePricing{
+		Properties: pricing.VolumePricingProperties{
+			Provider:   provider,
+			VolumeType: pricing.VolumeType(volumeType),
+			Region:     region,
+		},
+		Prices: *regionPricing,
+	}, nil
+}
+
+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
+}
+
+func (pm *PricingModule) NewVolumePricingReader(ctx context.Context) (reader.Reader[*pricing.VolumePricing], error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+	return reader.NewSliceReader(pm.pricingSet.Volumes), nil
+}
+
+// GetPricingSet returns the current in-memory pricing set
+func (pm *PricingModule) GetPricingSet() *pricing.PricingSet {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+	return pm.pricingSet
+}
+
+// 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()
+
+	if pm.pricingSet == nil {
+		return false, fmt.Errorf("current pricing set is nil")
+	}
+	if newPricingSet == nil {
+		return false, fmt.Errorf("new pricing set is nil")
+	}
+
+	// Compare by serializing both to JSON and computing checksums
+	currentJSON, err := pm.serializePricingSet(pm.pricingSet)
+	if err != nil {
+		return false, fmt.Errorf("failed to serialize current pricing set: %w", err)
+	}
+
+	newJSON, err := pm.serializePricingSet(newPricingSet)
+	if err != nil {
+		return false, fmt.Errorf("failed to serialize new pricing set: %w", err)
+	}
+
+	return string(currentJSON) == string(newJSON), nil
+}
+
+// UpdatePricingSet replaces the current pricing set with a new one and re-indexes it
+func (pm *PricingModule) UpdatePricingSet(ctx context.Context, newPricingSet *pricing.PricingSet) error {
+	if newPricingSet == nil {
+		return fmt.Errorf("new pricing set is nil")
+	}
+
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	// Store the new pricing set
+	pm.pricingSet = newPricingSet
+
+	// Re-index the pricing data
+	err := pm.indexPricingSet(ctx, newPricingSet)
+	if err != nil {
+		return fmt.Errorf("failed to index new pricing set: %w", err)
+	}
+
+	log.Infof("Updated pricing set: %d node pricing records and %d volume pricing records",
+		len(newPricingSet.Nodes), len(newPricingSet.Volumes))
+
+	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()
+
+	for {
+		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 {
+				log.Errorf("Failed to refresh pricing data: %v", err)
+				continue
+			}
+
+			// Compare with existing data
+			isIdentical, err := pm.ComparePricingSet(newPricingSet)
+			if err != nil {
+				log.Errorf("Failed to compare pricing data: %v", err)
+				continue
+			}
+
+			if isIdentical {
+				log.Infof("Pricing data unchanged, skipping update")
+				continue
+			}
+
+			// Update with new data
+			ctx := context.Background()
+			if err := pm.UpdatePricingSet(ctx, newPricingSet); err != nil {
+				log.Errorf("Failed to update pricing data: %v", err)
+				continue
+			}
+
+			log.Infof("Successfully refreshed pricing data")
+
+		case <-pm.stopCh:
+			log.Infof("Stopping background pricing refresh")
+			return
+		}
+	}
+}
+
+// Stop gracefully stops the background refresh goroutine
+func (pm *PricingModule) Stop() {
+	if pm.config.RefreshInterval > 0 {
+		close(pm.stopCh)
+		<-pm.doneCh
+		log.Infof("Background pricing refresh stopped")
+	}
+}

+ 66 - 0
modules/pricing/public/module_test.go

@@ -0,0 +1,66 @@
+package public
+
+import (
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+// TestPricingModuleConfig tests that the config struct is properly defined
+func TestPricingModuleConfig(t *testing.T) {
+	config := PricingModuleConfig{
+		Provider: pricing.AWSProvider,
+		Currency: unit.USD,
+	}
+
+	if config.Provider != pricing.AWSProvider {
+		t.Errorf("Provider = %v, want %v", config.Provider, pricing.AWSProvider)
+	}
+	if config.Currency != unit.USD {
+		t.Errorf("Currency = %v, want %v", config.Currency, unit.USD)
+	}
+}
+
+// TestProviderPricingStructure tests the nested map structure
+func TestProviderPricingStructure(t *testing.T) {
+	providers := make(ProviderPricing)
+
+	// Create a simple structure
+	instanceMap := make(InstanceTypePricing)
+	regionMap := make(RegionPricing)
+
+	prices := &pricing.Prices{
+		unit.USD: []pricing.Price{
+			{Currency: unit.USD, Unit: unit.Hour, Price: 0.0416},
+		},
+	}
+
+	regionMap["us-east-1"] = prices
+	instanceMap["t3.medium"] = &regionMap
+	providers[pricing.AWSProvider] = &instanceMap
+
+	// Verify structure
+	if providers[pricing.AWSProvider] == nil {
+		t.Fatal("AWS provider not found")
+	}
+
+	if (*providers[pricing.AWSProvider])["t3.medium"] == nil {
+		t.Fatal("t3.medium instance type not found")
+	}
+
+	if (*(*providers[pricing.AWSProvider])["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

+ 7 - 0
modules/pricing/public/source.go

@@ -0,0 +1,7 @@
+package public
+
+import "github.com/opencost/opencost/core/pkg/pricing"
+
+type PricingSource interface {
+	GetPricing() (*pricing.PricingSet, error)
+}

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

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

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

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

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

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

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

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

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

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

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

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

+ 26 - 94
pkg/cloud/aws/provider_test.go

@@ -133,8 +133,8 @@ func Test_PricingData_Regression(t *testing.T) {
 			t.Errorf("Failed to download pricing data for region %s: %v", region, err)
 		}
 
-		// Unmarshal pricing data into AWSPricing
-		var pricingData AWSPricing
+		// Unmarshal pricing data into PriceListEC2Response
+		var pricingData PriceListEC2Response
 		body, err := io.ReadAll(res.Body)
 		if err != nil {
 			t.Errorf("Failed to read pricing data for region %s: %v", region, err)
@@ -207,13 +207,13 @@ func Test_populate_pricing(t *testing.T) {
 		Storage: "",
 		VCpu:    "",
 		GPU:     "",
-		OnDemand: &AWSOfferTerm{
+		OnDemand: &PriceListEC2Term{
 			Sku:           "M6UGCCQ3CDJQAA37",
 			OfferTermCode: "JRTCKXETXF",
-			PriceDimensions: map[string]*AWSRateCode{
+			PriceDimensions: map[string]*PriceListEC2PriceDimension{
 				"M6UGCCQ3CDJQAA37.JRTCKXETXF.6YS6EN2CT7": {
 					Unit: "GB-Mo",
-					PricePerUnit: AWSCurrencyCode{
+					PricePerUnit: PriceListEC2PricePerUnit{
 						USD: "0.0800000000",
 						CNY: "",
 					},
@@ -236,13 +236,13 @@ func Test_populate_pricing(t *testing.T) {
 		Storage: "EBS only",
 		VCpu:    "2",
 		GPU:     "",
-		OnDemand: &AWSOfferTerm{
+		OnDemand: &PriceListEC2Term{
 			Sku:           "8D49XP354UEYTHGM",
 			OfferTermCode: "MZU6U2429S",
-			PriceDimensions: map[string]*AWSRateCode{
+			PriceDimensions: map[string]*PriceListEC2PriceDimension{
 				"8D49XP354UEYTHGM.MZU6U2429S.2TG2D8R56U": {
 					Unit: "Quantity",
-					PricePerUnit: AWSCurrencyCode{
+					PricePerUnit: PriceListEC2PricePerUnit{
 						USD: "1161",
 						CNY: "",
 					},
@@ -257,13 +257,13 @@ func Test_populate_pricing(t *testing.T) {
 		Storage: "EBS only",
 		VCpu:    "2",
 		GPU:     "",
-		OnDemand: &AWSOfferTerm{
+		OnDemand: &PriceListEC2Term{
 			Sku:           "8D49XP354UEYTHGM",
 			OfferTermCode: "MZU6U2429S",
-			PriceDimensions: map[string]*AWSRateCode{
+			PriceDimensions: map[string]*PriceListEC2PriceDimension{
 				"8D49XP354UEYTHGM.MZU6U2429S.2TG2D8R56U": {
 					Unit: "Quantity",
-					PricePerUnit: AWSCurrencyCode{
+					PricePerUnit: PriceListEC2PricePerUnit{
 						USD: "1161",
 						CNY: "",
 					},
@@ -274,13 +274,13 @@ func Test_populate_pricing(t *testing.T) {
 
 	expectedProdTermsLoadbalancer := &AWSProductTerms{
 		Sku: "Y9RYMSE644KDSV4S",
-		OnDemand: &AWSOfferTerm{
+		OnDemand: &PriceListEC2Term{
 			Sku:           "Y9RYMSE644KDSV4S",
 			OfferTermCode: "JRTCKXETXF",
-			PriceDimensions: map[string]*AWSRateCode{
+			PriceDimensions: map[string]*PriceListEC2PriceDimension{
 				"Y9RYMSE644KDSV4S.JRTCKXETXF.6YS6EN2CT7": {
 					Unit: "Hrs",
-					PricePerUnit: AWSCurrencyCode{
+					PricePerUnit: PriceListEC2PricePerUnit{
 						USD: "0.0225000000",
 						CNY: "",
 					},
@@ -337,13 +337,13 @@ func Test_populate_pricing(t *testing.T) {
 		Storage: "8 x 1000 SSD",
 		VCpu:    "96",
 		GPU:     "8",
-		OnDemand: &AWSOfferTerm{
+		OnDemand: &PriceListEC2Term{
 			Sku:           "H7NGEAC6UEHNTKSJ",
 			OfferTermCode: "JRTCKXETXF",
-			PriceDimensions: map[string]*AWSRateCode{
+			PriceDimensions: map[string]*PriceListEC2PriceDimension{
 				"H7NGEAC6UEHNTKSJ.JRTCKXETXF.6YS6EN2CT7": {
 					Unit: "Hrs",
-					PricePerUnit: AWSCurrencyCode{
+					PricePerUnit: PriceListEC2PricePerUnit{
 						USD: "32.7726000000",
 					},
 				},
@@ -392,13 +392,13 @@ func Test_populate_pricing(t *testing.T) {
 		Storage: "",
 		VCpu:    "",
 		GPU:     "",
-		OnDemand: &AWSOfferTerm{
+		OnDemand: &PriceListEC2Term{
 			Sku:           "R83VXG9NAPDASEGN",
 			OfferTermCode: "5Y9WH78GDR",
-			PriceDimensions: map[string]*AWSRateCode{
+			PriceDimensions: map[string]*PriceListEC2PriceDimension{
 				"R83VXG9NAPDASEGN.5Y9WH78GDR.Q7UJUT2CE6": {
 					Unit: "GB-Mo",
-					PricePerUnit: AWSCurrencyCode{
+					PricePerUnit: PriceListEC2PricePerUnit{
 						USD: "",
 						CNY: "0.5312000000",
 					},
@@ -696,74 +696,6 @@ func Test_configUpdaterWithReaderAndType_forSpotValues(t *testing.T) {
 	}
 }
 
-// Mock cluster cache for testing
-type mockClusterCache struct {
-	pods []*clustercache.Pod
-}
-
-func (m *mockClusterCache) Run()  {}
-func (m *mockClusterCache) Stop() {}
-
-func (m *mockClusterCache) GetAllPods() []*clustercache.Pod {
-	return m.pods
-}
-
-func (m *mockClusterCache) GetAllNodes() []*clustercache.Node {
-	return nil
-}
-
-func (m *mockClusterCache) GetAllPersistentVolumes() []*clustercache.PersistentVolume {
-	return nil
-}
-
-func (m *mockClusterCache) GetAllPersistentVolumeClaims() []*clustercache.PersistentVolumeClaim {
-	return nil
-}
-
-func (m *mockClusterCache) GetAllStorageClasses() []*clustercache.StorageClass {
-	return nil
-}
-
-func (m *mockClusterCache) GetAllServices() []*clustercache.Service {
-	return nil
-}
-
-func (m *mockClusterCache) GetAllDeployments() []*clustercache.Deployment {
-	return nil
-}
-
-func (m *mockClusterCache) GetAllDaemonSets() []*clustercache.DaemonSet {
-	return nil
-}
-
-func (m *mockClusterCache) GetAllStatefulSets() []*clustercache.StatefulSet {
-	return nil
-}
-
-func (m *mockClusterCache) GetAllReplicaSets() []*clustercache.ReplicaSet {
-	return nil
-}
-
-func (m *mockClusterCache) GetAllJobs() []*clustercache.Job {
-	return nil
-}
-
-func (m *mockClusterCache) GetAllNamespaces() []*clustercache.Namespace {
-	return nil
-}
-
-func (m *mockClusterCache) GetAllPodDisruptionBudgets() []*clustercache.PodDisruptionBudget {
-	return nil
-}
-
-func (m *mockClusterCache) GetAllReplicationControllers() []*clustercache.ReplicationController {
-	return nil
-}
-
-func (m *mockClusterCache) GetAllResourceQuotas() []*clustercache.ResourceQuota {
-	return nil
-}
-
 func TestAWS_getFargatePod(t *testing.T) {
 	tests := []struct {
 		name     string
@@ -823,7 +755,7 @@ func TestAWS_getFargatePod(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			aws := &AWS{
-				Clientset: &mockClusterCache{pods: tt.pods},
+				Clientset: &clustercache.MockClusterCache{Pods: tt.pods},
 			}
 
 			gotPod, gotBool := aws.getFargatePod(tt.awsKey)
@@ -1132,13 +1064,13 @@ func TestAWS_createNode_spotHistoryFallback(t *testing.T) {
 		priceKey := sku + "." + offerTermCode + "." + HourlyRateCode
 		return &AWSProductTerms{
 			Sku: sku,
-			OnDemand: &AWSOfferTerm{
+			OnDemand: &PriceListEC2Term{
 				Sku:           sku,
 				OfferTermCode: offerTermCode,
-				PriceDimensions: map[string]*AWSRateCode{
+				PriceDimensions: map[string]*PriceListEC2PriceDimension{
 					priceKey: {
 						Unit:         "Hrs",
-						PricePerUnit: AWSCurrencyCode{USD: cost},
+						PricePerUnit: PriceListEC2PricePerUnit{USD: cost},
 					},
 				},
 			},
@@ -1277,10 +1209,10 @@ func TestAWS_createNode_spotHistoryFallback(t *testing.T) {
 		// Terms without valid pricing dimensions
 		terms := &AWSProductTerms{
 			Sku: "SKU123",
-			OnDemand: &AWSOfferTerm{
+			OnDemand: &PriceListEC2Term{
 				Sku:             "SKU123",
 				OfferTermCode:   "JRTCKXETXF",
-				PriceDimensions: map[string]*AWSRateCode{},
+				PriceDimensions: map[string]*PriceListEC2PriceDimension{},
 			},
 			VCpu:   "4",
 			Memory: "16",

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

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

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

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

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

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

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

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

+ 7 - 49
pkg/cloud/gcp/provider_test.go

@@ -456,7 +456,7 @@ func TestGCP_GetManagementPlatform(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			gcp := &GCP{
-				Clientset: &mockClusterCache{nodes: tt.nodes},
+				Clientset: &clustercache.MockClusterCache{Nodes: tt.nodes},
 			}
 
 			result, err := gcp.GetManagementPlatform()
@@ -1098,10 +1098,10 @@ func TestGCP_parsePages(t *testing.T) {
 func TestGCP_DownloadPricingData(t *testing.T) {
 	gcp := &GCP{
 		Config: &mockConfig{},
-		Clientset: &mockClusterCache{
-			nodes: []*clustercache.Node{},
-			pvs:   []*clustercache.PersistentVolume{},
-			scs:   []*clustercache.StorageClass{},
+		Clientset: &clustercache.MockClusterCache{
+			Nodes:             []*clustercache.Node{},
+			PersistentVolumes: []*clustercache.PersistentVolume{},
+			StorageClasses:    []*clustercache.StorageClass{},
 		},
 	}
 
@@ -1153,8 +1153,8 @@ func TestGCP_ApplyReservedInstancePricing(t *testing.T) {
 				},
 			},
 		},
-		Clientset: &mockClusterCache{
-			nodes: []*clustercache.Node{
+		Clientset: &clustercache.MockClusterCache{
+			Nodes: []*clustercache.Node{
 				{
 					Name: "test-node",
 					Labels: map[string]string{
@@ -1333,48 +1333,6 @@ func (m *mockConfig) ConfigFileManager() *config.ConfigFileManager {
 	return nil
 }
 
-type mockClusterCache struct {
-	nodes []*clustercache.Node
-	pvs   []*clustercache.PersistentVolume
-	scs   []*clustercache.StorageClass
-}
-
-func (m *mockClusterCache) GetAllNodes() []*clustercache.Node {
-	return m.nodes
-}
-
-func (m *mockClusterCache) GetAllDaemonSets() []*clustercache.DaemonSet {
-	return nil
-}
-
-func (m *mockClusterCache) GetAllDeployments() []*clustercache.Deployment {
-	return nil
-}
-
-func (m *mockClusterCache) Run()                                                      {}
-func (m *mockClusterCache) Stop()                                                     {}
-func (m *mockClusterCache) GetAllNamespaces() []*clustercache.Namespace               { return nil }
-func (m *mockClusterCache) GetAllPods() []*clustercache.Pod                           { return nil }
-func (m *mockClusterCache) GetAllServices() []*clustercache.Service                   { return nil }
-func (m *mockClusterCache) GetAllStatefulSets() []*clustercache.StatefulSet           { return nil }
-func (m *mockClusterCache) GetAllReplicaSets() []*clustercache.ReplicaSet             { return nil }
-func (m *mockClusterCache) GetAllPersistentVolumes() []*clustercache.PersistentVolume { return m.pvs }
-func (m *mockClusterCache) GetAllPersistentVolumeClaims() []*clustercache.PersistentVolumeClaim {
-	return nil
-}
-func (m *mockClusterCache) GetAllStorageClasses() []*clustercache.StorageClass { return m.scs }
-func (m *mockClusterCache) GetAllJobs() []*clustercache.Job                    { return nil }
-func (m *mockClusterCache) GetAllPodDisruptionBudgets() []*clustercache.PodDisruptionBudget {
-	return nil
-}
-func (m *mockClusterCache) GetAllReplicationControllers() []*clustercache.ReplicationController {
-	return nil
-}
-
-func (m *mockClusterCache) GetAllResourceQuotas() []*clustercache.ResourceQuota {
-	return nil
-}
-
 type mockMetadataClient struct{}
 
 func (m *mockMetadataClient) InstanceAttributeValue(attr string) (string, error) {

+ 2 - 0
pkg/env/cloudcost.go

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