azurepricingsource.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  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. Nodes: []*pricing.NodePricing{},
  38. Volumes: []*pricing.VolumePricing{},
  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.Nodes), 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.Nodes), len(ps.Volumes))
  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. // Parse the currency from config, default to USD if invalid
  132. currency, err := unit.ParseCurrency(a.config.CurrencyCode)
  133. if err != nil {
  134. log.Warnf("invalid currency code '%s', defaulting to USD: %s", a.config.CurrencyCode, err.Error())
  135. currency = unit.USD
  136. }
  137. priceObj := pricing.Price{
  138. Currency: currency,
  139. Unit: unit.Hour,
  140. Price: float64(item.RetailPrice),
  141. }
  142. nodePricing := &pricing.NodePricing{
  143. Properties: pricing.NodePricingProperties{
  144. Provider: pricing.Provider(shared.ProviderAzure),
  145. Region: item.ArmRegionName,
  146. InstanceType: item.ArmSkuName,
  147. Provisioning: pricing.ProvisioningOnDemand,
  148. },
  149. Prices: pricing.Prices{
  150. currency: []pricing.Price{
  151. priceObj,
  152. },
  153. },
  154. }
  155. ps.Nodes = append(ps.Nodes, nodePricing)
  156. }
  157. return page.NextPageLink, nil
  158. }
  159. func (a *AzurePricingSource) parseDiskPage(body io.Reader, ps *pricing.PricingSet) (nextURL string, err error) {
  160. data, err := io.ReadAll(body)
  161. if err != nil {
  162. return "", fmt.Errorf("reading response body: %w", err)
  163. }
  164. var page AzurePricing
  165. if err := json.Unmarshal(data, &page); err != nil {
  166. return "", fmt.Errorf("unmarshalling response: %w", err)
  167. }
  168. for _, item := range page.Items {
  169. if !a.includeDiskItem(item) {
  170. continue
  171. }
  172. volumeType := mapAzureDiskType(item.SkuName)
  173. if volumeType == pricing.VolumeTypeNil {
  174. continue
  175. }
  176. currency, err := unit.ParseCurrency(a.config.CurrencyCode)
  177. if err != nil {
  178. log.Warnf("invalid currency code '%s', defaulting to USD: %s", a.config.CurrencyCode, err.Error())
  179. currency = unit.USD
  180. }
  181. // Azure disk pricing is per GB-month, convert to per GB-hour
  182. hourlyPrice := float64(item.RetailPrice) / 730.0
  183. volumePricing := &pricing.VolumePricing{
  184. Properties: pricing.VolumePricingProperties{
  185. Provider: pricing.AzureProvider,
  186. Region: item.ArmRegionName,
  187. VolumeType: volumeType,
  188. },
  189. Prices: pricing.Prices{
  190. currency: []pricing.Price{{
  191. Currency: currency,
  192. Unit: unit.Hour,
  193. Price: hourlyPrice,
  194. }},
  195. },
  196. }
  197. ps.Volumes = append(ps.Volumes, volumePricing)
  198. }
  199. return page.NextPageLink, nil
  200. }
  201. // includeItem mirrors the filtering logic in the existing Azure provider for VMs.
  202. func (a *AzurePricingSource) includeItem(item AzurePricingAttributes) bool {
  203. if item.ArmSkuName == "" || item.ArmRegionName == "" {
  204. return false
  205. }
  206. if strings.Contains(item.ProductName, "Windows") {
  207. return false
  208. }
  209. skuLower := strings.ToLower(item.SkuName)
  210. productLower := strings.ToLower(item.ProductName)
  211. if strings.Contains(skuLower, "low priority") {
  212. return false
  213. }
  214. if strings.Contains(productLower, "cloud services") || strings.Contains(productLower, "cloudservices") {
  215. return false
  216. }
  217. return true
  218. }
  219. // includeDiskItem filters disk items to include only managed disks.
  220. func (a *AzurePricingSource) includeDiskItem(item AzurePricingAttributes) bool {
  221. if item.ArmRegionName == "" {
  222. return false
  223. }
  224. productLower := strings.ToLower(item.ProductName)
  225. // Exclude unmanaged disks explicitly (weird case where "Unmanaged disk" still has managed "managed disk" :\)
  226. if strings.Contains(productLower, "unmanaged") {
  227. return false
  228. }
  229. // Only include managed disks
  230. return strings.Contains(productLower, "managed disk")
  231. }
  232. // AzurePricing represents the response from Azure Retail Prices API
  233. type AzurePricing struct {
  234. BillingCurrency string `json:"BillingCurrency"`
  235. CustomerEntityId string `json:"CustomerEntityId"`
  236. CustomerEntityType string `json:"CustomerEntityType"`
  237. Items []AzurePricingAttributes `json:"Items"`
  238. NextPageLink string `json:"NextPageLink"`
  239. Count int `json:"Count"`
  240. }
  241. // AzurePricingAttributes represents a single pricing item from Azure Retail Prices API
  242. type AzurePricingAttributes struct {
  243. CurrencyCode string `json:"currencyCode"`
  244. TierMinimumUnits float32 `json:"tierMinimumUnits"`
  245. RetailPrice float32 `json:"retailPrice"`
  246. UnitPrice float32 `json:"unitPrice"`
  247. ArmRegionName string `json:"armRegionName"`
  248. Location string `json:"location"`
  249. EffectiveStartDate *time.Time `json:"effectiveStartDate"`
  250. EffectiveEndDate *time.Time `json:"effectiveEndDate"`
  251. MeterId string `json:"meterId"`
  252. MeterName string `json:"meterName"`
  253. ProductId string `json:"productId"`
  254. SkuId string `json:"skuId"`
  255. ProductName string `json:"productName"`
  256. SkuName string `json:"skuName"`
  257. ServiceName string `json:"serviceName"`
  258. ServiceId string `json:"serviceId"`
  259. ServiceFamily string `json:"serviceFamily"`
  260. UnitOfMeasure string `json:"unitOfMeasure"`
  261. Type string `json:"type"`
  262. IsPrimaryMeterRegion bool `json:"isPrimaryMeterRegion"`
  263. ArmSkuName string `json:"armSkuName"`
  264. }