Răsfoiți Sursa

Pricing changeset (#3876)

Signed-off-by: Sean Holcomb <seanholcomb@gmail.com>
Signed-off-by: Sean Holcomb <sean.holcomb@ibm.com>
Co-authored-by: Niko Kovacevic <nikovacevic@gmail.com>
Co-authored-by: Alex Meijer <ameijer@users.noreply.github.com>
Co-authored-by: alexsouthard <Alex.Southard@ibm.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Sean Holcomb 3 săptămâni în urmă
părinte
comite
0df2cee46c
78 a modificat fișierele cu 10121 adăugiri și 120 ștergeri
  1. 95 0
      .github/workflows/compare-pricing-data.yaml
  2. 5 1
      core/pkg/cloud/provider.go
  3. 1 1
      core/pkg/cloud/provider_test.go
  4. 2 2
      core/pkg/compute/kubemodel/cluster.go
  5. 2 2
      core/pkg/compute/kubemodel/cluster_test.go
  6. 12 12
      core/pkg/external/configmapsource_test.go
  7. 1 1
      core/pkg/model/kubemodel/bingen.go
  8. 8 8
      core/pkg/model/kubemodel/cluster.go
  9. 497 68
      core/pkg/model/kubemodel/kubemodel_codecs.go
  10. 2 2
      core/pkg/model/kubemodel/mock.go
  11. 1 0
      core/pkg/model/kubemodel/networktrafficdetail.go
  12. 1 0
      core/pkg/pipelines/name.go
  13. 42 0
      core/pkg/pricing/cluster.go
  14. 42 0
      core/pkg/pricing/memory.go
  15. 159 0
      core/pkg/pricing/memory_test.go
  16. 152 0
      core/pkg/pricing/mock.go
  17. 163 0
      core/pkg/pricing/mock_test.go
  18. 8 0
      core/pkg/pricing/module.go
  19. 42 0
      core/pkg/pricing/network.go
  20. 84 0
      core/pkg/pricing/node.go
  21. 82 0
      core/pkg/pricing/persistentvolume.go
  22. 49 0
      core/pkg/pricing/price.go
  23. 250 0
      core/pkg/pricing/pricingset.go
  24. 256 0
      core/pkg/pricing/pricingset_test.go
  25. 8 0
      core/pkg/pricing/provisioning.go
  26. 58 0
      core/pkg/pricing/resource.go
  27. 47 0
      core/pkg/pricing/service.go
  28. 39 0
      core/pkg/pricing/source.go
  29. 84 0
      core/pkg/pricing/storage.go
  30. 139 0
      core/pkg/pricing/storage_test.go
  31. 10 0
      core/pkg/pricing/store.go
  32. 195 0
      core/pkg/pricing/test/aws.yaml
  33. 180 0
      core/pkg/pricing/test/azure.yaml
  34. 36 0
      core/pkg/pricing/test/default.yaml
  35. 241 0
      core/pkg/pricing/test/gcp.yaml
  36. 54 0
      core/pkg/pricing/volumetype.go
  37. 81 0
      core/pkg/reader/func.go
  38. 110 0
      core/pkg/reader/func_test.go
  39. 26 0
      core/pkg/reader/jsonlines.go
  40. 391 0
      core/pkg/reader/jsonlines_test.go
  41. 15 0
      core/pkg/reader/reader.go
  42. 43 0
      core/pkg/reader/slice.go
  43. 245 0
      core/pkg/reader/slice_test.go
  44. 19 20
      core/pkg/source/decoders.go
  45. 0 3
      core/pkg/source/mock.go
  46. 57 0
      core/pkg/unit/currency.go
  47. 103 0
      core/pkg/unit/currency_test.go
  48. 63 0
      core/pkg/unit/unit.go
  49. 76 0
      core/pkg/unit/unit_test.go
  50. 2 0
      justfile
  51. 136 0
      modules/pricing/basic/default.go
  52. 117 0
      modules/pricing/basic/go.mod
  53. 272 0
      modules/pricing/basic/go.sum
  54. 522 0
      modules/pricing/basic/module.go
  55. 660 0
      modules/pricing/basic/module_test.go
  56. 23 0
      modules/pricing/basic/store.go
  57. 196 0
      modules/pricing/public/aws/awspricingsource.go
  58. 270 0
      modules/pricing/public/aws/awspricingsource_test.go
  59. 232 0
      modules/pricing/public/aws/pricelistapi.go
  60. 97 0
      modules/pricing/public/aws/pricelistapi_test.go
  61. 36 0
      modules/pricing/public/aws/types.go
  62. 289 0
      modules/pricing/public/azure/azurepricingsource.go
  63. 344 0
      modules/pricing/public/azure/azurepricingsource_test.go
  64. 30 0
      modules/pricing/public/azure/types.go
  65. 165 0
      modules/pricing/public/cmd/main.go
  66. 122 0
      modules/pricing/public/cny/module.go
  67. 0 0
      modules/pricing/public/cny/nodes.jsonl
  68. 0 0
      modules/pricing/public/cny/persistentvolumes.jsonl
  69. 14 0
      modules/pricing/public/env/env.go
  70. 341 0
      modules/pricing/public/gcp/gcppricingsource.go
  71. 1145 0
      modules/pricing/public/gcp/gcppricingsource_test.go
  72. 183 0
      modules/pricing/public/gcp/types.go
  73. 123 0
      modules/pricing/public/generator.go
  74. 116 0
      modules/pricing/public/go.mod
  75. 288 0
      modules/pricing/public/go.sum
  76. 122 0
      modules/pricing/public/usd/module.go
  77. 0 0
      modules/pricing/public/usd/nodes.jsonl
  78. 0 0
      modules/pricing/public/usd/persistentvolumes.jsonl

+ 95 - 0
.github/workflows/compare-pricing-data.yaml

@@ -0,0 +1,95 @@
+name: Compare Pricing Data
+
+on:
+  schedule:
+    - cron: '0 10 * * *'
+
+permissions:
+  contents: read
+
+jobs:
+  compare-usd:
+    runs-on: ubuntu-latest
+    outputs:
+      exit_code: ${{ steps.compare.outputs.exit_code }}
+    steps:
+      - uses: actions/checkout@v6.0.2
+
+      - name: Install Go
+        uses: actions/setup-go@v6
+        with:
+          go-version: 'stable'
+
+      - name: Go Mod cache
+        uses: actions/cache@v5
+        with:
+          path: |
+            ~/.cache/go-build
+            ~/go/pkg/mod
+          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+
+      - name: Compare USD pricing data
+        id: compare
+        working-directory: modules/pricing/public/cmd
+        env:
+          GCP_API_KEY: ${{ secrets.GCP_API_KEY }}
+        # Capture the exit code before the shell exits so downstream steps can
+        # distinguish drift (2) from a generation/infra error (1).
+        run: |
+          set +e
+          go run . --currency USD --compare
+          CODE=$?
+          echo "exit_code=$CODE" >> $GITHUB_OUTPUT
+          exit $CODE
+
+  compare-cny:
+    runs-on: ubuntu-latest
+    outputs:
+      exit_code: ${{ steps.compare.outputs.exit_code }}
+    steps:
+      - uses: actions/checkout@v6.0.2
+
+      - name: Install Go
+        uses: actions/setup-go@v6
+        with:
+          go-version: 'stable'
+
+      - name: Go Mod cache
+        uses: actions/cache@v5
+        with:
+          path: |
+            ~/.cache/go-build
+            ~/go/pkg/mod
+          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
+
+      - name: Compare CNY pricing data
+        id: compare
+        working-directory: modules/pricing/public/cmd
+        # CNY does not use GCP so no API key needed
+        run: |
+          set +e
+          go run . --currency CNY --compare
+          CODE=$?
+          echo "exit_code=$CODE" >> $GITHUB_OUTPUT
+          exit $CODE
+
+  notify-drift:
+    runs-on: ubuntu-latest
+    needs: [compare-usd, compare-cny]
+    if: |
+      always() &&
+      (needs.compare-usd.outputs.exit_code == '2' || needs.compare-cny.outputs.exit_code == '2')
+    steps:
+      - name: Notify Slack — pricing drift
+        uses: slackapi/slack-github-action@v2
+        with:
+          method: chat.postMessage
+          token: ${{ secrets.SLACK_BOT_TOKEN }}
+          payload: |
+            channel: ${{ secrets.SLACK_PRICING_CHANNEL_ID }}
+            text: ":warning: Pricing data drift detected :warning:"
+            blocks:
+              - type: section
+                text:
+                  type: mrkdwn
+                  text: ":warning: *Pricing data drift detected*\nUSD: ${{ needs.compare-usd.outputs.exit_code == '2' && 'drifted :x:' || 'ok :white_check_mark:' }} | CNY: ${{ needs.compare-cny.outputs.exit_code == '2' && 'drifted :x:' || 'ok :white_check_mark:' }}\nThe live cloud provider pricing no longer matches the committed <https://github.com/${{ github.repository }}/tree/${{ github.ref_name }}/modules/pricing/public|pricing-data.json>.\n*Workflow:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View run>"

+ 5 - 1
core/pkg/model/shared/provider.go → core/pkg/cloud/provider.go

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

+ 1 - 1
core/pkg/model/shared/provider_test.go → core/pkg/cloud/provider_test.go

@@ -1,4 +1,4 @@
-package shared
+package cloud
 
 import (
 	"testing"

+ 2 - 2
core/pkg/compute/kubemodel/cluster.go

@@ -4,9 +4,9 @@ import (
 	"fmt"
 	"time"
 
+	"github.com/opencost/opencost/core/pkg/cloud"
 	"github.com/opencost/opencost/core/pkg/log"
 	"github.com/opencost/opencost/core/pkg/model/kubemodel"
-	"github.com/opencost/opencost/core/pkg/model/shared"
 	"github.com/opencost/opencost/core/pkg/source"
 )
 
@@ -23,7 +23,7 @@ func (km *KubeModel) computeCluster(kms *kubemodel.KubeModelSet, start, end time
 	for _, res := range clusterInfoResult {
 		clusterMap[res.UID] = &kubemodel.Cluster{
 			UID:      res.UID,
-			Provider: shared.ParseProvider(res.Provider),
+			Provider: cloud.ParseProvider(res.Provider),
 			Account:  res.AccountID,
 			Name:     res.Cluster,
 			Region:   res.Region,

+ 2 - 2
core/pkg/compute/kubemodel/cluster_test.go

@@ -7,8 +7,8 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
+	"github.com/opencost/opencost/core/pkg/cloud"
 	"github.com/opencost/opencost/core/pkg/model/kubemodel"
-	"github.com/opencost/opencost/core/pkg/model/shared"
 	"github.com/opencost/opencost/core/pkg/source"
 )
 
@@ -57,7 +57,7 @@ func TestComputeCluster(t *testing.T) {
 			want: &kubemodel.Cluster{
 				UID:      testClusterUID,
 				Name:     "prod-cluster",
-				Provider: shared.ProviderAWS,
+				Provider: cloud.ProviderAWS,
 				Account:  "123456789",
 				Region:   "us-east-1",
 				Start:    start,

+ 12 - 12
core/pkg/external/configmapsource_test.go

@@ -333,11 +333,11 @@ labels:
 
 func TestFilterValidLabels_AllValid_ReturnedUnchanged(t *testing.T) {
 	in := map[string]string{
-		"env":                     "prod",
-		"region":                  "us-east-1",
-		"app.kubernetes.io/name":  "opencost",
-		"my-key":                  "my-value",
-		"a":                       "b",
+		"env":                    "prod",
+		"region":                 "us-east-1",
+		"app.kubernetes.io/name": "opencost",
+		"my-key":                 "my-value",
+		"a":                      "b",
 	}
 	out := filterValidLabels(in)
 	assert.Equal(t, in, out)
@@ -396,8 +396,8 @@ func TestFilterValidLabels_KeyTooLong_EntryDropped(t *testing.T) {
 
 func TestFilterValidLabels_PrefixedKey_Valid(t *testing.T) {
 	in := map[string]string{
-		"app.kubernetes.io/name":      "opencost",
-		"example.com/env":             "staging",
+		"app.kubernetes.io/name": "opencost",
+		"example.com/env":        "staging",
 	}
 	out := filterValidLabels(in)
 	assert.Equal(t, in, out)
@@ -405,7 +405,7 @@ func TestFilterValidLabels_PrefixedKey_Valid(t *testing.T) {
 
 func TestFilterValidLabels_PrefixedKey_InvalidPrefix_EntryDropped(t *testing.T) {
 	in := map[string]string{
-		"valid":          "kept",
+		"valid":         "kept",
 		"-bad.prefix/k": "dropped",
 	}
 	out := filterValidLabels(in)
@@ -414,10 +414,10 @@ func TestFilterValidLabels_PrefixedKey_InvalidPrefix_EntryDropped(t *testing.T)
 
 func TestFilterValidLabels_MixedValidAndInvalid_OnlyValidReturned(t *testing.T) {
 	in := map[string]string{
-		"cluster":    "prod",
-		"":           "no-key",
-		"bad-value":  "-oops",
-		"region":     "eu-west-1",
+		"cluster":   "prod",
+		"":          "no-key",
+		"bad-value": "-oops",
+		"region":    "eu-west-1",
 	}
 	out := filterValidLabels(in)
 	assert.Equal(t, map[string]string{"cluster": "prod", "region": "eu-west-1"}, out)

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

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

+ 8 - 8
core/pkg/model/kubemodel/cluster.go

@@ -4,18 +4,18 @@ import (
 	"fmt"
 	"time"
 
-	"github.com/opencost/opencost/core/pkg/model/shared"
+	"github.com/opencost/opencost/core/pkg/cloud"
 )
 
 // @bingen:generate:Cluster
 type Cluster struct {
-	UID      string          `json:"uid"`      // @bingen:field[version=1]
-	Provider shared.Provider `json:"provider"` // @bingen:field[version=1]
-	Account  string          `json:"account"`  // @bingen:field[version=1]
-	Name     string          `json:"name"`     // @bingen:field[version=1]
-	Region   string          `json:"region"`   // @bingen:field[version=2]
-	Start    time.Time       `json:"start"`    // @bingen:field[version=1]
-	End      time.Time       `json:"end"`      // @bingen:field[version=1]
+	UID      string         `json:"uid"`      // @bingen:field[version=1]
+	Provider cloud.Provider `json:"provider"` // @bingen:field[version=1]
+	Account  string         `json:"account"`  // @bingen:field[version=1]
+	Name     string         `json:"name"`     // @bingen:field[version=1]
+	Region   string         `json:"region"`   // @bingen:field[version=2]
+	Start    time.Time      `json:"start"`    // @bingen:field[version=1]
+	End      time.Time      `json:"end"`      // @bingen:field[version=1]
 }
 
 func (c *Cluster) ValidateCluster(window Window) error {

Fișier diff suprimat deoarece este prea mare
+ 497 - 68
core/pkg/model/kubemodel/kubemodel_codecs.go


+ 2 - 2
core/pkg/model/kubemodel/mock.go

@@ -3,7 +3,7 @@ package kubemodel
 import (
 	"time"
 
-	"github.com/opencost/opencost/core/pkg/model/shared"
+	"github.com/opencost/opencost/core/pkg/cloud"
 )
 
 // NewMockKubeModelSet returns a KubeModelSet populated with one instance of every
@@ -15,7 +15,7 @@ func NewMockKubeModelSet(start, end time.Time) *KubeModelSet {
 	// --- Cluster ---
 	kms.RegisterCluster(&Cluster{
 		UID:      "cluster-uid",
-		Provider: shared.ProviderAWS,
+		Provider: cloud.ProviderAWS,
 		Account:  "123456789012",
 		Name:     "test-cluster",
 		Region:   "us-east-1",

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

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

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

@@ -18,6 +18,7 @@ const (
 	HeartbeatPipelineName         string = "heartbeat"
 	DiagnosticsPipelineName       string = "diagnostics"
 	KubeModelPipelineName         string = "kubemodel"
+	PricingModelPipelineName      string = "pricingmodel"
 )
 
 var nameByType map[string]string

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

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

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

@@ -0,0 +1,42 @@
+package pricing
+
+import (
+	"context"
+	"errors"
+	"sync"
+)
+
+type MemoryPricingStore struct {
+	mu      sync.RWMutex
+	pricing *PricingSet
+}
+
+func NewMemoryPricingStore() *MemoryPricingStore {
+	return &MemoryPricingStore{
+		pricing: &PricingSet{},
+	}
+}
+
+// GetPricingSet returns a deep copy of the stored pricing set so that callers
+// cannot mutate the store's internal state through the returned pointer.
+func (mps *MemoryPricingStore) GetPricingSet(ctx context.Context) (*PricingSet, error) {
+	mps.mu.RLock()
+	defer mps.mu.RUnlock()
+
+	return mps.pricing.Clone(), nil
+}
+
+// SetPricingSet stores a deep copy of the provided pricing set so that the
+// caller cannot mutate the store's internal state after setting it.
+func (mps *MemoryPricingStore) SetPricingSet(ctx context.Context, pricing *PricingSet) error {
+	if pricing == nil {
+		return errors.New("nil pricing")
+	}
+
+	mps.mu.Lock()
+	defer mps.mu.Unlock()
+
+	mps.pricing = pricing.Clone()
+
+	return nil
+}

+ 159 - 0
core/pkg/pricing/memory_test.go

@@ -0,0 +1,159 @@
+package pricing
+
+import (
+	"sync"
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+// TestMemoryGetEmpty verifies that a fresh store returns a non-nil, empty set.
+func TestMemoryGetEmpty(t *testing.T) {
+	mps := NewMemoryPricingStore()
+
+	ps, err := mps.GetPricingSet(t.Context())
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if ps == nil {
+		t.Fatal("expected non-nil pricing set")
+	}
+	if !ps.IsEmpty() {
+		t.Errorf("expected empty pricing set, got %+v", ps)
+	}
+}
+
+// TestMemoryRoundTrip verifies that a set survives a Set/Get round trip while
+// being returned as a distinct copy, not the stored pointer.
+func TestMemoryRoundTrip(t *testing.T) {
+	mps := NewMemoryPricingStore()
+	in := fullPricingSet()
+
+	if err := mps.SetPricingSet(t.Context(), in); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	out, err := mps.GetPricingSet(t.Context())
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	csIn, err := in.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	csOut, err := out.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if csIn != csOut {
+		t.Errorf("round-trip changed contents: %q -> %q", csIn, csOut)
+	}
+
+	if out == in {
+		t.Error("expected Get to return a copy, not the set pointer")
+	}
+}
+
+// TestMemorySetIsolation verifies that mutating the value passed to Set after
+// the call does not affect what the store returns.
+func TestMemorySetIsolation(t *testing.T) {
+	mps := NewMemoryPricingStore()
+	in := fullPricingSet()
+
+	if err := mps.SetPricingSet(t.Context(), in); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	before, err := mps.GetPricingSet(t.Context())
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	csBefore, err := before.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	// Mutate the caller's copy after setting it.
+	in.NodePricing[0].Properties.Labels["team"] = "mutated"
+	in.NodePricing[0].Prices[ResourceNode] = Price{Unit: unit.Hour, Price: 99}
+	in.NodePricing = append(in.NodePricing, nodePricing("m5.xlarge", 0.192))
+
+	after, err := mps.GetPricingSet(t.Context())
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	csAfter, err := after.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if csBefore != csAfter {
+		t.Errorf("mutating the set argument changed store contents: %q -> %q", csBefore, csAfter)
+	}
+}
+
+// TestMemoryGetIsolation verifies that mutating a value returned by Get does not
+// affect what subsequent Get calls return.
+func TestMemoryGetIsolation(t *testing.T) {
+	mps := NewMemoryPricingStore()
+	if err := mps.SetPricingSet(t.Context(), fullPricingSet()); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	got, err := mps.GetPricingSet(t.Context())
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	csBefore, err := got.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	// Mutate the returned copy.
+	got.NodePricing[0].Properties.Labels["team"] = "mutated"
+	got.NodePricing[0].Prices[ResourceNode] = Price{Unit: unit.Hour, Price: 99}
+
+	again, err := mps.GetPricingSet(t.Context())
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	csAfter, err := again.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if csBefore != csAfter {
+		t.Errorf("mutating a returned set changed store contents: %q -> %q", csBefore, csAfter)
+	}
+}
+
+// TestMemorySetNil verifies that Set rejects a nil pricing set.
+func TestMemorySetNil(t *testing.T) {
+	mps := NewMemoryPricingStore()
+	if err := mps.SetPricingSet(t.Context(), nil); err == nil {
+		t.Error("expected error for nil pricing set")
+	}
+}
+
+// TestMemoryConcurrentAccess exercises the store under concurrent readers and
+// writers; run with -race to catch data races.
+func TestMemoryConcurrentAccess(t *testing.T) {
+	mps := NewMemoryPricingStore()
+
+	var wg sync.WaitGroup
+	for i := 0; i < 50; i++ {
+		wg.Add(2)
+		go func() {
+			defer wg.Done()
+			_ = mps.SetPricingSet(t.Context(), fullPricingSet())
+		}()
+		go func() {
+			defer wg.Done()
+			if _, err := mps.GetPricingSet(t.Context()); err != nil {
+				t.Errorf("unexpected error: %v", err)
+			}
+		}()
+	}
+	wg.Wait()
+}

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

@@ -0,0 +1,152 @@
+package pricing
+
+import (
+	"context"
+	"embed"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"github.com/opencost/opencost/core/pkg/reader"
+	"gopkg.in/yaml.v3"
+)
+
+// MockPricingModule must satisfy the PricingModule interface
+var _ PricingModule = (*MockPricingModule)(nil)
+
+type MockPricingModule struct {
+	ClusterPricing          []*ClusterPricing
+	NetworkPricing          []*NetworkPricing
+	NodePricing             []*NodePricing
+	PersistentVolumePricing []*PersistentVolumePricing
+	ServicePricing          []*ServicePricing
+}
+
+func NewMockPricingModule() (*MockPricingModule, error) {
+	mpm := &MockPricingModule{
+		ClusterPricing:          []*ClusterPricing{},
+		NetworkPricing:          []*NetworkPricing{},
+		NodePricing:             []*NodePricing{},
+		PersistentVolumePricing: []*PersistentVolumePricing{},
+		ServicePricing:          []*ServicePricing{},
+	}
+
+	// Default
+	err := mpm.loadTestFile("default.yaml")
+	if err != nil {
+		return nil, fmt.Errorf("error loading test default pricing: %w", err)
+	}
+
+	// AWS
+	err = mpm.loadTestFile("aws.yaml")
+	if err != nil {
+		return nil, fmt.Errorf("error loading test AWS pricing: %w", err)
+	}
+
+	// Azure
+	err = mpm.loadTestFile("azure.yaml")
+	if err != nil {
+		return nil, fmt.Errorf("error loading test Azure pricing: %w", err)
+	}
+
+	// GCP
+	err = mpm.loadTestFile("gcp.yaml")
+	if err != nil {
+		return nil, fmt.Errorf("error loading test GCP pricing: %w", err)
+	}
+
+	return mpm, nil
+}
+
+func (mpm *MockPricingModule) NewClusterPricingReader(ctx context.Context) (reader.Reader[*ClusterPricing], error) {
+	return reader.NewSliceReader(mpm.ClusterPricing), nil
+}
+
+func (mpm *MockPricingModule) NewNetworkPricingReader(ctx context.Context) (reader.Reader[*NetworkPricing], error) {
+	return reader.NewSliceReader(mpm.NetworkPricing), nil
+}
+
+func (mpm *MockPricingModule) NewNodePricingReader(ctx context.Context) (reader.Reader[*NodePricing], error) {
+	return reader.NewSliceReader(mpm.NodePricing), nil
+}
+
+func (mpm *MockPricingModule) NewPersistentVolumePricingReader(ctx context.Context) (reader.Reader[*PersistentVolumePricing], error) {
+	return reader.NewSliceReader(mpm.PersistentVolumePricing), nil
+}
+
+func (mpm *MockPricingModule) NewServicePricingReader(ctx context.Context) (reader.Reader[*ServicePricing], error) {
+	return reader.NewSliceReader(mpm.ServicePricing), nil
+}
+
+func (mpm *MockPricingModule) GetPricingSet(ctx context.Context) (*PricingSet, error) {
+	ps := &PricingSet{
+		ClusterPricing:          mpm.ClusterPricing,
+		NetworkPricing:          mpm.NetworkPricing,
+		NodePricing:             mpm.NodePricing,
+		PersistentVolumePricing: mpm.PersistentVolumePricing,
+		ServicePricing:          mpm.ServicePricing,
+	}
+
+	return ps, nil
+}
+
+func (mpm *MockPricingModule) SourceKind() string {
+	return "test"
+}
+
+func (mpm *MockPricingModule) SourceName() string {
+	return "mock"
+}
+
+func (mpm *MockPricingModule) Checksum(ctx context.Context) (string, error) {
+	ps, err := mpm.GetPricingSet(ctx)
+	if err != nil {
+		return "", fmt.Errorf("getting pricing set: %w", err)
+	}
+
+	return ps.Checksum()
+}
+
+//go:embed test/*
+var pricingTestFS embed.FS
+
+func (mpm *MockPricingModule) loadTestFile(filename string) error {
+	path := filepath.Join("test", filename)
+	bs, err := pricingTestFS.ReadFile(path)
+	if err != nil {
+		return fmt.Errorf("failed to read embedded pricing file: %w", err)
+	}
+
+	var set *PricingSet
+
+	// Detect file format based on extension
+	ext := strings.ToLower(filepath.Ext(filename))
+	switch ext {
+	case ".json":
+		err = json.Unmarshal(bs, &set)
+		if err != nil {
+			return fmt.Errorf("failed to parse json: %w", err)
+		}
+	case ".yaml", ".yml":
+		err = yaml.Unmarshal(bs, &set)
+		if err != nil {
+			return fmt.Errorf("failed to parse yaml: %w", err)
+		}
+	default:
+		return fmt.Errorf("unsupported file format: %s (expected .json, .yaml, or .yml)", ext)
+	}
+
+	if set == nil {
+		return errors.New("nil set")
+	}
+
+	mpm.ClusterPricing = append(mpm.ClusterPricing, set.ClusterPricing...)
+	mpm.NetworkPricing = append(mpm.NetworkPricing, set.NetworkPricing...)
+	mpm.NodePricing = append(mpm.NodePricing, set.NodePricing...)
+	mpm.PersistentVolumePricing = append(mpm.PersistentVolumePricing, set.PersistentVolumePricing...)
+	mpm.ServicePricing = append(mpm.ServicePricing, set.ServicePricing...)
+
+	return nil
+}

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

@@ -0,0 +1,163 @@
+package pricing
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/reader"
+)
+
+func TestMockPricingModule(t *testing.T) {
+	var source PricingSource
+
+	pricingModule, err := NewMockPricingModule()
+	if err != nil {
+		t.Fatalf("unexpected error initializing mock repository: %s", err)
+	}
+
+	source = pricingModule
+
+	// Simple example of a sink for pricing data (will be database tables in reality)
+	bufferSize := 10
+	ingestor := newMockIngestor(bufferSize)
+
+	// Test ingestion of mock node reader
+
+	nodePricingReader, err := source.NewNodePricingReader(t.Context())
+	if err != nil {
+		t.Errorf("unexpected error initializing node reader: %s", err)
+	}
+
+	n, err := ingestor.ingestNodePricing(context.Background(), nodePricingReader)
+	if err != nil {
+		t.Errorf("unexpected error ingesting node pricing: %s", err)
+	}
+	if n != 39 {
+		t.Errorf("expected to ingest %d node pricing records; ingested %d", 39, n)
+	}
+
+	nodePricingCount := ingestor.countNodePricing()
+	if nodePricingCount != 39 {
+		t.Errorf("expected %d node pricing records; received %d", 39, nodePricingCount)
+	}
+
+	// Test ingestion of mock persistent volume reader
+
+	volumePricingReader, err := source.NewPersistentVolumePricingReader(t.Context())
+	if err != nil {
+		t.Errorf("unexpected error initializing volume reader: %s", err)
+	}
+
+	n, err = ingestor.ingestPersistentVolumePricing(context.Background(), volumePricingReader)
+	if err != nil {
+		t.Errorf("unexpected error ingesting volume pricing: %s", err)
+	}
+	if n != 20 {
+		t.Errorf("expected to ingest %d volume pricing records; ingested %d", 20, n)
+	}
+
+	volumePricingCount := ingestor.countVolumePricing()
+	if volumePricingCount != 20 {
+		t.Errorf("expected %d volume pricing records; received %d", 20, volumePricingCount)
+	}
+}
+
+// newMock is a helper that constructs a fresh MockPricingModule and fails the
+// test if construction errors.
+func newMock(t *testing.T) *MockPricingModule {
+	t.Helper()
+	mpm, err := NewMockPricingModule()
+	if err != nil {
+		t.Fatalf("unexpected error initializing mock pricing module: %v", err)
+	}
+	return mpm
+}
+
+type mockPricingIngestor struct {
+	bufferSize              int
+	clusterPricing          []*ClusterPricing
+	networkPricing          []*NetworkPricing
+	nodePricing             []*NodePricing
+	persistentVolumePricing []*PersistentVolumePricing
+	servicePricing          []*ServicePricing
+}
+
+func newMockIngestor(bufferSize int) *mockPricingIngestor {
+	if bufferSize == 0 {
+		bufferSize = 100
+	}
+
+	return &mockPricingIngestor{
+		bufferSize:              bufferSize,
+		clusterPricing:          []*ClusterPricing{},
+		networkPricing:          []*NetworkPricing{},
+		nodePricing:             []*NodePricing{},
+		persistentVolumePricing: []*PersistentVolumePricing{},
+		servicePricing:          []*ServicePricing{},
+	}
+}
+
+func (ing *mockPricingIngestor) countNodePricing() int {
+	return len(ing.nodePricing)
+}
+
+func (ing *mockPricingIngestor) ingestNodePricing(ctx context.Context, pricingReader reader.Reader[*NodePricing]) (int, error) {
+	defer pricingReader.Close()
+
+	nodeBuf := make([]*NodePricing, ing.bufferSize)
+
+	totalCount := 0
+
+	for {
+		n, err := pricingReader.Read(ctx, nodeBuf)
+
+		if n > 0 {
+			ing.nodePricing = append(ing.nodePricing, nodeBuf[:n]...)
+			totalCount += n
+		}
+
+		if errors.Is(err, io.EOF) {
+			break
+		}
+
+		if err != nil {
+			return totalCount, fmt.Errorf("unexpected error reading node pricing: %s", err)
+		}
+	}
+
+	return totalCount, nil
+}
+
+func (ing *mockPricingIngestor) countVolumePricing() int {
+	return len(ing.persistentVolumePricing)
+}
+
+func (ing *mockPricingIngestor) ingestPersistentVolumePricing(ctx context.Context, pricingReader reader.Reader[*PersistentVolumePricing]) (int, error) {
+	defer pricingReader.Close()
+
+	volBuf := make([]*PersistentVolumePricing, ing.bufferSize)
+
+	totalCount := 0
+
+	for {
+		n, err := pricingReader.Read(ctx, volBuf)
+
+		if n > 0 {
+			ing.persistentVolumePricing = append(ing.persistentVolumePricing, volBuf[:n]...)
+			totalCount += n
+		}
+
+		if errors.Is(err, io.EOF) {
+			break
+		}
+
+		if err != nil {
+			return totalCount, fmt.Errorf("unexpected error reading volume pricing: %s", err)
+		}
+	}
+
+	return totalCount, nil
+}

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,250 @@
+package pricing
+
+import (
+	"cmp"
+	"encoding/hex"
+	"fmt"
+	"hash/fnv"
+	"slices"
+	"time"
+)
+
+type PricingSet struct {
+	ClusterPricing          []*ClusterPricing          `json:"clusterPricing" yaml:"clusterPricing"`
+	NetworkPricing          []*NetworkPricing          `json:"networkPricing" yaml:"networkPricing"`
+	NodePricing             []*NodePricing             `json:"nodePricing" yaml:"nodePricing"`
+	PersistentVolumePricing []*PersistentVolumePricing `json:"persistentVolumePricing" yaml:"persistentVolumePricing"`
+	ServicePricing          []*ServicePricing          `json:"servicePricing" yaml:"servicePricing"`
+}
+
+func (ps *PricingSet) IsEmpty() bool {
+	if ps == nil {
+		return true
+	}
+
+	return len(ps.ClusterPricing) == 0 &&
+		len(ps.NetworkPricing) == 0 &&
+		len(ps.NodePricing) == 0 &&
+		len(ps.PersistentVolumePricing) == 0 &&
+		len(ps.ServicePricing) == 0
+}
+
+// Checksum returns a hash that is stable across map and slice ordering and
+// sensitive to both pricing properties and price values.
+//
+// TODO: Consider commutative hash folding via a multiset hashing algorithm
+// if the string-based Checksum() implementation is too resource intensive
+// for large pricing sets. For now, this string version is more readable.
+func (ps *PricingSet) Checksum() (string, error) {
+	if ps == nil {
+		ps = &PricingSet{}
+	}
+
+	// Each item's String() is prefixed with its kind so that items of
+	// different kinds cannot collide, then all keys are sorted to make the
+	// hash independent of input ordering.
+	keys := make([]string, 0,
+		len(ps.ClusterPricing)+
+			len(ps.NetworkPricing)+
+			len(ps.NodePricing)+
+			len(ps.PersistentVolumePricing)+
+			len(ps.ServicePricing))
+
+	for _, cp := range ps.ClusterPricing {
+		keys = append(keys, "cluster:"+cp.String())
+	}
+	for _, np := range ps.NetworkPricing {
+		keys = append(keys, "network:"+np.String())
+	}
+	for _, np := range ps.NodePricing {
+		keys = append(keys, "node:"+np.String())
+	}
+	for _, pvp := range ps.PersistentVolumePricing {
+		keys = append(keys, "persistentvolume:"+pvp.String())
+	}
+	for _, sp := range ps.ServicePricing {
+		keys = append(keys, "service:"+sp.String())
+	}
+
+	slices.Sort(keys)
+
+	hasher := fnv.New64a()
+	for _, key := range keys {
+		if _, err := hasher.Write([]byte(key)); err != nil {
+			return "", fmt.Errorf("fnv hash: %w", err)
+		}
+	}
+
+	return hex.EncodeToString(hasher.Sum(nil)), nil
+}
+
+// Clone returns a deep copy of the PricingSet. Mutating the returned set
+// (including its nested slices, maps, and time pointers) does not affect the
+// original, and vice versa. A nil receiver returns an empty, non-nil set.
+func (ps *PricingSet) Clone() *PricingSet {
+	if ps == nil {
+		return &PricingSet{}
+	}
+
+	return &PricingSet{
+		ClusterPricing:          cloneSlice(ps.ClusterPricing, (*ClusterPricing).clone),
+		NetworkPricing:          cloneSlice(ps.NetworkPricing, (*NetworkPricing).clone),
+		NodePricing:             cloneSlice(ps.NodePricing, (*NodePricing).clone),
+		PersistentVolumePricing: cloneSlice(ps.PersistentVolumePricing, (*PersistentVolumePricing).clone),
+		ServicePricing:          cloneSlice(ps.ServicePricing, (*ServicePricing).clone),
+	}
+}
+
+// cloneSlice deep-copies a slice of pointers using the provided element clone
+// function, preserving a nil source slice as nil.
+func cloneSlice[T any](src []*T, clone func(*T) *T) []*T {
+	if src == nil {
+		return nil
+	}
+
+	dst := make([]*T, len(src))
+	for i, e := range src {
+		dst[i] = clone(e)
+	}
+
+	return dst
+}
+
+func (cp *ClusterPricing) clone() *ClusterPricing {
+	if cp == nil {
+		return nil
+	}
+
+	clone := *cp
+	clone.Properties.Start = cloneTime(cp.Properties.Start)
+	clone.Properties.End = cloneTime(cp.Properties.End)
+	clone.Prices = clonePrices(cp.Prices)
+
+	return &clone
+}
+
+func (np *NetworkPricing) clone() *NetworkPricing {
+	if np == nil {
+		return nil
+	}
+
+	clone := *np
+	clone.Properties.Start = cloneTime(np.Properties.Start)
+	clone.Properties.End = cloneTime(np.Properties.End)
+	clone.Prices = clonePrices(np.Prices)
+
+	return &clone
+}
+
+func (np *NodePricing) clone() *NodePricing {
+	if np == nil {
+		return nil
+	}
+
+	clone := *np
+	clone.Properties.Labels = cloneLabels(np.Properties.Labels)
+	clone.Properties.Start = cloneTime(np.Properties.Start)
+	clone.Properties.End = cloneTime(np.Properties.End)
+	clone.Prices = clonePrices(np.Prices)
+
+	return &clone
+}
+
+func (pvp *PersistentVolumePricing) clone() *PersistentVolumePricing {
+	if pvp == nil {
+		return nil
+	}
+
+	clone := *pvp
+	clone.Properties.Labels = cloneLabels(pvp.Properties.Labels)
+	clone.Properties.Start = cloneTime(pvp.Properties.Start)
+	clone.Properties.End = cloneTime(pvp.Properties.End)
+	clone.Prices = clonePrices(pvp.Prices)
+
+	return &clone
+}
+
+func (sp *ServicePricing) clone() *ServicePricing {
+	if sp == nil {
+		return nil
+	}
+
+	clone := *sp
+	clone.Properties.Start = cloneTime(sp.Properties.Start)
+	clone.Properties.End = cloneTime(sp.Properties.End)
+	clone.Prices = clonePrices(sp.Prices)
+
+	return &clone
+}
+
+// clonePrices returns a deep copy of a Prices map, preserving nil as nil. Price
+// values contain no reference fields, so a shallow value copy per entry is safe.
+func clonePrices(src Prices) Prices {
+	if src == nil {
+		return nil
+	}
+
+	dst := make(Prices, len(src))
+	for k, v := range src {
+		dst[k] = v
+	}
+
+	return dst
+}
+
+// cloneLabels returns a deep copy of a labels map, preserving nil as nil.
+func cloneLabels(src map[string]string) map[string]string {
+	if src == nil {
+		return nil
+	}
+
+	dst := make(map[string]string, len(src))
+	for k, v := range src {
+		dst[k] = v
+	}
+
+	return dst
+}
+
+// cloneTime returns a copy of the time pointer, preserving nil as nil.
+func cloneTime(src *time.Time) *time.Time {
+	if src == nil {
+		return nil
+	}
+
+	t := *src
+
+	return &t
+}
+
+// Sort sorts the pricing data to ensure deterministic serialization.
+func (ps *PricingSet) Sort() {
+	if ps == nil {
+		return
+	}
+
+	// Sort clusters
+	slices.SortFunc(ps.ClusterPricing, func(a, b *ClusterPricing) int {
+		return cmp.Compare(a.String(), b.String())
+	})
+
+	// Sort network
+	slices.SortFunc(ps.NetworkPricing, func(a, b *NetworkPricing) int {
+		return cmp.Compare(a.String(), b.String())
+	})
+
+	// Sort nodes
+	slices.SortFunc(ps.NodePricing, func(a, b *NodePricing) int {
+		return cmp.Compare(a.String(), b.String())
+	})
+
+	// Sort persistent volumes
+	slices.SortFunc(ps.PersistentVolumePricing, func(a, b *PersistentVolumePricing) int {
+		return cmp.Compare(a.String(), b.String())
+	})
+
+	// Sort services
+	slices.SortFunc(ps.ServicePricing, func(a, b *ServicePricing) int {
+		return cmp.Compare(a.String(), b.String())
+	})
+}

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

@@ -0,0 +1,256 @@
+package pricing
+
+import (
+	"testing"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/cloud"
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+func nodePricing(instanceType string, price float64) *NodePricing {
+	return &NodePricing{
+		Properties: NodePricingProperties{
+			Provider:     cloud.ProviderAWS,
+			Region:       "us-east-1",
+			InstanceType: instanceType,
+		},
+		Prices: Prices{
+			ResourceNode: {Unit: unit.Hour, Price: price},
+		},
+	}
+}
+
+func pvPricing(volumeType VolumeType, price float64) *PersistentVolumePricing {
+	return &PersistentVolumePricing{
+		Properties: PersistentVolumePricingProperties{
+			Provider:   cloud.ProviderAWS,
+			Region:     "us-east-1",
+			VolumeType: volumeType,
+		},
+		Prices: Prices{
+			ResourceStorage: {Unit: unit.GiBHour, Price: price},
+		},
+	}
+}
+
+// TestChecksumPriceSensitivity verifies that the checksum changes when only a
+// price value changes, even if all properties are identical.
+func TestChecksumPriceSensitivity(t *testing.T) {
+	a := &PricingSet{NodePricing: []*NodePricing{nodePricing("m5.large", 0.096)}}
+	b := &PricingSet{NodePricing: []*NodePricing{nodePricing("m5.large", 0.192)}}
+
+	csA, err := a.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	csB, err := b.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if csA == csB {
+		t.Errorf("expected differing checksums for differing prices, got %q for both", csA)
+	}
+}
+
+// TestChecksumOrderStability verifies that the checksum is independent of the
+// ordering of pricing slices.
+func TestChecksumOrderStability(t *testing.T) {
+	n1 := nodePricing("m5.large", 0.096)
+	n2 := nodePricing("m5.xlarge", 0.192)
+	n3 := nodePricing("m5.2xlarge", 0.384)
+
+	forward := &PricingSet{NodePricing: []*NodePricing{n1, n2, n3}}
+	reverse := &PricingSet{NodePricing: []*NodePricing{n3, n2, n1}}
+
+	csForward, err := forward.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	csReverse, err := reverse.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if csForward != csReverse {
+		t.Errorf("expected checksum to be order-independent, got %q vs %q", csForward, csReverse)
+	}
+}
+
+// TestChecksumNilReceiver verifies that Checksum handles a nil receiver like
+// IsEmpty and Currencies do, rather than panicking.
+func TestChecksumNilReceiver(t *testing.T) {
+	var ps *PricingSet
+	if _, err := ps.Checksum(); err != nil {
+		t.Errorf("unexpected error on nil receiver: %v", err)
+	}
+}
+
+// TestIsEmptyAllKinds verifies that a set holding only Cluster/Network/Service
+// pricing is not reported empty.
+func TestIsEmptyAllKinds(t *testing.T) {
+	if !(&PricingSet{}).IsEmpty() {
+		t.Errorf("expected empty set to report empty")
+	}
+
+	cases := map[string]*PricingSet{
+		"cluster": {ClusterPricing: []*ClusterPricing{{Properties: ClusterPricingProperties{Provider: cloud.ProviderAWS}}}},
+		"network": {NetworkPricing: []*NetworkPricing{{Properties: NetworkPricingProperties{Provider: cloud.ProviderAWS}}}},
+		"node":    {NodePricing: []*NodePricing{nodePricing("m5.large", 0.096)}},
+		"volume":  {PersistentVolumePricing: []*PersistentVolumePricing{pvPricing(VolumeTypeGP3, 0.0001)}},
+		"service": {ServicePricing: []*ServicePricing{{Properties: ServicePricingProperties{Provider: cloud.ProviderAWS}}}},
+	}
+
+	for name, ps := range cases {
+		if ps.IsEmpty() {
+			t.Errorf("set with only %s pricing should not be empty", name)
+		}
+	}
+}
+
+// fullPricingSet returns a PricingSet that exercises every kind plus every
+// reference-typed field (Labels maps, Prices maps, and *time.Time pointers) so
+// that Clone independence can be verified end to end.
+func fullPricingSet() *PricingSet {
+	start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+	end := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
+
+	node := nodePricing("m5.large", 0.096)
+	node.Properties.Labels = map[string]string{"team": "platform"}
+	node.Properties.Start = &start
+	node.Properties.End = &end
+
+	pv := pvPricing(VolumeTypeGP3, 0.0001)
+	pv.Properties.Labels = map[string]string{"env": "prod"}
+	pv.Properties.Start = &start
+
+	return &PricingSet{
+		ClusterPricing: []*ClusterPricing{{
+			Properties: ClusterPricingProperties{Provider: cloud.ProviderAWS, Start: &start},
+			Prices:     Prices{ResourceCluster: {Unit: unit.Hour, Price: 1.0}},
+		}},
+		NetworkPricing: []*NetworkPricing{{
+			Properties: NetworkPricingProperties{Provider: cloud.ProviderAWS, End: &end},
+			Prices:     Prices{ResourceInternetEgress: {Unit: unit.GiB, Price: 0.09}},
+		}},
+		NodePricing:             []*NodePricing{node},
+		PersistentVolumePricing: []*PersistentVolumePricing{pv},
+		ServicePricing: []*ServicePricing{{
+			Properties: ServicePricingProperties{Provider: cloud.ProviderAWS, Region: "us-east-1", Start: &start},
+			Prices:     Prices{ResourceService: {Unit: unit.Hour, Price: 0.025}},
+		}},
+	}
+}
+
+// TestCloneEquality verifies that a clone is equal to the original by checksum.
+func TestCloneEquality(t *testing.T) {
+	orig := fullPricingSet()
+	clone := orig.Clone()
+
+	csOrig, err := orig.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	csClone, err := clone.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if csOrig != csClone {
+		t.Errorf("expected clone checksum %q to equal original %q", csClone, csOrig)
+	}
+}
+
+// TestCloneIndependence verifies that mutating a clone's nested slices, maps,
+// and time pointers does not affect the original.
+func TestCloneIndependence(t *testing.T) {
+	orig := fullPricingSet()
+
+	csBefore, err := orig.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	clone := orig.Clone()
+
+	// Mutate every reference-typed field reachable from the clone.
+	clone.NodePricing[0].Properties.Labels["team"] = "mutated"
+	clone.NodePricing[0].Prices[ResourceNode] = Price{Unit: unit.Hour, Price: 99}
+	*clone.NodePricing[0].Properties.Start = time.Date(1999, 1, 1, 0, 0, 0, 0, time.UTC)
+	clone.NodePricing[0].Properties.End = nil
+	clone.PersistentVolumePricing[0].Properties.Labels["env"] = "mutated"
+	clone.PersistentVolumePricing[0].Prices[ResourceStorage] = Price{Unit: unit.GiBHour, Price: 99}
+	clone.ClusterPricing[0].Prices[ResourceCluster] = Price{Unit: unit.Hour, Price: 99}
+	clone.NetworkPricing[0].Prices[ResourceInternetEgress] = Price{Unit: unit.GiB, Price: 99}
+	clone.ServicePricing[0].Prices[ResourceService] = Price{Unit: unit.Hour, Price: 99}
+
+	// Replace whole slices to confirm the slice headers are independent too.
+	clone.NodePricing = append(clone.NodePricing, nodePricing("m5.xlarge", 0.192))
+
+	csAfter, err := orig.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if csBefore != csAfter {
+		t.Errorf("mutating clone changed original: checksum %q -> %q", csBefore, csAfter)
+	}
+}
+
+// TestCloneNilReceiver verifies that Clone handles a nil receiver by returning
+// an empty, non-nil set rather than panicking.
+func TestCloneNilReceiver(t *testing.T) {
+	var ps *PricingSet
+	clone := ps.Clone()
+
+	if clone == nil {
+		t.Fatal("expected non-nil clone from nil receiver")
+	}
+	if !clone.IsEmpty() {
+		t.Errorf("expected empty clone from nil receiver")
+	}
+}
+
+// TestClonePreservesNilSlices verifies that Clone does not turn nil pricing
+// slices into empty ones, keeping serialization semantics stable.
+func TestClonePreservesNilSlices(t *testing.T) {
+	clone := (&PricingSet{}).Clone()
+
+	if clone.NodePricing != nil {
+		t.Errorf("expected nil NodePricing slice, got %v", clone.NodePricing)
+	}
+	if clone.ClusterPricing != nil {
+		t.Errorf("expected nil ClusterPricing slice, got %v", clone.ClusterPricing)
+	}
+}
+
+// TestMockGetPricingSetAllKinds verifies that the mock's GetPricingSet exposes
+// the same kinds as its readers, not just node + persistent volume.
+func TestMockGetPricingSetAllKinds(t *testing.T) {
+	mpm, err := NewMockPricingModule()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	ps, err := mpm.GetPricingSet(t.Context())
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if len(ps.NodePricing) != len(mpm.NodePricing) {
+		t.Errorf("expected %d node pricing, got %d", len(mpm.NodePricing), len(ps.NodePricing))
+	}
+	if len(ps.PersistentVolumePricing) != len(mpm.PersistentVolumePricing) {
+		t.Errorf("expected %d volume pricing, got %d", len(mpm.PersistentVolumePricing), len(ps.PersistentVolumePricing))
+	}
+	if len(ps.ClusterPricing) != len(mpm.ClusterPricing) {
+		t.Errorf("expected %d cluster pricing, got %d", len(mpm.ClusterPricing), len(ps.ClusterPricing))
+	}
+	if len(ps.NetworkPricing) != len(mpm.NetworkPricing) {
+		t.Errorf("expected %d network pricing, got %d", len(mpm.NetworkPricing), len(ps.NetworkPricing))
+	}
+	if len(ps.ServicePricing) != len(mpm.ServicePricing) {
+		t.Errorf("expected %d service pricing, got %d", len(mpm.ServicePricing), len(ps.ServicePricing))
+	}
+}

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

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

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

@@ -0,0 +1,58 @@
+package pricing
+
+import (
+	"fmt"
+	"strings"
+)
+
+type Resource string
+
+const (
+	ResourceNil               Resource = ""
+	ResourceNode              Resource = "node"
+	ResourceCPU               Resource = "cpu"
+	ResourceRAM               Resource = "ram"
+	ResourceGPU               Resource = "gpu"
+	ResourceStorage           Resource = "storage"
+	ResourceCluster           Resource = "cluster"
+	ResourceService           Resource = "service"
+	ResourceLocalEgress       Resource = "local-egress"
+	ResourceCrossZoneEgress   Resource = "x-zone-egress"
+	ResourceCrossRegionEgress Resource = "x-region-egress"
+	ResourceInternetEgress    Resource = "internet-egress"
+	ResourceNATGatewayEgress  Resource = "nat-egress"
+	ResourceNATGatewayIngress Resource = "nat-ingress"
+)
+
+func ParseResource(str string) (Resource, error) {
+	switch strings.ToLower(str) {
+	case string(ResourceNode):
+		return ResourceNode, nil
+	case string(ResourceCPU):
+		return ResourceCPU, nil
+	case string(ResourceRAM):
+		return ResourceRAM, nil
+	case string(ResourceGPU):
+		return ResourceGPU, nil
+	case string(ResourceStorage):
+		return ResourceStorage, nil
+	case string(ResourceCluster):
+		return ResourceCluster, nil
+	case string(ResourceService):
+		return ResourceService, nil
+	case string(ResourceLocalEgress):
+		return ResourceLocalEgress, nil
+	case string(ResourceCrossZoneEgress):
+		return ResourceCrossZoneEgress, nil
+	case string(ResourceCrossRegionEgress):
+		return ResourceCrossRegionEgress, nil
+	case string(ResourceInternetEgress):
+		return ResourceInternetEgress, nil
+	case string(ResourceNATGatewayEgress):
+		return ResourceNATGatewayEgress, nil
+	case string(ResourceNATGatewayIngress):
+		return ResourceNATGatewayIngress, nil
+	default:
+		return ResourceNil, fmt.Errorf("unknown resource %q", str)
+	}
+}

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

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

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

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

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

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

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

@@ -0,0 +1,139 @@
+package pricing
+
+import (
+	"sync"
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/storage"
+)
+
+// TestStorageNewValidation verifies the constructor's argument validation and
+// that a non-existent path is auto-initialized with an empty pricing set.
+func TestStorageNewValidation(t *testing.T) {
+	if _, err := NewStoragePricingStore(t.Context(), nil, "pricing.json"); err == nil {
+		t.Error("expected error for nil storage")
+	}
+
+	if _, err := NewStoragePricingStore(t.Context(), storage.NewMemoryStorage(), ""); err == nil {
+		t.Error("expected error for empty path")
+	}
+
+	store := storage.NewMemoryStorage()
+	sps, err := NewStoragePricingStore(t.Context(), store, "pricing.json")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	exists, err := store.Exists("pricing.json")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if !exists {
+		t.Error("expected constructor to initialize the pricing path")
+	}
+
+	ps, err := sps.GetPricingSet(t.Context())
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if !ps.IsEmpty() {
+		t.Errorf("expected auto-initialized set to be empty, got %+v", ps)
+	}
+}
+
+// TestStorageRoundTrip verifies that a set survives a Set/Get round trip through
+// the backing storage.
+func TestStorageRoundTrip(t *testing.T) {
+	sps, err := NewStoragePricingStore(t.Context(), storage.NewMemoryStorage(), "pricing.json")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	in := fullPricingSet()
+	if err := sps.SetPricingSet(t.Context(), in); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	out, err := sps.GetPricingSet(t.Context())
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	csIn, err := in.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	csOut, err := out.Checksum()
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if csIn != csOut {
+		t.Errorf("round-trip changed contents: %q -> %q", csIn, csOut)
+	}
+}
+
+// TestStorageSetNil verifies that Set rejects a nil pricing set.
+func TestStorageSetNil(t *testing.T) {
+	sps, err := NewStoragePricingStore(t.Context(), storage.NewMemoryStorage(), "pricing.json")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	if err := sps.SetPricingSet(t.Context(), nil); err == nil {
+		t.Error("expected error for nil pricing set")
+	}
+}
+
+// TestStorageGetMissingPath verifies that Get surfaces a read error when the
+// backing path does not exist. The store is constructed directly to bypass the
+// constructor's auto-initialization.
+func TestStorageGetMissingPath(t *testing.T) {
+	sps := &StoragePricingStore{
+		store: storage.NewMemoryStorage(),
+		path:  "missing.json",
+	}
+
+	if _, err := sps.GetPricingSet(t.Context()); err == nil {
+		t.Error("expected error reading a missing path")
+	}
+}
+
+// TestStorageGetMalformedData verifies that Get surfaces a decode error when the
+// backing data is not valid JSON.
+func TestStorageGetMalformedData(t *testing.T) {
+	store := storage.NewMemoryStorage()
+	if err := store.Write("pricing.json", []byte("not json")); err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	sps := &StoragePricingStore{store: store, path: "pricing.json"}
+
+	if _, err := sps.GetPricingSet(t.Context()); err == nil {
+		t.Error("expected error decoding malformed pricing data")
+	}
+}
+
+// TestStorageConcurrentAccess exercises the store under concurrent readers and
+// writers; run with -race to catch data races.
+func TestStorageConcurrentAccess(t *testing.T) {
+	sps, err := NewStoragePricingStore(t.Context(), storage.NewMemoryStorage(), "pricing.json")
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+
+	var wg sync.WaitGroup
+	for i := 0; i < 50; i++ {
+		wg.Add(2)
+		go func() {
+			defer wg.Done()
+			_ = sps.SetPricingSet(t.Context(), fullPricingSet())
+		}()
+		go func() {
+			defer wg.Done()
+			if _, err := sps.GetPricingSet(t.Context()); err != nil {
+				t.Errorf("unexpected error: %v", err)
+			}
+		}()
+	}
+	wg.Wait()
+}

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

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

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

@@ -0,0 +1,195 @@
+nodePricing:
+  - properties:
+      provider: AWS
+      region: us-east-1
+      instanceType: m5.large
+      provisioning: on-demand
+    prices:
+      node:
+        unit: hr
+        price: 0.096
+  - properties:
+      provider: AWS
+      region: us-east-1
+      instanceType: m5.large
+      provisioning: spot
+    prices:
+      node:
+        unit: hr
+        price: 0.043
+  - properties:
+      provider: AWS
+      region: us-east-1
+      instanceType: m5.xlarge
+      provisioning: on-demand
+    prices:
+      node:
+        unit: hr
+        price: 0.192
+  - properties:
+      provider: AWS
+      region: us-east-1
+      instanceType: m5.xlarge
+      provisioning: spot
+    prices:
+      node:
+        unit: hr
+        price: 0.192
+  - properties:
+      provider: AWS
+      region: us-east-1
+      instanceType: m5.2xlarge
+      provisioning: on-demand
+    prices:
+      node:
+        unit: hr
+        price: 0.384
+  - properties:
+      provider: AWS
+      region: us-east-1
+      instanceType: m5.2xlarge
+      provisioning: spot
+    prices:
+      node:
+        unit: hr
+        price: 0.189
+  - properties:
+      provider: AWS
+      region: us-west-1
+      instanceType: m5.large
+      provisioning: on-demand
+    prices:
+      node:
+        unit: hr
+        price: 0.112
+  - properties:
+      provider: AWS
+      region: us-west-1
+      instanceType: m5.large
+      provisioning: spot
+    prices:
+      node:
+        unit: hr
+        price: 0.037
+  - properties:
+      provider: AWS
+      region: us-west-1
+      instanceType: m5.xlarge
+      provisioning: on-demand
+    prices:
+      node:
+        unit: hr
+        price: 0.224
+  - properties:
+      provider: AWS
+      region: us-west-1
+      instanceType: m5.xlarge
+      provisioning: spot
+    prices:
+      node:
+        unit: hr
+        price: 0.069
+  - properties:
+      provider: AWS
+      region: us-west-1
+      instanceType: m5.2xlarge
+      provisioning: on-demand
+    prices:
+      node:
+        unit: hr
+        price: 0.448
+  - properties:
+      provider: AWS
+      region: us-west-1
+      instanceType: m5.2xlarge
+      provisioning: spot
+    prices:
+      node:
+        unit: hr
+        price: 0.152
+persistentVolumePricing:
+  - properties:
+      provider: AWS
+      region: us-east-1
+      volumeType: gp3
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0001096
+  - properties:
+      provider: AWS
+      region: us-east-1
+      volumeType: gp2
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.000137
+  - properties:
+      provider: AWS
+      region: us-east-1
+      volumeType: standard
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0000205
+  - properties:
+      provider: AWS
+      region: us-west-1
+      volumeType: gp3
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0001315
+  - properties:
+      provider: AWS
+      region: us-west-1
+      volumeType: gp2
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0001644
+  - properties:
+      provider: AWS
+      region: us-west-1
+      volumeType: standard
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0000247
+clusterPricing:
+  - properties:
+      provider: AWS
+    prices:
+      cluster:
+        unit: hr
+        price: 0.10
+networkPricing:
+  - properties:
+      provider: AWS
+    prices:
+      local-egress:
+        unit: GiB
+        price: 0.0
+      x-zone-egress:
+        unit: GiB
+        price: 0.01
+      x-region-egress:
+        unit: GiB
+        price: 0.02
+      internet-egress:
+        unit: GiB
+        price: 0.09
+      nat-egress:
+        unit: GiB
+        price: 0.045
+      nat-ingress:
+        unit: GiB
+        price: 0.045
+servicePricing:
+  - properties:
+      provider: AWS
+      region: us-east-1
+    prices:
+      service:
+        unit: hr
+        price: 0.025

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

@@ -0,0 +1,180 @@
+nodePricing:
+  - properties:
+      provider: Azure
+      region: eastus
+      instanceType: Standard_D2s_v5
+      provisioning: on-demand
+    prices:
+      node:
+        unit: hr
+        price: 0.096
+  - properties:
+      provider: Azure
+      region: eastus
+      instanceType: Standard_D2s_v5
+      provisioning: spot
+    prices:
+      node:
+        unit: hr
+        price: 0.0288
+  - properties:
+      provider: Azure
+      region: eastus
+      instanceType: Standard_D4s_v5
+      provisioning: on-demand
+    prices:
+      node:
+        unit: hr
+        price: 0.192
+  - properties:
+      provider: Azure
+      region: eastus
+      instanceType: Standard_D4s_v5
+      provisioning: spot
+    prices:
+      node:
+        unit: hr
+        price: 0.0576
+  - properties:
+      provider: Azure
+      region: eastus
+      instanceType: Standard_E2s_v5
+      provisioning: on-demand
+    prices:
+      node:
+        unit: hr
+        price: 0.126
+  - properties:
+      provider: Azure
+      region: eastus
+      instanceType: Standard_E2s_v5
+      provisioning: spot
+    prices:
+      node:
+        unit: hr
+        price: 0.0378
+  - properties:
+      provider: Azure
+      region: westus2
+      instanceType: Standard_D2s_v5
+      provisioning: on-demand
+    prices:
+      node:
+        unit: hr
+        price: 0.109
+  - properties:
+      provider: Azure
+      region: westus2
+      instanceType: Standard_D2s_v5
+      provisioning: spot
+    prices:
+      node:
+        unit: hr
+        price: 0.0327
+  - properties:
+      provider: Azure
+      region: westus2
+      instanceType: Standard_D4s_v5
+      provisioning: on-demand
+    prices:
+      node:
+        unit: hr
+        price: 0.218
+  - properties:
+      provider: Azure
+      region: westus2
+      instanceType: Standard_D4s_v5
+      provisioning: spot
+    prices:
+      node:
+        unit: hr
+        price: 0.0654
+  - properties:
+      provider: Azure
+      region: westus2
+      instanceType: Standard_E2s_v5
+      provisioning: on-demand
+    prices:
+      node:
+        unit: hr
+        price: 0.143
+  - properties:
+      provider: Azure
+      region: westus2
+      instanceType: Standard_E2s_v5
+      provisioning: spot
+    prices:
+      node:
+        unit: hr
+        price: 0.0429
+persistentVolumePricing:
+  - properties:
+      provider: Azure
+      region: eastus
+      volumeType: Premium_LRS
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0002192
+  - properties:
+      provider: Azure
+      region: eastus
+      volumeType: StandardSSD_LRS
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0001096
+  - properties:
+      provider: Azure
+      region: eastus
+      volumeType: Standard_LRS
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0000685
+  - properties:
+      provider: Azure
+      region: westus2
+      volumeType: Premium_LRS
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0002466
+  - properties:
+      provider: Azure
+      region: westus2
+      volumeType: StandardSSD_LRS
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0001233
+  - properties:
+      provider: Azure
+      region: westus2
+      volumeType: Standard_LRS
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0000767
+networkPricing:
+  - properties:
+      provider: Azure
+    prices:
+      local-egress:
+        unit: GiB
+        price: 0.0
+      x-zone-egress:
+        unit: GiB
+        price: 0.01
+      x-region-egress:
+        unit: GiB
+        price: 0.02
+      internet-egress:
+        unit: GiB
+        price: 0.087
+      nat-egress:
+        unit: GiB
+        price: 0.045
+      nat-ingress:
+        unit: GiB
+        price: 0.045

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

@@ -0,0 +1,36 @@
+nodePricing:
+  - properties: {}
+    prices:
+      cpu:
+        unit: vCPU-hr
+        price: 0.031611
+      ram:
+        unit: GiB-hr
+        price: 0.004237
+persistentVolumePricing:
+  - properties: {}
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0000581
+networkPricing:
+  - properties: {}
+    prices:
+      local-egress:
+        unit: GiB
+        price: 0.0
+      x-zone-egress:
+        unit: GiB
+        price: 0.01
+      x-region-egress:
+        unit: GiB
+        price: 0.01
+      internet-egress:
+        unit: GiB
+        price: 0.143
+      nat-egress:
+        unit: GiB
+        price: 0.045
+      nat-ingress:
+        unit: GiB
+        price: 0.045

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

@@ -0,0 +1,241 @@
+nodePricing:
+  - properties:
+      provider: GCP
+      provisioning: on-demand
+    prices:
+      cpu:
+        unit: vCPU-hr
+        price: 0.031611
+      ram:
+        unit: GiB-hr
+        price: 0.004237
+  - properties:
+      provider: GCP
+      provisioning: spot
+    prices:
+      cpu:
+        unit: vCPU-hr
+        price: 0.006655
+      ram:
+        unit: GiB-hr
+        price: 0.000892
+  - properties:
+      provider: GCP
+      region: us-central1
+      instanceType: n2-standard-2
+      provisioning: on-demand
+    prices:
+      cpu:
+        unit: vCPU-hr
+        price: 0.031611
+      ram:
+        unit: GiB-hr
+        price: 0.004237
+  - properties:
+      provider: GCP
+      region: us-central1
+      instanceType: n2-standard-2
+      provisioning: spot
+    prices:
+      cpu:
+        unit: vCPU-hr
+        price: 0.009483
+      ram:
+        unit: GiB-hr
+        price: 0.001271
+  - properties:
+      provider: GCP
+      region: us-central1
+      instanceType: n2-standard-4
+      provisioning: on-demand
+    prices:
+      cpu:
+        unit: vCPU-hr
+        price: 0.031611
+      ram:
+        unit: GiB-hr
+        price: 0.004237
+  - properties:
+      provider: GCP
+      region: us-central1
+      instanceType: n2-standard-4
+      provisioning: spot
+    prices:
+      cpu:
+        unit: vCPU-hr
+        price: 0.009483
+      ram:
+        unit: GiB-hr
+        price: 0.001271
+  - properties:
+      provider: GCP
+      region: us-central1
+      instanceType: e2-standard-2
+      provisioning: on-demand
+    prices:
+      cpu:
+        unit: vCPU-hr
+        price: 0.031611
+      ram:
+        unit: GiB-hr
+        price: 0.004237
+  - properties:
+      provider: GCP
+      region: us-central1
+      instanceType: e2-standard-2
+      provisioning: spot
+    prices:
+      cpu:
+        unit: vCPU-hr
+        price: 0.009483
+      ram:
+        unit: GiB-hr
+        price: 0.001271
+  - properties:
+      provider: GCP
+      region: us-west1
+      instanceType: n2-standard-2
+      provisioning: on-demand
+    prices:
+      cpu:
+        unit: vCPU-hr
+        price: 0.033646
+      ram:
+        unit: GiB-hr
+        price: 0.004511
+  - properties:
+      provider: GCP
+      region: us-west1
+      instanceType: n2-standard-2
+      provisioning: spot
+    prices:
+      cpu:
+        unit: vCPU-hr
+        price: 0.010094
+      ram:
+        unit: GiB-hr
+        price: 0.001353
+  - properties:
+      provider: GCP
+      region: us-west1
+      instanceType: n2-standard-4
+      provisioning: on-demand
+    prices:
+      cpu:
+        unit: vCPU-hr
+        price: 0.033646
+      ram:
+        unit: GiB-hr
+        price: 0.004511
+  - properties:
+      provider: GCP
+      region: us-west1
+      instanceType: n2-standard-4
+      provisioning: spot
+    prices:
+      cpu:
+        unit: vCPU-hr
+        price: 0.010094
+      ram:
+        unit: GiB-hr
+        price: 0.001353
+  - properties:
+      provider: GCP
+      region: us-west1
+      instanceType: e2-standard-2
+      provisioning: on-demand
+    prices:
+      cpu:
+        unit: vCPU-hr
+        price: 0.033646
+      ram:
+        unit: GiB-hr
+        price: 0.004511
+  - properties:
+      provider: GCP
+      region: us-west1
+      instanceType: e2-standard-2
+      provisioning: spot
+    prices:
+      cpu:
+        unit: vCPU-hr
+        price: 0.010094
+      ram:
+        unit: GiB-hr
+        price: 0.001353
+persistentVolumePricing:
+  - properties: {}
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0000581
+  - properties:
+      provider: GCP
+      region: us-central1
+      volumeType: pd-ssd
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0002329
+  - properties:
+      provider: GCP
+      region: us-central1
+      volumeType: pd-balanced
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.000137
+  - properties:
+      provider: GCP
+      region: us-central1
+      volumeType: pd-standard
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0000548
+  - properties:
+      provider: GCP
+      region: us-west1
+      volumeType: pd-ssd
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0002466
+  - properties:
+      provider: GCP
+      region: us-west1
+      volumeType: pd-balanced
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0001452
+  - properties:
+      provider: GCP
+      region: us-west1
+      volumeType: pd-standard
+    prices:
+      storage:
+        unit: GiB-hr
+        price: 0.0000581
+networkPricing:
+  - properties:
+      provider: GCP
+    prices:
+      local-egress:
+        unit: GiB
+        price: 0.0
+      x-zone-egress:
+        unit: GiB
+        price: 0.01
+      x-region-egress:
+        unit: GiB
+        price: 0.01
+      internet-egress:
+        unit: GiB
+        price: 0.12
+      nat-egress:
+        unit: GiB
+        price: 0.045
+      nat-ingress:
+        unit: GiB
+        price: 0.045

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

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

+ 81 - 0
core/pkg/reader/func.go

@@ -0,0 +1,81 @@
+package reader
+
+import (
+	"context"
+	"io"
+)
+
+// nextFunc produces the next item for the FuncReader to read.
+//
+// It should return:
+//  1. (item, nil)    when an item is available and more may remain
+//  2. (zero, io.EOF) when there are no more items remaining
+//  3. (zero, err)    when there is an error getting the next item
+//
+// io.EOF is the end-of-stream signal; any other error is a failure. Any error
+// is terminal. Once a non-nil error is returned it is latched and re-returned
+// by Read.
+//
+// Do NOT return an item with io.EOF, as it will NOT be read.
+type nextFunc[T any] func() (item T, err error)
+
+// FuncReader adapts an arbitrary "next item" function into a Reader[T]. It
+// optionally accepts an io.Closer, and will close it in Close(). If a
+// terminal error is returned from next() it will hold that error and return
+// it on Read() indefinitely (e.g. io.EOF when the next() source is exhausted).
+type FuncReader[T any] struct {
+	next   nextFunc[T]
+	closer io.Closer
+	err    error
+}
+
+// NewFuncReader returns a Reader[T] driven by next. If a non-nil closer is
+// provided, it will be closed by Close().
+func NewFuncReader[T any](next nextFunc[T], closer io.Closer) *FuncReader[T] {
+	return &FuncReader[T]{next: next, closer: closer}
+}
+
+// Read fills dst with up to len(dst) items pulled from next() func, returning
+// the number of items read. Returns io.EOF with the final batch, and will
+// continue to return n=0 and the terminal error on subsequent reads.
+func (r *FuncReader[T]) Read(ctx context.Context, dst []T) (int, error) {
+	if err := ctx.Err(); err != nil {
+		return 0, err
+	}
+	if r.err != nil {
+		return 0, r.err
+	}
+
+	n := 0
+	for n < len(dst) {
+		// Re-check cancellation each iteration.
+		if err := ctx.Err(); err != nil {
+			return n, err
+		}
+
+		item, err := r.next()
+		if err != nil {
+			// Terminal error (io.EOF for normal exhaustion, or a real failure).
+			// Latch it and fold it into the batch gathered so far.
+			r.err = err
+			return n, err
+		}
+
+		// An item is available and more may remain.
+		dst[n] = item
+		n++
+	}
+
+	// dst is full, but more items may remain
+	return n, nil
+}
+
+// Close closes the underlying source if it implements io.Closer, and is a no-op
+// otherwise. It does not disturb the reader's terminal state, so it is safe to
+// Close early to abandon a partially-read stream.
+func (r *FuncReader[T]) Close() error {
+	if r.closer == nil {
+		return nil
+	}
+	return r.closer.Close()
+}

+ 110 - 0
core/pkg/reader/func_test.go

@@ -0,0 +1,110 @@
+package reader
+
+import (
+	"context"
+	"errors"
+	"io"
+	"testing"
+)
+
+func TestFuncReader(t *testing.T) {
+	// counter yields 0..total-1 then reports io.EOF.
+	newCounter := func(total int) nextFunc[int] {
+		i := 0
+		return func() (int, error) {
+			if i >= total {
+				return 0, io.EOF
+			}
+			v := i
+			i++
+			return v, nil
+		}
+	}
+
+	t.Run("returns io.EOF with the final batch, then stays terminal", func(t *testing.T) {
+		// counter(3) drained through a 2-slot buffer: reads of 2, 1, 0.
+		r := NewFuncReader(newCounter(3), nil)
+		dst := make([]int, 2)
+
+		// Exact fill: the buffer fills before next() reports io.EOF, so io.EOF
+		// is deferred to the following read (Read cannot know it is exhausted
+		// without pulling again).
+		if n, err := r.Read(context.Background(), dst); n != 2 || err != nil {
+			t.Fatalf("read 1: got (%d, %v), want (2, nil)", n, err)
+		}
+		// The last item and io.EOF come back together: the terminal error is
+		// returned alongside the final data-bearing batch, not on a separate call.
+		if n, err := r.Read(context.Background(), dst); n != 1 || !errors.Is(err, io.EOF) {
+			t.Fatalf("read 2: got (%d, %v), want (1, io.EOF)", n, err)
+		}
+		// Terminal state is sticky: io.EOF latched, no further items.
+		if n, err := r.Read(context.Background(), dst); n != 0 || !errors.Is(err, io.EOF) {
+			t.Fatalf("read 3: got (%d, %v), want (0, io.EOF)", n, err)
+		}
+	})
+
+	t.Run("io.EOF at batch start yields (0, io.EOF)", func(t *testing.T) {
+		r := NewFuncReader(newCounter(0), nil)
+		if n, err := r.Read(context.Background(), make([]int, 4)); n != 0 || !errors.Is(err, io.EOF) {
+			t.Fatalf("got (%d, %v), want (0, io.EOF)", n, err)
+		}
+	})
+
+	t.Run("item returned alongside io.EOF is not read", func(t *testing.T) {
+		// A nextFunc that violates the contract by returning a real item with
+		// io.EOF. io.EOF is authoritative: the item must be dropped, never
+		// written to dst. This pins the "Do NOT return an item with io.EOF"
+		// rule that lets FuncReader avoid disambiguating zero from placeholder.
+		next := func() (int, error) { return 99, io.EOF }
+
+		r := NewFuncReader(next, nil)
+		dst := []int{-1, -1}
+
+		n, err := r.Read(context.Background(), dst)
+		if n != 0 || !errors.Is(err, io.EOF) {
+			t.Fatalf("got (%d, %v), want (0, io.EOF)", n, err)
+		}
+		if dst[0] != -1 {
+			t.Errorf("dst[0] was written despite io.EOF: got %d, want untouched (-1)", dst[0])
+		}
+	})
+
+	t.Run("error is returned with prefix and is sticky", func(t *testing.T) {
+		wantErr := errors.New("boom")
+		calls := 0
+		next := func() (int, error) {
+			calls++
+			switch calls {
+			case 1:
+				return 10, nil
+			case 2:
+				return 0, wantErr
+			default:
+				t.Fatalf("next called %d times; should have latched", calls)
+				return 0, nil
+			}
+		}
+
+		r := NewFuncReader(next, nil)
+		dst := make([]int, 4)
+
+		n, err := r.Read(context.Background(), dst)
+		if n != 1 || !errors.Is(err, wantErr) {
+			t.Fatalf("read 1: got (%d, %v), want (1, %v)", n, err, wantErr)
+		}
+		if dst[0] != 10 {
+			t.Errorf("prefix item: got %d, want 10", dst[0])
+		}
+		// Sticky: no further calls to next, same error returned.
+		if n2, err2 := r.Read(context.Background(), dst); n2 != 0 || !errors.Is(err2, wantErr) {
+			t.Errorf("read 2: got (%d, %v), want (0, %v)", n2, err2, wantErr)
+		}
+	})
+
+	t.Run("nil closer Close is a no-op", func(t *testing.T) {
+		r := NewFuncReader(newCounter(1), nil)
+		if err := r.Close(); err != nil {
+			t.Errorf("Close: got %v, want nil", err)
+		}
+	})
+}

+ 26 - 0
core/pkg/reader/jsonlines.go

@@ -0,0 +1,26 @@
+package reader
+
+import (
+	"encoding/json"
+	"io"
+)
+
+// NewJSONLinesReader returns a Reader[T] that streams a sequence of JSON values
+// read from an underlying io.Reader. It handles JSON Lines (one JSON value per
+// line), which can be whitespace/newline-separated (or not) and consolidated
+// to a single line each (or not, e.g. pretty-printed). Stops on io.EOF from
+// the underlying reader. If the underlying reader is also an io.Closer, it
+// will close it on Close().
+func NewJSONLinesReader[T any](r io.Reader) *FuncReader[T] {
+	dec := json.NewDecoder(r)
+
+	next := func() (T, error) {
+		var item T
+		err := dec.Decode(&item)
+		return item, err
+	}
+
+	closer, _ := r.(io.Closer)
+
+	return NewFuncReader(next, closer)
+}

+ 391 - 0
core/pkg/reader/jsonlines_test.go

@@ -0,0 +1,391 @@
+package reader
+
+import (
+	"bufio"
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"math/rand/v2"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+
+	"github.com/google/uuid"
+)
+
+// jsonLinesOf marshals each of vals to its own line, returning a JSONL reader.
+func jsonLinesOf[T any](t *testing.T, vals []T, delimiter string) io.Reader {
+	t.Helper()
+	var sb strings.Builder
+	for _, v := range vals {
+		b, err := json.Marshal(v)
+		if err != nil {
+			t.Fatalf("marshaling test data: %v", err)
+		}
+		sb.Write(b)
+		if delimiter != "" {
+			sb.WriteString(delimiter)
+		}
+	}
+	return strings.NewReader(sb.String())
+}
+
+// recordingCloser wraps a reader and records whether Close was called, returning
+// a configurable error from Close. Used to exercise the io.Closer branch.
+type recordingCloser struct {
+	io.Reader
+	closed   bool
+	closeErr error
+}
+
+func (rc *recordingCloser) Close() error {
+	rc.closed = true
+	return rc.closeErr
+}
+
+// TestJSONLinesReader_FoldedTerminalSignal proves the JSONL reader honors the
+// same folded-io.EOF contract as the array reader (folds when the final batch is
+// short; defers to (0, io.EOF) on an exact fill).
+func TestJSONLinesReader_FoldedTerminalSignal(t *testing.T) {
+	tests := []struct {
+		name      string
+		items     int
+		bufSize   int
+		wantReads []struct {
+			n   int
+			eof bool
+		}
+	}{
+		{"buffer larger than items", 3, 10, []struct {
+			n   int
+			eof bool
+		}{{3, true}}},
+		{"buffer exactly fits items", 5, 5, []struct {
+			n   int
+			eof bool
+		}{{5, false}, {0, true}}},
+		{"buffer unevenly divides items", 7, 3, []struct {
+			n   int
+			eof bool
+		}{{3, false}, {3, false}, {1, true}}},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			r := NewJSONLinesReader[int](jsonLinesOf(t, seq(tc.items), "\n"))
+			dst := make([]int, tc.bufSize)
+			for i, want := range tc.wantReads {
+				n, err := r.Read(context.Background(), dst)
+				if n != want.n {
+					t.Errorf("read %d: got n=%d, want %d", i, n, want.n)
+				}
+				if gotEOF := errors.Is(err, io.EOF); gotEOF != want.eof {
+					t.Errorf("read %d: got eof=%v (err=%v), want eof=%v", i, gotEOF, err, want.eof)
+				}
+				if !want.eof && err != nil {
+					t.Errorf("read %d: unexpected error: %v", i, err)
+				}
+			}
+		})
+	}
+}
+
+// TestJSONLinesReader_Whitespace verifies newline separation, blank lines,
+// trailing whitespace, CRLF, and empty input are all handled.
+func TestJSONLinesReader_Whitespace(t *testing.T) {
+	tests := []struct {
+		name  string
+		input string
+		want  []int
+	}{
+		{"one per line", "1\n2\n3\n", []int{1, 2, 3}},
+		{"no trailing newline", "1\n2\n3", []int{1, 2, 3}},
+		{"space separated", "1 2 3", []int{1, 2, 3}},
+		{"crlf", "1\r\n2\r\n3\r\n", []int{1, 2, 3}},
+		{"blank lines", "1\n\n\n2\n", []int{1, 2}},
+		{"trailing whitespace", "1\n2\n   \n", []int{1, 2}},
+		{"empty", "", nil},
+		{"all whitespace", "   \n  ", nil},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			r := NewJSONLinesReader[int](strings.NewReader(tc.input))
+			dst := make([]int, 2)
+			var got []int
+			for {
+				n, err := r.Read(context.Background(), dst)
+				got = append(got, dst[:n]...)
+				if errors.Is(err, io.EOF) {
+					break
+				}
+				if err != nil {
+					t.Fatalf("unexpected error: %v", err)
+				}
+			}
+			if len(got) != len(tc.want) {
+				t.Fatalf("got %v, want %v", got, tc.want)
+			}
+			for i := range got {
+				if got[i] != tc.want[i] {
+					t.Errorf("item %d: got %d, want %d", i, got[i], tc.want[i])
+				}
+			}
+		})
+	}
+}
+
+// TestJSONLinesReader_TruncatedValue verifies a truncated trailing value is a
+// terminal, sticky, non-EOF error returned after the valid prefix — unlike the
+// array reader, whose lookahead swallows truncation.
+func TestJSONLinesReader_TruncatedValue(t *testing.T) {
+	r := NewJSONLinesReader[int](strings.NewReader("1\n2\n{\"id\":"))
+	dst := make([]int, 2)
+
+	if n, err := r.Read(context.Background(), dst); n != 2 || err != nil {
+		t.Fatalf("first read: got (%d, %v), want (2, nil)", n, err)
+	}
+
+	n, err := r.Read(context.Background(), dst)
+	if n != 0 || err == nil || errors.Is(err, io.EOF) {
+		t.Fatalf("second read: got (%d, %v), want (0, non-EOF error)", n, err)
+	}
+	// Sticky.
+	if _, err2 := r.Read(context.Background(), dst); err2 != err {
+		t.Errorf("third read: err=%v, want sticky %v", err2, err)
+	}
+}
+
+// TestJSONLinesReader_MalformedValue verifies a wrong-type value mid-stream is a
+// terminal, sticky, non-EOF error.
+func TestJSONLinesReader_MalformedValue(t *testing.T) {
+	r := NewJSONLinesReader[int](strings.NewReader("1\n{}\n3"))
+	dst := make([]int, 4)
+
+	// The batch stops at the malformed second value, returning the prefix.
+	n, err := r.Read(context.Background(), dst)
+	if n != 1 || err == nil || errors.Is(err, io.EOF) {
+		t.Fatalf("read: got (%d, %v), want (1, non-EOF error)", n, err)
+	}
+	if dst[0] != 1 {
+		t.Errorf("prefix item: got %d, want 1", dst[0])
+	}
+	if _, err2 := r.Read(context.Background(), dst); err2 != err {
+		t.Errorf("next read: err=%v, want sticky %v", err2, err)
+	}
+}
+
+// TestJSONLinesReader_StructValues exercises the realistic case: one JSON object
+// per line decoded into pointer elements across multiple batches.
+func TestJSONLinesReader_StructValues(t *testing.T) {
+	type item struct {
+		ID   int    `json:"id"`
+		Name string `json:"name"`
+	}
+	src := []*item{{1, "a"}, {2, "b"}, {3, "c"}, {4, "d"}, {5, "e"}}
+
+	r := NewJSONLinesReader[*item](jsonLinesOf(t, src, "\n"))
+	dst := make([]*item, 2)
+
+	var got []*item
+	for {
+		n, err := r.Read(context.Background(), dst)
+		got = append(got, dst[:n]...)
+		if errors.Is(err, io.EOF) {
+			break
+		}
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+	}
+
+	if len(got) != len(src) {
+		t.Fatalf("got %d items, want %d", len(got), len(src))
+	}
+	for i, p := range got {
+		if p == nil || p.ID != src[i].ID || p.Name != src[i].Name {
+			t.Errorf("item %d: got %v, want %+v", i, p, *src[i])
+		}
+	}
+}
+
+// TestJSONLinesReader_StructValues_NoNewline tests a file without newlines
+// between streamed JSON objects
+func TestJSONLinesReader_StructValues_NoNewline(t *testing.T) {
+	type item struct {
+		ID   int    `json:"id"`
+		Name string `json:"name"`
+	}
+	src := []*item{{1, "a"}, {2, "b"}, {3, "c"}, {4, "d"}, {5, "e"}}
+
+	r := NewJSONLinesReader[*item](jsonLinesOf(t, src, ""))
+	dst := make([]*item, 2)
+
+	var got []*item
+	for {
+		n, err := r.Read(context.Background(), dst)
+		got = append(got, dst[:n]...)
+		if errors.Is(err, io.EOF) {
+			break
+		}
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+	}
+
+	if len(got) != len(src) {
+		t.Fatalf("got %d items, want %d", len(got), len(src))
+	}
+	for i, p := range got {
+		if p == nil || p.ID != src[i].ID || p.Name != src[i].Name {
+			t.Errorf("item %d: got %v, want %+v", i, p, *src[i])
+		}
+	}
+}
+
+// TestJSONLinesReader_Close verifies the io.Closer source is closed.
+func TestJSONLinesReader_Close(t *testing.T) {
+	rc := &recordingCloser{Reader: strings.NewReader("1\n2\n")}
+	r := NewJSONLinesReader[int](rc)
+	if err := r.Close(); err != nil {
+		t.Errorf("Close: got err=%v, want nil", err)
+	}
+	if !rc.closed {
+		t.Error("underlying source was not closed")
+	}
+}
+
+// Simple struct with various data types for benchmarking JSONLinesReader
+type benchRecord struct {
+	ID     string            `json:"id"`
+	Name   string            `json:"name"`
+	Value  float64           `json:"value"`
+	Labels map[string]string `json:"labels"`
+}
+
+// writeBenchJSONL writes n JSON-Lines records to path (one value per line).
+func writeBenchJSONL(tb testing.TB, path string, n int) {
+	tb.Helper()
+
+	f, err := os.Create(path)
+	if err != nil {
+		tb.Fatal(err)
+	}
+	defer f.Close()
+
+	w := bufio.NewWriter(f)
+	enc := json.NewEncoder(w) // Encode appends a newline after each value.
+	for i := 0; i < n; i++ {
+		rec := benchRecord{
+			ID:     uuid.NewString(),
+			Name:   fmt.Sprintf("name-%d", i),
+			Value:  rand.Float64(),
+			Labels: map[string]string{"foo": "bar", "baz": "bat"},
+		}
+		if err := enc.Encode(&rec); err != nil {
+			tb.Fatal(err)
+		}
+	}
+	if err := w.Flush(); err != nil {
+		tb.Fatal(err)
+	}
+}
+
+// streamAll reads path in batches of batchSize, discarding every batch (dst is
+// reused, nothing retained), and returns the number of records seen.
+func streamAll(tb testing.TB, path string, batchSize int) int {
+	tb.Helper()
+
+	f, err := os.Open(path)
+	if err != nil {
+		tb.Fatal(err)
+	}
+
+	r := NewJSONLinesReader[benchRecord](f)
+	dst := make([]benchRecord, batchSize)
+	total := 0
+	for {
+		n, err := r.Read(context.Background(), dst)
+		total += n
+		// Discard: the next Read overwrites dst; no record outlives its batch.
+		if errors.Is(err, io.EOF) {
+			break
+		}
+		if err != nil {
+			tb.Fatal(err)
+		}
+	}
+	if err := r.Close(); err != nil {
+		tb.Fatal(err)
+	}
+	return total
+}
+
+const (
+	benchRecords   = 100_000
+	benchBatchSize = 1_000
+)
+
+// BenchmarkJSONLinesReader measures wall time and allocation churn to stream a
+// 50k-line JSONL file 1000 records at a time. With -benchmem, B/op is the TOTAL
+// bytes allocated per pass (churn, scales with record count) — not resident
+// memory. See BenchmarkJSONLinesReaderResident for the live-heap bound.
+func BenchmarkJSONLinesReader(b *testing.B) {
+	path := filepath.Join(b.TempDir(), "bench.jsonl") // disposable; auto-removed
+	writeBenchJSONL(b, path, benchRecords)
+
+	b.ReportAllocs()
+	b.ResetTimer()
+
+	for i := 0; i < b.N; i++ {
+		if got := streamAll(b, path, benchBatchSize); got != benchRecords {
+			b.Fatalf("streamed %d records, want %d", got, benchRecords)
+		}
+	}
+}
+
+// BenchmarkJSONLinesReaderResident measures PEAK live heap (HeapInuse) while
+// streaming, which is what the streaming design is meant to bound: it should
+// stay ~flat at one batch's worth of records regardless of file size. Note the
+// per-batch runtime.ReadMemStats sampling perturbs timing, so read ns/op from
+// BenchmarkJSONLinesReader, not this one.
+func BenchmarkJSONLinesReaderResident(b *testing.B) {
+	path := filepath.Join(b.TempDir(), "bench.jsonl")
+	writeBenchJSONL(b, path, benchRecords)
+
+	b.ResetTimer()
+
+	var peakHeapInuse uint64
+	for i := 0; i < b.N; i++ {
+		f, err := os.Open(path)
+		if err != nil {
+			b.Fatal(err)
+		}
+		r := NewJSONLinesReader[benchRecord](f)
+		dst := make([]benchRecord, benchBatchSize)
+		for {
+			n, err := r.Read(context.Background(), dst)
+			_ = n
+
+			var ms runtime.MemStats
+			runtime.ReadMemStats(&ms)
+			if ms.HeapInuse > peakHeapInuse {
+				peakHeapInuse = ms.HeapInuse
+			}
+
+			if errors.Is(err, io.EOF) {
+				break
+			}
+			if err != nil {
+				b.Fatal(err)
+			}
+		}
+		r.Close()
+	}
+
+	b.ReportMetric(float64(peakHeapInuse)/1024, "peakHeapKB")
+}

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

@@ -0,0 +1,15 @@
+package reader
+
+import (
+	"context"
+)
+
+// Reader is a generic, io.Reader-style streaming interface. Read fills dst with
+// up to len(dst) items and returns the number read. When the stream is
+// exhausted it returns io.EOF, which may accompany a non-zero count on the
+// final read; callers must always process the returned items before honoring
+// the error.
+type Reader[T any] interface {
+	Read(ctx context.Context, dst []T) (int, error)
+	Close() error
+}

+ 43 - 0
core/pkg/reader/slice.go

@@ -0,0 +1,43 @@
+package reader
+
+import (
+	"context"
+	"io"
+)
+
+type SliceReader[T any] struct {
+	items []T
+	pos   int
+}
+
+func NewSliceReader[T any](items []T) *SliceReader[T] {
+	return &SliceReader[T]{
+		items: items,
+		pos:   0,
+	}
+}
+
+func (r *SliceReader[T]) Read(ctx context.Context, dst []T) (int, error) {
+	if err := ctx.Err(); err != nil {
+		return 0, err
+	}
+
+	if r.pos >= len(r.items) {
+		return 0, io.EOF
+	}
+
+	n := copy(dst, r.items[r.pos:])
+	r.pos += n
+
+	// Fold the terminal signal into the final data-bearing read rather than
+	// requiring a separate trailing call that returns (0, io.EOF).
+	if r.pos >= len(r.items) {
+		return n, io.EOF
+	}
+
+	return n, nil
+}
+
+func (r *SliceReader[T]) Close() error {
+	return nil
+}

+ 245 - 0
core/pkg/reader/slice_test.go

@@ -0,0 +1,245 @@
+package reader
+
+import (
+	"context"
+	"errors"
+	"io"
+	"testing"
+)
+
+// TestSliceReader_FoldedTerminalSignal verifies that the read which exhausts the
+// slice returns io.EOF together with the final items, rather than deferring the
+// signal to a separate (0, io.EOF) call.
+func TestSliceReader_FoldedTerminalSignal(t *testing.T) {
+	tests := []struct {
+		name    string
+		items   int
+		bufSize int
+		// wantReads is the expected (n, eof) result of each successive Read.
+		wantReads []struct {
+			n   int
+			eof bool
+		}
+	}{
+		{
+			name:    "buffer larger than items",
+			items:   3,
+			bufSize: 10,
+			wantReads: []struct {
+				n   int
+				eof bool
+			}{{3, true}},
+		},
+		{
+			name:    "buffer exactly fits items",
+			items:   5,
+			bufSize: 5,
+			wantReads: []struct {
+				n   int
+				eof bool
+			}{{5, true}},
+		},
+		{
+			name:    "buffer evenly divides items",
+			items:   6,
+			bufSize: 3,
+			wantReads: []struct {
+				n   int
+				eof bool
+			}{{3, false}, {3, true}},
+		},
+		{
+			name:    "buffer unevenly divides items",
+			items:   7,
+			bufSize: 3,
+			wantReads: []struct {
+				n   int
+				eof bool
+			}{{3, false}, {3, false}, {1, true}},
+		},
+		{
+			name:    "single item",
+			items:   1,
+			bufSize: 1,
+			wantReads: []struct {
+				n   int
+				eof bool
+			}{{1, true}},
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			r := NewSliceReader(seq(tc.items))
+			dst := make([]int, tc.bufSize)
+
+			for i, want := range tc.wantReads {
+				n, err := r.Read(context.Background(), dst)
+				if n != want.n {
+					t.Errorf("read %d: got n=%d, want %d", i, n, want.n)
+				}
+				gotEOF := errors.Is(err, io.EOF)
+				if gotEOF != want.eof {
+					t.Errorf("read %d: got eof=%v (err=%v), want eof=%v", i, gotEOF, err, want.eof)
+				}
+				if !want.eof && err != nil {
+					t.Errorf("read %d: unexpected error: %v", i, err)
+				}
+			}
+		})
+	}
+}
+
+// TestSliceReader_EmptySlice verifies an exhausted-from-the-start reader returns
+// (0, io.EOF) immediately.
+func TestSliceReader_EmptySlice(t *testing.T) {
+	for _, items := range [][]int{nil, {}} {
+		r := NewSliceReader(items)
+		n, err := r.Read(context.Background(), make([]int, 4))
+		if n != 0 {
+			t.Errorf("got n=%d, want 0", n)
+		}
+		if !errors.Is(err, io.EOF) {
+			t.Errorf("got err=%v, want io.EOF", err)
+		}
+	}
+}
+
+// TestSliceReader_ReadAfterExhaustion verifies that reads following the folded
+// terminal signal continue to return (0, io.EOF).
+func TestSliceReader_ReadAfterExhaustion(t *testing.T) {
+	r := NewSliceReader(seq(2))
+	dst := make([]int, 4)
+
+	// First read drains everything and folds in io.EOF.
+	if n, err := r.Read(context.Background(), dst); n != 2 || !errors.Is(err, io.EOF) {
+		t.Fatalf("first read: got (%d, %v), want (2, io.EOF)", n, err)
+	}
+
+	// Subsequent reads keep reporting io.EOF with no data.
+	for i := 0; i < 3; i++ {
+		n, err := r.Read(context.Background(), dst)
+		if n != 0 || !errors.Is(err, io.EOF) {
+			t.Errorf("read after exhaustion %d: got (%d, %v), want (0, io.EOF)", i, n, err)
+		}
+	}
+}
+
+// TestSliceReader_PreservesOrderAndValues drains the reader in small chunks and
+// verifies every item is returned exactly once, in order.
+func TestSliceReader_PreservesOrderAndValues(t *testing.T) {
+	const items = 25
+	r := NewSliceReader(seq(items))
+	dst := make([]int, 4)
+
+	var got []int
+	for {
+		n, err := r.Read(context.Background(), dst)
+		got = append(got, dst[:n]...)
+		if errors.Is(err, io.EOF) {
+			break
+		}
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+	}
+
+	if len(got) != items {
+		t.Fatalf("got %d items, want %d", len(got), items)
+	}
+	for i, v := range got {
+		if v != i {
+			t.Errorf("item %d: got %d, want %d", i, v, i)
+		}
+	}
+}
+
+// TestSliceReader_ContextCancellation verifies a cancelled context short-circuits
+// the read even when items remain.
+func TestSliceReader_ContextCancellation(t *testing.T) {
+	r := NewSliceReader(seq(5))
+
+	ctx, cancel := context.WithCancel(context.Background())
+	cancel()
+
+	n, err := r.Read(ctx, make([]int, 4))
+	if n != 0 {
+		t.Errorf("got n=%d, want 0", n)
+	}
+	if !errors.Is(err, context.Canceled) {
+		t.Errorf("got err=%v, want context.Canceled", err)
+	}
+
+	// The reader was not advanced, so it still yields all items afterward.
+	got := 0
+	for {
+		n, err := r.Read(context.Background(), make([]int, 2))
+		got += n
+		if errors.Is(err, io.EOF) {
+			break
+		}
+		if err != nil {
+			t.Fatalf("unexpected error draining: %v", err)
+		}
+	}
+	if got != 5 {
+		t.Errorf("drained %d items after cancellation, want 5", got)
+	}
+}
+
+// TestSliceReader_ZeroLengthBuffer documents the io.Reader-style behavior: a
+// zero-length dst with items remaining reads nothing and returns no error.
+func TestSliceReader_ZeroLengthBuffer(t *testing.T) {
+	r := NewSliceReader(seq(3))
+
+	n, err := r.Read(context.Background(), []int{})
+	if n != 0 {
+		t.Errorf("got n=%d, want 0", n)
+	}
+	if err != nil {
+		t.Errorf("got err=%v, want nil", err)
+	}
+}
+
+// TestSliceReader_Close verifies Close is a no-op that reports no error and does
+// not disturb the read position.
+func TestSliceReader_Close(t *testing.T) {
+	r := NewSliceReader(seq(2))
+	if err := r.Close(); err != nil {
+		t.Errorf("Close: got err=%v, want nil", err)
+	}
+
+	n, err := r.Read(context.Background(), make([]int, 4))
+	if n != 2 || !errors.Is(err, io.EOF) {
+		t.Errorf("read after Close: got (%d, %v), want (2, io.EOF)", n, err)
+	}
+}
+
+// TestSliceReader_PointerElements exercises the generic reader with a pointer
+// element type, matching how the pricing readers use it.
+func TestSliceReader_PointerElements(t *testing.T) {
+	items := []*int{ptr(1), ptr(2), ptr(3)}
+	r := NewSliceReader(items)
+	dst := make([]*int, 3)
+
+	n, err := r.Read(context.Background(), dst)
+	if n != 3 || !errors.Is(err, io.EOF) {
+		t.Fatalf("got (%d, %v), want (3, io.EOF)", n, err)
+	}
+	for i, p := range dst {
+		if p == nil || *p != i+1 {
+			t.Errorf("item %d: got %v, want pointer to %d", i, p, i+1)
+		}
+	}
+}
+
+// seq returns []int{0, 1, ..., n-1}.
+func seq(n int) []int {
+	s := make([]int, n)
+	for i := range s {
+		s[i] = i
+	}
+	return s
+}
+
+func ptr[T any](v T) *T { return &v }

+ 19 - 20
core/pkg/source/decoders.go

@@ -2134,8 +2134,26 @@ type DCGMDeviceContainerUsageResult struct {
 	Value     float64
 }
 
-// Inference Metrics Decoders
+func DecodeDCGMDeviceContainerUsageResult(result *QueryResult) *DCGMDeviceContainerUsageResult {
+	uuid, _ := result.GetString(UUIDLabel)
+	podUID, _ := result.GetString(PodUIDLabel)
+	container, _ := result.GetString(ContainerLabel)
+	var value float64
+	if len(result.Values) > 0 {
+		value = result.Values[0].Value
+	} else {
+		log.Warnf("Error decoding DCGM Device Container Usage Result for device '%s': empty value returned", uuid)
+	}
 
+	return &DCGMDeviceContainerUsageResult{
+		UUID:      uuid,
+		PodUID:    podUID,
+		Container: container,
+		Value:     value,
+	}
+}
+
+// Inference Metrics Decoders
 func DecodeInferenceTokensResult(result *QueryResult) *InferenceTokensResult {
 	modelName, _ := result.GetString("model_name")
 	namespace, _ := result.GetString("namespace")
@@ -2192,25 +2210,6 @@ func DecodeInferenceCacheConfigResult(result *QueryResult) *InferenceCacheConfig
 	}
 }
 
-func DecodeDCGMDeviceContainerUsageResult(result *QueryResult) *DCGMDeviceContainerUsageResult {
-	uuid, _ := result.GetString(UUIDLabel)
-	podUID, _ := result.GetString(PodUIDLabel)
-	container, _ := result.GetString(ContainerLabel)
-	var value float64
-	if len(result.Values) > 0 {
-		value = result.Values[0].Value
-	} else {
-		log.Warnf("Error decoding DCGM Device Container Udage Result for device '%s': empty value returned", uuid)
-	}
-
-	return &DCGMDeviceContainerUsageResult{
-		UUID:      uuid,
-		PodUID:    podUID,
-		Container: container,
-		Value:     value,
-	}
-}
-
 func DecodeAll[T any](results []*QueryResult, decode ResultDecoder[T]) []*T {
 	decoded := make([]*T, 0, len(results))
 	for _, result := range results {

+ 0 - 3
core/pkg/source/mock.go

@@ -1026,8 +1026,6 @@ func (m *MockMetricsQuerier) QueryResourceQuotaStatusUsedRAMLimitMax(start, end
 	})
 }
 
-// Data Coverage Query
-
 // Inference Metrics
 
 func (m *MockMetricsQuerier) QueryInferencePromptTokens(start, end time.Time) *Future[InferenceTokensResult] {
@@ -1067,7 +1065,6 @@ func (m *MockMetricsQuerier) QueryInferenceCacheConfig(t time.Time) *Future[Infe
 }
 
 // Data Coverage Query
-
 func (m *MockMetricsQuerier) QueryDataCoverage(limitDays int) (time.Time, time.Time, error) {
 	if v, ok := m.overrides[QueryDataCoverage]; ok {
 		if f, ok := v.(func(int) (time.Time, time.Time, error)); ok {

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

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

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

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

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

@@ -0,0 +1,63 @@
+package unit
+
+import (
+	"fmt"
+	"strings"
+)
+
+type Unit string
+
+const (
+	// Durations of time
+	Millisecond Unit = "ms"
+	Second      Unit = "s"
+	Minute      Unit = "min"
+	Hour        Unit = "hr"
+
+	// Data storage and transfer
+	Byte Unit = "B"
+	KiB  Unit = "KiB"
+	MiB  Unit = "MiB"
+	GiB  Unit = "GiB"
+
+	// Compute resources
+	MCPU Unit = "mCPU"
+	VCPU Unit = "vCPU"
+	GPU  Unit = "GPU"
+
+	// Compute resources cumulative over time
+	GiBHour  Unit = "GiB-hr"
+	GPUHour  Unit = "GPU-hr"
+	VCPUHour Unit = "vCPU-hr"
+)
+
+// validUnits is a map of all valid unit strings for quick lookup
+var validUnits = map[string]Unit{
+	string(Millisecond): Millisecond,
+	string(Second):      Second,
+	string(Minute):      Minute,
+	string(Hour):        Hour,
+	string(Byte):        Byte,
+	string(KiB):         KiB,
+	string(MiB):         MiB,
+	string(GiB):         GiB,
+	string(MCPU):        MCPU,
+	string(VCPU):        VCPU,
+	string(GPU):         GPU,
+	string(GiBHour):     GiBHour,
+	string(VCPUHour):    VCPUHour,
+	string(GPUHour):     GPUHour,
+}
+
+// ParseUnit parses a string into a Unit type.
+// It performs case-insensitive matching and returns an error if the string
+// does not match any valid unit.
+func ParseUnit(s string) (Unit, error) {
+	for key, unit := range validUnits {
+		if strings.EqualFold(key, s) {
+			return unit, nil
+		}
+	}
+
+	return "", fmt.Errorf("invalid unit: %q", s)
+}

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

@@ -0,0 +1,76 @@
+package unit
+
+import (
+	"testing"
+)
+
+func TestParseUnit_Strings(t *testing.T) {
+	tests := []struct {
+		name      string
+		input     string
+		expect    Unit
+		expectErr bool
+	}{
+		// Duration units
+		{name: "millisecond", input: "ms", expect: Millisecond, expectErr: false},
+		{name: "second", input: "s", expect: Second, expectErr: false},
+		{name: "minute", input: "min", expect: Minute, expectErr: false},
+		{name: "hour", input: "hr", expect: Hour, expectErr: false},
+
+		// Data storage units
+		{name: "byte", input: "B", expect: Byte, expectErr: false},
+		{name: "kibibyte", input: "KiB", expect: KiB, expectErr: false},
+		{name: "mebibyte", input: "MiB", expect: MiB, expectErr: false},
+		{name: "gibibyte", input: "GiB", expect: GiB, expectErr: false},
+
+		// Compute resources
+		{name: "mCPU", input: "mCPU", expect: MCPU, expectErr: false},
+		{name: "vCPU", input: "vCPU", expect: VCPU, expectErr: false},
+		{name: "GPU", input: "GPU", expect: GPU, expectErr: false},
+
+		// Compute resources over time
+		{name: "GiB-hr", input: "GiB-hr", expect: GiBHour, expectErr: false},
+		{name: "vCPU-hr", input: "vCPU-hr", expect: VCPUHour, expectErr: false},
+		{name: "GPU-hr", input: "GPU-hr", expect: GPUHour, expectErr: false},
+
+		// Case insensitive tests
+		{name: "uppercase ms", input: "MS", expect: Millisecond, expectErr: false},
+		{name: "mixed case GiB", input: "gib", expect: GiB, expectErr: false},
+		{name: "mixed case mCPU", input: "Mcpu", expect: MCPU, expectErr: false},
+		{name: "mixed case vCPU-hr", input: "VCPU-HR", expect: VCPUHour, expectErr: false},
+
+		// Invalid units
+		{name: "invalid empty", input: "", expect: "", expectErr: true},
+		{name: "invalid unknown", input: "xyz", expect: "", expectErr: true},
+		{name: "invalid number", input: "123", expect: "", expectErr: true},
+		{name: "invalid partial", input: "G", expect: "", expectErr: true},
+		{name: "invalid with space", input: "G B", expect: "", expectErr: true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := ParseUnit(tt.input)
+			if (err != nil) != tt.expectErr {
+				t.Errorf("ParseUnit(%q) error = %v, expectErr %v", tt.input, err, tt.expectErr)
+				return
+			}
+			if got != tt.expect {
+				t.Errorf("ParseUnit(%q) = %v, expected %v", tt.input, got, tt.expect)
+			}
+		})
+	}
+}
+
+func TestParseUnit_Constants(t *testing.T) {
+	for _, unit := range validUnits {
+		t.Run(string(unit), func(t *testing.T) {
+			parsed, err := ParseUnit(string(unit))
+			if err != nil {
+				t.Errorf("ParseUnit(%q) unexpected error: %v", unit, err)
+			}
+			if parsed != unit {
+				t.Errorf("ParseUnit(%q) = %v, expected %v", unit, parsed, unit)
+			}
+		})
+	}
+}

+ 2 - 0
justfile

@@ -12,6 +12,8 @@ fmt:
     cd ./core && go fmt ./...
     cd ./modules/collector-source && go fmt ./...
     cd ./modules/prometheus-source && go fmt ./...
+    cd ./modules/pricing/basic && go fmt ./...
+    cd ./modules/pricing/public && go fmt ./...
 
 # check if code is formatted
 fmt-check:

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

@@ -0,0 +1,136 @@
+package basic
+
+import (
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+const DefaultClusterPricePerHour float64 = 0.0
+
+const DefaultNetworkLocalEgressPricePerGiB float64 = 0.0
+const DefaultNetworkCrossZoneEgressPricePerGiB float64 = 0.01
+const DefaultNetworkCrossRegionEgressPricePerGiB float64 = 0.01
+const DefaultNetworkInternetEgressPricePerGiB float64 = 0.143
+const DefaultNetworkNATGatewayEgressPricePerGiB float64 = 0.045
+const DefaultNetworkNATGatewayIngressPricePerGiB float64 = 0.045
+
+const DefaultNodePricePerVCPUHour float64 = 0.031611
+const DefaultNodePricePerRAMGiBHour float64 = 0.004237
+const DefaultNodePricePerGPUHour float64 = 0.95
+const DefaultNodePricePerLocalDiskGiBHour float64 = 0.0001096
+
+const DefaultPersistentVolumePricePerGiBHour float64 = 0.00005479452
+
+const DefaultServicePricePerHour float64 = 0.025
+
+func GetDefaultPricingSet() *pricing.PricingSet {
+	return &pricing.PricingSet{
+		ClusterPricing:          GetDefaultClusterPricing(),
+		NetworkPricing:          GetDefaultNetworkPricing(),
+		NodePricing:             GetDefaultNodePricing(),
+		PersistentVolumePricing: GetDefaultPersistentVolumePricing(),
+		ServicePricing:          GetDefaultServicePricing(),
+	}
+}
+
+func GetDefaultClusterPricing() []*pricing.ClusterPricing {
+	return []*pricing.ClusterPricing{
+		{
+			Properties: pricing.ClusterPricingProperties{},
+			Prices: pricing.Prices{
+				pricing.ResourceCluster: {
+					Unit:  unit.Hour,
+					Price: DefaultClusterPricePerHour,
+				},
+			},
+		},
+	}
+}
+
+func GetDefaultNetworkPricing() []*pricing.NetworkPricing {
+	return []*pricing.NetworkPricing{
+		{
+			Properties: pricing.NetworkPricingProperties{},
+			Prices: pricing.Prices{
+				pricing.ResourceLocalEgress: {
+					Unit:  unit.GiB,
+					Price: DefaultNetworkLocalEgressPricePerGiB,
+				},
+				pricing.ResourceCrossZoneEgress: {
+					Unit:  unit.GiB,
+					Price: DefaultNetworkCrossZoneEgressPricePerGiB,
+				},
+				pricing.ResourceCrossRegionEgress: {
+					Unit:  unit.GiB,
+					Price: DefaultNetworkCrossRegionEgressPricePerGiB,
+				},
+				pricing.ResourceInternetEgress: {
+					Unit:  unit.GiB,
+					Price: DefaultNetworkInternetEgressPricePerGiB,
+				},
+				pricing.ResourceNATGatewayEgress: {
+					Unit:  unit.GiB,
+					Price: DefaultNetworkNATGatewayEgressPricePerGiB,
+				},
+				pricing.ResourceNATGatewayIngress: {
+					Unit:  unit.GiB,
+					Price: DefaultNetworkNATGatewayIngressPricePerGiB,
+				},
+			},
+		},
+	}
+}
+
+func GetDefaultNodePricing() []*pricing.NodePricing {
+	return []*pricing.NodePricing{
+		{
+			Properties: pricing.NodePricingProperties{},
+			Prices: pricing.Prices{
+				pricing.ResourceCPU: {
+					Unit:  unit.VCPUHour,
+					Price: DefaultNodePricePerVCPUHour,
+				},
+				pricing.ResourceRAM: {
+					Unit:  unit.GiBHour,
+					Price: DefaultNodePricePerRAMGiBHour,
+				},
+				pricing.ResourceGPU: {
+					Unit:  unit.GPUHour,
+					Price: DefaultNodePricePerGPUHour,
+				},
+				pricing.ResourceStorage: {
+					Unit:  unit.GiBHour,
+					Price: DefaultNodePricePerLocalDiskGiBHour,
+				},
+			},
+		},
+	}
+}
+
+func GetDefaultPersistentVolumePricing() []*pricing.PersistentVolumePricing {
+	return []*pricing.PersistentVolumePricing{
+		{
+			Properties: pricing.PersistentVolumePricingProperties{},
+			Prices: pricing.Prices{
+				pricing.ResourceStorage: {
+					Unit:  unit.GiBHour,
+					Price: DefaultPersistentVolumePricePerGiBHour,
+				},
+			},
+		},
+	}
+}
+
+func GetDefaultServicePricing() []*pricing.ServicePricing {
+	return []*pricing.ServicePricing{
+		{
+			Properties: pricing.ServicePricingProperties{},
+			Prices: pricing.Prices{
+				pricing.ResourceService: {
+					Unit:  unit.Hour,
+					Price: DefaultServicePricePerHour,
+				},
+			},
+		},
+	}
+}

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

@@ -0,0 +1,117 @@
+module github.com/opencost/opencost/modules/pricing/basic
+
+replace github.com/opencost/opencost/core => ../../../core
+
+require github.com/opencost/opencost/core v0.0.0 // return to v1.120.2-0.20260514205745-aa41c03dc67a
+
+require github.com/stretchr/testify v1.11.1
+
+require (
+	cel.dev/expr v0.25.1 // indirect
+	cloud.google.com/go v0.123.0 // indirect
+	cloud.google.com/go/auth v0.18.2 // indirect
+	cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
+	cloud.google.com/go/compute/metadata v0.9.0 // indirect
+	cloud.google.com/go/iam v1.5.3 // indirect
+	cloud.google.com/go/monitoring v1.24.3 // indirect
+	cloud.google.com/go/storage v1.60.0 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 // indirect
+	github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
+	github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
+	github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
+	github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
+	github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
+	github.com/aws/aws-sdk-go-v2/config v1.32.17 // indirect
+	github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
+	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
+	github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
+	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
+	github.com/aws/smithy-go v1.25.1 // indirect
+	github.com/cespare/xxhash/v2 v2.3.0 // indirect
+	github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
+	github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
+	github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
+	github.com/felixge/httpsnoop v1.0.4 // indirect
+	github.com/fsnotify/fsnotify v1.9.0 // indirect
+	github.com/go-ini/ini v1.67.0 // indirect
+	github.com/go-jose/go-jose/v4 v4.1.4 // indirect
+	github.com/go-logr/logr v1.4.3 // indirect
+	github.com/go-logr/stdr v1.2.2 // indirect
+	github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
+	github.com/goccy/go-json v0.10.5 // indirect
+	github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+	github.com/google/s2a-go v0.1.9 // indirect
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
+	github.com/googleapis/gax-go/v2 v2.17.0 // indirect
+	github.com/json-iterator/go v1.1.12 // indirect
+	github.com/klauspost/compress v1.18.4 // indirect
+	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+	github.com/klauspost/crc32 v1.3.0 // indirect
+	github.com/kylelemons/godebug v1.1.0 // indirect
+	github.com/mattn/go-colorable v0.1.14 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/minio/crc64nvme v1.1.1 // indirect
+	github.com/minio/md5-simd v1.1.2 // indirect
+	github.com/minio/minio-go/v7 v7.0.98 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
+	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+	github.com/philhofer/fwd v1.2.0 // indirect
+	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+	github.com/pkg/errors v0.9.1 // indirect
+	github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
+	github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+	github.com/prometheus/client_model v0.6.2 // indirect
+	github.com/prometheus/common v0.67.5 // indirect
+	github.com/rs/xid v1.6.0 // indirect
+	github.com/rs/zerolog v1.34.0 // indirect
+	github.com/sagikazarmark/locafero v0.12.0 // indirect
+	github.com/spf13/afero v1.15.0 // indirect
+	github.com/spf13/cast v1.10.0 // indirect
+	github.com/spf13/pflag v1.0.10 // indirect
+	github.com/spf13/viper v1.21.0 // indirect
+	github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
+	github.com/subosito/gotenv v1.6.0 // indirect
+	github.com/tinylib/msgp v1.6.3 // indirect
+	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+	go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
+	go.opentelemetry.io/otel v1.41.0 // indirect
+	go.opentelemetry.io/otel/metric v1.41.0 // indirect
+	go.opentelemetry.io/otel/sdk v1.41.0 // indirect
+	go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
+	go.opentelemetry.io/otel/trace v1.41.0 // indirect
+	go.yaml.in/yaml/v2 v2.4.3 // indirect
+	go.yaml.in/yaml/v3 v3.0.4 // indirect
+	golang.org/x/crypto v0.49.0 // indirect
+	golang.org/x/net v0.52.0 // indirect
+	golang.org/x/oauth2 v0.35.0 // indirect
+	golang.org/x/sync v0.20.0 // indirect
+	golang.org/x/sys v0.42.0 // indirect
+	golang.org/x/text v0.36.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.4

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

@@ -0,0 +1,272 @@
+cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
+cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
+cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
+cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
+cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
+cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
+cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
+cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
+cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
+cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
+cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
+cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
+cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA=
+cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak=
+cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
+cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
+cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
+cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
+cloud.google.com/go/storage v1.60.0 h1:oBfZrSOCimggVNz9Y/bXY35uUcts7OViubeddTTVzQ8=
+cloud.google.com/go/storage v1.60.0/go.mod h1:q+5196hXfejkctrnx+VYU8RKQr/L3c0cBIlrjmiAKE0=
+cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
+cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew=
+github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
+github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
+github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
+github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
+github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
+github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
+github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
+github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
+github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
+github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
+github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
+github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
+github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
+github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
+github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
+github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
+github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
+github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
+github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
+github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
+github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
+github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
+github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
+github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
+github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
+github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
+github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
+github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
+github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
+github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
+github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
+github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
+github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
+github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
+github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
+github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
+github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
+github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
+github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
+github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
+github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
+github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA=
+go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0 h1:XmiuHzgJt067+a6kwyAzkhXooYVv3/TOw9cM2VfJgUM=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.65.0/go.mod h1:KDgtbWKTQs4bM+VPUr6WlL9m/WXcmkCcBlIzqxPGzmI=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
+go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
+go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI=
+go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
+go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
+go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
+go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
+go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
+go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
+go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
+go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
+go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
+go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
+golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
+golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
+golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
+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=

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

@@ -0,0 +1,522 @@
+package basic
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"sync"
+
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/reader"
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+// PricingModule must satisfy the pricing.PricingModule interface
+var _ pricing.PricingModule = (*PricingModule)(nil)
+
+const SourceKind = "basic"
+const SourceName = "basic"
+
+type PricingModule struct {
+	mu    sync.RWMutex
+	store pricing.PricingStore
+}
+
+func NewBasicPricingModule(store pricing.PricingStore) (*PricingModule, error) {
+	pricingSet, err := store.GetPricingSet(context.Background())
+	if err != nil {
+		return nil, fmt.Errorf("checking pricing store: %w", err)
+	}
+
+	if pricingSet.IsEmpty() {
+		// Populate store with a default pricing set.
+		err := store.SetPricingSet(context.Background(), GetDefaultPricingSet())
+		if err != nil {
+			return nil, fmt.Errorf("setting default pricing: %w", err)
+		}
+
+		pricingSet, err = store.GetPricingSet(context.Background())
+		if err != nil {
+			return nil, fmt.Errorf("checking default pricing: %w", err)
+		}
+
+		if pricingSet.IsEmpty() {
+			return nil, errors.New("unable to initialize store")
+		}
+	}
+
+	pm := &PricingModule{
+		store: store,
+	}
+
+	return pm, nil
+}
+
+func (pm *PricingModule) NewClusterPricingReader(ctx context.Context) (reader.Reader[*pricing.ClusterPricing], error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	cp, err := pm.getClusterPricing(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting node pricing: %w", err)
+	}
+
+	return reader.NewSliceReader([]*pricing.ClusterPricing{cp}), nil
+}
+
+func (pm *PricingModule) NewNetworkPricingReader(ctx context.Context) (reader.Reader[*pricing.NetworkPricing], error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	np, err := pm.getNetworkPricing(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting node pricing: %w", err)
+	}
+
+	return reader.NewSliceReader([]*pricing.NetworkPricing{np}), nil
+}
+
+func (pm *PricingModule) NewNodePricingReader(ctx context.Context) (reader.Reader[*pricing.NodePricing], error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting node pricing: %w", err)
+	}
+
+	return reader.NewSliceReader([]*pricing.NodePricing{np}), nil
+}
+
+func (pm *PricingModule) NewPersistentVolumePricingReader(ctx context.Context) (reader.Reader[*pricing.PersistentVolumePricing], error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	pvp, err := pm.getPersistentVolumePricing(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting volume pricing: %w", err)
+	}
+
+	return reader.NewSliceReader([]*pricing.PersistentVolumePricing{pvp}), nil
+}
+
+func (pm *PricingModule) NewServicePricingReader(ctx context.Context) (reader.Reader[*pricing.ServicePricing], error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	sp, err := pm.getServicePricing(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting service pricing: %w", err)
+	}
+
+	return reader.NewSliceReader([]*pricing.ServicePricing{sp}), nil
+}
+
+func (pm *PricingModule) GetPricingSet(ctx context.Context) (*pricing.PricingSet, error) {
+	pm.mu.RLock()
+	defer pm.mu.RUnlock()
+
+	return pm.store.GetPricingSet(ctx)
+}
+
+func (pm *PricingModule) SourceKind() string {
+	return SourceKind
+}
+
+func (pm *PricingModule) SourceName() string {
+	return SourceName
+}
+
+func (pm *PricingModule) Checksum(ctx context.Context) (string, error) {
+	pricingSet, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return "", fmt.Errorf("basic pricing module: error getting pricing set: %w", err)
+	}
+
+	checksum, err := pricingSet.Checksum()
+	if err != nil {
+		return "", fmt.Errorf("basic pricing module: error computing checksum: %s", err)
+	}
+
+	return checksum, nil
+}
+
+// Public CRUD functions
+
+func (pm *PricingModule) SetClusterPricePerHour(ctx context.Context, price float64) error {
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setClusterPrice(ctx, pricing.ResourceCluster, unit.Hour, price)
+}
+
+func (pm *PricingModule) SetNetworkLocalEgressPricePerGiB(ctx context.Context, price float64) error {
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setNetworkPrice(ctx, pricing.ResourceLocalEgress, unit.GiB, price)
+}
+
+func (pm *PricingModule) SetNetworkCrossZoneEgressPricePerGiB(ctx context.Context, price float64) error {
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setNetworkPrice(ctx, pricing.ResourceCrossZoneEgress, unit.GiB, price)
+}
+
+func (pm *PricingModule) SetNetworkCrossRegionEgressPricePerGiB(ctx context.Context, price float64) error {
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setNetworkPrice(ctx, pricing.ResourceCrossRegionEgress, unit.GiB, price)
+}
+
+func (pm *PricingModule) SetNetworkInternetEgressPricePerGiB(ctx context.Context, price float64) error {
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setNetworkPrice(ctx, pricing.ResourceInternetEgress, unit.GiB, price)
+}
+
+func (pm *PricingModule) SetNetworkNATGatewayEgressPricePerGiB(ctx context.Context, price float64) error {
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setNetworkPrice(ctx, pricing.ResourceNATGatewayEgress, unit.GiB, price)
+}
+
+func (pm *PricingModule) SetNetworkNATGatewayIngressPricePerGiB(ctx context.Context, price float64) error {
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setNetworkPrice(ctx, pricing.ResourceNATGatewayIngress, unit.GiB, price)
+}
+
+func (pm *PricingModule) SetNodePricePerCPUCoreHour(ctx context.Context, price float64) error {
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setNodePrice(ctx, pricing.ResourceCPU, unit.VCPUHour, price)
+}
+
+func (pm *PricingModule) SetNodePricePerRAMGiBHour(ctx context.Context, price float64) error {
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setNodePrice(ctx, pricing.ResourceRAM, unit.GiBHour, price)
+}
+
+func (pm *PricingModule) SetNodePricePerGPUHour(ctx context.Context, price float64) error {
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setNodePrice(ctx, pricing.ResourceGPU, unit.GPUHour, price)
+}
+
+func (pm *PricingModule) SetNodePricePerLocalDiskGiBHour(ctx context.Context, price float64) error {
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setNodePrice(ctx, pricing.ResourceStorage, unit.GiBHour, price)
+}
+
+func (pm *PricingModule) SetPersistentVolumePricePerStorageGiBHour(ctx context.Context, price float64) error {
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setPersistentVolumePrice(ctx, pricing.ResourceStorage, unit.GiBHour, price)
+}
+
+func (pm *PricingModule) SetServicePricePerHour(ctx context.Context, price float64) error {
+	pm.mu.Lock()
+	defer pm.mu.Unlock()
+
+	return pm.setServicePrice(ctx, pricing.ResourceService, unit.Hour, price)
+}
+
+// Private functions to set a price by resource and unit
+
+func (pm *PricingModule) setClusterPrice(ctx context.Context, resource pricing.Resource, unit unit.Unit, price float64) error {
+	cp, err := pm.getClusterPricing(ctx)
+	if err != nil {
+		return fmt.Errorf("getting cluster pricing: %w", err)
+	}
+
+	if cp.Prices == nil {
+		cp.Prices = pricing.Prices{}
+	}
+	cp.Prices[resource] = pricing.Price{Unit: unit, Price: price}
+
+	err = pm.setClusterPricing(ctx, cp)
+	if err != nil {
+		return fmt.Errorf("setting cluster pricing: %w", err)
+	}
+
+	return nil
+}
+
+func (pm *PricingModule) setNetworkPrice(ctx context.Context, resource pricing.Resource, unit unit.Unit, price float64) error {
+	np, err := pm.getNetworkPricing(ctx)
+	if err != nil {
+		return fmt.Errorf("getting network pricing: %w", err)
+	}
+
+	if np.Prices == nil {
+		np.Prices = pricing.Prices{}
+	}
+	np.Prices[resource] = pricing.Price{Unit: unit, Price: price}
+
+	err = pm.setNetworkPricing(ctx, np)
+	if err != nil {
+		return fmt.Errorf("setting network pricing: %w", err)
+	}
+
+	return nil
+}
+
+func (pm *PricingModule) setNodePrice(ctx context.Context, resource pricing.Resource, unit unit.Unit, price float64) error {
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		return fmt.Errorf("getting node pricing: %w", err)
+	}
+
+	if np.Prices == nil {
+		np.Prices = pricing.Prices{}
+	}
+	np.Prices[resource] = pricing.Price{Unit: unit, Price: price}
+
+	err = pm.setNodePricing(ctx, np)
+	if err != nil {
+		return fmt.Errorf("setting node pricing: %w", err)
+	}
+
+	return nil
+}
+
+func (pm *PricingModule) setPersistentVolumePrice(ctx context.Context, resource pricing.Resource, unit unit.Unit, price float64) error {
+	vp, err := pm.getPersistentVolumePricing(ctx)
+	if err != nil {
+		return fmt.Errorf("getting volume pricing: %w", err)
+	}
+
+	if vp.Prices == nil {
+		vp.Prices = pricing.Prices{}
+	}
+	vp.Prices[resource] = pricing.Price{Unit: unit, Price: price}
+
+	err = pm.setPersistentVolumePricing(ctx, vp)
+	if err != nil {
+		return fmt.Errorf("setting volume pricing: %w", err)
+	}
+
+	return nil
+}
+
+func (pm *PricingModule) setServicePrice(ctx context.Context, resource pricing.Resource, unit unit.Unit, price float64) error {
+	sp, err := pm.getServicePricing(ctx)
+	if err != nil {
+		return fmt.Errorf("getting service pricing: %w", err)
+	}
+
+	if sp.Prices == nil {
+		sp.Prices = pricing.Prices{}
+	}
+	sp.Prices[resource] = pricing.Price{Unit: unit, Price: price}
+
+	err = pm.setServicePricing(ctx, sp)
+	if err != nil {
+		return fmt.Errorf("setting service pricing: %w", err)
+	}
+
+	return nil
+}
+
+// Private functions to get and set pricing
+
+func (pm *PricingModule) getClusterPricing(ctx context.Context) (*pricing.ClusterPricing, error) {
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting pricing: %w", err)
+	}
+
+	if len(ps.ClusterPricing) == 0 {
+		return nil, errors.New("not found")
+	}
+
+	// Only one default ClusterPricing is allowed in basic pricing.
+	// If multiple exist, return only the first one.
+	return ps.ClusterPricing[0], nil
+}
+
+func (pm *PricingModule) setClusterPricing(ctx context.Context, cp *pricing.ClusterPricing) error {
+	if cp == nil {
+		return errors.New("nil cluster pricing")
+	}
+
+	// Get the pricing set
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return fmt.Errorf("getting pricing: %w", err)
+	}
+
+	// Only one default ClusterPricing is allowed in basic pricing.
+	ps.ClusterPricing = []*pricing.ClusterPricing{cp}
+
+	// Set the new pricing set
+	err = pm.store.SetPricingSet(ctx, ps)
+	if err != nil {
+		return fmt.Errorf("setting pricing: %w", err)
+	}
+
+	return nil
+}
+
+func (pm *PricingModule) getNetworkPricing(ctx context.Context) (*pricing.NetworkPricing, error) {
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting pricing: %w", err)
+	}
+
+	if len(ps.NetworkPricing) == 0 {
+		return nil, errors.New("not found")
+	}
+
+	return ps.NetworkPricing[0], nil
+}
+
+func (pm *PricingModule) setNetworkPricing(ctx context.Context, np *pricing.NetworkPricing) error {
+	if np == nil {
+		return errors.New("nil network pricing")
+	}
+
+	// Get the pricing set
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return fmt.Errorf("getting pricing: %w", err)
+	}
+
+	// Only one default NetworkPricing is allowed in basic pricing.
+	ps.NetworkPricing = []*pricing.NetworkPricing{np}
+
+	// Set the new pricing set
+	err = pm.store.SetPricingSet(ctx, ps)
+	if err != nil {
+		return fmt.Errorf("setting pricing: %w", err)
+	}
+
+	return nil
+}
+
+func (pm *PricingModule) getNodePricing(ctx context.Context) (*pricing.NodePricing, error) {
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting pricing: %w", err)
+	}
+
+	if len(ps.NodePricing) == 0 {
+		return nil, errors.New("not found")
+	}
+
+	// Only one default NodePricing is allowed in basic pricing.
+	// If multiple exist, return only the first one.
+	return ps.NodePricing[0], nil
+}
+
+func (pm *PricingModule) setNodePricing(ctx context.Context, np *pricing.NodePricing) error {
+	if np == nil {
+		return errors.New("nil node pricing")
+	}
+
+	// Get the pricing set
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return fmt.Errorf("getting pricing: %w", err)
+	}
+
+	// Only one default NodePricing is allowed in basic pricing.
+	ps.NodePricing = []*pricing.NodePricing{np}
+
+	// Set the new pricing set
+	err = pm.store.SetPricingSet(ctx, ps)
+	if err != nil {
+		return fmt.Errorf("setting pricing: %w", err)
+	}
+
+	return nil
+}
+
+func (pm *PricingModule) getPersistentVolumePricing(ctx context.Context) (*pricing.PersistentVolumePricing, error) {
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting pricing: %w", err)
+	}
+
+	if len(ps.PersistentVolumePricing) == 0 {
+		return nil, errors.New("not found")
+	}
+
+	// Only one default VolumePricing is allowed in basic pricing.
+	// If multiple exist, return only the first one.
+	return ps.PersistentVolumePricing[0], nil
+}
+
+func (pm *PricingModule) setPersistentVolumePricing(ctx context.Context, vp *pricing.PersistentVolumePricing) error {
+	if vp == nil {
+		return errors.New("nil volume pricing")
+	}
+
+	// Get the pricing set
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return fmt.Errorf("getting pricing: %w", err)
+	}
+
+	// Only one default VolumePricing is allowed in basic pricing.
+	ps.PersistentVolumePricing = []*pricing.PersistentVolumePricing{vp}
+
+	// Set the new pricing set
+	err = pm.store.SetPricingSet(ctx, ps)
+	if err != nil {
+		return fmt.Errorf("setting pricing: %w", err)
+	}
+
+	return nil
+}
+
+func (pm *PricingModule) getServicePricing(ctx context.Context) (*pricing.ServicePricing, error) {
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting pricing: %w", err)
+	}
+
+	if len(ps.ServicePricing) == 0 {
+		return nil, errors.New("not found")
+	}
+
+	// Only one default ServicePricing is allowed in basic pricing.
+	// If multiple exist, return only the first one.
+	return ps.ServicePricing[0], nil
+}
+
+func (pm *PricingModule) setServicePricing(ctx context.Context, sp *pricing.ServicePricing) error {
+	if sp == nil {
+		return errors.New("nil service pricing")
+	}
+
+	// Get the pricing set
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return fmt.Errorf("getting pricing: %w", err)
+	}
+
+	// Only one default ServicePricing is allowed in basic pricing.
+	ps.ServicePricing = []*pricing.ServicePricing{sp}
+
+	// Set the new pricing set
+	err = pm.store.SetPricingSet(ctx, ps)
+	if err != nil {
+		return fmt.Errorf("setting pricing: %w", err)
+	}
+
+	return nil
+}

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

@@ -0,0 +1,660 @@
+package basic
+
+import (
+	"context"
+	"errors"
+	"io"
+	"os"
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/storage"
+	"github.com/opencost/opencost/core/pkg/unit"
+	"github.com/stretchr/testify/require"
+)
+
+// TestNewBasicPricingModuleEmptyStore verifies the constructor populates a
+// default pricing set when given an empty store.
+func TestNewBasicPricingModuleEmptyStore(t *testing.T) {
+	store := pricing.NewMemoryPricingStore()
+
+	pm, err := NewBasicPricingModule(store)
+	require.NoError(t, err)
+
+	ps, err := store.GetPricingSet(t.Context())
+	require.NoError(t, err)
+	require.False(t, ps.IsEmpty())
+
+	np, err := pm.getNodePricing(t.Context())
+	require.NoError(t, err)
+	require.NotNil(t, np)
+}
+
+func TestPricingModule(t *testing.T) {
+	memoryPricingStore := pricing.NewMemoryPricingStore()
+
+	filePricingStore, err := pricing.NewStoragePricingStore(t.Context(), newFileStorage(t), "pricing.json")
+	require.NoError(t, err)
+
+	stores := map[string]pricing.PricingStore{
+		"MemoryPricingStore":  memoryPricingStore,
+		"StoragePricingStore": filePricingStore,
+	}
+
+	for name, store := range stores {
+		t.Run(name, testPricingModuleWithStore(store))
+	}
+}
+
+func testPricingModuleWithStore(store pricing.PricingStore) func(t *testing.T) {
+	return func(t *testing.T) {
+		ctx := t.Context()
+
+		pm, err := NewBasicPricingModule(store)
+		require.NoError(t, err)
+
+		t.Run("DefaultPricing", func(t *testing.T) {
+			testDefaultPricing(t, ctx, pm)
+		})
+
+		t.Run("Metadata", func(t *testing.T) {
+			testMetadata(t, pm)
+		})
+
+		t.Run("GetPricingSet", func(t *testing.T) {
+			testGetPricingSet(t, ctx, pm)
+		})
+
+		t.Run("SetClusterPricePerHour", func(t *testing.T) {
+			testSetClusterPricePerHour(t, ctx, pm)
+		})
+
+		t.Run("SetNetworkPrices", func(t *testing.T) {
+			testSetNetworkPrices(t, ctx, pm)
+		})
+
+		t.Run("SetServicePricePerHour", func(t *testing.T) {
+			testSetServicePricePerHour(t, ctx, pm)
+		})
+
+		t.Run("Checksum", func(t *testing.T) {
+			testChecksum(t, ctx, pm)
+		})
+
+		t.Run("SetNodePricePerCPUCoreHour", func(t *testing.T) {
+			testSetNodePricePerCPUCoreHour(t, ctx, pm)
+		})
+
+		t.Run("SetNodePricePerRAMGiBHour", func(t *testing.T) {
+			testSetNodePricePerRAMGiBHour(t, ctx, pm)
+		})
+
+		t.Run("SetNodePricePerGPUHour", func(t *testing.T) {
+			testSetNodePricePerGPUHour(t, ctx, pm)
+		})
+
+		t.Run("SetNodePricePerLocalDiskGiBHour", func(t *testing.T) {
+			testSetNodePricePerLocalDiskGiBHour(t, ctx, pm)
+		})
+
+		t.Run("SetVolumePricePerStorageGiBHour", func(t *testing.T) {
+			testSetVolumePricePerStorageGiBHour(t, ctx, pm)
+		})
+
+		t.Run("NewNodePricingReader", func(t *testing.T) {
+			testNewNodePricingReader(t, ctx, pm)
+		})
+
+		t.Run("NewVolumePricingReader", func(t *testing.T) {
+			testNewVolumePricingReader(t, ctx, pm)
+		})
+
+		t.Run("NewClusterPricingReader", func(t *testing.T) {
+			testNewClusterPricingReader(t, ctx, pm)
+		})
+
+		t.Run("NewNetworkPricingReader", func(t *testing.T) {
+			testNewNetworkPricingReader(t, ctx, pm)
+		})
+
+		t.Run("NewServicePricingReader", func(t *testing.T) {
+			testNewServicePricingReader(t, ctx, pm)
+		})
+
+		t.Run("ModulePersistence", func(t *testing.T) {
+			// Create a new PricingModule with the same store
+			pm2, err := NewBasicPricingModule(store)
+			require.NoError(t, err)
+
+			// Verify that pricing persists across all resource kinds.
+			np, err := pm2.getNodePricing(ctx)
+			require.NoError(t, err, "getting node pricing")
+			require.NotNil(t, np, "expected node pricing to be persisted")
+
+			cp, err := pm2.getClusterPricing(ctx)
+			require.NoError(t, err, "getting cluster pricing")
+			require.NotNil(t, cp, "expected cluster pricing to be persisted")
+
+			netp, err := pm2.getNetworkPricing(ctx)
+			require.NoError(t, err, "getting network pricing")
+			require.NotNil(t, netp, "expected network pricing to be persisted")
+
+			vp, err := pm2.getPersistentVolumePricing(ctx)
+			require.NoError(t, err, "getting volume pricing")
+			require.NotNil(t, vp, "expected volume pricing to be persisted")
+
+			sp, err := pm2.getServicePricing(ctx)
+			require.NoError(t, err, "getting service pricing")
+			require.NotNil(t, sp, "expected service pricing to be persisted")
+		})
+	}
+}
+
+// testDefaultPricing verifies that a freshly created PricingModule contains default pricing
+func testDefaultPricing(t *testing.T, ctx context.Context, pm *PricingModule) {
+	// Test default node pricing
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get node pricing: %v", err)
+	}
+
+	if np == nil {
+		t.Fatal("Expected node pricing to exist")
+	}
+
+	// Prices are keyed by Resource. RAM and local disk share the GiB-hr unit,
+	// so the Resource key is what distinguishes them.
+	nodeChecks := []struct {
+		resource pricing.Resource
+		want     float64
+	}{
+		{pricing.ResourceCPU, DefaultNodePricePerVCPUHour},
+		{pricing.ResourceRAM, DefaultNodePricePerRAMGiBHour},
+		{pricing.ResourceGPU, DefaultNodePricePerGPUHour},
+		{pricing.ResourceStorage, DefaultNodePricePerLocalDiskGiBHour},
+	}
+	for _, c := range nodeChecks {
+		price, ok := np.Prices[c.resource]
+		if !ok {
+			t.Errorf("Expected to find %s pricing", c.resource)
+			continue
+		}
+		if price.Price != c.want {
+			t.Errorf("Expected %s price to be %f, got %f", c.resource, c.want, price.Price)
+		}
+	}
+
+	// Test default volume pricing
+	vp, err := pm.getPersistentVolumePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get volume pricing: %v", err)
+	}
+
+	if vp == nil {
+		t.Fatal("Expected volume pricing to exist")
+	}
+
+	volumePrice, ok := vp.Prices[pricing.ResourceStorage]
+	if !ok {
+		t.Fatal("Expected to find volume storage pricing")
+	}
+	if volumePrice.Price != DefaultPersistentVolumePricePerGiBHour {
+		t.Errorf("Expected volume price to be %f, got %f", DefaultPersistentVolumePricePerGiBHour, volumePrice.Price)
+	}
+
+	// Test default cluster pricing
+	cp, err := pm.getClusterPricing(ctx)
+	require.NoError(t, err, "getting cluster pricing")
+	require.NotNil(t, cp, "expected cluster pricing to exist")
+
+	clusterPrice, ok := cp.Prices[pricing.ResourceCluster]
+	require.True(t, ok, "expected to find cluster pricing")
+	require.Equal(t, DefaultClusterPricePerHour, clusterPrice.Price)
+	require.Equal(t, unit.Hour, clusterPrice.Unit)
+
+	// Test default network pricing. RAM, egress types, etc. all share the GiB
+	// unit, so the Resource key distinguishes them.
+	netp, err := pm.getNetworkPricing(ctx)
+	require.NoError(t, err, "getting network pricing")
+	require.NotNil(t, netp, "expected network pricing to exist")
+
+	networkChecks := []struct {
+		resource pricing.Resource
+		want     float64
+	}{
+		{pricing.ResourceLocalEgress, DefaultNetworkLocalEgressPricePerGiB},
+		{pricing.ResourceCrossZoneEgress, DefaultNetworkCrossZoneEgressPricePerGiB},
+		{pricing.ResourceCrossRegionEgress, DefaultNetworkCrossRegionEgressPricePerGiB},
+		{pricing.ResourceInternetEgress, DefaultNetworkInternetEgressPricePerGiB},
+		{pricing.ResourceNATGatewayEgress, DefaultNetworkNATGatewayEgressPricePerGiB},
+		{pricing.ResourceNATGatewayIngress, DefaultNetworkNATGatewayIngressPricePerGiB},
+	}
+	for _, c := range networkChecks {
+		price, ok := netp.Prices[c.resource]
+		require.Truef(t, ok, "expected to find %s pricing", c.resource)
+		require.Equalf(t, c.want, price.Price, "price for %s", c.resource)
+		require.Equalf(t, unit.GiB, price.Unit, "unit for %s", c.resource)
+	}
+
+	// Test default service pricing
+	sp, err := pm.getServicePricing(ctx)
+	require.NoError(t, err, "getting service pricing")
+	require.NotNil(t, sp, "expected service pricing to exist")
+
+	servicePrice, ok := sp.Prices[pricing.ResourceService]
+	require.True(t, ok, "expected to find service pricing")
+	require.Equal(t, DefaultServicePricePerHour, servicePrice.Price)
+	require.Equal(t, unit.Hour, servicePrice.Unit)
+}
+
+// testSetNodePricePerCPUCoreHour tests the SetNodePricePerCPUCoreHour function
+func testSetNodePricePerCPUCoreHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 0.075
+
+	err := pm.SetNodePricePerCPUCoreHour(ctx, newPrice)
+	if err != nil {
+		t.Fatalf("Failed to set CPU price: %v", err)
+	}
+
+	// Verify the price was set
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get node pricing: %v", err)
+	}
+
+	price, ok := np.Prices[pricing.ResourceCPU]
+	if !ok {
+		t.Fatal("Expected to find CPU pricing")
+	}
+	if price.Price != newPrice {
+		t.Errorf("Expected CPU price to be %f, got %f", newPrice, price.Price)
+	}
+}
+
+// testSetNodePricePerRAMGiBHour tests the SetNodePricePerRAMGiBHour function
+func testSetNodePricePerRAMGiBHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 0.008
+
+	err := pm.SetNodePricePerRAMGiBHour(ctx, newPrice)
+	if err != nil {
+		t.Fatalf("Failed to set RAM price: %v", err)
+	}
+
+	// Verify the price was set
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get node pricing: %v", err)
+	}
+
+	price, ok := np.Prices[pricing.ResourceRAM]
+	if !ok {
+		t.Fatal("Expected to find RAM pricing")
+	}
+	if price.Price != newPrice {
+		t.Errorf("Expected RAM price to be %f, got %f", newPrice, price.Price)
+	}
+}
+
+// testSetNodePricePerGPUHour tests the SetNodePricePerGPUHour function
+func testSetNodePricePerGPUHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 2.0
+
+	err := pm.SetNodePricePerGPUHour(ctx, newPrice)
+	if err != nil {
+		t.Fatalf("Failed to set GPU price: %v", err)
+	}
+
+	// Verify the price was set
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get node pricing: %v", err)
+	}
+
+	price, ok := np.Prices[pricing.ResourceGPU]
+	if !ok {
+		t.Fatal("Expected to find GPU pricing")
+	}
+	if price.Price != newPrice {
+		t.Errorf("Expected GPU price to be %f, got %f", newPrice, price.Price)
+	}
+}
+
+// testSetNodePricePerLocalDiskGiBHour tests the SetNodePricePerLocalDiskGiBHour function
+func testSetNodePricePerLocalDiskGiBHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 0.0007
+
+	err := pm.SetNodePricePerLocalDiskGiBHour(ctx, newPrice)
+	if err != nil {
+		t.Fatalf("Failed to set local disk price: %v", err)
+	}
+
+	// Verify the price was set
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get node pricing: %v", err)
+	}
+
+	price, ok := np.Prices[pricing.ResourceStorage]
+	if !ok {
+		t.Fatal("Expected to find local disk pricing")
+	}
+	if price.Price != newPrice {
+		t.Errorf("Expected local disk price to be %f, got %f", newPrice, price.Price)
+	}
+}
+
+// testSetVolumePricePerStorageGiBHour tests the SetVolumePricePerStorageGiBHour function
+func testSetVolumePricePerStorageGiBHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 0.0003
+
+	err := pm.SetPersistentVolumePricePerStorageGiBHour(ctx, newPrice)
+	if err != nil {
+		t.Fatalf("Failed to set volume storage price: %v", err)
+	}
+
+	// Verify the price was set
+	vp, err := pm.getPersistentVolumePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get volume pricing: %v", err)
+	}
+
+	price, ok := vp.Prices[pricing.ResourceStorage]
+	if !ok {
+		t.Fatal("Expected to find volume storage pricing")
+	}
+	if price.Price != newPrice {
+		t.Errorf("Expected volume storage price to be %f, got %f", newPrice, price.Price)
+	}
+}
+
+// testNewNodePricingReader tests the NewNodePricingReader function
+func testNewNodePricingReader(t *testing.T, ctx context.Context, pm *PricingModule) {
+	// Test that NewNodePricingReader always produces a reader
+	rdr, err := pm.NewNodePricingReader(ctx)
+	if err != nil {
+		t.Fatalf("Failed to create node pricing reader: %v", err)
+	}
+
+	if rdr == nil {
+		t.Fatal("Expected reader to be non-nil")
+	}
+
+	// Test that the reader produces precisely one *NodePricing struct
+	dst := make([]*pricing.NodePricing, 10) // Buffer larger than expected
+	count := 0
+
+	for {
+		n, err := rdr.Read(ctx, dst)
+		count += n
+
+		// Verify all read items are non-nil
+		for i := 0; i < n; i++ {
+			if dst[i] == nil {
+				t.Error("Expected non-nil NodePricing")
+			}
+		}
+
+		if errors.Is(err, io.EOF) {
+			break
+		}
+		if err != nil {
+			t.Fatalf("Reader error: %v", err)
+		}
+	}
+
+	if count != 1 {
+		t.Errorf("Expected reader to produce exactly 1 NodePricing, got %d", count)
+	}
+
+	// Clean up
+	if err := rdr.Close(); err != nil {
+		t.Errorf("Failed to close reader: %v", err)
+	}
+}
+
+// testNewVolumePricingReader tests the NewVolumePricingReader function
+func testNewVolumePricingReader(t *testing.T, ctx context.Context, pm *PricingModule) {
+	// Test that NewVolumePricingReader always produces a reader
+	rdr, err := pm.NewPersistentVolumePricingReader(ctx)
+	if err != nil {
+		t.Fatalf("Failed to create volume pricing reader: %v", err)
+	}
+
+	if rdr == nil {
+		t.Fatal("Expected reader to be non-nil")
+	}
+
+	// Test that the reader produces precisely one *VolumePricing struct
+	dst := make([]*pricing.PersistentVolumePricing, 10) // Buffer larger than expected
+	count := 0
+
+	for {
+		n, err := rdr.Read(ctx, dst)
+		count += n
+
+		// Verify all read items are non-nil
+		for i := 0; i < n; i++ {
+			if dst[i] == nil {
+				t.Error("Expected non-nil VolumePricing")
+			}
+		}
+
+		if errors.Is(err, io.EOF) {
+			break
+		}
+		if err != nil {
+			t.Fatalf("Reader error: %v", err)
+		}
+	}
+
+	if count != 1 {
+		t.Errorf("Expected reader to produce exactly 1 VolumePricing, got %d", count)
+	}
+
+	// Clean up
+	if err := rdr.Close(); err != nil {
+		t.Errorf("Failed to close reader: %v", err)
+	}
+}
+
+// testMetadata verifies the source identity accessors.
+func testMetadata(t *testing.T, pm *PricingModule) {
+	require.Equal(t, SourceKind, pm.SourceKind())
+	require.Equal(t, SourceName, pm.SourceName())
+}
+
+// testGetPricingSet verifies GetPricingSet returns a populated set.
+func testGetPricingSet(t *testing.T, ctx context.Context, pm *PricingModule) {
+	ps, err := pm.GetPricingSet(ctx)
+	require.NoError(t, err)
+	require.NotNil(t, ps)
+	require.False(t, ps.IsEmpty())
+}
+
+// testSetClusterPricePerHour tests the SetClusterPricePerHour function.
+func testSetClusterPricePerHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 1.25
+
+	err := pm.SetClusterPricePerHour(ctx, newPrice)
+	require.NoError(t, err)
+
+	cp, err := pm.getClusterPricing(ctx)
+	require.NoError(t, err)
+
+	price, ok := cp.Prices[pricing.ResourceCluster]
+	require.True(t, ok, "expected to find cluster pricing")
+	require.Equal(t, newPrice, price.Price)
+	require.Equal(t, unit.Hour, price.Unit)
+}
+
+// testSetNetworkPrices tests each network setter. Each case verifies that only
+// the targeted Resource changes and that the other network prices are left
+// intact, guarding against a setter writing the wrong Resource key.
+func testSetNetworkPrices(t *testing.T, ctx context.Context, pm *PricingModule) {
+	cases := []struct {
+		name     string
+		resource pricing.Resource
+		newPrice float64
+		set      func(context.Context, float64) error
+	}{
+		{"LocalEgress", pricing.ResourceLocalEgress, 0.002, pm.SetNetworkLocalEgressPricePerGiB},
+		{"CrossZoneEgress", pricing.ResourceCrossZoneEgress, 0.012, pm.SetNetworkCrossZoneEgressPricePerGiB},
+		{"CrossRegionEgress", pricing.ResourceCrossRegionEgress, 0.022, pm.SetNetworkCrossRegionEgressPricePerGiB},
+		{"InternetEgress", pricing.ResourceInternetEgress, 0.111, pm.SetNetworkInternetEgressPricePerGiB},
+		{"NATGatewayEgress", pricing.ResourceNATGatewayEgress, 0.055, pm.SetNetworkNATGatewayEgressPricePerGiB},
+		{"NATGatewayIngress", pricing.ResourceNATGatewayIngress, 0.066, pm.SetNetworkNATGatewayIngressPricePerGiB},
+	}
+
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			// Snapshot existing prices so we can verify non-targeted
+			// resources are untouched.
+			before, err := pm.getNetworkPricing(ctx)
+			require.NoError(t, err)
+			prev := make(map[pricing.Resource]float64, len(before.Prices))
+			for r, p := range before.Prices {
+				prev[r] = p.Price
+			}
+
+			require.NoError(t, c.set(ctx, c.newPrice))
+
+			after, err := pm.getNetworkPricing(ctx)
+			require.NoError(t, err)
+
+			price, ok := after.Prices[c.resource]
+			require.Truef(t, ok, "expected to find %s pricing", c.resource)
+			require.Equal(t, c.newPrice, price.Price)
+			require.Equal(t, unit.GiB, price.Unit)
+
+			// All other network resources must be unchanged.
+			for r, want := range prev {
+				if r == c.resource {
+					continue
+				}
+				require.Equalf(t, want, after.Prices[r].Price, "unexpected change to %s", r)
+			}
+		})
+	}
+}
+
+// testSetServicePricePerHour tests the SetServicePricePerHour function.
+func testSetServicePricePerHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 0.05
+
+	err := pm.SetServicePricePerHour(ctx, newPrice)
+	require.NoError(t, err)
+
+	sp, err := pm.getServicePricing(ctx)
+	require.NoError(t, err)
+
+	price, ok := sp.Prices[pricing.ResourceService]
+	require.True(t, ok, "expected to find service pricing")
+	require.Equal(t, newPrice, price.Price)
+	require.Equal(t, unit.Hour, price.Unit)
+}
+
+// testChecksum verifies the checksum is non-empty, deterministic, and sensitive
+// to pricing mutations.
+func testChecksum(t *testing.T, ctx context.Context, pm *PricingModule) {
+	sum1, err := pm.Checksum(ctx)
+	require.NoError(t, err)
+	require.NotEmpty(t, sum1)
+
+	// Deterministic: recomputing without mutation yields the same checksum.
+	sum2, err := pm.Checksum(ctx)
+	require.NoError(t, err)
+	require.Equal(t, sum1, sum2)
+
+	// Sensitive: mutating pricing changes the checksum.
+	require.NoError(t, pm.SetServicePricePerHour(ctx, sum1Sentinel))
+
+	sum3, err := pm.Checksum(ctx)
+	require.NoError(t, err)
+	require.NotEqual(t, sum1, sum3, "expected checksum to change after a price mutation")
+}
+
+// sum1Sentinel is an arbitrary price unlikely to match the existing service
+// price, used to force a checksum change.
+const sum1Sentinel = 0.0419
+
+// testNewClusterPricingReader verifies the cluster pricing reader yields exactly
+// one non-nil element.
+func testNewClusterPricingReader(t *testing.T, ctx context.Context, pm *PricingModule) {
+	rdr, err := pm.NewClusterPricingReader(ctx)
+	require.NoError(t, err)
+	require.NotNil(t, rdr)
+
+	dst := make([]*pricing.ClusterPricing, 10)
+	count := 0
+	for {
+		n, err := rdr.Read(ctx, dst)
+		count += n
+		for i := 0; i < n; i++ {
+			require.NotNil(t, dst[i], "expected non-nil ClusterPricing")
+		}
+		if errors.Is(err, io.EOF) {
+			break
+		}
+		require.NoError(t, err)
+	}
+	require.Equal(t, 1, count, "expected exactly 1 ClusterPricing")
+	require.NoError(t, rdr.Close())
+}
+
+// testNewNetworkPricingReader verifies the network pricing reader yields exactly
+// one non-nil element.
+func testNewNetworkPricingReader(t *testing.T, ctx context.Context, pm *PricingModule) {
+	rdr, err := pm.NewNetworkPricingReader(ctx)
+	require.NoError(t, err)
+	require.NotNil(t, rdr)
+
+	dst := make([]*pricing.NetworkPricing, 10)
+	count := 0
+	for {
+		n, err := rdr.Read(ctx, dst)
+		count += n
+		for i := 0; i < n; i++ {
+			require.NotNil(t, dst[i], "expected non-nil NetworkPricing")
+		}
+		if errors.Is(err, io.EOF) {
+			break
+		}
+		require.NoError(t, err)
+	}
+	require.Equal(t, 1, count, "expected exactly 1 NetworkPricing")
+	require.NoError(t, rdr.Close())
+}
+
+// testNewServicePricingReader verifies the service pricing reader yields exactly
+// one non-nil element.
+func testNewServicePricingReader(t *testing.T, ctx context.Context, pm *PricingModule) {
+	rdr, err := pm.NewServicePricingReader(ctx)
+	require.NoError(t, err)
+	require.NotNil(t, rdr)
+
+	dst := make([]*pricing.ServicePricing, 10)
+	count := 0
+	for {
+		n, err := rdr.Read(ctx, dst)
+		count += n
+		for i := 0; i < n; i++ {
+			require.NotNil(t, dst[i], "expected non-nil ServicePricing")
+		}
+		if errors.Is(err, io.EOF) {
+			break
+		}
+		require.NoError(t, err)
+	}
+	require.Equal(t, 1, count, "expected exactly 1 ServicePricing")
+	require.NoError(t, rdr.Close())
+}
+
+func newFileStorage(t *testing.T) storage.Storage {
+	tempDir, err := os.MkdirTemp("", "pricing-test-*")
+	if err != nil {
+		t.Fatalf("Failed to create temp directory: %v", err)
+	}
+	defer os.RemoveAll(tempDir)
+
+	return storage.NewFileStorage(tempDir)
+}

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

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

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

@@ -0,0 +1,196 @@
+package aws
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/cloud"
+	"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{
+		NodePricing:             []*pricing.NodePricing{},
+		PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
+	}
+	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.NodePricing), len(ps.PersistentVolumePricing))
+		}
+
+		// 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
+		}
+
+		// Handle node pricing
+		if isNode {
+			nodePricing := &pricing.NodePricing{
+				Properties: pricing.NodePricingProperties{
+					Provider:     cloud.ProviderAWS,
+					Region:       nk.Region,
+					InstanceType: nk.InstanceType,
+					Provisioning: pricing.ProvisioningOnDemand,
+				},
+				Prices: pricing.Prices{
+					pricing.ResourceNode: pricing.Price{
+						Unit:  unit.Hour,
+						Price: price,
+					},
+				},
+			}
+
+			ps.NodePricing = append(ps.NodePricing, nodePricing)
+		}
+
+		// Handle volume pricing
+		if isVolume {
+			// AWS volume pricing is per GB-month, convert to per GB-hour
+			hourlyPrice := price / 730.0
+
+			volumePricing := &pricing.PersistentVolumePricing{
+				Properties: pricing.PersistentVolumePricingProperties{
+					Provider:   cloud.ProviderAWS,
+					Region:     vk.Region,
+					VolumeType: vk.VolumeType,
+				},
+				Prices: pricing.Prices{
+					pricing.ResourceStorage: pricing.Price{
+						Unit:  unit.Hour,
+						Price: hourlyPrice,
+					},
+				},
+			}
+
+			ps.PersistentVolumePricing = append(ps.PersistentVolumePricing, 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.NodePricing), len(ps.PersistentVolumePricing))
+
+	return ps, nil
+}

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

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

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

@@ -0,0 +1,232 @@
+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/modules/pricing/public/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)
+		}
+	}()
+
+	if resp.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(resp.Body)
+		return fmt.Errorf("unexpected status %d fetching %q: %s", resp.StatusCode, pricingURL, strings.TrimSpace(string(body)))
+	}
+
+	dec := json.NewDecoder(resp.Body)
+	for {
+		t, err := dec.Token()
+		if err == io.EOF {
+			log.Infof("done loading %q", resp.Request.URL.String())
+			break
+		} else if err != nil {
+			return fmt.Errorf("parsing pricing list JSON from %q: %w", resp.Request.URL.String(), err)
+		}
+		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 {
+					return fmt.Errorf("decoding EC2 product from %q: %w", resp.Request.URL.String(), err)
+				}
+
+				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 {
+						return fmt.Errorf("decoding EC2 Offer Term from %q: %w", resp.Request.URL.String(), err)
+					}
+
+					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"`
+}
+
+// PriceListEC2PriceDimension encodes data about the price of a product
+type PriceListEC2PriceDimension struct {
+	Unit         string                   `json:"unit"`
+	PricePerUnit PriceListEC2PricePerUnit `json:"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/modules/pricing/public/env"
+)
+
+func TestGetListPriceURL(t *testing.T) {
+	t.Run("uses override when configured", func(t *testing.T) {
+		t.Setenv(env.AWSPricingURLEnvVar, "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.AWSPricingURLEnvVar, "")
+
+		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.AWSPricingURLEnvVar, "")
+
+		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.AWSPricingURLEnvVar, "")
+
+		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)
+}

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

@@ -0,0 +1,289 @@
+package azure
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/cloud"
+	"github.com/opencost/opencost/core/pkg/log"
+	"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{
+		NodePricing:             []*pricing.NodePricing{},
+		PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
+	}
+
+	// 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.NodePricing), pageCount)
+
+	// Fetch disk pricing
+	url = a.buildDiskURL()
+	diskPageCount := 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 disk page %d: %s", resp.StatusCode, diskPageCount, string(body))
+		}
+
+		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 {
+			return nil, fmt.Errorf("PricingSource (Azure): parsing disk page %d: %w", diskPageCount, err)
+		}
+
+		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.NodePricing), len(ps.PersistentVolumePricing))
+
+	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
+		}
+
+		nodePricing := &pricing.NodePricing{
+			Properties: pricing.NodePricingProperties{
+				Provider:     cloud.ProviderAzure,
+				Region:       item.ArmRegionName,
+				InstanceType: item.ArmSkuName,
+				Provisioning: pricing.ProvisioningOnDemand,
+			},
+			Prices: pricing.Prices{
+				pricing.ResourceNode: pricing.Price{
+					Unit:  unit.Hour,
+					Price: float64(item.RetailPrice),
+				},
+			},
+		}
+
+		ps.NodePricing = append(ps.NodePricing, 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
+		}
+
+		// Azure disk pricing is per GB-month, convert to per GB-hour
+		hourlyPrice := float64(item.RetailPrice) / 730.0
+
+		volumePricing := &pricing.PersistentVolumePricing{
+			Properties: pricing.PersistentVolumePricingProperties{
+				Provider:   cloud.ProviderAzure,
+				Region:     item.ArmRegionName,
+				VolumeType: volumeType,
+			},
+			Prices: pricing.Prices{
+				pricing.ResourceStorage: pricing.Price{
+					Unit:  unit.Hour,
+					Price: hourlyPrice,
+				},
+			},
+		}
+
+		ps.PersistentVolumePricing = append(ps.PersistentVolumePricing, 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
+	}
+	productLower := strings.ToLower(item.ProductName)
+	if strings.Contains(productLower, "windows") {
+		return false
+	}
+	if strings.Contains(productLower, "cloud services") || strings.Contains(productLower, "cloudservices") {
+		return false
+	}
+
+	skuLower := strings.ToLower(item.SkuName)
+	if strings.Contains(skuLower, "low priority") {
+		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"`
+}

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

@@ -0,0 +1,344 @@
+package azure
+
+import (
+	"strings"
+	"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 !strings.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 !strings.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)
+	}
+}

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

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

@@ -0,0 +1,165 @@
+package main
+
+import (
+	"bufio"
+	"encoding/json"
+	"fmt"
+	"os"
+	"strings"
+
+	"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 (
+	currency  string
+	compare   bool
+	outputDir 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 JSONL files.`,
+	RunE:  run,
+}
+
+func init() {
+	rootCmd.Flags().StringVarP(&currency, "currency", "c", "USD", "Currency code (e.g. USD, CNY). Default: USD")
+	rootCmd.Flags().BoolVar(&compare, "compare", false, "Compare freshly fetched pricing against the existing JSONL files; exits 2 if they differ")
+	rootCmd.Flags().StringVarP(&outputDir, "output", "o", "..", "Base output directory")
+}
+
+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)
+	}
+
+	log.Infof("Generating pricing for %s", curr)
+	pricingSet, err := public.GeneratePricing(curr)
+	if err != nil {
+		return fmt.Errorf("failed to generate pricing: %w", err)
+	}
+
+	log.Infof("Generated %d node pricing entries and %d volume pricing entries",
+		len(pricingSet.NodePricing), len(pricingSet.PersistentVolumePricing))
+
+	if compare {
+		return comparePricing(curr, pricingSet)
+	}
+
+	dir := fmt.Sprintf("%s/%s", outputDir, strings.ToLower(string(curr)))
+	return writePricingJSONL(dir, pricingSet)
+}
+
+// writePricingJSONL writes each pricing kind to its own JSONL file under dir.
+func writePricingJSONL(dir string, ps *pricing.PricingSet) error {
+	if err := writeJSONL(dir+"/nodes.jsonl", ps.NodePricing); err != nil {
+		return err
+	}
+	if err := writeJSONL(dir+"/persistentvolumes.jsonl", ps.PersistentVolumePricing); err != nil {
+		return err
+	}
+	return nil
+}
+
+// writeJSONL marshals each item in items as a single line and writes them to path.
+func writeJSONL[T any](path string, items []T) error {
+	f, err := os.Create(path)
+	if err != nil {
+		return fmt.Errorf("creating %s: %w", path, err)
+	}
+	defer f.Close()
+
+	w := bufio.NewWriter(f)
+	enc := json.NewEncoder(w) // Encode appends a trailing newline after each value.
+	for _, item := range items {
+		if err := enc.Encode(item); err != nil {
+			return fmt.Errorf("encoding record to %s: %w", path, err)
+		}
+	}
+	if err := w.Flush(); err != nil {
+		return fmt.Errorf("flushing %s: %w", path, err)
+	}
+	log.Infof("Wrote %d records to %s", len(items), path)
+	return nil
+}
+
+// comparePricing compares a fresh pricing set against the existing JSONL files
+// for a given currency.
+func comparePricing(curr unit.Currency, newSet *pricing.PricingSet) error {
+	dir := fmt.Sprintf("%s/%s", outputDir, strings.ToLower(string(curr)))
+	existingSet, err := readPricingJSONL(dir)
+	if err != nil {
+		return fmt.Errorf("reading existing pricing data from %s: %w", dir, err)
+	}
+	existingSet.Sort()
+
+	newChecksum, err := newSet.Checksum()
+	if err != nil {
+		return fmt.Errorf("failed to checksum new pricing data: %w", err)
+	}
+
+	existingChecksum, err := existingSet.Checksum()
+	if err != nil {
+		return fmt.Errorf("failed to checksum existing pricing data: %w", err)
+	}
+
+	if newChecksum != existingChecksum {
+		fmt.Fprintf(os.Stderr, "pricing drift detected for %s: existing=%s fresh=%s\n", curr, existingChecksum, newChecksum)
+		os.Exit(2)
+	}
+
+	log.Infof("Pricing data is up to date for %s (checksum: %s)", curr, existingChecksum)
+	return nil
+}
+
+// readPricingJSONL reads nodes.jsonl and persistentvolumes.jsonl from dir.
+func readPricingJSONL(dir string) (*pricing.PricingSet, error) {
+	ps := &pricing.PricingSet{}
+
+	nodes, err := readJSONL[*pricing.NodePricing](dir + "/nodes.jsonl")
+	if err != nil {
+		return nil, err
+	}
+	ps.NodePricing = nodes
+
+	pvs, err := readJSONL[*pricing.PersistentVolumePricing](dir + "/persistentvolumes.jsonl")
+	if err != nil {
+		return nil, err
+	}
+	ps.PersistentVolumePricing = pvs
+
+	return ps, nil
+}
+
+// readJSONL decodes every line of path into a slice of T.
+func readJSONL[T any](path string) ([]T, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, fmt.Errorf("opening %s: %w", path, err)
+	}
+	defer f.Close()
+
+	var items []T
+	dec := json.NewDecoder(f)
+	for dec.More() {
+		var item T
+		if err := dec.Decode(&item); err != nil {
+			return nil, fmt.Errorf("decoding record from %s: %w", path, err)
+		}
+		items = append(items, item)
+	}
+	return items, nil
+}

+ 122 - 0
modules/pricing/public/cny/module.go

@@ -0,0 +1,122 @@
+package cny
+
+import (
+	"context"
+	"embed"
+	"fmt"
+	"io"
+
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/reader"
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+// PricingModule must satisfy the pricing.PricingModule interface
+var _ pricing.PricingModule = (*PricingModule)(nil)
+
+//go:embed *.jsonl
+var embeddedFS embed.FS
+
+type PricingModule struct{}
+
+// NewPricingModule creates a new CNY pricing module backed by embedded JSONL files.
+func NewPricingModule() (*PricingModule, error) {
+	return &PricingModule{}, nil
+}
+
+func (pm *PricingModule) newNodeReader() (reader.Reader[*pricing.NodePricing], error) {
+	f, err := embeddedFS.Open("nodes.jsonl")
+	if err != nil {
+		return nil, fmt.Errorf("opening embedded nodes.jsonl: %w", err)
+	}
+	return reader.NewJSONLinesReader[*pricing.NodePricing](f), nil
+}
+
+func (pm *PricingModule) newPVReader() (reader.Reader[*pricing.PersistentVolumePricing], error) {
+	f, err := embeddedFS.Open("persistentvolumes.jsonl")
+	if err != nil {
+		return nil, fmt.Errorf("opening embedded persistentvolumes.jsonl: %w", err)
+	}
+	return reader.NewJSONLinesReader[*pricing.PersistentVolumePricing](f), nil
+}
+
+func (pm *PricingModule) NewNodePricingReader(ctx context.Context) (reader.Reader[*pricing.NodePricing], error) {
+	return pm.newNodeReader()
+}
+
+func (pm *PricingModule) NewPersistentVolumePricingReader(ctx context.Context) (reader.Reader[*pricing.PersistentVolumePricing], error) {
+	return pm.newPVReader()
+}
+
+func (pm *PricingModule) NewClusterPricingReader(ctx context.Context) (reader.Reader[*pricing.ClusterPricing], error) {
+	return nil, fmt.Errorf("cluster pricing not yet implemented")
+}
+
+func (pm *PricingModule) NewNetworkPricingReader(ctx context.Context) (reader.Reader[*pricing.NetworkPricing], error) {
+	return nil, fmt.Errorf("network pricing not yet implemented")
+}
+
+func (pm *PricingModule) NewServicePricingReader(ctx context.Context) (reader.Reader[*pricing.ServicePricing], error) {
+	return nil, fmt.Errorf("service pricing not yet implemented")
+}
+
+func (pm *PricingModule) GetPricingSet(ctx context.Context) (*pricing.PricingSet, error) {
+	ps := &pricing.PricingSet{}
+
+	nodeReader, err := pm.newNodeReader()
+	if err != nil {
+		return nil, err
+	}
+	defer nodeReader.Close()
+	dst := make([]*pricing.NodePricing, 64)
+	for {
+		n, err := nodeReader.Read(ctx, dst)
+		ps.NodePricing = append(ps.NodePricing, dst[:n]...)
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	pvReader, err := pm.newPVReader()
+	if err != nil {
+		return nil, err
+	}
+	defer pvReader.Close()
+	pvDst := make([]*pricing.PersistentVolumePricing, 64)
+	for {
+		n, err := pvReader.Read(ctx, pvDst)
+		ps.PersistentVolumePricing = append(ps.PersistentVolumePricing, pvDst[:n]...)
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return ps, nil
+}
+
+func (pm *PricingModule) SourceKind() string {
+	return "public"
+}
+
+func (pm *PricingModule) SourceName() string {
+	return "public-cny"
+}
+
+func (pm *PricingModule) Checksum(ctx context.Context) (string, error) {
+	ps, err := pm.GetPricingSet(ctx)
+	if err != nil {
+		return "", err
+	}
+	return ps.Checksum()
+}
+
+// Currency returns CNY
+func Currency() unit.Currency {
+	return unit.CNY
+}

+ 0 - 0
modules/pricing/public/cny/nodes.jsonl


+ 0 - 0
modules/pricing/public/cny/persistentvolumes.jsonl


+ 14 - 0
modules/pricing/public/env/env.go

@@ -0,0 +1,14 @@
+package env
+
+import (
+	"github.com/opencost/opencost/core/pkg/env"
+)
+
+const (
+	AWSPricingURLEnvVar = "AWS_PRICING_URL"
+)
+
+// GetAWSPricingURL returns an optional alternative URL to fetch AWS pricing data from; for use in airgapped environments
+func GetAWSPricingURL() string {
+	return env.Get(AWSPricingURLEnvVar, "")
+}

+ 341 - 0
modules/pricing/public/gcp/gcppricingsource.go

@@ -0,0 +1,341 @@
+package gcp
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"math"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/cloud"
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+var BillingAPIBaseURL = "https://cloudbilling.googleapis.com/v1/services/6F81-5844-456A/skus"
+
+var gcpHTTPClient = &http.Client{Timeout: 120 * time.Second}
+
+type GCPPricingSourceConfig struct {
+	APIKey       string
+	CurrencyCode string
+}
+
+type GCPPricingSource struct {
+	config GCPPricingSourceConfig
+}
+
+func NewGCPPricingSource(cfg GCPPricingSourceConfig) *GCPPricingSource {
+	return &GCPPricingSource{
+		config: cfg,
+	}
+}
+
+func (g *GCPPricingSource) GetPricing() (*pricing.PricingSet, error) {
+	log.Infof("PricingSource (GCP): starting pricing download")
+	start := time.Now()
+
+	ps := &pricing.PricingSet{
+		NodePricing:             []*pricing.NodePricing{},
+		PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
+	}
+
+	// Maps to accumulate CPU and RAM costs per node key
+	nodeCPUCosts := make(map[nodeKey]float64)
+	nodeRAMCosts := make(map[nodeKey]float64)
+
+	// Track volume pricing
+	volumeCosts := make(map[volumeKey]float64)
+
+	pageCount := 0
+	nextPageToken := ""
+
+	for {
+		pageURL := g.buildURL(nextPageToken)
+
+		resp, err := gcpHTTPClient.Get(pageURL)
+		if err != nil {
+			return nil, fmt.Errorf("PricingSource (GCP): GET %s: %w", pageURL, 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 (GCP): unexpected status %d on page %d: %s", resp.StatusCode, pageCount, string(body))
+		}
+
+		nextToken, err := g.parsePage(resp.Body, nodeCPUCosts, nodeRAMCosts, volumeCosts)
+		closeErr := resp.Body.Close()
+		if closeErr != nil {
+			log.Warnf("failed to close response body: %v", closeErr)
+		}
+		if err != nil {
+			return nil, fmt.Errorf("PricingSource (GCP): parsing page %d: %w", pageCount, err)
+		}
+
+		pageCount++
+		log.Debugf("PricingSource (GCP): fetched page %d, next token: %s", pageCount, nextToken)
+
+		if nextToken == "" {
+			break
+		}
+		nextPageToken = nextToken
+	}
+
+	// Build node pricing from accumulated CPU and RAM costs
+	g.buildNodePricing(ps, nodeCPUCosts, nodeRAMCosts)
+
+	// Build volume pricing
+	g.buildVolumePricing(ps, volumeCosts)
+
+	log.Infof("PricingSource (GCP): completed in %s — %d pages, %d node pricing, %d volume pricing",
+		time.Since(start).Round(time.Second), pageCount, len(ps.NodePricing), len(ps.PersistentVolumePricing))
+
+	return ps, nil
+}
+
+func (g *GCPPricingSource) buildURL(pageToken string) string {
+	q := url.Values{}
+	q.Set("key", g.config.APIKey)
+	q.Set("currencyCode", g.config.CurrencyCode)
+	if pageToken != "" {
+		q.Set("pageToken", pageToken)
+	}
+	return BillingAPIBaseURL + "?" + q.Encode()
+}
+
+func (g *GCPPricingSource) parsePage(body io.Reader, nodeCPUCosts map[nodeKey]float64, nodeRAMCosts map[nodeKey]float64,
+	volumeCosts map[volumeKey]float64,
+) (nextPageToken string, err error) {
+
+	data, err := io.ReadAll(body)
+	if err != nil {
+		return "", fmt.Errorf("reading response body: %w", err)
+	}
+
+	var page GCPPricingResponse
+	if err := json.Unmarshal(data, &page); err != nil {
+		return "", fmt.Errorf("unmarshalling response: %w", err)
+	}
+
+	for _, sku := range page.Skus {
+		if sku.Category == nil || len(sku.PricingInfo) == 0 {
+			continue
+		}
+
+		category := sku.Category
+		resourceGroup := category.ResourceGroup
+		usageType := strings.ToLower(category.UsageType)
+
+		if isStorageResource(resourceGroup) {
+			g.parseVolumeSKU(sku, volumeCosts)
+			continue
+		}
+
+		if isComputeResource(resourceGroup) {
+			g.parseComputeSKU(sku, usageType, nodeCPUCosts, nodeRAMCosts)
+			continue
+		}
+
+		// TODO: Add GPU pricing support
+	}
+
+	return page.NextPageToken, nil
+}
+
+func (g *GCPPricingSource) parseVolumeSKU(sku *GCPPricing, volumeCosts map[volumeKey]float64) {
+	volumeType, isRegional := mapGCPVolumeType(sku.Category.ResourceGroup, sku.Description)
+	if volumeType == pricing.VolumeTypeNil {
+		return
+	}
+
+	// Get the hourly price
+	hourlyPrice, err := g.extractHourlyPrice(sku)
+	if err != nil || hourlyPrice == 0 {
+		return
+	}
+
+	// Convert from monthly to hourly (GCP storage is priced per GB-month)
+	hourlyPrice = hourlyPrice / 730.0
+
+	// Store pricing for each region
+	for _, region := range sku.ServiceRegions {
+		key := volumeKey{
+			Region:     region,
+			VolumeType: volumeType,
+			Regional:   isRegional,
+		}
+		volumeCosts[key] = hourlyPrice
+	}
+}
+
+func (g *GCPPricingSource) parseComputeSKU(sku *GCPPricing, usageType string, nodeCPUCosts map[nodeKey]float64,
+	nodeRAMCosts map[nodeKey]float64,
+) {
+	resourceGroup := sku.Category.ResourceGroup
+
+	// Normalize the instance type based on description
+	instanceType := normalizeInstanceType(resourceGroup, sku.Description)
+
+	// Get hourly price
+	hourlyPrice, err := g.extractHourlyPrice(sku)
+	if err != nil || hourlyPrice == 0 {
+		return
+	}
+
+	// Determine if this is CPU or RAM pricing
+	isRAM := strings.Contains(strings.ToUpper(sku.Description), "RAM")
+
+	// Handle E2 instance family expansion
+	instanceTypes := g.expandInstanceTypes(instanceType, sku.Category.ResourceGroup)
+
+	// Store pricing for each region and instance type
+	for _, region := range sku.ServiceRegions {
+		for _, instType := range instanceTypes {
+			key := nodeKey{
+				Region:       region,
+				InstanceType: instType,
+				UsageType:    usageType,
+			}
+
+			if isRAM {
+				nodeRAMCosts[key] = hourlyPrice
+			} else {
+				nodeCPUCosts[key] = hourlyPrice
+			}
+		}
+	}
+}
+
+// expandInstanceTypes handles special cases like E2 and A2 families that map to multiple instance types
+func (g *GCPPricingSource) expandInstanceTypes(instanceType, resourceGroup string) []string {
+	resourceGroupLower := strings.ToLower(resourceGroup)
+
+	// E2 family expands to multiple instance types
+	if instanceType == "e2" && (resourceGroupLower == "cpu" || resourceGroupLower == "ram") {
+		return []string{"e2-micro", "e2-small", "e2-medium", "e2-standard", "e2-custom"}
+	}
+
+	// A2 family expands to multiple GPU-optimized instance types
+	if instanceType == "a2" && (resourceGroupLower == "cpu" || resourceGroupLower == "ram") {
+		return []string{"a2-highgpu", "a2-megagpu", "a2-ultragpu"}
+	}
+
+	return []string{instanceType}
+}
+
+// extractHourlyPrice extracts the hourly price from a GCP SKU
+func (g *GCPPricingSource) extractHourlyPrice(sku *GCPPricing) (float64, error) {
+	if sku == nil || len(sku.PricingInfo) == 0 || sku.PricingInfo[0] == nil || sku.PricingInfo[0].PricingExpression == nil {
+		return 0, fmt.Errorf("no pricing info")
+	}
+
+	pricingInfo := sku.PricingInfo[0]
+	if pricingInfo.PricingExpression == nil || len(pricingInfo.PricingExpression.TieredRates) == 0 {
+		return 0, fmt.Errorf("no tiered rates")
+	}
+
+	// Get the last tier (highest usage tier, which is the standard rate)
+	lastRateIndex := len(pricingInfo.PricingExpression.TieredRates) - 1
+	unitPrice := pricingInfo.PricingExpression.TieredRates[lastRateIndex].UnitPrice
+
+	// Parse the base currency units
+	unitsBaseCurrency, err := strconv.Atoi(unitPrice.Units)
+	if err != nil {
+		return 0, fmt.Errorf("parsing base unit price: %w", err)
+	}
+
+	// Calculate hourly price: whole currency units + fractional nanos
+	// As per https://cloud.google.com/billing/v1/how-tos/catalog-api
+	hourlyPrice := float64(unitsBaseCurrency) + (unitPrice.Nanos * math.Pow10(-9))
+
+	return hourlyPrice, nil
+}
+
+func (g *GCPPricingSource) buildNodePricing(ps *pricing.PricingSet, nodeCPUCosts map[nodeKey]float64,
+	nodeRAMCosts map[nodeKey]float64,
+) {
+	// Combine CPU and RAM costs into complete node pricing
+	processedKeys := make(map[nodeKey]bool)
+
+	// Process all keys that have either CPU or RAM pricing
+	allKeys := make(map[nodeKey]bool)
+	for k := range nodeCPUCosts {
+		allKeys[k] = true
+	}
+	for k := range nodeRAMCosts {
+		allKeys[k] = true
+	}
+
+	for key := range allKeys {
+		if processedKeys[key] {
+			continue
+		}
+		processedKeys[key] = true
+
+		// Skip spot/preemptible pricing
+		if strings.EqualFold(key.UsageType, "preemptible") {
+			continue
+		}
+
+		cpuCost := nodeCPUCosts[key]
+		ramCost := nodeRAMCosts[key]
+
+		// Skip if we don't have both CPU and RAM costs
+		if cpuCost == 0 || ramCost == 0 {
+			continue
+		}
+
+		nodePricing := &pricing.NodePricing{
+			Properties: pricing.NodePricingProperties{
+				Provider:     cloud.ProviderGCP,
+				Region:       key.Region,
+				InstanceType: key.InstanceType,
+				Provisioning: pricing.ProvisioningOnDemand,
+			},
+			Prices: pricing.Prices{
+				pricing.ResourceCPU: pricing.Price{
+					Unit:  unit.Hour,
+					Price: cpuCost,
+				},
+				pricing.ResourceRAM: pricing.Price{
+					Unit:  unit.Hour,
+					Price: ramCost,
+				},
+			},
+		}
+
+		ps.NodePricing = append(ps.NodePricing, nodePricing)
+	}
+}
+
+func (g *GCPPricingSource) buildVolumePricing(
+	ps *pricing.PricingSet,
+	volumeCosts map[volumeKey]float64,
+) {
+	for key, cost := range volumeCosts {
+		volumePricing := &pricing.PersistentVolumePricing{
+			Properties: pricing.PersistentVolumePricingProperties{
+				Provider:   cloud.ProviderGCP,
+				Region:     key.Region,
+				VolumeType: key.VolumeType,
+			},
+			Prices: pricing.Prices{
+				pricing.ResourceStorage: pricing.Price{
+					Unit:  unit.Hour,
+					Price: cost,
+				},
+			},
+		}
+
+		ps.PersistentVolumePricing = append(ps.PersistentVolumePricing, volumePricing)
+	}
+}

+ 1145 - 0
modules/pricing/public/gcp/gcppricingsource_test.go

@@ -0,0 +1,1145 @@
+package gcp
+
+import (
+	"bytes"
+	"encoding/json"
+	"math"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/pricing"
+)
+
+const floatTolerance = 0.0000001
+
+func TestNewGCPPricingSource(t *testing.T) {
+	config := GCPPricingSourceConfig{
+		APIKey:       "test-api-key",
+		CurrencyCode: "USD",
+	}
+
+	source := NewGCPPricingSource(config)
+
+	if source == nil {
+		t.Fatal("NewGCPPricingSource() returned nil")
+	}
+
+	if source.config.APIKey != "test-api-key" {
+		t.Errorf("APIKey = %v, want test-api-key", source.config.APIKey)
+	}
+
+	if source.config.CurrencyCode != "USD" {
+		t.Errorf("CurrencyCode = %v, want USD", source.config.CurrencyCode)
+	}
+}
+
+func TestBuildURL(t *testing.T) {
+	tests := []struct {
+		name           string
+		apiKey         string
+		currencyCode   string
+		pageToken      string
+		wantContains   []string
+		wantNotContain string
+	}{
+		{
+			name:         "Basic URL without page token",
+			apiKey:       "test-key",
+			currencyCode: "USD",
+			pageToken:    "",
+			wantContains: []string{
+				"cloudbilling.googleapis.com",
+				"key=test-key",
+				"currencyCode=USD",
+			},
+			wantNotContain: "pageToken",
+		},
+		{
+			name:         "URL with page token",
+			apiKey:       "test-key",
+			currencyCode: "EUR",
+			pageToken:    "next-page-123",
+			wantContains: []string{
+				"cloudbilling.googleapis.com",
+				"key=test-key",
+				"currencyCode=EUR",
+				"pageToken=next-page-123",
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			source := &GCPPricingSource{
+				config: GCPPricingSourceConfig{
+					APIKey:       tt.apiKey,
+					CurrencyCode: tt.currencyCode,
+				},
+			}
+
+			url := source.buildURL(tt.pageToken)
+
+			for _, want := range tt.wantContains {
+				if !contains(url, want) {
+					t.Errorf("buildURL() = %v, want to contain %v", url, want)
+				}
+			}
+
+			if tt.wantNotContain != "" && contains(url, tt.wantNotContain) {
+				t.Errorf("buildURL() = %v, should not contain %v", url, tt.wantNotContain)
+			}
+		})
+	}
+}
+
+func TestExtractHourlyPrice(t *testing.T) {
+	tests := []struct {
+		name      string
+		sku       *GCPPricing
+		wantPrice float64
+		wantErr   bool
+	}{
+		{
+			name: "Valid pricing with single tier",
+			sku: &GCPPricing{
+				PricingInfo: []*PricingInfo{
+					{
+						PricingExpression: &PricingExpression{
+							TieredRates: []*TieredRates{
+								{
+									UnitPrice: &UnitPriceInfo{
+										Units: "0",
+										Nanos: 41600000,
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			wantPrice: 0.0416,
+			wantErr:   false,
+		},
+		{
+			name: "Valid pricing with multiple tiers",
+			sku: &GCPPricing{
+				PricingInfo: []*PricingInfo{
+					{
+						PricingExpression: &PricingExpression{
+							TieredRates: []*TieredRates{
+								{
+									UnitPrice: &UnitPriceInfo{
+										Units: "0",
+										Nanos: 10000000,
+									},
+								},
+								{
+									UnitPrice: &UnitPriceInfo{
+										Units: "0",
+										Nanos: 50000000,
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			wantPrice: 0.05, // Should use last tier
+			wantErr:   false,
+		},
+		{
+			name: "Pricing with whole units",
+			sku: &GCPPricing{
+				PricingInfo: []*PricingInfo{
+					{
+						PricingExpression: &PricingExpression{
+							TieredRates: []*TieredRates{
+								{
+									UnitPrice: &UnitPriceInfo{
+										Units: "1",
+										Nanos: 500000000,
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			wantPrice: 1.5,
+			wantErr:   false,
+		},
+		{
+			name: "No pricing info",
+			sku: &GCPPricing{
+				PricingInfo: []*PricingInfo{},
+			},
+			wantPrice: 0,
+			wantErr:   true,
+		},
+		{
+			name: "No tiered rates",
+			sku: &GCPPricing{
+				PricingInfo: []*PricingInfo{
+					{
+						PricingExpression: &PricingExpression{
+							TieredRates: []*TieredRates{},
+						},
+					},
+				},
+			},
+			wantPrice: 0,
+			wantErr:   true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			source := &GCPPricingSource{}
+			price, err := source.extractHourlyPrice(tt.sku)
+
+			if (err != nil) != tt.wantErr {
+				t.Errorf("extractHourlyPrice() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+
+			if !tt.wantErr && math.Abs(price-tt.wantPrice) > floatTolerance {
+				t.Errorf("extractHourlyPrice() = %v, want %v", price, tt.wantPrice)
+			}
+		})
+	}
+}
+
+func TestExpandInstanceTypes(t *testing.T) {
+	tests := []struct {
+		name          string
+		instanceType  string
+		resourceGroup string
+		want          []string
+	}{
+		{
+			name:          "E2 CPU expands",
+			instanceType:  "e2",
+			resourceGroup: "CPU",
+			want:          []string{"e2-micro", "e2-small", "e2-medium", "e2-standard", "e2-custom"},
+		},
+		{
+			name:          "E2 RAM expands",
+			instanceType:  "e2",
+			resourceGroup: "RAM",
+			want:          []string{"e2-micro", "e2-small", "e2-medium", "e2-standard", "e2-custom"},
+		},
+		{
+			name:          "A2 CPU expands",
+			instanceType:  "a2",
+			resourceGroup: "CPU",
+			want:          []string{"a2-highgpu", "a2-megagpu", "a2-ultragpu"},
+		},
+		{
+			name:          "A2 RAM expands",
+			instanceType:  "a2",
+			resourceGroup: "RAM",
+			want:          []string{"a2-highgpu", "a2-megagpu", "a2-ultragpu"},
+		},
+		{
+			name:          "N2 does not expand",
+			instanceType:  "n2-standard",
+			resourceGroup: "CPU",
+			want:          []string{"n2-standard"},
+		},
+		{
+			name:          "Custom does not expand",
+			instanceType:  "custom",
+			resourceGroup: "CPU",
+			want:          []string{"custom"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			source := &GCPPricingSource{}
+			got := source.expandInstanceTypes(tt.instanceType, tt.resourceGroup)
+
+			if len(got) != len(tt.want) {
+				t.Errorf("expandInstanceTypes() returned %d types, want %d", len(got), len(tt.want))
+				return
+			}
+
+			for i, wantType := range tt.want {
+				if got[i] != wantType {
+					t.Errorf("expandInstanceTypes()[%d] = %v, want %v", i, got[i], wantType)
+				}
+			}
+		})
+	}
+}
+
+func TestParsePage(t *testing.T) {
+	tests := []struct {
+		name              string
+		response          GCPPricingResponse
+		wantNextPageToken string
+		wantErr           bool
+		checkCPUCosts     bool
+		checkRAMCosts     bool
+		checkVolumeCosts  bool
+	}{
+		{
+			name: "Valid compute SKU",
+			response: GCPPricingResponse{
+				Skus: []*GCPPricing{
+					{
+						Description: "N2 Instance Core running in Americas",
+						Category: &GCPResourceInfo{
+							ResourceGroup: "CPU",
+							UsageType:     "OnDemand",
+						},
+						ServiceRegions: []string{"us-central1"},
+						PricingInfo: []*PricingInfo{
+							{
+								PricingExpression: &PricingExpression{
+									TieredRates: []*TieredRates{
+										{
+											UnitPrice: &UnitPriceInfo{
+												Units: "0",
+												Nanos: 31611000,
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				NextPageToken: "next-token-123",
+			},
+			wantNextPageToken: "next-token-123",
+			wantErr:           false,
+			checkCPUCosts:     true,
+		},
+		{
+			name: "Valid storage SKU",
+			response: GCPPricingResponse{
+				Skus: []*GCPPricing{
+					{
+						Description: "SSD backed PD Capacity",
+						Category: &GCPResourceInfo{
+							ResourceGroup: "SSD",
+							UsageType:     "OnDemand",
+						},
+						ServiceRegions: []string{"us-central1"},
+						PricingInfo: []*PricingInfo{
+							{
+								PricingExpression: &PricingExpression{
+									TieredRates: []*TieredRates{
+										{
+											UnitPrice: &UnitPriceInfo{
+												Units: "0",
+												Nanos: 170000000,
+											},
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+				NextPageToken: "",
+			},
+			wantNextPageToken: "",
+			wantErr:           false,
+			checkVolumeCosts:  true,
+		},
+		{
+			name: "SKU with no category",
+			response: GCPPricingResponse{
+				Skus: []*GCPPricing{
+					{
+						Description: "Some SKU",
+						Category:    nil,
+						PricingInfo: []*PricingInfo{},
+					},
+				},
+				NextPageToken: "",
+			},
+			wantNextPageToken: "",
+			wantErr:           false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			source := &GCPPricingSource{
+				config: GCPPricingSourceConfig{
+					CurrencyCode: "USD",
+				},
+			}
+
+			// Marshal response to JSON
+			data, err := json.Marshal(tt.response)
+			if err != nil {
+				t.Fatalf("Failed to marshal test response: %v", err)
+			}
+
+			nodeCPUCosts := make(map[nodeKey]float64)
+			nodeRAMCosts := make(map[nodeKey]float64)
+			volumeCosts := make(map[volumeKey]float64)
+
+			nextToken, err := source.parsePage(bytes.NewReader(data), nodeCPUCosts, nodeRAMCosts, volumeCosts)
+
+			if (err != nil) != tt.wantErr {
+				t.Errorf("parsePage() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+
+			if nextToken != tt.wantNextPageToken {
+				t.Errorf("parsePage() nextToken = %v, want %v", nextToken, tt.wantNextPageToken)
+			}
+
+			if tt.checkCPUCosts && len(nodeCPUCosts) == 0 {
+				t.Error("parsePage() should have populated CPU costs")
+			}
+
+			if tt.checkRAMCosts && len(nodeRAMCosts) == 0 {
+				t.Error("parsePage() should have populated RAM costs")
+			}
+
+			if tt.checkVolumeCosts && len(volumeCosts) == 0 {
+				t.Error("parsePage() should have populated volume costs")
+			}
+		})
+	}
+}
+
+func TestParseVolumeSKU(t *testing.T) {
+	tests := []struct {
+		name      string
+		sku       *GCPPricing
+		wantCosts int // Expected number of volume cost entries
+	}{
+		{
+			name: "Valid PD-SSD",
+			sku: &GCPPricing{
+				Description: "SSD backed PD Capacity",
+				Category: &GCPResourceInfo{
+					ResourceGroup: "SSD",
+				},
+				ServiceRegions: []string{"us-central1", "us-east1"},
+				PricingInfo: []*PricingInfo{
+					{
+						PricingExpression: &PricingExpression{
+							TieredRates: []*TieredRates{
+								{
+									UnitPrice: &UnitPriceInfo{
+										Units: "0",
+										Nanos: 170000000,
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			wantCosts: 2, // Two regions
+		},
+		{
+			name: "Regional disk",
+			sku: &GCPPricing{
+				Description: "Regional SSD backed PD Capacity",
+				Category: &GCPResourceInfo{
+					ResourceGroup: "SSD",
+				},
+				ServiceRegions: []string{"us-central1"},
+				PricingInfo: []*PricingInfo{
+					{
+						PricingExpression: &PricingExpression{
+							TieredRates: []*TieredRates{
+								{
+									UnitPrice: &UnitPriceInfo{
+										Units: "0",
+										Nanos: 204000000,
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			wantCosts: 1,
+		},
+		{
+			name: "Unknown volume type",
+			sku: &GCPPricing{
+				Description: "Unknown Disk Type",
+				Category: &GCPResourceInfo{
+					ResourceGroup: "UnknownType",
+				},
+				ServiceRegions: []string{"us-central1"},
+				PricingInfo: []*PricingInfo{
+					{
+						PricingExpression: &PricingExpression{
+							TieredRates: []*TieredRates{
+								{
+									UnitPrice: &UnitPriceInfo{
+										Units: "0",
+										Nanos: 100000000,
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			wantCosts: 0, // Should not add unknown types
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			source := &GCPPricingSource{}
+			volumeCosts := make(map[volumeKey]float64)
+
+			source.parseVolumeSKU(tt.sku, volumeCosts)
+
+			if len(volumeCosts) != tt.wantCosts {
+				t.Errorf("parseVolumeSKU() added %d costs, want %d", len(volumeCosts), tt.wantCosts)
+			}
+		})
+	}
+}
+
+func TestParseComputeSKU(t *testing.T) {
+	tests := []struct {
+		name         string
+		sku          *GCPPricing
+		usageType    string
+		wantCPUCosts int
+		wantRAMCosts int
+	}{
+		{
+			name: "CPU SKU",
+			sku: &GCPPricing{
+				Description: "N2 Instance Core running in Americas",
+				Category: &GCPResourceInfo{
+					ResourceGroup: "CPU",
+				},
+				ServiceRegions: []string{"us-central1"},
+				PricingInfo: []*PricingInfo{
+					{
+						PricingExpression: &PricingExpression{
+							TieredRates: []*TieredRates{
+								{
+									UnitPrice: &UnitPriceInfo{
+										Units: "0",
+										Nanos: 31611000,
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			usageType:    "ondemand",
+			wantCPUCosts: 1,
+			wantRAMCosts: 0,
+		},
+		{
+			name: "RAM SKU",
+			sku: &GCPPricing{
+				Description: "N2 Instance RAM running in Americas",
+				Category: &GCPResourceInfo{
+					ResourceGroup: "RAM",
+				},
+				ServiceRegions: []string{"us-central1"},
+				PricingInfo: []*PricingInfo{
+					{
+						PricingExpression: &PricingExpression{
+							TieredRates: []*TieredRates{
+								{
+									UnitPrice: &UnitPriceInfo{
+										Units: "0",
+										Nanos: 4237000,
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			usageType:    "ondemand",
+			wantCPUCosts: 0,
+			wantRAMCosts: 1,
+		},
+		{
+			name: "E2 CPU expands to multiple types",
+			sku: &GCPPricing{
+				Description: "E2 Instance Core running in Americas",
+				Category: &GCPResourceInfo{
+					ResourceGroup: "CPU",
+				},
+				ServiceRegions: []string{"us-central1"},
+				PricingInfo: []*PricingInfo{
+					{
+						PricingExpression: &PricingExpression{
+							TieredRates: []*TieredRates{
+								{
+									UnitPrice: &UnitPriceInfo{
+										Units: "0",
+										Nanos: 21811000,
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+			usageType:    "ondemand",
+			wantCPUCosts: 5, // e2-micro, e2-small, e2-medium, e2-standard, e2-custom
+			wantRAMCosts: 0,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			source := &GCPPricingSource{}
+			nodeCPUCosts := make(map[nodeKey]float64)
+			nodeRAMCosts := make(map[nodeKey]float64)
+
+			source.parseComputeSKU(tt.sku, tt.usageType, nodeCPUCosts, nodeRAMCosts)
+
+			if len(nodeCPUCosts) != tt.wantCPUCosts {
+				t.Errorf("parseComputeSKU() added %d CPU costs, want %d", len(nodeCPUCosts), tt.wantCPUCosts)
+			}
+
+			if len(nodeRAMCosts) != tt.wantRAMCosts {
+				t.Errorf("parseComputeSKU() added %d RAM costs, want %d", len(nodeRAMCosts), tt.wantRAMCosts)
+			}
+		})
+	}
+}
+
+func TestBuildNodePricing(t *testing.T) {
+	tests := []struct {
+		name         string
+		cpuCosts     map[nodeKey]float64
+		ramCosts     map[nodeKey]float64
+		wantNodes    int
+		currencyCode string
+	}{
+		{
+			name: "Complete node with CPU and RAM",
+			cpuCosts: map[nodeKey]float64{
+				{Region: "us-central1", InstanceType: "n2-standard", UsageType: "ondemand"}: 0.031611,
+			},
+			ramCosts: map[nodeKey]float64{
+				{Region: "us-central1", InstanceType: "n2-standard", UsageType: "ondemand"}: 0.004237,
+			},
+			wantNodes:    1,
+			currencyCode: "USD",
+		},
+		{
+			name: "Missing RAM cost",
+			cpuCosts: map[nodeKey]float64{
+				{Region: "us-central1", InstanceType: "n2-standard", UsageType: "ondemand"}: 0.031611,
+			},
+			ramCosts:     map[nodeKey]float64{},
+			wantNodes:    0,
+			currencyCode: "USD",
+		},
+		{
+			name:     "Missing CPU cost",
+			cpuCosts: map[nodeKey]float64{},
+			ramCosts: map[nodeKey]float64{
+				{Region: "us-central1", InstanceType: "n2-standard", UsageType: "ondemand"}: 0.004237,
+			},
+			wantNodes:    0,
+			currencyCode: "USD",
+		},
+		{
+			name: "Preemptible instance - excluded",
+			cpuCosts: map[nodeKey]float64{
+				{Region: "us-central1", InstanceType: "n2-standard", UsageType: "preemptible"}: 0.007583,
+			},
+			ramCosts: map[nodeKey]float64{
+				{Region: "us-central1", InstanceType: "n2-standard", UsageType: "preemptible"}: 0.001017,
+			},
+			wantNodes:    0,
+			currencyCode: "USD",
+		},
+		{
+			name: "Multiple regions",
+			cpuCosts: map[nodeKey]float64{
+				{Region: "us-central1", InstanceType: "n2-standard", UsageType: "ondemand"}: 0.031611,
+				{Region: "us-east1", InstanceType: "n2-standard", UsageType: "ondemand"}:    0.031611,
+			},
+			ramCosts: map[nodeKey]float64{
+				{Region: "us-central1", InstanceType: "n2-standard", UsageType: "ondemand"}: 0.004237,
+				{Region: "us-east1", InstanceType: "n2-standard", UsageType: "ondemand"}:    0.004237,
+			},
+			wantNodes:    2,
+			currencyCode: "USD",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			source := &GCPPricingSource{
+				config: GCPPricingSourceConfig{
+					CurrencyCode: tt.currencyCode,
+				},
+			}
+			ps := &pricing.PricingSet{
+				NodePricing:             []*pricing.NodePricing{},
+				PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
+			}
+
+			source.buildNodePricing(ps, tt.cpuCosts, tt.ramCosts)
+
+			if len(ps.NodePricing) != tt.wantNodes {
+				t.Errorf("buildNodePricing() created %d nodes, want %d", len(ps.NodePricing), tt.wantNodes)
+			}
+
+		})
+	}
+}
+
+func TestBuildVolumePricing(t *testing.T) {
+	tests := []struct {
+		name         string
+		volumeCosts  map[volumeKey]float64
+		wantVolumes  int
+		currencyCode string
+	}{
+		{
+			name: "Single volume type",
+			volumeCosts: map[volumeKey]float64{
+				{Region: "us-central1", VolumeType: pricing.VolumeTypePDSSD, Regional: false}: 0.000232876,
+			},
+			wantVolumes:  1,
+			currencyCode: "USD",
+		},
+		{
+			name: "Multiple volume types",
+			volumeCosts: map[volumeKey]float64{
+				{Region: "us-central1", VolumeType: pricing.VolumeTypePDSSD, Regional: false}:      0.000232876,
+				{Region: "us-central1", VolumeType: pricing.VolumeTypePDStandard, Regional: false}: 0.000054795,
+			},
+			wantVolumes:  2,
+			currencyCode: "USD",
+		},
+		{
+			name: "Regional and zonal",
+			volumeCosts: map[volumeKey]float64{
+				{Region: "us-central1", VolumeType: pricing.VolumeTypePDSSD, Regional: false}: 0.000232876,
+				{Region: "us-central1", VolumeType: pricing.VolumeTypePDSSD, Regional: true}:  0.000279452,
+			},
+			wantVolumes:  2,
+			currencyCode: "USD",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			source := &GCPPricingSource{
+				config: GCPPricingSourceConfig{
+					CurrencyCode: tt.currencyCode,
+				},
+			}
+			ps := &pricing.PricingSet{
+				NodePricing:             []*pricing.NodePricing{},
+				PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
+			}
+
+			source.buildVolumePricing(ps, tt.volumeCosts)
+
+			if len(ps.PersistentVolumePricing) != tt.wantVolumes {
+				t.Errorf("buildVolumePricing() created %d volumes, want %d", len(ps.PersistentVolumePricing), tt.wantVolumes)
+			}
+		})
+	}
+}
+
+func TestGetPricing_Integration(t *testing.T) {
+	// Serve one page of pricing: one N2 CPU SKU, one N2 RAM SKU, one SSD volume SKU.
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		response := GCPPricingResponse{
+			Skus: []*GCPPricing{
+				{
+					Description: "N2 Instance Core running in Americas",
+					Category: &GCPResourceInfo{
+						ResourceGroup: "CPU",
+						UsageType:     "OnDemand",
+					},
+					ServiceRegions: []string{"us-central1"},
+					PricingInfo: []*PricingInfo{
+						{
+							PricingExpression: &PricingExpression{
+								TieredRates: []*TieredRates{
+									{UnitPrice: &UnitPriceInfo{Units: "0", Nanos: 31611000}},
+								},
+							},
+						},
+					},
+				},
+				{
+					Description: "N2 Instance RAM running in Americas",
+					Category: &GCPResourceInfo{
+						ResourceGroup: "RAM",
+						UsageType:     "OnDemand",
+					},
+					ServiceRegions: []string{"us-central1"},
+					PricingInfo: []*PricingInfo{
+						{
+							PricingExpression: &PricingExpression{
+								TieredRates: []*TieredRates{
+									{UnitPrice: &UnitPriceInfo{Units: "0", Nanos: 4237000}},
+								},
+							},
+						},
+					},
+				},
+				{
+					Description: "SSD backed PD Capacity in Americas",
+					Category: &GCPResourceInfo{
+						ResourceGroup: "SSD",
+						UsageType:     "OnDemand",
+					},
+					ServiceRegions: []string{"us-central1"},
+					PricingInfo: []*PricingInfo{
+						{
+							PricingExpression: &PricingExpression{
+								TieredRates: []*TieredRates{
+									{UnitPrice: &UnitPriceInfo{Units: "0", Nanos: 170000000}},
+								},
+							},
+						},
+					},
+				},
+			},
+			NextPageToken: "",
+		}
+
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(response)
+	}))
+	defer server.Close()
+
+	// Point the package-level base URL at the test server.
+	originalURL := BillingAPIBaseURL
+	BillingAPIBaseURL = server.URL
+	defer func() { BillingAPIBaseURL = originalURL }()
+
+	// Use the test server's own client so TLS/redirect rules match.
+	originalClient := gcpHTTPClient
+	gcpHTTPClient = server.Client()
+	defer func() { gcpHTTPClient = originalClient }()
+
+	source := NewGCPPricingSource(GCPPricingSourceConfig{
+		APIKey:       "test-key",
+		CurrencyCode: "USD",
+	})
+
+	ps, err := source.GetPricing()
+	if err != nil {
+		t.Fatalf("GetPricing() unexpected error: %v", err)
+	}
+
+	if len(ps.NodePricing) != 1 {
+		t.Errorf("NodePricing len = %d, want 1", len(ps.NodePricing))
+	}
+	if len(ps.PersistentVolumePricing) != 1 {
+		t.Errorf("PersistentVolumePricing len = %d, want 1", len(ps.PersistentVolumePricing))
+	}
+
+	node := ps.NodePricing[0]
+	if node.Properties.Region != "us-central1" {
+		t.Errorf("NodePricing region = %q, want us-central1", node.Properties.Region)
+	}
+	if node.Properties.InstanceType != "n2-standard" {
+		t.Errorf("NodePricing instance type = %q, want n2-standard", node.Properties.InstanceType)
+	}
+}
+
+func TestMapGCPVolumeType(t *testing.T) {
+	tests := []struct {
+		name          string
+		resourceGroup string
+		description   string
+		wantType      pricing.VolumeType
+		wantRegional  bool
+	}{
+		{
+			name:          "PD-SSD zonal",
+			resourceGroup: "SSD",
+			description:   "SSD backed PD Capacity",
+			wantType:      pricing.VolumeTypePDSSD,
+			wantRegional:  false,
+		},
+		{
+			name:          "PD-SSD regional",
+			resourceGroup: "SSD",
+			description:   "Regional SSD backed PD Capacity",
+			wantType:      pricing.VolumeTypePDSSD,
+			wantRegional:  true,
+		},
+		{
+			name:          "PD-Standard",
+			resourceGroup: "PDStandard",
+			description:   "Storage PD Capacity",
+			wantType:      pricing.VolumeTypePDStandard,
+			wantRegional:  false,
+		},
+		{
+			name:          "PD-Balanced",
+			resourceGroup: "PDBalanced",
+			description:   "Balanced PD Capacity",
+			wantType:      pricing.VolumeTypePDBalanced,
+			wantRegional:  false,
+		},
+		{
+			name:          "PD-Extreme",
+			resourceGroup: "PDExtreme",
+			description:   "Extreme PD Capacity",
+			wantType:      pricing.VolumeTypePDExtreme,
+			wantRegional:  false,
+		},
+		{
+			name:          "Hyperdisk Balanced",
+			resourceGroup: "HyperdiskBalanced",
+			description:   "Hyperdisk Balanced",
+			wantType:      pricing.VolumeTypeHyperdiskBalanced,
+			wantRegional:  false,
+		},
+		{
+			name:          "Hyperdisk Extreme",
+			resourceGroup: "HyperdiskExtreme",
+			description:   "Hyperdisk Extreme",
+			wantType:      pricing.VolumeTypeHyperdiskExtreme,
+			wantRegional:  false,
+		},
+		{
+			name:          "Hyperdisk Throughput",
+			resourceGroup: "HyperdiskThroughput",
+			description:   "Hyperdisk Throughput",
+			wantType:      pricing.VolumeTypeHyperdiskThroughput,
+			wantRegional:  false,
+		},
+		{
+			name:          "Unknown type",
+			resourceGroup: "UnknownDisk",
+			description:   "Some unknown disk",
+			wantType:      pricing.VolumeTypeNil,
+			wantRegional:  false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotType, gotRegional := mapGCPVolumeType(tt.resourceGroup, tt.description)
+			if gotType != tt.wantType {
+				t.Errorf("mapGCPVolumeType() type = %v, want %v", gotType, tt.wantType)
+			}
+			if gotRegional != tt.wantRegional {
+				t.Errorf("mapGCPVolumeType() regional = %v, want %v", gotRegional, tt.wantRegional)
+			}
+		})
+	}
+}
+
+func TestNormalizeInstanceType(t *testing.T) {
+	tests := []struct {
+		name          string
+		resourceGroup string
+		description   string
+		want          string
+	}{
+		{
+			name:          "Custom instance",
+			resourceGroup: "CPU",
+			description:   "Custom Instance Core running in Americas",
+			want:          "custom",
+		},
+		{
+			name:          "N2 standard",
+			resourceGroup: "RAM",
+			description:   "N2 Instance Ram running in Americas",
+			want:          "n2-standard",
+		},
+		{
+			name:          "N2D AMD",
+			resourceGroup: "CPU",
+			description:   "N2D AMD Instance Core running in Americas",
+			want:          "n2d-standard",
+		},
+		{
+			name:          "N4 instance",
+			resourceGroup: "CPU",
+			description:   "N4 Instance Core running in Americas",
+			want:          "n4-standard",
+		},
+		{
+			name:          "A2 instance",
+			resourceGroup: "RAM",
+			description:   "A2 Instance Ram running in Americas",
+			want:          "a2",
+		},
+		{
+			name:          "C2 compute optimized",
+			resourceGroup: "CPU",
+			description:   "Compute Optimized Core running in Americas",
+			want:          "c2-standard",
+		},
+		{
+			name:          "E2 instance",
+			resourceGroup: "CPU",
+			description:   "E2 Instance Core running in Americas",
+			want:          "e2",
+		},
+		{
+			name:          "T2D AMD",
+			resourceGroup: "RAM",
+			description:   "T2D AMD Instance Ram running in Americas",
+			want:          "t2d-standard",
+		},
+		{
+			name:          "T2A ARM",
+			resourceGroup: "CPU",
+			description:   "T2A ARM Instance Core running in Americas",
+			want:          "t2a-standard",
+		},
+		{
+			name:          "Unknown type defaults to resource group",
+			resourceGroup: "SomeType",
+			description:   "Some Instance Type",
+			want:          "sometype",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := normalizeInstanceType(tt.resourceGroup, tt.description)
+			if got != tt.want {
+				t.Errorf("normalizeInstanceType() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestIsComputeResource(t *testing.T) {
+	tests := []struct {
+		name          string
+		resourceGroup string
+		want          bool
+	}{
+		{
+			name:          "CPU is compute",
+			resourceGroup: "CPU",
+			want:          true,
+		},
+		{
+			name:          "RAM is compute",
+			resourceGroup: "RAM",
+			want:          true,
+		},
+		{
+			name:          "cpu lowercase is compute",
+			resourceGroup: "cpu",
+			want:          true,
+		},
+		{
+			name:          "ram lowercase is compute",
+			resourceGroup: "ram",
+			want:          true,
+		},
+		{
+			name:          "SSD is not compute",
+			resourceGroup: "SSD",
+			want:          false,
+		},
+		{
+			name:          "GPU is not compute",
+			resourceGroup: "GPU",
+			want:          false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := isComputeResource(tt.resourceGroup)
+			if got != tt.want {
+				t.Errorf("isComputeResource() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestIsStorageResource(t *testing.T) {
+	tests := []struct {
+		name          string
+		resourceGroup string
+		want          bool
+	}{
+		{
+			name:          "SSD is storage",
+			resourceGroup: "SSD",
+			want:          true,
+		},
+		{
+			name:          "PDStandard is storage",
+			resourceGroup: "PDStandard",
+			want:          true,
+		},
+		{
+			name:          "PDBalanced is storage",
+			resourceGroup: "PDBalanced",
+			want:          true,
+		},
+		{
+			name:          "PDExtreme is storage",
+			resourceGroup: "PDExtreme",
+			want:          true,
+		},
+		{
+			name:          "HyperdiskBalanced is storage",
+			resourceGroup: "HyperdiskBalanced",
+			want:          true,
+		},
+		{
+			name:          "HyperdiskExtreme is storage",
+			resourceGroup: "HyperdiskExtreme",
+			want:          true,
+		},
+		{
+			name:          "HyperdiskThroughput is storage",
+			resourceGroup: "HyperdiskThroughput",
+			want:          true,
+		},
+		{
+			name:          "CPU is not storage",
+			resourceGroup: "CPU",
+			want:          false,
+		},
+		{
+			name:          "RAM is not storage",
+			resourceGroup: "RAM",
+			want:          false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := isStorageResource(tt.resourceGroup)
+			if got != tt.want {
+				t.Errorf("isStorageResource() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+// 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

+ 183 - 0
modules/pricing/public/gcp/types.go

@@ -0,0 +1,183 @@
+package gcp
+
+import (
+	"strings"
+
+	"github.com/opencost/opencost/core/pkg/pricing"
+)
+
+// GCPPricing represents a single SKU from the GCP Cloud Billing API
+type GCPPricing struct {
+	Name                string           `json:"name"`
+	SKUID               string           `json:"skuId"`
+	Description         string           `json:"description"`
+	Category            *GCPResourceInfo `json:"category"`
+	ServiceRegions      []string         `json:"serviceRegions"`
+	PricingInfo         []*PricingInfo   `json:"pricingInfo"`
+	ServiceProviderName string           `json:"serviceProviderName"`
+}
+
+// PricingInfo contains pricing details for a SKU
+type PricingInfo struct {
+	Summary                string             `json:"summary"`
+	PricingExpression      *PricingExpression `json:"pricingExpression"`
+	CurrencyConversionRate float64            `json:"currencyConversionRate"`
+	EffectiveTime          string             `json:"-"`
+}
+
+// PricingExpression contains the actual pricing rates
+type PricingExpression struct {
+	UsageUnit                string         `json:"usageUnit"`
+	UsageUnitDescription     string         `json:"usageUnitDescription"`
+	BaseUnit                 string         `json:"baseUnit"`
+	BaseUnitConversionFactor int64          `json:"-"`
+	DisplayQuantity          int            `json:"displayQuantity"`
+	TieredRates              []*TieredRates `json:"tieredRates"`
+}
+
+// TieredRates contains pricing tiers
+type TieredRates struct {
+	StartUsageAmount int            `json:"startUsageAmount"`
+	UnitPrice        *UnitPriceInfo `json:"unitPrice"`
+}
+
+// UnitPriceInfo contains the actual price in currency units and nanos
+type UnitPriceInfo struct {
+	CurrencyCode string  `json:"currencyCode"`
+	Units        string  `json:"units"`
+	Nanos        float64 `json:"nanos"`
+}
+
+// GCPResourceInfo contains categorization information for a SKU
+type GCPResourceInfo struct {
+	ServiceDisplayName string `json:"serviceDisplayName"`
+	ResourceFamily     string `json:"resourceFamily"`
+	ResourceGroup      string `json:"resourceGroup"`
+	UsageType          string `json:"usageType"`
+}
+
+// GCPPricingResponse represents the paginated response from GCP Cloud Billing API
+type GCPPricingResponse struct {
+	Skus          []*GCPPricing `json:"skus"`
+	NextPageToken string        `json:"nextPageToken"`
+}
+
+// nodeKey is used internally to track node metadata during parsing
+type nodeKey struct {
+	Region       string
+	InstanceType string
+	UsageType    string // OnDemand, Preemptible, Spot
+}
+
+// volumeKey is used internally to track volume metadata during parsing
+type volumeKey struct {
+	Region     string
+	VolumeType pricing.VolumeType
+	Regional   bool // Whether this is a regional disk
+}
+
+// mapGCPVolumeType maps GCP disk descriptions to VolumeType constants
+func mapGCPVolumeType(resourceGroup, description string) (pricing.VolumeType, bool) {
+	resourceGroupLower := strings.ToLower(resourceGroup)
+	descriptionLower := strings.ToLower(description)
+
+	isRegional := strings.Contains(descriptionLower, "regional")
+
+	switch resourceGroupLower {
+	case "ssd":
+		if strings.Contains(descriptionLower, "ssd backed") {
+			return pricing.VolumeTypePDSSD, isRegional
+		}
+	case "pdstandard":
+		return pricing.VolumeTypePDStandard, isRegional
+	case "pdbalanced":
+		return pricing.VolumeTypePDBalanced, isRegional
+	case "pdextreme":
+		return pricing.VolumeTypePDExtreme, isRegional
+	case "hyperdiskbalanced":
+		return pricing.VolumeTypeHyperdiskBalanced, isRegional
+	case "hyperdiskextreme":
+		return pricing.VolumeTypeHyperdiskExtreme, isRegional
+	case "hyperdiskthroughput":
+		return pricing.VolumeTypeHyperdiskThroughput, isRegional
+	}
+
+	return pricing.VolumeTypeNil, false
+}
+
+// normalizeInstanceType maps GCP resource groups and descriptions to instance type families
+func normalizeInstanceType(resourceGroup, description string) string {
+	resourceGroupLower := strings.ToLower(resourceGroup)
+	descriptionUpper := strings.ToUpper(description)
+
+	// Handle custom instances
+	if (resourceGroupLower == "ram" || resourceGroupLower == "cpu") &&
+		strings.Contains(descriptionUpper, "CUSTOM") {
+		return "custom"
+	}
+
+	// Handle N2 instances
+	if (resourceGroupLower == "ram" || resourceGroupLower == "cpu") &&
+		strings.Contains(descriptionUpper, "N2") &&
+		!strings.Contains(descriptionUpper, "PREMIUM") {
+		if strings.Contains(descriptionUpper, "N2D AMD") {
+			return "n2d-standard"
+		}
+		return "n2-standard"
+	}
+
+	// Handle N4 instances
+	if (resourceGroupLower == "ram" || resourceGroupLower == "cpu") &&
+		strings.Contains(descriptionUpper, "N4 INSTANCE") {
+		return "n4-standard"
+	}
+
+	// Handle A2 instances (GPU-optimized)
+	if (resourceGroupLower == "ram" || resourceGroupLower == "cpu") &&
+		strings.Contains(descriptionUpper, "A2 INSTANCE") {
+		return "a2"
+	}
+
+	// Handle C2 instances (compute-optimized)
+	if (resourceGroupLower == "ram" || resourceGroupLower == "cpu") &&
+		strings.Contains(descriptionUpper, "COMPUTE OPTIMIZED") {
+		return "c2-standard"
+	}
+
+	// Handle E2 instances
+	if (resourceGroupLower == "ram" || resourceGroupLower == "cpu") &&
+		strings.Contains(descriptionUpper, "E2 INSTANCE") {
+		return "e2"
+	}
+
+	// Handle T2D instances
+	if (resourceGroupLower == "ram" || resourceGroupLower == "cpu") &&
+		strings.Contains(descriptionUpper, "T2D AMD") {
+		return "t2d-standard"
+	}
+
+	// Handle T2A instances
+	if (resourceGroupLower == "ram" || resourceGroupLower == "cpu") &&
+		strings.Contains(descriptionUpper, "T2A ARM") {
+		return "t2a-standard"
+	}
+
+	// Default to the resource group as-is
+	return resourceGroupLower
+}
+
+// isComputeResource checks if a SKU is for compute resources (CPU/RAM)
+func isComputeResource(resourceGroup string) bool {
+	resourceGroupLower := strings.ToLower(resourceGroup)
+	return resourceGroupLower == "cpu" || resourceGroupLower == "ram"
+}
+
+// isStorageResource checks if a SKU is for storage resources
+func isStorageResource(resourceGroup string) bool {
+	resourceGroupLower := strings.ToLower(resourceGroup)
+	return resourceGroupLower == "ssd" ||
+		resourceGroupLower == "pdstandard" ||
+		resourceGroupLower == "pdbalanced" ||
+		resourceGroupLower == "pdextreme" ||
+		strings.HasPrefix(resourceGroupLower, "hyperdisk")
+}

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

@@ -0,0 +1,123 @@
+package public
+
+import (
+	"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/aws"
+	"github.com/opencost/opencost/modules/pricing/public/azure"
+	"github.com/opencost/opencost/modules/pricing/public/gcp"
+)
+
+// 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.NodePricing))
+	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.NodePricing))
+	return pricingSet, nil
+}
+
+// GenerateGCPPricing fetches GCP pricing data in the specified currency
+func GenerateGCPPricing(currency unit.Currency) (*pricing.PricingSet, error) {
+	log.Infof("Generating GCP pricing for currency: %s", currency)
+
+	source := gcp.NewGCPPricingSource(gcp.GCPPricingSourceConfig{
+		CurrencyCode: string(currency),
+		APIKey:       os.Getenv("GCP_API_KEY"),
+	})
+
+	pricingSet, err := source.GetPricing()
+	if err != nil {
+		return nil, fmt.Errorf("failed to get GCP pricing: %w", err)
+	}
+
+	// Sort to ensure deterministic output for checksums
+	pricingSet.Sort()
+
+	log.Infof("Generated %d GCP node pricing entries", len(pricingSet.NodePricing))
+	return pricingSet, nil
+}
+
+// GeneratePricing fetches pricing data for all supported providers
+// and combines them into a single PricingSet
+func GeneratePricing(currency unit.Currency) (*pricing.PricingSet, error) {
+	log.Infof("Generating pricing for providers in currency: %s", currency)
+
+	// Create a combined pricing set
+	combinedSet := &pricing.PricingSet{
+		NodePricing:             []*pricing.NodePricing{},
+		PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
+	}
+
+	// Fetch AWS pricing
+	awsSet, err := GenerateAWSPricing(currency)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get AWS pricing: %w", err)
+	}
+	combinedSet.NodePricing = append(combinedSet.NodePricing, awsSet.NodePricing...)
+	combinedSet.PersistentVolumePricing = append(combinedSet.PersistentVolumePricing, awsSet.PersistentVolumePricing...)
+	log.Infof("Added %d AWS node pricing entries", len(awsSet.NodePricing))
+
+	// Fetch Azure pricing
+	azureSet, err := GenerateAzurePricing(currency)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get Azure pricing: %w", err)
+	}
+	combinedSet.NodePricing = append(combinedSet.NodePricing, azureSet.NodePricing...)
+	combinedSet.PersistentVolumePricing = append(combinedSet.PersistentVolumePricing, azureSet.PersistentVolumePricing...)
+	log.Infof("Added %d Azure node pricing entries", len(azureSet.NodePricing))
+
+	// GCP does NOT support CNY
+	if currency != "CNY" {
+		gcpSet, err := GenerateGCPPricing(currency)
+		if err != nil {
+			return nil, fmt.Errorf("failed to get GCP pricing: %w", err)
+		}
+		combinedSet.NodePricing = append(combinedSet.NodePricing, gcpSet.NodePricing...)
+		combinedSet.PersistentVolumePricing = append(combinedSet.PersistentVolumePricing, gcpSet.PersistentVolumePricing...)
+		log.Infof("Added %d GCP node pricing entries", len(gcpSet.NodePricing))
+	}
+
+	// Sort the combined set to ensure deterministic output
+	combinedSet.Sort()
+
+	log.Infof("Generated combined pricing set with %d total node entries and %d volume entries",
+		len(combinedSet.NodePricing), len(combinedSet.PersistentVolumePricing))
+
+	return combinedSet, nil
+}

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

@@ -0,0 +1,116 @@
+module github.com/opencost/opencost/modules/pricing/public
+
+replace github.com/opencost/opencost/core => ../../../core
+
+require github.com/opencost/opencost/core v0.0.0 // return to v1.120.2-0.20260514205745-aa41c03dc67a
+
+require github.com/spf13/cobra v1.10.2
+
+require (
+	cel.dev/expr v0.25.2 // indirect
+	cloud.google.com/go v0.123.0 // indirect
+	cloud.google.com/go/auth v0.20.0 // indirect
+	cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
+	cloud.google.com/go/compute/metadata v0.9.0 // indirect
+	cloud.google.com/go/iam v1.11.0 // indirect
+	cloud.google.com/go/monitoring v1.29.0 // indirect
+	cloud.google.com/go/storage v1.62.3 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
+	github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0 // indirect
+	github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect
+	github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
+	github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
+	github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
+	github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect
+	github.com/aws/aws-sdk-go-v2/config v1.32.25 // indirect
+	github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
+	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
+	github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
+	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
+	github.com/aws/smithy-go v1.27.2 // indirect
+	github.com/cespare/xxhash/v2 v2.3.0 // indirect
+	github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
+	github.com/dustin/go-humanize v1.0.1 // indirect
+	github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
+	github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
+	github.com/felixge/httpsnoop v1.1.0 // indirect
+	github.com/fsnotify/fsnotify v1.10.1 // 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.6 // indirect
+	github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+	github.com/google/s2a-go v0.1.9 // indirect
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect
+	github.com/googleapis/gax-go/v2 v2.22.0 // indirect
+	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/json-iterator/go v1.1.12 // indirect
+	github.com/klauspost/compress v1.18.6 // indirect
+	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+	github.com/klauspost/crc32 v1.3.0 // indirect
+	github.com/kylelemons/godebug v1.1.0 // indirect
+	github.com/mattn/go-colorable v0.1.15 // indirect
+	github.com/mattn/go-isatty v0.0.22 // indirect
+	github.com/minio/crc64nvme v1.1.1 // indirect
+	github.com/minio/md5-simd v1.1.2 // indirect
+	github.com/minio/minio-go/v7 v7.2.0 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
+	github.com/pelletier/go-toml/v2 v2.4.2 // indirect
+	github.com/philhofer/fwd v1.2.0 // indirect
+	github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
+	github.com/pkg/errors v0.9.1 // indirect
+	github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
+	github.com/prometheus/client_model v0.6.2 // indirect
+	github.com/prometheus/common v0.69.0 // indirect
+	github.com/rs/xid v1.6.0 // indirect
+	github.com/rs/zerolog v1.35.1 // indirect
+	github.com/sagikazarmark/locafero v0.12.0 // indirect
+	github.com/spf13/afero v1.15.0 // indirect
+	github.com/spf13/cast v1.10.0 // indirect
+	github.com/spf13/pflag v1.0.10 // indirect
+	github.com/spf13/viper v1.21.0 // indirect
+	github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
+	github.com/subosito/gotenv v1.6.0 // indirect
+	github.com/tinylib/msgp v1.6.4 // indirect
+	github.com/zeebo/xxh3 v1.1.0 // indirect
+	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+	go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
+	go.opentelemetry.io/otel v1.44.0 // indirect
+	go.opentelemetry.io/otel/metric v1.44.0 // indirect
+	go.opentelemetry.io/otel/sdk v1.44.0 // indirect
+	go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
+	go.opentelemetry.io/otel/trace v1.44.0 // indirect
+	go.yaml.in/yaml/v3 v3.0.4 // indirect
+	golang.org/x/crypto v0.53.0 // indirect
+	golang.org/x/net v0.56.0 // indirect
+	golang.org/x/oauth2 v0.36.0 // indirect
+	golang.org/x/sync v0.21.0 // indirect
+	golang.org/x/sys v0.46.0 // indirect
+	golang.org/x/text v0.38.0 // indirect
+	golang.org/x/time v0.15.0 // indirect
+	google.golang.org/api v0.286.0 // indirect
+	google.golang.org/genproto v0.0.0-20260622175928-b703f567277d // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect
+	google.golang.org/grpc v1.81.1 // indirect
+	google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
+	gopkg.in/ini.v1 v1.67.3 // indirect
+	gopkg.in/yaml.v2 v2.4.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)
+
+go 1.26.4

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

@@ -0,0 +1,288 @@
+cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
+cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
+cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
+cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
+cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
+cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
+cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
+cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
+cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
+cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
+cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
+cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
+cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k=
+cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
+cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY=
+cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
+cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
+cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
+cloud.google.com/go/storage v1.62.3 h1:SZq1t23NCI+e96dH77Dg3PEfsNNEjqO8zE5AnD8gVD0=
+cloud.google.com/go/storage v1.62.3/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
+cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
+cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
+github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0/go.mod h1:mCBhUhlMjLLJKr5aqw2TNS/VqJOie8MzWq3DAMJeKso=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0 h1:irsmOWwkp0KCTTNS5e2hdFeIvSQClQo2No3IaNmL3Vw=
+github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0/go.mod h1:GWcBkQj3MqN7ozHKLaCCAuNLiXoIGv2RtanfAwSjY/Y=
+github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
+github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMslb1sZpAokUt+zTVmue0hKSs2C791hhzU=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
+github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
+github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
+github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
+github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
+github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
+github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
+github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
+github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
+github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
+github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
+github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
+github.com/aws/smithy-go v1.27.2 h1:y9NPmSE6am6LjEFPfqHqG/jJk7AauQvhCJONKh7kpzk=
+github.com/aws/smithy-go v1.27.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
+github.com/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.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
+github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
+github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
+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.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
+github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+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.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg=
+github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
+github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
+github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
+github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
+github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
+github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+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.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
+github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
+github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
+github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
+github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
+github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
+github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
+github.com/minio/minio-go/v7 v7.2.0 h1:RCJM0R1XOsRs+A3x3UCaf3ZYbByDaLjFeAi+YCQEPhs=
+github.com/minio/minio-go/v7 v7.2.0/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+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.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
+github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
+github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+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.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
+github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
+github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
+github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
+github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
+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.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E=
+github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
+github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
+github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
+github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
+github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw=
+go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
+go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
+go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
+golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/api v0.286.0 h1:TdTXMvzYKnWV1/lPbCdbXRqBrkDqjPto22H2xeZZ8LI=
+google.golang.org/api v0.286.0/go.mod h1:NlOlUIr8MPoIhT9Bb/oUnRuHbJOLwxb6JSYJM8Yz+jQ=
+google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY=
+google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA=
+google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs=
+google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d/go.mod h1:O0ZOWSrfWfJ+Z5HbwZ+wNtHsg/vk1k2C/w67eww8PfQ=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
+google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
+gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

+ 122 - 0
modules/pricing/public/usd/module.go

@@ -0,0 +1,122 @@
+package usd
+
+import (
+	"context"
+	"embed"
+	"fmt"
+	"io"
+
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/reader"
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+// PricingModule must satisfy the pricing.PricingModule interface
+var _ pricing.PricingModule = (*PricingModule)(nil)
+
+//go:embed *.jsonl
+var embeddedFS embed.FS
+
+type PricingModule struct{}
+
+// NewPricingModule creates a new USD pricing module backed by embedded JSONL files.
+func NewPricingModule() (*PricingModule, error) {
+	return &PricingModule{}, nil
+}
+
+func (pm *PricingModule) newNodeReader() (reader.Reader[*pricing.NodePricing], error) {
+	f, err := embeddedFS.Open("nodes.jsonl")
+	if err != nil {
+		return nil, fmt.Errorf("opening embedded nodes.jsonl: %w", err)
+	}
+	return reader.NewJSONLinesReader[*pricing.NodePricing](f), nil
+}
+
+func (pm *PricingModule) newPVReader() (reader.Reader[*pricing.PersistentVolumePricing], error) {
+	f, err := embeddedFS.Open("persistentvolumes.jsonl")
+	if err != nil {
+		return nil, fmt.Errorf("opening embedded persistentvolumes.jsonl: %w", err)
+	}
+	return reader.NewJSONLinesReader[*pricing.PersistentVolumePricing](f), nil
+}
+
+func (pm *PricingModule) NewNodePricingReader(ctx context.Context) (reader.Reader[*pricing.NodePricing], error) {
+	return pm.newNodeReader()
+}
+
+func (pm *PricingModule) NewPersistentVolumePricingReader(ctx context.Context) (reader.Reader[*pricing.PersistentVolumePricing], error) {
+	return pm.newPVReader()
+}
+
+func (pm *PricingModule) NewClusterPricingReader(ctx context.Context) (reader.Reader[*pricing.ClusterPricing], error) {
+	return nil, fmt.Errorf("cluster pricing not yet implemented")
+}
+
+func (pm *PricingModule) NewNetworkPricingReader(ctx context.Context) (reader.Reader[*pricing.NetworkPricing], error) {
+	return nil, fmt.Errorf("network pricing not yet implemented")
+}
+
+func (pm *PricingModule) NewServicePricingReader(ctx context.Context) (reader.Reader[*pricing.ServicePricing], error) {
+	return nil, fmt.Errorf("service pricing not yet implemented")
+}
+
+func (pm *PricingModule) GetPricingSet(ctx context.Context) (*pricing.PricingSet, error) {
+	ps := &pricing.PricingSet{}
+
+	nodeReader, err := pm.newNodeReader()
+	if err != nil {
+		return nil, err
+	}
+	defer nodeReader.Close()
+	dst := make([]*pricing.NodePricing, 64)
+	for {
+		n, err := nodeReader.Read(ctx, dst)
+		ps.NodePricing = append(ps.NodePricing, dst[:n]...)
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	pvReader, err := pm.newPVReader()
+	if err != nil {
+		return nil, err
+	}
+	defer pvReader.Close()
+	pvDst := make([]*pricing.PersistentVolumePricing, 64)
+	for {
+		n, err := pvReader.Read(ctx, pvDst)
+		ps.PersistentVolumePricing = append(ps.PersistentVolumePricing, pvDst[:n]...)
+		if err == io.EOF {
+			break
+		}
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return ps, nil
+}
+
+func (pm *PricingModule) SourceKind() string {
+	return "public"
+}
+
+func (pm *PricingModule) SourceName() string {
+	return "public-usd"
+}
+
+func (pm *PricingModule) Checksum(ctx context.Context) (string, error) {
+	ps, err := pm.GetPricingSet(ctx)
+	if err != nil {
+		return "", err
+	}
+	return ps.Checksum()
+}
+
+// Currency returns USD
+func Currency() unit.Currency {
+	return unit.USD
+}

+ 0 - 0
modules/pricing/public/usd/nodes.jsonl


+ 0 - 0
modules/pricing/public/usd/persistentvolumes.jsonl


Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff