Răsfoiți Sursa

KubeModel: Pricing (#3796)

Niko Kovacevic 1 săptămână în urmă
părinte
comite
8a9e0c4ca5
47 a modificat fișierele cu 3439 adăugiri și 2087 ștergeri
  1. 0 26
      core/pkg/model/pricingmodel/bingen.go
  2. 0 31
      core/pkg/model/pricingmodel/node.go
  3. 0 101
      core/pkg/model/pricingmodel/node_test.go
  4. 45 2
      core/pkg/model/pricingmodel/pricingmodel.go
  5. 0 1351
      core/pkg/model/pricingmodel/pricingmodel_codecs.go
  6. 0 13
      core/pkg/model/pricingmodel/pricingsource.go
  7. 8 0
      core/pkg/pricing/commitment.go
  8. 30 0
      core/pkg/pricing/memory.go
  9. 99 0
      core/pkg/pricing/mock.go
  10. 147 0
      core/pkg/pricing/mock_test.go
  11. 37 0
      core/pkg/pricing/node.go
  12. 52 0
      core/pkg/pricing/price.go
  13. 368 0
      core/pkg/pricing/price_test.go
  14. 11 0
      core/pkg/pricing/provider.go
  15. 8 0
      core/pkg/pricing/provisioning.go
  16. 24 0
      core/pkg/pricing/repository.go
  17. 43 0
      core/pkg/pricing/set.go
  18. 76 0
      core/pkg/pricing/storage.go
  19. 10 0
      core/pkg/pricing/store.go
  20. 176 0
      core/pkg/pricing/test/aws.yaml
  21. 176 0
      core/pkg/pricing/test/azure.yaml
  22. 17 0
      core/pkg/pricing/test/default.yaml
  23. 240 0
      core/pkg/pricing/test/gcp.yaml
  24. 35 0
      core/pkg/pricing/volume.go
  25. 54 0
      core/pkg/pricing/volumetype.go
  26. 40 0
      core/pkg/reader/reader.go
  27. 60 0
      core/pkg/unit/currency.go
  28. 103 0
      core/pkg/unit/currency_test.go
  29. 81 0
      core/pkg/unit/unit.go
  30. 87 0
      core/pkg/unit/unit_test.go
  31. 65 0
      modules/pricing/basic/default.go
  32. 117 0
      modules/pricing/basic/go.mod
  33. 272 0
      modules/pricing/basic/go.sum
  34. 342 0
      modules/pricing/basic/module.go
  35. 564 0
      modules/pricing/basic/module_test.go
  36. 4 0
      modules/pricing/public/generator.go
  37. 9 0
      modules/pricing/public/go.mod
  38. 3 0
      modules/pricing/public/go.sum
  39. 27 0
      modules/pricing/public/module.go
  40. 9 0
      modules/pricing/public/source.go
  41. 0 44
      pkg/cloud/aws/pricinglistpricingsource.go
  42. 0 53
      pkg/pricingmodel/config.go
  43. 0 194
      pkg/pricingmodel/pipeline.go
  44. 0 47
      pkg/pricingmodel/pipelineservice.go
  45. 0 140
      pkg/pricingmodel/runner.go
  46. 0 14
      pkg/pricingmodel/status.go
  47. 0 71
      pkg/pricingmodel/storage.go

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

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

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

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

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

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

+ 45 - 2
core/pkg/model/pricingmodel/pricingmodel.go

@@ -2,12 +2,32 @@ package pricingmodel
 
 import (
 	"time"
+
+	"github.com/opencost/opencost/core/pkg/model/shared"
 )
 
-// @bingen:generate:PricingSourceType
+// TODO: assess whether we need any of this, or whether we can adapt all existing
+// references to it to use core/pkg/pricing concepts, instead.
+//
+// See:
+//   pkg/cloud/aws/pricinglistpricingsource.go
+//   pkg/cloud/azure/retailpricingsource.go
+//   pkg/cloud/gcp/billingpricingsource.go
+
+type PricingSource interface {
+	// PricingSourceType returns the instance type of the PricingSource, each implementation of this interface should
+	// provide a unique type that all instances should return
+	PricingSourceType() PricingSourceType
+	// PricingSourceKey returns the unique key of the PricingSource instance. In PricingSource implementation that may
+	// have multiple instances running side by side this key (derived from some configuration will) will Identify each
+	// instance. In PricingSource implementations where there will only be a single instance (ex Provider List Pricing)
+	// The PricingSourceKey should match the PricingSourceType
+	PricingSourceKey() string
+	GetPricing() (*PricingModelSet, error)
+}
+
 type PricingSourceType string
 
-// @bingen:generate[stringtable,streamable]:PricingModelSet
 type PricingModelSet struct {
 	TimeStamp   time.Time
 	SourceType  PricingSourceType
@@ -24,3 +44,26 @@ func NewPricingModelSet(timeStamp time.Time, sourceType PricingSourceType, sourc
 		NodePricing: make(map[NodeKey]NodePricing),
 	}
 }
+
+type NodePricingType string
+
+const (
+	NodePricingTypeTotal   NodePricingType = "Total"
+	NodePricingTypeCPUCore NodePricingType = "CPUCore"
+	NodePricingTypeRamGB   NodePricingType = "RamGB"
+	NodePricingTypeDevice  NodePricingType = "Device"
+)
+
+type NodeKey struct {
+	Provider    shared.Provider
+	PricingType NodePricingType
+	UsageType   shared.UsageType
+	Region      string
+	NodeType    string
+	Family      string
+	DeviceType  string
+}
+
+type NodePricing struct {
+	HourlyRate float64
+}

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

@@ -1,1351 +0,0 @@
-////////////////////////////////////////////////////////////////////////////////
-//
-//                             DO NOT MODIFY
-//
-//                          ┻━┻ ︵ヽ(`Д´)ノ︵ ┻━┻
-//
-//
-//            This source file was automatically generated by bingen.
-//
-////////////////////////////////////////////////////////////////////////////////
-
-package pricingmodel
-
-import (
-	"fmt"
-	"github.com/opencost/opencost/core/pkg/model/shared"
-	"io"
-	"iter"
-	"os"
-	"reflect"
-	"strings"
-	"sync"
-	"time"
-	"unsafe"
-
-	util "github.com/opencost/opencost/core/pkg/util"
-)
-
-const (
-	// GeneratorPackageName is the package the generator is targetting
-	GeneratorPackageName string = "pricingmodel"
-
-	// BinaryTagStringTable is written and/or read prior to the existence of a string
-	// table (where each index is encoded as a string entry in the resource
-	BinaryTagStringTable string = "BGST"
-
-	// DefaultCodecVersion is used for any resources listed in the Default version set
-	DefaultCodecVersion uint8 = 1
-)
-
-//--------------------------------------------------------------------------
-//  Configuration
-//--------------------------------------------------------------------------
-
-var (
-	bingenConfigLock sync.RWMutex
-	bingenConfig     *BingenConfiguration = DefaultBingenConfiguration()
-)
-
-// BingenConfiguration is used to set any custom configuration in the way files are encoded
-// or decoded.
-type BingenConfiguration struct {
-	// FileBackedStringTableEnabled enables the use of file-backed string tables for streaming
-	// bingen decoding.
-	FileBackedStringTableEnabled bool
-
-	// FileBackedStringTableDir is the directory to write the string table files for reading.
-	FileBackedStringTableDir string
-}
-
-// DefaultBingenConfiguration creates the default implementation of the bingen configuration
-// and returns it.
-func DefaultBingenConfiguration() *BingenConfiguration {
-	return &BingenConfiguration{
-		FileBackedStringTableEnabled: false,
-		FileBackedStringTableDir:     os.TempDir(),
-	}
-}
-
-// ConfigureBingen accepts a new *BingenConfiguration instance which updates the internal decoder
-// and encoder behavior.
-func ConfigureBingen(config *BingenConfiguration) {
-	bingenConfigLock.Lock()
-	defer bingenConfigLock.Unlock()
-
-	if config == nil {
-		config = DefaultBingenConfiguration()
-	}
-	bingenConfig = config
-}
-
-// IsBingenFileBackedStringTableEnabled accessor for file backed string table configuration
-func IsBingenFileBackedStringTableEnabled() bool {
-	bingenConfigLock.RLock()
-	defer bingenConfigLock.RUnlock()
-
-	return bingenConfig.FileBackedStringTableEnabled
-}
-
-// BingenFileBackedStringTableDir returns the directory configured for file backed string tables.
-func BingenFileBackedStringTableDir() string {
-	bingenConfigLock.RLock()
-	defer bingenConfigLock.RUnlock()
-
-	return bingenConfig.FileBackedStringTableDir
-}
-
-//--------------------------------------------------------------------------
-//  Type Map
-//--------------------------------------------------------------------------
-
-// Generated type map for resolving interface implementations to to concrete types
-var typeMap map[string]reflect.Type = map[string]reflect.Type{
-	"NodeKey":         reflect.TypeFor[NodeKey](),
-	"NodePricing":     reflect.TypeFor[NodePricing](),
-	"PricingModelSet": reflect.TypeFor[PricingModelSet](),
-}
-
-//--------------------------------------------------------------------------
-//  Type Helpers
-//--------------------------------------------------------------------------
-
-// isBinaryTag returns true when the first bytes in the provided binary matches the tag
-func isBinaryTag(data []byte, tag string) bool {
-	if len(data) < len(tag) {
-		return false
-	}
-
-	return string(data[:len(tag)]) == tag
-}
-
-// isReaderBinaryTag is used to peek the header for an io.Reader Buffer
-func isReaderBinaryTag(buff *util.Buffer, tag string) bool {
-	data, err := buff.Peek(len(tag))
-	if err != nil && err != io.EOF {
-		panic(fmt.Sprintf("called Peek() on a non buffered reader: %s", err))
-	}
-	if len(data) < len(tag) {
-		return false
-	}
-
-	return string(data[:len(tag)]) == tag
-}
-
-// appendBytes combines a and b into a new byte array
-func appendBytes(a []byte, b []byte) []byte {
-	al := len(a)
-	bl := len(b)
-	tl := al + bl
-
-	// allocate a new byte array for the combined
-	// use native copy for speedy byte copying
-	result := make([]byte, tl)
-	copy(result, a)
-	copy(result[al:], b)
-
-	return result
-}
-
-// typeToString determines the basic properties of the type, the qualifier, package path, and
-// type name, and returns the qualified type
-func typeToString(f interface{}) string {
-	qual := ""
-	t := reflect.TypeOf(f)
-	if t.Kind() == reflect.Ptr {
-		t = t.Elem()
-		qual = "*"
-	}
-
-	return fmt.Sprintf("%s%s.%s", qual, t.PkgPath(), t.Name())
-}
-
-// resolveType uses the name of a type and returns the package, base type name, and whether
-// or not it's a pointer.
-func resolveType(t string) (pkg string, name string, isPtr bool) {
-	isPtr = t[:1] == "*"
-	if isPtr {
-		t = t[1:]
-	}
-
-	slashIndex := strings.LastIndex(t, "/")
-	if slashIndex >= 0 {
-		t = t[slashIndex+1:]
-	}
-	parts := strings.Split(t, ".")
-	if parts[0] == GeneratorPackageName {
-		parts[0] = ""
-	}
-
-	pkg = parts[0]
-	name = parts[1]
-	return
-}
-
-//--------------------------------------------------------------------------
-//  Stream Helpers
-//--------------------------------------------------------------------------
-
-// StreamFactoryFunc is an alias for a func that creates a BingenStream implementation.
-type StreamFactoryFunc func(io.Reader) BingenStream
-
-// Generated streamable factory map for finding the specific new stream methods
-// by T type
-var streamFactoryMap map[reflect.Type]StreamFactoryFunc = map[reflect.Type]StreamFactoryFunc{
-	reflect.TypeFor[PricingModelSet](): NewPricingModelSetStream,
-}
-
-// NewStreamFor accepts an io.Reader, and returns a new BingenStream for the generic T
-// type provided _if_ it is a registered bingen type that is annotated as 'streamable'. See
-// the streamFactoryMap for generated type listings.
-func NewStreamFor[T any](reader io.Reader) (BingenStream, error) {
-	typeKey := reflect.TypeFor[T]()
-
-	factory, ok := streamFactoryMap[typeKey]
-	if !ok {
-		return nil, fmt.Errorf("the type: %s is not a registered bingen streamable type", typeKey.Name())
-	}
-
-	return factory(reader), nil
-}
-
-// BingenStream is the stream interface for all streamable types
-type BingenStream interface {
-	// Stream returns the iterator which will stream each field of the target type and
-	// return the field info as well as the value.
-	Stream() iter.Seq2[BingenFieldInfo, *BingenValue]
-
-	// Close will close any dynamic io.Reader used to stream in the fields
-	Close()
-
-	// Error returns an error if one occurred during the process of streaming the type's fields.
-	// This can be checked after iterating through the Stream().
-	Error() error
-}
-
-// BingenValue contains the value of a field as well as any index/key associated with that value.
-type BingenValue struct {
-	Value any
-	Index any
-}
-
-// IsNil is just a method accessor way to check to see if the value returned was nil
-func (bv *BingenValue) IsNil() bool {
-	return bv == nil
-}
-
-// creates a single BingenValue instance without a key or index
-func singleV(value any) *BingenValue {
-	return &BingenValue{
-		Value: value,
-	}
-}
-
-// creates a pair of key/index and value.
-func pairV(index any, value any) *BingenValue {
-	return &BingenValue{
-		Value: value,
-		Index: index,
-	}
-}
-
-// BingenFieldInfo contains the type of the field being streamed as well as the name of the field.
-type BingenFieldInfo struct {
-	Type reflect.Type
-	Name string
-}
-
-//--------------------------------------------------------------------------
-//  String Table Writer
-//--------------------------------------------------------------------------
-
-// StringTableWriter maps strings to specific indices for encoding
-type StringTableWriter struct {
-	l       sync.Mutex
-	indices map[string]int
-	next    int
-}
-
-// NewStringTableWriter Creates a new StringTableWriter instance with provided contents
-func NewStringTableWriter(contents ...string) *StringTableWriter {
-	st := &StringTableWriter{
-		indices: make(map[string]int, len(contents)),
-		next:    len(contents),
-	}
-
-	for i, entry := range contents {
-		st.indices[entry] = i
-	}
-
-	return st
-}
-
-// AddOrGet atomically retrieves a string entry's index if it exist. Otherwise, it will
-// add the entry and return the index.
-func (st *StringTableWriter) AddOrGet(s string) int {
-	st.l.Lock()
-	defer st.l.Unlock()
-
-	if ind, ok := st.indices[s]; ok {
-		return ind
-	}
-
-	current := st.next
-	st.next++
-
-	st.indices[s] = current
-	return current
-}
-
-// ToSlice Converts the contents to a string array for encoding.
-func (st *StringTableWriter) ToSlice() []string {
-	st.l.Lock()
-	defer st.l.Unlock()
-
-	if st.next == 0 {
-		return []string{}
-	}
-
-	sl := make([]string, st.next)
-	for s, i := range st.indices {
-		sl[i] = s
-	}
-	return sl
-}
-
-// ToBytes Converts the contents to a binary encoded representation
-func (st *StringTableWriter) ToBytes() []byte {
-	buff := util.NewBuffer()
-	buff.WriteBytes([]byte(BinaryTagStringTable)) // bingen table header
-
-	strs := st.ToSlice()
-
-	buff.WriteInt(len(strs)) // table length
-	for _, s := range strs {
-		buff.WriteString(s)
-	}
-
-	return buff.Bytes()
-}
-
-//--------------------------------------------------------------------------
-//  String Table Reader
-//--------------------------------------------------------------------------
-
-// StringTableReader is the interface used to read the string table from the decoding.
-type StringTableReader interface {
-	// At returns the string entry at a specific index, or panics on out of bounds.
-	At(index int) string
-
-	// Len returns the total number of strings loaded in the string table.
-	Len() int
-
-	// Close will clear the loaded table, and drop any external resources used.
-	Close() error
-}
-
-// SliceStringTableReader is a basic pre-loaded []string that provides index-based access.
-// The cost of this implementation is holding all strings in memory, which provides faster
-// lookup performance at the expense of memory usage.
-type SliceStringTableReader struct {
-	table []string
-}
-
-// NewSliceStringTableReaderFrom creates a new SliceStringTableReader instance loading
-// data directly from the buffer. The buffer's position should start at the table length.
-func NewSliceStringTableReaderFrom(buffer *util.Buffer) StringTableReader {
-	// table length
-	tl := buffer.ReadInt()
-
-	var table []string
-	if tl > 0 {
-		table = make([]string, tl)
-		for i := range tl {
-			table[i] = buffer.ReadString()
-		}
-	}
-
-	return &SliceStringTableReader{
-		table: table,
-	}
-}
-
-// At returns the string entry at a specific index, or panics on out of bounds.
-func (sstr *SliceStringTableReader) At(index int) string {
-	if index < 0 || index >= len(sstr.table) {
-		panic(fmt.Errorf("%s: string table index out of bounds: %d", GeneratorPackageName, index))
-	}
-
-	return sstr.table[index]
-}
-
-// Len returns the total number of strings loaded in the string table.
-func (sstr *SliceStringTableReader) Len() int {
-	if sstr == nil {
-		return 0
-	}
-
-	return len(sstr.table)
-}
-
-// Close for the slice tables just nils out the slice and returns
-func (sstr *SliceStringTableReader) Close() error {
-	sstr.table = nil
-	return nil
-}
-
-// fileStringRef maps a bingen string-table index to a payload stored in a temp file.
-type fileStringRef struct {
-	off    int64
-	length int
-}
-
-// FileStringTableReader leverages a local file to write string table data for lookup. On
-// memory focused systems, this allows a slower parse with a significant decrease in memory
-// usage. This implementation is often pair with streaming readers for high throughput with
-// reduced memory usage.
-type FileStringTableReader struct {
-	f    *os.File
-	refs []fileStringRef
-}
-
-// NewFileStringTableFromBuffer reads exactly tl length-prefixed (uint16) string payloads from buffer
-// and appends each payload to a new temp file. It does not retain full strings in memory.
-func NewFileStringTableReaderFrom(buffer *util.Buffer, dir string) StringTableReader {
-	// helper func to cast a string in-place to a byte slice.
-	// NOTE: Return value is READ-ONLY. DO NOT MODIFY!
-	byteSliceFor := func(s string) []byte {
-		return unsafe.Slice(unsafe.StringData(s), len(s))
-	}
-
-	err := os.MkdirAll(dir, 0755)
-	if err != nil {
-		panic(fmt.Errorf("%s: failed to create string table directory: %w", GeneratorPackageName, err))
-	}
-
-	f, err := os.CreateTemp(dir, fmt.Sprintf("%s-bgst-*", GeneratorPackageName))
-	if err != nil {
-		panic(fmt.Errorf("%s: failed to create string table file: %w", GeneratorPackageName, err))
-	}
-
-	var writeErr error
-	defer func() {
-		if writeErr != nil {
-			_ = f.Close()
-		}
-	}()
-
-	// table length
-	tl := buffer.ReadInt()
-
-	var refs []fileStringRef
-	if tl > 0 {
-		refs = make([]fileStringRef, tl)
-
-		for i := range tl {
-			payload := byteSliceFor(buffer.ReadString())
-
-			var off int64
-			if len(payload) > 0 {
-				off, err = f.Seek(0, io.SeekEnd)
-				if err != nil {
-					writeErr = fmt.Errorf("%s: failed to seek string table file: %w", GeneratorPackageName, err)
-					panic(writeErr)
-				}
-				if _, err := f.Write(payload); err != nil {
-					writeErr = fmt.Errorf("%s: failed to write string table entry %d: %w", GeneratorPackageName, i, err)
-					panic(writeErr)
-				}
-			}
-
-			refs[i] = fileStringRef{
-				off:    off,
-				length: len(payload),
-			}
-		}
-	}
-
-	return &FileStringTableReader{
-		f:    f,
-		refs: refs,
-	}
-}
-
-// At returns the string from the internal file using the reference's offset and length.
-func (fstr *FileStringTableReader) At(index int) string {
-	if fstr == nil || fstr.f == nil {
-		panic(fmt.Errorf("%s: failed to read file string table data", GeneratorPackageName))
-	}
-	if index < 0 || index >= len(fstr.refs) {
-		panic(fmt.Errorf("%s: string table index out of bounds: %d", GeneratorPackageName, index))
-	}
-
-	ref := fstr.refs[index]
-	if ref.length == 0 {
-		return ""
-	}
-
-	b := make([]byte, ref.length)
-	_, err := fstr.f.ReadAt(b, ref.off)
-	if err != nil {
-		return ""
-	}
-
-	// cast the allocated bytes to a string in-place, as we
-	// were the ones that allocated the bytes
-	return unsafe.String(unsafe.SliceData(b), len(b))
-}
-
-// Len returns the total number of strings loaded in the string table.
-func (fstr *FileStringTableReader) Len() int {
-	if fstr == nil {
-		return 0
-	}
-
-	return len(fstr.refs)
-}
-
-// Close for the file string table reader closes the file and deletes it.
-func (fstr *FileStringTableReader) Close() error {
-	if fstr == nil || fstr.f == nil {
-		return nil
-	}
-
-	path := fstr.f.Name()
-	err := fstr.f.Close()
-	fstr.f = nil
-	fstr.refs = nil
-
-	if path != "" {
-		_ = os.Remove(path)
-	}
-
-	return err
-}
-
-//--------------------------------------------------------------------------
-//  Codec Context
-//--------------------------------------------------------------------------
-
-// EncodingContext is a context object passed to the encoders to ensure reuse of buffer
-// and table data
-type EncodingContext struct {
-	Buffer *util.Buffer
-	Table  *StringTableWriter
-}
-
-// IsStringTable returns true if the table is available
-func (ec *EncodingContext) IsStringTable() bool {
-	return ec.Table != nil
-}
-
-// DecodingContext is a context object passed to the decoders to ensure parent objects
-// reuse as much data as possible
-type DecodingContext struct {
-	Buffer *util.Buffer
-	Table  StringTableReader
-}
-
-// NewDecodingContextFromBytes creates a new DecodingContext instance using an byte slice
-func NewDecodingContextFromBytes(data []byte) *DecodingContext {
-	var table StringTableReader
-
-	buff := util.NewBufferFromBytes(data)
-
-	// string table header validation
-	if isBinaryTag(data, BinaryTagStringTable) {
-		buff.ReadBytes(len(BinaryTagStringTable)) // strip tag length
-
-		// always use a slice string table with a byte array since the
-		// data is already in memory
-		table = NewSliceStringTableReaderFrom(buff)
-	}
-
-	return &DecodingContext{
-		Buffer: buff,
-		Table:  table,
-	}
-}
-
-// NewDecodingContextFromReader creates a new DecodingContext instance using an io.Reader
-// implementation
-func NewDecodingContextFromReader(reader io.Reader) *DecodingContext {
-	var table StringTableReader
-
-	buff := util.NewBufferFromReader(reader)
-
-	if isReaderBinaryTag(buff, BinaryTagStringTable) {
-		buff.ReadBytes(len(BinaryTagStringTable)) // strip tag length
-
-		// create correct string table implementation
-		if IsBingenFileBackedStringTableEnabled() {
-			table = NewFileStringTableReaderFrom(buff, BingenFileBackedStringTableDir())
-		} else {
-			table = NewSliceStringTableReaderFrom(buff)
-		}
-	}
-
-	return &DecodingContext{
-		Buffer: buff,
-		Table:  table,
-	}
-}
-
-// IsStringTable returns true if the table is available
-func (dc *DecodingContext) IsStringTable() bool {
-	return dc.Table != nil && dc.Table.Len() > 0
-}
-
-// Close will ensure that any string table resources and buffer resources are
-// cleaned up.
-func (dc *DecodingContext) Close() {
-	if dc.Table != nil {
-		_ = dc.Table.Close()
-		dc.Table = nil
-	}
-}
-
-//--------------------------------------------------------------------------
-//  Binary Codec
-//--------------------------------------------------------------------------
-
-// BinEncoder is an encoding interface which defines a context based marshal contract.
-type BinEncoder interface {
-	MarshalBinaryWithContext(*EncodingContext) error
-}
-
-// BinDecoder is a decoding interface which defines a context based unmarshal contract.
-type BinDecoder interface {
-	UnmarshalBinaryWithContext(*DecodingContext) error
-}
-
-//--------------------------------------------------------------------------
-//  NodeKey
-//--------------------------------------------------------------------------
-
-// MarshalBinary serializes the internal properties of this NodeKey instance
-// into a byte array
-func (target *NodeKey) MarshalBinary() (data []byte, err error) {
-	ctx := &EncodingContext{
-		Buffer: util.NewBuffer(),
-		Table:  nil,
-	}
-
-	e := target.MarshalBinaryWithContext(ctx)
-	if e != nil {
-		return nil, e
-	}
-
-	encBytes := ctx.Buffer.Bytes()
-	return encBytes, nil
-}
-
-// MarshalBinaryWithContext serializes the internal properties of this NodeKey instance
-// into a byte array leveraging a predefined context.
-func (target *NodeKey) MarshalBinaryWithContext(ctx *EncodingContext) (err error) {
-	// panics are recovered and propagated as errors
-	defer func() {
-		if r := recover(); r != nil {
-			if e, ok := r.(error); ok {
-				err = e
-			} else if s, ok := r.(string); ok {
-				err = fmt.Errorf("unexpected panic: %s", s)
-			} else {
-				err = fmt.Errorf("unexpected panic: %+v", r)
-			}
-		}
-	}()
-
-	buff := ctx.Buffer
-	buff.WriteUInt8(DefaultCodecVersion) // version
-
-	// --- [begin][write][alias](shared.Provider) ---
-
-	if ctx.IsStringTable() {
-		a := ctx.Table.AddOrGet(string(target.Provider))
-		buff.WriteInt(a) // write table index
-	} else {
-		buff.WriteString(string(target.Provider)) // write string
-	}
-
-	// --- [end][write][alias](shared.Provider) ---
-
-	// --- [begin][write][alias](NodePricingType) ---
-
-	if ctx.IsStringTable() {
-		b := ctx.Table.AddOrGet(string(target.PricingType))
-		buff.WriteInt(b) // write table index
-	} else {
-		buff.WriteString(string(target.PricingType)) // write string
-	}
-
-	// --- [end][write][alias](NodePricingType) ---
-
-	// --- [begin][write][alias](shared.UsageType) ---
-
-	if ctx.IsStringTable() {
-		c := ctx.Table.AddOrGet(string(target.UsageType))
-		buff.WriteInt(c) // write table index
-	} else {
-		buff.WriteString(string(target.UsageType)) // write string
-	}
-
-	// --- [end][write][alias](shared.UsageType) ---
-
-	if ctx.IsStringTable() {
-		d := ctx.Table.AddOrGet(target.Region)
-		buff.WriteInt(d) // write table index
-	} else {
-		buff.WriteString(target.Region) // write string
-	}
-
-	if ctx.IsStringTable() {
-		e := ctx.Table.AddOrGet(target.NodeType)
-		buff.WriteInt(e) // write table index
-	} else {
-		buff.WriteString(target.NodeType) // write string
-	}
-
-	if ctx.IsStringTable() {
-		f := ctx.Table.AddOrGet(target.Family)
-		buff.WriteInt(f) // write table index
-	} else {
-		buff.WriteString(target.Family) // write string
-	}
-
-	if ctx.IsStringTable() {
-		g := ctx.Table.AddOrGet(target.DeviceType)
-		buff.WriteInt(g) // write table index
-	} else {
-		buff.WriteString(target.DeviceType) // write string
-	}
-
-	return nil
-}
-
-// UnmarshalBinary uses the data passed byte array to set all the internal properties of
-// the NodeKey type
-func (target *NodeKey) UnmarshalBinary(data []byte) error {
-	ctx := NewDecodingContextFromBytes(data)
-	defer ctx.Close()
-
-	err := target.UnmarshalBinaryWithContext(ctx)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-// UnmarshalBinaryFromReader uses the io.Reader data to set all the internal properties of
-// the NodeKey type
-func (target *NodeKey) UnmarshalBinaryFromReader(reader io.Reader) error {
-	ctx := NewDecodingContextFromReader(reader)
-	defer ctx.Close()
-
-	err := target.UnmarshalBinaryWithContext(ctx)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-// UnmarshalBinaryWithContext uses the context containing a string table and binary buffer to set all the internal properties of
-// the NodeKey type
-func (target *NodeKey) UnmarshalBinaryWithContext(ctx *DecodingContext) (err error) {
-	// panics are recovered and propagated as errors
-	defer func() {
-		if r := recover(); r != nil {
-			if e, ok := r.(error); ok {
-				err = e
-			} else if s, ok := r.(string); ok {
-				err = fmt.Errorf("unexpected panic: %s", s)
-			} else {
-				err = fmt.Errorf("unexpected panic: %+v", r)
-			}
-		}
-	}()
-
-	buff := ctx.Buffer
-	version := buff.ReadUInt8()
-
-	if version > DefaultCodecVersion {
-		return fmt.Errorf("Invalid Version Unmarshalling NodeKey. Expected %d or less, got %d", DefaultCodecVersion, version)
-	}
-
-	// --- [begin][read][alias](shared.Provider) ---
-	var a string
-	var c string
-	if ctx.IsStringTable() {
-		d := buff.ReadInt() // read string index
-		c = ctx.Table.At(d)
-	} else {
-		c = buff.ReadString() // read string
-	}
-	b := c
-	a = b
-
-	target.Provider = shared.Provider(a)
-	// --- [end][read][alias](shared.Provider) ---
-
-	// --- [begin][read][alias](NodePricingType) ---
-	var e string
-	var g string
-	if ctx.IsStringTable() {
-		h := buff.ReadInt() // read string index
-		g = ctx.Table.At(h)
-	} else {
-		g = buff.ReadString() // read string
-	}
-	f := g
-	e = f
-
-	target.PricingType = NodePricingType(e)
-	// --- [end][read][alias](NodePricingType) ---
-
-	// --- [begin][read][alias](shared.UsageType) ---
-	var l string
-	var n string
-	if ctx.IsStringTable() {
-		o := buff.ReadInt() // read string index
-		n = ctx.Table.At(o)
-	} else {
-		n = buff.ReadString() // read string
-	}
-	m := n
-	l = m
-
-	target.UsageType = shared.UsageType(l)
-	// --- [end][read][alias](shared.UsageType) ---
-
-	var q string
-	if ctx.IsStringTable() {
-		r := buff.ReadInt() // read string index
-		q = ctx.Table.At(r)
-	} else {
-		q = buff.ReadString() // read string
-	}
-	p := q
-	target.Region = p
-
-	var t string
-	if ctx.IsStringTable() {
-		u := buff.ReadInt() // read string index
-		t = ctx.Table.At(u)
-	} else {
-		t = buff.ReadString() // read string
-	}
-	s := t
-	target.NodeType = s
-
-	var x string
-	if ctx.IsStringTable() {
-		y := buff.ReadInt() // read string index
-		x = ctx.Table.At(y)
-	} else {
-		x = buff.ReadString() // read string
-	}
-	w := x
-	target.Family = w
-
-	var bb string
-	if ctx.IsStringTable() {
-		cc := buff.ReadInt() // read string index
-		bb = ctx.Table.At(cc)
-	} else {
-		bb = buff.ReadString() // read string
-	}
-	aa := bb
-	target.DeviceType = aa
-
-	return nil
-}
-
-//--------------------------------------------------------------------------
-//  NodePricing
-//--------------------------------------------------------------------------
-
-// MarshalBinary serializes the internal properties of this NodePricing instance
-// into a byte array
-func (target *NodePricing) MarshalBinary() (data []byte, err error) {
-	ctx := &EncodingContext{
-		Buffer: util.NewBuffer(),
-		Table:  nil,
-	}
-
-	e := target.MarshalBinaryWithContext(ctx)
-	if e != nil {
-		return nil, e
-	}
-
-	encBytes := ctx.Buffer.Bytes()
-	return encBytes, nil
-}
-
-// MarshalBinaryWithContext serializes the internal properties of this NodePricing instance
-// into a byte array leveraging a predefined context.
-func (target *NodePricing) MarshalBinaryWithContext(ctx *EncodingContext) (err error) {
-	// panics are recovered and propagated as errors
-	defer func() {
-		if r := recover(); r != nil {
-			if e, ok := r.(error); ok {
-				err = e
-			} else if s, ok := r.(string); ok {
-				err = fmt.Errorf("unexpected panic: %s", s)
-			} else {
-				err = fmt.Errorf("unexpected panic: %+v", r)
-			}
-		}
-	}()
-
-	buff := ctx.Buffer
-	buff.WriteUInt8(DefaultCodecVersion) // version
-
-	buff.WriteFloat64(target.HourlyRate) // write float64
-
-	return nil
-}
-
-// UnmarshalBinary uses the data passed byte array to set all the internal properties of
-// the NodePricing type
-func (target *NodePricing) UnmarshalBinary(data []byte) error {
-	ctx := NewDecodingContextFromBytes(data)
-	defer ctx.Close()
-
-	err := target.UnmarshalBinaryWithContext(ctx)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-// UnmarshalBinaryFromReader uses the io.Reader data to set all the internal properties of
-// the NodePricing type
-func (target *NodePricing) UnmarshalBinaryFromReader(reader io.Reader) error {
-	ctx := NewDecodingContextFromReader(reader)
-	defer ctx.Close()
-
-	err := target.UnmarshalBinaryWithContext(ctx)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-// UnmarshalBinaryWithContext uses the context containing a string table and binary buffer to set all the internal properties of
-// the NodePricing type
-func (target *NodePricing) UnmarshalBinaryWithContext(ctx *DecodingContext) (err error) {
-	// panics are recovered and propagated as errors
-	defer func() {
-		if r := recover(); r != nil {
-			if e, ok := r.(error); ok {
-				err = e
-			} else if s, ok := r.(string); ok {
-				err = fmt.Errorf("unexpected panic: %s", s)
-			} else {
-				err = fmt.Errorf("unexpected panic: %+v", r)
-			}
-		}
-	}()
-
-	buff := ctx.Buffer
-	version := buff.ReadUInt8()
-
-	if version > DefaultCodecVersion {
-		return fmt.Errorf("Invalid Version Unmarshalling NodePricing. Expected %d or less, got %d", DefaultCodecVersion, version)
-	}
-
-	a := buff.ReadFloat64() // read float64
-	target.HourlyRate = a
-
-	return nil
-}
-
-//--------------------------------------------------------------------------
-//  PricingModelSet
-//--------------------------------------------------------------------------
-
-// MarshalBinary serializes the internal properties of this PricingModelSet instance
-// into a byte array
-func (target *PricingModelSet) MarshalBinary() (data []byte, err error) {
-	ctx := &EncodingContext{
-		Buffer: util.NewBuffer(),
-		Table:  NewStringTableWriter(),
-	}
-
-	e := target.MarshalBinaryWithContext(ctx)
-	if e != nil {
-		return nil, e
-	}
-
-	encBytes := ctx.Buffer.Bytes()
-	sTableBytes := ctx.Table.ToBytes()
-	merged := appendBytes(sTableBytes, encBytes)
-	return merged, nil
-}
-
-// MarshalBinaryWithContext serializes the internal properties of this PricingModelSet instance
-// into a byte array leveraging a predefined context.
-func (target *PricingModelSet) MarshalBinaryWithContext(ctx *EncodingContext) (err error) {
-	// panics are recovered and propagated as errors
-	defer func() {
-		if r := recover(); r != nil {
-			if e, ok := r.(error); ok {
-				err = e
-			} else if s, ok := r.(string); ok {
-				err = fmt.Errorf("unexpected panic: %s", s)
-			} else {
-				err = fmt.Errorf("unexpected panic: %+v", r)
-			}
-		}
-	}()
-
-	buff := ctx.Buffer
-	buff.WriteUInt8(DefaultCodecVersion) // version
-
-	// --- [begin][write][reference](time.Time) ---
-	a, errA := target.TimeStamp.MarshalBinary()
-	if errA != nil {
-		return errA
-	}
-	buff.WriteInt(len(a))
-	buff.WriteBytes(a)
-	// --- [end][write][reference](time.Time) ---
-
-	// --- [begin][write][alias](PricingSourceType) ---
-
-	if ctx.IsStringTable() {
-		b := ctx.Table.AddOrGet(string(target.SourceType))
-		buff.WriteInt(b) // write table index
-	} else {
-		buff.WriteString(string(target.SourceType)) // write string
-	}
-
-	// --- [end][write][alias](PricingSourceType) ---
-
-	if ctx.IsStringTable() {
-		c := ctx.Table.AddOrGet(target.SourceKey)
-		buff.WriteInt(c) // write table index
-	} else {
-		buff.WriteString(target.SourceKey) // write string
-	}
-
-	if target.NodePricing == nil {
-		buff.WriteUInt8(uint8(0)) // write nil byte
-	} else {
-		buff.WriteUInt8(uint8(1)) // write non-nil byte
-
-		// --- [begin][write][map](map[NodeKey]NodePricing) ---
-		buff.WriteInt(len(target.NodePricing)) // map length
-		for v, z := range target.NodePricing {
-			// --- [begin][write][struct](NodeKey) ---
-			buff.WriteInt(0) // [compatibility, unused]
-			errB := v.MarshalBinaryWithContext(ctx)
-			if errB != nil {
-				return errB
-			}
-			// --- [end][write][struct](NodeKey) ---
-
-			// --- [begin][write][struct](NodePricing) ---
-			buff.WriteInt(0) // [compatibility, unused]
-			errC := z.MarshalBinaryWithContext(ctx)
-			if errC != nil {
-				return errC
-			}
-			// --- [end][write][struct](NodePricing) ---
-
-		}
-		// --- [end][write][map](map[NodeKey]NodePricing) ---
-
-	}
-
-	return nil
-}
-
-// UnmarshalBinary uses the data passed byte array to set all the internal properties of
-// the PricingModelSet type
-func (target *PricingModelSet) UnmarshalBinary(data []byte) error {
-	ctx := NewDecodingContextFromBytes(data)
-	defer ctx.Close()
-
-	err := target.UnmarshalBinaryWithContext(ctx)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-// UnmarshalBinaryFromReader uses the io.Reader data to set all the internal properties of
-// the PricingModelSet type
-func (target *PricingModelSet) UnmarshalBinaryFromReader(reader io.Reader) error {
-	ctx := NewDecodingContextFromReader(reader)
-	defer ctx.Close()
-
-	err := target.UnmarshalBinaryWithContext(ctx)
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
-// UnmarshalBinaryWithContext uses the context containing a string table and binary buffer to set all the internal properties of
-// the PricingModelSet type
-func (target *PricingModelSet) UnmarshalBinaryWithContext(ctx *DecodingContext) (err error) {
-	// panics are recovered and propagated as errors
-	defer func() {
-		if r := recover(); r != nil {
-			if e, ok := r.(error); ok {
-				err = e
-			} else if s, ok := r.(string); ok {
-				err = fmt.Errorf("unexpected panic: %s", s)
-			} else {
-				err = fmt.Errorf("unexpected panic: %+v", r)
-			}
-		}
-	}()
-
-	buff := ctx.Buffer
-	version := buff.ReadUInt8()
-
-	if version > DefaultCodecVersion {
-		return fmt.Errorf("Invalid Version Unmarshalling PricingModelSet. Expected %d or less, got %d", DefaultCodecVersion, version)
-	}
-
-	// --- [begin][read][reference](time.Time) ---
-	a := new(time.Time)
-	b := buff.ReadInt() // byte array length
-	c := buff.ReadBytes(b)
-	errA := a.UnmarshalBinary(c)
-	if errA != nil {
-		return errA
-	}
-	target.TimeStamp = *a
-	// --- [end][read][reference](time.Time) ---
-
-	// --- [begin][read][alias](PricingSourceType) ---
-	var d string
-	var f string
-	if ctx.IsStringTable() {
-		g := buff.ReadInt() // read string index
-		f = ctx.Table.At(g)
-	} else {
-		f = buff.ReadString() // read string
-	}
-	e := f
-	d = e
-
-	target.SourceType = PricingSourceType(d)
-	// --- [end][read][alias](PricingSourceType) ---
-
-	var l string
-	if ctx.IsStringTable() {
-		m := buff.ReadInt() // read string index
-		l = ctx.Table.At(m)
-	} else {
-		l = buff.ReadString() // read string
-	}
-	h := l
-	target.SourceKey = h
-
-	if buff.ReadUInt8() == uint8(0) {
-		target.NodePricing = nil
-	} else {
-		// --- [begin][read][map](map[NodeKey]NodePricing) ---
-		o := buff.ReadInt() // map len
-		n := make(map[NodeKey]NodePricing, o)
-		for range o {
-			// --- [begin][read][struct](NodeKey) ---
-			p := new(NodeKey)
-			buff.ReadInt() // [compatibility, unused]
-			errB := p.UnmarshalBinaryWithContext(ctx)
-			if errB != nil {
-				return errB
-			}
-			v := *p
-			// --- [end][read][struct](NodeKey) ---
-
-			// --- [begin][read][struct](NodePricing) ---
-			q := new(NodePricing)
-			buff.ReadInt() // [compatibility, unused]
-			errC := q.UnmarshalBinaryWithContext(ctx)
-			if errC != nil {
-				return errC
-			}
-			z := *q
-			// --- [end][read][struct](NodePricing) ---
-
-			n[v] = z
-		}
-		target.NodePricing = n
-		// --- [end][read][map](map[NodeKey]NodePricing) ---
-
-	}
-
-	return nil
-}
-
-//--------------------------------------------------------------------------
-//  PricingModelSetStream
-//--------------------------------------------------------------------------
-
-// PricingModelSetStream is a single use field stream for the contents of an PricingModelSet instance. Instead of creating an instance and populating
-// the fields on that instance, we provide a streaming iterator which yields (BingenFieldInfo, *BingenValue) tuples for each
-// streamable element. All slices and maps will be flattened one depth and each element streamed individually.
-type PricingModelSetStream struct {
-	reader io.Reader
-	ctx    *DecodingContext
-	err    error
-}
-
-// Closes closes the internal io.Reader used to read and parse the PricingModelSet fields.
-// This should be called once the stream is no longer needed.
-func (stream *PricingModelSetStream) Close() {
-	if closer, ok := stream.reader.(io.Closer); ok {
-		closer.Close()
-	}
-	stream.ctx.Close()
-}
-
-// Error returns an error if one occurred during the process of streaming the PricingModelSet
-// This can be checked after iterating through the Stream().
-func (stream *PricingModelSetStream) Error() error {
-	return stream.err
-}
-
-// NewPricingModelSetStream creates a new PricingModelSetStream, which uses the io.Reader data to stream all internal fields of an PricingModelSet instance
-func NewPricingModelSetStream(reader io.Reader) BingenStream {
-	ctx := NewDecodingContextFromReader(reader)
-
-	return &PricingModelSetStream{
-		ctx:    ctx,
-		reader: reader,
-	}
-}
-
-// Stream returns the iterator which will stream each field of the target type.
-func (stream *PricingModelSetStream) Stream() iter.Seq2[BingenFieldInfo, *BingenValue] {
-	return func(yield func(BingenFieldInfo, *BingenValue) bool) {
-		var fi BingenFieldInfo
-
-		ctx := stream.ctx
-		buff := ctx.Buffer
-		version := buff.ReadUInt8()
-
-		if version > DefaultCodecVersion {
-			stream.err = fmt.Errorf("Invalid Version Unmarshalling PricingModelSet. Expected %d or less, got %d", DefaultCodecVersion, version)
-			return
-		}
-
-		fi = BingenFieldInfo{
-			Type: reflect.TypeFor[time.Time](),
-			Name: "TimeStamp",
-		}
-
-		// --- [begin][read][reference](time.Time) ---
-		b := new(time.Time)
-		c := buff.ReadInt() // byte array length
-		d := buff.ReadBytes(c)
-		errA := b.UnmarshalBinary(d)
-		if errA != nil {
-			stream.err = errA
-			return
-
-		}
-		a := *b
-		// --- [end][read][reference](time.Time) ---
-		if !yield(fi, singleV(a)) {
-			return
-		}
-
-		fi = BingenFieldInfo{
-			Type: reflect.TypeFor[PricingSourceType](),
-			Name: "SourceType",
-		}
-		// --- [begin][read][streaming-alias](PricingSourceType) ---
-
-		var e string
-		var g string
-		if ctx.IsStringTable() {
-			h := buff.ReadInt() // read string index
-			g = ctx.Table.At(h)
-		} else {
-			g = buff.ReadString() // read string
-		}
-		f := g
-		e = f
-
-		if !yield(fi, singleV(PricingSourceType(e))) {
-			return
-		}
-		// --- [end][read][streaming-alias](PricingSourceType) ---
-
-		fi = BingenFieldInfo{
-			Type: reflect.TypeFor[string](),
-			Name: "SourceKey",
-		}
-
-		var l string
-		var n string
-		if ctx.IsStringTable() {
-			o := buff.ReadInt() // read string index
-			n = ctx.Table.At(o)
-		} else {
-			n = buff.ReadString() // read string
-		}
-		m := n
-		l = m
-		if !yield(fi, singleV(l)) {
-			return
-		}
-
-		fi = BingenFieldInfo{
-			Type: reflect.TypeFor[map[NodeKey]NodePricing](),
-			Name: "NodePricing",
-		}
-		if buff.ReadUInt8() == uint8(0) {
-			if !yield(fi, nil) {
-				return
-			}
-		} else {
-			// --- [begin][read][streaming-map](map[NodeKey]NodePricing) ---
-			p := buff.ReadInt() // map len
-			for range p {
-				// --- [begin][read][struct](NodeKey) ---
-				q := new(NodeKey)
-				buff.ReadInt() // [compatibility, unused]
-				errB := q.UnmarshalBinaryWithContext(ctx)
-				if errB != nil {
-					stream.err = errB
-					return
-
-				}
-				v := *q
-				// --- [end][read][struct](NodeKey) ---
-
-				// --- [begin][read][struct](NodePricing) ---
-				r := new(NodePricing)
-				buff.ReadInt() // [compatibility, unused]
-				errC := r.UnmarshalBinaryWithContext(ctx)
-				if errC != nil {
-					stream.err = errC
-					return
-
-				}
-				z := *r
-				// --- [end][read][struct](NodePricing) ---
-
-				if !yield(fi, pairV(v, z)) {
-					return
-				}
-			}
-			// --- [end][read][streaming-map](map[NodeKey]NodePricing) ---
-
-		}
-
-	}
-}

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

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

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

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

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

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

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

@@ -0,0 +1,99 @@
+package pricing
+
+import (
+	"context"
+	"embed"
+	"encoding/json"
+	"fmt"
+	"path/filepath"
+	"strings"
+
+	"github.com/opencost/opencost/core/pkg/reader"
+	"gopkg.in/yaml.v3"
+)
+
+type MockPricingRepository struct {
+	NodePricing   []*NodePricing
+	VolumePricing []*VolumePricing
+}
+
+func NewMockPricingRepository() (*MockPricingRepository, error) {
+	repo := &MockPricingRepository{
+		NodePricing:   []*NodePricing{},
+		VolumePricing: []*VolumePricing{},
+	}
+
+	// Default
+	defaultPricingSet, err := loadTestFile("default.yaml")
+	if err != nil {
+		return nil, fmt.Errorf("error loading test default pricing: %w", err)
+	}
+	repo.NodePricing = append(repo.NodePricing, defaultPricingSet.Nodes...)
+	repo.VolumePricing = append(repo.VolumePricing, defaultPricingSet.Volumes...)
+
+	// AWS
+	awsPricingSet, err := loadTestFile("aws.yaml")
+	if err != nil {
+		return nil, fmt.Errorf("error loading test AWS pricing: %w", err)
+	}
+	repo.NodePricing = append(repo.NodePricing, awsPricingSet.Nodes...)
+	repo.VolumePricing = append(repo.VolumePricing, awsPricingSet.Volumes...)
+
+	// Azure
+	azurePricingSet, err := loadTestFile("azure.yaml")
+	if err != nil {
+		return nil, fmt.Errorf("error loading test Azure pricing: %w", err)
+	}
+	repo.NodePricing = append(repo.NodePricing, azurePricingSet.Nodes...)
+	repo.VolumePricing = append(repo.VolumePricing, azurePricingSet.Volumes...)
+
+	// GCP
+	gcpPricingSet, err := loadTestFile("gcp.yaml")
+	if err != nil {
+		return nil, fmt.Errorf("error loading test GCP pricing: %w", err)
+	}
+	repo.NodePricing = append(repo.NodePricing, gcpPricingSet.Nodes...)
+	repo.VolumePricing = append(repo.VolumePricing, gcpPricingSet.Volumes...)
+
+	return repo, nil
+}
+
+func (repo *MockPricingRepository) NewNodePricingReader(ctx context.Context) (reader.Reader[*NodePricing], error) {
+	return reader.NewSliceReader(repo.NodePricing), nil
+}
+
+func (repo *MockPricingRepository) NewVolumePricingReader(ctx context.Context) (reader.Reader[*VolumePricing], error) {
+	return reader.NewSliceReader(repo.VolumePricing), nil
+}
+
+//go:embed test/*
+var pricingTestFS embed.FS
+
+func loadTestFile(filename string) (*PricingSet, error) {
+	path := filepath.Join("test", filename)
+	bs, err := pricingTestFS.ReadFile(path)
+	if err != nil {
+		panic(fmt.Errorf("failed to read embedded pricing file: %w", err))
+	}
+
+	var set *PricingSet
+
+	// Detect file format based on extension
+	ext := strings.ToLower(filepath.Ext(filename))
+	switch ext {
+	case ".json":
+		err = json.Unmarshal(bs, &set)
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse json: %w", err)
+		}
+	case ".yaml", ".yml":
+		err = yaml.Unmarshal(bs, &set)
+		if err != nil {
+			return nil, fmt.Errorf("failed to parse yaml: %w", err)
+		}
+	default:
+		return nil, fmt.Errorf("unsupported file format: %s (expected .json, .yaml, or .yml)", ext)
+	}
+
+	return set, nil
+}

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

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

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

@@ -0,0 +1,37 @@
+package pricing
+
+import (
+	"maps"
+	"slices"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+type NodePricingProperties struct {
+	Provider     Provider          `json:"provider,omitempty" yaml:"provider,omitempty"`
+	Region       string            `json:"region,omitempty" yaml:"region,omitempty"`
+	InstanceType string            `json:"instanceType,omitempty" yaml:"instanceType,omitempty"`
+	Provisioning ProvisioningType  `json:"provisioning,omitempty" yaml:"provisioning,omitempty"`
+	Commitment   CommitmentType    `json:"commitment,omitempty" yaml:"commitment,omitempty"`
+	Cluster      string            `json:"cluster,omitempty" yaml:"cluster,omitempty"`
+	ProviderID   string            `json:"providerID,omitempty" yaml:"providerID,omitempty"`
+	Labels       map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"`
+	Start        *time.Time        `json:"start,omitempty" yaml:"start,omitempty"`
+	End          *time.Time        `json:"end,omitempty" yaml:"end,omitempty"`
+}
+
+type NodePricing struct {
+	Properties NodePricingProperties `json:"properties" yaml:"properties"`
+	Prices     Prices                `json:"prices" yaml:"pricing"`
+}
+
+func (np *NodePricing) GetCurrencies() []unit.Currency {
+	currencies := map[unit.Currency]struct{}{}
+
+	for currency := range np.Prices {
+		currencies[currency] = struct{}{}
+	}
+
+	return slices.Collect(maps.Keys(currencies))
+}

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

@@ -0,0 +1,52 @@
+package pricing
+
+import (
+	"errors"
+
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+var NotFound = errors.New("Not found")
+
+type Price struct {
+	Currency unit.Currency `json:"currency" yaml:"currency"`
+	Unit     unit.Unit     `json:"unit" yaml:"unit"`
+	Price    float64       `json:"price" yaml:"price"`
+}
+
+type Prices map[unit.Currency][]Price
+
+func (p Prices) GetPrices() []Price {
+	prices := make([]Price, 0, len(p))
+
+	for _, price := range p {
+		prices = append(prices, price...)
+	}
+
+	return prices
+}
+
+func (p Prices) GetPricesInCurrency(currency unit.Currency) ([]Price, error) {
+	result := []Price{}
+
+	for curr, prices := range p {
+		if curr == currency {
+			result = append(result, prices...)
+		}
+	}
+
+	if len(result) == 0 {
+		return nil, NotFound
+	}
+
+	return result, nil
+}
+
+func (p Prices) GetPricesInCurrencyWithDefault(currency, defaultCurrency unit.Currency) ([]Price, error) {
+	prices, err := p.GetPricesInCurrency(currency)
+	if len(prices) > 0 && err == nil {
+		return prices, nil
+	}
+
+	return p.GetPricesInCurrency(defaultCurrency)
+}

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

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

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

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

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

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

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

@@ -0,0 +1,24 @@
+package pricing
+
+import (
+	"context"
+
+	"github.com/opencost/opencost/core/pkg/reader"
+)
+
+type PricingRepository interface {
+	NodePricingRepository
+	VolumePricingRepository
+}
+
+// TODO: add the following function for Opencost pricing
+// GetNodePricing(NodePricingProperties) (*NodePricing, error)
+type NodePricingRepository interface {
+	NewNodePricingReader(ctx context.Context) (reader.Reader[*NodePricing], error)
+}
+
+// TODO: add the following function for Opencost pricing
+// GetVolumePricing(VolumePricingProperties) (*VolumePricing, error)
+type VolumePricingRepository interface {
+	NewVolumePricingReader(ctx context.Context) (reader.Reader[*VolumePricing], error)
+}

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

@@ -0,0 +1,43 @@
+package pricing
+
+import (
+	"maps"
+	"slices"
+
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+type PricingSet struct {
+	Nodes   []*NodePricing   `json:"nodes" yaml:"nodes"`
+	Volumes []*VolumePricing `json:"volumes" yaml:"volumes"`
+}
+
+func (ps *PricingSet) IsEmpty() bool {
+	if ps == nil {
+		return true
+	}
+
+	return len(ps.Nodes) == 0 && len(ps.Volumes) == 0
+}
+
+func (ps *PricingSet) Currencies() []unit.Currency {
+	if ps == nil {
+		return []unit.Currency{}
+	}
+
+	currencies := map[unit.Currency]struct{}{}
+
+	for _, np := range ps.Nodes {
+		for _, curr := range np.GetCurrencies() {
+			currencies[curr] = struct{}{}
+		}
+	}
+
+	for _, vp := range ps.Volumes {
+		for _, curr := range vp.GetCurrencies() {
+			currencies[curr] = struct{}{}
+		}
+	}
+
+	return slices.Collect(maps.Keys(currencies))
+}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,65 @@
+package basic
+
+import (
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+const DefaultNodePricePerVCPUHour float64 = 0.031611
+const DefaultNodePricePerRAMGiBHour float64 = 0.004237
+const DefaultNodePricePerGPUHour float64 = 0.95
+const DefaultNodePricePerLocalDiskGiBHour float64 = 0.0001096
+
+const DefaultVolumePricePerGiBHour float64 = 0.00005479452
+
+func GetDefaultPricingSet() *pricing.PricingSet {
+	return &pricing.PricingSet{
+		Nodes:   []*pricing.NodePricing{GetDefaultNodePricing()},
+		Volumes: []*pricing.VolumePricing{GetDefaultVolumePricing()},
+	}
+}
+
+func GetDefaultNodePricing() *pricing.NodePricing {
+	return &pricing.NodePricing{
+		Properties: pricing.NodePricingProperties{},
+		Prices: pricing.Prices{
+			unit.USD: []pricing.Price{
+				{
+					Currency: unit.USD,
+					Unit:     unit.VCPUHour,
+					Price:    DefaultNodePricePerVCPUHour,
+				},
+				{
+					Currency: unit.USD,
+					Unit:     unit.RAMGiBHour,
+					Price:    DefaultNodePricePerRAMGiBHour,
+				},
+				{
+					Currency: unit.USD,
+					Unit:     unit.GPUHour,
+					Price:    DefaultNodePricePerGPUHour,
+				},
+				{
+					Currency: unit.USD,
+					Unit:     unit.StorageGiBHour,
+					Price:    DefaultNodePricePerLocalDiskGiBHour,
+				},
+			},
+		},
+	}
+}
+
+func GetDefaultVolumePricing() *pricing.VolumePricing {
+	return &pricing.VolumePricing{
+		Properties: pricing.VolumePricingProperties{},
+		Prices: pricing.Prices{
+			unit.USD: []pricing.Price{
+				{
+					Currency: unit.USD,
+					Unit:     unit.StorageGiBHour,
+					Price:    DefaultVolumePricePerGiBHour,
+				},
+			},
+		},
+	}
+}

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

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

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

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

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

@@ -0,0 +1,342 @@
+package basic
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/reader"
+	"github.com/opencost/opencost/core/pkg/unit"
+)
+
+type PricingModule struct {
+	currency unit.Currency
+	store    pricing.PricingStore
+}
+
+func NewBasicPricingModule(store pricing.PricingStore) (*PricingModule, error) {
+	pricingSet, err := store.GetPricingSet(context.Background())
+	if err != nil {
+		return nil, fmt.Errorf("checking pricing store: %w", err)
+	}
+
+	if pricingSet.IsEmpty() {
+		// Populate store with a default pricing set.
+		err := store.SetPricingSet(context.Background(), GetDefaultPricingSet())
+		if err != nil {
+			return nil, fmt.Errorf("setting default pricing: %w", err)
+		}
+
+		pricingSet, err = store.GetPricingSet(context.Background())
+		if err != nil {
+			return nil, fmt.Errorf("checking default pricing: %w", err)
+		}
+
+		if pricingSet.IsEmpty() {
+			return nil, errors.New("unable to initialize store")
+		}
+	}
+
+	currencies := pricingSet.Currencies()
+	if len(currencies) > 0 {
+		log.Warnf("detected multiple currencies in basic pricing module (%v): defaulting to %s", currencies, currencies[0])
+	}
+
+	pm := &PricingModule{
+		currency: currencies[0],
+		store:    store,
+	}
+
+	return pm, nil
+}
+
+func (pm *PricingModule) GetCurrency() unit.Currency {
+	return pm.currency
+}
+
+func (pm *PricingModule) SetCurrency(ctx context.Context, currency unit.Currency) error {
+	prevCurrency := pm.currency
+	if currency == prevCurrency {
+		return nil
+	}
+
+	// 1. Convert existing node pricing to new currency
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		return fmt.Errorf("getting node pricing: %w", err)
+	}
+
+	// Set up new Prices for the new currency
+	newPrices := []pricing.Price{}
+
+	// Convert all existing prices to the new currency
+	oldPrices, ok := np.Prices[prevCurrency]
+	if !ok {
+		log.Warnf("setting currency to '%s': no node prices found for existing currency '%s'", currency, pm.currency)
+		// There are no prices for the current currency.
+		// Set default prices using the new currency.
+		newPrices = GetDefaultNodePricing().Prices[unit.USD]
+	}
+
+	for _, price := range oldPrices {
+		newPrices = append(newPrices, pricing.Price{
+			Currency: currency,
+			Unit:     price.Unit,
+			Price:    price.Price,
+		})
+	}
+
+	// Set new prices under new currency
+	np.Prices = make(pricing.Prices, 1)
+	np.Prices[currency] = newPrices
+
+	// Set node pricing on the module
+	err = pm.setNodePricing(ctx, np)
+	if err != nil {
+		return fmt.Errorf("setting node pricing: %w", err)
+	}
+
+	// 2. Convert existing volume pricing to new currency
+	vp, err := pm.getVolumePricing(ctx)
+	if err != nil {
+		return fmt.Errorf("getting node pricing: %w", err)
+	}
+
+	// Set up new Prices for the new currency
+	newPrices = []pricing.Price{}
+
+	// Convert all existing prices to the new currency
+	oldPrices, ok = vp.Prices[prevCurrency]
+	if !ok {
+		log.Warnf("setting currency to '%s': no node prices found for existing currency '%s'", currency, pm.currency)
+		// There are no prices for the current currency.
+		// Set default prices using the new currency.
+		newPrices = GetDefaultVolumePricing().Prices[unit.USD]
+	}
+
+	for _, price := range oldPrices {
+		newPrices = append(newPrices, pricing.Price{
+			Currency: currency,
+			Unit:     price.Unit,
+			Price:    price.Price,
+		})
+	}
+
+	// Set new prices under new currency
+	vp.Prices = make(pricing.Prices, 1)
+	vp.Prices[currency] = newPrices
+
+	// Set node pricing on the module
+	err = pm.setVolumePricing(ctx, vp)
+	if err != nil {
+		return fmt.Errorf("setting node pricing: %w", err)
+	}
+
+	return nil
+}
+
+func (pm *PricingModule) SetNodePricePerCPUCoreHour(ctx context.Context, price float64) error {
+	return pm.setNodePrice(ctx, unit.VCPUHour, price)
+}
+
+func (pm *PricingModule) SetNodePricePerRAMGiBHour(ctx context.Context, price float64) error {
+	return pm.setNodePrice(ctx, unit.RAMGiBHour, price)
+}
+
+func (pm *PricingModule) SetNodePricePerGPUHour(ctx context.Context, price float64) error {
+	return pm.setNodePrice(ctx, unit.GPUHour, price)
+}
+
+func (pm *PricingModule) SetNodePricePerLocalDiskGiBHour(ctx context.Context, price float64) error {
+	return pm.setNodePrice(ctx, unit.StorageGiBHour, price)
+}
+
+func (pm *PricingModule) SetVolumePricePerStorageGiBHour(ctx context.Context, price float64) error {
+	return pm.setVolumePrice(ctx, unit.StorageGiBHour, price)
+}
+
+func (pm *PricingModule) NewNodePricingReader(ctx context.Context) (reader.Reader[*pricing.NodePricing], error) {
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting node pricing: %w", err)
+	}
+	return reader.NewSliceReader([]*pricing.NodePricing{np}), nil
+}
+
+func (pm *PricingModule) NewVolumePricingReader(ctx context.Context) (reader.Reader[*pricing.VolumePricing], error) {
+	vp, err := pm.getVolumePricing(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting volume pricing: %w", err)
+	}
+	return reader.NewSliceReader([]*pricing.VolumePricing{vp}), nil
+}
+
+func (pm *PricingModule) setNodePrice(ctx context.Context, unit unit.Unit, price float64) error {
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		return fmt.Errorf("getting node pricing: %w", err)
+	}
+
+	prices, ok := np.Prices[pm.currency]
+	if !ok {
+		log.Warnf("setting price per %s to '%f': no node prices found for existing currency '%s'", unit, price, pm.currency)
+		// There are no prices for the current currency.
+		// Set default prices using the new currency.
+		np = GetDefaultNodePricing()
+	}
+
+	// Set the price with unit GiBHour to the given price
+	for i, p := range prices {
+		if p.Unit == unit {
+			prices[i] = pricing.Price{
+				Currency: p.Currency,
+				Unit:     p.Unit,
+				Price:    price,
+			}
+		}
+	}
+
+	// Set the new node pricing
+	err = pm.setNodePricing(ctx, np)
+	if err != nil {
+		return fmt.Errorf("setting node pricing: %w", err)
+	}
+
+	return nil
+}
+
+func (pm *PricingModule) setVolumePrice(ctx context.Context, unit unit.Unit, price float64) error {
+	vp, err := pm.getVolumePricing(ctx)
+	if err != nil {
+		return fmt.Errorf("getting volume pricing: %w", err)
+	}
+
+	prices, ok := vp.Prices[pm.currency]
+	if !ok {
+		log.Warnf("setting price per %s to '%f': no volume prices found for existing currency '%s'", unit, price, pm.currency)
+		// There are no prices for the current currency.
+		// Set default prices using the new currency.
+		vp = GetDefaultVolumePricing()
+	}
+
+	// Set the price with unit GiBHour to the given price
+	for i, p := range prices {
+		if p.Unit == unit {
+			prices[i] = pricing.Price{
+				Currency: p.Currency,
+				Unit:     p.Unit,
+				Price:    price,
+			}
+		}
+	}
+
+	// Set the new volume pricing
+	err = pm.setVolumePricing(ctx, vp)
+	if err != nil {
+		return fmt.Errorf("setting node pricing: %w", err)
+	}
+
+	return nil
+}
+
+func (pm *PricingModule) getNodePricing(ctx context.Context) (*pricing.NodePricing, error) {
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting pricing: %w", err)
+	}
+
+	if len(ps.Nodes) == 0 {
+		return nil, errors.New("not found")
+	}
+
+	// Only one default NodePricing is allowed in basic pricing.
+	// If multiple exist, return only the first one.
+	return ps.Nodes[0], nil
+}
+
+func (pm *PricingModule) setNodePricing(ctx context.Context, np *pricing.NodePricing) error {
+	if np == nil {
+		return errors.New("nil node pricing")
+	}
+
+	// Make sure precisely one currency is set
+	currs := np.GetCurrencies()
+	if len(currs) == 0 {
+		return errors.New("pricing is empty")
+	}
+	if len(currs) > 1 {
+		return fmt.Errorf("setting multiple currencies: %v", currs)
+	}
+
+	// Update PricingModule to use given currency
+	pm.currency = currs[0]
+
+	// Get the pricing set
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return fmt.Errorf("getting pricing: %w", err)
+	}
+
+	// Only one default NodePricing is allowed in basic pricing.
+	ps.Nodes = []*pricing.NodePricing{np}
+
+	// Set the new pricing set
+	err = pm.store.SetPricingSet(ctx, ps)
+	if err != nil {
+		return fmt.Errorf("setting pricing: %w", err)
+	}
+
+	return nil
+}
+
+func (pm *PricingModule) getVolumePricing(ctx context.Context) (*pricing.VolumePricing, error) {
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("getting pricing: %w", err)
+	}
+
+	if len(ps.Volumes) == 0 {
+		return nil, errors.New("not found")
+	}
+
+	// Only one default VolumePricing is allowed in basic pricing.
+	// If multiple exist, return only the first one.
+	return ps.Volumes[0], nil
+}
+
+func (pm *PricingModule) setVolumePricing(ctx context.Context, vp *pricing.VolumePricing) error {
+	if vp == nil {
+		return errors.New("nil volume pricing")
+	}
+
+	// Make sure precisely one currency is set
+	currs := vp.GetCurrencies()
+	if len(currs) == 0 {
+		return errors.New("pricing is empty")
+	}
+	if len(currs) > 1 {
+		return fmt.Errorf("setting multiple currencies: %v", currs)
+	}
+
+	// Update PricingModule to use given currency
+	pm.currency = currs[0]
+
+	// Get the pricing set
+	ps, err := pm.store.GetPricingSet(ctx)
+	if err != nil {
+		return fmt.Errorf("getting pricing: %w", err)
+	}
+
+	// Only one default VolumePricing is allowed in basic pricing.
+	ps.Volumes = []*pricing.VolumePricing{vp}
+
+	// Set the new pricing set
+	err = pm.store.SetPricingSet(ctx, ps)
+	if err != nil {
+		return fmt.Errorf("setting pricing: %w", err)
+	}
+
+	return nil
+}

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

@@ -0,0 +1,564 @@
+package basic
+
+import (
+	"context"
+	"os"
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/reader"
+	"github.com/opencost/opencost/core/pkg/storage"
+	"github.com/opencost/opencost/core/pkg/unit"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPricingModule(t *testing.T) {
+	memoryPricingStore := pricing.NewMemoryPricingStore()
+
+	filePricingStore, err := pricing.NewStoragePricingStore(t.Context(), newFileStorage(t), "pricing.json")
+	require.NoError(t, err)
+
+	stores := map[string]pricing.PricingStore{
+		"MemoryPricingStore":  memoryPricingStore,
+		"StoragePricingStore": filePricingStore,
+	}
+
+	for name, store := range stores {
+		t.Run(name, testPricingModuleWithStore(store))
+	}
+}
+
+func testPricingModuleWithStore(store pricing.PricingStore) func(t *testing.T) {
+	return func(t *testing.T) {
+		ctx := t.Context()
+
+		pm, err := NewBasicPricingModule(store)
+		require.NoError(t, err)
+
+		t.Run("DefaultPricing", func(t *testing.T) {
+			testDefaultPricing(t, ctx, pm)
+		})
+
+		t.Run("SetCurrency", func(t *testing.T) {
+			testSetCurrency(t, ctx, pm)
+		})
+
+		t.Run("SetNodePricePerCPUCoreHour", func(t *testing.T) {
+			testSetNodePricePerCPUCoreHour(t, ctx, pm)
+		})
+
+		t.Run("SetNodePricePerRAMGiBHour", func(t *testing.T) {
+			testSetNodePricePerRAMGiBHour(t, ctx, pm)
+		})
+
+		t.Run("SetNodePricePerGPUHour", func(t *testing.T) {
+			testSetNodePricePerGPUHour(t, ctx, pm)
+		})
+
+		t.Run("SetNodePricePerLocalDiskGiBHour", func(t *testing.T) {
+			testSetNodePricePerLocalDiskGiBHour(t, ctx, pm)
+		})
+
+		t.Run("SetVolumePricePerStorageGiBHour", func(t *testing.T) {
+			testSetVolumePricePerStorageGiBHour(t, ctx, pm)
+		})
+
+		t.Run("NewNodePricingReader", func(t *testing.T) {
+			testNewNodePricingReader(t, ctx, pm)
+		})
+
+		t.Run("NewVolumePricingReader", func(t *testing.T) {
+			testNewVolumePricingReader(t, ctx, pm)
+		})
+
+		t.Run("ModulePersistence", func(t *testing.T) {
+			// Create a new PricingModule with the same store
+			pm2, err := NewBasicPricingModule(store)
+			require.NoError(t, err)
+
+			// Verify that pricing persists
+			np, err := pm2.getNodePricing(ctx)
+			if err != nil {
+				t.Fatalf("Failed to get node pricing: %v", err)
+			}
+
+			if np == nil {
+				t.Fatal("Expected node pricing to be persisted")
+			}
+		})
+	}
+}
+
+// testDefaultPricing verifies that a freshly created PricingModule contains default pricing
+func testDefaultPricing(t *testing.T, ctx context.Context, pm *PricingModule) {
+	// Test default currency
+	currency := pm.GetCurrency()
+	if currency != unit.USD {
+		t.Errorf("Expected default currency to be USD, got %s", currency)
+	}
+
+	// Test default node pricing
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get node pricing: %v", err)
+	}
+
+	if np == nil {
+		t.Fatal("Expected node pricing to exist")
+	}
+
+	prices, err := np.Prices.GetPricesInCurrency(unit.USD)
+	if err != nil {
+		t.Fatalf("Failed to get prices in USD: %v", err)
+	}
+
+	// Verify default prices exist
+	foundCPU := false
+	foundRAM := false
+	foundGPU := false
+
+	for _, price := range prices {
+		switch price.Unit {
+		case unit.VCPUHour:
+			foundCPU = true
+			if price.Price != DefaultNodePricePerVCPUHour {
+				t.Errorf("Expected CPU price to be %f, got %f", DefaultNodePricePerVCPUHour, price.Price)
+			}
+		case unit.RAMGiBHour:
+			foundRAM = true
+			if price.Price != DefaultNodePricePerRAMGiBHour {
+				t.Errorf("Expected RAM price to be %f, got %f", DefaultNodePricePerRAMGiBHour, price.Price)
+			}
+		case unit.GPUHour:
+			foundGPU = true
+			if price.Price != DefaultNodePricePerGPUHour {
+				t.Errorf("Expected GPU price to be %f, got %f", DefaultNodePricePerGPUHour, price.Price)
+			}
+		}
+	}
+
+	if !foundCPU {
+		t.Error("Expected to find CPU pricing")
+	}
+	if !foundRAM {
+		t.Error("Expected to find RAM pricing")
+	}
+	if !foundGPU {
+		t.Error("Expected to find GPU pricing")
+	}
+
+	// Test default volume pricing
+	vp, err := pm.getVolumePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get volume pricing: %v", err)
+	}
+
+	if vp == nil {
+		t.Fatal("Expected volume pricing to exist")
+	}
+
+	volumePrices, err := vp.Prices.GetPricesInCurrency(unit.USD)
+	if err != nil {
+		t.Fatalf("Failed to get volume prices in USD: %v", err)
+	}
+
+	foundVolume := false
+	for _, price := range volumePrices {
+		if price.Unit == unit.StorageGiBHour {
+			foundVolume = true
+			if price.Price != DefaultVolumePricePerGiBHour {
+				t.Errorf("Expected volume price to be %f, got %f", DefaultVolumePricePerGiBHour, price.Price)
+			}
+		}
+	}
+
+	if !foundVolume {
+		t.Error("Expected to find volume pricing")
+	}
+}
+
+// testSetCurrency tests the SetCurrency function
+func testSetCurrency(t *testing.T, ctx context.Context, pm *PricingModule) {
+	// Get current pricing to compare later
+	npBefore, err := pm.getNodePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get node pricing before currency change: %v", err)
+	}
+
+	pricesBefore, err := npBefore.Prices.GetPricesInCurrency(pm.GetCurrency())
+	if err != nil {
+		t.Fatalf("Failed to get prices before currency change: %v", err)
+	}
+
+	vpBefore, err := pm.getVolumePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get volume pricing before currency change: %v", err)
+	}
+
+	volumePricesBefore, err := vpBefore.Prices.GetPricesInCurrency(pm.GetCurrency())
+	if err != nil {
+		t.Fatalf("Failed to get volume prices before currency change: %v", err)
+	}
+
+	// Change currency to EUR
+	err = pm.SetCurrency(ctx, unit.EUR)
+	if err != nil {
+		t.Fatalf("Failed to set currency: %v", err)
+	}
+
+	// Verify currency changed
+	currency := pm.GetCurrency()
+	if currency != unit.EUR {
+		t.Errorf("Expected currency to be EUR, got %s", currency)
+	}
+
+	// Verify node pricing units and prices remain the same, only currency changed
+	npAfter, err := pm.getNodePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get node pricing after currency change: %v", err)
+	}
+
+	pricesAfter, err := npAfter.Prices.GetPricesInCurrency(unit.EUR)
+	if err != nil {
+		t.Fatalf("Failed to get prices after currency change: %v", err)
+	}
+
+	if len(pricesBefore) != len(pricesAfter) {
+		t.Errorf("Expected same number of prices, got %d before and %d after", len(pricesBefore), len(pricesAfter))
+	}
+
+	// Create maps for easier comparison
+	beforeMap := make(map[unit.Unit]float64)
+	for _, p := range pricesBefore {
+		beforeMap[p.Unit] = p.Price
+	}
+
+	afterMap := make(map[unit.Unit]float64)
+	for _, p := range pricesAfter {
+		afterMap[p.Unit] = p.Price
+		if p.Currency != unit.EUR {
+			t.Errorf("Expected currency to be EUR, got %s", p.Currency)
+		}
+	}
+
+	// Verify units and prices match
+	for unit, priceBefore := range beforeMap {
+		priceAfter, ok := afterMap[unit]
+		if !ok {
+			t.Errorf("Unit %s not found after currency change", unit)
+			continue
+		}
+		if priceBefore != priceAfter {
+			t.Errorf("Price for unit %s changed from %f to %f", unit, priceBefore, priceAfter)
+		}
+	}
+
+	// Verify volume pricing units and prices remain the same
+	vpAfter, err := pm.getVolumePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get volume pricing after currency change: %v", err)
+	}
+
+	volumePricesAfter, err := vpAfter.Prices.GetPricesInCurrency(unit.EUR)
+	if err != nil {
+		t.Fatalf("Failed to get volume prices after currency change: %v", err)
+	}
+
+	if len(volumePricesBefore) != len(volumePricesAfter) {
+		t.Errorf("Expected same number of volume prices, got %d before and %d after", len(volumePricesBefore), len(volumePricesAfter))
+	}
+
+	for i, priceBefore := range volumePricesBefore {
+		priceAfter := volumePricesAfter[i]
+		if priceAfter.Currency != unit.EUR {
+			t.Errorf("Expected currency to be EUR, got %s", priceAfter.Currency)
+		}
+		if priceBefore.Unit != priceAfter.Unit {
+			t.Errorf("Unit changed from %s to %s", priceBefore.Unit, priceAfter.Unit)
+		}
+		if priceBefore.Price != priceAfter.Price {
+			t.Errorf("Price changed from %f to %f", priceBefore.Price, priceAfter.Price)
+		}
+	}
+
+	// Change back to USD for other tests
+	err = pm.SetCurrency(ctx, unit.USD)
+	if err != nil {
+		t.Fatalf("Failed to set currency back to USD: %v", err)
+	}
+}
+
+// testSetNodePricePerCPUCoreHour tests the SetNodePricePerCPUCoreHour function
+func testSetNodePricePerCPUCoreHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 0.075
+
+	err := pm.SetNodePricePerCPUCoreHour(ctx, newPrice)
+	if err != nil {
+		t.Fatalf("Failed to set CPU price: %v", err)
+	}
+
+	// Verify the price was set
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get node pricing: %v", err)
+	}
+
+	prices, err := np.Prices.GetPricesInCurrency(pm.GetCurrency())
+	if err != nil {
+		t.Fatalf("Failed to get prices: %v", err)
+	}
+
+	found := false
+	for _, price := range prices {
+		if price.Unit == unit.VCPUHour {
+			found = true
+			if price.Price != newPrice {
+				t.Errorf("Expected CPU price to be %f, got %f", newPrice, price.Price)
+			}
+		}
+	}
+
+	if !found {
+		t.Error("Expected to find CPU pricing")
+	}
+}
+
+// testSetNodePricePerRAMGiBHour tests the SetNodePricePerRAMGiBHour function
+func testSetNodePricePerRAMGiBHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 0.008
+
+	err := pm.SetNodePricePerRAMGiBHour(ctx, newPrice)
+	if err != nil {
+		t.Fatalf("Failed to set RAM price: %v", err)
+	}
+
+	// Verify the price was set
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get node pricing: %v", err)
+	}
+
+	prices, err := np.Prices.GetPricesInCurrency(pm.GetCurrency())
+	if err != nil {
+		t.Fatalf("Failed to get prices: %v", err)
+	}
+
+	found := false
+	for _, price := range prices {
+		if price.Unit == unit.RAMGiBHour {
+			found = true
+			if price.Price != newPrice {
+				t.Errorf("Expected RAM price to be %f, got %f", newPrice, price.Price)
+			}
+		}
+	}
+
+	if !found {
+		t.Error("Expected to find RAM pricing")
+	}
+}
+
+// testSetNodePricePerGPUHour tests the SetNodePricePerGPUHour function
+func testSetNodePricePerGPUHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 2.0
+
+	err := pm.SetNodePricePerGPUHour(ctx, newPrice)
+	if err != nil {
+		t.Fatalf("Failed to set GPU price: %v", err)
+	}
+
+	// Verify the price was set
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get node pricing: %v", err)
+	}
+
+	prices, err := np.Prices.GetPricesInCurrency(pm.GetCurrency())
+	if err != nil {
+		t.Fatalf("Failed to get prices: %v", err)
+	}
+
+	found := false
+	for _, price := range prices {
+		if price.Unit == unit.GPUHour {
+			found = true
+			if price.Price != newPrice {
+				t.Errorf("Expected GPU price to be %f, got %f", newPrice, price.Price)
+			}
+		}
+	}
+
+	if !found {
+		t.Error("Expected to find GPU pricing")
+	}
+}
+
+// testSetNodePricePerLocalDiskGiBHour tests the SetNodePricePerLocalDiskGiBHour function
+func testSetNodePricePerLocalDiskGiBHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 0.0007
+
+	err := pm.SetNodePricePerLocalDiskGiBHour(ctx, newPrice)
+	if err != nil {
+		t.Fatalf("Failed to set local disk price: %v", err)
+	}
+
+	// Verify the price was set
+	np, err := pm.getNodePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get node pricing: %v", err)
+	}
+
+	prices, err := np.Prices.GetPricesInCurrency(pm.GetCurrency())
+	if err != nil {
+		t.Fatalf("Failed to get prices: %v", err)
+	}
+
+	found := false
+	for _, price := range prices {
+		if price.Unit == unit.StorageGiBHour {
+			found = true
+			if price.Price != newPrice {
+				t.Errorf("Expected local disk price to be %f, got %f", newPrice, price.Price)
+			}
+		}
+	}
+
+	if !found {
+		t.Error("Expected to find local disk pricing")
+	}
+}
+
+// testSetVolumePricePerStorageGiBHour tests the SetVolumePricePerStorageGiBHour function
+func testSetVolumePricePerStorageGiBHour(t *testing.T, ctx context.Context, pm *PricingModule) {
+	newPrice := 0.0003
+
+	err := pm.SetVolumePricePerStorageGiBHour(ctx, newPrice)
+	if err != nil {
+		t.Fatalf("Failed to set volume storage price: %v", err)
+	}
+
+	// Verify the price was set
+	vp, err := pm.getVolumePricing(ctx)
+	if err != nil {
+		t.Fatalf("Failed to get volume pricing: %v", err)
+	}
+
+	prices, err := vp.Prices.GetPricesInCurrency(pm.GetCurrency())
+	if err != nil {
+		t.Fatalf("Failed to get prices: %v", err)
+	}
+
+	found := false
+	for _, price := range prices {
+		if price.Unit == unit.StorageGiBHour {
+			found = true
+			if price.Price != newPrice {
+				t.Errorf("Expected volume storage price to be %f, got %f", newPrice, price.Price)
+			}
+		}
+	}
+
+	if !found {
+		t.Error("Expected to find volume storage pricing")
+	}
+}
+
+// testNewNodePricingReader tests the NewNodePricingReader function
+func testNewNodePricingReader(t *testing.T, ctx context.Context, pm *PricingModule) {
+	// Test that NewNodePricingReader always produces a reader
+	rdr, err := pm.NewNodePricingReader(ctx)
+	if err != nil {
+		t.Fatalf("Failed to create node pricing reader: %v", err)
+	}
+
+	if rdr == nil {
+		t.Fatal("Expected reader to be non-nil")
+	}
+
+	// Test that the reader produces precisely one *NodePricing struct
+	dst := make([]*pricing.NodePricing, 10) // Buffer larger than expected
+	count := 0
+
+	for {
+		n, err := rdr.Read(ctx, dst)
+		count += n
+
+		// Verify all read items are non-nil
+		for i := 0; i < n; i++ {
+			if dst[i] == nil {
+				t.Error("Expected non-nil NodePricing")
+			}
+		}
+
+		if err == reader.Done {
+			break
+		}
+		if err != nil {
+			t.Fatalf("Reader error: %v", err)
+		}
+	}
+
+	if count != 1 {
+		t.Errorf("Expected reader to produce exactly 1 NodePricing, got %d", count)
+	}
+
+	// Clean up
+	if err := rdr.Close(); err != nil {
+		t.Errorf("Failed to close reader: %v", err)
+	}
+}
+
+// testNewVolumePricingReader tests the NewVolumePricingReader function
+func testNewVolumePricingReader(t *testing.T, ctx context.Context, pm *PricingModule) {
+	// Test that NewVolumePricingReader always produces a reader
+	rdr, err := pm.NewVolumePricingReader(ctx)
+	if err != nil {
+		t.Fatalf("Failed to create volume pricing reader: %v", err)
+	}
+
+	if rdr == nil {
+		t.Fatal("Expected reader to be non-nil")
+	}
+
+	// Test that the reader produces precisely one *VolumePricing struct
+	dst := make([]*pricing.VolumePricing, 10) // Buffer larger than expected
+	count := 0
+
+	for {
+		n, err := rdr.Read(ctx, dst)
+		count += n
+
+		// Verify all read items are non-nil
+		for i := 0; i < n; i++ {
+			if dst[i] == nil {
+				t.Error("Expected non-nil VolumePricing")
+			}
+		}
+
+		if err == reader.Done {
+			break
+		}
+		if err != nil {
+			t.Fatalf("Reader error: %v", err)
+		}
+	}
+
+	if count != 1 {
+		t.Errorf("Expected reader to produce exactly 1 VolumePricing, got %d", count)
+	}
+
+	// Clean up
+	if err := rdr.Close(); err != nil {
+		t.Errorf("Failed to close reader: %v", err)
+	}
+}
+
+func newFileStorage(t *testing.T) storage.Storage {
+	tempDir, err := os.MkdirTemp("", "pricing-test-*")
+	if err != nil {
+		t.Fatalf("Failed to create temp directory: %v", err)
+	}
+	defer os.RemoveAll(tempDir)
+
+	return storage.NewFileStorage(tempDir)
+}

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

@@ -0,0 +1,4 @@
+package public
+
+type PricingModuleGenerator struct {
+}

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

@@ -0,0 +1,9 @@
+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 gopkg.in/yaml.v3 v3.0.1 // indirect
+
+go 1.26.3

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

@@ -0,0 +1,3 @@
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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

@@ -0,0 +1,27 @@
+package public
+
+import (
+	"context"
+	"errors"
+
+	"github.com/opencost/opencost/core/pkg/pricing"
+	"github.com/opencost/opencost/core/pkg/reader"
+)
+
+type PricingModule struct {
+	Providers *ProviderPricing `json:"provider" yaml:"provider"`
+}
+
+type ProviderPricing map[pricing.Provider]*InstanceTypePricing
+
+type InstanceTypePricing map[string]*RegionPricing
+
+type RegionPricing map[string]*pricing.Prices
+
+func (pm *PricingModule) NewNodePricingReader(ctx context.Context) (reader.Reader[*pricing.NodePricing], error) {
+	return nil, errors.New("not implemented")
+}
+
+func (pm *PricingModule) NewVolumePricingReader(ctx context.Context) (reader.Reader[*pricing.VolumePricing], error) {
+	return nil, errors.New("not implemented")
+}

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

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

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

@@ -43,43 +43,6 @@ func (p *PricingListPricingSource) cacheFilePath() (string, error) {
 	return filepath.Join(dir, pricingCacheFile), nil
 }
 
-func (p *PricingListPricingSource) loadFromCache() (*pricingmodel.PricingModelSet, bool) {
-	path, err := p.cacheFilePath()
-	if err != nil {
-		return nil, false
-	}
-	info, err := os.Stat(path)
-	if err != nil || time.Since(info.ModTime()) > pricingCacheTTL {
-		return nil, false
-	}
-	data, err := os.ReadFile(path)
-	if err != nil {
-		return nil, false
-	}
-	pms := &pricingmodel.PricingModelSet{}
-	if err := pms.UnmarshalBinary(data); err != nil {
-		log.Warnf("failed to unmarshal cached pricing data: %s", err.Error())
-		return nil, false
-	}
-	return pms, true
-}
-
-func (p *PricingListPricingSource) saveToCache(pms *pricingmodel.PricingModelSet) {
-	path, err := p.cacheFilePath()
-	if err != nil {
-		log.Warnf("failed to determine pricing cache path: %s", err.Error())
-		return
-	}
-	data, err := pms.MarshalBinary()
-	if err != nil {
-		log.Warnf("failed to marshal pricing data for cache: %s", err.Error())
-		return
-	}
-	if err := os.WriteFile(path, data, 0600); err != nil {
-		log.Warnf("failed to write pricing cache: %s", err.Error())
-	}
-}
-
 func (p *PricingListPricingSource) PricingSourceType() pricingmodel.PricingSourceType {
 	return PricingListPricingSourceType
 }
@@ -90,11 +53,6 @@ func (p *PricingListPricingSource) PricingSourceKey() string {
 }
 
 func (p *PricingListPricingSource) GetPricing() (*pricingmodel.PricingModelSet, error) {
-	if cached, ok := p.loadFromCache(); ok {
-		log.Infof("PricingListPricingSource: loaded %d pricing entries from cache", len(cached.NodePricing))
-		return cached, nil
-	}
-
 	log.Infof("PricingListPricingSource: starting AWS EC2 pricing list download (large file, this may take a while)")
 	start := time.Now()
 
@@ -187,7 +145,5 @@ func (p *PricingListPricingSource) GetPricing() (*pricingmodel.PricingModelSet,
 	log.Infof("PricingListPricingSource: completed in %s — %d products, %d terms, %d pricing entries",
 		time.Since(start).Round(time.Second), productCount, termCount, len(pms.NodePricing))
 
-	p.saveToCache(pms)
-
 	return pms, nil
 }

+ 0 - 53
pkg/pricingmodel/config.go

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

+ 0 - 194
pkg/pricingmodel/pipeline.go

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

+ 0 - 47
pkg/pricingmodel/pipelineservice.go

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

+ 0 - 140
pkg/pricingmodel/runner.go

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

+ 0 - 14
pkg/pricingmodel/status.go

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

+ 0 - 71
pkg/pricingmodel/storage.go

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