provider.go 27 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010
  1. package digitalocean
  2. import (
  3. "context"
  4. "fmt"
  5. "io"
  6. "math"
  7. "regexp"
  8. "sort"
  9. "strconv"
  10. "strings"
  11. "sync"
  12. "time"
  13. "github.com/digitalocean/godo"
  14. "github.com/opencost/opencost/core/pkg/clustercache"
  15. "github.com/opencost/opencost/core/pkg/log"
  16. "github.com/opencost/opencost/pkg/cloud/models"
  17. "github.com/opencost/opencost/pkg/env"
  18. )
  19. // DigitalOcean Volumes Block Storage: $0.10/GiB/month
  20. // Converting to hourly: $0.10 / 730 hours (average month) ≈ $0.000137/GiB/hour
  21. const doVolumeHourlyRatePerGiB = 0.000137
  22. // Legacy fallback rate (kept for compatibility)
  23. const fallbackPVHourlyRate = 0.00015
  24. type DOKS struct {
  25. PricingURL string
  26. Cache *PricingCache
  27. Sizes map[string]*DOSize
  28. Config models.ProviderConfig
  29. Clientset clustercache.ClusterCache
  30. ClusterManagementCost float64
  31. client *godo.Client // DigitalOcean API client for fetching sizes
  32. }
  33. type PricingCache struct {
  34. data *DOResponse
  35. lastUpdate time.Time
  36. mu sync.Mutex
  37. }
  38. // DOResponse represents the response from DigitalOcean's /v2/sizes API
  39. type DOResponse struct {
  40. Sizes []DOSize `json:"sizes"`
  41. Links DOLinks `json:"links,omitempty"`
  42. Meta DOMeta `json:"meta,omitempty"`
  43. }
  44. // DOSize represents a DigitalOcean Droplet size
  45. type DOSize struct {
  46. Slug string `json:"slug"`
  47. Memory int `json:"memory"` // Memory in MB
  48. VCPUs int `json:"vcpus"`
  49. Disk int `json:"disk"` // Disk in GB
  50. Transfer float64 `json:"transfer"` // Transfer in TB
  51. PriceMonthly float64 `json:"price_monthly"` // Monthly price in USD
  52. PriceHourly float64 `json:"price_hourly"` // Hourly price in USD
  53. Regions []string `json:"regions"`
  54. Available bool `json:"available"`
  55. Description string `json:"description"`
  56. DiskInfo []DODiskInfo `json:"disk_info,omitempty"`
  57. GPUInfo DOGPUInfo `json:"gpu_info,omitempty"`
  58. }
  59. // DODiskInfo represents disk information for a DigitalOcean size
  60. type DODiskInfo struct {
  61. Type string `json:"type"`
  62. Size DODiskSize `json:"size"`
  63. }
  64. // DOGPUInfo represents GPU information for a DigitalOcean size
  65. type DOGPUInfo struct {
  66. Count int `json:"count"`
  67. VRAM DOGPUVRAM `json:"vram"`
  68. Model string `json:"model"`
  69. }
  70. // DOGPUVRAM represents GPU VRAM details
  71. type DOGPUVRAM struct {
  72. Amount int `json:"amount"`
  73. Unit string `json:"unit"`
  74. }
  75. // DODiskSize represents disk size details
  76. type DODiskSize struct {
  77. Amount int `json:"amount"`
  78. Unit string `json:"unit"`
  79. }
  80. // DOLinks represents pagination links
  81. type DOLinks struct {
  82. Pages DOPages `json:"pages,omitempty"`
  83. }
  84. // DOPages represents pagination page links
  85. type DOPages struct {
  86. First string `json:"first,omitempty"`
  87. Prev string `json:"prev,omitempty"`
  88. Next string `json:"next,omitempty"`
  89. Last string `json:"last,omitempty"`
  90. }
  91. // DOMeta represents metadata about the response
  92. type DOMeta struct {
  93. Total int `json:"total"`
  94. }
  95. func NewDOKSProvider(pricingURL string) *DOKS {
  96. // Create a godo client for API requests
  97. var client *godo.Client
  98. token := env.GetDigitalOceanAccessToken()
  99. if token != "" {
  100. client = godo.NewFromToken(token)
  101. }
  102. return &DOKS{
  103. PricingURL: pricingURL,
  104. Cache: &PricingCache{},
  105. Sizes: make(map[string]*DOSize),
  106. client: client,
  107. }
  108. }
  109. func NewPricingCache() *PricingCache {
  110. return &PricingCache{
  111. data: nil,
  112. lastUpdate: time.Time{},
  113. }
  114. }
  115. func (do *DOKS) fetchPricingData() (*DOResponse, error) {
  116. do.Cache.mu.Lock()
  117. defer do.Cache.mu.Unlock()
  118. // Return cached data if still valid
  119. if do.Cache.data != nil && time.Since(do.Cache.lastUpdate) < time.Hour {
  120. log.Debugf("Using cached pricing data (last updated: %v)", do.Cache.lastUpdate)
  121. return do.Cache.data, nil
  122. }
  123. // Check if godo client is available
  124. if do.client == nil {
  125. log.Errorf("DigitalOcean API client is not initialized. Set DIGITALOCEAN_ACCESS_TOKEN or CLOUD_PROVIDER_API_KEY before creating the provider")
  126. return nil, fmt.Errorf("digitalocean client not initialized: set DIGITALOCEAN_ACCESS_TOKEN or CLOUD_PROVIDER_API_KEY before provider initialization")
  127. }
  128. log.Infof("Fetching DigitalOcean sizes using godo client")
  129. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
  130. defer cancel()
  131. // Fetch all sizes with pagination
  132. var allSizes []godo.Size
  133. opt := &godo.ListOptions{}
  134. for {
  135. sizes, resp, err := do.client.Sizes.List(ctx, opt)
  136. if err != nil {
  137. log.Warnf("Failed to fetch sizes from DigitalOcean: %v", err)
  138. return nil, fmt.Errorf("sizes API fetch error: %w", err)
  139. }
  140. // Append current page's sizes to our list
  141. allSizes = append(allSizes, sizes...)
  142. log.Debugf("Fetched page %d with %d sizes", opt.Page, len(sizes))
  143. // Check if we're at the last page
  144. if resp.Links == nil || resp.Links.IsLastPage() {
  145. break
  146. }
  147. // Get the next page number
  148. page, err := resp.Links.CurrentPage()
  149. if err != nil {
  150. log.Warnf("Failed to get current page number: %v", err)
  151. return nil, fmt.Errorf("sizes API pagination: %w", err)
  152. }
  153. // Set the page for the next request
  154. opt.Page = page + 1
  155. }
  156. // Index sizes by slug for quick lookup
  157. sizesMap := make(map[string]*DOSize)
  158. cachedResponse := &DOResponse{
  159. Sizes: make([]DOSize, 0, len(allSizes)),
  160. }
  161. for _, godoSize := range allSizes {
  162. doSize := &DOSize{
  163. Slug: godoSize.Slug,
  164. Memory: godoSize.Memory,
  165. VCPUs: godoSize.Vcpus,
  166. Disk: godoSize.Disk,
  167. Transfer: godoSize.Transfer,
  168. PriceMonthly: godoSize.PriceMonthly,
  169. PriceHourly: godoSize.PriceHourly,
  170. Regions: godoSize.Regions,
  171. Available: godoSize.Available,
  172. Description: godoSize.Description,
  173. }
  174. // Convert GPU info if present
  175. if godoSize.GPUInfo != nil {
  176. doSize.GPUInfo = DOGPUInfo{
  177. Count: godoSize.GPUInfo.Count,
  178. Model: godoSize.GPUInfo.Model,
  179. }
  180. if godoSize.GPUInfo.VRAM != nil {
  181. doSize.GPUInfo.VRAM = DOGPUVRAM{
  182. Amount: godoSize.GPUInfo.VRAM.Amount,
  183. Unit: godoSize.GPUInfo.VRAM.Unit,
  184. }
  185. }
  186. }
  187. sizesMap[doSize.Slug] = doSize
  188. cachedResponse.Sizes = append(cachedResponse.Sizes, *doSize)
  189. log.Debugf("Indexing size: Slug=%s, VCPUs=%d, Memory=%dMB, PriceHourly=$%.5f",
  190. doSize.Slug, doSize.VCPUs, doSize.Memory, doSize.PriceHourly)
  191. }
  192. // Cache and return
  193. do.Sizes = sizesMap
  194. do.Cache.data = cachedResponse
  195. do.Cache.lastUpdate = time.Now()
  196. log.Infof("Successfully updated DigitalOcean pricing cache (%d sizes)", len(allSizes))
  197. return do.Cache.data, nil
  198. }
  199. // DO Node
  200. type doksKey struct {
  201. Labels map[string]string
  202. ProviderID string
  203. }
  204. func (do *DOKS) GetKey(labels map[string]string, n *clustercache.Node) models.Key {
  205. var providerID string
  206. if n != nil {
  207. providerID = n.SpecProviderID
  208. if providerID != "" {
  209. labels["providerID"] = providerID
  210. }
  211. cpuQty := n.Status.Capacity["cpu"]
  212. cpuCores := cpuQty.MilliValue() / 1000
  213. labels["node.opencost.io/cpu"] = fmt.Sprintf("%d", cpuCores)
  214. log.Debugf("Set label 'node.opencost.io/cpu' = %d", cpuCores)
  215. memQty := n.Status.Capacity["memory"]
  216. memGiB := int(math.Ceil(float64(memQty.Value()) / (1024 * 1024 * 1024)))
  217. labels["node.opencost.io/ram"] = fmt.Sprintf("%d", memGiB)
  218. log.Debugf("Set label 'node.opencost.io/ram' = %d", memGiB)
  219. }
  220. return &doksKey{
  221. Labels: labels,
  222. ProviderID: providerID,
  223. }
  224. }
  225. func (k *doksKey) ID() string {
  226. if it, ok := k.Labels["node.kubernetes.io/instance-type"]; ok {
  227. return it
  228. }
  229. if it, ok := k.Labels["beta.kubernetes.io/instance-type"]; ok {
  230. return it
  231. }
  232. log.Debugf("doksKey: missing instance-type. Labels: %+v", k.Labels)
  233. return ""
  234. }
  235. func (k *doksKey) Features() string {
  236. features := map[string]string{}
  237. for _, label := range []string{
  238. "node.kubernetes.io/instance-type",
  239. "beta.kubernetes.io/instance-type",
  240. "kubernetes.io/arch",
  241. "beta.kubernetes.io/arch",
  242. "node.opencost.io/ram",
  243. "node.opencost.io/cpu",
  244. } {
  245. if val, ok := k.Labels[label]; ok {
  246. features[label] = val
  247. }
  248. }
  249. var parts []string
  250. for k, v := range features {
  251. parts = append(parts, fmt.Sprintf("%s=%s", k, v))
  252. }
  253. sort.Strings(parts)
  254. return strings.Join(parts, ",")
  255. }
  256. func (k *doksKey) GPUType() string {
  257. t := k.ID()
  258. if t != "" && strings.HasPrefix(t, "gpu-") {
  259. parts := strings.Split(t, "-")
  260. if len(parts) >= 2 {
  261. modelParts := strings.Split(parts[1], "x")
  262. return modelParts[0]
  263. }
  264. }
  265. return ""
  266. }
  267. func (k *doksKey) String() string {
  268. if instanceType, ok := k.Labels["node.kubernetes.io/instance-type"]; ok {
  269. return instanceType
  270. }
  271. if instanceType, ok := k.Labels["beta.kubernetes.io/instance-type"]; ok {
  272. return instanceType
  273. }
  274. return ""
  275. }
  276. func (k *doksKey) GPUCount() int {
  277. t := k.ID()
  278. if t != "" && strings.HasPrefix(t, "gpu-") {
  279. matches := reGPUCount.FindStringSubmatch(t)
  280. if len(matches) == 2 {
  281. count, err := strconv.Atoi(matches[1])
  282. if err == nil {
  283. return count
  284. }
  285. }
  286. return 1
  287. }
  288. return 0
  289. }
  290. type SlugBase struct {
  291. BaseSlug string
  292. BaseCost float64
  293. BaseVCPU int
  294. BaseRAMGiB int
  295. }
  296. type slugSeeds struct {
  297. BaseVCPU int
  298. BaseHourly float64
  299. RamPerVCPU int
  300. IntelHourly float64
  301. }
  302. var slugFamilySeed = map[string]slugSeeds{
  303. "c": {BaseVCPU: 4, BaseHourly: 0.12500, RamPerVCPU: 2, IntelHourly: 0.16220},
  304. "c2": {BaseVCPU: 4, BaseHourly: 0.13988, RamPerVCPU: 2, IntelHourly: 0.18155},
  305. "g": {BaseVCPU: 4, BaseHourly: 0.18750, RamPerVCPU: 4, IntelHourly: 0.22470},
  306. "gd": {BaseVCPU: 4, BaseHourly: 0.20238, RamPerVCPU: 4, IntelHourly: 0.23512},
  307. "m": {BaseVCPU: 8, BaseHourly: 0.50000, RamPerVCPU: 8, IntelHourly: 0.58929},
  308. "m3": {BaseVCPU: 8, BaseHourly: 0.61905, RamPerVCPU: 8, IntelHourly: 0.65476},
  309. "m6": {BaseVCPU: 8, BaseHourly: 0.77976, RamPerVCPU: 8, IntelHourly: 0},
  310. "s": {BaseVCPU: 4, BaseHourly: 0.07143, RamPerVCPU: 2, IntelHourly: 0.08333},
  311. "so": {BaseVCPU: 8, BaseHourly: 0.77976, RamPerVCPU: 8, IntelHourly: 0.77976},
  312. "so1_5": {BaseVCPU: 8, BaseHourly: 0.97024, RamPerVCPU: 8, IntelHourly: 0.82738},
  313. }
  314. // TODO Refine GPU pricing and move to GPU method once GPUs are fully GA
  315. var gpuHourly = map[string]float64{
  316. "gpu-4000adax1-20gb": 0.76,
  317. "gpu-6000adax1-48gb": 1.57,
  318. "gpu-h100x1-80gb": 3.39,
  319. "gpu-h100x8-640gb": 23.92,
  320. "gpu-h200x1-141gb": 3.44,
  321. "gpu-h200x8-1128gb": 27.52,
  322. "gpu-l40sx1-48gb": 1.57,
  323. "gpu-mi300x1-192gb": 1.99,
  324. "gpu-mi300x8-1536gb": 15.92,
  325. }
  326. var (
  327. reVCpu = regexp.MustCompile(`(\d+)\s*vcpu`)
  328. reRAM = regexp.MustCompile(`(\d+)\s*gb`)
  329. reSimpleCount = regexp.MustCompile(`^[a-z0-9_]+-(\d+)(?:-|$)`)
  330. reGPUCount = regexp.MustCompile(`x(\d+)(?:-|$)`)
  331. )
  332. func extractResources(slug string) (int, int, bool) {
  333. parts := strings.Split(slug, "-")
  334. var vcpu, ram int
  335. var foundVCPU, foundRAM bool
  336. for _, part := range parts {
  337. switch {
  338. case strings.HasSuffix(part, "vcpu"):
  339. v, err := strconv.Atoi(strings.TrimSuffix(part, "vcpu"))
  340. if err == nil {
  341. vcpu = v
  342. foundVCPU = true
  343. }
  344. case strings.HasSuffix(part, "gb"):
  345. v, err := strconv.Atoi(strings.TrimSuffix(part, "gb"))
  346. if err == nil {
  347. ram = v
  348. foundRAM = true
  349. }
  350. default:
  351. // Fallback case for just "8", "16", etc.
  352. v, err := strconv.Atoi(part)
  353. if err == nil {
  354. if !foundVCPU {
  355. vcpu = v
  356. foundVCPU = true
  357. } else if !foundRAM {
  358. ram = v
  359. foundRAM = true
  360. }
  361. }
  362. }
  363. }
  364. // If vCPU found but not RAM, assume RAM is 2x vCPU, works for all c families
  365. if foundVCPU && !foundRAM {
  366. ram = 2 * vcpu
  367. foundRAM = true
  368. }
  369. return vcpu, ram, foundVCPU && foundRAM
  370. }
  371. // Estimate cost based on slug pattern and scale from base slugs which are seeded
  372. func estimateCostFromSlug(slug string) (float64, int, int, bool) {
  373. s := strings.ToLower(strings.TrimSpace(slug))
  374. // GPUs are to be handled as a separate case
  375. if strings.HasPrefix(s, "gpu-") {
  376. if h, ok := gpuHourly[s]; ok {
  377. vcpu, ram := extractVCpuRAMGuess(s, "", 0) // we don’t rely on these for pricing
  378. return h, vcpu, ram, true
  379. }
  380. return 0, 0, 0, false
  381. }
  382. dashPosition := strings.IndexByte(s, '-')
  383. if dashPosition <= 0 {
  384. return 0, 0, 0, false
  385. }
  386. family := s[:dashPosition]
  387. seed, ok := slugFamilySeed[family]
  388. if !ok {
  389. return 0, 0, 0, false
  390. }
  391. hasIntel := strings.Contains(s, "-intel")
  392. vcpu, ramGiB := extractVCpuRAMGuess(s, family, seed.RamPerVCPU)
  393. if vcpu == 0 {
  394. return 0, 0, 0, false
  395. }
  396. if ramGiB == 0 && seed.RamPerVCPU > 0 {
  397. ramGiB = seed.RamPerVCPU * vcpu
  398. }
  399. scale := float64(vcpu) / float64(seed.BaseVCPU)
  400. hourly := seed.BaseHourly * scale
  401. if hasIntel && seed.IntelHourly > 0 && seed.BaseHourly > 0 {
  402. mult := seed.IntelHourly / seed.BaseHourly
  403. hourly *= mult
  404. }
  405. return hourly, vcpu, ramGiB, true
  406. }
  407. // TODO Fix GPU Pricing after GA
  408. func extractVCpuRAMGuess(slugLower, family string, ramPerVCPU int) (vcpu int, ramGiB int) {
  409. // Regex for matching CPU, we try to find CPU first
  410. // If RAM not found, we can multiply VCPU by 2 to find it
  411. if m := reVCpu.FindStringSubmatch(slugLower); len(m) == 2 {
  412. if n, _ := strconv.Atoi(m[1]); n > 0 {
  413. vcpu = n
  414. }
  415. }
  416. if m := reRAM.FindStringSubmatch(slugLower); len(m) == 2 {
  417. if n, _ := strconv.Atoi(m[1]); n > 0 {
  418. ramGiB = n
  419. }
  420. }
  421. if vcpu == 0 {
  422. if m := reSimpleCount.FindStringSubmatch(slugLower); len(m) == 2 {
  423. if n, _ := strconv.Atoi(m[1]); n > 0 {
  424. vcpu = n
  425. }
  426. }
  427. }
  428. if ramGiB == 0 && vcpu > 0 && ramPerVCPU > 0 {
  429. ramGiB = vcpu * ramPerVCPU
  430. }
  431. return
  432. }
  433. func parseResources(features string) (int, int, error) {
  434. parts := strings.Split(features, ",")
  435. var cpu, ram int
  436. for _, part := range parts {
  437. kv := strings.SplitN(part, "=", 2)
  438. if len(kv) != 2 {
  439. continue
  440. }
  441. switch kv[0] {
  442. case "node.opencost.io/cpu":
  443. val, err := strconv.Atoi(kv[1])
  444. if err == nil {
  445. cpu = val
  446. }
  447. case "node.opencost.io/ram":
  448. val, err := strconv.Atoi(kv[1])
  449. if err == nil {
  450. ram = val
  451. }
  452. }
  453. }
  454. if cpu > 0 && ram > 0 {
  455. return cpu, ram, nil
  456. }
  457. return 0, 0, fmt.Errorf("cpu or ram not found in features")
  458. }
  459. func (do *DOKS) NodePricing(key models.Key) (*models.Node, models.PricingMetadata, error) {
  460. log.Debugf("Fetching DigitalOcean pricing data (key: %s)", key)
  461. // Try fetching catalog; fallback is okay
  462. _, err := do.fetchPricingData()
  463. if err != nil {
  464. log.Warnf("Failed to fetch catalog: %v. Will try estimation or fallback.", err)
  465. }
  466. arch := parseArch(key.Features())
  467. slug := key.ID()
  468. // First, try to find by exact slug match
  469. if size, ok := do.Sizes[slug]; ok && size.Available {
  470. node, meta := do.sizeToNode(size, arch)
  471. log.Infof("Found size by slug: %s (vCPU: %d, RAM: %dMB, price: $%.5f/hr)",
  472. size.Slug, size.VCPUs, size.Memory, size.PriceHourly)
  473. return node, meta, nil
  474. }
  475. // Try parsing vCPU/RAM from labels
  476. vcpu, ram, err := parseResources(key.Features())
  477. if err != nil || vcpu == 0 || ram == 0 {
  478. log.Infof("Failed to extract CPU/RAM from features. Trying slug: %s", slug)
  479. var ok bool
  480. // Try getting from slug (e.g., "s-2vcpu-4gb")
  481. vcpu, ram, ok = extractResources(slug)
  482. if !ok {
  483. // Fallback: RAM = 2x CPU if CPU is known, cases like c-2
  484. if vcpu > 0 {
  485. ram = vcpu * 2
  486. log.Warnf("Only CPU found. Assuming RAM = 2 * CPU → %dGiB", ram)
  487. } else {
  488. log.Warnf("Could not extract vCPU/RAM from features or slug. Returning fallback.")
  489. return fallbackNode(slug)
  490. }
  491. }
  492. }
  493. // If slug lookup fails, search by vCPU and RAM specs
  494. ramMB := ram * 1024 // Convert GiB to MB
  495. for _, size := range do.Sizes {
  496. if !size.Available {
  497. continue
  498. }
  499. // Match by vCPU and memory (with small tolerance for memory)
  500. if size.VCPUs == vcpu && size.Memory == ramMB {
  501. node, meta := do.sizeToNode(size, arch)
  502. log.Infof("Found size by specs: %s (vCPU: %d, RAM: %dMB, price: $%.5f/hr)",
  503. size.Slug, size.VCPUs, size.Memory, size.PriceHourly)
  504. return node, meta, nil
  505. }
  506. }
  507. log.Warnf("No matching size found for slug %s (vCPU: %d, RAM: %dGiB), falling back", slug, vcpu, ram)
  508. return fallbackNode(slug)
  509. }
  510. func parseArch(features string) string {
  511. parts := strings.Split(features, ",")
  512. for _, part := range parts {
  513. pair := strings.SplitN(part, "=", 2)
  514. if len(pair) == 2 && (pair[0] == "kubernetes.io/arch" || pair[0] == "beta.kubernetes.io/arch") {
  515. return pair[1]
  516. }
  517. }
  518. return ""
  519. }
  520. // sizeToNode converts a DigitalOcean size to an OpenCost Node model
  521. func (do *DOKS) sizeToNode(size *DOSize, arch string) (*models.Node, models.PricingMetadata) {
  522. hourlyCost := size.PriceHourly
  523. vcpu := size.VCPUs
  524. ramGiB := float64(size.Memory) / 1024.0 // Convert MB to GiB
  525. // Distribute cost proportionally between CPU and RAM based on resource counts.
  526. // VCPUCost = total CPU cost portion, RAMCost = total RAM cost portion.
  527. totalUnits := float64(vcpu) + ramGiB
  528. var vcpuCost, ramCost float64
  529. if totalUnits > 0 {
  530. vcpuCost = hourlyCost * float64(vcpu) / totalUnits
  531. ramCost = hourlyCost * ramGiB / totalUnits
  532. }
  533. if arch == "" {
  534. arch = "amd64"
  535. }
  536. region := "global"
  537. if len(size.Regions) > 0 {
  538. region = size.Regions[0]
  539. }
  540. // Convert RAM from MB to bytes directly to avoid float rounding
  541. ramBytes := int64(size.Memory) * 1024 * 1024
  542. // Format RAM as integer GiB when possible
  543. ramGiBInt := int(ramGiB)
  544. ramStr := fmt.Sprintf("%dGiB", ramGiBInt)
  545. node := &models.Node{
  546. Cost: fmt.Sprintf("%.5f", hourlyCost),
  547. VCPUCost: fmt.Sprintf("%.5f", vcpuCost),
  548. RAMCost: fmt.Sprintf("%.5f", ramCost),
  549. VCPU: strconv.Itoa(vcpu),
  550. RAM: ramStr,
  551. RAMBytes: fmt.Sprintf("%d", ramBytes),
  552. InstanceType: size.Slug,
  553. Region: region,
  554. UsageType: "droplet",
  555. PricingType: models.DefaultPrices,
  556. ArchType: arch,
  557. }
  558. if size.GPUInfo.Count > 0 {
  559. node.GPU = strconv.Itoa(size.GPUInfo.Count)
  560. node.GPUName = size.GPUInfo.Model
  561. }
  562. return node, models.PricingMetadata{
  563. Currency: "USD",
  564. Source: "digitalocean-sizes-api",
  565. }
  566. }
  567. func fallbackNode(slug string) (*models.Node, models.PricingMetadata, error) {
  568. if cost, vcpu, ram, ok := estimateCostFromSlug(slug); ok {
  569. totalUnits := float64(vcpu + ram)
  570. if totalUnits == 0 {
  571. return nil, models.PricingMetadata{
  572. Currency: "USD",
  573. Source: "static-fallback",
  574. Warnings: []string{"invalid vCPU and RAM (0) for fallback"},
  575. }, fmt.Errorf("invalid fallback spec: totalUnits=0")
  576. }
  577. unitCost := cost / totalUnits
  578. log.Infof("FallbackNode (estimated): %s , hourly=%.5f, vcpuUnit=%.5f, ramUnit=%.5f", slug, cost, unitCost, unitCost)
  579. ramBytes := int64(ram) * 1024 * 1024 * 1024
  580. return &models.Node{
  581. Cost: fmt.Sprintf("%.5f", cost),
  582. VCPUCost: fmt.Sprintf("%.5f", unitCost),
  583. RAMCost: fmt.Sprintf("%.5f", unitCost),
  584. VCPU: strconv.Itoa(vcpu),
  585. RAM: fmt.Sprintf("%dGiB", ram),
  586. RAMBytes: fmt.Sprintf("%d", ramBytes),
  587. InstanceType: slug,
  588. Region: "global",
  589. UsageType: "static-fallback",
  590. PricingType: models.DefaultPrices,
  591. ArchType: "amd64",
  592. }, models.PricingMetadata{
  593. Currency: "USD",
  594. Source: "static-fallback",
  595. Warnings: []string{"used estimated fallback"},
  596. }, nil
  597. }
  598. return nil, models.PricingMetadata{
  599. Currency: "USD",
  600. Source: "none",
  601. Warnings: []string{"no fallback available"},
  602. }, fmt.Errorf("no fallback pricing for slug: %s", slug)
  603. }
  604. type doksPVKey struct {
  605. id string
  606. storageClass string
  607. sizeBytes int64
  608. ProviderID string
  609. region string
  610. }
  611. func (k *doksPVKey) ID() string {
  612. return k.ProviderID
  613. }
  614. func (k *doksPVKey) SizeGiB() int64 {
  615. return k.sizeBytes / (1024 * 1024 * 1024)
  616. }
  617. // Features Only one type of PV
  618. func (k *doksPVKey) Features() string {
  619. return ""
  620. }
  621. func (k *doksPVKey) GetStorageClass() string {
  622. return k.storageClass
  623. }
  624. func (do *DOKS) PVPricing(key models.PVKey) (*models.PV, error) {
  625. log.Debug("Fetching DigitalOcean block storage pricing")
  626. // DigitalOcean volumes have fixed pricing: $0.10/GiB/month
  627. // This is approximately $0.000137/GiB/hour
  628. k, ok := key.(*doksPVKey)
  629. var sizeGB int64
  630. var region string
  631. if ok {
  632. sizeGB = k.SizeGiB()
  633. region = k.region
  634. }
  635. if region == "" {
  636. region = "global"
  637. }
  638. log.Infof("Using DigitalOcean volume pricing: $%.6f/GiB/hr | Class=%s | SizeGiB=%d | Region=%s | ID=%s",
  639. doVolumeHourlyRatePerGiB, key.GetStorageClass(), sizeGB, region, key.ID())
  640. return &models.PV{
  641. Cost: fmt.Sprintf("%.6f", doVolumeHourlyRatePerGiB),
  642. CostPerIO: "0",
  643. Class: key.GetStorageClass(),
  644. Size: fmt.Sprintf("%d", sizeGB),
  645. Region: region,
  646. ProviderID: key.ID(),
  647. Parameters: nil,
  648. }, nil
  649. }
  650. func fallbackPV(key models.PVKey) (*models.PV, error) {
  651. k, ok := key.(*doksPVKey)
  652. var sizeGB int64
  653. if ok {
  654. sizeGB = k.SizeGiB()
  655. }
  656. region := "global"
  657. if ok && k.region != "" {
  658. region = k.region
  659. }
  660. log.Infof("Using fallback PV pricing: %.5f USD/GiB/hr | Class=%s | SizeGiB=%d | Region=%s | ID=%s",
  661. fallbackPVHourlyRate, key.GetStorageClass(), sizeGB, region, key.ID())
  662. return &models.PV{
  663. Cost: fmt.Sprintf("%.5f", fallbackPVHourlyRate),
  664. CostPerIO: "0",
  665. Class: key.GetStorageClass(),
  666. Size: fmt.Sprintf("%d", sizeGB),
  667. Region: region,
  668. ProviderID: key.ID(),
  669. Parameters: nil,
  670. }, nil
  671. }
  672. // LoadBalancerPricing returns the hourly cost of a Load Balancer in DigitalOcean (DOKS).
  673. //
  674. // DigitalOcean offers multiple Load Balancers with different prices:
  675. //
  676. // - Public HTTP Load Balancer: ~$0.01786/hr
  677. // - Private Network Load Balancer: ~$0.02232/hr
  678. // - Public Network Load Balancer: ~$0.02232/hr
  679. // - Statically sized Load Balancers: $0.01786–$0.10714/hr
  680. //
  681. // However, the current OpenCost provider interface does not pass information about
  682. // individual Load Balancer characteristics (like annotations or network mode).
  683. //
  684. // As a result, this implementation uses a fixed average hourly rate of $0.02,
  685. // which is representative of the most common DO LBs.
  686. //
  687. // TODO Once the provider interface supports more granular Load Balancer metadata,
  688. // this method should be updated to assign costs more precisely.
  689. func (do *DOKS) LoadBalancerPricing() (*models.LoadBalancer, error) {
  690. hourlyCost := 0.02
  691. return &models.LoadBalancer{
  692. Cost: hourlyCost,
  693. }, nil
  694. }
  695. func (do *DOKS) NetworkPricing() (*models.Network, error) {
  696. // fallback
  697. const (
  698. defaultZoneEgress = 0.00
  699. defaultRegionEgress = 0.00
  700. defaultInternetEgress = 0.01
  701. defaultNatGatewayEgress = 0.045
  702. defaultNatGatewayIngress = 0.045
  703. )
  704. log.Infof("NetworkPricing: retrieving custom pricing data")
  705. cpricing, err := do.GetConfig()
  706. if err != nil || isDefaultNetworkPricing(cpricing) {
  707. log.Warnf("NetworkPricing: failed to load custom pricing data: %v", err)
  708. log.Infof("NetworkPricing: using fallback network prices: zone=%.4f, region=%.4f, internet=%.4f",
  709. defaultZoneEgress, defaultRegionEgress, defaultInternetEgress)
  710. return &models.Network{
  711. ZoneNetworkEgressCost: defaultZoneEgress,
  712. RegionNetworkEgressCost: defaultRegionEgress,
  713. InternetNetworkEgressCost: defaultInternetEgress,
  714. NatGatewayEgressCost: defaultNatGatewayEgress,
  715. NatGatewayIngressCost: defaultNatGatewayIngress,
  716. }, nil
  717. }
  718. znec := parseWithDefault(cpricing.ZoneNetworkEgress, defaultZoneEgress, "ZoneNetworkEgress")
  719. rnec := parseWithDefault(cpricing.RegionNetworkEgress, defaultRegionEgress, "RegionNetworkEgress")
  720. inec := parseWithDefault(cpricing.InternetNetworkEgress, defaultInternetEgress, "InternetNetworkEgress")
  721. nge := parseWithDefault(cpricing.NatGatewayEgress, defaultNatGatewayEgress, "NatGatewayEgress")
  722. ngi := parseWithDefault(cpricing.NatGatewayIngress, defaultNatGatewayIngress, "NatGatewayIngress")
  723. log.Infof("NetworkPricing: using parsed values: zone=%.4f/GiB, region=%.4f/GiB, internet=%.4f/GIB", znec, rnec, inec)
  724. return &models.Network{
  725. ZoneNetworkEgressCost: znec,
  726. RegionNetworkEgressCost: rnec,
  727. InternetNetworkEgressCost: inec,
  728. NatGatewayEgressCost: nge,
  729. NatGatewayIngressCost: ngi,
  730. }, nil
  731. }
  732. func parseWithDefault(val string, fallback float64, label string) float64 {
  733. if val == "" {
  734. log.Warnf("NetworkPricing: missing value for %s, using fallback %.4f", label, fallback)
  735. return fallback
  736. }
  737. parsed, err := strconv.ParseFloat(val, 64)
  738. if err != nil {
  739. log.Warnf("NetworkPricing: failed to parse %s='%s', using fallback %.4f", label, val, fallback)
  740. return fallback
  741. }
  742. return parsed
  743. }
  744. func isDefaultNetworkPricing(cp *models.CustomPricing) bool {
  745. return cp != nil &&
  746. cp.ZoneNetworkEgress == "0.01" &&
  747. cp.RegionNetworkEgress == "0.01" &&
  748. cp.InternetNetworkEgress == "0.12" &&
  749. cp.NatGatewayEgress == "0.045" &&
  750. cp.NatGatewayIngress == "0.045"
  751. }
  752. func (do *DOKS) AllNodePricing() (interface{}, error) {
  753. _, _ = do.fetchPricingData()
  754. return do.Cache, nil
  755. }
  756. func (do *DOKS) AllPVPricing() (map[models.PVKey]*models.PV, error) {
  757. // DigitalOcean has a single, fixed pricing tier for block storage volumes
  758. key := &doksPVKey{
  759. id: "do-volume",
  760. storageClass: "do-block-storage",
  761. }
  762. pv, err := do.PVPricing(key)
  763. if err != nil {
  764. return nil, fmt.Errorf("failed to get PV pricing: %w", err)
  765. }
  766. return map[models.PVKey]*models.PV{
  767. key: pv,
  768. }, nil
  769. }
  770. func (do *DOKS) GetPVKey(pv *clustercache.PersistentVolume, parameters map[string]string, defaultRegion string) models.PVKey {
  771. var storageClass string
  772. if pv.Spec.StorageClassName != "" {
  773. storageClass = pv.Spec.StorageClassName
  774. }
  775. var volumeHandle string
  776. if pv.Spec.CSI != nil {
  777. volumeHandle = pv.Spec.CSI.VolumeHandle
  778. }
  779. sizeBytes := pv.Spec.Capacity.Storage().Value()
  780. // Region is in node affinity
  781. region := defaultRegion
  782. if pv.Spec.NodeAffinity != nil && pv.Spec.NodeAffinity.Required != nil {
  783. for _, term := range pv.Spec.NodeAffinity.Required.NodeSelectorTerms {
  784. for _, expr := range term.MatchExpressions {
  785. if expr.Key == "region" && len(expr.Values) > 0 {
  786. region = expr.Values[0]
  787. break
  788. }
  789. }
  790. }
  791. }
  792. return &doksPVKey{
  793. id: pv.Name,
  794. storageClass: storageClass,
  795. sizeBytes: sizeBytes,
  796. ProviderID: volumeHandle,
  797. region: region,
  798. }
  799. }
  800. func (do *DOKS) ClusterInfo() (map[string]string, error) {
  801. return map[string]string{"provider": "digitalocean", "platform": "doks"}, nil
  802. }
  803. func (do *DOKS) GetAddresses() ([]byte, error) {
  804. return nil, nil
  805. }
  806. func (do *DOKS) GetDisks() ([]byte, error) {
  807. return nil, nil
  808. }
  809. func (do *DOKS) GetOrphanedResources() ([]models.OrphanedResource, error) {
  810. return nil, nil
  811. }
  812. func (do *DOKS) GpuPricing(input map[string]string) (string, error) {
  813. return "", nil
  814. }
  815. func (do *DOKS) DownloadPricingData() error {
  816. _, err := do.fetchPricingData()
  817. return err
  818. }
  819. func (do *DOKS) UpdateConfig(r io.Reader, updateType string) (*models.CustomPricing, error) {
  820. return nil, nil
  821. }
  822. func (do *DOKS) UpdateConfigFromConfigMap(map[string]string) (*models.CustomPricing, error) {
  823. return nil, nil
  824. }
  825. func (do *DOKS) GetConfig() (*models.CustomPricing, error) {
  826. if do.Config == nil {
  827. log.Errorf("DOKS: ProviderConfig is nil")
  828. return nil, fmt.Errorf("provider config not available")
  829. }
  830. customPricing, err := do.Config.GetCustomPricingData()
  831. if err != nil {
  832. log.Errorf("DOKS: failed to get custom pricing data: %v", err)
  833. return nil, err
  834. }
  835. return customPricing, nil
  836. }
  837. func (do *DOKS) GetManagementPlatform() (string, error) {
  838. return "DOKS", nil
  839. }
  840. func (do *DOKS) ApplyReservedInstancePricing(map[string]*models.Node) {}
  841. func (do *DOKS) ServiceAccountStatus() *models.ServiceAccountStatus {
  842. return &models.ServiceAccountStatus{}
  843. }
  844. func (do *DOKS) PricingSourceStatus() map[string]*models.PricingSource {
  845. return map[string]*models.PricingSource{}
  846. }
  847. func (do *DOKS) ClusterManagementPricing() (string, float64, error) {
  848. return "", 0, nil
  849. }
  850. func (do *DOKS) CombinedDiscountForNode(string, bool, float64, float64) float64 {
  851. return 0
  852. }
  853. func (do *DOKS) Regions() []string {
  854. return []string{"nyc1", "sfo3", "ams3"}
  855. }
  856. func (do *DOKS) PricingSourceSummary() interface{} {
  857. return nil
  858. }
  859. func (do *DOKS) GetClusterManagementPricing() float64 {
  860. return do.ClusterManagementCost
  861. }
  862. func (do *DOKS) CustomPricingEnabled() bool {
  863. return false
  864. }