retailpricingsource.go 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. package azure
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "io"
  6. "net/http"
  7. "net/url"
  8. "strings"
  9. "time"
  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 (
  15. azureRetailPricingBaseURL = "https://prices.azure.com/api/retail/prices"
  16. azureRetailVMFilter = "serviceName eq 'Virtual Machines' and priceType eq 'Consumption'"
  17. )
  18. const AzureRetailPricingSourceType pricingmodel.PricingSourceType = "azure_retail_pricing_api"
  19. // AzureRetailPricingSourceConfig holds configuration for AzureRetailPricingSource.
  20. type AzureRetailPricingSourceConfig struct {
  21. CurrencyCode string
  22. }
  23. var azureRetailHTTPClient = &http.Client{Timeout: 60 * time.Second}
  24. // AzureRetailPricingSource implements pricingmodel.PricingSource using the
  25. // Azure Retail Prices API (no authentication required).
  26. type AzureRetailPricingSource struct {
  27. config AzureRetailPricingSourceConfig
  28. }
  29. func NewAzureRetailPricingSource(cfg AzureRetailPricingSourceConfig) *AzureRetailPricingSource {
  30. return &AzureRetailPricingSource{config: cfg}
  31. }
  32. func (a *AzureRetailPricingSource) PricingSourceType() pricingmodel.PricingSourceType {
  33. return AzureRetailPricingSourceType
  34. }
  35. // PricingSourceKey returns the PricingSourceType because it is meant to run single instance.
  36. func (a *AzureRetailPricingSource) PricingSourceKey() string {
  37. return string(AzureRetailPricingSourceType)
  38. }
  39. func (a *AzureRetailPricingSource) GetPricing() (*pricingmodel.PricingModelSet, error) {
  40. now := time.Now().UTC()
  41. pms := pricingmodel.NewPricingModelSet(now, a.PricingSourceType(), a.PricingSourceKey())
  42. url := a.buildInitialURL()
  43. pageCount := 0
  44. for url != "" {
  45. resp, err := azureRetailHTTPClient.Get(url)
  46. if err != nil {
  47. return nil, fmt.Errorf("AzureRetailPricingSource: GET %s: %w", url, err)
  48. }
  49. if resp.StatusCode != http.StatusOK {
  50. body, _ := io.ReadAll(resp.Body)
  51. resp.Body.Close()
  52. return nil, fmt.Errorf("AzureRetailPricingSource: unexpected status %d on page %d: %s", resp.StatusCode, pageCount, string(body))
  53. }
  54. next, err := a.parsePage(resp.Body, pms)
  55. resp.Body.Close()
  56. if err != nil {
  57. return nil, fmt.Errorf("AzureRetailPricingSource: parsing page %d: %w", pageCount, err)
  58. }
  59. pageCount++
  60. url = next
  61. log.Debugf("AzureRetailPricingSource: fetched page %d, next: %s", pageCount, url)
  62. }
  63. log.Infof("AzureRetailPricingSource: loaded %d pricing entries across %d pages", len(pms.NodePricing), pageCount)
  64. return pms, nil
  65. }
  66. func (a *AzureRetailPricingSource) buildInitialURL() string {
  67. u := azureRetailPricingBaseURL + "?$filter=" + url.QueryEscape(azureRetailVMFilter)
  68. if a.config.CurrencyCode != "" {
  69. u += "&currencyCode=" + url.QueryEscape(a.config.CurrencyCode)
  70. }
  71. return u
  72. }
  73. func (a *AzureRetailPricingSource) parsePage(body io.Reader, pms *pricingmodel.PricingModelSet) (nextURL string, err error) {
  74. data, err := io.ReadAll(body)
  75. if err != nil {
  76. return "", fmt.Errorf("reading response body: %w", err)
  77. }
  78. var page AzureRetailPricing
  79. if err := json.Unmarshal(data, &page); err != nil {
  80. return "", fmt.Errorf("unmarshalling response: %w", err)
  81. }
  82. for _, item := range page.Items {
  83. if !a.includeItem(item) {
  84. continue
  85. }
  86. key := pricingmodel.NodeKey{
  87. Provider: shared.ProviderAzure,
  88. Region: item.ArmRegionName,
  89. NodeType: item.ArmSkuName,
  90. UsageType: usageTypeFromSku(item.SkuName),
  91. PricingType: pricingmodel.NodePricingTypeTotal,
  92. }
  93. pms.NodePricing[key] = pricingmodel.NodePricing{
  94. HourlyRate: float64(item.RetailPrice),
  95. }
  96. }
  97. return page.NextPageLink, nil
  98. }
  99. // includeItem mirrors the filtering logic in the existing Azure provider.
  100. func (a *AzureRetailPricingSource) includeItem(item AzureRetailPricingAttributes) bool {
  101. if item.ArmSkuName == "" || item.ArmRegionName == "" {
  102. return false
  103. }
  104. if strings.Contains(item.ProductName, "Windows") {
  105. return false
  106. }
  107. skuLower := strings.ToLower(item.SkuName)
  108. productLower := strings.ToLower(item.ProductName)
  109. if strings.Contains(skuLower, "low priority") {
  110. return false
  111. }
  112. if strings.Contains(productLower, "cloud services") || strings.Contains(productLower, "cloudservices") {
  113. return false
  114. }
  115. return true
  116. }
  117. func usageTypeFromSku(skuName string) shared.UsageType {
  118. if strings.Contains(strings.ToLower(skuName), " spot") {
  119. return shared.UsageTypeSpot
  120. }
  121. return shared.UsageTypeOnDemand
  122. }