awspricingsource.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. package aws
  2. import (
  3. "context"
  4. "fmt"
  5. "strconv"
  6. "strings"
  7. "sync"
  8. "time"
  9. "github.com/opencost/opencost/core/pkg/cloud"
  10. "github.com/opencost/opencost/core/pkg/log"
  11. "github.com/opencost/opencost/core/pkg/pricing"
  12. "github.com/opencost/opencost/core/pkg/unit"
  13. )
  14. type AWSPricingSourceConfig struct {
  15. CurrencyCode string
  16. }
  17. type AWSPricingSource struct {
  18. config AWSPricingSourceConfig
  19. }
  20. func NewAWSPricingSource(cfg AWSPricingSourceConfig) *AWSPricingSource {
  21. return &AWSPricingSource{config: cfg}
  22. }
  23. func (p *AWSPricingSource) GetPricing() (*pricing.PricingSet, error) {
  24. log.Infof("PricingSource (AWS): starting EC2 pricing list download (large file, this may take a while)")
  25. start := time.Now()
  26. ps := &pricing.PricingSet{
  27. NodePricing: []*pricing.NodePricing{},
  28. PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
  29. ServicePricing: []*pricing.ServicePricing{},
  30. }
  31. skuToNodeKey := make(map[string]nodeKey)
  32. seenNodeKeys := make(map[nodeKey]struct{})
  33. skuToVolumeKey := make(map[string]volumeKey)
  34. seenVolumeKeys := make(map[volumeKey]struct{})
  35. skuToLBRegion := make(map[string]string)
  36. seenLBRegions := make(map[string]struct{})
  37. // Regions is used by the spotAPI to know what to query
  38. regions := make(map[string]struct{})
  39. var productCount, termCount int
  40. const logInterval = 50000
  41. region := ""
  42. if strings.ToUpper(p.config.CurrencyCode) == "CNY" {
  43. region = "cn-north-1"
  44. log.Infof("PricingSource (AWS): Using China pricing endpoint for CNY currency")
  45. }
  46. // When parsing product we create keys based off of product attributes and link those to a SKU.
  47. handleProduct := func(product *PriceListEC2Product) {
  48. productCount++
  49. if productCount%logInterval == 0 {
  50. log.Infof("PricingSource (AWS): processed %d products...", productCount)
  51. }
  52. attr := product.Attributes
  53. if attr.LocationType != "AWS Region" {
  54. return
  55. }
  56. // Handle EC2 instances.
  57. // We only want the base Linux on-demand price:
  58. // - UsageType must be a BoxUsage (compute hour charge)
  59. // - CapacityStatus must be "Used" (not a capacity reservation)
  60. // - MarketOption must be "OnDemand" (not Spot)
  61. // - OperatingSystem must be Linux (or not returned by API)
  62. // - PreInstalledSw must be "NA" (no paid software bundle)
  63. // All of these can appear empty when the API omits the field, so we
  64. // treat empty as "unknown" and require the affirmative value where it
  65. // matters, except OperatingSystem where empty/NA is acceptable.
  66. if (strings.HasPrefix(attr.UsageType, "BoxUsage") || strings.Contains(attr.UsageType, "-BoxUsage")) &&
  67. (attr.CapacityStatus == "Used" || attr.CapacityStatus == "") &&
  68. (attr.MarketOption == "OnDemand" || attr.MarketOption == "") {
  69. // Skip non-Linux operating systems; allow empty/NA (field may not be returned).
  70. if attr.OperatingSystem != "" && attr.OperatingSystem != "NA" && attr.OperatingSystem != "Linux" {
  71. return
  72. }
  73. // Skip software bundles (SQL Server, etc.); allow empty (field may not be returned).
  74. if attr.PreInstalledSw != "" && attr.PreInstalledSw != "NA" {
  75. return
  76. }
  77. // Skip capacity reservations; allow empty (field may not be returned).
  78. if attr.CapacityStatus != "" && attr.CapacityStatus != "Used" {
  79. return
  80. }
  81. if attr.RegionCode == "" || attr.InstanceType == "" {
  82. return
  83. }
  84. nk := nodeKey{
  85. Region: attr.RegionCode,
  86. InstanceType: attr.InstanceType,
  87. }
  88. if _, seen := seenNodeKeys[nk]; seen {
  89. return
  90. }
  91. seenNodeKeys[nk] = struct{}{}
  92. regions[attr.RegionCode] = struct{}{}
  93. skuToNodeKey[product.Sku] = nk
  94. return
  95. }
  96. // Handle Network Load Balancer pricing
  97. if strings.Contains(attr.UsageType, "LoadBalancerUsage") && attr.Operation == "LoadBalancing:Network" {
  98. if attr.RegionCode == "" {
  99. return
  100. }
  101. if _, seen := seenLBRegions[attr.RegionCode]; seen {
  102. return
  103. }
  104. seenLBRegions[attr.RegionCode] = struct{}{}
  105. skuToLBRegion[product.Sku] = attr.RegionCode
  106. return
  107. }
  108. // Handle EBS volumes
  109. if strings.Contains(attr.UsageType, "EBS:Volume") {
  110. // Extract the volume type from the usage type (e.g., "USE1-EBS:VolumeUsage.gp3" -> "EBS:VolumeUsage.gp3")
  111. usageTypeMatch := usageTypeRegex.FindStringSubmatch(attr.UsageType)
  112. if len(usageTypeMatch) == 0 {
  113. return
  114. }
  115. usageTypeNoRegion := usageTypeMatch[len(usageTypeMatch)-1]
  116. // Map to volume type
  117. volumeType, ok := awsVolumeTypes[usageTypeNoRegion]
  118. if !ok {
  119. return
  120. }
  121. if attr.RegionCode == "" {
  122. return
  123. }
  124. vk := volumeKey{
  125. Region: attr.RegionCode,
  126. VolumeType: volumeType,
  127. UsageType: usageTypeNoRegion,
  128. }
  129. if _, seen := seenVolumeKeys[vk]; seen {
  130. return
  131. }
  132. seenVolumeKeys[vk] = struct{}{}
  133. skuToVolumeKey[product.Sku] = vk
  134. }
  135. }
  136. // Terms are used to define pricing and have the sku to look up the appropriate key.
  137. handleTerm := func(term *PriceListEC2Term) {
  138. termCount++
  139. if termCount%logInterval == 0 {
  140. log.Infof("PricingSource (AWS): processed %d terms, %d node pricing, %d volume pricing so far...",
  141. termCount, len(ps.NodePricing), len(ps.PersistentVolumePricing))
  142. }
  143. // Check if this SKU is for a node, volume, or load balancer we're tracking
  144. nk, isNode := skuToNodeKey[term.Sku]
  145. vk, isVolume := skuToVolumeKey[term.Sku]
  146. lbRegion, isLB := skuToLBRegion[term.Sku]
  147. if !isNode && !isVolume && !isLB {
  148. return
  149. }
  150. // Determine the hourly rate code based on the offer term
  151. hourlyRateCode := HourlyRateCode
  152. if _, ok := OnDemandRateCodes[term.OfferTermCode]; !ok {
  153. if _, okCN := OnDemandRateCodesCn[term.OfferTermCode]; !okCN {
  154. // Skip if term is not OnDemand
  155. return
  156. }
  157. hourlyRateCode = HourlyRateCodeCn
  158. }
  159. priceDimensionKey := strings.Join([]string{term.Sku, term.OfferTermCode, hourlyRateCode}, ".")
  160. pricingDimension, ok := term.PriceDimensions[priceDimensionKey]
  161. if !ok {
  162. return
  163. }
  164. priceStr := pricingDimension.PricePerUnit.ForCurrency(p.config.CurrencyCode)
  165. price, err := strconv.ParseFloat(priceStr, 64)
  166. if err != nil {
  167. log.Errorf("failed to parse price '%s': %s", priceStr, err.Error())
  168. return
  169. }
  170. // Handle node pricing
  171. if isNode {
  172. nodePricing := &pricing.NodePricing{
  173. Properties: pricing.NodePricingProperties{
  174. Provider: cloud.ProviderAWS,
  175. Region: nk.Region,
  176. InstanceType: nk.InstanceType,
  177. Provisioning: pricing.ProvisioningOnDemand,
  178. },
  179. Prices: pricing.Prices{
  180. pricing.ResourceNode: pricing.Price{
  181. Unit: unit.Hour,
  182. Price: price,
  183. },
  184. },
  185. }
  186. ps.NodePricing = append(ps.NodePricing, nodePricing)
  187. }
  188. // Handle volume pricing
  189. if isVolume {
  190. // AWS volume pricing is per GB-month, convert to per GB-hour
  191. hourlyPrice := price / 730.0
  192. volumePricing := &pricing.PersistentVolumePricing{
  193. Properties: pricing.PersistentVolumePricingProperties{
  194. Provider: cloud.ProviderAWS,
  195. Region: vk.Region,
  196. VolumeType: vk.VolumeType,
  197. },
  198. Prices: pricing.Prices{
  199. pricing.ResourceStorage: pricing.Price{
  200. Unit: unit.GiBHour,
  201. Price: hourlyPrice,
  202. },
  203. },
  204. }
  205. ps.PersistentVolumePricing = append(ps.PersistentVolumePricing, volumePricing)
  206. }
  207. // Handle load balancer pricing
  208. if isLB {
  209. servicePricing := &pricing.ServicePricing{
  210. Properties: pricing.ServicePricingProperties{
  211. Provider: cloud.ProviderAWS,
  212. Region: lbRegion,
  213. },
  214. Prices: pricing.Prices{
  215. pricing.ResourceService: pricing.Price{
  216. Unit: unit.Hour,
  217. Price: price,
  218. },
  219. },
  220. }
  221. ps.ServicePricing = append(ps.ServicePricing, servicePricing)
  222. }
  223. }
  224. err := QueryEC2PriceList(region, handleProduct, handleTerm)
  225. if err != nil {
  226. return nil, fmt.Errorf("failed to query list pricing data %w", err)
  227. }
  228. log.Infof("PricingSource (AWS): on-demand completed in %s — %d products, %d terms, %d node pricing, %d volume pricing",
  229. time.Since(start).Round(time.Second), productCount, termCount, len(ps.NodePricing), len(ps.PersistentVolumePricing))
  230. // China does not have the spotAPI endpoint
  231. if strings.ToUpper(p.config.CurrencyCode) != "CNY" {
  232. spotStart := time.Now()
  233. ctx := context.Background()
  234. type regionResult struct {
  235. prices []SpotPrice
  236. err error
  237. region string
  238. }
  239. resultCh := make(chan regionResult, len(regions))
  240. var wg sync.WaitGroup
  241. // TODO: Add separate credential path for aws gov regions. Current AWS account cannot hit it
  242. for r := range regions {
  243. wg.Add(1)
  244. go func(r string) {
  245. defer wg.Done()
  246. prices, err := QuerySpotPrices(ctx, r)
  247. resultCh <- regionResult{prices: prices, err: err, region: r}
  248. }(r)
  249. }
  250. wg.Wait()
  251. close(resultCh)
  252. var spotCount int
  253. for res := range resultCh {
  254. if res.err != nil {
  255. log.Warnf("PricingSource (AWS): failed to fetch spot prices for region %s: %v", res.region, res.err)
  256. continue
  257. }
  258. for _, sp := range res.prices {
  259. ps.NodePricing = append(ps.NodePricing, &pricing.NodePricing{
  260. Properties: pricing.NodePricingProperties{
  261. Provider: cloud.ProviderAWS,
  262. Region: sp.Region,
  263. InstanceType: sp.InstanceType,
  264. Provisioning: pricing.ProvisioningSpot,
  265. },
  266. Prices: pricing.Prices{
  267. pricing.ResourceNode: pricing.Price{
  268. Unit: unit.Hour,
  269. Price: sp.Price,
  270. },
  271. },
  272. })
  273. spotCount++
  274. }
  275. }
  276. log.Infof("PricingSource (AWS): spot pricing completed in %s — %d entries across %d regions",
  277. time.Since(spotStart).Round(time.Second), spotCount, len(regions))
  278. }
  279. return ps, nil
  280. }