awspricingsource.go 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. package aws
  2. import (
  3. "fmt"
  4. "strconv"
  5. "strings"
  6. "time"
  7. "github.com/opencost/opencost/core/pkg/log"
  8. "github.com/opencost/opencost/core/pkg/pricing"
  9. "github.com/opencost/opencost/core/pkg/unit"
  10. )
  11. type AWSPricingSourceConfig struct {
  12. CurrencyCode string
  13. }
  14. type AWSPricingSource struct {
  15. config AWSPricingSourceConfig
  16. }
  17. func NewAWSPricingSource(cfg AWSPricingSourceConfig) *AWSPricingSource {
  18. return &AWSPricingSource{config: cfg}
  19. }
  20. func (p *AWSPricingSource) GetPricing() (*pricing.PricingSet, error) {
  21. log.Infof("PricingSource (AWS): starting EC2 pricing list download (large file, this may take a while)")
  22. start := time.Now()
  23. ps := &pricing.PricingSet{
  24. Nodes: []*pricing.NodePricing{},
  25. Volumes: []*pricing.VolumePricing{},
  26. }
  27. skuToNodeKey := make(map[string]nodeKey)
  28. skuToVolumeKey := make(map[string]volumeKey)
  29. var productCount, termCount int
  30. const logInterval = 50000
  31. region := ""
  32. if strings.ToUpper(p.config.CurrencyCode) == "CNY" {
  33. region = "cn-north-1"
  34. log.Infof("PricingSource (AWS): Using China pricing endpoint for CNY currency")
  35. }
  36. // When parsing product we create keys based off of product attributes and link those to a SKU.
  37. handleProduct := func(product *PriceListEC2Product) {
  38. productCount++
  39. if productCount%logInterval == 0 {
  40. log.Infof("PricingSource (AWS): processed %d products...", productCount)
  41. }
  42. attr := product.Attributes
  43. if attr.LocationType != "AWS Region" {
  44. return
  45. }
  46. // Handle EC2 instances
  47. if (strings.HasPrefix(attr.UsageType, "BoxUsage") || strings.Contains(attr.UsageType, "-BoxUsage")) &&
  48. (attr.CapacityStatus == "Used" || attr.CapacityStatus == "") &&
  49. (attr.MarketOption == "OnDemand" || attr.MarketOption == "") {
  50. if attr.OperatingSystem != "" && attr.OperatingSystem != "NA" && attr.OperatingSystem != "Linux" {
  51. return
  52. }
  53. if attr.RegionCode == "" || attr.InstanceType == "" {
  54. return
  55. }
  56. skuToNodeKey[product.Sku] = nodeKey{
  57. Region: attr.RegionCode,
  58. InstanceType: attr.InstanceType,
  59. }
  60. return
  61. }
  62. // Handle EBS volumes
  63. if strings.Contains(attr.UsageType, "EBS:Volume") {
  64. // Extract the volume type from the usage type (e.g., "USE1-EBS:VolumeUsage.gp3" -> "EBS:VolumeUsage.gp3")
  65. usageTypeMatch := usageTypeRegex.FindStringSubmatch(attr.UsageType)
  66. if len(usageTypeMatch) == 0 {
  67. return
  68. }
  69. usageTypeNoRegion := usageTypeMatch[len(usageTypeMatch)-1]
  70. // Map to volume type
  71. volumeType, ok := awsVolumeTypes[usageTypeNoRegion]
  72. if !ok {
  73. return
  74. }
  75. if attr.RegionCode == "" {
  76. return
  77. }
  78. skuToVolumeKey[product.Sku] = volumeKey{
  79. Region: attr.RegionCode,
  80. VolumeType: volumeType,
  81. UsageType: usageTypeNoRegion,
  82. }
  83. }
  84. }
  85. // Terms are used to define pricing and have the sku to look up the appropriate key.
  86. handleTerm := func(term *PriceListEC2Term) {
  87. termCount++
  88. if termCount%logInterval == 0 {
  89. log.Infof("PricingSource (AWS): processed %d terms, %d node pricing, %d volume pricing so far...",
  90. termCount, len(ps.Nodes), len(ps.Volumes))
  91. }
  92. // Check if this SKU is for a node or volume we're tracking
  93. nk, isNode := skuToNodeKey[term.Sku]
  94. vk, isVolume := skuToVolumeKey[term.Sku]
  95. if !isNode && !isVolume {
  96. return
  97. }
  98. // Determine the hourly rate code based on the offer term
  99. hourlyRateCode := HourlyRateCode
  100. if _, ok := OnDemandRateCodes[term.OfferTermCode]; !ok {
  101. if _, okCN := OnDemandRateCodesCn[term.OfferTermCode]; !okCN {
  102. // Skip if term is not OnDemand
  103. return
  104. }
  105. hourlyRateCode = HourlyRateCodeCn
  106. }
  107. priceDimensionKey := strings.Join([]string{term.Sku, term.OfferTermCode, hourlyRateCode}, ".")
  108. pricingDimension, ok := term.PriceDimensions[priceDimensionKey]
  109. if !ok {
  110. return
  111. }
  112. priceStr := pricingDimension.PricePerUnit.ForCurrency(p.config.CurrencyCode)
  113. price, err := strconv.ParseFloat(priceStr, 64)
  114. if err != nil {
  115. log.Errorf("failed to parse price '%s': %s", priceStr, err.Error())
  116. return
  117. }
  118. // Parse the currency from config, default to USD if invalid
  119. currency, err := unit.ParseCurrency(p.config.CurrencyCode)
  120. if err != nil {
  121. log.Warnf("invalid currency code '%s', defaulting to USD: %s", p.config.CurrencyCode, err.Error())
  122. currency = unit.USD
  123. }
  124. // Handle node pricing
  125. if isNode {
  126. priceObj := pricing.Price{
  127. Currency: currency,
  128. Unit: unit.Hour,
  129. Price: price,
  130. }
  131. nodePricing := &pricing.NodePricing{
  132. Properties: pricing.NodePricingProperties{
  133. Provider: pricing.AWSProvider,
  134. Region: nk.Region,
  135. InstanceType: nk.InstanceType,
  136. Provisioning: pricing.ProvisioningOnDemand,
  137. },
  138. Prices: pricing.Prices{
  139. currency: []pricing.Price{priceObj},
  140. },
  141. }
  142. ps.Nodes = append(ps.Nodes, nodePricing)
  143. }
  144. // Handle volume pricing
  145. if isVolume {
  146. // AWS volume pricing is per GB-month, convert to per GB-hour
  147. hourlyPrice := price / 730.0
  148. priceObj := pricing.Price{
  149. Currency: currency,
  150. Unit: unit.Hour,
  151. Price: hourlyPrice,
  152. }
  153. volumePricing := &pricing.VolumePricing{
  154. Properties: pricing.VolumePricingProperties{
  155. Provider: pricing.AWSProvider,
  156. Region: vk.Region,
  157. VolumeType: vk.VolumeType,
  158. },
  159. Prices: pricing.Prices{
  160. currency: []pricing.Price{priceObj},
  161. },
  162. }
  163. ps.Volumes = append(ps.Volumes, volumePricing)
  164. }
  165. }
  166. err := QueryEC2PriceList(region, handleProduct, handleTerm)
  167. if err != nil {
  168. return nil, fmt.Errorf("failed to query list pricing data %w", err)
  169. }
  170. log.Infof("PricingSource (AWS): completed in %s — %d products, %d terms, %d node pricing, %d volume pricing",
  171. time.Since(start).Round(time.Second), productCount, termCount, len(ps.Nodes), len(ps.Volumes))
  172. return ps, nil
  173. }