provider.go 27 KB

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