Преглед изворни кода

KCM 5801/3: AWS & Azure Pricing Module (#3831)

alexsouthard пре 5 дана
родитељ
комит
e147e2e8f8

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

@@ -62,10 +62,34 @@ func (repo *MockPricingRepository) NewNodePricingReader(ctx context.Context) (re
 	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
 

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

@@ -4,6 +4,7 @@ type Provider string
 
 const (
 	NilProvider    Provider = ""
+	AllProvider    Provider = "all"
 	AWSProvider    Provider = "aws"
 	AzureProvider  Provider = "azure"
 	CustomProvider Provider = "custom"

+ 2 - 4
core/pkg/pricing/repository.go

@@ -11,14 +11,12 @@ type PricingRepository interface {
 	VolumePricingRepository
 }
 
-// TODO: add the following function for Opencost pricing
-// GetNodePricing(NodePricingProperties) (*NodePricing, error)
 type NodePricingRepository interface {
 	NewNodePricingReader(ctx context.Context) (reader.Reader[*NodePricing], error)
+	GetNodePricing(provider Provider, instanceType string, region string) (*NodePricing, error)
 }
 
-// TODO: add the following function for Opencost pricing
-// GetVolumePricing(VolumePricingProperties) (*VolumePricing, error)
 type VolumePricingRepository interface {
 	NewVolumePricingReader(ctx context.Context) (reader.Reader[*VolumePricing], error)
+	GetVolumePricing(VolumePricingProperties) (*VolumePricing, error)
 }

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

@@ -41,3 +41,70 @@ func (ps *PricingSet) Currencies() []unit.Currency {
 
 	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

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

+ 106 - 1
modules/pricing/public/generator.go

@@ -1,4 +1,109 @@
 package public
 
-type PricingModuleGenerator struct {
+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)
+	}
 }

+ 111 - 1
modules/pricing/public/go.mod

@@ -4,6 +4,116 @@ 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 gopkg.in/yaml.v3 v3.0.1 // indirect
+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

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

@@ -1,3 +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=

+ 296 - 4
modules/pricing/public/module.go

@@ -2,14 +2,63 @@ package public
 
 import (
 	"context"
-	"errors"
+	"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 {
-	Providers *ProviderPricing `json:"provider" yaml:"provider"`
+	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
@@ -18,10 +67,253 @@ 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) {
-	return nil, errors.New("not implemented")
+	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) {
-	return nil, errors.New("not implemented")
+	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

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

@@ -2,8 +2,6 @@ package public
 
 import "github.com/opencost/opencost/core/pkg/pricing"
 
-// TODO
-
 type PricingSource interface {
 	GetPricing() (*pricing.PricingSet, error)
 }