| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309 |
- 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/shared"
- "github.com/opencost/opencost/core/pkg/pricing"
- "github.com/opencost/opencost/core/pkg/unit"
- )
- const (
- azurePricingBaseURL = "https://prices.azure.com/api/retail/prices"
- azureVMFilter = "serviceName eq 'Virtual Machines' and priceType eq 'Consumption'"
- azureDiskFilter = "serviceName eq 'Storage' and priceType eq 'Consumption'"
- )
- // AzurePricingSourceConfig holds configuration for AzurePricingSource.
- type AzurePricingSourceConfig struct {
- CurrencyCode string
- }
- var azureHTTPClient = &http.Client{Timeout: 60 * time.Second}
- // AzurePricingSource implements the PricingSource interface using the
- // Azure Retail Prices API (no auth required).
- type AzurePricingSource struct {
- config AzurePricingSourceConfig
- }
- func NewAzurePricingSource(cfg AzurePricingSourceConfig) *AzurePricingSource {
- return &AzurePricingSource{config: cfg}
- }
- func (a *AzurePricingSource) GetPricing() (*pricing.PricingSet, error) {
- log.Infof("PricingSource (Azure): starting pricing download")
- start := time.Now()
- ps := &pricing.PricingSet{
- Nodes: []*pricing.NodePricing{},
- Volumes: []*pricing.VolumePricing{},
- }
- // Fetch VM pricing
- url := a.buildVMURL()
- pageCount := 0
- for url != "" {
- resp, err := azureHTTPClient.Get(url)
- if err != nil {
- return nil, fmt.Errorf("PricingSource (Azure): GET %s: %w", url, err)
- }
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- closeErr := resp.Body.Close()
- if closeErr != nil {
- log.Warnf("failed to close response body: %v", closeErr)
- }
- return nil, fmt.Errorf("PricingSource (Azure): unexpected status %d on VM page %d: %s", resp.StatusCode, pageCount, string(body))
- }
- next, err := a.parseVMPage(resp.Body, ps)
- closeErr := resp.Body.Close()
- if closeErr != nil {
- log.Warnf("failed to close response body: %v", closeErr)
- }
- if err != nil {
- return nil, fmt.Errorf("PricingSource (Azure): parsing VM page %d: %w", pageCount, err)
- }
- pageCount++
- url = next
- log.Debugf("PricingSource (Azure): fetched VM page %d, next: %s", pageCount, url)
- }
- log.Infof("PricingSource (Azure): fetched %d VM pricing entries across %d pages", len(ps.Nodes), pageCount)
- // Fetch disk pricing
- url = a.buildDiskURL()
- diskPageCount := 0
- for url != "" {
- resp, err := azureHTTPClient.Get(url)
- if err != nil {
- log.Warnf("PricingSource (Azure): failed to fetch disk pricing: %v", err)
- break
- }
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- closeErr := resp.Body.Close()
- if closeErr != nil {
- log.Warnf("failed to close response body: %v", closeErr)
- }
- log.Warnf("PricingSource (Azure): unexpected status %d on disk page %d: %s", resp.StatusCode, diskPageCount, string(body))
- break
- }
- next, err := a.parseDiskPage(resp.Body, ps)
- closeErr := resp.Body.Close()
- if closeErr != nil {
- log.Warnf("failed to close response body: %v", closeErr)
- }
- if err != nil {
- log.Warnf("PricingSource (Azure): error parsing disk page %d: %v", diskPageCount, err)
- break
- }
- diskPageCount++
- url = next
- log.Debugf("PricingSource (Azure): fetched disk page %d, next: %s", diskPageCount, url)
- }
- log.Infof("PricingSource (Azure): completed in %s — %d node pricing, %d volume pricing",
- time.Since(start).Round(time.Second), len(ps.Nodes), len(ps.Volumes))
- return ps, nil
- }
- func (a *AzurePricingSource) buildVMURL() string {
- u := azurePricingBaseURL + "?$filter=" + url.QueryEscape(azureVMFilter)
- if a.config.CurrencyCode != "" {
- u += "¤cyCode=" + url.QueryEscape(a.config.CurrencyCode)
- }
- return u
- }
- func (a *AzurePricingSource) buildDiskURL() string {
- u := azurePricingBaseURL + "?$filter=" + url.QueryEscape(azureDiskFilter)
- if a.config.CurrencyCode != "" {
- u += "¤cyCode=" + url.QueryEscape(a.config.CurrencyCode)
- }
- return u
- }
- func (a *AzurePricingSource) parseVMPage(body io.Reader, ps *pricing.PricingSet) (nextURL string, err error) {
- data, err := io.ReadAll(body)
- if err != nil {
- return "", fmt.Errorf("reading response body: %w", err)
- }
- var page AzurePricing
- 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
- }
- // Parse the currency from config, default to USD if invalid
- currency, err := unit.ParseCurrency(a.config.CurrencyCode)
- if err != nil {
- log.Warnf("invalid currency code '%s', defaulting to USD: %s", a.config.CurrencyCode, err.Error())
- currency = unit.USD
- }
- priceObj := pricing.Price{
- Currency: currency,
- Unit: unit.Hour,
- Price: float64(item.RetailPrice),
- }
- nodePricing := &pricing.NodePricing{
- Properties: pricing.NodePricingProperties{
- Provider: pricing.Provider(shared.ProviderAzure),
- Region: item.ArmRegionName,
- InstanceType: item.ArmSkuName,
- Provisioning: pricing.ProvisioningOnDemand,
- },
- Prices: pricing.Prices{
- currency: []pricing.Price{
- priceObj,
- },
- },
- }
- ps.Nodes = append(ps.Nodes, nodePricing)
- }
- return page.NextPageLink, nil
- }
- func (a *AzurePricingSource) parseDiskPage(body io.Reader, ps *pricing.PricingSet) (nextURL string, err error) {
- data, err := io.ReadAll(body)
- if err != nil {
- return "", fmt.Errorf("reading response body: %w", err)
- }
- var page AzurePricing
- if err := json.Unmarshal(data, &page); err != nil {
- return "", fmt.Errorf("unmarshalling response: %w", err)
- }
- for _, item := range page.Items {
- if !a.includeDiskItem(item) {
- continue
- }
- volumeType := mapAzureDiskType(item.SkuName)
- if volumeType == pricing.VolumeTypeNil {
- continue
- }
- currency, err := unit.ParseCurrency(a.config.CurrencyCode)
- if err != nil {
- log.Warnf("invalid currency code '%s', defaulting to USD: %s", a.config.CurrencyCode, err.Error())
- currency = unit.USD
- }
- // Azure disk pricing is per GB-month, convert to per GB-hour
- hourlyPrice := float64(item.RetailPrice) / 730.0
- volumePricing := &pricing.VolumePricing{
- Properties: pricing.VolumePricingProperties{
- Provider: pricing.AzureProvider,
- Region: item.ArmRegionName,
- VolumeType: volumeType,
- },
- Prices: pricing.Prices{
- currency: []pricing.Price{{
- Currency: currency,
- Unit: unit.Hour,
- Price: hourlyPrice,
- }},
- },
- }
- ps.Volumes = append(ps.Volumes, volumePricing)
- }
- return page.NextPageLink, nil
- }
- // includeItem mirrors the filtering logic in the existing Azure provider for VMs.
- func (a *AzurePricingSource) includeItem(item AzurePricingAttributes) 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
- }
- // includeDiskItem filters disk items to include only managed disks.
- func (a *AzurePricingSource) includeDiskItem(item AzurePricingAttributes) bool {
- if item.ArmRegionName == "" {
- return false
- }
- productLower := strings.ToLower(item.ProductName)
- // Exclude unmanaged disks explicitly (weird case where "Unmanaged disk" still has managed "managed disk" :\)
- if strings.Contains(productLower, "unmanaged") {
- return false
- }
- // Only include managed disks
- return strings.Contains(productLower, "managed disk")
- }
- // AzurePricing represents the response from Azure Retail Prices API
- type AzurePricing struct {
- BillingCurrency string `json:"BillingCurrency"`
- CustomerEntityId string `json:"CustomerEntityId"`
- CustomerEntityType string `json:"CustomerEntityType"`
- Items []AzurePricingAttributes `json:"Items"`
- NextPageLink string `json:"NextPageLink"`
- Count int `json:"Count"`
- }
- // AzurePricingAttributes represents a single pricing item from Azure Retail Prices API
- type AzurePricingAttributes struct {
- CurrencyCode string `json:"currencyCode"`
- TierMinimumUnits float32 `json:"tierMinimumUnits"`
- RetailPrice float32 `json:"retailPrice"`
- UnitPrice float32 `json:"unitPrice"`
- ArmRegionName string `json:"armRegionName"`
- Location string `json:"location"`
- EffectiveStartDate *time.Time `json:"effectiveStartDate"`
- EffectiveEndDate *time.Time `json:"effectiveEndDate"`
- MeterId string `json:"meterId"`
- MeterName string `json:"meterName"`
- ProductId string `json:"productId"`
- SkuId string `json:"skuId"`
- ProductName string `json:"productName"`
- SkuName string `json:"skuName"`
- ServiceName string `json:"serviceName"`
- ServiceId string `json:"serviceId"`
- ServiceFamily string `json:"serviceFamily"`
- UnitOfMeasure string `json:"unitOfMeasure"`
- Type string `json:"type"`
- IsPrimaryMeterRegion bool `json:"isPrimaryMeterRegion"`
- ArmSkuName string `json:"armSkuName"`
- }
|