pricinglistpricingsource.go 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. package aws
  2. import (
  3. "fmt"
  4. "os"
  5. "path/filepath"
  6. "strconv"
  7. "strings"
  8. "time"
  9. "github.com/opencost/opencost/core/pkg/env"
  10. "github.com/opencost/opencost/core/pkg/log"
  11. "github.com/opencost/opencost/core/pkg/model/pricingmodel"
  12. "github.com/opencost/opencost/core/pkg/model/shared"
  13. )
  14. const pricingCacheTTL = 24 * time.Hour
  15. const pricingCacheDir = "pricingsource/aws"
  16. const pricingCacheFile = "cached_ec2_pricingmodelset"
  17. const PricingListPricingSourceType pricingmodel.PricingSourceType = "aws_pricing_list_api"
  18. type PricingListPricingSourceConfig struct {
  19. CurrencyCode string
  20. }
  21. type PricingListPricingSource struct {
  22. config PricingListPricingSourceConfig
  23. }
  24. func NewPricingListPricingSource(cfg PricingListPricingSourceConfig) *PricingListPricingSource {
  25. return &PricingListPricingSource{config: cfg}
  26. }
  27. func (p *PricingListPricingSource) cacheFilePath() (string, error) {
  28. dir := env.GetPathFromConfig(pricingCacheDir)
  29. if _, e := os.Stat(dir); e != nil && os.IsNotExist(e) {
  30. err := os.MkdirAll(dir, os.ModePerm)
  31. if err != nil {
  32. return "", err
  33. }
  34. }
  35. return filepath.Join(dir, pricingCacheFile), nil
  36. }
  37. func (p *PricingListPricingSource) loadFromCache() (*pricingmodel.PricingModelSet, bool) {
  38. path, err := p.cacheFilePath()
  39. if err != nil {
  40. return nil, false
  41. }
  42. info, err := os.Stat(path)
  43. if err != nil || time.Since(info.ModTime()) > pricingCacheTTL {
  44. return nil, false
  45. }
  46. data, err := os.ReadFile(path)
  47. if err != nil {
  48. return nil, false
  49. }
  50. pms := &pricingmodel.PricingModelSet{}
  51. if err := pms.UnmarshalBinary(data); err != nil {
  52. log.Warnf("failed to unmarshal cached pricing data: %s", err.Error())
  53. return nil, false
  54. }
  55. return pms, true
  56. }
  57. func (p *PricingListPricingSource) saveToCache(pms *pricingmodel.PricingModelSet) {
  58. path, err := p.cacheFilePath()
  59. if err != nil {
  60. log.Warnf("failed to determine pricing cache path: %s", err.Error())
  61. return
  62. }
  63. data, err := pms.MarshalBinary()
  64. if err != nil {
  65. log.Warnf("failed to marshal pricing data for cache: %s", err.Error())
  66. return
  67. }
  68. if err := os.WriteFile(path, data, 0600); err != nil {
  69. log.Warnf("failed to write pricing cache: %s", err.Error())
  70. }
  71. }
  72. func (p *PricingListPricingSource) PricingSourceType() pricingmodel.PricingSourceType {
  73. return PricingListPricingSourceType
  74. }
  75. // PricingSourceKey returns the PricingSourceType because it is meant to run single instance.
  76. func (p *PricingListPricingSource) PricingSourceKey() string {
  77. return string(PricingListPricingSourceType)
  78. }
  79. func (p *PricingListPricingSource) GetPricing() (*pricingmodel.PricingModelSet, error) {
  80. if cached, ok := p.loadFromCache(); ok {
  81. log.Infof("PricingListPricingSource: loaded %d pricing entries from cache", len(cached.NodePricing))
  82. return cached, nil
  83. }
  84. log.Infof("PricingListPricingSource: starting AWS EC2 pricing list download (large file, this may take a while)")
  85. start := time.Now()
  86. now := time.Now().UTC()
  87. pms := pricingmodel.NewPricingModelSet(now, p.PricingSourceType(), p.PricingSourceKey())
  88. skuToNodeKey := make(map[string]pricingmodel.NodeKey)
  89. var productCount, termCount int
  90. const logInterval = 50000
  91. // When parsing product we create keys based off of product attributes and link those to a SKU.
  92. handleProduct := func(product *PriceListEC2Product) {
  93. productCount++
  94. if productCount%logInterval == 0 {
  95. log.Infof("PricingListPricingSource: processed %d products...", productCount)
  96. }
  97. attr := product.Attributes
  98. if attr.LocationType != "AWS Region" {
  99. return
  100. }
  101. if !((strings.HasPrefix(attr.UsageType, "BoxUsage") || strings.Contains(attr.UsageType, "-BoxUsage")) &&
  102. (attr.CapacityStatus == "Used" || attr.CapacityStatus == "") &&
  103. (attr.MarketOption == "OnDemand" || attr.MarketOption == "")) {
  104. return
  105. }
  106. if attr.OperatingSystem != "" && attr.OperatingSystem != "NA" && attr.OperatingSystem != "Linux" {
  107. return
  108. }
  109. if attr.PreInstalledSw != "" && attr.PreInstalledSw != "NA" {
  110. }
  111. if attr.RegionCode == "" || attr.InstanceType == "" {
  112. return
  113. }
  114. skuToNodeKey[product.Sku] = pricingmodel.NodeKey{
  115. Provider: shared.ProviderAWS,
  116. Region: attr.RegionCode,
  117. NodeType: attr.InstanceType,
  118. UsageType: shared.UsageTypeOnDemand,
  119. PricingType: pricingmodel.NodePricingTypeTotal,
  120. }
  121. }
  122. // Terms are used to define pricing and have the sku to look up the appropriate key.
  123. handleTerm := func(term *PriceListEC2Term) {
  124. termCount++
  125. if termCount%logInterval == 0 {
  126. log.Infof("PricingListPricingSource: processed %d terms, %d pricing entries so far...", termCount, len(pms.NodePricing))
  127. }
  128. nodeKey, ok := skuToNodeKey[term.Sku]
  129. if !ok {
  130. return
  131. }
  132. hourlyRateCode := HourlyRateCode
  133. if _, ok = OnDemandRateCodes[term.OfferTermCode]; !ok {
  134. if _, okCN := OnDemandRateCodesCn[term.OfferTermCode]; !okCN {
  135. // Skip if term is not OnDemand
  136. return
  137. }
  138. hourlyRateCode = HourlyRateCodeCn
  139. }
  140. priceDimensionKey := strings.Join([]string{term.Sku, term.OfferTermCode, hourlyRateCode}, ".")
  141. pricingDimension, ok := term.PriceDimensions[priceDimensionKey]
  142. if !ok {
  143. return
  144. }
  145. priceStr := pricingDimension.PricePerUnit.ForCurrency(p.config.CurrencyCode)
  146. price, err := strconv.ParseFloat(priceStr, 64)
  147. if err != nil {
  148. log.Errorf("failed to parse str to float '%s': %s", priceStr, err.Error())
  149. return
  150. }
  151. pms.NodePricing[nodeKey] = pricingmodel.NodePricing{
  152. HourlyRate: price,
  153. }
  154. }
  155. err := QueryEC2PriceList("", handleProduct, handleTerm)
  156. if err != nil {
  157. return nil, fmt.Errorf("failed to query list pricing data %w", err)
  158. }
  159. log.Infof("PricingListPricingSource: completed in %s — %d products, %d terms, %d pricing entries",
  160. time.Since(start).Round(time.Second), productCount, termCount, len(pms.NodePricing))
  161. p.saveToCache(pms)
  162. return pms, nil
  163. }