| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249 |
- package gcp
- import (
- "encoding/json"
- "fmt"
- "io"
- "math"
- "net/http"
- "strconv"
- "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 (
- gcpBillingComputeServiceID = "6F81-5844-456A"
- gcpBillingBaseURL = "https://cloudbilling.googleapis.com/v1/services/" + gcpBillingComputeServiceID + "/skus"
- )
- const GCPBillingPricingSourceType pricingmodel.PricingSourceType = "gcp_billing_catalog_api"
- const (
- gcpResourceFamilyCompute = "Compute"
- gcpResourceGroupGPU = "GPU"
- gcpUsageTypeOnDemand = "OnDemand"
- gcpUsageTypePreemptible = "Preemptible"
- )
- // GCPBillingPricingSourceConfig holds configuration for GCPBillingPricingSource.
- type GCPBillingPricingSourceConfig struct {
- APIKey string
- CurrencyCode string
- }
- var gcpBillingHTTPClient = &http.Client{Timeout: 60 * time.Second}
- // GCPBillingPricingSource implements pricingmodel.PricingSource using the
- // GCP Cloud Billing Catalog API. It emits per-vCPU, per-GB RAM, and per-GPU
- // hourly rates keyed by family and region, which consumers combine with
- // machine specs to compute total instance costs.
- type GCPBillingPricingSource struct {
- apiKey string
- currencyCode string
- }
- func NewGCPBillingPricingSource(cfg GCPBillingPricingSourceConfig) (*GCPBillingPricingSource, error) {
- if cfg.APIKey == "" {
- return nil, fmt.Errorf("cannot initialize GCPBillingPriceSource with empty API Key")
- }
- return &GCPBillingPricingSource{
- apiKey: cfg.APIKey,
- currencyCode: cfg.CurrencyCode,
- }, nil
- }
- func (g *GCPBillingPricingSource) PricingSourceType() pricingmodel.PricingSourceType {
- return GCPBillingPricingSourceType
- }
- // PricingSourceKey returns the PricingSourceType because it is meant to run single instance.
- func (g *GCPBillingPricingSource) PricingSourceKey() string {
- return string(GCPBillingPricingSourceType)
- }
- func (g *GCPBillingPricingSource) GetPricing() (*pricingmodel.PricingModelSet, error) {
- if g.apiKey == "" {
- return nil, fmt.Errorf("GCPBillingPricingSource: api key is nil")
- }
- now := time.Now().UTC()
- pms := pricingmodel.NewPricingModelSet(now, g.PricingSourceType(), g.PricingSourceKey())
- url := g.buildURL("")
- pageCount := 0
- for url != "" {
- resp, err := gcpBillingHTTPClient.Get(url)
- if err != nil {
- return nil, fmt.Errorf("GCPBillingPricingSource: GET: %w", err)
- }
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- resp.Body.Close()
- return nil, fmt.Errorf("GCPBillingPricingSource: unexpected status %d on page %d: %s", resp.StatusCode, pageCount, string(body))
- }
- nextToken, err := g.parsePage(resp.Body, pms)
- resp.Body.Close()
- if err != nil {
- return nil, fmt.Errorf("GCPBillingPricingSource: parsing page %d: %w", pageCount, err)
- }
- pageCount++
- log.Debugf("GCPBillingPricingSource: fetched page %d", pageCount)
- if nextToken == "" {
- break
- }
- url = g.buildURL(nextToken)
- }
- log.Infof("GCPBillingPricingSource: loaded %d pricing entries across %d pages", len(pms.NodePricing), pageCount)
- return pms, nil
- }
- func (g *GCPBillingPricingSource) buildURL(pageToken string) string {
- url := fmt.Sprintf("%s?key=%s", gcpBillingBaseURL, g.apiKey)
- if g.currencyCode != "" {
- url += "¤cyCode=" + g.currencyCode
- }
- if pageToken != "" {
- url += "&pageToken=" + pageToken
- }
- return url
- }
- type gcpBillingPage struct {
- SKUs []*GCPPricing `json:"skus"`
- NextPageToken string `json:"nextPageToken"`
- }
- func (g *GCPBillingPricingSource) parsePage(body io.Reader, pms *pricingmodel.PricingModelSet) (string, error) {
- data, err := io.ReadAll(body)
- if err != nil {
- return "", fmt.Errorf("reading response body: %w", err)
- }
- var page gcpBillingPage
- if err := json.Unmarshal(data, &page); err != nil {
- return "", fmt.Errorf("unmarshalling response: %w", err)
- }
- for _, sku := range page.SKUs {
- g.processSKU(sku, pms)
- }
- return page.NextPageToken, nil
- }
- func (g *GCPBillingPricingSource) processSKU(sku *GCPPricing, pms *pricingmodel.PricingModelSet) {
- if sku.Category == nil || sku.Category.ResourceFamily != gcpResourceFamilyCompute {
- return
- }
- usageType := gcpUsageType(sku.Category.UsageType)
- if usageType == shared.UsageTypeEmpty {
- return // skip commitments and unrecognized usage types
- }
- hourlyRate, ok := gcpExtractHourlyRate(sku)
- if !ok || hourlyRate == 0 {
- return
- }
- if strings.EqualFold(sku.Category.ResourceGroup, gcpResourceGroupGPU) {
- g.processGPUSKU(sku, usageType, hourlyRate, pms)
- return
- }
- g.processComputeSKU(sku, usageType, hourlyRate, pms)
- }
- func (g *GCPBillingPricingSource) processGPUSKU(sku *GCPPricing, usageType shared.UsageType, hourlyRate float64, pms *pricingmodel.PricingModelSet) {
- accelerator := NormalizeGPULabel(sku.Description)
- if accelerator == "" {
- return
- }
- for _, region := range sku.ServiceRegions {
- key := pricingmodel.NodeKey{
- Provider: shared.ProviderGCP,
- Region: region,
- UsageType: usageType,
- DeviceType: accelerator,
- PricingType: pricingmodel.NodePricingTypeDevice,
- }
- pms.NodePricing[key] = pricingmodel.NodePricing{HourlyRate: hourlyRate}
- }
- }
- func (g *GCPBillingPricingSource) processComputeSKU(sku *GCPPricing, usageType shared.UsageType, hourlyRate float64, pms *pricingmodel.PricingModelSet) {
- // Skip legacy ambiguous resource groups — family cannot be determined without
- // parsing the description, which is unreliable across SKU generations.
- rg := strings.ToLower(sku.Category.ResourceGroup)
- if rg == "ram" || rg == "cpu" {
- return
- }
- family := gcpNormalizeFamily(sku.Category.ResourceGroup)
- if family == "" {
- return
- }
- pricingType := pricingmodel.NodePricingTypeCPUCore
- if strings.Contains(strings.ToUpper(sku.Description), "RAM") {
- pricingType = pricingmodel.NodePricingTypeRamGB
- }
- for _, region := range sku.ServiceRegions {
- key := pricingmodel.NodeKey{
- Provider: shared.ProviderGCP,
- Region: region,
- Family: family,
- UsageType: usageType,
- PricingType: pricingType,
- }
- pms.NodePricing[key] = pricingmodel.NodePricing{HourlyRate: hourlyRate}
- }
- }
- // gcpNormalizeFamily maps a GCP Billing API ResourceGroup (e.g. "N1Standard",
- // "N2DStandard", "E2", "A2") to a lowercase family identifier (e.g. "n1",
- // "n2d", "e2", "a2") by lowercasing and stripping the "standard" suffix.
- func gcpNormalizeFamily(resourceGroup string) string {
- return strings.TrimSuffix(strings.ToLower(resourceGroup), "standard")
- }
- // gcpUsageType maps GCP billing usage type strings to shared.UsageType.
- // Returns UsageTypeEmpty for commitment SKUs, which should be skipped.
- func gcpUsageType(gcpType string) shared.UsageType {
- switch gcpType {
- case gcpUsageTypeOnDemand:
- return shared.UsageTypeOnDemand
- case gcpUsageTypePreemptible:
- return shared.UsageTypeSpot
- default:
- return shared.UsageTypeEmpty
- }
- }
- // gcpExtractHourlyRate extracts the hourly rate from a SKU's pricing info.
- // Per the GCP Billing Catalog API docs, the price is units + nanos*10^-9.
- func gcpExtractHourlyRate(sku *GCPPricing) (float64, bool) {
- if len(sku.PricingInfo) == 0 {
- return 0, false
- }
- rates := sku.PricingInfo[0].PricingExpression.TieredRates
- if len(rates) == 0 {
- return 0, false
- }
- last := rates[len(rates)-1]
- units, err := strconv.Atoi(last.UnitPrice.Units)
- if err != nil {
- units = 0
- }
- return (last.UnitPrice.Nanos * math.Pow10(-9)) + float64(units), true
- }
|