| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321 |
- package stackit
- import (
- "bytes"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "time"
- "github.com/opencost/opencost/core/pkg/log"
- )
- const (
- pimBaseURL = "https://pim-service.pim.stackit.cloud"
- pimMaxPage = 100
- pimMaxPages = 200 // safety limit to prevent infinite pagination
- pimTimeoutSec = 30
- )
- var pimHTTPClient = &http.Client{
- Timeout: pimTimeoutSec * time.Second,
- }
- // pimFlavorPricing holds PIM-fetched pricing for a VM flavor.
- type pimFlavorPricing struct {
- HourlyCost string
- VCPU int
- RAMGB float64
- GPUCount int
- GPUType string
- }
- // pimStoragePricing holds PIM-fetched pricing for a storage class.
- type pimStoragePricing struct {
- CostPerGBHr string
- }
- // pimSearchRequest is the POST body for /v2/skus/search.
- type pimSearchRequest struct {
- GeneralProductGroup string `json:"generalProductGroup,omitempty"`
- CategoryName string `json:"categoryName,omitempty"`
- ProductName string `json:"productName,omitempty"`
- Metro *bool `json:"metro,omitempty"`
- }
- // pimSKU represents the relevant fields of a PublicSKU from the PIM API.
- type pimSKU struct {
- ID string `json:"id"`
- Name string `json:"name"`
- CategoryName string `json:"categoryName"`
- GeneralProductGroup string `json:"generalProductGroup"`
- Unit string `json:"unit"`
- UnitBilling string `json:"unitBilling"`
- Region string `json:"region"`
- CPUOverprovisioning *bool `json:"cpuOverprovisioning"`
- Prices []pimPrice `json:"prices"`
- ProductSpecificAttributes pimProductSpecificAttrs `json:"productSpecificAttributes"`
- ServiceID []string `json:"serviceId"`
- }
- type pimPrice struct {
- Value string `json:"value"`
- MonthlyPrice string `json:"monthlyPrice"`
- CurrencyCode string `json:"currencyCode"`
- }
- type pimProductSpecificAttrs struct {
- Discriminator string `json:"discriminator"`
- Flavor string `json:"flavor"`
- Hardware string `json:"hardware"`
- VCPU *int `json:"vCPU"`
- RAM *float64 `json:"ram"`
- Metro *bool `json:"metro"`
- OS string `json:"os"`
- // Storage-specific
- Class string `json:"class"`
- StorageType string `json:"storage"`
- Type string `json:"type"`
- }
- type pimSearchResponse struct {
- Meta struct {
- NextCursor string `json:"nextCursor"`
- PageSize int `json:"pageSize"`
- } `json:"meta"`
- Data []pimSKU `json:"data"`
- }
- // fetchAllPIMSKUs fetches all SKUs matching the search criteria, handling pagination.
- func fetchAllPIMSKUs(req pimSearchRequest) ([]pimSKU, error) {
- var allSKUs []pimSKU
- cursor := ""
- for page := 0; page < pimMaxPages; page++ {
- reqURL := fmt.Sprintf("%s/v2/skus/search?pageSize=%d", pimBaseURL, pimMaxPage)
- if cursor != "" {
- reqURL += "&cursor=" + url.QueryEscape(cursor)
- }
- body, err := json.Marshal(req)
- if err != nil {
- return nil, fmt.Errorf("marshaling PIM search request: %w", err)
- }
- httpReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(body))
- if err != nil {
- return nil, fmt.Errorf("creating PIM request: %w", err)
- }
- httpReq.Header.Set("Content-Type", "application/json")
- resp, err := pimHTTPClient.Do(httpReq)
- if err != nil {
- return nil, fmt.Errorf("PIM API request failed: %w", err)
- }
- respBody, err := io.ReadAll(resp.Body)
- resp.Body.Close()
- if err != nil {
- return nil, fmt.Errorf("reading PIM response: %w", err)
- }
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("PIM API returned status %d: %s", resp.StatusCode, string(respBody))
- }
- var searchResp pimSearchResponse
- if err := json.Unmarshal(respBody, &searchResp); err != nil {
- return nil, fmt.Errorf("decoding PIM response: %w", err)
- }
- allSKUs = append(allSKUs, searchResp.Data...)
- if searchResp.Meta.NextCursor == "" || len(searchResp.Data) == 0 || len(searchResp.Data) < pimMaxPage {
- break
- }
- cursor = searchResp.Meta.NextCursor
- }
- return allSKUs, nil
- }
- // parsePIMVMFlavors converts VM SKUs into a flavor -> pricing map.
- // It filters for non-metro SKUs and extracts flavor name, vCPU, RAM, and hourly price.
- func parsePIMVMFlavors(skus []pimSKU) map[string]*pimFlavorPricing {
- flavors := make(map[string]*pimFlavorPricing)
- for _, sku := range skus {
- attrs := sku.ProductSpecificAttributes
- flavor := attrs.Flavor
- if flavor == "" {
- continue
- }
- // Skip metro variants (priced separately, ~2x)
- if attrs.Metro != nil && *attrs.Metro {
- continue
- }
- // Must have pricing data
- if len(sku.Prices) == 0 {
- continue
- }
- hourlyPrice := sku.Prices[0].Value
- if hourlyPrice == "" {
- continue
- }
- vcpu := 0
- if attrs.VCPU != nil {
- vcpu = *attrs.VCPU
- }
- ramGB := 0.0
- if attrs.RAM != nil {
- ramGB = *attrs.RAM
- }
- // Detect GPU count from flavor name (e.g. "n1.14d.g1" -> 1, "n1.28d.g2" -> 2)
- gpuCount := 0
- gpuType := ""
- if strings.HasPrefix(flavor, "n1.") || strings.HasPrefix(flavor, "n2.") || strings.HasPrefix(flavor, "n3.") {
- gpuCount = gpuCountFromFlavor(flavor)
- if gpuCount > 0 {
- gpuType = gpuTypeFromFlavor(flavor)
- }
- }
- flavors[flavor] = &pimFlavorPricing{
- HourlyCost: hourlyPrice,
- VCPU: vcpu,
- RAMGB: ramGB,
- GPUCount: gpuCount,
- GPUType: gpuType,
- }
- }
- return flavors
- }
- // parsePIMStoragePricing extracts per-GB-hour storage pricing from Storage SKUs.
- // Returns a map keyed by storage class name (e.g. "storage_premium_perf0").
- // Also includes a "default" entry for the cheapest capacity-based storage found.
- func parsePIMStoragePricing(skus []pimSKU) map[string]*pimStoragePricing {
- pricing := make(map[string]*pimStoragePricing)
- var defaultCost float64
- for _, sku := range skus {
- attrs := sku.ProductSpecificAttributes
- // Skip metro variants
- if attrs.Metro != nil && *attrs.Metro {
- continue
- }
- // Only capacity-based storage with per-GB/hour billing
- if sku.UnitBilling != "per GB/hour" {
- continue
- }
- if len(sku.Prices) == 0 || sku.Prices[0].Value == "" {
- continue
- }
- costStr := sku.Prices[0].Value
- // Key by storage class if available
- if attrs.Class != "" {
- pricing[attrs.Class] = &pimStoragePricing{CostPerGBHr: costStr}
- }
- // Track cheapest for default
- cost, err := strconv.ParseFloat(costStr, 64)
- if err == nil && (defaultCost == 0 || cost < defaultCost) {
- defaultCost = cost
- pricing["default"] = &pimStoragePricing{CostPerGBHr: costStr}
- }
- }
- return pricing
- }
- // gpuCountFromFlavor extracts GPU count from a STACKIT GPU flavor name.
- // e.g. "n1.14d.g1" -> 1, "n1.28d.g2" -> 2, "n3.104d.g8" -> 8
- func gpuCountFromFlavor(flavor string) int {
- parts := strings.Split(flavor, ".g")
- if len(parts) == 2 {
- if count, err := strconv.Atoi(parts[1]); err == nil {
- return count
- }
- }
- return 0
- }
- // gpuTypeFromFlavor returns the GPU model based on the flavor prefix.
- func gpuTypeFromFlavor(flavor string) string {
- switch {
- case strings.HasPrefix(flavor, "n1."):
- return "NVIDIA A100"
- case strings.HasPrefix(flavor, "n2."):
- return "NVIDIA L40S"
- case strings.HasPrefix(flavor, "n3."):
- return "NVIDIA H100 HGX"
- default:
- return ""
- }
- }
- // downloadPIMPricing fetches all VM, GPU, and storage pricing from the PIM API.
- // Returns the flavor map, storage map, and any error.
- func downloadPIMPricing() (map[string]*pimFlavorPricing, map[string]*pimStoragePricing, error) {
- metro := false
- // 1. Fetch non-metro VM SKUs
- log.Infof("STACKIT: fetching VM pricing from PIM API...")
- vmSKUs, err := fetchAllPIMSKUs(pimSearchRequest{
- GeneralProductGroup: "Virtual Machines",
- Metro: &metro,
- })
- if err != nil {
- return nil, nil, fmt.Errorf("fetching VM SKUs: %w", err)
- }
- flavors := parsePIMVMFlavors(vmSKUs)
- log.Infof("STACKIT: fetched %d VM flavor prices from PIM API", len(flavors))
- // 2. Fetch GPU SKUs (separate category)
- log.Infof("STACKIT: fetching GPU pricing from PIM API...")
- gpuSKUs, err := fetchAllPIMSKUs(pimSearchRequest{
- CategoryName: "Compute Engine GPU",
- Metro: &metro,
- })
- if err != nil {
- log.Warnf("STACKIT: failed to fetch GPU pricing from PIM API: %v", err)
- } else {
- gpuFlavors := parsePIMVMFlavors(gpuSKUs)
- for k, v := range gpuFlavors {
- flavors[k] = v
- }
- log.Infof("STACKIT: fetched %d GPU flavor prices from PIM API", len(gpuFlavors))
- }
- // 3. Fetch Storage SKUs
- log.Infof("STACKIT: fetching Storage pricing from PIM API...")
- storageSKUs, err := fetchAllPIMSKUs(pimSearchRequest{
- CategoryName: "Storage",
- Metro: &metro,
- })
- var storagePricing map[string]*pimStoragePricing
- if err != nil {
- log.Warnf("STACKIT: failed to fetch storage pricing from PIM API: %v", err)
- } else {
- storagePricing = parsePIMStoragePricing(storageSKUs)
- log.Infof("STACKIT: fetched %d storage price entries from PIM API", len(storagePricing))
- }
- return flavors, storagePricing, nil
- }
|