azurepricingsource.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  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/cloud"
  11. "github.com/opencost/opencost/core/pkg/log"
  12. "github.com/opencost/opencost/core/pkg/pricing"
  13. "github.com/opencost/opencost/core/pkg/unit"
  14. "github.com/opencost/opencost/modules/pricing/public/httpclient"
  15. )
  16. const (
  17. azurePricingBaseURL = "https://prices.azure.com/api/retail/prices"
  18. azureVMFilter = "serviceName eq 'Virtual Machines' and priceType eq 'Consumption'"
  19. azureDiskFilter = "serviceName eq 'Storage' and priceType eq 'Consumption'"
  20. )
  21. // AzurePricingSourceConfig holds configuration for AzurePricingSource.
  22. type AzurePricingSourceConfig struct {
  23. CurrencyCode string
  24. }
  25. var azureHTTPClient = httpclient.NewClient(120 * time.Second)
  26. // AzurePricingSource implements the PricingSource interface using the
  27. // Azure Retail Prices API (no auth required).
  28. type AzurePricingSource struct {
  29. config AzurePricingSourceConfig
  30. }
  31. func NewAzurePricingSource(cfg AzurePricingSourceConfig) *AzurePricingSource {
  32. return &AzurePricingSource{config: cfg}
  33. }
  34. func (a *AzurePricingSource) GetPricing() (*pricing.PricingSet, error) {
  35. log.Infof("PricingSource (Azure): starting pricing download")
  36. start := time.Now()
  37. ps := &pricing.PricingSet{
  38. NodePricing: []*pricing.NodePricing{},
  39. PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
  40. }
  41. // Fetch VM pricing
  42. url := a.buildVMURL()
  43. pageCount := 0
  44. seenNodes := make(map[nodeKey]struct{})
  45. for url != "" {
  46. resp, err := azureHTTPClient.Get(url)
  47. if err != nil {
  48. return nil, fmt.Errorf("PricingSource (Azure): GET %s: %w", url, err)
  49. }
  50. if resp.StatusCode != http.StatusOK {
  51. body, _ := io.ReadAll(resp.Body)
  52. closeErr := resp.Body.Close()
  53. if closeErr != nil {
  54. log.Warnf("failed to close response body: %v", closeErr)
  55. }
  56. return nil, fmt.Errorf("PricingSource (Azure): unexpected status %d on VM page %d: %s", resp.StatusCode, pageCount, string(body))
  57. }
  58. next, err := a.parseVMPage(resp.Body, ps, seenNodes)
  59. closeErr := resp.Body.Close()
  60. if closeErr != nil {
  61. log.Warnf("failed to close response body: %v", closeErr)
  62. }
  63. if err != nil {
  64. return nil, fmt.Errorf("PricingSource (Azure): parsing VM page %d: %w", pageCount, err)
  65. }
  66. pageCount++
  67. url = next
  68. log.Debugf("PricingSource (Azure): fetched VM page %d, next: %s", pageCount, url)
  69. }
  70. log.Infof("PricingSource (Azure): fetched %d VM pricing entries across %d pages", len(ps.NodePricing), pageCount)
  71. // Fetch disk pricing
  72. url = a.buildDiskURL()
  73. diskPageCount := 0
  74. for url != "" {
  75. resp, err := azureHTTPClient.Get(url)
  76. if err != nil {
  77. return nil, fmt.Errorf("PricingSource (Azure): GET %s: %w", url, err)
  78. }
  79. if resp.StatusCode != http.StatusOK {
  80. body, _ := io.ReadAll(resp.Body)
  81. closeErr := resp.Body.Close()
  82. if closeErr != nil {
  83. log.Warnf("failed to close response body: %v", closeErr)
  84. }
  85. return nil, fmt.Errorf("PricingSource (Azure): unexpected status %d on disk page %d: %s", resp.StatusCode, diskPageCount, string(body))
  86. }
  87. next, err := a.parseDiskPage(resp.Body, ps)
  88. closeErr := resp.Body.Close()
  89. if closeErr != nil {
  90. log.Warnf("failed to close response body: %v", closeErr)
  91. }
  92. if err != nil {
  93. return nil, fmt.Errorf("PricingSource (Azure): parsing disk page %d: %w", diskPageCount, err)
  94. }
  95. diskPageCount++
  96. url = next
  97. log.Debugf("PricingSource (Azure): fetched disk page %d, next: %s", diskPageCount, url)
  98. }
  99. log.Infof("PricingSource (Azure): completed in %s — %d node pricing, %d volume pricing",
  100. time.Since(start).Round(time.Second), len(ps.NodePricing), len(ps.PersistentVolumePricing))
  101. return ps, nil
  102. }
  103. func (a *AzurePricingSource) buildVMURL() string {
  104. u := azurePricingBaseURL + "?$filter=" + url.QueryEscape(azureVMFilter)
  105. if a.config.CurrencyCode != "" {
  106. u += "&currencyCode=" + url.QueryEscape(a.config.CurrencyCode)
  107. }
  108. return u
  109. }
  110. func (a *AzurePricingSource) buildDiskURL() string {
  111. u := azurePricingBaseURL + "?$filter=" + url.QueryEscape(azureDiskFilter)
  112. if a.config.CurrencyCode != "" {
  113. u += "&currencyCode=" + url.QueryEscape(a.config.CurrencyCode)
  114. }
  115. return u
  116. }
  117. func (a *AzurePricingSource) parseVMPage(body io.Reader, ps *pricing.PricingSet, seen map[nodeKey]struct{}) (nextURL string, err error) {
  118. data, err := io.ReadAll(body)
  119. if err != nil {
  120. return "", fmt.Errorf("reading response body: %w", err)
  121. }
  122. var page AzurePricing
  123. if err := json.Unmarshal(data, &page); err != nil {
  124. return "", fmt.Errorf("unmarshalling response: %w", err)
  125. }
  126. for _, item := range page.Items {
  127. if !a.includeItem(item) {
  128. continue
  129. }
  130. provisioning := pricing.ProvisioningOnDemand
  131. if isSpotItem(item) {
  132. provisioning = pricing.ProvisioningSpot
  133. }
  134. nk := nodeKey{Region: item.ArmRegionName, InstanceType: item.ArmSkuName, Provisioning: provisioning}
  135. if _, ok := seen[nk]; ok {
  136. continue
  137. }
  138. seen[nk] = struct{}{}
  139. nodePricing := &pricing.NodePricing{
  140. Properties: pricing.NodePricingProperties{
  141. Provider: cloud.ProviderAzure,
  142. Region: item.ArmRegionName,
  143. InstanceType: item.ArmSkuName,
  144. Provisioning: provisioning,
  145. },
  146. Prices: pricing.Prices{
  147. pricing.ResourceNode: pricing.Price{
  148. Unit: unit.Hour,
  149. Price: float64(item.RetailPrice),
  150. },
  151. },
  152. }
  153. ps.NodePricing = append(ps.NodePricing, nodePricing)
  154. }
  155. return page.NextPageLink, nil
  156. }
  157. func (a *AzurePricingSource) parseDiskPage(body io.Reader, ps *pricing.PricingSet) (nextURL string, err error) {
  158. data, err := io.ReadAll(body)
  159. if err != nil {
  160. return "", fmt.Errorf("reading response body: %w", err)
  161. }
  162. var page AzurePricing
  163. if err := json.Unmarshal(data, &page); err != nil {
  164. return "", fmt.Errorf("unmarshalling response: %w", err)
  165. }
  166. for _, item := range page.Items {
  167. if !a.includeDiskItem(item) {
  168. continue
  169. }
  170. volumeType := mapAzureDiskType(item.SkuName)
  171. if volumeType == pricing.VolumeTypeNil {
  172. continue
  173. }
  174. // Azure disk pricing is per GB-month, convert to per GB-hour
  175. hourlyPrice := float64(item.RetailPrice) / 730.0
  176. volumePricing := &pricing.PersistentVolumePricing{
  177. Properties: pricing.PersistentVolumePricingProperties{
  178. Provider: cloud.ProviderAzure,
  179. Region: item.ArmRegionName,
  180. VolumeType: volumeType,
  181. },
  182. Prices: pricing.Prices{
  183. pricing.ResourceStorage: pricing.Price{
  184. Unit: unit.GiBHour,
  185. Price: hourlyPrice,
  186. },
  187. },
  188. }
  189. ps.PersistentVolumePricing = append(ps.PersistentVolumePricing, volumePricing)
  190. }
  191. return page.NextPageLink, nil
  192. }
  193. // includeItem mirrors the filtering logic in the existing Azure provider for VMs.
  194. func (a *AzurePricingSource) includeItem(item AzurePricingAttributes) bool {
  195. if item.ArmSkuName == "" || item.ArmRegionName == "" {
  196. return false
  197. }
  198. productLower := strings.ToLower(item.ProductName)
  199. if strings.Contains(productLower, "windows") {
  200. return false
  201. }
  202. if strings.Contains(productLower, "cloud services") || strings.Contains(productLower, "cloudservices") {
  203. return false
  204. }
  205. // The Azure API appends an exact suffix to SkuName for non-on-demand rows.
  206. // We want on-demand and Spot Linux pricing, so only reject Low Priority
  207. // (a deprecated pricing model with no corresponding ProvisioningType).
  208. skuLower := strings.ToLower(item.SkuName)
  209. return !strings.HasSuffix(skuLower, " low priority")
  210. }
  211. // isSpotItem returns true if the pricing item represents Spot VM pricing.
  212. // The Azure Retail Prices API denotes Spot rows with a " Spot" suffix on SkuName.
  213. func isSpotItem(item AzurePricingAttributes) bool {
  214. return strings.HasSuffix(strings.ToLower(item.SkuName), " spot")
  215. }
  216. // includeDiskItem filters disk items to include only managed disks.
  217. func (a *AzurePricingSource) includeDiskItem(item AzurePricingAttributes) bool {
  218. if item.ArmRegionName == "" {
  219. return false
  220. }
  221. productLower := strings.ToLower(item.ProductName)
  222. // Exclude unmanaged disks explicitly (weird case where "Unmanaged disk" still has managed "managed disk" :\)
  223. if strings.Contains(productLower, "unmanaged") {
  224. return false
  225. }
  226. // Only include managed disks
  227. return strings.Contains(productLower, "managed disk")
  228. }
  229. // AzurePricing represents the response from Azure Retail Prices API
  230. type AzurePricing struct {
  231. BillingCurrency string `json:"BillingCurrency"`
  232. CustomerEntityId string `json:"CustomerEntityId"`
  233. CustomerEntityType string `json:"CustomerEntityType"`
  234. Items []AzurePricingAttributes `json:"Items"`
  235. NextPageLink string `json:"NextPageLink"`
  236. Count int `json:"Count"`
  237. }
  238. // AzurePricingAttributes represents a single pricing item from Azure Retail Prices API
  239. type AzurePricingAttributes struct {
  240. CurrencyCode string `json:"currencyCode"`
  241. TierMinimumUnits float32 `json:"tierMinimumUnits"`
  242. RetailPrice float32 `json:"retailPrice"`
  243. UnitPrice float32 `json:"unitPrice"`
  244. ArmRegionName string `json:"armRegionName"`
  245. Location string `json:"location"`
  246. EffectiveStartDate *time.Time `json:"effectiveStartDate"`
  247. EffectiveEndDate *time.Time `json:"effectiveEndDate"`
  248. MeterId string `json:"meterId"`
  249. MeterName string `json:"meterName"`
  250. ProductId string `json:"productId"`
  251. SkuId string `json:"skuId"`
  252. ProductName string `json:"productName"`
  253. SkuName string `json:"skuName"`
  254. ServiceName string `json:"serviceName"`
  255. ServiceId string `json:"serviceId"`
  256. ServiceFamily string `json:"serviceFamily"`
  257. UnitOfMeasure string `json:"unitOfMeasure"`
  258. Type string `json:"type"`
  259. IsPrimaryMeterRegion bool `json:"isPrimaryMeterRegion"`
  260. ArmSkuName string `json:"armSkuName"`
  261. }