| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148 |
- package azure
- import (
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
- "time"
- "github.com/opencost/opencost/core/pkg/log"
- "github.com/opencost/opencost/core/pkg/model/pricingmodel"
- "github.com/opencost/opencost/core/pkg/model/shared"
- )
- const (
- azureRetailPricingBaseURL = "https://prices.azure.com/api/retail/prices"
- azureRetailVMFilter = "serviceName eq 'Virtual Machines' and priceType eq 'Consumption'"
- )
- const AzureRetailPricingSourceType pricingmodel.PricingSourceType = "azure_retail_pricing_api"
- // AzureRetailPricingSourceConfig holds configuration for AzureRetailPricingSource.
- type AzureRetailPricingSourceConfig struct {
- CurrencyCode string
- }
- var azureRetailHTTPClient = &http.Client{Timeout: 60 * time.Second}
- // AzureRetailPricingSource implements pricingmodel.PricingSource using the
- // Azure Retail Prices API (no authentication required).
- type AzureRetailPricingSource struct {
- config AzureRetailPricingSourceConfig
- }
- func NewAzureRetailPricingSource(cfg AzureRetailPricingSourceConfig) *AzureRetailPricingSource {
- return &AzureRetailPricingSource{config: cfg}
- }
- func (a *AzureRetailPricingSource) PricingSourceType() pricingmodel.PricingSourceType {
- return AzureRetailPricingSourceType
- }
- // PricingSourceKey returns the PricingSourceType because it is meant to run single instance.
- func (a *AzureRetailPricingSource) PricingSourceKey() string {
- return string(AzureRetailPricingSourceType)
- }
- func (a *AzureRetailPricingSource) GetPricing() (*pricingmodel.PricingModelSet, error) {
- now := time.Now().UTC()
- pms := pricingmodel.NewPricingModelSet(now, a.PricingSourceType(), a.PricingSourceKey())
- url := a.buildInitialURL()
- pageCount := 0
- for url != "" {
- resp, err := azureRetailHTTPClient.Get(url)
- if err != nil {
- return nil, fmt.Errorf("AzureRetailPricingSource: GET %s: %w", url, err)
- }
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- resp.Body.Close()
- return nil, fmt.Errorf("AzureRetailPricingSource: unexpected status %d on page %d: %s", resp.StatusCode, pageCount, string(body))
- }
- next, err := a.parsePage(resp.Body, pms)
- resp.Body.Close()
- if err != nil {
- return nil, fmt.Errorf("AzureRetailPricingSource: parsing page %d: %w", pageCount, err)
- }
- pageCount++
- url = next
- log.Debugf("AzureRetailPricingSource: fetched page %d, next: %s", pageCount, url)
- }
- log.Infof("AzureRetailPricingSource: loaded %d pricing entries across %d pages", len(pms.NodePricing), pageCount)
- return pms, nil
- }
- func (a *AzureRetailPricingSource) buildInitialURL() string {
- u := azureRetailPricingBaseURL + "?$filter=" + url.QueryEscape(azureRetailVMFilter)
- if a.config.CurrencyCode != "" {
- u += "¤cyCode=" + url.QueryEscape(a.config.CurrencyCode)
- }
- return u
- }
- func (a *AzureRetailPricingSource) parsePage(body io.Reader, pms *pricingmodel.PricingModelSet) (nextURL string, err error) {
- data, err := io.ReadAll(body)
- if err != nil {
- return "", fmt.Errorf("reading response body: %w", err)
- }
- var page AzureRetailPricing
- if err := json.Unmarshal(data, &page); err != nil {
- return "", fmt.Errorf("unmarshalling response: %w", err)
- }
- for _, item := range page.Items {
- if !a.includeItem(item) {
- continue
- }
- key := pricingmodel.NodeKey{
- Provider: shared.ProviderAzure,
- Region: item.ArmRegionName,
- NodeType: item.ArmSkuName,
- UsageType: usageTypeFromSku(item.SkuName),
- PricingType: pricingmodel.NodePricingTypeTotal,
- }
- pms.NodePricing[key] = pricingmodel.NodePricing{
- HourlyRate: float64(item.RetailPrice),
- }
- }
- return page.NextPageLink, nil
- }
- // includeItem mirrors the filtering logic in the existing Azure provider.
- func (a *AzureRetailPricingSource) includeItem(item AzureRetailPricingAttributes) bool {
- if item.ArmSkuName == "" || item.ArmRegionName == "" {
- return false
- }
- if strings.Contains(item.ProductName, "Windows") {
- return false
- }
- skuLower := strings.ToLower(item.SkuName)
- productLower := strings.ToLower(item.ProductName)
- if strings.Contains(skuLower, "low priority") {
- return false
- }
- if strings.Contains(productLower, "cloud services") || strings.Contains(productLower, "cloudservices") {
- return false
- }
- return true
- }
- func usageTypeFromSku(skuName string) shared.UsageType {
- if strings.Contains(strings.ToLower(skuName), " spot") {
- return shared.UsageTypeSpot
- }
- return shared.UsageTypeOnDemand
- }
|