provider_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  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. require.Nil(t, results)
  65. })
  66. t.Run("storage", func(t *testing.T) {
  67. info := meterInfo("Storage", "Some SSD type", "P4 are good", "US East", 2000)
  68. results, err := convertMeterToPricings(info, regions, baseCPUPrice)
  69. require.NoError(t, err)
  70. expected := map[string]*AzurePricing{
  71. "useast,premium_ssd": {
  72. PV: &models.PV{Cost: "0.085616", Region: "useast"},
  73. },
  74. }
  75. require.Equal(t, expected, results)
  76. })
  77. t.Run("virtual machines", func(t *testing.T) {
  78. info := meterInfo("Virtual Machines", "Eav4/Easv4 Series", "E96a v4/E96as v4 Low Priority", "JA West", 10)
  79. results, err := convertMeterToPricings(info, regions, baseCPUPrice)
  80. require.NoError(t, err)
  81. expected := map[string]*AzurePricing{
  82. "japanwest,Standard_E96a_v4,preemptible": {
  83. Node: &models.Node{Cost: "10.000000", BaseCPUPrice: "0.30000", UsageType: "preemptible"},
  84. },
  85. "japanwest,Standard_E96as_v4,preemptible": {
  86. Node: &models.Node{Cost: "10.000000", BaseCPUPrice: "0.30000", UsageType: "preemptible"},
  87. },
  88. }
  89. require.Equal(t, expected, results)
  90. })
  91. }
  92. func TestAzure_findCostForDisk(t *testing.T) {
  93. var loc string = "location"
  94. var size int32 = 1
  95. az := &Azure{
  96. Pricing: map[string]*AzurePricing{
  97. "location,nil": nil,
  98. "location,nilpv": {
  99. PV: nil,
  100. },
  101. "location,ssd": {
  102. PV: &models.PV{
  103. Cost: "1",
  104. },
  105. },
  106. },
  107. }
  108. testCases := []struct {
  109. name string
  110. disk *compute.Disk
  111. exp float64
  112. expErr error
  113. }{
  114. {
  115. "disk is nil",
  116. nil,
  117. 0.0,
  118. fmt.Errorf("disk is empty"),
  119. },
  120. {
  121. "nil location",
  122. &compute.Disk{
  123. Location: nil,
  124. Sku: &compute.DiskSku{
  125. Name: "ssd",
  126. },
  127. DiskProperties: &compute.DiskProperties{
  128. DiskSizeGB: &size,
  129. },
  130. },
  131. 0.0,
  132. fmt.Errorf("failed to find pricing for key: ,ssd"),
  133. },
  134. {
  135. "nil disk properties",
  136. &compute.Disk{
  137. Location: &loc,
  138. Sku: &compute.DiskSku{
  139. Name: "ssd",
  140. },
  141. DiskProperties: nil,
  142. },
  143. 0.0,
  144. fmt.Errorf("disk properties are nil"),
  145. },
  146. {
  147. "nil disk size",
  148. &compute.Disk{
  149. Location: &loc,
  150. Sku: &compute.DiskSku{
  151. Name: "ssd",
  152. },
  153. DiskProperties: &compute.DiskProperties{
  154. DiskSizeGB: nil,
  155. },
  156. },
  157. 0.0,
  158. fmt.Errorf("disk size is nil"),
  159. },
  160. {
  161. "sku does not exist",
  162. &compute.Disk{
  163. Location: &loc,
  164. Sku: &compute.DiskSku{
  165. Name: "doesnotexist",
  166. },
  167. DiskProperties: &compute.DiskProperties{
  168. DiskSizeGB: &size,
  169. },
  170. },
  171. 0.0,
  172. fmt.Errorf("failed to find pricing for key: location,doesnotexist"),
  173. },
  174. {
  175. "pricing is nil",
  176. &compute.Disk{
  177. Sku: &compute.DiskSku{
  178. Name: "nil",
  179. },
  180. DiskProperties: &compute.DiskProperties{
  181. DiskSizeGB: &size,
  182. },
  183. },
  184. 0.0,
  185. fmt.Errorf("failed to find pricing for key: location,nil"),
  186. },
  187. {
  188. "pricing.PV is nil",
  189. &compute.Disk{
  190. Sku: &compute.DiskSku{
  191. Name: "nilpv",
  192. },
  193. DiskProperties: &compute.DiskProperties{
  194. DiskSizeGB: &size,
  195. },
  196. },
  197. 0.0,
  198. fmt.Errorf("pricing for key 'location,nilpv' has nil PV"),
  199. },
  200. {
  201. "valid (ssd)",
  202. &compute.Disk{
  203. Location: &loc,
  204. Sku: &compute.DiskSku{
  205. Name: "ssd",
  206. },
  207. DiskProperties: &compute.DiskProperties{
  208. DiskSizeGB: &size,
  209. },
  210. },
  211. 730.0,
  212. nil,
  213. },
  214. {
  215. "nil sku",
  216. &compute.Disk{
  217. Location: nil,
  218. Sku: nil,
  219. DiskProperties: nil,
  220. },
  221. 0.0,
  222. fmt.Errorf("disk sku is nil"),
  223. },
  224. }
  225. for _, tc := range testCases {
  226. t.Run(tc.name, func(t *testing.T) {
  227. act, actErr := az.findCostForDisk(tc.disk)
  228. if actErr != nil && tc.expErr == nil {
  229. t.Fatalf("unexpected error: %s", actErr)
  230. }
  231. if tc.expErr != nil && actErr == nil {
  232. t.Fatalf("missing expected error: %s", tc.expErr)
  233. }
  234. if !mathutil.Approximately(tc.exp, act) {
  235. t.Fatalf("expected value %f; got %f", tc.exp, act)
  236. }
  237. })
  238. }
  239. }
  240. func TestAzurePVKeyFeatures(t *testing.T) {
  241. tests := []struct {
  242. name string
  243. parameters map[string]string
  244. expected string
  245. }{
  246. {
  247. name: "managed disk storageaccounttype premium",
  248. parameters: map[string]string{
  249. "storageaccounttype": "Premium_LRS",
  250. },
  251. expected: "eastus,premium_ssd",
  252. },
  253. {
  254. name: "managed disk csi skuname premium",
  255. parameters: map[string]string{
  256. "skuname": "Premium_LRS",
  257. },
  258. expected: "eastus,premium_ssd",
  259. },
  260. {
  261. name: "managed disk csi skuname standard ssd",
  262. parameters: map[string]string{
  263. "skuname": "StandardSSD_LRS",
  264. },
  265. expected: "eastus,standard_ssd",
  266. },
  267. {
  268. name: "managed disk csi skuname standard hdd",
  269. parameters: map[string]string{
  270. "skuname": "Standard_LRS",
  271. },
  272. expected: "eastus,standard_hdd",
  273. },
  274. {
  275. name: "azure files skuName remains file pricing",
  276. parameters: map[string]string{
  277. "skuName": "Premium_LRS",
  278. },
  279. expected: "eastus,premium_smb",
  280. },
  281. {
  282. name: "azure files skuName standard remains file pricing",
  283. parameters: map[string]string{
  284. "skuName": "Standard_LRS",
  285. },
  286. expected: "eastus,standard_smb",
  287. },
  288. }
  289. for _, tc := range tests {
  290. t.Run(tc.name, func(t *testing.T) {
  291. key := &azurePvKey{
  292. StorageClassParameters: tc.parameters,
  293. DefaultRegion: "eastus",
  294. }
  295. require.Equal(t, tc.expected, key.Features())
  296. })
  297. }
  298. }
  299. func Test_buildAzureRetailPricesURL(t *testing.T) {
  300. testCases := []struct {
  301. name string
  302. region string
  303. skuName string
  304. currencyCode string
  305. expected string
  306. }{
  307. {
  308. name: "all parameters provided",
  309. region: "eastus",
  310. skuName: "Standard_D8ds_v5",
  311. currencyCode: "USD",
  312. 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",
  313. },
  314. {
  315. name: "no currency code",
  316. region: "westus",
  317. skuName: "Standard_D4s_v3",
  318. currencyCode: "",
  319. 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",
  320. },
  321. {
  322. name: "no region",
  323. region: "",
  324. skuName: "Standard_D8s_v3",
  325. currencyCode: "EUR",
  326. 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",
  327. },
  328. {
  329. name: "no sku name",
  330. region: "northeurope",
  331. skuName: "",
  332. currencyCode: "GBP",
  333. 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",
  334. },
  335. {
  336. name: "only currency code",
  337. region: "",
  338. skuName: "",
  339. currencyCode: "JPY",
  340. 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",
  341. },
  342. {
  343. name: "no parameters",
  344. region: "",
  345. skuName: "",
  346. currencyCode: "",
  347. 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",
  348. },
  349. {
  350. name: "region with special characters",
  351. region: "south-central-us",
  352. skuName: "Standard_B2s",
  353. currencyCode: "USD",
  354. 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",
  355. },
  356. {
  357. name: "sku name with underscores",
  358. region: "eastus2",
  359. skuName: "Standard_E16_v3",
  360. currencyCode: "CAD",
  361. 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",
  362. },
  363. }
  364. for _, tc := range testCases {
  365. t.Run(tc.name, func(t *testing.T) {
  366. result := buildAzureRetailPricesURL(tc.region, tc.skuName, tc.currencyCode)
  367. require.Equal(t, tc.expected, result, "URL mismatch for test case: %s", tc.name)
  368. })
  369. }
  370. }
  371. func Test_extractAzureVMRetailAndSpotPrices(t *testing.T) {
  372. testCases := []struct {
  373. name string
  374. jsonResponse string
  375. expectedRetail string
  376. expectedSpot string
  377. expectedError bool
  378. expectedErrorMsg string
  379. }{
  380. {
  381. name: "valid response with retail and spot prices",
  382. jsonResponse: `{
  383. "BillingCurrency": "USD",
  384. "CustomerEntityId": "Default",
  385. "CustomerEntityType": "Retail",
  386. "Items": [
  387. {
  388. "currencyCode": "USD",
  389. "tierMinimumUnits": 0,
  390. "retailPrice": 0.384,
  391. "unitPrice": 0.384,
  392. "armRegionName": "eastus2",
  393. "location": "US East 2",
  394. "effectiveStartDate": "2023-01-01T00:00:00Z",
  395. "meterId": "abc-123",
  396. "meterName": "D8ds v5",
  397. "productId": "DZH318Z0BQ4B",
  398. "skuId": "DZH318Z0BQ4B/00G1",
  399. "productName": "Virtual Machines Ddsv5 Series",
  400. "skuName": "D8ds v5",
  401. "serviceName": "Virtual Machines",
  402. "serviceId": "DZH313Z7MMC8",
  403. "serviceFamily": "Compute",
  404. "unitOfMeasure": "1 Hour",
  405. "type": "Consumption",
  406. "isPrimaryMeterRegion": true,
  407. "armSkuName": "Standard_D8ds_v5"
  408. },
  409. {
  410. "currencyCode": "USD",
  411. "tierMinimumUnits": 0,
  412. "retailPrice": 0.0768,
  413. "unitPrice": 0.0768,
  414. "armRegionName": "eastus2",
  415. "location": "US East 2",
  416. "effectiveStartDate": "2023-01-01T00:00:00Z",
  417. "meterId": "def-456",
  418. "meterName": "D8ds v5 Spot",
  419. "productId": "DZH318Z0BQ4B",
  420. "skuId": "DZH318Z0BQ4B/00G2",
  421. "productName": "Virtual Machines Ddsv5 Series",
  422. "skuName": "D8ds v5 Spot",
  423. "serviceName": "Virtual Machines",
  424. "serviceId": "DZH313Z7MMC8",
  425. "serviceFamily": "Compute",
  426. "unitOfMeasure": "1 Hour",
  427. "type": "Consumption",
  428. "isPrimaryMeterRegion": true,
  429. "armSkuName": "Standard_D8ds_v5"
  430. }
  431. ],
  432. "NextPageLink": "",
  433. "Count": 2
  434. }`,
  435. expectedRetail: "0.384000",
  436. expectedSpot: "0.076800",
  437. expectedError: false,
  438. },
  439. {
  440. name: "only retail price available",
  441. jsonResponse: `{
  442. "BillingCurrency": "USD",
  443. "CustomerEntityId": "Default",
  444. "CustomerEntityType": "Retail",
  445. "Items": [
  446. {
  447. "currencyCode": "USD",
  448. "retailPrice": 0.192,
  449. "armRegionName": "westus",
  450. "productName": "Virtual Machines Dsv3 Series",
  451. "skuName": "D4s v3",
  452. "armSkuName": "Standard_D4s_v3"
  453. }
  454. ],
  455. "Count": 1
  456. }`,
  457. expectedRetail: "0.192000",
  458. expectedSpot: "",
  459. expectedError: false,
  460. },
  461. {
  462. name: "only spot price available",
  463. jsonResponse: `{
  464. "BillingCurrency": "USD",
  465. "CustomerEntityId": "Default",
  466. "CustomerEntityType": "Retail",
  467. "Items": [
  468. {
  469. "currencyCode": "USD",
  470. "retailPrice": 0.0384,
  471. "armRegionName": "eastus",
  472. "productName": "Virtual Machines Dsv3 Series",
  473. "skuName": "D4s v3 Spot",
  474. "armSkuName": "Standard_D4s_v3"
  475. }
  476. ],
  477. "Count": 1
  478. }`,
  479. expectedRetail: "",
  480. expectedSpot: "0.038400",
  481. expectedError: false,
  482. },
  483. {
  484. name: "filters out Windows instances",
  485. jsonResponse: `{
  486. "BillingCurrency": "USD",
  487. "CustomerEntityId": "Default",
  488. "CustomerEntityType": "Retail",
  489. "Items": [
  490. {
  491. "currencyCode": "USD",
  492. "retailPrice": 0.5,
  493. "armRegionName": "eastus",
  494. "productName": "Virtual Machines Dsv3 Series Windows",
  495. "skuName": "D4s v3",
  496. "armSkuName": "Standard_D4s_v3"
  497. },
  498. {
  499. "currencyCode": "USD",
  500. "retailPrice": 0.192,
  501. "armRegionName": "eastus",
  502. "productName": "Virtual Machines Dsv3 Series",
  503. "skuName": "D4s v3",
  504. "armSkuName": "Standard_D4s_v3"
  505. }
  506. ],
  507. "Count": 2
  508. }`,
  509. expectedRetail: "0.192000",
  510. expectedSpot: "",
  511. expectedError: false,
  512. },
  513. {
  514. name: "filters out low priority instances",
  515. jsonResponse: `{
  516. "BillingCurrency": "USD",
  517. "CustomerEntityId": "Default",
  518. "CustomerEntityType": "Retail",
  519. "Items": [
  520. {
  521. "currencyCode": "USD",
  522. "retailPrice": 0.05,
  523. "armRegionName": "eastus",
  524. "productName": "Virtual Machines Dsv3 Series",
  525. "skuName": "D4s v3 Low Priority",
  526. "armSkuName": "Standard_D4s_v3"
  527. },
  528. {
  529. "currencyCode": "USD",
  530. "retailPrice": 0.192,
  531. "armRegionName": "eastus",
  532. "productName": "Virtual Machines Dsv3 Series",
  533. "skuName": "D4s v3",
  534. "armSkuName": "Standard_D4s_v3"
  535. }
  536. ],
  537. "Count": 2
  538. }`,
  539. expectedRetail: "0.192000",
  540. expectedSpot: "",
  541. expectedError: false,
  542. },
  543. {
  544. name: "empty items array",
  545. jsonResponse: `{
  546. "BillingCurrency": "USD",
  547. "CustomerEntityId": "Default",
  548. "CustomerEntityType": "Retail",
  549. "Items": [],
  550. "Count": 0
  551. }`,
  552. expectedRetail: "",
  553. expectedSpot: "",
  554. expectedError: false,
  555. },
  556. {
  557. name: "invalid JSON",
  558. jsonResponse: `{
  559. "BillingCurrency": "USD",
  560. "Items": [
  561. {
  562. "retailPrice": "invalid"
  563. }
  564. ]
  565. `,
  566. expectedRetail: "",
  567. expectedSpot: "",
  568. expectedError: true,
  569. expectedErrorMsg: "error unmarshalling data",
  570. },
  571. }
  572. for _, tc := range testCases {
  573. t.Run(tc.name, func(t *testing.T) {
  574. // Create a mock http.Response with the JSON response as the body
  575. resp := &http.Response{
  576. StatusCode: 200,
  577. Body: io.NopCloser(bytes.NewBufferString(tc.jsonResponse)),
  578. }
  579. retailPrice, spotPrice, err := extractAzureVMRetailAndSpotPrices(resp)
  580. if tc.expectedError {
  581. require.Error(t, err)
  582. if tc.expectedErrorMsg != "" {
  583. require.Contains(t, err.Error(), tc.expectedErrorMsg)
  584. }
  585. } else {
  586. require.NoError(t, err)
  587. require.Equal(t, tc.expectedRetail, retailPrice, "Retail price mismatch")
  588. require.Equal(t, tc.expectedSpot, spotPrice, "Spot price mismatch")
  589. }
  590. })
  591. }
  592. }