provider.go 25 KB

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