ソースを参照

Add DigitalOcean Kubernetes Services provider (#3232)

Signed-off-by: Prateek Arora <1192prateek@gmail.com>
prateek1192 8 ヶ月 前
コミット
646ab6cb73

+ 5 - 0
configs/digitalocean.json

@@ -0,0 +1,5 @@
+{
+  "zoneNetworkEgress": "0.00",
+  "regionNetworkEgress": "0.00",
+  "internetNetworkEgress": "0.01"
+}

+ 5 - 0
core/pkg/opencost/assetprops.go

@@ -190,6 +190,9 @@ const OracleProvider = "Oracle"
 // OTCProvider describes the provider OTC
 const OTCProvider = "OTC"
 
+// DigitalOceanProvider describes the provider DigitalOcean
+const DigitalOceanProvider = "DigitalOcean"
+
 // NilProvider describes unknown provider
 const NilProvider = "-"
 
@@ -210,6 +213,8 @@ func ParseProvider(str string) string {
 		return ScalewayProvider
 	case "oci", "oracle":
 		return OracleProvider
+	case "digitalocean", "doks", "do":
+		return DigitalOceanProvider
 	default:
 		return NilProvider
 	}

+ 943 - 0
pkg/cloud/digitalocean/provider.go

@@ -0,0 +1,943 @@
+package digitalocean
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"math"
+	"net/http"
+	"regexp"
+	"sort"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/clustercache"
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/pkg/cloud/models"
+	"github.com/opencost/opencost/pkg/env"
+)
+
+const fallbackPVHourlyRate = 0.00015
+
+type DOKS struct {
+	PricingURL            string
+	Cache                 *PricingCache
+	Products              map[string][]DOProduct
+	Config                models.ProviderConfig
+	Clientset             clustercache.ClusterCache
+	ClusterManagementCost float64
+}
+
+type PricingCache struct {
+	data       *DOResponse
+	lastUpdate time.Time
+	mu         sync.Mutex
+}
+
+type DOResponse struct {
+	Products []DOProduct `json:"products"`
+}
+
+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"`
+}
+
+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"`
+}
+
+type DOAllowance struct {
+	Quantity    string `json:"quantity"`
+	Unit        string `json:"unit"`
+	AllowanceId string `json:"allowanceId"`
+	Schedule    string `json:"schedule"`
+}
+
+type DOAttribute struct {
+	Name  string `json:"name"`
+	Value string `json:"value"`
+	Unit  string `json:"unit"`
+}
+
+func NewDOKSProvider(pricingURL string) *DOKS {
+	return &DOKS{
+		PricingURL: pricingURL,
+		Cache:      &PricingCache{},
+		Products:   make(map[string][]DOProduct),
+	}
+}
+
+func NewPricingCache() *PricingCache {
+	return &PricingCache{
+		data:       nil,
+		lastUpdate: time.Time{},
+	}
+}
+
+func (do *DOKS) fetchPricingData() (*DOResponse, error) {
+	do.Cache.mu.Lock()
+	defer do.Cache.mu.Unlock()
+
+	// Return cached data if still valid
+	if do.Cache.data != nil && time.Since(do.Cache.lastUpdate) < time.Hour {
+		log.Debugf("Using cached pricing data (last updated: %v)", do.Cache.lastUpdate)
+		return do.Cache.data, nil
+	}
+
+	pricingURL := do.PricingURL
+	if pricingURL == "" {
+		pricingURL = env.GetDOKSPricingURL()
+	}
+	log.Infof("Fetching DigitalOcean pricing from: %s", pricingURL)
+
+	resp, err := http.Get(pricingURL)
+	if err != nil {
+		log.Warnf("Failed to fetch pricing from DigitalOcean: %v", err)
+		return nil, fmt.Errorf("pricing 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)
+	}
+
+	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)
+	}
+
+	// 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)
+	}
+
+	// Cache and return
+	do.Products = categorized
+	do.Cache.data = &data
+	do.Cache.lastUpdate = time.Now()
+
+	log.Infof("Successfully updated DigitalOcean pricing cache (%d products)", len(data.Products))
+	return do.Cache.data, nil
+}
+
+// DO Node
+type doksKey struct {
+	Labels     map[string]string
+	ProviderID string
+}
+
+func (do *DOKS) GetKey(labels map[string]string, n *clustercache.Node) models.Key {
+	var providerID string
+	if n != nil {
+		providerID = n.SpecProviderID
+		if providerID != "" {
+			labels["providerID"] = providerID
+		}
+
+		cpuQty := n.Status.Capacity["cpu"]
+		cpuCores := cpuQty.MilliValue() / 1000
+		labels["node.opencost.io/cpu"] = fmt.Sprintf("%d", cpuCores)
+		log.Debugf("Set label 'node.opencost.io/cpu' = %d", cpuCores)
+
+		memQty := n.Status.Capacity["memory"]
+		memGiB := int(math.Ceil(float64(memQty.Value()) / (1024 * 1024 * 1024)))
+		labels["node.opencost.io/ram"] = fmt.Sprintf("%d", memGiB)
+		log.Debugf("Set label 'node.opencost.io/ram' = %d", memGiB)
+
+	}
+
+	return &doksKey{
+		Labels:     labels,
+		ProviderID: providerID,
+	}
+}
+
+func (k *doksKey) ID() string {
+	if it, ok := k.Labels["node.kubernetes.io/instance-type"]; ok {
+		return it
+	}
+	if it, ok := k.Labels["beta.kubernetes.io/instance-type"]; ok {
+		return it
+	}
+	log.Debugf("doksKey: missing instance-type. Labels: %+v", k.Labels)
+	return ""
+}
+
+func (k *doksKey) Features() string {
+	features := map[string]string{}
+
+	for _, label := range []string{
+		"node.kubernetes.io/instance-type",
+		"beta.kubernetes.io/instance-type",
+		"kubernetes.io/arch",
+		"beta.kubernetes.io/arch",
+		"node.opencost.io/ram",
+		"node.opencost.io/cpu",
+	} {
+		if val, ok := k.Labels[label]; ok {
+			features[label] = val
+		}
+	}
+
+	var parts []string
+	for k, v := range features {
+		parts = append(parts, fmt.Sprintf("%s=%s", k, v))
+	}
+
+	sort.Strings(parts)
+	return strings.Join(parts, ",")
+}
+
+func (k *doksKey) GPUType() string {
+	return ""
+}
+
+func (k *doksKey) String() string {
+	if instanceType, ok := k.Labels["node.kubernetes.io/instance-type"]; ok {
+		return instanceType
+	}
+	if instanceType, ok := k.Labels["beta.kubernetes.io/instance-type"]; ok {
+		return instanceType
+	}
+	return ""
+}
+
+func (k *doksKey) GPUCount() int {
+	return 0
+}
+
+type SlugBase struct {
+	BaseSlug   string
+	BaseCost   float64
+	BaseVCPU   int
+	BaseRAMGiB int
+}
+
+type slugSeeds struct {
+	BaseVCPU    int
+	BaseHourly  float64
+	RamPerVCPU  int
+	IntelHourly float64
+}
+
+var slugFamilySeed = map[string]slugSeeds{
+	"c":     {BaseVCPU: 4, BaseHourly: 0.12500, RamPerVCPU: 2, IntelHourly: 0.16220},
+	"c2":    {BaseVCPU: 4, BaseHourly: 0.13988, RamPerVCPU: 2, IntelHourly: 0.18155},
+	"g":     {BaseVCPU: 4, BaseHourly: 0.18750, RamPerVCPU: 4, IntelHourly: 0.22470},
+	"gd":    {BaseVCPU: 4, BaseHourly: 0.20238, RamPerVCPU: 4, IntelHourly: 0.23512},
+	"m":     {BaseVCPU: 8, BaseHourly: 0.50000, RamPerVCPU: 8, IntelHourly: 0.58929},
+	"m3":    {BaseVCPU: 8, BaseHourly: 0.61905, RamPerVCPU: 8, IntelHourly: 0.65476},
+	"m6":    {BaseVCPU: 8, BaseHourly: 0.77976, RamPerVCPU: 8, IntelHourly: 0},
+	"s":     {BaseVCPU: 4, BaseHourly: 0.07143, RamPerVCPU: 2, IntelHourly: 0.08333},
+	"so":    {BaseVCPU: 8, BaseHourly: 0.77976, RamPerVCPU: 8, IntelHourly: 0.77976},
+	"so1_5": {BaseVCPU: 8, BaseHourly: 0.97024, RamPerVCPU: 8, IntelHourly: 0.82738},
+}
+
+// TODO Refine GPU pricing and move to GPU method once GPUs are fully GA
+var gpuHourly = map[string]float64{
+	"gpu-4000adax1-20gb": 0.76,
+	"gpu-6000adax1-48gb": 1.57,
+	"gpu-h100x1-80gb":    3.39,
+	"gpu-h100x8-640gb":   23.92,
+	"gpu-h200x1-141gb":   3.44,
+	"gpu-h200x8-1128gb":  27.52,
+	"gpu-l40sx1-48gb":    1.57,
+	"gpu-mi300x1-192gb":  1.99,
+	"gpu-mi300x8-1536gb": 15.92,
+}
+
+var (
+	reVCpu        = regexp.MustCompile(`(\d+)\s*vcpu`)
+	reRAM         = regexp.MustCompile(`(\d+)\s*gb`)
+	reSimpleCount = regexp.MustCompile(`^[a-z0-9_]+-(\d+)(?:-|$)`)
+)
+
+func extractResources(slug string) (int, int, bool) {
+	parts := strings.Split(slug, "-")
+
+	var vcpu, ram int
+	var foundVCPU, foundRAM bool
+
+	for _, part := range parts {
+		switch {
+		case strings.HasSuffix(part, "vcpu"):
+			v, err := strconv.Atoi(strings.TrimSuffix(part, "vcpu"))
+			if err == nil {
+				vcpu = v
+				foundVCPU = true
+			}
+		case strings.HasSuffix(part, "gb"):
+			v, err := strconv.Atoi(strings.TrimSuffix(part, "gb"))
+			if err == nil {
+				ram = v
+				foundRAM = true
+			}
+		default:
+			// Fallback case for just "8", "16", etc.
+			v, err := strconv.Atoi(part)
+			if err == nil {
+				if !foundVCPU {
+					vcpu = v
+					foundVCPU = true
+				} else if !foundRAM {
+					ram = v
+					foundRAM = true
+				}
+			}
+		}
+	}
+
+	// If vCPU found but not RAM, assume RAM is 2x vCPU, works for all c families
+	if foundVCPU && !foundRAM {
+		ram = 2 * vcpu
+		foundRAM = true
+	}
+
+	return vcpu, ram, foundVCPU && foundRAM
+}
+
+// Estimate cost based on slug pattern and scale from base slugs which are seeded
+func estimateCostFromSlug(slug string) (float64, int, int, bool) {
+	s := strings.ToLower(strings.TrimSpace(slug))
+
+	// GPUs are to be handled as a separate case
+	if strings.HasPrefix(s, "gpu-") {
+		if h, ok := gpuHourly[s]; ok {
+			vcpu, ram := extractVCpuRAMGuess(s, "", 0) // we don’t rely on these for pricing
+			return h, vcpu, ram, true
+		}
+		return 0, 0, 0, false
+	}
+
+	dashPosition := strings.IndexByte(s, '-')
+	if dashPosition <= 0 {
+		return 0, 0, 0, false
+	}
+	family := s[:dashPosition]
+	seed, ok := slugFamilySeed[family]
+	if !ok {
+		return 0, 0, 0, false
+	}
+
+	hasIntel := strings.Contains(s, "-intel")
+
+	vcpu, ramGiB := extractVCpuRAMGuess(s, family, seed.RamPerVCPU)
+	if vcpu == 0 {
+		return 0, 0, 0, false
+	}
+	if ramGiB == 0 && seed.RamPerVCPU > 0 {
+		ramGiB = seed.RamPerVCPU * vcpu
+	}
+	scale := float64(vcpu) / float64(seed.BaseVCPU)
+	hourly := seed.BaseHourly * scale
+
+	if hasIntel && seed.IntelHourly > 0 && seed.BaseHourly > 0 {
+		mult := seed.IntelHourly / seed.BaseHourly
+		hourly *= mult
+	}
+
+	return hourly, vcpu, ramGiB, true
+}
+
+// TODO Fix GPU Pricing after GA
+func extractVCpuRAMGuess(slugLower, family string, ramPerVCPU int) (vcpu int, ramGiB int) {
+	// Regex for matching CPU, we try to find CPU first
+	// If RAM not found, we can multiply VCPU by 2 to find it
+	if m := reVCpu.FindStringSubmatch(slugLower); len(m) == 2 {
+		if n, _ := strconv.Atoi(m[1]); n > 0 {
+			vcpu = n
+		}
+	}
+	if m := reRAM.FindStringSubmatch(slugLower); len(m) == 2 {
+		if n, _ := strconv.Atoi(m[1]); n > 0 {
+			ramGiB = n
+		}
+	}
+	if vcpu == 0 {
+		if m := reSimpleCount.FindStringSubmatch(slugLower); len(m) == 2 {
+			if n, _ := strconv.Atoi(m[1]); n > 0 {
+				vcpu = n
+			}
+		}
+	}
+
+	if ramGiB == 0 && vcpu > 0 && ramPerVCPU > 0 {
+		ramGiB = vcpu * ramPerVCPU
+	}
+	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
+	for _, part := range parts {
+		kv := strings.SplitN(part, "=", 2)
+		if len(kv) != 2 {
+			continue
+		}
+		switch kv[0] {
+		case "node.opencost.io/cpu":
+			val, err := strconv.Atoi(kv[1])
+			if err == nil {
+				cpu = val
+			}
+		case "node.opencost.io/ram":
+			val, err := strconv.Atoi(kv[1])
+			if err == nil {
+				ram = val
+			}
+		}
+	}
+
+	if cpu > 0 && ram > 0 {
+		return cpu, ram, nil
+	}
+	return 0, 0, fmt.Errorf("cpu or ram not found in features")
+}
+
+func (do *DOKS) NodePricing(key models.Key) (*models.Node, models.PricingMetadata, error) {
+	log.Debugf("Fetching DigitalOcean pricing data (key: %s)", key)
+
+	// Try fetching catalog; fallback is okay
+	_, err := do.fetchPricingData()
+	if err != nil {
+		log.Warnf("Failed to fetch catalog: %v. Will try estimation or fallback.", err)
+	}
+
+	arch := parseArch(key.Features())
+	slug := key.ID()
+
+	// Try parsing vCPU/RAM from labels
+	vcpu, ram, err := parseResources(key.Features())
+	if err != nil || vcpu == 0 || ram == 0 {
+		log.Infof("Failed to extract CPU/RAM from features. Trying slug: %s", slug)
+
+		var ok bool
+		// Try getting from slug (e.g., "s-2vcpu-4gb")
+		vcpu, ram, ok = extractResources(slug)
+		if !ok {
+			// Fallback: RAM = 2x CPU if CPU is known, cases like c-2
+			if vcpu > 0 {
+				ram = vcpu * 2
+				log.Warnf("Only CPU found. Assuming RAM = 2 * CPU → %dGiB", ram)
+			} else {
+				log.Warnf("Could not extract vCPU/RAM from features or slug. Returning fallback.")
+				return fallbackNode(slug)
+			}
+		}
+	}
+
+	// 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 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
+			}
+		}
+	}
+
+	log.Warnf("No matching product found for slug %s (vCPU: %d, RAM: %d), falling back", slug, vcpu, ram)
+	return fallbackNode(slug)
+}
+
+func parseArch(features string) string {
+	parts := strings.Split(features, ",")
+	for _, part := range parts {
+		pair := strings.SplitN(part, "=", 2)
+		if len(pair) == 2 && (pair[0] == "kubernetes.io/arch" || pair[0] == "beta.kubernetes.io/arch") {
+			return pair[1]
+		}
+	}
+	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)
+	}
+
+	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
+
+	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
+}
+
+func fallbackNode(slug string) (*models.Node, models.PricingMetadata, error) {
+	if cost, vcpu, ram, ok := estimateCostFromSlug(slug); ok {
+		totalUnits := float64(vcpu + ram)
+		if totalUnits == 0 {
+			return nil, models.PricingMetadata{
+				Currency: "USD",
+				Source:   "static-fallback",
+				Warnings: []string{"invalid vCPU and RAM (0) for fallback"},
+			}, fmt.Errorf("invalid fallback spec: totalUnits=0")
+		}
+
+		unitCost := cost / totalUnits
+
+		log.Infof("FallbackNode (estimated): %s , hourly=%.5f, vcpuUnit=%.5f, ramUnit=%.5f", slug, cost, unitCost, unitCost)
+
+		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),
+				InstanceType: slug,
+				Region:       "global",
+				UsageType:    "static-fallback",
+				PricingType:  models.DefaultPrices,
+				ArchType:     "amd64",
+			}, models.PricingMetadata{
+				Currency: "USD",
+				Source:   "static-fallback",
+				Warnings: []string{"used estimated fallback"},
+			}, nil
+	}
+
+	return nil, models.PricingMetadata{
+		Currency: "USD",
+		Source:   "none",
+		Warnings: []string{"no fallback available"},
+	}, fmt.Errorf("no fallback pricing for slug: %s", slug)
+}
+
+type doksPVKey struct {
+	id           string
+	storageClass string
+	sizeBytes    int64
+	ProviderID   string
+	region       string
+}
+
+func (k *doksPVKey) ID() string {
+	return k.ProviderID
+}
+
+func (k *doksPVKey) SizeGiB() int64 {
+	return k.sizeBytes / (1024 * 1024 * 1024)
+}
+
+// Features Only one type of PV
+func (k *doksPVKey) Features() string {
+	return ""
+}
+
+func (k *doksPVKey) GetStorageClass() string {
+	return k.storageClass
+}
+
+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)
+	}
+
+	k, ok := key.(*doksPVKey)
+	var sizeGB int64
+	if ok {
+		sizeGB = k.SizeGiB()
+	}
+
+	return &models.PV{
+		Cost:       fmt.Sprintf("%.5f", rate),
+		CostPerIO:  "0",
+		Class:      key.GetStorageClass(),
+		Size:       fmt.Sprintf("%d", sizeGB),
+		Region:     price.Region,
+		ProviderID: key.ID(),
+		Parameters: nil,
+	}, nil
+}
+
+func fallbackPV(key models.PVKey) (*models.PV, error) {
+	k, ok := key.(*doksPVKey)
+	var sizeGB int64
+	if ok {
+		sizeGB = k.SizeGiB()
+	}
+
+	region := "global"
+	if ok && k.region != "" {
+		region = k.region
+	}
+
+	log.Infof("Using fallback PV pricing: %.5f USD/GiB/hr | Class=%s | SizeGiB=%d | Region=%s | ID=%s",
+		fallbackPVHourlyRate, key.GetStorageClass(), sizeGB, region, key.ID())
+
+	return &models.PV{
+		Cost:       fmt.Sprintf("%.5f", fallbackPVHourlyRate),
+		CostPerIO:  "0",
+		Class:      key.GetStorageClass(),
+		Size:       fmt.Sprintf("%d", sizeGB),
+		Region:     region,
+		ProviderID: key.ID(),
+		Parameters: nil,
+	}, nil
+}
+
+// LoadBalancerPricing returns the hourly cost of a Load Balancer in DigitalOcean (DOKS).
+//
+// DigitalOcean offers multiple Load Balancers with different prices:
+//
+// - Public HTTP Load Balancer:           ~$0.01786/hr
+// - Private Network Load Balancer:      ~$0.02232/hr
+// - Public Network Load Balancer:       ~$0.02232/hr
+// - Statically sized Load Balancers:    $0.01786–$0.10714/hr
+//
+// However, the current OpenCost provider interface does not pass information about
+// individual Load Balancer characteristics (like annotations or network mode).
+//
+// As a result, this implementation uses a fixed average hourly rate of $0.02,
+// which is representative of the most common DO LBs.
+//
+// TODO Once the provider interface supports more granular Load Balancer metadata,
+// this method should be updated to assign costs more precisely.
+func (do *DOKS) LoadBalancerPricing() (*models.LoadBalancer, error) {
+	hourlyCost := 0.02
+	return &models.LoadBalancer{
+		Cost: hourlyCost,
+	}, nil
+}
+
+func (do *DOKS) NetworkPricing() (*models.Network, error) {
+	// fallback
+	const (
+		defaultZoneEgress     = 0.00
+		defaultRegionEgress   = 0.00
+		defaultInternetEgress = 0.01
+	)
+
+	log.Infof("NetworkPricing: retrieving custom pricing data")
+	cpricing, err := do.GetConfig()
+	if err != nil || isDefaultNetworkPricing(cpricing) {
+		log.Warnf("NetworkPricing: failed to load custom pricing data: %v", err)
+		log.Infof("NetworkPricing: using fallback network prices: zone=%.4f, region=%.4f, internet=%.4f",
+			defaultZoneEgress, defaultRegionEgress, defaultInternetEgress)
+		return &models.Network{
+			ZoneNetworkEgressCost:     defaultZoneEgress,
+			RegionNetworkEgressCost:   defaultRegionEgress,
+			InternetNetworkEgressCost: defaultInternetEgress,
+		}, nil
+	}
+
+	znec := parseWithDefault(cpricing.ZoneNetworkEgress, defaultZoneEgress, "ZoneNetworkEgress")
+	rnec := parseWithDefault(cpricing.RegionNetworkEgress, defaultRegionEgress, "RegionNetworkEgress")
+	inec := parseWithDefault(cpricing.InternetNetworkEgress, defaultInternetEgress, "InternetNetworkEgress")
+
+	log.Infof("NetworkPricing: using parsed values: zone=%.4f/GiB, region=%.4f/GiB, internet=%.4f/GIB", znec, rnec, inec)
+
+	return &models.Network{
+		ZoneNetworkEgressCost:     znec,
+		RegionNetworkEgressCost:   rnec,
+		InternetNetworkEgressCost: inec,
+	}, nil
+}
+
+func parseWithDefault(val string, fallback float64, label string) float64 {
+	if val == "" {
+		log.Warnf("NetworkPricing: missing value for %s, using fallback %.4f", label, fallback)
+		return fallback
+	}
+	parsed, err := strconv.ParseFloat(val, 64)
+	if err != nil {
+		log.Warnf("NetworkPricing: failed to parse %s='%s', using fallback %.4f", label, val, fallback)
+		return fallback
+	}
+	return parsed
+}
+
+func isDefaultNetworkPricing(cp *models.CustomPricing) bool {
+	return cp != nil &&
+		cp.ZoneNetworkEgress == "0.01" &&
+		cp.RegionNetworkEgress == "0.01" &&
+		cp.InternetNetworkEgress == "0.12"
+}
+
+func (do *DOKS) AllNodePricing() (interface{}, error) {
+	_, _ = do.fetchPricingData()
+	return do.Cache, nil
+}
+
+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]
+	key := &doksPVKey{
+		id:           product.SKU,
+		storageClass: "do-block-storage",
+	}
+
+	pv, err := do.PVPricing(key)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get PV pricing: %w", err)
+	}
+
+	return map[models.PVKey]*models.PV{
+		key: pv,
+	}, nil
+}
+
+func (do *DOKS) GetPVKey(pv *clustercache.PersistentVolume, parameters map[string]string, defaultRegion string) models.PVKey {
+	var storageClass string
+	if pv.Spec.StorageClassName != "" {
+		storageClass = pv.Spec.StorageClassName
+	}
+
+	var volumeHandle string
+	if pv.Spec.CSI != nil {
+		volumeHandle = pv.Spec.CSI.VolumeHandle
+	}
+
+	sizeBytes := pv.Spec.Capacity.Storage().Value()
+
+	// Region is in node affinity
+	region := defaultRegion
+	if pv.Spec.NodeAffinity != nil && pv.Spec.NodeAffinity.Required != nil {
+		for _, term := range pv.Spec.NodeAffinity.Required.NodeSelectorTerms {
+			for _, expr := range term.MatchExpressions {
+				if expr.Key == "region" && len(expr.Values) > 0 {
+					region = expr.Values[0]
+					break
+				}
+			}
+		}
+	}
+
+	return &doksPVKey{
+		id:           pv.Name,
+		storageClass: storageClass,
+		sizeBytes:    sizeBytes,
+		ProviderID:   volumeHandle,
+		region:       region,
+	}
+}
+
+func (do *DOKS) ClusterInfo() (map[string]string, error) {
+	return map[string]string{"provider": "digitalocean", "platform": "doks"}, nil
+}
+
+func (do *DOKS) GetAddresses() ([]byte, error) {
+	return nil, nil
+}
+
+func (do *DOKS) GetDisks() ([]byte, error) {
+	return nil, nil
+}
+
+func (do *DOKS) GetOrphanedResources() ([]models.OrphanedResource, error) {
+	return nil, nil
+}
+
+func (do *DOKS) GpuPricing(input map[string]string) (string, error) {
+	return "", nil
+}
+
+func (do *DOKS) DownloadPricingData() error {
+	_, err := do.fetchPricingData()
+	return err
+}
+
+func (do *DOKS) UpdateConfig(r io.Reader, updateType string) (*models.CustomPricing, error) {
+	return nil, nil
+}
+
+func (do *DOKS) UpdateConfigFromConfigMap(map[string]string) (*models.CustomPricing, error) {
+	return nil, nil
+}
+
+func (do *DOKS) GetConfig() (*models.CustomPricing, error) {
+	if do.Config == nil {
+		log.Errorf("DOKS: ProviderConfig is nil")
+		return nil, fmt.Errorf("provider config not available")
+	}
+
+	customPricing, err := do.Config.GetCustomPricingData()
+	if err != nil {
+		log.Errorf("DOKS: failed to get custom pricing data: %v", err)
+		return nil, err
+	}
+	return customPricing, nil
+}
+
+func (do *DOKS) GetManagementPlatform() (string, error) {
+	return "DOKS", nil
+}
+
+func (do *DOKS) ApplyReservedInstancePricing(map[string]*models.Node) {}
+
+func (do *DOKS) ServiceAccountStatus() *models.ServiceAccountStatus {
+	return &models.ServiceAccountStatus{}
+}
+
+func (do *DOKS) PricingSourceStatus() map[string]*models.PricingSource {
+	return map[string]*models.PricingSource{}
+}
+
+func (do *DOKS) ClusterManagementPricing() (string, float64, error) {
+	return "", 0, nil
+}
+
+func (do *DOKS) CombinedDiscountForNode(string, bool, float64, float64) float64 {
+	return 0
+}
+
+func (do *DOKS) Regions() []string {
+	return []string{"nyc1", "sfo3", "ams3"}
+}
+
+func (do *DOKS) PricingSourceSummary() interface{} {
+	return nil
+}
+
+func (do *DOKS) GetClusterManagementPricing() float64 {
+	return do.ClusterManagementCost
+}
+
+func (do *DOKS) CustomPricingEnabled() bool {
+	return false
+}

+ 568 - 0
pkg/cloud/digitalocean/provider_test.go

@@ -0,0 +1,568 @@
+package digitalocean
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"testing"
+
+	"github.com/opencost/opencost/pkg/cloud/models"
+)
+
+func newTestProviderWithFile(t *testing.T, filename string) (*DOKS, func() int) {
+	t.Helper()
+
+	data, err := os.ReadFile(filename)
+	if err != nil {
+		t.Fatalf("Failed to read file: %v", err)
+	}
+
+	var count int
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		count++
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write(data)
+	}))
+
+	t.Cleanup(server.Close)
+
+	provider := NewDOKSProvider(server.URL)
+	return provider, func() int { return count }
+}
+
+func newTestProviderWith404(t *testing.T) *DOKS {
+	t.Helper()
+
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusNotFound)
+	}))
+
+	t.Cleanup(server.Close)
+
+	provider := NewDOKSProvider(server.URL)
+	return provider
+}
+
+func TestNodePricing_APIMatches(t *testing.T) {
+	provider, callCount := newTestProviderWithFile(t, "testdata/do_pricing.json")
+
+	key := &doksKey{
+		Labels: map[string]string{
+			"node.kubernetes.io/instance-type": "s-1vcpu-2gb",
+			"kubernetes.io/arch":               "amd64",
+		},
+	}
+
+	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, "0.01199")
+	assertEqual("VCPUCost", node.VCPUCost, "0.00400") // 1/3
+	assertEqual("RAMCost", node.RAMCost, "0.00799")   // 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 c := callCount(); c != 1 {
+		t.Errorf("expected 1 API call, got %d", c)
+	}
+}
+
+func TestNodePricing_Fallback(t *testing.T) {
+	provider, callCount := newTestProviderWithFile(t, "testdata/do_pricing.json")
+
+	key := &doksKey{
+		Labels: map[string]string{
+			"node.kubernetes.io/instance-type": "s-2vcpu-4gb",
+			"kubernetes.io/arch":               "amd64",
+		},
+	}
+
+	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, "0.03571")
+	assertEqual("VCPUCost", node.VCPUCost, "0.00595")
+	assertEqual("RAMCost", node.RAMCost, "0.00595")
+	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 c := callCount(); c != 1 {
+		t.Errorf("expected 1 API call, got %d", c)
+	}
+}
+
+func TestNodePricing_Estimation_C8Intel(t *testing.T) {
+	provider := newTestProviderWith404(t)
+
+	key := &doksKey{
+		Labels: map[string]string{
+			"node.kubernetes.io/instance-type": "c-8-intel",
+			"kubernetes.io/arch":               "amd64",
+		},
+	}
+
+	node, meta, err := provider.NodePricing(key)
+	if err != nil {
+		t.Fatalf("expected no error, got: %v", err)
+	}
+
+	expectedCost := "0.32440"
+	expectedVCPUCost := "0.01352"
+	expectedRAMCost := "0.01352"
+
+	if node.Cost != expectedCost {
+		t.Errorf("Cost: got %s, want %s", node.Cost, expectedCost)
+	}
+	if node.VCPUCost != expectedVCPUCost {
+		t.Errorf("VCPUCost: got %s, want %s", node.VCPUCost, expectedVCPUCost)
+	}
+	if node.RAMCost != expectedRAMCost {
+		t.Errorf("RAMCost: got %s, want %s", node.RAMCost, expectedRAMCost)
+	}
+	if node.VCPU != "8" {
+		t.Errorf("VCPU: got %s, want 8", node.VCPU)
+	}
+	if node.RAM != "16GiB" {
+		t.Errorf("RAM: got %s, want 16GiB", node.RAM)
+	}
+	if meta.Source != "static-fallback" {
+		t.Errorf("expected metadata source to be estimated, got: %s", meta.Source)
+	}
+}
+
+func TestNodePricing_EstimationFromSlug(t *testing.T) {
+	tests := []struct {
+		name            string
+		slug            string
+		expectedVCPU    string
+		expectedRAM     string
+		expectedCost    string
+		expectedCPU     string
+		expectedRAMCost string
+	}{
+		{
+			name:            "s-4vcpu-8gb",
+			slug:            "s-4vcpu-8gb",
+			expectedVCPU:    "4",
+			expectedRAM:     "8GiB",
+			expectedCost:    "0.07143",
+			expectedCPU:     "0.00595",
+			expectedRAMCost: "0.00595",
+		},
+		{
+			name:            "m-8vcpu-64gb",
+			slug:            "m-8vcpu-64gb",
+			expectedVCPU:    "8",
+			expectedRAM:     "64GiB",
+			expectedCost:    "0.50000",
+			expectedCPU:     "0.00694",
+			expectedRAMCost: "0.00694",
+		},
+		{
+			name:            "g-4vcpu-16gb-intel",
+			slug:            "g-4vcpu-16gb-intel",
+			expectedVCPU:    "4",
+			expectedRAM:     "16GiB",
+			expectedCost:    "0.22470",
+			expectedCPU:     "0.01124",
+			expectedRAMCost: "0.01124",
+		},
+	}
+
+	provider := newTestProviderWith404(t) // Force fallback/estimate
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			key := &doksKey{
+				Labels: map[string]string{
+					"node.kubernetes.io/instance-type": tc.slug,
+					"kubernetes.io/arch":               "amd64",
+				},
+			}
+
+			node, meta, err := provider.NodePricing(key)
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+
+			if node == nil {
+				t.Fatal("expected node to be non-nil")
+			}
+
+			assertEqual := func(field, got, want string) {
+				if got != want {
+					t.Errorf("%s: got %s, want %s", field, got, want)
+				}
+			}
+
+			assertEqual("Cost", node.Cost, tc.expectedCost)
+			assertEqual("VCPUCost", node.VCPUCost, tc.expectedCPU)
+			assertEqual("RAMCost", node.RAMCost, tc.expectedRAMCost)
+			assertEqual("VCPU", node.VCPU, tc.expectedVCPU)
+			assertEqual("RAM", node.RAM, tc.expectedRAM)
+			assertEqual("ArchType", node.ArchType, "amd64")
+
+			if meta.Source != "static-fallback" {
+				t.Errorf("expected metadata source to be 'estimated', got: %s", meta.Source)
+			}
+		})
+	}
+}
+
+func TestNodePricing_Estimation_BaseSlugs(t *testing.T) {
+	tests := []struct {
+		name            string
+		slug            string
+		expectedVCPU    string
+		expectedRAM     string
+		expectedCost    string
+		expectedCPU     string
+		expectedRAMCost string
+	}{
+		{
+			name:            "c-8-intel",
+			slug:            "c-8-intel",
+			expectedVCPU:    "8",
+			expectedRAM:     "16GiB",
+			expectedCost:    "0.32440",
+			expectedCPU:     "0.01352",
+			expectedRAMCost: "0.01352",
+		},
+		{
+			name:            "s-2vcpu-4gb",
+			slug:            "s-2vcpu-4gb",
+			expectedVCPU:    "2",
+			expectedRAM:     "4GiB",
+			expectedCost:    "0.03571",
+			expectedCPU:     "0.00595",
+			expectedRAMCost: "0.00595",
+		},
+		{
+			name:            "m-4vcpu-32gb",
+			slug:            "m-4vcpu-32gb",
+			expectedVCPU:    "4",
+			expectedRAM:     "32GiB",
+			expectedCost:    "0.25000",
+			expectedCPU:     "0.00694",
+			expectedRAMCost: "0.00694",
+		},
+		{
+			name:            "g-16vcpu-64gb-intel",
+			slug:            "g-16vcpu-64gb-intel",
+			expectedVCPU:    "16",
+			expectedRAM:     "64GiB",
+			expectedCost:    "0.89880",
+			expectedCPU:     "0.01124",
+			expectedRAMCost: "0.01124",
+		},
+	}
+
+	provider := newTestProviderWith404(t) // ensures fallback path is tested
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			key := &doksKey{
+				Labels: map[string]string{
+					"node.kubernetes.io/instance-type": tc.slug,
+					"kubernetes.io/arch":               "amd64",
+				},
+			}
+
+			node, meta, err := provider.NodePricing(key)
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+
+			if node == nil {
+				t.Fatal("expected node to be non-nil")
+			}
+
+			assertEqual := func(field, got, want string) {
+				if got != want {
+					t.Errorf("%s: got %s, want %s", field, got, want)
+				}
+			}
+
+			assertEqual("Cost", node.Cost, tc.expectedCost)
+			assertEqual("VCPUCost", node.VCPUCost, tc.expectedCPU)
+			assertEqual("RAMCost", node.RAMCost, tc.expectedRAMCost)
+			assertEqual("VCPU", node.VCPU, tc.expectedVCPU)
+			assertEqual("RAM", node.RAM, tc.expectedRAM)
+			assertEqual("ArchType", node.ArchType, "amd64")
+
+			if meta.Source != "static-fallback" {
+				t.Errorf("expected metadata source to be 'static-fallback', got: %s", meta.Source)
+			}
+		})
+	}
+}
+
+func TestNodePricing_Estimation_FamilySeeds(t *testing.T) {
+	tests := []struct {
+		name            string
+		slug            string
+		expectedVCPU    string
+		expectedRAM     string
+		expectedCost    string
+		expectedCPU     string
+		expectedRAMCost string
+	}{
+		{
+			name:            "c-16",
+			slug:            "c-16",
+			expectedVCPU:    "16",
+			expectedRAM:     "32GiB",
+			expectedCost:    "0.50000",
+			expectedCPU:     "0.01042",
+			expectedRAMCost: "0.01042",
+		},
+		{
+			name:            "c-16-intel",
+			slug:            "c-16-intel",
+			expectedVCPU:    "16",
+			expectedRAM:     "32GiB",
+			expectedCost:    "0.64880",
+			expectedCPU:     "0.01352",
+			expectedRAMCost: "0.01352",
+		},
+
+		{
+			name:            "c2-8vcpu-16gb",
+			slug:            "c2-8vcpu-16gb",
+			expectedVCPU:    "8",
+			expectedRAM:     "16GiB",
+			expectedCost:    "0.27976",
+			expectedCPU:     "0.01166",
+			expectedRAMCost: "0.01166",
+		},
+		{
+			name:            "c2-8vcpu-16gb-intel",
+			slug:            "c2-8vcpu-16gb-intel",
+			expectedVCPU:    "8",
+			expectedRAM:     "16GiB",
+			expectedCost:    "0.36310",
+			expectedCPU:     "0.01513",
+			expectedRAMCost: "0.01513",
+		},
+		{
+			name:            "g-8vcpu-32gb",
+			slug:            "g-8vcpu-32gb",
+			expectedVCPU:    "8",
+			expectedRAM:     "32GiB",
+			expectedCost:    "0.37500",
+			expectedCPU:     "0.00937",
+			expectedRAMCost: "0.00937",
+		},
+		{
+			name:            "g-8vcpu-32gb-intel",
+			slug:            "g-8vcpu-32gb-intel",
+			expectedVCPU:    "8",
+			expectedRAM:     "32GiB",
+			expectedCost:    "0.44940",
+			expectedCPU:     "0.01124",
+			expectedRAMCost: "0.01124",
+		},
+		{
+			name:            "gd-40vcpu-160gb",
+			slug:            "gd-40vcpu-160gb",
+			expectedVCPU:    "40",
+			expectedRAM:     "160GiB",
+			expectedCost:    "2.02380",
+			expectedCPU:     "0.01012",
+			expectedRAMCost: "0.01012",
+		},
+		{
+			name:            "gd-16vcpu-64gb-intel",
+			slug:            "gd-16vcpu-64gb-intel",
+			expectedVCPU:    "16",
+			expectedRAM:     "64GiB",
+			expectedCost:    "0.94048",
+			expectedCPU:     "0.01176",
+			expectedRAMCost: "0.01176",
+		},
+		{
+			name:            "m-16vcpu-128gb",
+			slug:            "m-16vcpu-128gb",
+			expectedVCPU:    "16",
+			expectedRAM:     "128GiB",
+			expectedCost:    "1.00000",
+			expectedCPU:     "0.00694",
+			expectedRAMCost: "0.00694",
+		},
+		{
+			name:            "m-16vcpu-128gb-intel",
+			slug:            "m-16vcpu-128gb-intel",
+			expectedVCPU:    "16",
+			expectedRAM:     "128GiB",
+			expectedCost:    "1.17858",
+			expectedCPU:     "0.00818",
+			expectedRAMCost: "0.00818",
+		},
+
+		// m3
+		{
+			name:            "m3-8vcpu-64gb",
+			slug:            "m3-8vcpu-64gb",
+			expectedVCPU:    "8",
+			expectedRAM:     "64GiB",
+			expectedCost:    "0.61905",
+			expectedCPU:     "0.00860",
+			expectedRAMCost: "0.00860",
+		},
+		{
+			name:            "m3-32vcpu-256gb-intel",
+			slug:            "m3-32vcpu-256gb-intel",
+			expectedVCPU:    "32",
+			expectedRAM:     "256GiB",
+			expectedCost:    "2.61904",
+			expectedCPU:     "0.00909",
+			expectedRAMCost: "0.00909",
+		},
+		{
+			name:            "m6-8vcpu-64gb",
+			slug:            "m6-8vcpu-64gb",
+			expectedVCPU:    "8",
+			expectedRAM:     "64GiB",
+			expectedCost:    "0.77976",
+			expectedCPU:     "0.01083",
+			expectedRAMCost: "0.01083",
+		},
+		{
+			name:            "m6-24vcpu-192gb",
+			slug:            "m6-24vcpu-192gb",
+			expectedVCPU:    "24",
+			expectedRAM:     "192GiB",
+			expectedCost:    "2.33928",
+			expectedCPU:     "0.01083",
+			expectedRAMCost: "0.01083",
+		},
+		{
+			name:            "s-1vcpu-2gb",
+			slug:            "s-1vcpu-2gb",
+			expectedVCPU:    "1",
+			expectedRAM:     "2GiB",
+			expectedCost:    "0.01786",
+			expectedCPU:     "0.00595",
+			expectedRAMCost: "0.00595",
+		},
+		{
+			name:            "s-8vcpu-16gb-intel",
+			slug:            "s-8vcpu-16gb-intel",
+			expectedVCPU:    "8",
+			expectedRAM:     "16GiB",
+			expectedCost:    "0.16666",
+			expectedCPU:     "0.00694",
+			expectedRAMCost: "0.00694",
+		},
+		{
+			name:            "so-8vcpu-64gb",
+			slug:            "so-8vcpu-64gb",
+			expectedVCPU:    "8",
+			expectedRAM:     "64GiB",
+			expectedCost:    "0.77976",
+			expectedCPU:     "0.01083",
+			expectedRAMCost: "0.01083",
+		},
+		{
+			name:            "so-8vcpu-64gb-intel",
+			slug:            "so-8vcpu-64gb-intel",
+			expectedVCPU:    "8",
+			expectedRAM:     "64GiB",
+			expectedCost:    "0.77976",
+			expectedCPU:     "0.01083",
+			expectedRAMCost: "0.01083",
+		},
+		{
+			name:            "so1_5-8vcpu-64gb",
+			slug:            "so1_5-8vcpu-64gb",
+			expectedVCPU:    "8",
+			expectedRAM:     "64GiB",
+			expectedCost:    "0.97024",
+			expectedCPU:     "0.01348",
+			expectedRAMCost: "0.01348",
+		},
+		{
+			name:            "so1_5-8vcpu-64gb-intel",
+			slug:            "so1_5-8vcpu-64gb-intel",
+			expectedVCPU:    "8",
+			expectedRAM:     "64GiB",
+			expectedCost:    "0.82738",
+			expectedCPU:     "0.01149",
+			expectedRAMCost: "0.01149",
+		},
+	}
+
+	provider := newTestProviderWith404(t)
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			key := &doksKey{
+				Labels: map[string]string{
+					"node.kubernetes.io/instance-type": tc.slug,
+					"kubernetes.io/arch":               "amd64",
+				},
+			}
+
+			node, meta, err := provider.NodePricing(key)
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if node == nil {
+				t.Fatal("expected node to be non-nil")
+			}
+
+			assertEqual := func(field, got, want string) {
+				if got != want {
+					t.Errorf("%s: got %s, want %s", field, got, want)
+				}
+			}
+
+			assertEqual("Cost", node.Cost, tc.expectedCost)
+			assertEqual("VCPUCost", node.VCPUCost, tc.expectedCPU)
+			assertEqual("RAMCost", node.RAMCost, tc.expectedRAMCost)
+			assertEqual("VCPU", node.VCPU, tc.expectedVCPU)
+			assertEqual("RAM", node.RAM, tc.expectedRAM)
+			assertEqual("ArchType", node.ArchType, "amd64")
+
+			if meta.Source != "static-fallback" {
+				t.Errorf("expected metadata source to be 'static-fallback', got: %s", meta.Source)
+			}
+		})
+	}
+}

+ 30 - 0
pkg/cloud/digitalocean/testdata/do_pricing.json

@@ -0,0 +1,30 @@
+{
+  "products": [
+    {
+      "sku": "1-KS-K8SWN-00123",
+      "itemType": "K8S_WORKER_NODE",
+      "displayName": "Kubernetes Worker Node, General Purpose Droplets - 1 VCPU 2GB RAM",
+      "category": "IAAS",
+      "prices": [
+        {
+          "unit": "ITEM_PER_SECOND",
+          "rate": "0.00000333",
+          "currency": "USD",
+          "region": "global",
+          "minAmount": "0.01",
+          "maxAmount": "10.00",
+          "minUsage": "60",
+          "maxUsage": "2419200"
+        }
+      ],
+      "attributes": [
+        {
+          "name": "size_id",
+          "value": "184",
+          "unit": "NO_UNIT"
+        }
+      ],
+      "effectiveAt": "2023-09-08T00:00:00Z"
+    }
+  ]
+}

+ 14 - 0
pkg/cloud/provider/provider.go

@@ -15,6 +15,7 @@ import (
 	"github.com/opencost/opencost/pkg/cloud/alibaba"
 	"github.com/opencost/opencost/pkg/cloud/aws"
 	"github.com/opencost/opencost/pkg/cloud/azure"
+	"github.com/opencost/opencost/pkg/cloud/digitalocean"
 	"github.com/opencost/opencost/pkg/cloud/gcp"
 	"github.com/opencost/opencost/pkg/cloud/models"
 	"github.com/opencost/opencost/pkg/cloud/oracle"
@@ -215,6 +216,15 @@ func NewProvider(cache clustercache.ClusterCache, apiKey string, config *config.
 			Config:        NewProviderConfig(config, cp.configFileName),
 			ClusterRegion: cp.region,
 		}, nil
+	case opencost.DigitalOceanProvider:
+		log.Info("Detected DigitalOcean, using DOKS")
+		return &digitalocean.DOKS{
+			Config:                NewProviderConfig(config, cp.configFileName),
+			Cache:                 digitalocean.NewPricingCache(),
+			Products:              make(map[string][]digitalocean.DOProduct),
+			Clientset:             cache,
+			ClusterManagementCost: 0.0,
+		}, nil
 	default:
 		log.Info("Unsupported provider, falling back to default")
 		return &CustomProvider{
@@ -290,6 +300,10 @@ func getClusterProperties(node *clustercache.Node) clusterProperties {
 		log.Debug("using OTC provider")
 		cp.provider = opencost.OTCProvider
 		cp.configFileName = "otc.json"
+	} else if strings.HasPrefix(providerID, "digitalocean") {
+		log.Debug("using DigitalOcean provider")
+		cp.provider = opencost.DigitalOceanProvider
+		cp.configFileName = "digitalocean.json"
 	}
 	// Override provider to CSV if CSVProvider is used and custom provider is not set
 	if env.IsUseCSVProvider() {

+ 8 - 2
pkg/env/costmodel.go

@@ -32,7 +32,8 @@ const (
 	AzureOfferIDEnvVar        = "AZURE_OFFER_ID"
 	AzureBillingAccountEnvVar = "AZURE_BILLING_ACCOUNT"
 
-	OCIPricingURL = "OCI_PRICING_URL"
+	// Currently being used for OCI and DigitalOcean
+	ProviderPricingURL = "PROVIDER_PRICING_URL"
 
 	ClusterProfileEnvVar    = "CLUSTER_PROFILE"
 	RemoteEnabledEnvVar     = "REMOTE_WRITE_ENABLED"
@@ -344,7 +345,7 @@ func IsKubernetesEnabled() bool {
 }
 
 func GetOCIPricingURL() string {
-	return env.Get(OCIPricingURL, "https://apexapps.oracle.com/pls/apex/cetools/api/v1/products")
+	return env.Get(ProviderPricingURL, "https://apexapps.oracle.com/pls/apex/cetools/api/v1/products")
 }
 
 func IsCarbonEstimatesEnabled() bool {
@@ -372,4 +373,9 @@ func GetMetricConfigFile() string {
 func GetLocalCollectorDirectory() string {
 	dir := env.Get(LocalCollectorDirectoryEnvVar, DefaultLocalCollectorDir)
 	return env.GetPathFromConfig(dir)
+
+}
+
+func GetDOKSPricingURL() string {
+	return env.Get(ProviderPricingURL, "https://api.digitalocean.com/v2/billing/pricing")
 }