gcppricingsource.go 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  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 and RAM costs per node key
  40. nodeCPUCosts := make(map[nodeKey]float64)
  41. nodeRAMCosts := make(map[nodeKey]float64)
  42. // Track volume pricing
  43. volumeCosts := make(map[volumeKey]float64)
  44. pageCount := 0
  45. nextPageToken := ""
  46. for {
  47. pageURL := g.buildURL(nextPageToken)
  48. resp, err := gcpHTTPClient.Get(pageURL)
  49. if err != nil {
  50. return nil, fmt.Errorf("PricingSource (GCP): GET %s: %w", pageURL, err)
  51. }
  52. if resp.StatusCode != http.StatusOK {
  53. body, _ := io.ReadAll(resp.Body)
  54. closeErr := resp.Body.Close()
  55. if closeErr != nil {
  56. log.Warnf("failed to close response body: %v", closeErr)
  57. }
  58. return nil, fmt.Errorf("PricingSource (GCP): unexpected status %d on page %d: %s", resp.StatusCode, pageCount, string(body))
  59. }
  60. nextToken, err := g.parsePage(resp.Body, nodeCPUCosts, nodeRAMCosts, volumeCosts)
  61. closeErr := resp.Body.Close()
  62. if closeErr != nil {
  63. log.Warnf("failed to close response body: %v", closeErr)
  64. }
  65. if err != nil {
  66. return nil, fmt.Errorf("PricingSource (GCP): parsing page %d: %w", pageCount, err)
  67. }
  68. pageCount++
  69. log.Debugf("PricingSource (GCP): fetched page %d, next token: %s", pageCount, nextToken)
  70. if nextToken == "" {
  71. break
  72. }
  73. nextPageToken = nextToken
  74. }
  75. // Build node pricing from accumulated CPU and RAM costs
  76. g.buildNodePricing(ps, nodeCPUCosts, nodeRAMCosts)
  77. // Build volume pricing
  78. g.buildVolumePricing(ps, volumeCosts)
  79. log.Infof("PricingSource (GCP): completed in %s — %d pages, %d node pricing, %d volume pricing",
  80. time.Since(start).Round(time.Second), pageCount, len(ps.NodePricing), len(ps.PersistentVolumePricing))
  81. return ps, nil
  82. }
  83. func (g *GCPPricingSource) buildURL(pageToken string) string {
  84. q := url.Values{}
  85. q.Set("key", g.config.APIKey)
  86. q.Set("currencyCode", g.config.CurrencyCode)
  87. if pageToken != "" {
  88. q.Set("pageToken", pageToken)
  89. }
  90. return BillingAPIBaseURL + "?" + q.Encode()
  91. }
  92. func (g *GCPPricingSource) parsePage(body io.Reader, nodeCPUCosts map[nodeKey]float64, nodeRAMCosts map[nodeKey]float64,
  93. volumeCosts map[volumeKey]float64,
  94. ) (nextPageToken string, err error) {
  95. data, err := io.ReadAll(body)
  96. if err != nil {
  97. return "", fmt.Errorf("reading response body: %w", err)
  98. }
  99. var page GCPPricingResponse
  100. if err := json.Unmarshal(data, &page); err != nil {
  101. return "", fmt.Errorf("unmarshalling response: %w", err)
  102. }
  103. for _, sku := range page.Skus {
  104. if sku.Category == nil || len(sku.PricingInfo) == 0 {
  105. continue
  106. }
  107. category := sku.Category
  108. resourceGroup := category.ResourceGroup
  109. usageType := strings.ToLower(category.UsageType)
  110. if isStorageResource(resourceGroup) {
  111. g.parseVolumeSKU(sku, volumeCosts)
  112. continue
  113. }
  114. if isComputeResource(resourceGroup) {
  115. g.parseComputeSKU(sku, usageType, nodeCPUCosts, nodeRAMCosts)
  116. continue
  117. }
  118. // TODO: Add GPU pricing support
  119. }
  120. return page.NextPageToken, nil
  121. }
  122. func (g *GCPPricingSource) parseVolumeSKU(sku *GCPPricing, volumeCosts map[volumeKey]float64) {
  123. volumeType, isRegional := mapGCPVolumeType(sku.Category.ResourceGroup, sku.Description)
  124. if volumeType == pricing.VolumeTypeNil {
  125. return
  126. }
  127. // Get the hourly price
  128. hourlyPrice, err := g.extractHourlyPrice(sku)
  129. if err != nil || hourlyPrice == 0 {
  130. return
  131. }
  132. // Convert from monthly to hourly (GCP storage is priced per GB-month)
  133. hourlyPrice = hourlyPrice / 730.0
  134. // Store pricing for each region
  135. for _, region := range sku.ServiceRegions {
  136. key := volumeKey{
  137. Region: region,
  138. VolumeType: volumeType,
  139. Regional: isRegional,
  140. }
  141. volumeCosts[key] = hourlyPrice
  142. }
  143. }
  144. func (g *GCPPricingSource) parseComputeSKU(sku *GCPPricing, usageType string, nodeCPUCosts map[nodeKey]float64,
  145. nodeRAMCosts map[nodeKey]float64,
  146. ) {
  147. resourceGroup := sku.Category.ResourceGroup
  148. // Normalize the instance type based on description
  149. instanceType := normalizeInstanceType(resourceGroup, sku.Description)
  150. // Get hourly price
  151. hourlyPrice, err := g.extractHourlyPrice(sku)
  152. if err != nil || hourlyPrice == 0 {
  153. return
  154. }
  155. // Determine if this is CPU or RAM pricing
  156. isRAM := strings.Contains(strings.ToUpper(sku.Description), "RAM")
  157. // Handle E2 instance family expansion
  158. instanceTypes := g.expandInstanceTypes(instanceType, sku.Category.ResourceGroup)
  159. // Store pricing for each region and instance type
  160. for _, region := range sku.ServiceRegions {
  161. for _, instType := range instanceTypes {
  162. key := nodeKey{
  163. Region: region,
  164. InstanceType: instType,
  165. UsageType: usageType,
  166. }
  167. if isRAM {
  168. nodeRAMCosts[key] = hourlyPrice
  169. } else {
  170. nodeCPUCosts[key] = hourlyPrice
  171. }
  172. }
  173. }
  174. }
  175. // expandInstanceTypes handles special cases like E2 and A2 families that map to multiple instance types
  176. func (g *GCPPricingSource) expandInstanceTypes(instanceType, resourceGroup string) []string {
  177. resourceGroupLower := strings.ToLower(resourceGroup)
  178. // E2 family expands to multiple instance types
  179. if instanceType == "e2" && (resourceGroupLower == "cpu" || resourceGroupLower == "ram") {
  180. return []string{"e2-micro", "e2-small", "e2-medium", "e2-standard", "e2-custom"}
  181. }
  182. // A2 family expands to multiple GPU-optimized instance types
  183. if instanceType == "a2" && (resourceGroupLower == "cpu" || resourceGroupLower == "ram") {
  184. return []string{"a2-highgpu", "a2-megagpu", "a2-ultragpu"}
  185. }
  186. return []string{instanceType}
  187. }
  188. // extractHourlyPrice extracts the hourly price from a GCP SKU
  189. func (g *GCPPricingSource) extractHourlyPrice(sku *GCPPricing) (float64, error) {
  190. if sku == nil || len(sku.PricingInfo) == 0 || sku.PricingInfo[0] == nil || sku.PricingInfo[0].PricingExpression == nil {
  191. return 0, fmt.Errorf("no pricing info")
  192. }
  193. pricingInfo := sku.PricingInfo[0]
  194. if pricingInfo.PricingExpression == nil || len(pricingInfo.PricingExpression.TieredRates) == 0 {
  195. return 0, fmt.Errorf("no tiered rates")
  196. }
  197. // Get the last tier (highest usage tier, which is the standard rate)
  198. lastRateIndex := len(pricingInfo.PricingExpression.TieredRates) - 1
  199. unitPrice := pricingInfo.PricingExpression.TieredRates[lastRateIndex].UnitPrice
  200. // Parse the base currency units
  201. unitsBaseCurrency, err := strconv.Atoi(unitPrice.Units)
  202. if err != nil {
  203. return 0, fmt.Errorf("parsing base unit price: %w", err)
  204. }
  205. // Calculate hourly price: whole currency units + fractional nanos
  206. // As per https://cloud.google.com/billing/v1/how-tos/catalog-api
  207. hourlyPrice := float64(unitsBaseCurrency) + (unitPrice.Nanos * math.Pow10(-9))
  208. return hourlyPrice, nil
  209. }
  210. func (g *GCPPricingSource) buildNodePricing(ps *pricing.PricingSet, nodeCPUCosts map[nodeKey]float64,
  211. nodeRAMCosts map[nodeKey]float64,
  212. ) {
  213. // Combine CPU and RAM costs into complete node pricing
  214. processedKeys := make(map[nodeKey]bool)
  215. // Process all keys that have either CPU or RAM pricing
  216. allKeys := make(map[nodeKey]bool)
  217. for k := range nodeCPUCosts {
  218. allKeys[k] = true
  219. }
  220. for k := range nodeRAMCosts {
  221. allKeys[k] = true
  222. }
  223. for key := range allKeys {
  224. if processedKeys[key] {
  225. continue
  226. }
  227. processedKeys[key] = true
  228. // Skip spot/preemptible pricing
  229. if strings.EqualFold(key.UsageType, "preemptible") {
  230. continue
  231. }
  232. cpuCost := nodeCPUCosts[key]
  233. ramCost := nodeRAMCosts[key]
  234. // Skip if we don't have both CPU and RAM costs
  235. if cpuCost == 0 || ramCost == 0 {
  236. continue
  237. }
  238. nodePricing := &pricing.NodePricing{
  239. Properties: pricing.NodePricingProperties{
  240. Provider: cloud.ProviderGCP,
  241. Region: key.Region,
  242. InstanceType: key.InstanceType,
  243. Provisioning: pricing.ProvisioningOnDemand,
  244. },
  245. Prices: pricing.Prices{
  246. pricing.ResourceCPU: pricing.Price{
  247. Unit: unit.Hour,
  248. Price: cpuCost,
  249. },
  250. pricing.ResourceRAM: pricing.Price{
  251. Unit: unit.Hour,
  252. Price: ramCost,
  253. },
  254. },
  255. }
  256. ps.NodePricing = append(ps.NodePricing, nodePricing)
  257. }
  258. }
  259. func (g *GCPPricingSource) buildVolumePricing(
  260. ps *pricing.PricingSet,
  261. volumeCosts map[volumeKey]float64,
  262. ) {
  263. for key, cost := range volumeCosts {
  264. volumePricing := &pricing.PersistentVolumePricing{
  265. Properties: pricing.PersistentVolumePricingProperties{
  266. Provider: cloud.ProviderGCP,
  267. Region: key.Region,
  268. VolumeType: key.VolumeType,
  269. },
  270. Prices: pricing.Prices{
  271. pricing.ResourceStorage: pricing.Price{
  272. Unit: unit.Hour,
  273. Price: cost,
  274. },
  275. },
  276. }
  277. ps.PersistentVolumePricing = append(ps.PersistentVolumePricing, volumePricing)
  278. }
  279. }