provider_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  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 Test_buildAzureRetailPricesURL(t *testing.T) {
  241. testCases := []struct {
  242. name string
  243. region string
  244. skuName string
  245. currencyCode string
  246. expected string
  247. }{
  248. {
  249. name: "all parameters provided",
  250. region: "eastus",
  251. skuName: "Standard_D8ds_v5",
  252. currencyCode: "USD",
  253. 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",
  254. },
  255. {
  256. name: "no currency code",
  257. region: "westus",
  258. skuName: "Standard_D4s_v3",
  259. currencyCode: "",
  260. 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",
  261. },
  262. {
  263. name: "no region",
  264. region: "",
  265. skuName: "Standard_D8s_v3",
  266. currencyCode: "EUR",
  267. 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",
  268. },
  269. {
  270. name: "no sku name",
  271. region: "northeurope",
  272. skuName: "",
  273. currencyCode: "GBP",
  274. 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",
  275. },
  276. {
  277. name: "only currency code",
  278. region: "",
  279. skuName: "",
  280. currencyCode: "JPY",
  281. 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",
  282. },
  283. {
  284. name: "no parameters",
  285. region: "",
  286. skuName: "",
  287. currencyCode: "",
  288. 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",
  289. },
  290. {
  291. name: "region with special characters",
  292. region: "south-central-us",
  293. skuName: "Standard_B2s",
  294. currencyCode: "USD",
  295. 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",
  296. },
  297. {
  298. name: "sku name with underscores",
  299. region: "eastus2",
  300. skuName: "Standard_E16_v3",
  301. currencyCode: "CAD",
  302. 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",
  303. },
  304. }
  305. for _, tc := range testCases {
  306. t.Run(tc.name, func(t *testing.T) {
  307. result := buildAzureRetailPricesURL(tc.region, tc.skuName, tc.currencyCode)
  308. require.Equal(t, tc.expected, result, "URL mismatch for test case: %s", tc.name)
  309. })
  310. }
  311. }
  312. func Test_extractAzureVMRetailAndSpotPrices(t *testing.T) {
  313. testCases := []struct {
  314. name string
  315. jsonResponse string
  316. expectedRetail string
  317. expectedSpot string
  318. expectedError bool
  319. expectedErrorMsg string
  320. }{
  321. {
  322. name: "valid response with retail and spot prices",
  323. jsonResponse: `{
  324. "BillingCurrency": "USD",
  325. "CustomerEntityId": "Default",
  326. "CustomerEntityType": "Retail",
  327. "Items": [
  328. {
  329. "currencyCode": "USD",
  330. "tierMinimumUnits": 0,
  331. "retailPrice": 0.384,
  332. "unitPrice": 0.384,
  333. "armRegionName": "eastus2",
  334. "location": "US East 2",
  335. "effectiveStartDate": "2023-01-01T00:00:00Z",
  336. "meterId": "abc-123",
  337. "meterName": "D8ds v5",
  338. "productId": "DZH318Z0BQ4B",
  339. "skuId": "DZH318Z0BQ4B/00G1",
  340. "productName": "Virtual Machines Ddsv5 Series",
  341. "skuName": "D8ds v5",
  342. "serviceName": "Virtual Machines",
  343. "serviceId": "DZH313Z7MMC8",
  344. "serviceFamily": "Compute",
  345. "unitOfMeasure": "1 Hour",
  346. "type": "Consumption",
  347. "isPrimaryMeterRegion": true,
  348. "armSkuName": "Standard_D8ds_v5"
  349. },
  350. {
  351. "currencyCode": "USD",
  352. "tierMinimumUnits": 0,
  353. "retailPrice": 0.0768,
  354. "unitPrice": 0.0768,
  355. "armRegionName": "eastus2",
  356. "location": "US East 2",
  357. "effectiveStartDate": "2023-01-01T00:00:00Z",
  358. "meterId": "def-456",
  359. "meterName": "D8ds v5 Spot",
  360. "productId": "DZH318Z0BQ4B",
  361. "skuId": "DZH318Z0BQ4B/00G2",
  362. "productName": "Virtual Machines Ddsv5 Series",
  363. "skuName": "D8ds v5 Spot",
  364. "serviceName": "Virtual Machines",
  365. "serviceId": "DZH313Z7MMC8",
  366. "serviceFamily": "Compute",
  367. "unitOfMeasure": "1 Hour",
  368. "type": "Consumption",
  369. "isPrimaryMeterRegion": true,
  370. "armSkuName": "Standard_D8ds_v5"
  371. }
  372. ],
  373. "NextPageLink": "",
  374. "Count": 2
  375. }`,
  376. expectedRetail: "0.384000",
  377. expectedSpot: "0.076800",
  378. expectedError: false,
  379. },
  380. {
  381. name: "only retail price available",
  382. jsonResponse: `{
  383. "BillingCurrency": "USD",
  384. "CustomerEntityId": "Default",
  385. "CustomerEntityType": "Retail",
  386. "Items": [
  387. {
  388. "currencyCode": "USD",
  389. "retailPrice": 0.192,
  390. "armRegionName": "westus",
  391. "productName": "Virtual Machines Dsv3 Series",
  392. "skuName": "D4s v3",
  393. "armSkuName": "Standard_D4s_v3"
  394. }
  395. ],
  396. "Count": 1
  397. }`,
  398. expectedRetail: "0.192000",
  399. expectedSpot: "",
  400. expectedError: false,
  401. },
  402. {
  403. name: "only spot price available",
  404. jsonResponse: `{
  405. "BillingCurrency": "USD",
  406. "CustomerEntityId": "Default",
  407. "CustomerEntityType": "Retail",
  408. "Items": [
  409. {
  410. "currencyCode": "USD",
  411. "retailPrice": 0.0384,
  412. "armRegionName": "eastus",
  413. "productName": "Virtual Machines Dsv3 Series",
  414. "skuName": "D4s v3 Spot",
  415. "armSkuName": "Standard_D4s_v3"
  416. }
  417. ],
  418. "Count": 1
  419. }`,
  420. expectedRetail: "",
  421. expectedSpot: "0.038400",
  422. expectedError: false,
  423. },
  424. {
  425. name: "filters out Windows instances",
  426. jsonResponse: `{
  427. "BillingCurrency": "USD",
  428. "CustomerEntityId": "Default",
  429. "CustomerEntityType": "Retail",
  430. "Items": [
  431. {
  432. "currencyCode": "USD",
  433. "retailPrice": 0.5,
  434. "armRegionName": "eastus",
  435. "productName": "Virtual Machines Dsv3 Series Windows",
  436. "skuName": "D4s v3",
  437. "armSkuName": "Standard_D4s_v3"
  438. },
  439. {
  440. "currencyCode": "USD",
  441. "retailPrice": 0.192,
  442. "armRegionName": "eastus",
  443. "productName": "Virtual Machines Dsv3 Series",
  444. "skuName": "D4s v3",
  445. "armSkuName": "Standard_D4s_v3"
  446. }
  447. ],
  448. "Count": 2
  449. }`,
  450. expectedRetail: "0.192000",
  451. expectedSpot: "",
  452. expectedError: false,
  453. },
  454. {
  455. name: "filters out low priority instances",
  456. jsonResponse: `{
  457. "BillingCurrency": "USD",
  458. "CustomerEntityId": "Default",
  459. "CustomerEntityType": "Retail",
  460. "Items": [
  461. {
  462. "currencyCode": "USD",
  463. "retailPrice": 0.05,
  464. "armRegionName": "eastus",
  465. "productName": "Virtual Machines Dsv3 Series",
  466. "skuName": "D4s v3 Low Priority",
  467. "armSkuName": "Standard_D4s_v3"
  468. },
  469. {
  470. "currencyCode": "USD",
  471. "retailPrice": 0.192,
  472. "armRegionName": "eastus",
  473. "productName": "Virtual Machines Dsv3 Series",
  474. "skuName": "D4s v3",
  475. "armSkuName": "Standard_D4s_v3"
  476. }
  477. ],
  478. "Count": 2
  479. }`,
  480. expectedRetail: "0.192000",
  481. expectedSpot: "",
  482. expectedError: false,
  483. },
  484. {
  485. name: "empty items array",
  486. jsonResponse: `{
  487. "BillingCurrency": "USD",
  488. "CustomerEntityId": "Default",
  489. "CustomerEntityType": "Retail",
  490. "Items": [],
  491. "Count": 0
  492. }`,
  493. expectedRetail: "",
  494. expectedSpot: "",
  495. expectedError: false,
  496. },
  497. {
  498. name: "invalid JSON",
  499. jsonResponse: `{
  500. "BillingCurrency": "USD",
  501. "Items": [
  502. {
  503. "retailPrice": "invalid"
  504. }
  505. ]
  506. `,
  507. expectedRetail: "",
  508. expectedSpot: "",
  509. expectedError: true,
  510. expectedErrorMsg: "error unmarshalling data",
  511. },
  512. }
  513. for _, tc := range testCases {
  514. t.Run(tc.name, func(t *testing.T) {
  515. // Create a mock http.Response with the JSON response as the body
  516. resp := &http.Response{
  517. StatusCode: 200,
  518. Body: io.NopCloser(bytes.NewBufferString(tc.jsonResponse)),
  519. }
  520. retailPrice, spotPrice, err := extractAzureVMRetailAndSpotPrices(resp)
  521. if tc.expectedError {
  522. require.Error(t, err)
  523. if tc.expectedErrorMsg != "" {
  524. require.Contains(t, err.Error(), tc.expectedErrorMsg)
  525. }
  526. } else {
  527. require.NoError(t, err)
  528. require.Equal(t, tc.expectedRetail, retailPrice, "Retail price mismatch")
  529. require.Equal(t, tc.expectedSpot, spotPrice, "Spot price mismatch")
  530. }
  531. })
  532. }
  533. }