gcppricingsource.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. package gcp
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "io"
  6. "math"
  7. "net/http"
  8. "net/url"
  9. "strconv"
  10. "strings"
  11. "time"
  12. "github.com/opencost/opencost/core/pkg/cloud"
  13. "github.com/opencost/opencost/core/pkg/log"
  14. "github.com/opencost/opencost/core/pkg/pricing"
  15. "github.com/opencost/opencost/core/pkg/unit"
  16. "github.com/opencost/opencost/modules/pricing/public/httpclient"
  17. )
  18. var BillingAPIBaseURL = "https://cloudbilling.googleapis.com/v1/services/6F81-5844-456A/skus"
  19. var gcpHTTPClient = httpclient.NewClient(120 * time.Second)
  20. type GCPPricingSourceConfig struct {
  21. APIKey string
  22. CurrencyCode string
  23. }
  24. type GCPPricingSource struct {
  25. config GCPPricingSourceConfig
  26. }
  27. func NewGCPPricingSource(cfg GCPPricingSourceConfig) *GCPPricingSource {
  28. return &GCPPricingSource{
  29. config: cfg,
  30. }
  31. }
  32. func (g *GCPPricingSource) GetPricing() (*pricing.PricingSet, error) {
  33. log.Infof("PricingSource (GCP): starting pricing download")
  34. start := time.Now()
  35. ps := &pricing.PricingSet{
  36. NodePricing: []*pricing.NodePricing{},
  37. PersistentVolumePricing: []*pricing.PersistentVolumePricing{},
  38. }
  39. // Maps to accumulate CPU, RAM, and per-GPU costs.
  40. nodeCPUCosts := make(map[nodeKey]float64)
  41. nodeRAMCosts := make(map[nodeKey]float64)
  42. nodeGPUCosts := make(map[gpuKey]float64)
  43. // Track volume pricing
  44. volumeCosts := make(map[volumeKey]float64)
  45. pageCount := 0
  46. nextPageToken := ""
  47. for {
  48. pageURL := g.buildURL(nextPageToken)
  49. resp, err := gcpHTTPClient.Get(pageURL)
  50. if err != nil {
  51. return nil, fmt.Errorf("PricingSource (GCP): GET %s: %w", pageURL, err)
  52. }
  53. if resp.StatusCode != http.StatusOK {
  54. body, _ := io.ReadAll(resp.Body)
  55. closeErr := resp.Body.Close()
  56. if closeErr != nil {
  57. log.Warnf("failed to close response body: %v", closeErr)
  58. }
  59. return nil, fmt.Errorf("PricingSource (GCP): unexpected status %d on page %d: %s", resp.StatusCode, pageCount, string(body))
  60. }
  61. nextToken, err := g.parsePage(resp.Body, nodeCPUCosts, nodeRAMCosts, nodeGPUCosts, volumeCosts)
  62. closeErr := resp.Body.Close()
  63. if closeErr != nil {
  64. log.Warnf("failed to close response body: %v", closeErr)
  65. }
  66. if err != nil {
  67. return nil, fmt.Errorf("PricingSource (GCP): parsing page %d: %w", pageCount, err)
  68. }
  69. pageCount++
  70. log.Debugf("PricingSource (GCP): fetched page %d, next token: %s", pageCount, nextToken)
  71. if nextToken == "" {
  72. break
  73. }
  74. nextPageToken = nextToken
  75. }
  76. // Build node pricing from accumulated CPU and RAM costs and GPU-qualified copies
  77. g.buildNodePricing(ps, nodeCPUCosts, nodeRAMCosts, nodeGPUCosts)
  78. // Build volume pricing
  79. g.buildVolumePricing(ps, volumeCosts)
  80. log.Infof("PricingSource (GCP): completed in %s — %d pages, %d node pricing, %d volume pricing",
  81. time.Since(start).Round(time.Second), pageCount, len(ps.NodePricing), len(ps.PersistentVolumePricing))
  82. return ps, nil
  83. }
  84. func (g *GCPPricingSource) buildURL(pageToken string) string {
  85. q := url.Values{}
  86. q.Set("key", g.config.APIKey)
  87. q.Set("currencyCode", g.config.CurrencyCode)
  88. if pageToken != "" {
  89. q.Set("pageToken", pageToken)
  90. }
  91. return BillingAPIBaseURL + "?" + q.Encode()
  92. }
  93. func (g *GCPPricingSource) parsePage(body io.Reader, nodeCPUCosts map[nodeKey]float64, nodeRAMCosts map[nodeKey]float64,
  94. nodeGPUCosts map[gpuKey]float64, volumeCosts map[volumeKey]float64,
  95. ) (nextPageToken string, err error) {
  96. data, err := io.ReadAll(body)
  97. if err != nil {
  98. return "", fmt.Errorf("reading response body: %w", err)
  99. }
  100. var page GCPPricingResponse
  101. if err := json.Unmarshal(data, &page); err != nil {
  102. return "", fmt.Errorf("unmarshalling response: %w", err)
  103. }
  104. for _, sku := range page.Skus {
  105. if sku.Category == nil || len(sku.PricingInfo) == 0 {
  106. continue
  107. }
  108. if isCommitmentOrReservedSKU(sku.Description) {
  109. continue
  110. }
  111. category := sku.Category
  112. resourceGroup := category.ResourceGroup
  113. usageType := strings.ToLower(category.UsageType)
  114. if isStorageResource(resourceGroup) {
  115. g.parseVolumeSKU(sku, volumeCosts)
  116. continue
  117. }
  118. if isComputeResource(resourceGroup) {
  119. g.parseComputeSKU(sku, usageType, nodeCPUCosts, nodeRAMCosts)
  120. continue
  121. }
  122. if isGPUResource(resourceGroup) {
  123. g.parseGPUSKU(sku, usageType, nodeGPUCosts)
  124. }
  125. }
  126. return page.NextPageToken, nil
  127. }
  128. func (g *GCPPricingSource) parseVolumeSKU(sku *GCPPricing, volumeCosts map[volumeKey]float64) {
  129. volumeType, isRegional := mapGCPVolumeType(sku.Category.ResourceGroup, sku.Description)
  130. if volumeType == pricing.VolumeTypeNil {
  131. return
  132. }
  133. // Get the hourly price
  134. hourlyPrice, err := g.extractHourlyPrice(sku)
  135. if err != nil || hourlyPrice == 0 {
  136. return
  137. }
  138. // Convert from monthly to hourly (GCP storage is priced per GB-month)
  139. hourlyPrice = hourlyPrice / 730.0
  140. // Store pricing for each region
  141. for _, region := range sku.ServiceRegions {
  142. key := volumeKey{
  143. Region: region,
  144. VolumeType: volumeType,
  145. Regional: isRegional,
  146. }
  147. volumeCosts[key] = hourlyPrice
  148. }
  149. }
  150. func (g *GCPPricingSource) parseComputeSKU(sku *GCPPricing, usageType string, nodeCPUCosts map[nodeKey]float64,
  151. nodeRAMCosts map[nodeKey]float64,
  152. ) {
  153. resourceGroup := sku.Category.ResourceGroup
  154. // Normalize the instance type based on description
  155. instanceType := normalizeInstanceType(resourceGroup, sku.Description)
  156. // Get hourly price
  157. hourlyPrice, err := g.extractHourlyPrice(sku)
  158. if err != nil || hourlyPrice == 0 {
  159. return
  160. }
  161. // Determine if this is CPU or RAM pricing
  162. isRAM := strings.Contains(strings.ToUpper(sku.Description), "RAM")
  163. // Handle E2 instance family expansion
  164. instanceTypes := g.expandInstanceTypes(instanceType, sku.Category.ResourceGroup)
  165. // Store pricing for each region and instance type
  166. for _, region := range sku.ServiceRegions {
  167. for _, instType := range instanceTypes {
  168. key := nodeKey{
  169. Region: region,
  170. InstanceType: instType,
  171. UsageType: usageType,
  172. }
  173. if isRAM {
  174. nodeRAMCosts[key] = hourlyPrice
  175. } else {
  176. nodeCPUCosts[key] = hourlyPrice
  177. }
  178. }
  179. }
  180. }
  181. // parseGPUSKU accumulates an hourly price for one GPU. The product label is
  182. // part of the key because GPU SKUs are not tied to a single machine type.
  183. func (g *GCPPricingSource) parseGPUSKU(sku *GCPPricing, usageType string, nodeGPUCosts map[gpuKey]float64) {
  184. if nodeGPUCosts == nil {
  185. return
  186. }
  187. product := normalizeGPUProduct(sku.Description)
  188. if product == "" {
  189. log.Debugf("PricingSource (GCP): skipping GPU SKU with unrecognized product label: %q", sku.Description)
  190. return
  191. }
  192. hourlyPrice, err := g.extractHourlyPrice(sku)
  193. if err != nil || hourlyPrice == 0 {
  194. return
  195. }
  196. for _, region := range sku.ServiceRegions {
  197. key := gpuKey{
  198. Region: region,
  199. Product: product,
  200. UsageType: usageType,
  201. }
  202. nodeGPUCosts[key] = hourlyPrice
  203. }
  204. }
  205. // expandInstanceTypes handles special cases like E2 and A2 families that map to multiple instance types
  206. func (g *GCPPricingSource) expandInstanceTypes(instanceType, resourceGroup string) []string {
  207. resourceGroupLower := strings.ToLower(resourceGroup)
  208. // E2 family expands to multiple instance types
  209. if instanceType == "e2" && (resourceGroupLower == "cpu" || resourceGroupLower == "ram") {
  210. return []string{"e2-micro", "e2-small", "e2-medium", "e2-standard", "e2-custom"}
  211. }
  212. // A2 family expands to multiple GPU-optimized instance types
  213. if instanceType == "a2" && (resourceGroupLower == "cpu" || resourceGroupLower == "ram") {
  214. return []string{"a2-highgpu", "a2-megagpu", "a2-ultragpu"}
  215. }
  216. return []string{instanceType}
  217. }
  218. // extractHourlyPrice extracts the hourly price from a GCP SKU
  219. func (g *GCPPricingSource) extractHourlyPrice(sku *GCPPricing) (float64, error) {
  220. if sku == nil || len(sku.PricingInfo) == 0 || sku.PricingInfo[0] == nil || sku.PricingInfo[0].PricingExpression == nil {
  221. return 0, fmt.Errorf("no pricing info")
  222. }
  223. pricingInfo := sku.PricingInfo[0]
  224. if pricingInfo.PricingExpression == nil || len(pricingInfo.PricingExpression.TieredRates) == 0 {
  225. return 0, fmt.Errorf("no tiered rates")
  226. }
  227. // Get the last tier (highest usage tier, which is the standard rate)
  228. lastRateIndex := len(pricingInfo.PricingExpression.TieredRates) - 1
  229. unitPrice := pricingInfo.PricingExpression.TieredRates[lastRateIndex].UnitPrice
  230. // Parse the base currency units
  231. unitsBaseCurrency, err := strconv.Atoi(unitPrice.Units)
  232. if err != nil {
  233. return 0, fmt.Errorf("parsing base unit price: %w", err)
  234. }
  235. // Calculate hourly price: whole currency units + fractional nanos
  236. // As per https://cloud.google.com/billing/v1/how-tos/catalog-api
  237. hourlyPrice := float64(unitsBaseCurrency) + (unitPrice.Nanos * math.Pow10(-9))
  238. return hourlyPrice, nil
  239. }
  240. func (g *GCPPricingSource) buildNodePricing(ps *pricing.PricingSet, nodeCPUCosts map[nodeKey]float64,
  241. nodeRAMCosts map[nodeKey]float64, nodeGPUCosts map[gpuKey]float64,
  242. ) {
  243. // Combine CPU and RAM costs into complete node pricing
  244. processedKeys := make(map[nodeKey]bool)
  245. // Process all keys that have either CPU or RAM pricing
  246. allKeys := make(map[nodeKey]bool)
  247. for k := range nodeCPUCosts {
  248. allKeys[k] = true
  249. }
  250. for k := range nodeRAMCosts {
  251. allKeys[k] = true
  252. }
  253. for key := range allKeys {
  254. if processedKeys[key] {
  255. continue
  256. }
  257. processedKeys[key] = true
  258. // GCP's Cloud Billing Catalog API bills both legacy Preemptible VMs
  259. // and modern Spot VMs under the "Preemptible" usageType, so we map
  260. // that usage type to our Spot provisioning type.
  261. provisioning := pricing.ProvisioningOnDemand
  262. if strings.EqualFold(key.UsageType, "preemptible") {
  263. provisioning = pricing.ProvisioningSpot
  264. }
  265. cpuCost := nodeCPUCosts[key]
  266. ramCost := nodeRAMCosts[key]
  267. // Skip if we don't have both CPU and RAM costs
  268. if cpuCost == 0 || ramCost == 0 {
  269. continue
  270. }
  271. nodePricing := &pricing.NodePricing{
  272. Properties: pricing.NodePricingProperties{
  273. Provider: cloud.ProviderGCP,
  274. Region: key.Region,
  275. InstanceType: key.InstanceType,
  276. Provisioning: provisioning,
  277. },
  278. Prices: pricing.Prices{
  279. pricing.ResourceCPU: pricing.Price{
  280. Unit: unit.VCPUHour,
  281. Price: cpuCost,
  282. },
  283. pricing.ResourceRAM: pricing.Price{
  284. Unit: unit.GiBHour,
  285. Price: ramCost,
  286. },
  287. },
  288. }
  289. ps.NodePricing = append(ps.NodePricing, nodePricing)
  290. // A GPU-qualified record must repeat CPU/RAM pricing
  291. for gpuKey, gpuCost := range nodeGPUCosts {
  292. if gpuKey.Region != key.Region || gpuKey.UsageType != key.UsageType {
  293. continue
  294. }
  295. gpuNodePricing := &pricing.NodePricing{
  296. Properties: nodePricing.Properties,
  297. Prices: pricing.Prices{
  298. pricing.ResourceCPU: nodePricing.Prices[pricing.ResourceCPU],
  299. pricing.ResourceRAM: nodePricing.Prices[pricing.ResourceRAM],
  300. pricing.ResourceGPU: {
  301. Unit: unit.GPUHour,
  302. Price: gpuCost,
  303. },
  304. },
  305. }
  306. gpuNodePricing.Properties.Labels = map[string]string{
  307. gpuProductLabel: gpuKey.Product,
  308. }
  309. ps.NodePricing = append(ps.NodePricing, gpuNodePricing)
  310. }
  311. }
  312. }
  313. func (g *GCPPricingSource) buildVolumePricing(
  314. ps *pricing.PricingSet,
  315. volumeCosts map[volumeKey]float64,
  316. ) {
  317. for key, cost := range volumeCosts {
  318. volumePricing := &pricing.PersistentVolumePricing{
  319. Properties: pricing.PersistentVolumePricingProperties{
  320. Provider: cloud.ProviderGCP,
  321. Region: key.Region,
  322. VolumeType: key.VolumeType,
  323. },
  324. Prices: pricing.Prices{
  325. pricing.ResourceStorage: pricing.Price{
  326. Unit: unit.GiBHour,
  327. Price: cost,
  328. },
  329. },
  330. }
  331. ps.PersistentVolumePricing = append(ps.PersistentVolumePricing, volumePricing)
  332. }
  333. }