pim.go 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. package stackit
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "net/url"
  9. "strconv"
  10. "strings"
  11. "time"
  12. "github.com/opencost/opencost/core/pkg/log"
  13. )
  14. const (
  15. pimBaseURL = "https://pim-service.pim.stackit.cloud"
  16. pimMaxPage = 100
  17. pimMaxPages = 200 // safety limit to prevent infinite pagination
  18. pimTimeoutSec = 30
  19. )
  20. var pimHTTPClient = &http.Client{
  21. Timeout: pimTimeoutSec * time.Second,
  22. }
  23. // pimFlavorPricing holds PIM-fetched pricing for a VM flavor.
  24. type pimFlavorPricing struct {
  25. HourlyCost string
  26. VCPU int
  27. RAMGB float64
  28. GPUCount int
  29. GPUType string
  30. }
  31. // pimStoragePricing holds PIM-fetched pricing for a storage class.
  32. type pimStoragePricing struct {
  33. CostPerGBHr string
  34. }
  35. // pimSearchRequest is the POST body for /v2/skus/search.
  36. type pimSearchRequest struct {
  37. GeneralProductGroup string `json:"generalProductGroup,omitempty"`
  38. CategoryName string `json:"categoryName,omitempty"`
  39. ProductName string `json:"productName,omitempty"`
  40. Metro *bool `json:"metro,omitempty"`
  41. }
  42. // pimSKU represents the relevant fields of a PublicSKU from the PIM API.
  43. type pimSKU struct {
  44. ID string `json:"id"`
  45. Name string `json:"name"`
  46. CategoryName string `json:"categoryName"`
  47. GeneralProductGroup string `json:"generalProductGroup"`
  48. Unit string `json:"unit"`
  49. UnitBilling string `json:"unitBilling"`
  50. Region string `json:"region"`
  51. CPUOverprovisioning *bool `json:"cpuOverprovisioning"`
  52. Prices []pimPrice `json:"prices"`
  53. ProductSpecificAttributes pimProductSpecificAttrs `json:"productSpecificAttributes"`
  54. ServiceID []string `json:"serviceId"`
  55. }
  56. type pimPrice struct {
  57. Value string `json:"value"`
  58. MonthlyPrice string `json:"monthlyPrice"`
  59. CurrencyCode string `json:"currencyCode"`
  60. }
  61. type pimProductSpecificAttrs struct {
  62. Discriminator string `json:"discriminator"`
  63. Flavor string `json:"flavor"`
  64. Hardware string `json:"hardware"`
  65. VCPU *int `json:"vCPU"`
  66. RAM *float64 `json:"ram"`
  67. Metro *bool `json:"metro"`
  68. OS string `json:"os"`
  69. // Storage-specific
  70. Class string `json:"class"`
  71. StorageType string `json:"storage"`
  72. Type string `json:"type"`
  73. }
  74. type pimSearchResponse struct {
  75. Meta struct {
  76. NextCursor string `json:"nextCursor"`
  77. PageSize int `json:"pageSize"`
  78. } `json:"meta"`
  79. Data []pimSKU `json:"data"`
  80. }
  81. // fetchAllPIMSKUs fetches all SKUs matching the search criteria, handling pagination.
  82. func fetchAllPIMSKUs(req pimSearchRequest) ([]pimSKU, error) {
  83. var allSKUs []pimSKU
  84. cursor := ""
  85. for page := 0; page < pimMaxPages; page++ {
  86. reqURL := fmt.Sprintf("%s/v2/skus/search?pageSize=%d", pimBaseURL, pimMaxPage)
  87. if cursor != "" {
  88. reqURL += "&cursor=" + url.QueryEscape(cursor)
  89. }
  90. body, err := json.Marshal(req)
  91. if err != nil {
  92. return nil, fmt.Errorf("marshaling PIM search request: %w", err)
  93. }
  94. httpReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(body))
  95. if err != nil {
  96. return nil, fmt.Errorf("creating PIM request: %w", err)
  97. }
  98. httpReq.Header.Set("Content-Type", "application/json")
  99. resp, err := pimHTTPClient.Do(httpReq)
  100. if err != nil {
  101. return nil, fmt.Errorf("PIM API request failed: %w", err)
  102. }
  103. respBody, err := io.ReadAll(resp.Body)
  104. resp.Body.Close()
  105. if err != nil {
  106. return nil, fmt.Errorf("reading PIM response: %w", err)
  107. }
  108. if resp.StatusCode != http.StatusOK {
  109. return nil, fmt.Errorf("PIM API returned status %d: %s", resp.StatusCode, string(respBody))
  110. }
  111. var searchResp pimSearchResponse
  112. if err := json.Unmarshal(respBody, &searchResp); err != nil {
  113. return nil, fmt.Errorf("decoding PIM response: %w", err)
  114. }
  115. allSKUs = append(allSKUs, searchResp.Data...)
  116. if searchResp.Meta.NextCursor == "" || len(searchResp.Data) == 0 || len(searchResp.Data) < pimMaxPage {
  117. break
  118. }
  119. cursor = searchResp.Meta.NextCursor
  120. }
  121. return allSKUs, nil
  122. }
  123. // parsePIMVMFlavors converts VM SKUs into a flavor -> pricing map.
  124. // It filters for non-metro SKUs and extracts flavor name, vCPU, RAM, and hourly price.
  125. func parsePIMVMFlavors(skus []pimSKU) map[string]*pimFlavorPricing {
  126. flavors := make(map[string]*pimFlavorPricing)
  127. for _, sku := range skus {
  128. attrs := sku.ProductSpecificAttributes
  129. flavor := attrs.Flavor
  130. if flavor == "" {
  131. continue
  132. }
  133. // Skip metro variants (priced separately, ~2x)
  134. if attrs.Metro != nil && *attrs.Metro {
  135. continue
  136. }
  137. // Must have pricing data
  138. if len(sku.Prices) == 0 {
  139. continue
  140. }
  141. hourlyPrice := sku.Prices[0].Value
  142. if hourlyPrice == "" {
  143. continue
  144. }
  145. vcpu := 0
  146. if attrs.VCPU != nil {
  147. vcpu = *attrs.VCPU
  148. }
  149. ramGB := 0.0
  150. if attrs.RAM != nil {
  151. ramGB = *attrs.RAM
  152. }
  153. // Detect GPU count from flavor name (e.g. "n1.14d.g1" -> 1, "n1.28d.g2" -> 2)
  154. gpuCount := 0
  155. gpuType := ""
  156. if strings.HasPrefix(flavor, "n1.") || strings.HasPrefix(flavor, "n2.") || strings.HasPrefix(flavor, "n3.") {
  157. gpuCount = gpuCountFromFlavor(flavor)
  158. if gpuCount > 0 {
  159. gpuType = gpuTypeFromFlavor(flavor)
  160. }
  161. }
  162. flavors[flavor] = &pimFlavorPricing{
  163. HourlyCost: hourlyPrice,
  164. VCPU: vcpu,
  165. RAMGB: ramGB,
  166. GPUCount: gpuCount,
  167. GPUType: gpuType,
  168. }
  169. }
  170. return flavors
  171. }
  172. // parsePIMStoragePricing extracts per-GB-hour storage pricing from Storage SKUs.
  173. // Returns a map keyed by storage class name (e.g. "storage_premium_perf0").
  174. // Also includes a "default" entry for the cheapest capacity-based storage found.
  175. func parsePIMStoragePricing(skus []pimSKU) map[string]*pimStoragePricing {
  176. pricing := make(map[string]*pimStoragePricing)
  177. var defaultCost float64
  178. for _, sku := range skus {
  179. attrs := sku.ProductSpecificAttributes
  180. // Skip metro variants
  181. if attrs.Metro != nil && *attrs.Metro {
  182. continue
  183. }
  184. // Only capacity-based storage with per-GB/hour billing
  185. if sku.UnitBilling != "per GB/hour" {
  186. continue
  187. }
  188. if len(sku.Prices) == 0 || sku.Prices[0].Value == "" {
  189. continue
  190. }
  191. costStr := sku.Prices[0].Value
  192. // Key by storage class if available
  193. if attrs.Class != "" {
  194. pricing[attrs.Class] = &pimStoragePricing{CostPerGBHr: costStr}
  195. }
  196. // Track cheapest for default
  197. cost, err := strconv.ParseFloat(costStr, 64)
  198. if err == nil && (defaultCost == 0 || cost < defaultCost) {
  199. defaultCost = cost
  200. pricing["default"] = &pimStoragePricing{CostPerGBHr: costStr}
  201. }
  202. }
  203. return pricing
  204. }
  205. // gpuCountFromFlavor extracts GPU count from a STACKIT GPU flavor name.
  206. // e.g. "n1.14d.g1" -> 1, "n1.28d.g2" -> 2, "n3.104d.g8" -> 8
  207. func gpuCountFromFlavor(flavor string) int {
  208. parts := strings.Split(flavor, ".g")
  209. if len(parts) == 2 {
  210. if count, err := strconv.Atoi(parts[1]); err == nil {
  211. return count
  212. }
  213. }
  214. return 0
  215. }
  216. // gpuTypeFromFlavor returns the GPU model based on the flavor prefix.
  217. func gpuTypeFromFlavor(flavor string) string {
  218. switch {
  219. case strings.HasPrefix(flavor, "n1."):
  220. return "NVIDIA A100"
  221. case strings.HasPrefix(flavor, "n2."):
  222. return "NVIDIA L40S"
  223. case strings.HasPrefix(flavor, "n3."):
  224. return "NVIDIA H100 HGX"
  225. default:
  226. return ""
  227. }
  228. }
  229. // downloadPIMPricing fetches all VM, GPU, and storage pricing from the PIM API.
  230. // Returns the flavor map, storage map, and any error.
  231. func downloadPIMPricing() (map[string]*pimFlavorPricing, map[string]*pimStoragePricing, error) {
  232. metro := false
  233. // 1. Fetch non-metro VM SKUs
  234. log.Infof("STACKIT: fetching VM pricing from PIM API...")
  235. vmSKUs, err := fetchAllPIMSKUs(pimSearchRequest{
  236. GeneralProductGroup: "Virtual Machines",
  237. Metro: &metro,
  238. })
  239. if err != nil {
  240. return nil, nil, fmt.Errorf("fetching VM SKUs: %w", err)
  241. }
  242. flavors := parsePIMVMFlavors(vmSKUs)
  243. log.Infof("STACKIT: fetched %d VM flavor prices from PIM API", len(flavors))
  244. // 2. Fetch GPU SKUs (separate category)
  245. log.Infof("STACKIT: fetching GPU pricing from PIM API...")
  246. gpuSKUs, err := fetchAllPIMSKUs(pimSearchRequest{
  247. CategoryName: "Compute Engine GPU",
  248. Metro: &metro,
  249. })
  250. if err != nil {
  251. log.Warnf("STACKIT: failed to fetch GPU pricing from PIM API: %v", err)
  252. } else {
  253. gpuFlavors := parsePIMVMFlavors(gpuSKUs)
  254. for k, v := range gpuFlavors {
  255. flavors[k] = v
  256. }
  257. log.Infof("STACKIT: fetched %d GPU flavor prices from PIM API", len(gpuFlavors))
  258. }
  259. // 3. Fetch Storage SKUs
  260. log.Infof("STACKIT: fetching Storage pricing from PIM API...")
  261. storageSKUs, err := fetchAllPIMSKUs(pimSearchRequest{
  262. CategoryName: "Storage",
  263. Metro: &metro,
  264. })
  265. var storagePricing map[string]*pimStoragePricing
  266. if err != nil {
  267. log.Warnf("STACKIT: failed to fetch storage pricing from PIM API: %v", err)
  268. } else {
  269. storagePricing = parsePIMStoragePricing(storageSKUs)
  270. log.Infof("STACKIT: fetched %d storage price entries from PIM API", len(storagePricing))
  271. }
  272. return flavors, storagePricing, nil
  273. }