provider_test.go 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813
  1. package azure
  2. import (
  3. "bytes"
  4. "fmt"
  5. "io"
  6. "net/http"
  7. "testing"
  8. "github.com/Azure/azure-sdk-for-go/services/compute/mgmt/2021-11-01/compute"
  9. "github.com/Azure/azure-sdk-for-go/services/preview/commerce/mgmt/2015-06-01-preview/commerce"
  10. "github.com/stretchr/testify/require"
  11. "github.com/opencost/opencost/core/pkg/util/mathutil"
  12. "github.com/opencost/opencost/pkg/cloud/models"
  13. )
  14. func TestParseAzureSubscriptionID(t *testing.T) {
  15. cases := []struct {
  16. input string
  17. expected string
  18. }{
  19. {
  20. input: "azure:///subscriptions/0badafdf-1234-abcd-wxyz-123456789/...",
  21. expected: "0badafdf-1234-abcd-wxyz-123456789",
  22. },
  23. {
  24. input: "azure:/subscriptions/0badafdf-1234-abcd-wxyz-123456789/...",
  25. expected: "",
  26. },
  27. {
  28. input: "azure:///subscriptions//",
  29. expected: "",
  30. },
  31. {
  32. input: "",
  33. expected: "",
  34. },
  35. }
  36. for _, test := range cases {
  37. result := ParseAzureSubscriptionID(test.input)
  38. if result != test.expected {
  39. t.Errorf("Input: %s, Expected: %s, Actual: %s", test.input, test.expected, result)
  40. }
  41. }
  42. }
  43. func TestConvertMeterToPricings(t *testing.T) {
  44. regions := map[string]string{
  45. "useast": "US East",
  46. "japanwest": "Japan West",
  47. "australiasoutheast": "Australia Southeast",
  48. "norwaywest": "Norway West",
  49. }
  50. baseCPUPrice := "0.30000"
  51. meterInfo := func(category, subcategory, name, region string, rate float64) commerce.MeterInfo {
  52. return commerce.MeterInfo{
  53. MeterCategory: &category,
  54. MeterSubCategory: &subcategory,
  55. MeterName: &name,
  56. MeterRegion: &region,
  57. MeterRates: map[string]*float64{"0": &rate},
  58. }
  59. }
  60. t.Run("windows", func(t *testing.T) {
  61. info := meterInfo("Virtual Machines", "D2 Series Windows", "D2s v3", "AU Southeast", 0.3)
  62. results, err := convertMeterToPricings(info, regions, baseCPUPrice)
  63. require.NoError(t, err)
  64. key := "australiasoutheast,Standard_D2s_v3,ondemand,windows"
  65. pricing, ok := results[key]
  66. require.Truef(t, ok, "expected a pricing entry under key %q", key)
  67. require.NotNil(t, pricing.Node)
  68. require.Equal(t, "ondemand", pricing.Node.UsageType)
  69. require.Equal(t, "0.300000", pricing.Node.Cost)
  70. require.Equal(t, baseCPUPrice, pricing.Node.BaseCPUPrice)
  71. })
  72. t.Run("storage", func(t *testing.T) {
  73. info := meterInfo("Storage", "Some SSD type", "P4 are good", "US East", 2000)
  74. results, err := convertMeterToPricings(info, regions, baseCPUPrice)
  75. require.NoError(t, err)
  76. expected := map[string]*AzurePricing{
  77. "useast,premium_ssd": {
  78. PV: &models.PV{Cost: "0.085616", Region: "useast"},
  79. },
  80. }
  81. require.Equal(t, expected, results)
  82. })
  83. t.Run("virtual machines", func(t *testing.T) {
  84. info := meterInfo("Virtual Machines", "Eav4/Easv4 Series", "E96a v4/E96as v4 Low Priority", "JA West", 10)
  85. results, err := convertMeterToPricings(info, regions, baseCPUPrice)
  86. require.NoError(t, err)
  87. expected := map[string]*AzurePricing{
  88. "japanwest,Standard_E96a_v4,preemptible": {
  89. Node: &models.Node{Cost: "10.000000", BaseCPUPrice: "0.30000", UsageType: "preemptible"},
  90. },
  91. "japanwest,Standard_E96as_v4,preemptible": {
  92. Node: &models.Node{Cost: "10.000000", BaseCPUPrice: "0.30000", UsageType: "preemptible"},
  93. },
  94. }
  95. require.Equal(t, expected, results)
  96. })
  97. }
  98. func TestSelectRetailPrice(t *testing.T) {
  99. cases := []struct {
  100. name string
  101. linuxRetailPrice string
  102. windowsRetailPrice string
  103. spotPrice string
  104. windowsSpotPrice string
  105. spot bool
  106. isWindows bool
  107. expected string
  108. expectErr bool
  109. }{
  110. {
  111. name: "windows retail prefers windows price",
  112. linuxRetailPrice: "1.000000",
  113. windowsRetailPrice: "2.000000",
  114. isWindows: true,
  115. expected: "2.000000",
  116. },
  117. {
  118. name: "windows retail falls back to linux when windows missing",
  119. linuxRetailPrice: "1.000000",
  120. isWindows: true,
  121. expected: "1.000000",
  122. },
  123. {
  124. name: "linux retail uses linux price",
  125. linuxRetailPrice: "1.000000",
  126. isWindows: false,
  127. expected: "1.000000",
  128. },
  129. {
  130. name: "windows spot prefers windows spot price",
  131. spotPrice: "0.500000",
  132. windowsSpotPrice: "0.900000",
  133. spot: true,
  134. isWindows: true,
  135. expected: "0.900000",
  136. },
  137. {
  138. name: "windows spot falls back to linux spot when windows missing",
  139. spotPrice: "0.500000",
  140. spot: true,
  141. isWindows: true,
  142. expected: "0.500000",
  143. },
  144. {
  145. name: "linux spot uses linux spot price",
  146. spotPrice: "0.500000",
  147. spot: true,
  148. isWindows: false,
  149. expected: "0.500000",
  150. },
  151. {
  152. name: "spot windows with no spot price falls back to retail",
  153. windowsRetailPrice: "2.000000",
  154. spot: true,
  155. isWindows: true,
  156. expected: "2.000000",
  157. },
  158. {
  159. name: "no price available returns error",
  160. isWindows: true,
  161. expectErr: true,
  162. },
  163. }
  164. for _, tc := range cases {
  165. t.Run(tc.name, func(t *testing.T) {
  166. got, err := selectRetailPrice("eastus", "Standard_D2s_v3", tc.linuxRetailPrice, tc.windowsRetailPrice, tc.spotPrice, tc.windowsSpotPrice, tc.spot, tc.isWindows)
  167. if tc.expectErr {
  168. require.Error(t, err)
  169. return
  170. }
  171. require.NoError(t, err)
  172. require.Equal(t, tc.expected, got)
  173. })
  174. }
  175. }
  176. func TestAzure_findCostForDisk(t *testing.T) {
  177. var loc string = "location"
  178. var size int32 = 1
  179. az := &Azure{
  180. Pricing: map[string]*AzurePricing{
  181. "location,nil": nil,
  182. "location,nilpv": {
  183. PV: nil,
  184. },
  185. "location,ssd": {
  186. PV: &models.PV{
  187. Cost: "1",
  188. },
  189. },
  190. },
  191. }
  192. testCases := []struct {
  193. name string
  194. disk *compute.Disk
  195. exp float64
  196. expErr error
  197. }{
  198. {
  199. "disk is nil",
  200. nil,
  201. 0.0,
  202. fmt.Errorf("disk is empty"),
  203. },
  204. {
  205. "nil location",
  206. &compute.Disk{
  207. Location: nil,
  208. Sku: &compute.DiskSku{
  209. Name: "ssd",
  210. },
  211. DiskProperties: &compute.DiskProperties{
  212. DiskSizeGB: &size,
  213. },
  214. },
  215. 0.0,
  216. fmt.Errorf("failed to find pricing for key: ,ssd"),
  217. },
  218. {
  219. "nil disk properties",
  220. &compute.Disk{
  221. Location: &loc,
  222. Sku: &compute.DiskSku{
  223. Name: "ssd",
  224. },
  225. DiskProperties: nil,
  226. },
  227. 0.0,
  228. fmt.Errorf("disk properties are nil"),
  229. },
  230. {
  231. "nil disk size",
  232. &compute.Disk{
  233. Location: &loc,
  234. Sku: &compute.DiskSku{
  235. Name: "ssd",
  236. },
  237. DiskProperties: &compute.DiskProperties{
  238. DiskSizeGB: nil,
  239. },
  240. },
  241. 0.0,
  242. fmt.Errorf("disk size is nil"),
  243. },
  244. {
  245. "sku does not exist",
  246. &compute.Disk{
  247. Location: &loc,
  248. Sku: &compute.DiskSku{
  249. Name: "doesnotexist",
  250. },
  251. DiskProperties: &compute.DiskProperties{
  252. DiskSizeGB: &size,
  253. },
  254. },
  255. 0.0,
  256. fmt.Errorf("failed to find pricing for key: location,doesnotexist"),
  257. },
  258. {
  259. "pricing is nil",
  260. &compute.Disk{
  261. Sku: &compute.DiskSku{
  262. Name: "nil",
  263. },
  264. DiskProperties: &compute.DiskProperties{
  265. DiskSizeGB: &size,
  266. },
  267. },
  268. 0.0,
  269. fmt.Errorf("failed to find pricing for key: location,nil"),
  270. },
  271. {
  272. "pricing.PV is nil",
  273. &compute.Disk{
  274. Sku: &compute.DiskSku{
  275. Name: "nilpv",
  276. },
  277. DiskProperties: &compute.DiskProperties{
  278. DiskSizeGB: &size,
  279. },
  280. },
  281. 0.0,
  282. fmt.Errorf("pricing for key 'location,nilpv' has nil PV"),
  283. },
  284. {
  285. "valid (ssd)",
  286. &compute.Disk{
  287. Location: &loc,
  288. Sku: &compute.DiskSku{
  289. Name: "ssd",
  290. },
  291. DiskProperties: &compute.DiskProperties{
  292. DiskSizeGB: &size,
  293. },
  294. },
  295. 730.0,
  296. nil,
  297. },
  298. {
  299. "nil sku",
  300. &compute.Disk{
  301. Location: nil,
  302. Sku: nil,
  303. DiskProperties: nil,
  304. },
  305. 0.0,
  306. fmt.Errorf("disk sku is nil"),
  307. },
  308. }
  309. for _, tc := range testCases {
  310. t.Run(tc.name, func(t *testing.T) {
  311. act, actErr := az.findCostForDisk(tc.disk)
  312. if actErr != nil && tc.expErr == nil {
  313. t.Fatalf("unexpected error: %s", actErr)
  314. }
  315. if tc.expErr != nil && actErr == nil {
  316. t.Fatalf("missing expected error: %s", tc.expErr)
  317. }
  318. if !mathutil.Approximately(tc.exp, act) {
  319. t.Fatalf("expected value %f; got %f", tc.exp, act)
  320. }
  321. })
  322. }
  323. }
  324. func TestAzurePVKeyFeatures(t *testing.T) {
  325. tests := []struct {
  326. name string
  327. parameters map[string]string
  328. expected string
  329. }{
  330. {
  331. name: "managed disk storageaccounttype premium",
  332. parameters: map[string]string{
  333. "storageaccounttype": "Premium_LRS",
  334. },
  335. expected: "eastus,premium_ssd",
  336. },
  337. {
  338. name: "managed disk csi skuname premium",
  339. parameters: map[string]string{
  340. "skuname": "Premium_LRS",
  341. },
  342. expected: "eastus,premium_ssd",
  343. },
  344. {
  345. name: "managed disk csi skuname standard ssd",
  346. parameters: map[string]string{
  347. "skuname": "StandardSSD_LRS",
  348. },
  349. expected: "eastus,standard_ssd",
  350. },
  351. {
  352. name: "managed disk csi skuname standard hdd",
  353. parameters: map[string]string{
  354. "skuname": "Standard_LRS",
  355. },
  356. expected: "eastus,standard_hdd",
  357. },
  358. {
  359. name: "azure files skuName remains file pricing",
  360. parameters: map[string]string{
  361. "skuName": "Premium_LRS",
  362. },
  363. expected: "eastus,premium_smb",
  364. },
  365. {
  366. name: "azure files skuName standard remains file pricing",
  367. parameters: map[string]string{
  368. "skuName": "Standard_LRS",
  369. },
  370. expected: "eastus,standard_smb",
  371. },
  372. }
  373. for _, tc := range tests {
  374. t.Run(tc.name, func(t *testing.T) {
  375. key := &azurePvKey{
  376. StorageClassParameters: tc.parameters,
  377. DefaultRegion: "eastus",
  378. }
  379. require.Equal(t, tc.expected, key.Features())
  380. })
  381. }
  382. }
  383. func Test_buildAzureRetailPricesURL(t *testing.T) {
  384. testCases := []struct {
  385. name string
  386. region string
  387. skuName string
  388. currencyCode string
  389. expected string
  390. }{
  391. {
  392. name: "all parameters provided",
  393. region: "eastus",
  394. skuName: "Standard_D8ds_v5",
  395. currencyCode: "USD",
  396. expected: "https://prices.azure.com/api/retail/prices?$skip=0&currencyCode='USD'&$filter=armRegionName+eq+%27eastus%27+and+armSkuName+eq+%27Standard_D8ds_v5%27+and+serviceFamily+eq+%27Compute%27+and+type+eq+%27Consumption%27+and+contains%28meterName%2C%27Low+Priority%27%29+eq+false",
  397. },
  398. {
  399. name: "no currency code",
  400. region: "westus",
  401. skuName: "Standard_D4s_v3",
  402. currencyCode: "",
  403. expected: "https://prices.azure.com/api/retail/prices?$skip=0&$filter=armRegionName+eq+%27westus%27+and+armSkuName+eq+%27Standard_D4s_v3%27+and+serviceFamily+eq+%27Compute%27+and+type+eq+%27Consumption%27+and+contains%28meterName%2C%27Low+Priority%27%29+eq+false",
  404. },
  405. {
  406. name: "no region",
  407. region: "",
  408. skuName: "Standard_D8s_v3",
  409. currencyCode: "EUR",
  410. expected: "https://prices.azure.com/api/retail/prices?$skip=0&currencyCode='EUR'&$filter=armSkuName+eq+%27Standard_D8s_v3%27+and+serviceFamily+eq+%27Compute%27+and+type+eq+%27Consumption%27+and+contains%28meterName%2C%27Low+Priority%27%29+eq+false",
  411. },
  412. {
  413. name: "no sku name",
  414. region: "northeurope",
  415. skuName: "",
  416. currencyCode: "GBP",
  417. expected: "https://prices.azure.com/api/retail/prices?$skip=0&currencyCode='GBP'&$filter=armRegionName+eq+%27northeurope%27+and+serviceFamily+eq+%27Compute%27+and+type+eq+%27Consumption%27+and+contains%28meterName%2C%27Low+Priority%27%29+eq+false",
  418. },
  419. {
  420. name: "only currency code",
  421. region: "",
  422. skuName: "",
  423. currencyCode: "JPY",
  424. expected: "https://prices.azure.com/api/retail/prices?$skip=0&currencyCode='JPY'&$filter=serviceFamily+eq+%27Compute%27+and+type+eq+%27Consumption%27+and+contains%28meterName%2C%27Low+Priority%27%29+eq+false",
  425. },
  426. {
  427. name: "no parameters",
  428. region: "",
  429. skuName: "",
  430. currencyCode: "",
  431. expected: "https://prices.azure.com/api/retail/prices?$skip=0&$filter=serviceFamily+eq+%27Compute%27+and+type+eq+%27Consumption%27+and+contains%28meterName%2C%27Low+Priority%27%29+eq+false",
  432. },
  433. {
  434. name: "region with special characters",
  435. region: "south-central-us",
  436. skuName: "Standard_B2s",
  437. currencyCode: "USD",
  438. expected: "https://prices.azure.com/api/retail/prices?$skip=0&currencyCode='USD'&$filter=armRegionName+eq+%27south-central-us%27+and+armSkuName+eq+%27Standard_B2s%27+and+serviceFamily+eq+%27Compute%27+and+type+eq+%27Consumption%27+and+contains%28meterName%2C%27Low+Priority%27%29+eq+false",
  439. },
  440. {
  441. name: "sku name with underscores",
  442. region: "eastus2",
  443. skuName: "Standard_E16_v3",
  444. currencyCode: "CAD",
  445. expected: "https://prices.azure.com/api/retail/prices?$skip=0&currencyCode='CAD'&$filter=armRegionName+eq+%27eastus2%27+and+armSkuName+eq+%27Standard_E16_v3%27+and+serviceFamily+eq+%27Compute%27+and+type+eq+%27Consumption%27+and+contains%28meterName%2C%27Low+Priority%27%29+eq+false",
  446. },
  447. }
  448. for _, tc := range testCases {
  449. t.Run(tc.name, func(t *testing.T) {
  450. result := buildAzureRetailPricesURL(tc.region, tc.skuName, tc.currencyCode)
  451. require.Equal(t, tc.expected, result, "URL mismatch for test case: %s", tc.name)
  452. })
  453. }
  454. }
  455. func TestAzureKeyFeaturesOS(t *testing.T) {
  456. tests := []struct {
  457. name string
  458. labels map[string]string
  459. expected string
  460. }{
  461. {
  462. name: "windows node via kubernetes.io/os",
  463. labels: map[string]string{
  464. "kubernetes.io/os": "windows",
  465. "node.kubernetes.io/instance-type": "Standard_D4s_v3",
  466. "topology.kubernetes.io/region": "eastus",
  467. },
  468. expected: "eastus,Standard_D4s_v3,ondemand,windows",
  469. },
  470. {
  471. name: "windows node via beta.kubernetes.io/os",
  472. labels: map[string]string{
  473. "beta.kubernetes.io/os": "windows",
  474. "node.kubernetes.io/instance-type": "Standard_D4s_v3",
  475. "topology.kubernetes.io/region": "eastus",
  476. },
  477. expected: "eastus,Standard_D4s_v3,ondemand,windows",
  478. },
  479. {
  480. name: "linux node",
  481. labels: map[string]string{
  482. "kubernetes.io/os": "linux",
  483. "node.kubernetes.io/instance-type": "Standard_D4s_v3",
  484. "topology.kubernetes.io/region": "eastus",
  485. },
  486. expected: "eastus,Standard_D4s_v3,ondemand",
  487. },
  488. {
  489. name: "no OS label defaults to linux key",
  490. labels: map[string]string{
  491. "node.kubernetes.io/instance-type": "Standard_D4s_v3",
  492. "topology.kubernetes.io/region": "eastus",
  493. },
  494. expected: "eastus,Standard_D4s_v3,ondemand",
  495. },
  496. {
  497. name: "windows case-insensitive",
  498. labels: map[string]string{
  499. "kubernetes.io/os": "Windows",
  500. "node.kubernetes.io/instance-type": "Standard_D4s_v3",
  501. "topology.kubernetes.io/region": "eastus",
  502. },
  503. expected: "eastus,Standard_D4s_v3,ondemand,windows",
  504. },
  505. }
  506. for _, tc := range tests {
  507. t.Run(tc.name, func(t *testing.T) {
  508. key := &azureKey{Labels: tc.labels}
  509. require.Equal(t, tc.expected, key.Features())
  510. })
  511. }
  512. }
  513. func Test_extractAzureVMRetailAndSpotPrices(t *testing.T) {
  514. testCases := []struct {
  515. name string
  516. jsonResponse string
  517. expectedRetail string
  518. expectedWindowsRetail string
  519. expectedSpot string
  520. expectedWindowsSpot string
  521. expectedError bool
  522. expectedErrorMsg string
  523. }{
  524. {
  525. name: "valid response with retail and spot prices",
  526. jsonResponse: `{
  527. "BillingCurrency": "USD",
  528. "CustomerEntityId": "Default",
  529. "CustomerEntityType": "Retail",
  530. "Items": [
  531. {
  532. "currencyCode": "USD",
  533. "tierMinimumUnits": 0,
  534. "retailPrice": 0.384,
  535. "unitPrice": 0.384,
  536. "armRegionName": "eastus2",
  537. "location": "US East 2",
  538. "effectiveStartDate": "2023-01-01T00:00:00Z",
  539. "meterId": "abc-123",
  540. "meterName": "D8ds v5",
  541. "productId": "DZH318Z0BQ4B",
  542. "skuId": "DZH318Z0BQ4B/00G1",
  543. "productName": "Virtual Machines Ddsv5 Series",
  544. "skuName": "D8ds v5",
  545. "serviceName": "Virtual Machines",
  546. "serviceId": "DZH313Z7MMC8",
  547. "serviceFamily": "Compute",
  548. "unitOfMeasure": "1 Hour",
  549. "type": "Consumption",
  550. "isPrimaryMeterRegion": true,
  551. "armSkuName": "Standard_D8ds_v5"
  552. },
  553. {
  554. "currencyCode": "USD",
  555. "tierMinimumUnits": 0,
  556. "retailPrice": 0.0768,
  557. "unitPrice": 0.0768,
  558. "armRegionName": "eastus2",
  559. "location": "US East 2",
  560. "effectiveStartDate": "2023-01-01T00:00:00Z",
  561. "meterId": "def-456",
  562. "meterName": "D8ds v5 Spot",
  563. "productId": "DZH318Z0BQ4B",
  564. "skuId": "DZH318Z0BQ4B/00G2",
  565. "productName": "Virtual Machines Ddsv5 Series",
  566. "skuName": "D8ds v5 Spot",
  567. "serviceName": "Virtual Machines",
  568. "serviceId": "DZH313Z7MMC8",
  569. "serviceFamily": "Compute",
  570. "unitOfMeasure": "1 Hour",
  571. "type": "Consumption",
  572. "isPrimaryMeterRegion": true,
  573. "armSkuName": "Standard_D8ds_v5"
  574. }
  575. ],
  576. "NextPageLink": "",
  577. "Count": 2
  578. }`,
  579. expectedRetail: "0.384000",
  580. expectedSpot: "0.076800",
  581. expectedError: false,
  582. },
  583. {
  584. name: "only retail price available",
  585. jsonResponse: `{
  586. "BillingCurrency": "USD",
  587. "CustomerEntityId": "Default",
  588. "CustomerEntityType": "Retail",
  589. "Items": [
  590. {
  591. "currencyCode": "USD",
  592. "retailPrice": 0.192,
  593. "armRegionName": "westus",
  594. "productName": "Virtual Machines Dsv3 Series",
  595. "skuName": "D4s v3",
  596. "armSkuName": "Standard_D4s_v3"
  597. }
  598. ],
  599. "Count": 1
  600. }`,
  601. expectedRetail: "0.192000",
  602. expectedSpot: "",
  603. expectedError: false,
  604. },
  605. {
  606. name: "only spot price available",
  607. jsonResponse: `{
  608. "BillingCurrency": "USD",
  609. "CustomerEntityId": "Default",
  610. "CustomerEntityType": "Retail",
  611. "Items": [
  612. {
  613. "currencyCode": "USD",
  614. "retailPrice": 0.0384,
  615. "armRegionName": "eastus",
  616. "productName": "Virtual Machines Dsv3 Series",
  617. "skuName": "D4s v3 Spot",
  618. "armSkuName": "Standard_D4s_v3"
  619. }
  620. ],
  621. "Count": 1
  622. }`,
  623. expectedRetail: "",
  624. expectedSpot: "0.038400",
  625. expectedError: false,
  626. },
  627. {
  628. name: "returns separate Windows and Linux prices",
  629. jsonResponse: `{
  630. "BillingCurrency": "USD",
  631. "CustomerEntityId": "Default",
  632. "CustomerEntityType": "Retail",
  633. "Items": [
  634. {
  635. "currencyCode": "USD",
  636. "retailPrice": 0.5,
  637. "armRegionName": "eastus",
  638. "productName": "Virtual Machines Dsv3 Series Windows",
  639. "skuName": "D4s v3",
  640. "armSkuName": "Standard_D4s_v3"
  641. },
  642. {
  643. "currencyCode": "USD",
  644. "retailPrice": 0.192,
  645. "armRegionName": "eastus",
  646. "productName": "Virtual Machines Dsv3 Series",
  647. "skuName": "D4s v3",
  648. "armSkuName": "Standard_D4s_v3"
  649. }
  650. ],
  651. "Count": 2
  652. }`,
  653. expectedRetail: "0.192000",
  654. expectedWindowsRetail: "0.500000",
  655. expectedSpot: "",
  656. expectedWindowsSpot: "",
  657. expectedError: false,
  658. },
  659. {
  660. name: "windows spot price available",
  661. jsonResponse: `{
  662. "BillingCurrency": "USD",
  663. "CustomerEntityId": "Default",
  664. "CustomerEntityType": "Retail",
  665. "Items": [
  666. {
  667. "currencyCode": "USD",
  668. "retailPrice": 0.12,
  669. "armRegionName": "eastus",
  670. "productName": "Virtual Machines Dsv3 Series Windows",
  671. "skuName": "D4s v3 Spot",
  672. "armSkuName": "Standard_D4s_v3"
  673. }
  674. ],
  675. "Count": 1
  676. }`,
  677. expectedRetail: "",
  678. expectedWindowsRetail: "",
  679. expectedSpot: "",
  680. expectedWindowsSpot: "0.120000",
  681. expectedError: false,
  682. },
  683. {
  684. name: "filters out low priority instances",
  685. jsonResponse: `{
  686. "BillingCurrency": "USD",
  687. "CustomerEntityId": "Default",
  688. "CustomerEntityType": "Retail",
  689. "Items": [
  690. {
  691. "currencyCode": "USD",
  692. "retailPrice": 0.05,
  693. "armRegionName": "eastus",
  694. "productName": "Virtual Machines Dsv3 Series",
  695. "skuName": "D4s v3 Low Priority",
  696. "armSkuName": "Standard_D4s_v3"
  697. },
  698. {
  699. "currencyCode": "USD",
  700. "retailPrice": 0.192,
  701. "armRegionName": "eastus",
  702. "productName": "Virtual Machines Dsv3 Series",
  703. "skuName": "D4s v3",
  704. "armSkuName": "Standard_D4s_v3"
  705. }
  706. ],
  707. "Count": 2
  708. }`,
  709. expectedRetail: "0.192000",
  710. expectedSpot: "",
  711. expectedError: false,
  712. },
  713. {
  714. name: "empty items array",
  715. jsonResponse: `{
  716. "BillingCurrency": "USD",
  717. "CustomerEntityId": "Default",
  718. "CustomerEntityType": "Retail",
  719. "Items": [],
  720. "Count": 0
  721. }`,
  722. expectedRetail: "",
  723. expectedSpot: "",
  724. expectedError: false,
  725. },
  726. {
  727. name: "invalid JSON",
  728. jsonResponse: `{
  729. "BillingCurrency": "USD",
  730. "Items": [
  731. {
  732. "retailPrice": "invalid"
  733. }
  734. ]
  735. `,
  736. expectedRetail: "",
  737. expectedSpot: "",
  738. expectedError: true,
  739. expectedErrorMsg: "error unmarshalling data",
  740. },
  741. }
  742. for _, tc := range testCases {
  743. t.Run(tc.name, func(t *testing.T) {
  744. // Create a mock http.Response with the JSON response as the body
  745. resp := &http.Response{
  746. StatusCode: 200,
  747. Body: io.NopCloser(bytes.NewBufferString(tc.jsonResponse)),
  748. }
  749. linuxRetail, windowsRetail, spotPrice, windowsSpotPrice, err := extractAzureVMRetailAndSpotPrices(resp)
  750. if tc.expectedError {
  751. require.Error(t, err)
  752. if tc.expectedErrorMsg != "" {
  753. require.Contains(t, err.Error(), tc.expectedErrorMsg)
  754. }
  755. } else {
  756. require.NoError(t, err)
  757. require.Equal(t, tc.expectedRetail, linuxRetail, "Linux retail price mismatch")
  758. require.Equal(t, tc.expectedWindowsRetail, windowsRetail, "Windows retail price mismatch")
  759. require.Equal(t, tc.expectedSpot, spotPrice, "Spot price mismatch")
  760. require.Equal(t, tc.expectedWindowsSpot, windowsSpotPrice, "Windows spot price mismatch")
  761. }
  762. })
  763. }
  764. }
  765. // failingReader is an io.Reader that always errors, used to exercise the
  766. // response body read-failure path in extractAzureVMRetailAndSpotPrices.
  767. type failingReader struct{}
  768. func (failingReader) Read(_ []byte) (int, error) {
  769. return 0, fmt.Errorf("simulated read failure")
  770. }
  771. func Test_extractAzureVMRetailAndSpotPrices_bodyReadError(t *testing.T) {
  772. resp := &http.Response{
  773. StatusCode: 200,
  774. Body: io.NopCloser(failingReader{}),
  775. }
  776. _, _, _, _, err := extractAzureVMRetailAndSpotPrices(resp)
  777. require.Error(t, err)
  778. require.Contains(t, err.Error(), "error getting response")
  779. }