billingpricingsource.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. package gcp
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "io"
  6. "math"
  7. "net/http"
  8. "strconv"
  9. "strings"
  10. "time"
  11. "github.com/opencost/opencost/core/pkg/log"
  12. "github.com/opencost/opencost/core/pkg/model/pricingmodel"
  13. "github.com/opencost/opencost/core/pkg/model/shared"
  14. )
  15. const (
  16. gcpBillingComputeServiceID = "6F81-5844-456A"
  17. gcpBillingBaseURL = "https://cloudbilling.googleapis.com/v1/services/" + gcpBillingComputeServiceID + "/skus"
  18. )
  19. const GCPBillingPricingSourceType pricingmodel.PricingSourceType = "gcp_billing_catalog_api"
  20. const (
  21. gcpResourceFamilyCompute = "Compute"
  22. gcpResourceGroupGPU = "GPU"
  23. gcpUsageTypeOnDemand = "OnDemand"
  24. gcpUsageTypePreemptible = "Preemptible"
  25. )
  26. // GCPBillingPricingSourceConfig holds configuration for GCPBillingPricingSource.
  27. type GCPBillingPricingSourceConfig struct {
  28. APIKey string
  29. CurrencyCode string
  30. }
  31. var gcpBillingHTTPClient = &http.Client{Timeout: 60 * time.Second}
  32. // GCPBillingPricingSource implements pricingmodel.PricingSource using the
  33. // GCP Cloud Billing Catalog API. It emits per-vCPU, per-GB RAM, and per-GPU
  34. // hourly rates keyed by family and region, which consumers combine with
  35. // machine specs to compute total instance costs.
  36. type GCPBillingPricingSource struct {
  37. apiKey string
  38. currencyCode string
  39. }
  40. func NewGCPBillingPricingSource(cfg GCPBillingPricingSourceConfig) (*GCPBillingPricingSource, error) {
  41. if cfg.APIKey == "" {
  42. return nil, fmt.Errorf("cannot initialize GCPBillingPriceSource with empty API Key")
  43. }
  44. return &GCPBillingPricingSource{
  45. apiKey: cfg.APIKey,
  46. currencyCode: cfg.CurrencyCode,
  47. }, nil
  48. }
  49. func (g *GCPBillingPricingSource) PricingSourceType() pricingmodel.PricingSourceType {
  50. return GCPBillingPricingSourceType
  51. }
  52. // PricingSourceKey returns the PricingSourceType because it is meant to run single instance.
  53. func (g *GCPBillingPricingSource) PricingSourceKey() string {
  54. return string(GCPBillingPricingSourceType)
  55. }
  56. func (g *GCPBillingPricingSource) GetPricing() (*pricingmodel.PricingModelSet, error) {
  57. if g.apiKey == "" {
  58. return nil, fmt.Errorf("GCPBillingPricingSource: api key is nil")
  59. }
  60. now := time.Now().UTC()
  61. pms := pricingmodel.NewPricingModelSet(now, g.PricingSourceType(), g.PricingSourceKey())
  62. url := g.buildURL("")
  63. pageCount := 0
  64. for url != "" {
  65. resp, err := gcpBillingHTTPClient.Get(url)
  66. if err != nil {
  67. return nil, fmt.Errorf("GCPBillingPricingSource: GET: %w", err)
  68. }
  69. if resp.StatusCode != http.StatusOK {
  70. body, _ := io.ReadAll(resp.Body)
  71. resp.Body.Close()
  72. return nil, fmt.Errorf("GCPBillingPricingSource: unexpected status %d on page %d: %s", resp.StatusCode, pageCount, string(body))
  73. }
  74. nextToken, err := g.parsePage(resp.Body, pms)
  75. resp.Body.Close()
  76. if err != nil {
  77. return nil, fmt.Errorf("GCPBillingPricingSource: parsing page %d: %w", pageCount, err)
  78. }
  79. pageCount++
  80. log.Debugf("GCPBillingPricingSource: fetched page %d", pageCount)
  81. if nextToken == "" {
  82. break
  83. }
  84. url = g.buildURL(nextToken)
  85. }
  86. log.Infof("GCPBillingPricingSource: loaded %d pricing entries across %d pages", len(pms.NodePricing), pageCount)
  87. return pms, nil
  88. }
  89. func (g *GCPBillingPricingSource) buildURL(pageToken string) string {
  90. url := fmt.Sprintf("%s?key=%s", gcpBillingBaseURL, g.apiKey)
  91. if g.currencyCode != "" {
  92. url += "&currencyCode=" + g.currencyCode
  93. }
  94. if pageToken != "" {
  95. url += "&pageToken=" + pageToken
  96. }
  97. return url
  98. }
  99. type gcpBillingPage struct {
  100. SKUs []*GCPPricing `json:"skus"`
  101. NextPageToken string `json:"nextPageToken"`
  102. }
  103. func (g *GCPBillingPricingSource) parsePage(body io.Reader, pms *pricingmodel.PricingModelSet) (string, error) {
  104. data, err := io.ReadAll(body)
  105. if err != nil {
  106. return "", fmt.Errorf("reading response body: %w", err)
  107. }
  108. var page gcpBillingPage
  109. if err := json.Unmarshal(data, &page); err != nil {
  110. return "", fmt.Errorf("unmarshalling response: %w", err)
  111. }
  112. for _, sku := range page.SKUs {
  113. g.processSKU(sku, pms)
  114. }
  115. return page.NextPageToken, nil
  116. }
  117. func (g *GCPBillingPricingSource) processSKU(sku *GCPPricing, pms *pricingmodel.PricingModelSet) {
  118. if sku.Category == nil || sku.Category.ResourceFamily != gcpResourceFamilyCompute {
  119. return
  120. }
  121. usageType := gcpUsageType(sku.Category.UsageType)
  122. if usageType == shared.UsageTypeEmpty {
  123. return // skip commitments and unrecognized usage types
  124. }
  125. hourlyRate, ok := gcpExtractHourlyRate(sku)
  126. if !ok || hourlyRate == 0 {
  127. return
  128. }
  129. if strings.EqualFold(sku.Category.ResourceGroup, gcpResourceGroupGPU) {
  130. g.processGPUSKU(sku, usageType, hourlyRate, pms)
  131. return
  132. }
  133. g.processComputeSKU(sku, usageType, hourlyRate, pms)
  134. }
  135. func (g *GCPBillingPricingSource) processGPUSKU(sku *GCPPricing, usageType shared.UsageType, hourlyRate float64, pms *pricingmodel.PricingModelSet) {
  136. accelerator := NormalizeGPULabel(sku.Description)
  137. if accelerator == "" {
  138. return
  139. }
  140. for _, region := range sku.ServiceRegions {
  141. key := pricingmodel.NodeKey{
  142. Provider: shared.ProviderGCP,
  143. Region: region,
  144. UsageType: usageType,
  145. DeviceType: accelerator,
  146. PricingType: pricingmodel.NodePricingTypeDevice,
  147. }
  148. pms.NodePricing[key] = pricingmodel.NodePricing{HourlyRate: hourlyRate}
  149. }
  150. }
  151. func (g *GCPBillingPricingSource) processComputeSKU(sku *GCPPricing, usageType shared.UsageType, hourlyRate float64, pms *pricingmodel.PricingModelSet) {
  152. // Skip legacy ambiguous resource groups — family cannot be determined without
  153. // parsing the description, which is unreliable across SKU generations.
  154. rg := strings.ToLower(sku.Category.ResourceGroup)
  155. if rg == "ram" || rg == "cpu" {
  156. return
  157. }
  158. family := gcpNormalizeFamily(sku.Category.ResourceGroup)
  159. if family == "" {
  160. return
  161. }
  162. pricingType := pricingmodel.NodePricingTypeCPUCore
  163. if strings.Contains(strings.ToUpper(sku.Description), "RAM") {
  164. pricingType = pricingmodel.NodePricingTypeRamGB
  165. }
  166. for _, region := range sku.ServiceRegions {
  167. key := pricingmodel.NodeKey{
  168. Provider: shared.ProviderGCP,
  169. Region: region,
  170. Family: family,
  171. UsageType: usageType,
  172. PricingType: pricingType,
  173. }
  174. pms.NodePricing[key] = pricingmodel.NodePricing{HourlyRate: hourlyRate}
  175. }
  176. }
  177. // gcpNormalizeFamily maps a GCP Billing API ResourceGroup (e.g. "N1Standard",
  178. // "N2DStandard", "E2", "A2") to a lowercase family identifier (e.g. "n1",
  179. // "n2d", "e2", "a2") by lowercasing and stripping the "standard" suffix.
  180. func gcpNormalizeFamily(resourceGroup string) string {
  181. return strings.TrimSuffix(strings.ToLower(resourceGroup), "standard")
  182. }
  183. // gcpUsageType maps GCP billing usage type strings to shared.UsageType.
  184. // Returns UsageTypeEmpty for commitment SKUs, which should be skipped.
  185. func gcpUsageType(gcpType string) shared.UsageType {
  186. switch gcpType {
  187. case gcpUsageTypeOnDemand:
  188. return shared.UsageTypeOnDemand
  189. case gcpUsageTypePreemptible:
  190. return shared.UsageTypeSpot
  191. default:
  192. return shared.UsageTypeEmpty
  193. }
  194. }
  195. // gcpExtractHourlyRate extracts the hourly rate from a SKU's pricing info.
  196. // Per the GCP Billing Catalog API docs, the price is units + nanos*10^-9.
  197. func gcpExtractHourlyRate(sku *GCPPricing) (float64, bool) {
  198. if len(sku.PricingInfo) == 0 {
  199. return 0, false
  200. }
  201. rates := sku.PricingInfo[0].PricingExpression.TieredRates
  202. if len(rates) == 0 {
  203. return 0, false
  204. }
  205. last := rates[len(rates)-1]
  206. units, err := strconv.Atoi(last.UnitPrice.Units)
  207. if err != nil {
  208. units = 0
  209. }
  210. return (last.UnitPrice.Nanos * math.Pow10(-9)) + float64(units), true
  211. }