Quellcode durchsuchen

fix: digitalocean provider (#3541)

Signed-off-by: Alex Meijer <ameijer@users.noreply.github.com>
Co-authored-by: Alex Meijer <ameijer@users.noreply.github.com>
Michelangelo vor 2 Monaten
Ursprung
Commit
23fefe9b26

+ 1 - 1
Dockerfile

@@ -1,4 +1,4 @@
-FROM --platform=$BUILDPLATFORM golang:1.25.3-alpine3.21 AS build-env
+FROM --platform=$BUILDPLATFORM golang:1.25.5-alpine3.21 AS build-env
 
 WORKDIR /app
 

+ 207 - 187
pkg/cloud/digitalocean/provider.go

@@ -19,12 +19,17 @@ import (
 	"github.com/opencost/opencost/pkg/env"
 )
 
+// DigitalOcean Volumes Block Storage: $0.10/GiB/month
+// Converting to hourly: $0.10 / 730 hours (average month) ≈ $0.000137/GiB/hour
+const doVolumeHourlyRatePerGiB = 0.000137
+
+// Legacy fallback rate (kept for compatibility)
 const fallbackPVHourlyRate = 0.00015
 
 type DOKS struct {
 	PricingURL            string
 	Cache                 *PricingCache
-	Products              map[string][]DOProduct
+	Sizes                 map[string]*DOSize
 	Config                models.ProviderConfig
 	Clientset             clustercache.ClusterCache
 	ClusterManagementCost float64
@@ -36,50 +41,77 @@ type PricingCache struct {
 	mu         sync.Mutex
 }
 
+// DOResponse represents the response from DigitalOcean's /v2/sizes API
 type DOResponse struct {
-	Products []DOProduct `json:"products"`
+	Sizes []DOSize `json:"sizes"`
+	Links DOLinks  `json:"links,omitempty"`
+	Meta  DOMeta   `json:"meta,omitempty"`
+}
+
+// DOSize represents a DigitalOcean Droplet size
+type DOSize struct {
+	Slug         string       `json:"slug"`
+	Memory       int          `json:"memory"` // Memory in MB
+	VCPUs        int          `json:"vcpus"`
+	Disk         int          `json:"disk"`          // Disk in GB
+	Transfer     float64      `json:"transfer"`      // Transfer in TB
+	PriceMonthly float64      `json:"price_monthly"` // Monthly price in USD
+	PriceHourly  float64      `json:"price_hourly"`  // Hourly price in USD
+	Regions      []string     `json:"regions"`
+	Available    bool         `json:"available"`
+	Description  string       `json:"description"`
+	DiskInfo     []DODiskInfo `json:"disk_info,omitempty"`
+	GPUInfo      DOGPUInfo    `json:"gpu_info,omitempty"`
 }
 
-type DOProduct struct {
-	SKU         string        `json:"sku"`
-	ItemType    string        `json:"itemType"`
-	DisplayName string        `json:"displayName"`
-	Category    string        `json:"category"`
-	Prices      []DOPrice     `json:"prices"`
-	Allowances  []DOAllowance `json:"allowances,omitempty"`
-	Attributes  []DOAttribute `json:"attributes,omitempty"`
-	EffectiveAt string        `json:"effectiveAt"`
+// DODiskInfo represents disk information for a DigitalOcean size
+type DODiskInfo struct {
+	Type string     `json:"type"`
+	Size DODiskSize `json:"size"`
 }
 
-type DOPrice struct {
-	Unit      string `json:"unit"`
-	Rate      string `json:"rate"`
-	MinAmount string `json:"minAmount"`
-	MaxAmount string `json:"maxAmount"`
-	MinUsage  string `json:"minUsage"`
-	MaxUsage  string `json:"maxUsage"`
-	Currency  string `json:"currency"`
-	Region    string `json:"region"`
+// DOGPUInfo represents GPU information for a DigitalOcean size
+type DOGPUInfo struct {
+	Count int       `json:"count"`
+	VRAM  DOGPUVRAM `json:"vram"`
+	Model string    `json:"model"`
 }
 
-type DOAllowance struct {
-	Quantity    string `json:"quantity"`
-	Unit        string `json:"unit"`
-	AllowanceId string `json:"allowanceId"`
-	Schedule    string `json:"schedule"`
+// DOGPUVRAM represents GPU VRAM details
+type DOGPUVRAM struct {
+	Amount int    `json:"amount"`
+	Unit   string `json:"unit"`
 }
 
-type DOAttribute struct {
-	Name  string `json:"name"`
-	Value string `json:"value"`
-	Unit  string `json:"unit"`
+// DODiskSize represents disk size details
+type DODiskSize struct {
+	Amount int    `json:"amount"`
+	Unit   string `json:"unit"`
+}
+
+// DOLinks represents pagination links
+type DOLinks struct {
+	Pages DOPages `json:"pages,omitempty"`
+}
+
+// DOPages represents pagination page links
+type DOPages struct {
+	First string `json:"first,omitempty"`
+	Prev  string `json:"prev,omitempty"`
+	Next  string `json:"next,omitempty"`
+	Last  string `json:"last,omitempty"`
+}
+
+// DOMeta represents metadata about the response
+type DOMeta struct {
+	Total int `json:"total"`
 }
 
 func NewDOKSProvider(pricingURL string) *DOKS {
 	return &DOKS{
 		PricingURL: pricingURL,
 		Cache:      &PricingCache{},
-		Products:   make(map[string][]DOProduct),
+		Sizes:      make(map[string]*DOSize),
 	}
 }
 
@@ -104,39 +136,62 @@ func (do *DOKS) fetchPricingData() (*DOResponse, error) {
 	if pricingURL == "" {
 		pricingURL = env.GetDOKSPricingURL()
 	}
-	log.Infof("Fetching DigitalOcean pricing from: %s", pricingURL)
+	log.Infof("Fetching DigitalOcean sizes from: %s", pricingURL)
+
+	// Create request with authentication
+	req, err := http.NewRequest("GET", pricingURL, nil)
+	if err != nil {
+		log.Warnf("Failed to create request: %v", err)
+		return nil, fmt.Errorf("failed to create request: %w", err)
+	}
+
+	// Authentication is required for the DigitalOcean sizes API
+	token := env.GetDigitalOceanAccessToken()
+	if token == "" {
+		log.Errorf("DigitalOcean API requires authentication. Set DIGITALOCEAN_ACCESS_TOKEN or CLOUD_PROVIDER_API_KEY environment variable with your DigitalOcean Personal Access Token")
+		return nil, fmt.Errorf("DigitalOcean authentication required: set DIGITALOCEAN_ACCESS_TOKEN or CLOUD_PROVIDER_API_KEY environment variable")
+	}
 
-	resp, err := http.Get(pricingURL)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+	req.Header.Set("Content-Type", "application/json")
+	log.Debugf("Using authenticated DigitalOcean API request")
+
+	client := &http.Client{Timeout: 30 * time.Second}
+	resp, err := client.Do(req)
 	if err != nil {
-		log.Warnf("Failed to fetch pricing from DigitalOcean: %v", err)
-		return nil, fmt.Errorf("pricing API fetch error: %w", err)
+		log.Warnf("Failed to fetch sizes from DigitalOcean: %v", err)
+		return nil, fmt.Errorf("sizes API fetch error: %w", err)
 	}
 	defer resp.Body.Close()
 
 	if resp.StatusCode != http.StatusOK {
-		log.Warnf("Pricing API returned unexpected status: %d", resp.StatusCode)
-		return nil, fmt.Errorf("pricing API returned status: %d", resp.StatusCode)
+		log.Warnf("Sizes API returned unexpected status: %d", resp.StatusCode)
+		return nil, fmt.Errorf("sizes API returned status: %d", resp.StatusCode)
 	}
 
 	var data DOResponse
 	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
-		log.Errorf("Failed to decode pricing JSON: %v", err)
-		return nil, fmt.Errorf("failed to decode pricing response: %w", err)
+		log.Errorf("Failed to decode sizes JSON: %v", err)
+		return nil, fmt.Errorf("failed to decode sizes response: %w", err)
 	}
 
-	// Categorize products by item type
-	categorized := make(map[string][]DOProduct)
-	for _, product := range data.Products {
-		log.Debugf("Indexing product: SKU=%s, ItemType=%s, Name=%s", product.SKU, product.ItemType, product.DisplayName)
-		categorized[product.ItemType] = append(categorized[product.ItemType], product)
+	// TODO: handle pagination
+
+	// Index sizes by slug for quick lookup
+	sizesMap := make(map[string]*DOSize)
+	for i := range data.Sizes {
+		size := &data.Sizes[i]
+		sizesMap[size.Slug] = size
+		log.Debugf("Indexing size: Slug=%s, VCPUs=%d, Memory=%dMB, PriceHourly=$%.5f",
+			size.Slug, size.VCPUs, size.Memory, size.PriceHourly)
 	}
 
 	// Cache and return
-	do.Products = categorized
+	do.Sizes = sizesMap
 	do.Cache.data = &data
 	do.Cache.lastUpdate = time.Now()
 
-	log.Infof("Successfully updated DigitalOcean pricing cache (%d products)", len(data.Products))
+	log.Infof("Successfully updated DigitalOcean pricing cache (%d sizes)", len(data.Sizes))
 	return do.Cache.data, nil
 }
 
@@ -209,6 +264,14 @@ func (k *doksKey) Features() string {
 }
 
 func (k *doksKey) GPUType() string {
+	t := k.ID()
+	if t != "" && strings.HasPrefix(t, "gpu-") {
+		parts := strings.Split(t, "-")
+		if len(parts) >= 2 {
+			modelParts := strings.Split(parts[1], "x")
+			return modelParts[0]
+		}
+	}
 	return ""
 }
 
@@ -223,6 +286,17 @@ func (k *doksKey) String() string {
 }
 
 func (k *doksKey) GPUCount() int {
+	t := k.ID()
+	if t != "" && strings.HasPrefix(t, "gpu-") {
+		matches := reGPUCount.FindStringSubmatch(t)
+		if len(matches) == 2 {
+			count, err := strconv.Atoi(matches[1])
+			if err == nil {
+				return count
+			}
+		}
+		return 1
+	}
 	return 0
 }
 
@@ -270,6 +344,7 @@ var (
 	reVCpu        = regexp.MustCompile(`(\d+)\s*vcpu`)
 	reRAM         = regexp.MustCompile(`(\d+)\s*gb`)
 	reSimpleCount = regexp.MustCompile(`^[a-z0-9_]+-(\d+)(?:-|$)`)
+	reGPUCount    = regexp.MustCompile(`x(\d+)(?:-|$)`)
 )
 
 func extractResources(slug string) (int, int, bool) {
@@ -387,32 +462,6 @@ func extractVCpuRAMGuess(slugLower, family string, ramPerVCPU int) (vcpu int, ra
 	return
 }
 
-var (
-	vcpuRegex = regexp.MustCompile(`(?i)(\d+)\s*VCPU`)
-	ramRegex  = regexp.MustCompile(`(?i)(\d+)\s*GB\s*RAM`)
-)
-
-func extractSpecsFromDisplayName(name string) (vcpu int, memoryGiB int, err error) {
-	vcpuMatches := vcpuRegex.FindStringSubmatch(name)
-	ramMatches := ramRegex.FindStringSubmatch(name)
-
-	if len(vcpuMatches) < 2 || len(ramMatches) < 2 {
-		return 0, 0, fmt.Errorf("could not extract specs from displayName: %q", name)
-	}
-
-	vcpu, err = strconv.Atoi(vcpuMatches[1])
-	if err != nil {
-		return 0, 0, fmt.Errorf("invalid vCPU format: %v", err)
-	}
-
-	memoryGiB, err = strconv.Atoi(ramMatches[1])
-	if err != nil {
-		return 0, 0, fmt.Errorf("invalid RAM format: %v", err)
-	}
-
-	return vcpu, memoryGiB, nil
-}
-
 func parseResources(features string) (int, int, error) {
 	parts := strings.Split(features, ",")
 	var cpu, ram int
@@ -453,6 +502,14 @@ func (do *DOKS) NodePricing(key models.Key) (*models.Node, models.PricingMetadat
 	arch := parseArch(key.Features())
 	slug := key.ID()
 
+	// First, try to find by exact slug match
+	if size, ok := do.Sizes[slug]; ok && size.Available {
+		node, meta := do.sizeToNode(size, arch)
+		log.Infof("Found size by slug: %s (vCPU: %d, RAM: %dMB, price: $%.5f/hr)",
+			size.Slug, size.VCPUs, size.Memory, size.PriceHourly)
+		return node, meta, nil
+	}
+
 	// Try parsing vCPU/RAM from labels
 	vcpu, ram, err := parseResources(key.Features())
 	if err != nil || vcpu == 0 || ram == 0 {
@@ -473,30 +530,23 @@ func (do *DOKS) NodePricing(key models.Key) (*models.Node, models.PricingMetadat
 		}
 	}
 
-	// Search for matching product in the DigitalOcean catalog
-	for _, products := range do.Products {
-		for _, product := range products {
-			if product.ItemType != "K8S_WORKER_NODE" {
-				continue
-			}
-
-			productVCPU, productRAM, err := extractSpecsFromDisplayName(product.DisplayName)
-			if err != nil {
-				continue
-			}
+	// If slug lookup fails, search by vCPU and RAM specs
+	ramMB := ram * 1024 // Convert GiB to MB
+	for _, size := range do.Sizes {
+		if !size.Available {
+			continue
+		}
 
-			if productVCPU == vcpu && productRAM == ram {
-				node, meta, err := do.productToNode(product, vcpu, ram, arch)
-				if err != nil {
-					log.Warnf("Failed to convert product %s to node: %v", product.SKU, err)
-					continue
-				}
-				return node, meta, nil
-			}
+		// Match by vCPU and memory (with small tolerance for memory)
+		if size.VCPUs == vcpu && size.Memory == ramMB {
+			node, meta := do.sizeToNode(size, arch)
+			log.Infof("Found size by specs: %s (vCPU: %d, RAM: %dMB, price: $%.5f/hr)",
+				size.Slug, size.VCPUs, size.Memory, size.PriceHourly)
+			return node, meta, nil
 		}
 	}
 
-	log.Warnf("No matching product found for slug %s (vCPU: %d, RAM: %d), falling back", slug, vcpu, ram)
+	log.Warnf("No matching size found for slug %s (vCPU: %d, RAM: %dGiB), falling back", slug, vcpu, ram)
 	return fallbackNode(slug)
 }
 
@@ -511,63 +561,60 @@ func parseArch(features string) string {
 	return ""
 }
 
-func (do *DOKS) productToNode(product DOProduct, vcpu int, ramGiB int, arch string) (*models.Node, models.PricingMetadata, error) {
-	if len(product.Prices) == 0 {
-		return nil, models.PricingMetadata{
-			Currency: "USD",
-			Source:   "digitalocean",
-			Warnings: []string{"product has no prices"},
-		}, fmt.Errorf("no pricing data for product: %s", product.SKU)
-	}
+// sizeToNode converts a DigitalOcean size to an OpenCost Node model
+func (do *DOKS) sizeToNode(size *DOSize, arch string) (*models.Node, models.PricingMetadata) {
+	hourlyCost := size.PriceHourly
+	vcpu := size.VCPUs
+	ramGiB := float64(size.Memory) / 1024.0 // Convert MB to GiB
 
-	price := product.Prices[0]
-	rate, err := strconv.ParseFloat(price.Rate, 64)
-	if err != nil {
-		return nil, models.PricingMetadata{
-			Currency: "USD",
-			Source:   "digitalocean",
-			Warnings: []string{"invalid price rate format"},
-		}, fmt.Errorf("invalid rate for %s: %v", product.SKU, err)
-	}
-
-	var hourlyCost float64
-	switch price.Unit {
-	case "ITEM_PER_SECOND":
-		hourlyCost = rate * 3600
-	case "ITEM_PER_HOUR":
-		hourlyCost = rate
-	default:
-		return nil, models.PricingMetadata{
-			Currency: "USD",
-			Source:   "digitalocean",
-			Warnings: []string{"unsupported pricing unit"},
-		}, fmt.Errorf("unsupported unit: %s", price.Unit)
-	}
-
-	// Assuming CPU and RAM are priced similarly
-	totalUnits := float64(vcpu + ramGiB)
-	vcpuCost := hourlyCost * float64(vcpu) / totalUnits
-	ramCost := hourlyCost * float64(ramGiB) / totalUnits
+	// Distribute cost proportionally between CPU and RAM based on resource counts.
+	// VCPUCost = total CPU cost portion, RAMCost = total RAM cost portion.
+	totalUnits := float64(vcpu) + ramGiB
+	var vcpuCost, ramCost float64
+	if totalUnits > 0 {
+		vcpuCost = hourlyCost * float64(vcpu) / totalUnits
+		ramCost = hourlyCost * ramGiB / totalUnits
+	}
 
 	if arch == "" {
 		arch = "amd64"
 	}
 
-	return &models.Node{
-			Cost:         fmt.Sprintf("%.5f", hourlyCost),
-			VCPUCost:     fmt.Sprintf("%.5f", vcpuCost),
-			RAMCost:      fmt.Sprintf("%.5f", ramCost),
-			VCPU:         strconv.Itoa(vcpu),
-			RAM:          fmt.Sprintf("%dGiB", ramGiB),
-			InstanceType: product.DisplayName,
-			Region:       price.Region,
-			UsageType:    product.ItemType,
-			PricingType:  models.DefaultPrices,
-			ArchType:     arch,
-		}, models.PricingMetadata{
-			Currency: "USD",
-			Source:   "digitalocean",
-		}, nil
+	region := "global"
+	if len(size.Regions) > 0 {
+		region = size.Regions[0]
+	}
+
+	// Convert RAM from MB to bytes directly to avoid float rounding
+	ramBytes := int64(size.Memory) * 1024 * 1024
+
+	// Format RAM as integer GiB when possible
+	ramGiBInt := int(ramGiB)
+	ramStr := fmt.Sprintf("%dGiB", ramGiBInt)
+
+	node := &models.Node{
+		Cost:         fmt.Sprintf("%.5f", hourlyCost),
+		VCPUCost:     fmt.Sprintf("%.5f", vcpuCost),
+		RAMCost:      fmt.Sprintf("%.5f", ramCost),
+		VCPU:         strconv.Itoa(vcpu),
+		RAM:          ramStr,
+		RAMBytes:     fmt.Sprintf("%d", ramBytes),
+		InstanceType: size.Slug,
+		Region:       region,
+		UsageType:    "droplet",
+		PricingType:  models.DefaultPrices,
+		ArchType:     arch,
+	}
+
+	if size.GPUInfo.Count > 0 {
+		node.GPU = strconv.Itoa(size.GPUInfo.Count)
+		node.GPUName = size.GPUInfo.Model
+	}
+
+	return node, models.PricingMetadata{
+		Currency: "USD",
+		Source:   "digitalocean-sizes-api",
+	}
 }
 
 func fallbackNode(slug string) (*models.Node, models.PricingMetadata, error) {
@@ -585,12 +632,15 @@ func fallbackNode(slug string) (*models.Node, models.PricingMetadata, error) {
 
 		log.Infof("FallbackNode (estimated): %s , hourly=%.5f, vcpuUnit=%.5f, ramUnit=%.5f", slug, cost, unitCost, unitCost)
 
+		ramBytes := int64(ram) * 1024 * 1024 * 1024
+
 		return &models.Node{
 				Cost:         fmt.Sprintf("%.5f", cost),
 				VCPUCost:     fmt.Sprintf("%.5f", unitCost),
 				RAMCost:      fmt.Sprintf("%.5f", unitCost),
 				VCPU:         strconv.Itoa(vcpu),
 				RAM:          fmt.Sprintf("%dGiB", ram),
+				RAMBytes:     fmt.Sprintf("%d", ramBytes),
 				InstanceType: slug,
 				Region:       "global",
 				UsageType:    "static-fallback",
@@ -638,48 +688,29 @@ func (k *doksPVKey) GetStorageClass() string {
 func (do *DOKS) PVPricing(key models.PVKey) (*models.PV, error) {
 	log.Debug("Fetching DigitalOcean block storage pricing")
 
-	_, err := do.fetchPricingData()
-	if err != nil {
-		log.Warnf("Failed to fetch PV pricing data: %v, using fallback", err)
-		return fallbackPV(key)
-	}
-
-	products, ok := do.Products["K8S_VOLUME"]
-	if !ok || len(products) == 0 {
-		log.Warn("No 'K8S_VOLUME' product found in catalog, using fallback")
-		return fallbackPV(key)
-	}
-
-	product := products[0]
-	if len(product.Prices) == 0 {
-		log.Warn("No pricing info found for K8S_VOLUME, using fallback")
-		return fallbackPV(key)
-	}
-
-	price := product.Prices[0]
-	if price.Unit != "GIB_PER_HOUR" {
-		log.Warnf("Unsupported PV price unit: %s, expected GIB_PER_HOUR. Using fallback.", price.Unit)
-		return fallbackPV(key)
-	}
-
-	rate, err := strconv.ParseFloat(price.Rate, 64)
-	if err != nil {
-		log.Warnf("Failed to parse PV rate: %v, using fallback", err)
-		return fallbackPV(key)
-	}
-
+	// DigitalOcean volumes have fixed pricing: $0.10/GiB/month
+	// This is approximately $0.000137/GiB/hour
 	k, ok := key.(*doksPVKey)
 	var sizeGB int64
+	var region string
 	if ok {
 		sizeGB = k.SizeGiB()
+		region = k.region
 	}
 
+	if region == "" {
+		region = "global"
+	}
+
+	log.Infof("Using DigitalOcean volume pricing: $%.6f/GiB/hr | Class=%s | SizeGiB=%d | Region=%s | ID=%s",
+		doVolumeHourlyRatePerGiB, key.GetStorageClass(), sizeGB, region, key.ID())
+
 	return &models.PV{
-		Cost:       fmt.Sprintf("%.5f", rate),
+		Cost:       fmt.Sprintf("%.6f", doVolumeHourlyRatePerGiB),
 		CostPerIO:  "0",
 		Class:      key.GetStorageClass(),
 		Size:       fmt.Sprintf("%d", sizeGB),
-		Region:     price.Region,
+		Region:     region,
 		ProviderID: key.ID(),
 		Parameters: nil,
 	}, nil
@@ -805,20 +836,9 @@ func (do *DOKS) AllNodePricing() (interface{}, error) {
 }
 
 func (do *DOKS) AllPVPricing() (map[models.PVKey]*models.PV, error) {
-	_, err := do.fetchPricingData()
-	if err != nil {
-		return nil, fmt.Errorf("failed to fetch pricing data: %w", err)
-	}
-
-	products, ok := do.Products["K8S_VOLUME"]
-	if !ok || len(products) == 0 {
-		return nil, fmt.Errorf("no PV products found")
-	}
-
-	// Only one PV product
-	product := products[0]
+	// DigitalOcean has a single, fixed pricing tier for block storage volumes
 	key := &doksPVKey{
-		id:           product.SKU,
+		id:           "do-volume",
 		storageClass: "do-block-storage",
 	}
 

+ 66 - 11
pkg/cloud/digitalocean/provider_test.go

@@ -17,6 +17,9 @@ func newTestProviderWithFile(t *testing.T, filename string) (*DOKS, func() int)
 		t.Fatalf("Failed to read file: %v", err)
 	}
 
+	// Set a fake token for testing
+	t.Setenv("DIGITALOCEAN_ACCESS_TOKEN", "test_token_dop_v1_fake")
+
 	var count int
 	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		count++
@@ -33,6 +36,9 @@ func newTestProviderWithFile(t *testing.T, filename string) (*DOKS, func() int)
 func newTestProviderWith404(t *testing.T) *DOKS {
 	t.Helper()
 
+	// Set a fake token for testing
+	t.Setenv("DIGITALOCEAN_ACCESS_TOKEN", "test_token_dop_v1_fake")
+
 	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		w.WriteHeader(http.StatusNotFound)
 	}))
@@ -68,16 +74,16 @@ func TestNodePricing_APIMatches(t *testing.T) {
 		}
 	}
 
-	assertEqual("Cost", node.Cost, "0.01199")
-	assertEqual("VCPUCost", node.VCPUCost, "0.00400") // 1/3
-	assertEqual("RAMCost", node.RAMCost, "0.00799")   // 2/3
+	assertEqual("Cost", node.Cost, "0.01786")
+	assertEqual("VCPUCost", node.VCPUCost, "0.00595") // 1/3
+	assertEqual("RAMCost", node.RAMCost, "0.01191")   // 2/3
 	assertEqual("VCPU", node.VCPU, "1")
 	assertEqual("RAM", node.RAM, "2GiB")
 	assertEqual("ArchType", node.ArchType, "amd64")
 	assertEqual("PricingType", string(node.PricingType), string(models.DefaultPrices))
 
-	if meta.Source != "digitalocean" {
-		t.Errorf("expected metadata source to be digitalocean, got: %s", meta.Source)
+	if meta.Source != "digitalocean-sizes-api" {
+		t.Errorf("expected metadata source to be digitalocean-sizes-api, got: %s", meta.Source)
 	}
 
 	if c := callCount(); c != 1 {
@@ -85,7 +91,7 @@ func TestNodePricing_APIMatches(t *testing.T) {
 	}
 }
 
-func TestNodePricing_Fallback(t *testing.T) {
+func TestNodePricing_S2(t *testing.T) {
 	provider, callCount := newTestProviderWithFile(t, "testdata/do_pricing.json")
 
 	key := &doksKey{
@@ -111,22 +117,21 @@ func TestNodePricing_Fallback(t *testing.T) {
 	}
 
 	assertEqual("Cost", node.Cost, "0.03571")
-	assertEqual("VCPUCost", node.VCPUCost, "0.00595")
-	assertEqual("RAMCost", node.RAMCost, "0.00595")
+	assertEqual("VCPUCost", node.VCPUCost, "0.01190")
+	assertEqual("RAMCost", node.RAMCost, "0.02381")
 	assertEqual("VCPU", node.VCPU, "2")
 	assertEqual("RAM", node.RAM, "4GiB")
 	assertEqual("ArchType", node.ArchType, "amd64")
 	assertEqual("PricingType", string(node.PricingType), string(models.DefaultPrices))
 
-	if meta.Source != "static-fallback" {
-		t.Errorf("expected metadata source to be static-fallback, got: %s", meta.Source)
+	if meta.Source != "digitalocean-sizes-api" {
+		t.Errorf("expected metadata source to be digitalocean-sizes-api, got: %s", meta.Source)
 	}
 
 	if c := callCount(); c != 1 {
 		t.Errorf("expected 1 API call, got %d", c)
 	}
 }
-
 func TestNodePricing_Estimation_C8Intel(t *testing.T) {
 	provider := newTestProviderWith404(t)
 
@@ -566,3 +571,53 @@ func TestNodePricing_Estimation_FamilySeeds(t *testing.T) {
 		})
 	}
 }
+
+func TestNodePricing_GPU(t *testing.T) {
+	provider, callCount := newTestProviderWithFile(t, "testdata/do_pricing.json")
+
+	key := &doksKey{
+		Labels: map[string]string{
+			"node.kubernetes.io/instance-type": "gpu-h100x1-80gb",
+			"kubernetes.io/arch":               "amd64",
+		},
+	}
+
+	// Verify key methods - might return defaults but shouldn't panic
+	if count := key.GPUCount(); count != 1 {
+		t.Errorf("expected GPUCount 1, got %d", count)
+	}
+	if gpuType := key.GPUType(); gpuType != "h100" {
+		t.Errorf("expected GPUType h100, got %s", gpuType)
+	}
+
+	node, meta, err := provider.NodePricing(key)
+	if err != nil {
+		t.Fatalf("expected no error, got: %v", err)
+	}
+
+	if node == nil {
+		t.Fatal("expected node pricing, got nil")
+	}
+
+	assertEqual := func(name, got, want string) {
+		if got != want {
+			t.Errorf("%s: got %s, want %s", name, got, want)
+		}
+	}
+
+	assertEqual("Cost", node.Cost, "3.39000")
+	assertEqual("VCPUCost", node.VCPUCost, "0.26077") // 3.39 * 20 / 260 = 0.260769...
+	assertEqual("RAMCost", node.RAMCost, "3.12923")   // 3.39 * 240 / 260 = 3.129230...
+	assertEqual("VCPU", node.VCPU, "20")
+	assertEqual("RAM", node.RAM, "240GiB")
+	assertEqual("GPU", node.GPU, "1")
+	assertEqual("GPUName", node.GPUName, "nvidia_h100")
+
+	if meta.Source != "digitalocean-sizes-api" {
+		t.Errorf("expected metadata source to be digitalocean-sizes-api, got: %s", meta.Source)
+	}
+
+	if c := callCount(); c != 1 {
+		t.Errorf("expected 1 API call, got %d", c)
+	}
+}

+ 96 - 25
pkg/cloud/digitalocean/testdata/do_pricing.json

@@ -1,30 +1,101 @@
 {
-  "products": [
-    {
-      "sku": "1-KS-K8SWN-00123",
-      "itemType": "K8S_WORKER_NODE",
-      "displayName": "Kubernetes Worker Node, General Purpose Droplets - 1 VCPU 2GB RAM",
-      "category": "IAAS",
-      "prices": [
+    "sizes": [
         {
-          "unit": "ITEM_PER_SECOND",
-          "rate": "0.00000333",
-          "currency": "USD",
-          "region": "global",
-          "minAmount": "0.01",
-          "maxAmount": "10.00",
-          "minUsage": "60",
-          "maxUsage": "2419200"
-        }
-      ],
-      "attributes": [
+            "slug": "s-1vcpu-2gb",
+            "memory": 2048,
+            "vcpus": 1,
+            "disk": 50,
+            "transfer": 2.0,
+            "price_monthly": 12,
+            "price_hourly": 0.01786,
+            "regions": [
+                "ams2",
+                "ams3",
+                "blr1",
+                "fra1",
+                "lon1",
+                "nyc1",
+                "nyc2",
+                "nyc3",
+                "sfo2",
+                "sfo3",
+                "sgp1",
+                "syd1",
+                "tor1"
+            ],
+            "available": true,
+            "description": "Basic"
+        },
+        {
+            "slug": "s-2vcpu-4gb",
+            "memory": 4096,
+            "vcpus": 2,
+            "disk": 80,
+            "transfer": 4.0,
+            "price_monthly": 24,
+            "price_hourly": 0.03571,
+            "regions": [
+                "ams2",
+                "ams3",
+                "blr1",
+                "fra1",
+                "lon1",
+                "nyc1",
+                "nyc2",
+                "nyc3",
+                "sfo2",
+                "sfo3",
+                "sgp1",
+                "syd1",
+                "tor1"
+            ],
+            "available": true,
+            "description": "Basic"
+        },
         {
-          "name": "size_id",
-          "value": "184",
-          "unit": "NO_UNIT"
+            "slug": "gpu-h100x1-80gb",
+            "memory": 245760,
+            "vcpus": 20,
+            "disk": 720,
+            "transfer": 15.0,
+            "price_monthly": 2522.16,
+            "price_hourly": 3.39,
+            "regions": [
+                "ams3",
+                "nyc2",
+                "tor1"
+            ],
+            "available": true,
+            "description": "H100 GPU - 1X",
+            "networking_throughput": 10000,
+            "gpu_info": {
+                "count": 1,
+                "vram": {
+                    "amount": 80,
+                    "unit": "gib"
+                },
+                "model": "nvidia_h100"
+            },
+            "disk_info": [
+                {
+                    "type": "local",
+                    "size": {
+                        "amount": 720,
+                        "unit": "gib"
+                    }
+                },
+                {
+                    "type": "scratch",
+                    "size": {
+                        "amount": 5120,
+                        "unit": "gib"
+                    }
+                }
+            ]
         }
-      ],
-      "effectiveAt": "2023-09-08T00:00:00Z"
+    ],
+    "links": {},
+    "meta": {
+        "total": 3
     }
-  ]
-}
+}

+ 1 - 1
pkg/cloud/provider/provider.go

@@ -225,7 +225,7 @@ func NewProvider(cache clustercache.ClusterCache, apiKey string, config *config.
 		return &digitalocean.DOKS{
 			Config:                NewProviderConfig(config, cp.configFileName),
 			Cache:                 digitalocean.NewPricingCache(),
-			Products:              make(map[string][]digitalocean.DOProduct),
+			Sizes:                 make(map[string]*digitalocean.DOSize),
 			Clientset:             cache,
 			ClusterManagementCost: 0.0,
 		}, nil

+ 14 - 4
pkg/env/costmodel.go

@@ -35,11 +35,12 @@ const (
 
 	AzureOfferIDEnvVar        = "AZURE_OFFER_ID"
 	AzureBillingAccountEnvVar = "AZURE_BILLING_ACCOUNT"
+	AzureLocaleEnvVar         = "AZURE_LOCALE"
+	AzureCurrencyEnvVar       = "AZURE_CURRENCY"
+	AzureRegionInfoEnvVar     = "AZURE_REGION_INFO"
 
+	DigitalOceanAccessTokenEnvVar = "DIGITALOCEAN_ACCESS_TOKEN"
 	// Azure rate card filter environment variables
-	AzureLocaleEnvVar     = "AZURE_LOCALE"
-	AzureCurrencyEnvVar   = "AZURE_CURRENCY"
-	AzureRegionInfoEnvVar = "AZURE_REGION_INFO"
 
 	// Currently being used for OCI and DigitalOcean
 	ProviderPricingURL = "PROVIDER_PRICING_URL"
@@ -401,7 +402,16 @@ func GetLocalCollectorDirectory() string {
 }
 
 func GetDOKSPricingURL() string {
-	return env.Get(ProviderPricingURL, "https://api.digitalocean.com/v2/billing/pricing")
+	return env.Get(ProviderPricingURL, "https://api.digitalocean.com/v2/sizes")
+}
+
+func GetDigitalOceanAccessToken() string {
+	// Try DIGITALOCEAN_ACCESS_TOKEN first, then fall back to CLOUD_PROVIDER_API_KEY
+	token := env.Get(DigitalOceanAccessTokenEnvVar, "")
+	if token == "" {
+		token = env.Get(CloudProviderAPIKeyEnvVar, "")
+	}
+	return token
 }
 
 func GetOVHSubsidiary() string {