provider_test.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918
  1. package digitalocean
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "os"
  7. "testing"
  8. "github.com/digitalocean/godo"
  9. "github.com/opencost/opencost/pkg/cloud/models"
  10. )
  11. func newTestProviderWithFile(t *testing.T, filename string) (*DOKS, func() int) {
  12. t.Helper()
  13. data, err := os.ReadFile(filename)
  14. if err != nil {
  15. t.Fatalf("Failed to read file: %v", err)
  16. }
  17. // Parse the JSON data to get sizes
  18. var response DOResponse
  19. if err := json.Unmarshal(data, &response); err != nil {
  20. t.Fatalf("Failed to parse JSON: %v", err)
  21. }
  22. // Set a fake token for testing
  23. t.Setenv("DIGITALOCEAN_ACCESS_TOKEN", "test_token_dop_v1_fake")
  24. // Convert DOSize to godo.Size for mock
  25. var godoSizes []godo.Size
  26. for _, doSize := range response.Sizes {
  27. godoSize := godo.Size{
  28. Slug: doSize.Slug,
  29. Memory: doSize.Memory,
  30. Vcpus: doSize.VCPUs,
  31. Disk: doSize.Disk,
  32. Transfer: doSize.Transfer,
  33. PriceMonthly: doSize.PriceMonthly,
  34. PriceHourly: doSize.PriceHourly,
  35. Regions: doSize.Regions,
  36. Available: doSize.Available,
  37. Description: doSize.Description,
  38. }
  39. // Convert GPU info if present
  40. if doSize.GPUInfo.Count > 0 {
  41. godoSize.GPUInfo = &godo.GPUInfo{
  42. Count: doSize.GPUInfo.Count,
  43. Model: doSize.GPUInfo.Model,
  44. VRAM: &godo.VRAM{
  45. Amount: doSize.GPUInfo.VRAM.Amount,
  46. Unit: doSize.GPUInfo.VRAM.Unit,
  47. },
  48. }
  49. }
  50. godoSizes = append(godoSizes, godoSize)
  51. }
  52. // Create a mock godo client with all sizes on a single page
  53. var callCount int
  54. mockService := &testMockSizesService{
  55. sizes: godoSizes,
  56. callCount: &callCount,
  57. }
  58. provider := &DOKS{
  59. PricingURL: "https://api.digitalocean.com/v2/sizes",
  60. Cache: &PricingCache{},
  61. Sizes: make(map[string]*DOSize),
  62. client: &godo.Client{Sizes: mockService},
  63. }
  64. return provider, func() int { return callCount }
  65. }
  66. // testMockSizesService is a simple mock that returns all sizes on a single page
  67. type testMockSizesService struct {
  68. sizes []godo.Size
  69. callCount *int
  70. }
  71. func (m *testMockSizesService) List(ctx context.Context, opt *godo.ListOptions) ([]godo.Size, *godo.Response, error) {
  72. *m.callCount++
  73. // Return all sizes on page 1 (no pagination for simple tests)
  74. return m.sizes, &godo.Response{
  75. Links: &godo.Links{
  76. Pages: &godo.Pages{},
  77. },
  78. }, nil
  79. }
  80. func (m *testMockSizesService) GetStorage(ctx context.Context, slug string) (*godo.Size, *godo.Response, error) {
  81. return nil, nil, nil
  82. }
  83. func newTestProviderWith404(t *testing.T) *DOKS {
  84. t.Helper()
  85. // Set a fake token for testing
  86. t.Setenv("DIGITALOCEAN_ACCESS_TOKEN", "test_token_dop_v1_fake")
  87. // Create a mock service that returns an error
  88. errorService := &testErrorSizesService{}
  89. provider := &DOKS{
  90. PricingURL: "https://api.digitalocean.com/v2/sizes",
  91. Cache: &PricingCache{},
  92. Sizes: make(map[string]*DOSize),
  93. client: &godo.Client{Sizes: errorService},
  94. }
  95. return provider
  96. }
  97. // testErrorSizesService returns an error for testing error handling
  98. type testErrorSizesService struct{}
  99. func (m *testErrorSizesService) List(ctx context.Context, opt *godo.ListOptions) ([]godo.Size, *godo.Response, error) {
  100. return nil, nil, fmt.Errorf("API error: 404 Not Found")
  101. }
  102. func (m *testErrorSizesService) GetStorage(ctx context.Context, slug string) (*godo.Size, *godo.Response, error) {
  103. return nil, nil, nil
  104. }
  105. func TestNodePricing_APIMatches(t *testing.T) {
  106. provider, callCount := newTestProviderWithFile(t, "testdata/do_pricing.json")
  107. key := &doksKey{
  108. Labels: map[string]string{
  109. "node.kubernetes.io/instance-type": "s-1vcpu-2gb",
  110. "kubernetes.io/arch": "amd64",
  111. },
  112. }
  113. node, meta, err := provider.NodePricing(key)
  114. if err != nil {
  115. t.Fatalf("expected no error, got: %v", err)
  116. }
  117. if node == nil {
  118. t.Fatal("expected node pricing, got nil")
  119. }
  120. assertEqual := func(name, got, want string) {
  121. if got != want {
  122. t.Errorf("%s: got %s, want %s", name, got, want)
  123. }
  124. }
  125. assertEqual("Cost", node.Cost, "0.01786")
  126. assertEqual("VCPUCost", node.VCPUCost, "0.00595") // 1/3
  127. assertEqual("RAMCost", node.RAMCost, "0.01191") // 2/3
  128. assertEqual("VCPU", node.VCPU, "1")
  129. assertEqual("RAM", node.RAM, "2GiB")
  130. assertEqual("ArchType", node.ArchType, "amd64")
  131. assertEqual("PricingType", string(node.PricingType), string(models.DefaultPrices))
  132. if meta.Source != "digitalocean-sizes-api" {
  133. t.Errorf("expected metadata source to be digitalocean-sizes-api, got: %s", meta.Source)
  134. }
  135. if c := callCount(); c != 1 {
  136. t.Errorf("expected 1 API call, got %d", c)
  137. }
  138. }
  139. func TestNodePricing_S2(t *testing.T) {
  140. provider, callCount := newTestProviderWithFile(t, "testdata/do_pricing.json")
  141. key := &doksKey{
  142. Labels: map[string]string{
  143. "node.kubernetes.io/instance-type": "s-2vcpu-4gb",
  144. "kubernetes.io/arch": "amd64",
  145. },
  146. }
  147. node, meta, err := provider.NodePricing(key)
  148. if err != nil {
  149. t.Fatalf("expected no error, got: %v", err)
  150. }
  151. if node == nil {
  152. t.Fatal("expected node pricing, got nil")
  153. }
  154. assertEqual := func(name, got, want string) {
  155. if got != want {
  156. t.Errorf("%s: got %s, want %s", name, got, want)
  157. }
  158. }
  159. assertEqual("Cost", node.Cost, "0.03571")
  160. assertEqual("VCPUCost", node.VCPUCost, "0.01190")
  161. assertEqual("RAMCost", node.RAMCost, "0.02381")
  162. assertEqual("VCPU", node.VCPU, "2")
  163. assertEqual("RAM", node.RAM, "4GiB")
  164. assertEqual("ArchType", node.ArchType, "amd64")
  165. assertEqual("PricingType", string(node.PricingType), string(models.DefaultPrices))
  166. if meta.Source != "digitalocean-sizes-api" {
  167. t.Errorf("expected metadata source to be digitalocean-sizes-api, got: %s", meta.Source)
  168. }
  169. if c := callCount(); c != 1 {
  170. t.Errorf("expected 1 API call, got %d", c)
  171. }
  172. }
  173. func TestNodePricing_Estimation_C8Intel(t *testing.T) {
  174. provider := newTestProviderWith404(t)
  175. key := &doksKey{
  176. Labels: map[string]string{
  177. "node.kubernetes.io/instance-type": "c-8-intel",
  178. "kubernetes.io/arch": "amd64",
  179. },
  180. }
  181. node, meta, err := provider.NodePricing(key)
  182. if err != nil {
  183. t.Fatalf("expected no error, got: %v", err)
  184. }
  185. expectedCost := "0.32440"
  186. expectedVCPUCost := "0.01352"
  187. expectedRAMCost := "0.01352"
  188. if node.Cost != expectedCost {
  189. t.Errorf("Cost: got %s, want %s", node.Cost, expectedCost)
  190. }
  191. if node.VCPUCost != expectedVCPUCost {
  192. t.Errorf("VCPUCost: got %s, want %s", node.VCPUCost, expectedVCPUCost)
  193. }
  194. if node.RAMCost != expectedRAMCost {
  195. t.Errorf("RAMCost: got %s, want %s", node.RAMCost, expectedRAMCost)
  196. }
  197. if node.VCPU != "8" {
  198. t.Errorf("VCPU: got %s, want 8", node.VCPU)
  199. }
  200. if node.RAM != "16GiB" {
  201. t.Errorf("RAM: got %s, want 16GiB", node.RAM)
  202. }
  203. if meta.Source != "static-fallback" {
  204. t.Errorf("expected metadata source to be estimated, got: %s", meta.Source)
  205. }
  206. }
  207. func TestNodePricing_EstimationFromSlug(t *testing.T) {
  208. tests := []struct {
  209. name string
  210. slug string
  211. expectedVCPU string
  212. expectedRAM string
  213. expectedCost string
  214. expectedCPU string
  215. expectedRAMCost string
  216. }{
  217. {
  218. name: "s-4vcpu-8gb",
  219. slug: "s-4vcpu-8gb",
  220. expectedVCPU: "4",
  221. expectedRAM: "8GiB",
  222. expectedCost: "0.07143",
  223. expectedCPU: "0.00595",
  224. expectedRAMCost: "0.00595",
  225. },
  226. {
  227. name: "m-8vcpu-64gb",
  228. slug: "m-8vcpu-64gb",
  229. expectedVCPU: "8",
  230. expectedRAM: "64GiB",
  231. expectedCost: "0.50000",
  232. expectedCPU: "0.00694",
  233. expectedRAMCost: "0.00694",
  234. },
  235. {
  236. name: "g-4vcpu-16gb-intel",
  237. slug: "g-4vcpu-16gb-intel",
  238. expectedVCPU: "4",
  239. expectedRAM: "16GiB",
  240. expectedCost: "0.22470",
  241. expectedCPU: "0.01124",
  242. expectedRAMCost: "0.01124",
  243. },
  244. }
  245. provider := newTestProviderWith404(t) // Force fallback/estimate
  246. for _, tc := range tests {
  247. t.Run(tc.name, func(t *testing.T) {
  248. key := &doksKey{
  249. Labels: map[string]string{
  250. "node.kubernetes.io/instance-type": tc.slug,
  251. "kubernetes.io/arch": "amd64",
  252. },
  253. }
  254. node, meta, err := provider.NodePricing(key)
  255. if err != nil {
  256. t.Fatalf("unexpected error: %v", err)
  257. }
  258. if node == nil {
  259. t.Fatal("expected node to be non-nil")
  260. }
  261. assertEqual := func(field, got, want string) {
  262. if got != want {
  263. t.Errorf("%s: got %s, want %s", field, got, want)
  264. }
  265. }
  266. assertEqual("Cost", node.Cost, tc.expectedCost)
  267. assertEqual("VCPUCost", node.VCPUCost, tc.expectedCPU)
  268. assertEqual("RAMCost", node.RAMCost, tc.expectedRAMCost)
  269. assertEqual("VCPU", node.VCPU, tc.expectedVCPU)
  270. assertEqual("RAM", node.RAM, tc.expectedRAM)
  271. assertEqual("ArchType", node.ArchType, "amd64")
  272. if meta.Source != "static-fallback" {
  273. t.Errorf("expected metadata source to be 'estimated', got: %s", meta.Source)
  274. }
  275. })
  276. }
  277. }
  278. func TestNodePricing_Estimation_BaseSlugs(t *testing.T) {
  279. tests := []struct {
  280. name string
  281. slug string
  282. expectedVCPU string
  283. expectedRAM string
  284. expectedCost string
  285. expectedCPU string
  286. expectedRAMCost string
  287. }{
  288. {
  289. name: "c-8-intel",
  290. slug: "c-8-intel",
  291. expectedVCPU: "8",
  292. expectedRAM: "16GiB",
  293. expectedCost: "0.32440",
  294. expectedCPU: "0.01352",
  295. expectedRAMCost: "0.01352",
  296. },
  297. {
  298. name: "s-2vcpu-4gb",
  299. slug: "s-2vcpu-4gb",
  300. expectedVCPU: "2",
  301. expectedRAM: "4GiB",
  302. expectedCost: "0.03571",
  303. expectedCPU: "0.00595",
  304. expectedRAMCost: "0.00595",
  305. },
  306. {
  307. name: "m-4vcpu-32gb",
  308. slug: "m-4vcpu-32gb",
  309. expectedVCPU: "4",
  310. expectedRAM: "32GiB",
  311. expectedCost: "0.25000",
  312. expectedCPU: "0.00694",
  313. expectedRAMCost: "0.00694",
  314. },
  315. {
  316. name: "g-16vcpu-64gb-intel",
  317. slug: "g-16vcpu-64gb-intel",
  318. expectedVCPU: "16",
  319. expectedRAM: "64GiB",
  320. expectedCost: "0.89880",
  321. expectedCPU: "0.01124",
  322. expectedRAMCost: "0.01124",
  323. },
  324. }
  325. provider := newTestProviderWith404(t) // ensures fallback path is tested
  326. for _, tc := range tests {
  327. t.Run(tc.name, func(t *testing.T) {
  328. key := &doksKey{
  329. Labels: map[string]string{
  330. "node.kubernetes.io/instance-type": tc.slug,
  331. "kubernetes.io/arch": "amd64",
  332. },
  333. }
  334. node, meta, err := provider.NodePricing(key)
  335. if err != nil {
  336. t.Fatalf("unexpected error: %v", err)
  337. }
  338. if node == nil {
  339. t.Fatal("expected node to be non-nil")
  340. }
  341. assertEqual := func(field, got, want string) {
  342. if got != want {
  343. t.Errorf("%s: got %s, want %s", field, got, want)
  344. }
  345. }
  346. assertEqual("Cost", node.Cost, tc.expectedCost)
  347. assertEqual("VCPUCost", node.VCPUCost, tc.expectedCPU)
  348. assertEqual("RAMCost", node.RAMCost, tc.expectedRAMCost)
  349. assertEqual("VCPU", node.VCPU, tc.expectedVCPU)
  350. assertEqual("RAM", node.RAM, tc.expectedRAM)
  351. assertEqual("ArchType", node.ArchType, "amd64")
  352. if meta.Source != "static-fallback" {
  353. t.Errorf("expected metadata source to be 'static-fallback', got: %s", meta.Source)
  354. }
  355. })
  356. }
  357. }
  358. func TestNodePricing_Estimation_FamilySeeds(t *testing.T) {
  359. tests := []struct {
  360. name string
  361. slug string
  362. expectedVCPU string
  363. expectedRAM string
  364. expectedCost string
  365. expectedCPU string
  366. expectedRAMCost string
  367. }{
  368. {
  369. name: "c-16",
  370. slug: "c-16",
  371. expectedVCPU: "16",
  372. expectedRAM: "32GiB",
  373. expectedCost: "0.50000",
  374. expectedCPU: "0.01042",
  375. expectedRAMCost: "0.01042",
  376. },
  377. {
  378. name: "c-16-intel",
  379. slug: "c-16-intel",
  380. expectedVCPU: "16",
  381. expectedRAM: "32GiB",
  382. expectedCost: "0.64880",
  383. expectedCPU: "0.01352",
  384. expectedRAMCost: "0.01352",
  385. },
  386. {
  387. name: "c2-8vcpu-16gb",
  388. slug: "c2-8vcpu-16gb",
  389. expectedVCPU: "8",
  390. expectedRAM: "16GiB",
  391. expectedCost: "0.27976",
  392. expectedCPU: "0.01166",
  393. expectedRAMCost: "0.01166",
  394. },
  395. {
  396. name: "c2-8vcpu-16gb-intel",
  397. slug: "c2-8vcpu-16gb-intel",
  398. expectedVCPU: "8",
  399. expectedRAM: "16GiB",
  400. expectedCost: "0.36310",
  401. expectedCPU: "0.01513",
  402. expectedRAMCost: "0.01513",
  403. },
  404. {
  405. name: "g-8vcpu-32gb",
  406. slug: "g-8vcpu-32gb",
  407. expectedVCPU: "8",
  408. expectedRAM: "32GiB",
  409. expectedCost: "0.37500",
  410. expectedCPU: "0.00937",
  411. expectedRAMCost: "0.00937",
  412. },
  413. {
  414. name: "g-8vcpu-32gb-intel",
  415. slug: "g-8vcpu-32gb-intel",
  416. expectedVCPU: "8",
  417. expectedRAM: "32GiB",
  418. expectedCost: "0.44940",
  419. expectedCPU: "0.01124",
  420. expectedRAMCost: "0.01124",
  421. },
  422. {
  423. name: "gd-40vcpu-160gb",
  424. slug: "gd-40vcpu-160gb",
  425. expectedVCPU: "40",
  426. expectedRAM: "160GiB",
  427. expectedCost: "2.02380",
  428. expectedCPU: "0.01012",
  429. expectedRAMCost: "0.01012",
  430. },
  431. {
  432. name: "gd-16vcpu-64gb-intel",
  433. slug: "gd-16vcpu-64gb-intel",
  434. expectedVCPU: "16",
  435. expectedRAM: "64GiB",
  436. expectedCost: "0.94048",
  437. expectedCPU: "0.01176",
  438. expectedRAMCost: "0.01176",
  439. },
  440. {
  441. name: "m-16vcpu-128gb",
  442. slug: "m-16vcpu-128gb",
  443. expectedVCPU: "16",
  444. expectedRAM: "128GiB",
  445. expectedCost: "1.00000",
  446. expectedCPU: "0.00694",
  447. expectedRAMCost: "0.00694",
  448. },
  449. {
  450. name: "m-16vcpu-128gb-intel",
  451. slug: "m-16vcpu-128gb-intel",
  452. expectedVCPU: "16",
  453. expectedRAM: "128GiB",
  454. expectedCost: "1.17858",
  455. expectedCPU: "0.00818",
  456. expectedRAMCost: "0.00818",
  457. },
  458. // m3
  459. {
  460. name: "m3-8vcpu-64gb",
  461. slug: "m3-8vcpu-64gb",
  462. expectedVCPU: "8",
  463. expectedRAM: "64GiB",
  464. expectedCost: "0.61905",
  465. expectedCPU: "0.00860",
  466. expectedRAMCost: "0.00860",
  467. },
  468. {
  469. name: "m3-32vcpu-256gb-intel",
  470. slug: "m3-32vcpu-256gb-intel",
  471. expectedVCPU: "32",
  472. expectedRAM: "256GiB",
  473. expectedCost: "2.61904",
  474. expectedCPU: "0.00909",
  475. expectedRAMCost: "0.00909",
  476. },
  477. {
  478. name: "m6-8vcpu-64gb",
  479. slug: "m6-8vcpu-64gb",
  480. expectedVCPU: "8",
  481. expectedRAM: "64GiB",
  482. expectedCost: "0.77976",
  483. expectedCPU: "0.01083",
  484. expectedRAMCost: "0.01083",
  485. },
  486. {
  487. name: "m6-24vcpu-192gb",
  488. slug: "m6-24vcpu-192gb",
  489. expectedVCPU: "24",
  490. expectedRAM: "192GiB",
  491. expectedCost: "2.33928",
  492. expectedCPU: "0.01083",
  493. expectedRAMCost: "0.01083",
  494. },
  495. {
  496. name: "s-1vcpu-2gb",
  497. slug: "s-1vcpu-2gb",
  498. expectedVCPU: "1",
  499. expectedRAM: "2GiB",
  500. expectedCost: "0.01786",
  501. expectedCPU: "0.00595",
  502. expectedRAMCost: "0.00595",
  503. },
  504. {
  505. name: "s-8vcpu-16gb-intel",
  506. slug: "s-8vcpu-16gb-intel",
  507. expectedVCPU: "8",
  508. expectedRAM: "16GiB",
  509. expectedCost: "0.16666",
  510. expectedCPU: "0.00694",
  511. expectedRAMCost: "0.00694",
  512. },
  513. {
  514. name: "so-8vcpu-64gb",
  515. slug: "so-8vcpu-64gb",
  516. expectedVCPU: "8",
  517. expectedRAM: "64GiB",
  518. expectedCost: "0.77976",
  519. expectedCPU: "0.01083",
  520. expectedRAMCost: "0.01083",
  521. },
  522. {
  523. name: "so-8vcpu-64gb-intel",
  524. slug: "so-8vcpu-64gb-intel",
  525. expectedVCPU: "8",
  526. expectedRAM: "64GiB",
  527. expectedCost: "0.77976",
  528. expectedCPU: "0.01083",
  529. expectedRAMCost: "0.01083",
  530. },
  531. {
  532. name: "so1_5-8vcpu-64gb",
  533. slug: "so1_5-8vcpu-64gb",
  534. expectedVCPU: "8",
  535. expectedRAM: "64GiB",
  536. expectedCost: "0.97024",
  537. expectedCPU: "0.01348",
  538. expectedRAMCost: "0.01348",
  539. },
  540. {
  541. name: "so1_5-8vcpu-64gb-intel",
  542. slug: "so1_5-8vcpu-64gb-intel",
  543. expectedVCPU: "8",
  544. expectedRAM: "64GiB",
  545. expectedCost: "0.82738",
  546. expectedCPU: "0.01149",
  547. expectedRAMCost: "0.01149",
  548. },
  549. }
  550. provider := newTestProviderWith404(t)
  551. for _, tc := range tests {
  552. t.Run(tc.name, func(t *testing.T) {
  553. key := &doksKey{
  554. Labels: map[string]string{
  555. "node.kubernetes.io/instance-type": tc.slug,
  556. "kubernetes.io/arch": "amd64",
  557. },
  558. }
  559. node, meta, err := provider.NodePricing(key)
  560. if err != nil {
  561. t.Fatalf("unexpected error: %v", err)
  562. }
  563. if node == nil {
  564. t.Fatal("expected node to be non-nil")
  565. }
  566. assertEqual := func(field, got, want string) {
  567. if got != want {
  568. t.Errorf("%s: got %s, want %s", field, got, want)
  569. }
  570. }
  571. assertEqual("Cost", node.Cost, tc.expectedCost)
  572. assertEqual("VCPUCost", node.VCPUCost, tc.expectedCPU)
  573. assertEqual("RAMCost", node.RAMCost, tc.expectedRAMCost)
  574. assertEqual("VCPU", node.VCPU, tc.expectedVCPU)
  575. assertEqual("RAM", node.RAM, tc.expectedRAM)
  576. assertEqual("ArchType", node.ArchType, "amd64")
  577. if meta.Source != "static-fallback" {
  578. t.Errorf("expected metadata source to be 'static-fallback', got: %s", meta.Source)
  579. }
  580. })
  581. }
  582. }
  583. func TestNodePricing_GPU(t *testing.T) {
  584. provider, callCount := newTestProviderWithFile(t, "testdata/do_pricing.json")
  585. key := &doksKey{
  586. Labels: map[string]string{
  587. "node.kubernetes.io/instance-type": "gpu-h100x1-80gb",
  588. "kubernetes.io/arch": "amd64",
  589. },
  590. }
  591. // Verify key methods - might return defaults but shouldn't panic
  592. if count := key.GPUCount(); count != 1 {
  593. t.Errorf("expected GPUCount 1, got %d", count)
  594. }
  595. if gpuType := key.GPUType(); gpuType != "h100" {
  596. t.Errorf("expected GPUType h100, got %s", gpuType)
  597. }
  598. node, meta, err := provider.NodePricing(key)
  599. if err != nil {
  600. t.Fatalf("expected no error, got: %v", err)
  601. }
  602. if node == nil {
  603. t.Fatal("expected node pricing, got nil")
  604. }
  605. assertEqual := func(name, got, want string) {
  606. if got != want {
  607. t.Errorf("%s: got %s, want %s", name, got, want)
  608. }
  609. }
  610. assertEqual("Cost", node.Cost, "3.39000")
  611. assertEqual("VCPUCost", node.VCPUCost, "0.26077") // 3.39 * 20 / 260 = 0.260769...
  612. assertEqual("RAMCost", node.RAMCost, "3.12923") // 3.39 * 240 / 260 = 3.129230...
  613. assertEqual("VCPU", node.VCPU, "20")
  614. assertEqual("RAM", node.RAM, "240GiB")
  615. assertEqual("GPU", node.GPU, "1")
  616. assertEqual("GPUName", node.GPUName, "nvidia_h100")
  617. if meta.Source != "digitalocean-sizes-api" {
  618. t.Errorf("expected metadata source to be digitalocean-sizes-api, got: %s", meta.Source)
  619. }
  620. if c := callCount(); c != 1 {
  621. t.Errorf("expected 1 API call, got %d", c)
  622. }
  623. }
  624. // mockSizesService implements the godo.SizesService interface for testing pagination
  625. type mockSizesService struct {
  626. pages [][]godo.Size
  627. }
  628. func (m *mockSizesService) List(ctx context.Context, opt *godo.ListOptions) ([]godo.Size, *godo.Response, error) {
  629. if opt == nil {
  630. opt = &godo.ListOptions{}
  631. }
  632. // Pages are 1-indexed in godo
  633. page := opt.Page
  634. if page == 0 {
  635. page = 1
  636. }
  637. // Check if page is within range
  638. if page > len(m.pages) {
  639. // Return last page indicator
  640. return []godo.Size{}, &godo.Response{
  641. Links: &godo.Links{
  642. Pages: &godo.Pages{}, // No Next link = last page
  643. },
  644. }, nil
  645. }
  646. sizes := m.pages[page-1]
  647. // Create response with pagination links
  648. // godo.Pages has: First, Last, Next, Prev
  649. resp := &godo.Response{
  650. Links: &godo.Links{
  651. Pages: &godo.Pages{
  652. // Set First link (required for CurrentPage to parse)
  653. First: "https://api.digitalocean.com/v2/sizes?page=1&per_page=20",
  654. },
  655. },
  656. }
  657. // Set Last link - always set for godo to work
  658. resp.Links.Pages.Last = fmt.Sprintf("https://api.digitalocean.com/v2/sizes?page=%d&per_page=20", len(m.pages))
  659. // Set Next link if not on the last page
  660. if page < len(m.pages) {
  661. resp.Links.Pages.Next = fmt.Sprintf("https://api.digitalocean.com/v2/sizes?page=%d&per_page=20", page+1)
  662. }
  663. // Set Prev link if not on the first page
  664. if page > 1 {
  665. resp.Links.Pages.Prev = fmt.Sprintf("https://api.digitalocean.com/v2/sizes?page=%d&per_page=20", page-1)
  666. }
  667. return sizes, resp, nil
  668. }
  669. func (m *mockSizesService) GetStorage(ctx context.Context, slug string) (*godo.Size, *godo.Response, error) {
  670. return nil, nil, nil
  671. }
  672. // createMockGodoClient creates a godo client with a mock SizesService for pagination testing
  673. func createMockGodoClient(t *testing.T) *godo.Client {
  674. t.Helper()
  675. page1 := []godo.Size{
  676. {
  677. Slug: "s-1vcpu-2gb",
  678. Memory: 2048,
  679. Vcpus: 1,
  680. Disk: 50,
  681. PriceHourly: 0.01786,
  682. Available: true,
  683. },
  684. {
  685. Slug: "s-2vcpu-4gb",
  686. Memory: 4096,
  687. Vcpus: 2,
  688. Disk: 80,
  689. PriceHourly: 0.03571,
  690. Available: true,
  691. },
  692. }
  693. page2 := []godo.Size{
  694. {
  695. Slug: "m-4vcpu-32gb",
  696. Memory: 32768,
  697. Vcpus: 4,
  698. Disk: 160,
  699. PriceHourly: 0.25000,
  700. Available: true,
  701. },
  702. {
  703. Slug: "m-8vcpu-64gb",
  704. Memory: 65536,
  705. Vcpus: 8,
  706. Disk: 320,
  707. PriceHourly: 0.50000,
  708. Available: true,
  709. },
  710. }
  711. page3 := []godo.Size{
  712. {
  713. Slug: "c-8-intel",
  714. Memory: 16384,
  715. Vcpus: 8,
  716. Disk: 160,
  717. PriceHourly: 0.32440,
  718. Available: true,
  719. },
  720. {
  721. Slug: "c-16-intel",
  722. Memory: 32768,
  723. Vcpus: 16,
  724. Disk: 320,
  725. PriceHourly: 0.64880,
  726. Available: true,
  727. },
  728. }
  729. // Create a client (we'll replace its Sizes service)
  730. client := godo.NewFromToken("test_token")
  731. // Replace the Sizes service with our mock
  732. client.Sizes = &mockSizesService{
  733. pages: [][]godo.Size{page1, page2, page3},
  734. }
  735. return client
  736. }
  737. // TestFetchPricingData_Pagination verifies that pagination is correctly handled when fetching sizes
  738. func TestFetchPricingData_Pagination(t *testing.T) {
  739. t.Setenv("DIGITALOCEAN_ACCESS_TOKEN", "test_token_dop_v1_fake")
  740. // Create a provider with a mock client that simulates pagination
  741. provider := &DOKS{
  742. PricingURL: "https://api.digitalocean.com/v2/sizes",
  743. Cache: &PricingCache{},
  744. Sizes: make(map[string]*DOSize),
  745. client: createMockGodoClient(t),
  746. }
  747. // Fetch pricing data which triggers pagination
  748. response, err := provider.fetchPricingData()
  749. if err != nil {
  750. t.Fatalf("expected no error, got: %v", err)
  751. }
  752. if response == nil {
  753. t.Fatal("expected non-nil response")
  754. }
  755. // Verify that all sizes from all pages were collected
  756. expectedSizes := map[string]bool{
  757. "s-1vcpu-2gb": true,
  758. "s-2vcpu-4gb": true,
  759. "m-4vcpu-32gb": true,
  760. "m-8vcpu-64gb": true,
  761. "c-8-intel": true,
  762. "c-16-intel": true,
  763. }
  764. // Check that all expected sizes are in the provider's sizes map
  765. for slug := range expectedSizes {
  766. if _, exists := provider.Sizes[slug]; !exists {
  767. t.Errorf("expected size %q to be indexed, but it was not", slug)
  768. }
  769. }
  770. // Verify the total count
  771. if len(provider.Sizes) != len(expectedSizes) {
  772. t.Errorf("expected %d sizes, got %d", len(expectedSizes), len(provider.Sizes))
  773. }
  774. // Verify specific size details
  775. testCases := []struct {
  776. slug string
  777. expectedVCPUs int
  778. expectedMemory int
  779. }{
  780. {"s-1vcpu-2gb", 1, 2048},
  781. {"s-2vcpu-4gb", 2, 4096},
  782. {"m-4vcpu-32gb", 4, 32768},
  783. {"m-8vcpu-64gb", 8, 65536},
  784. {"c-8-intel", 8, 16384},
  785. {"c-16-intel", 16, 32768},
  786. }
  787. for _, tc := range testCases {
  788. size, exists := provider.Sizes[tc.slug]
  789. if !exists {
  790. t.Fatalf("expected size %q to exist", tc.slug)
  791. }
  792. if size.VCPUs != tc.expectedVCPUs {
  793. t.Errorf("size %q: expected %d vCPUs, got %d", tc.slug, tc.expectedVCPUs, size.VCPUs)
  794. }
  795. if size.Memory != tc.expectedMemory {
  796. t.Errorf("size %q: expected %d MB memory, got %d", tc.slug, tc.expectedMemory, size.Memory)
  797. }
  798. }
  799. // Verify caching works (second fetch should use cached data)
  800. response2, err := provider.fetchPricingData()
  801. if err != nil {
  802. t.Fatalf("expected no error on second fetch, got: %v", err)
  803. }
  804. if response2 == nil {
  805. t.Fatal("expected non-nil response on second fetch")
  806. }
  807. // Verify cache timestamp was updated
  808. if provider.Cache.lastUpdate.IsZero() {
  809. t.Error("expected cache to have a non-zero timestamp")
  810. }
  811. }