azurepricingsource.go 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  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/shared"
  12. "github.com/opencost/opencost/core/pkg/pricing"
  13. "github.com/opencost/opencost/core/pkg/unit"
  14. )
  15. const (
  16. azurePricingBaseURL = "https://prices.azure.com/api/retail/prices"
  17. azureVMFilter = "serviceName eq 'Virtual Machines' and priceType eq 'Consumption'"
  18. azureDiskFilter = "serviceName eq 'Storage' and priceType eq 'Consumption'"
  19. )
  20. // AzurePricingSourceConfig holds configuration for AzurePricingSource.
  21. type AzurePricingSourceConfig struct {
  22. CurrencyCode string
  23. }
  24. var azureHTTPClient = &http.Client{Timeout: 60 * time.Second}
  25. // AzurePricingSource implements the PricingSource interface using the
  26. // Azure Retail Prices API (no auth required).
  27. type AzurePricingSource struct {
  28. config AzurePricingSourceConfig
  29. }
  30. func NewAzurePricingSource(cfg AzurePricingSourceConfig) *AzurePricingSource {
  31. return &AzurePricingSource{config: cfg}
  32. }
  33. func (a *AzurePricingSource) GetPricing() (*pricing.PricingSet, error) {
  34. log.Infof("PricingSource (Azure): starting pricing download")
  35. start := time.Now()
  36. ps := &pricing.PricingSet{
  37. NodePricing: []*pricing.NodePricing{},
  38. PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
  39. }
  40. // Fetch VM pricing
  41. url := a.buildVMURL()
  42. pageCount := 0
  43. for url != "" {
  44. resp, err := azureHTTPClient.Get(url)
  45. if err != nil {
  46. return nil, fmt.Errorf("PricingSource (Azure): GET %s: %w", url, err)
  47. }
  48. if resp.StatusCode != http.StatusOK {
  49. body, _ := io.ReadAll(resp.Body)
  50. closeErr := resp.Body.Close()
  51. if closeErr != nil {
  52. log.Warnf("failed to close response body: %v", closeErr)
  53. }
  54. return nil, fmt.Errorf("PricingSource (Azure): unexpected status %d on VM page %d: %s", resp.StatusCode, pageCount, string(body))
  55. }
  56. next, err := a.parseVMPage(resp.Body, ps)
  57. closeErr := resp.Body.Close()
  58. if closeErr != nil {
  59. log.Warnf("failed to close response body: %v", closeErr)
  60. }
  61. if err != nil {
  62. return nil, fmt.Errorf("PricingSource (Azure): parsing VM page %d: %w", pageCount, err)
  63. }
  64. pageCount++
  65. url = next
  66. log.Debugf("PricingSource (Azure): fetched VM page %d, next: %s", pageCount, url)
  67. }
  68. log.Infof("PricingSource (Azure): fetched %d VM pricing entries across %d pages", len(ps.NodePricing), pageCount)
  69. // Fetch disk pricing
  70. url = a.buildDiskURL()
  71. diskPageCount := 0
  72. for url != "" {
  73. resp, err := azureHTTPClient.Get(url)
  74. if err != nil {
  75. log.Warnf("PricingSource (Azure): failed to fetch disk pricing: %v", err)
  76. break
  77. }
  78. if resp.StatusCode != http.StatusOK {
  79. body, _ := io.ReadAll(resp.Body)
  80. closeErr := resp.Body.Close()
  81. if closeErr != nil {
  82. log.Warnf("failed to close response body: %v", closeErr)
  83. }
  84. log.Warnf("PricingSource (Azure): unexpected status %d on disk page %d: %s", resp.StatusCode, diskPageCount, string(body))
  85. break
  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. log.Warnf("PricingSource (Azure): error parsing disk page %d: %v", diskPageCount, err)
  94. break
  95. }
  96. diskPageCount++
  97. url = next
  98. log.Debugf("PricingSource (Azure): fetched disk page %d, next: %s", diskPageCount, url)
  99. }
  100. log.Infof("PricingSource (Azure): completed in %s — %d node pricing, %d volume pricing",
  101. time.Since(start).Round(time.Second), len(ps.NodePricing), len(ps.PersistentVolumePricing))
  102. return ps, nil
  103. }
  104. func (a *AzurePricingSource) buildVMURL() string {
  105. u := azurePricingBaseURL + "?$filter=" + url.QueryEscape(azureVMFilter)
  106. if a.config.CurrencyCode != "" {
  107. u += "&currencyCode=" + url.QueryEscape(a.config.CurrencyCode)
  108. }
  109. return u
  110. }
  111. func (a *AzurePricingSource) buildDiskURL() string {
  112. u := azurePricingBaseURL + "?$filter=" + url.QueryEscape(azureDiskFilter)
  113. if a.config.CurrencyCode != "" {
  114. u += "&currencyCode=" + url.QueryEscape(a.config.CurrencyCode)
  115. }
  116. return u
  117. }
  118. func (a *AzurePricingSource) parseVMPage(body io.Reader, ps *pricing.PricingSet) (nextURL string, err error) {
  119. data, err := io.ReadAll(body)
  120. if err != nil {
  121. return "", fmt.Errorf("reading response body: %w", err)
  122. }
  123. var page AzurePricing
  124. if err := json.Unmarshal(data, &page); err != nil {
  125. return "", fmt.Errorf("unmarshalling response: %w", err)
  126. }
  127. for _, item := range page.Items {
  128. if !a.includeItem(item) {
  129. continue
  130. }
  131. // TODO: handle currency?
  132. // Parse the currency from config, default to USD if invalid
  133. // currency, err := unit.ParseCurrency(a.config.CurrencyCode)
  134. // if err != nil {
  135. // log.Warnf("invalid currency code '%s', defaulting to USD: %s", a.config.CurrencyCode, err.Error())
  136. // currency = unit.USD
  137. // }
  138. nodePricing := &pricing.NodePricing{
  139. Properties: pricing.NodePricingProperties{
  140. Provider: shared.ProviderAzure,
  141. Region: item.ArmRegionName,
  142. InstanceType: item.ArmSkuName,
  143. Provisioning: pricing.ProvisioningOnDemand,
  144. },
  145. Prices: pricing.Prices{
  146. pricing.ResourceNode: pricing.Price{
  147. Unit: unit.Hour,
  148. Price: float64(item.RetailPrice),
  149. },
  150. },
  151. }
  152. ps.NodePricing = append(ps.NodePricing, nodePricing)
  153. }
  154. return page.NextPageLink, nil
  155. }
  156. func (a *AzurePricingSource) parseDiskPage(body io.Reader, ps *pricing.PricingSet) (nextURL string, err error) {
  157. data, err := io.ReadAll(body)
  158. if err != nil {
  159. return "", fmt.Errorf("reading response body: %w", err)
  160. }
  161. var page AzurePricing
  162. if err := json.Unmarshal(data, &page); err != nil {
  163. return "", fmt.Errorf("unmarshalling response: %w", err)
  164. }
  165. for _, item := range page.Items {
  166. if !a.includeDiskItem(item) {
  167. continue
  168. }
  169. volumeType := mapAzureDiskType(item.SkuName)
  170. if volumeType == pricing.VolumeTypeNil {
  171. continue
  172. }
  173. // TODO: handle currency?
  174. // currency, err := unit.ParseCurrency(a.config.CurrencyCode)
  175. // if err != nil {
  176. // log.Warnf("invalid currency code '%s', defaulting to USD: %s", a.config.CurrencyCode, err.Error())
  177. // currency = unit.USD
  178. // }
  179. // Azure disk pricing is per GB-month, convert to per GB-hour
  180. hourlyPrice := float64(item.RetailPrice) / 730.0
  181. volumePricing := &pricing.PersistentVolumePricing{
  182. Properties: pricing.PersistentVolumePricingProperties{
  183. Provider: shared.ProviderAzure,
  184. Region: item.ArmRegionName,
  185. VolumeType: volumeType,
  186. },
  187. Prices: pricing.Prices{
  188. pricing.ResourceStorage: pricing.Price{
  189. Unit: unit.Hour,
  190. Price: hourlyPrice,
  191. },
  192. },
  193. }
  194. ps.PersistentVolumePricing = append(ps.PersistentVolumePricing, volumePricing)
  195. }
  196. return page.NextPageLink, nil
  197. }
  198. // includeItem mirrors the filtering logic in the existing Azure provider for VMs.
  199. func (a *AzurePricingSource) includeItem(item AzurePricingAttributes) bool {
  200. if item.ArmSkuName == "" || item.ArmRegionName == "" {
  201. return false
  202. }
  203. if strings.Contains(item.ProductName, "Windows") {
  204. return false
  205. }
  206. skuLower := strings.ToLower(item.SkuName)
  207. productLower := strings.ToLower(item.ProductName)
  208. if strings.Contains(skuLower, "low priority") {
  209. return false
  210. }
  211. if strings.Contains(productLower, "cloud services") || strings.Contains(productLower, "cloudservices") {
  212. return false
  213. }
  214. return true
  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. }