provider_test.go 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129
  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", "Premium SSD Managed Disks", "P4 LRS Disk", "US East", 2000)
  74. results, err := convertMeterToPricings(info, regions, baseCPUPrice)
  75. require.NoError(t, err)
  76. expectedHourly := formatPrice(tierHourlyFromMonthly(2000))
  77. expected := map[string]*AzurePricing{
  78. "useast,premium_ssd,LRS,P4": {
  79. PV: &models.PV{Cost: expectedHourly, Class: AzureDiskPremiumSSDStorageClass, Region: "useast", Size: "P4"},
  80. },
  81. }
  82. require.Equal(t, expected, results)
  83. ensureDiskClassFallbacks(results)
  84. require.Equal(t, "0.085616", results["useast,premium_ssd"].PV.Cost)
  85. })
  86. t.Run("virtual machines", func(t *testing.T) {
  87. info := meterInfo("Virtual Machines", "Eav4/Easv4 Series", "E96a v4/E96as v4 Low Priority", "JA West", 10)
  88. results, err := convertMeterToPricings(info, regions, baseCPUPrice)
  89. require.NoError(t, err)
  90. expected := map[string]*AzurePricing{
  91. "japanwest,Standard_E96a_v4,preemptible": {
  92. Node: &models.Node{Cost: "10.000000", BaseCPUPrice: "0.30000", UsageType: "preemptible"},
  93. },
  94. "japanwest,Standard_E96as_v4,preemptible": {
  95. Node: &models.Node{Cost: "10.000000", BaseCPUPrice: "0.30000", UsageType: "preemptible"},
  96. },
  97. }
  98. require.Equal(t, expected, results)
  99. })
  100. }
  101. func TestSelectRetailPrice(t *testing.T) {
  102. cases := []struct {
  103. name string
  104. linuxRetailPrice string
  105. windowsRetailPrice string
  106. spotPrice string
  107. windowsSpotPrice string
  108. spot bool
  109. isWindows bool
  110. expected string
  111. expectErr bool
  112. }{
  113. {
  114. name: "windows retail prefers windows price",
  115. linuxRetailPrice: "1.000000",
  116. windowsRetailPrice: "2.000000",
  117. isWindows: true,
  118. expected: "2.000000",
  119. },
  120. {
  121. name: "windows retail falls back to linux when windows missing",
  122. linuxRetailPrice: "1.000000",
  123. isWindows: true,
  124. expected: "1.000000",
  125. },
  126. {
  127. name: "linux retail uses linux price",
  128. linuxRetailPrice: "1.000000",
  129. isWindows: false,
  130. expected: "1.000000",
  131. },
  132. {
  133. name: "windows spot prefers windows spot price",
  134. spotPrice: "0.500000",
  135. windowsSpotPrice: "0.900000",
  136. spot: true,
  137. isWindows: true,
  138. expected: "0.900000",
  139. },
  140. {
  141. name: "windows spot falls back to linux spot when windows missing",
  142. spotPrice: "0.500000",
  143. spot: true,
  144. isWindows: true,
  145. expected: "0.500000",
  146. },
  147. {
  148. name: "linux spot uses linux spot price",
  149. spotPrice: "0.500000",
  150. spot: true,
  151. isWindows: false,
  152. expected: "0.500000",
  153. },
  154. {
  155. name: "spot windows with no spot price falls back to retail",
  156. windowsRetailPrice: "2.000000",
  157. spot: true,
  158. isWindows: true,
  159. expected: "2.000000",
  160. },
  161. {
  162. name: "no price available returns error",
  163. isWindows: true,
  164. expectErr: true,
  165. },
  166. }
  167. for _, tc := range cases {
  168. t.Run(tc.name, func(t *testing.T) {
  169. got, err := selectRetailPrice("eastus", "Standard_D2s_v3", tc.linuxRetailPrice, tc.windowsRetailPrice, tc.spotPrice, tc.windowsSpotPrice, tc.spot, tc.isWindows)
  170. if tc.expectErr {
  171. require.Error(t, err)
  172. return
  173. }
  174. require.NoError(t, err)
  175. require.Equal(t, tc.expected, got)
  176. })
  177. }
  178. }
  179. func TestConvertMeterToPricings_PremiumSSDIgnoresDiskMount(t *testing.T) {
  180. regions := map[string]string{
  181. "centralus": "Central US",
  182. }
  183. baseCPUPrice := "0.30000"
  184. meterInfo := func(category, subcategory, name, region string, rate float64) commerce.MeterInfo {
  185. return commerce.MeterInfo{
  186. MeterCategory: &category,
  187. MeterSubCategory: &subcategory,
  188. MeterName: &name,
  189. MeterRegion: &region,
  190. MeterRates: map[string]*float64{"0": &rate},
  191. }
  192. }
  193. // Order matters: Disk first, Disk Mount second mirrors the Azure Rate Card
  194. // sort order and reproduces the overwrite bug.
  195. meters := []commerce.MeterInfo{
  196. meterInfo("Storage", "Premium SSD Managed Disks", "P4 LRS Disk", "US Central", 5.2795),
  197. meterInfo("Storage", "Premium SSD Managed Disks", "P4 LRS Disk Mount", "US Central", 0.32),
  198. }
  199. result := map[string]*AzurePricing{}
  200. for _, meter := range meters {
  201. pricings, err := convertMeterToPricings(meter, regions, baseCPUPrice)
  202. require.NoError(t, err)
  203. for key, pricing := range pricings {
  204. result[key] = pricing
  205. }
  206. }
  207. pricing := result["centralus,premium_ssd,LRS,P4"]
  208. require.NotNil(t, pricing)
  209. require.NotNil(t, pricing.PV)
  210. // Must reflect the Disk hourly price, not the Disk Mount price.
  211. require.Equal(t, formatPrice(tierHourlyFromMonthly(5.2795)), pricing.PV.Cost,
  212. "premium_ssd P4 pricing must use 'P4 LRS Disk' meter and ignore 'P4 LRS Disk Mount'")
  213. _, mountPresent := result["centralus,premium_ssd,LRS,P4 Mount"]
  214. require.False(t, mountPresent)
  215. }
  216. func TestRemoveManagedDiskTierEntries_KeepWindowsNodeKey(t *testing.T) {
  217. prices := map[string]*AzurePricing{
  218. "centralus,premium_ssd,LRS,P4": {
  219. PV: &models.PV{
  220. Cost: formatPrice(tierHourlyFromMonthly(5.2795)),
  221. Class: AzureDiskPremiumSSDStorageClass,
  222. Region: "centralus",
  223. Size: "P4",
  224. },
  225. },
  226. "centralus,Standard_D2s_v3,ondemand,windows": {
  227. Node: &models.Node{
  228. Cost: "0.300000",
  229. BaseCPUPrice: "0.30000",
  230. UsageType: "ondemand",
  231. },
  232. },
  233. }
  234. removeManagedDiskTierEntries(prices)
  235. _, diskTierPresent := prices["centralus,premium_ssd,LRS,P4"]
  236. require.False(t, diskTierPresent)
  237. _, windowsPresent := prices["centralus,Standard_D2s_v3,ondemand,windows"]
  238. require.True(t, windowsPresent)
  239. }
  240. func TestSelectDiskTier(t *testing.T) {
  241. cases := []struct {
  242. name string
  243. storageClass string
  244. sizeGiB float64
  245. wantTier string
  246. wantOK bool
  247. }{
  248. {name: "10 gib premium maps to P3", storageClass: AzureDiskPremiumSSDStorageClass, sizeGiB: 10, wantTier: "P3", wantOK: true},
  249. {name: "32 gib premium maps to P4", storageClass: AzureDiskPremiumSSDStorageClass, sizeGiB: 32, wantTier: "P4", wantOK: true},
  250. {name: "33 gib premium maps to P6", storageClass: AzureDiskPremiumSSDStorageClass, sizeGiB: 33, wantTier: "P6", wantOK: true},
  251. {name: "100 gib premium maps to P10", storageClass: AzureDiskPremiumSSDStorageClass, sizeGiB: 100, wantTier: "P10", wantOK: true},
  252. {name: "512 gib standard ssd maps to E20", storageClass: AzureDiskStandardSSDStorageClass, sizeGiB: 512, wantTier: "E20", wantOK: true},
  253. {name: "64 gib hdd maps to S6", storageClass: AzureDiskStandardStorageClass, sizeGiB: 64, wantTier: "S6", wantOK: true},
  254. {name: "over max premium clamps to P80", storageClass: AzureDiskPremiumSSDStorageClass, sizeGiB: 40000, wantTier: "P80", wantOK: true},
  255. {name: "zero size", storageClass: AzureDiskPremiumSSDStorageClass, sizeGiB: 0, wantOK: false},
  256. {name: "unknown class", storageClass: "unknown", sizeGiB: 10, wantOK: false},
  257. }
  258. for _, tc := range cases {
  259. t.Run(tc.name, func(t *testing.T) {
  260. tier, ok := selectDiskTier(tc.storageClass, tc.sizeGiB)
  261. require.Equal(t, tc.wantOK, ok)
  262. if tc.wantOK {
  263. require.Equal(t, tc.wantTier, tier.Name)
  264. }
  265. })
  266. }
  267. }
  268. func TestAzurePVPricing_TierAware(t *testing.T) {
  269. az := &Azure{
  270. Pricing: map[string]*AzurePricing{
  271. "centralus,premium_ssd": {
  272. PV: &models.PV{Cost: "0.000226", Class: AzureDiskPremiumSSDStorageClass, Region: "centralus"},
  273. },
  274. "centralus,standard_ssd": {
  275. PV: &models.PV{Cost: "0.000103", Class: AzureDiskStandardSSDStorageClass, Region: "centralus"},
  276. },
  277. "centralus,Custom_LRS": {
  278. PV: &models.PV{Cost: "0.100000", Class: "Custom_LRS", Region: "centralus"},
  279. },
  280. },
  281. managedDiskTierHourly: map[string]float64{
  282. "centralus,premium_ssd,LRS,P3": tierHourlyFromMonthly(2.64),
  283. "centralus,premium_ssd,LRS,P4": tierHourlyFromMonthly(5.2795),
  284. "centralus,premium_ssd,LRS,P10": tierHourlyFromMonthly(19.71),
  285. "centralus,premium_ssd,ZRS,P3": tierHourlyFromMonthly(4.0),
  286. "centralus,standard_ssd,LRS,E20": tierHourlyFromMonthly(38.4),
  287. },
  288. }
  289. t.Run("10 gib premium uses P3 effective rate", func(t *testing.T) {
  290. key := &azurePvKey{
  291. DefaultRegion: "centralus",
  292. SizeGiB: 10,
  293. StorageClassParameters: map[string]string{
  294. "skuname": "Premium_LRS",
  295. },
  296. }
  297. key.resolveSKU()
  298. pv, err := az.PVPricing(key)
  299. require.NoError(t, err)
  300. require.Equal(t, formatPrice(effectiveGiBHourRate(2.64, 10)), pv.Cost)
  301. require.Equal(t, "10", pv.Size)
  302. })
  303. t.Run("100 gib premium uses P10 effective rate", func(t *testing.T) {
  304. key := &azurePvKey{
  305. DefaultRegion: "centralus",
  306. SizeGiB: 100,
  307. StorageClassParameters: map[string]string{
  308. "skuname": "Premium_LRS",
  309. },
  310. }
  311. key.resolveSKU()
  312. pv, err := az.PVPricing(key)
  313. require.NoError(t, err)
  314. require.Equal(t, formatPrice(effectiveGiBHourRate(19.71, 100)), pv.Cost)
  315. })
  316. t.Run("512 gib standard ssd uses E20", func(t *testing.T) {
  317. key := &azurePvKey{
  318. DefaultRegion: "centralus",
  319. SizeGiB: 512,
  320. StorageClassParameters: map[string]string{
  321. "skuname": "StandardSSD_LRS",
  322. },
  323. }
  324. key.resolveSKU()
  325. pv, err := az.PVPricing(key)
  326. require.NoError(t, err)
  327. require.Equal(t, formatPrice(effectiveGiBHourRate(38.4, 512)), pv.Cost)
  328. })
  329. t.Run("premium zrs uses zrs meter", func(t *testing.T) {
  330. key := &azurePvKey{
  331. DefaultRegion: "centralus",
  332. SizeGiB: 10,
  333. StorageClassParameters: map[string]string{
  334. "skuname": "Premium_ZRS",
  335. },
  336. }
  337. key.resolveSKU()
  338. pv, err := az.PVPricing(key)
  339. require.NoError(t, err)
  340. require.Equal(t, formatPrice(effectiveGiBHourRate(4.0, 10)), pv.Cost)
  341. })
  342. t.Run("missing size falls back to class rate", func(t *testing.T) {
  343. key := &azurePvKey{
  344. DefaultRegion: "centralus",
  345. SizeGiB: 0,
  346. StorageClassParameters: map[string]string{
  347. "skuname": "Premium_LRS",
  348. },
  349. }
  350. key.resolveSKU()
  351. pv, err := az.PVPricing(key)
  352. require.NoError(t, err)
  353. require.Equal(t, "0.000226", pv.Cost)
  354. })
  355. t.Run("missing preferred tier falls back to class rate when no larger tier is available", func(t *testing.T) {
  356. azMissing := &Azure{
  357. Pricing: map[string]*AzurePricing{
  358. "centralus,premium_ssd": {
  359. PV: &models.PV{Cost: "0.000226", Class: AzureDiskPremiumSSDStorageClass, Region: "centralus"},
  360. },
  361. },
  362. managedDiskTierHourly: map[string]float64{
  363. "centralus,premium_ssd,LRS,P4": tierHourlyFromMonthly(5.2795),
  364. },
  365. }
  366. key := &azurePvKey{
  367. DefaultRegion: "centralus",
  368. SizeGiB: 100,
  369. StorageClassParameters: map[string]string{
  370. "skuname": "Premium_LRS",
  371. },
  372. }
  373. key.resolveSKU()
  374. pv, err := azMissing.PVPricing(key)
  375. require.NoError(t, err)
  376. require.Equal(t, "0.000226", pv.Cost)
  377. })
  378. t.Run("zrs missing size falls back to lrs class rate", func(t *testing.T) {
  379. key := &azurePvKey{
  380. DefaultRegion: "centralus",
  381. SizeGiB: 0,
  382. StorageClassParameters: map[string]string{
  383. "skuname": "Premium_ZRS",
  384. },
  385. }
  386. key.resolveSKU()
  387. pv, err := az.PVPricing(key)
  388. require.NoError(t, err)
  389. require.Equal(t, "0.000226", pv.Cost)
  390. })
  391. t.Run("unknown skuname preserves legacy lookup behavior", func(t *testing.T) {
  392. key := &azurePvKey{
  393. DefaultRegion: "centralus",
  394. SizeGiB: 1,
  395. StorageClassParameters: map[string]string{
  396. "skuname": "Custom_LRS",
  397. },
  398. }
  399. key.resolveSKU()
  400. pv, err := az.PVPricing(key)
  401. require.NoError(t, err)
  402. require.Equal(t, "0.100000", pv.Cost)
  403. })
  404. }
  405. func TestEnsureDiskClassFallbacks_UsesSmallestAvailableTier(t *testing.T) {
  406. prices := map[string]*AzurePricing{
  407. "useast,premium_ssd,LRS,P4": {
  408. PV: &models.PV{Cost: formatPrice(tierHourlyFromMonthly(5.2795)), Class: AzureDiskPremiumSSDStorageClass, Region: "useast", Size: "P4"},
  409. },
  410. "useast,premium_ssd,LRS,P10": {
  411. PV: &models.PV{Cost: formatPrice(tierHourlyFromMonthly(19.71)), Class: AzureDiskPremiumSSDStorageClass, Region: "useast", Size: "P10"},
  412. },
  413. }
  414. ensureDiskClassFallbacks(prices)
  415. require.NotNil(t, prices["useast,premium_ssd"])
  416. require.Equal(t, formatPrice(effectiveGiBHourRate(5.2795, 32)), prices["useast,premium_ssd"].PV.Cost)
  417. }
  418. func TestManagedDiskTierPricing_IsNotExportedInPricingMap(t *testing.T) {
  419. prices := map[string]*AzurePricing{
  420. "centralus,premium_ssd,LRS,P4": {
  421. PV: &models.PV{Cost: formatPrice(tierHourlyFromMonthly(5.2795)), Class: AzureDiskPremiumSSDStorageClass, Region: "centralus", Size: "P4"},
  422. },
  423. "centralus,premium_ssd": {
  424. PV: &models.PV{Cost: "0.000226", Class: AzureDiskPremiumSSDStorageClass, Region: "centralus"},
  425. },
  426. }
  427. tierHourly := collectManagedDiskTierHourly(prices)
  428. removeManagedDiskTierEntries(prices)
  429. require.Contains(t, tierHourly, "centralus,premium_ssd,LRS,P4")
  430. require.NotContains(t, prices, "centralus,premium_ssd,LRS,P4")
  431. require.Contains(t, prices, "centralus,premium_ssd")
  432. }
  433. func TestFindCostForDisk_TierAware(t *testing.T) {
  434. var loc = "centralus"
  435. var size int32 = 100
  436. az := &Azure{
  437. managedDiskTierHourly: map[string]float64{
  438. "centralus,premium_ssd,LRS,P10": tierHourlyFromMonthly(19.71),
  439. },
  440. }
  441. cost, err := az.findCostForDisk(&compute.Disk{
  442. Location: &loc,
  443. Sku: &compute.DiskSku{
  444. Name: "Premium_LRS",
  445. },
  446. DiskProperties: &compute.DiskProperties{
  447. DiskSizeGB: &size,
  448. },
  449. })
  450. require.NoError(t, err)
  451. require.InDelta(t, 19.71, cost, 0.0001)
  452. }
  453. func TestAzure_findCostForDisk(t *testing.T) {
  454. var loc string = "location"
  455. var size int32 = 1
  456. az := &Azure{
  457. Pricing: map[string]*AzurePricing{
  458. "location,nil": nil,
  459. "location,nilpv": {
  460. PV: nil,
  461. },
  462. "location,ssd": {
  463. PV: &models.PV{
  464. Cost: "1",
  465. },
  466. },
  467. },
  468. }
  469. testCases := []struct {
  470. name string
  471. disk *compute.Disk
  472. exp float64
  473. expErr error
  474. }{
  475. {
  476. "disk is nil",
  477. nil,
  478. 0.0,
  479. fmt.Errorf("disk is empty"),
  480. },
  481. {
  482. "nil location",
  483. &compute.Disk{
  484. Location: nil,
  485. Sku: &compute.DiskSku{
  486. Name: "ssd",
  487. },
  488. DiskProperties: &compute.DiskProperties{
  489. DiskSizeGB: &size,
  490. },
  491. },
  492. 0.0,
  493. fmt.Errorf("failed to find pricing for key: ,ssd"),
  494. },
  495. {
  496. "nil disk properties",
  497. &compute.Disk{
  498. Location: &loc,
  499. Sku: &compute.DiskSku{
  500. Name: "ssd",
  501. },
  502. DiskProperties: nil,
  503. },
  504. 0.0,
  505. fmt.Errorf("disk properties are nil"),
  506. },
  507. {
  508. "nil disk size",
  509. &compute.Disk{
  510. Location: &loc,
  511. Sku: &compute.DiskSku{
  512. Name: "ssd",
  513. },
  514. DiskProperties: &compute.DiskProperties{
  515. DiskSizeGB: nil,
  516. },
  517. },
  518. 0.0,
  519. fmt.Errorf("disk size is nil"),
  520. },
  521. {
  522. "sku does not exist",
  523. &compute.Disk{
  524. Location: &loc,
  525. Sku: &compute.DiskSku{
  526. Name: "doesnotexist",
  527. },
  528. DiskProperties: &compute.DiskProperties{
  529. DiskSizeGB: &size,
  530. },
  531. },
  532. 0.0,
  533. fmt.Errorf("failed to find pricing for key: location,doesnotexist"),
  534. },
  535. {
  536. "pricing is nil",
  537. &compute.Disk{
  538. Sku: &compute.DiskSku{
  539. Name: "nil",
  540. },
  541. DiskProperties: &compute.DiskProperties{
  542. DiskSizeGB: &size,
  543. },
  544. },
  545. 0.0,
  546. fmt.Errorf("failed to find pricing for key: location,nil"),
  547. },
  548. {
  549. "pricing.PV is nil",
  550. &compute.Disk{
  551. Sku: &compute.DiskSku{
  552. Name: "nilpv",
  553. },
  554. DiskProperties: &compute.DiskProperties{
  555. DiskSizeGB: &size,
  556. },
  557. },
  558. 0.0,
  559. fmt.Errorf("pricing for key 'location,nilpv' has nil PV"),
  560. },
  561. {
  562. "valid (ssd)",
  563. &compute.Disk{
  564. Location: &loc,
  565. Sku: &compute.DiskSku{
  566. Name: "ssd",
  567. },
  568. DiskProperties: &compute.DiskProperties{
  569. DiskSizeGB: &size,
  570. },
  571. },
  572. 730.0,
  573. nil,
  574. },
  575. {
  576. "nil sku",
  577. &compute.Disk{
  578. Location: nil,
  579. Sku: nil,
  580. DiskProperties: nil,
  581. },
  582. 0.0,
  583. fmt.Errorf("disk sku is nil"),
  584. },
  585. }
  586. for _, tc := range testCases {
  587. t.Run(tc.name, func(t *testing.T) {
  588. act, actErr := az.findCostForDisk(tc.disk)
  589. if actErr != nil && tc.expErr == nil {
  590. t.Fatalf("unexpected error: %s", actErr)
  591. }
  592. if tc.expErr != nil && actErr == nil {
  593. t.Fatalf("missing expected error: %s", tc.expErr)
  594. }
  595. if !mathutil.Approximately(tc.exp, act) {
  596. t.Fatalf("expected value %f; got %f", tc.exp, act)
  597. }
  598. })
  599. }
  600. }
  601. func TestAzurePVKeyFeatures(t *testing.T) {
  602. tests := []struct {
  603. name string
  604. parameters map[string]string
  605. expected string
  606. }{
  607. {
  608. name: "managed disk storageaccounttype premium",
  609. parameters: map[string]string{
  610. "storageaccounttype": "Premium_LRS",
  611. },
  612. expected: "eastus,premium_ssd",
  613. },
  614. {
  615. name: "managed disk csi skuname premium",
  616. parameters: map[string]string{
  617. "skuname": "Premium_LRS",
  618. },
  619. expected: "eastus,premium_ssd",
  620. },
  621. {
  622. name: "managed disk csi skuname premium zrs",
  623. parameters: map[string]string{
  624. "skuname": "Premium_ZRS",
  625. },
  626. expected: "eastus,premium_ssd",
  627. },
  628. {
  629. name: "managed disk csi skuname standard ssd",
  630. parameters: map[string]string{
  631. "skuname": "StandardSSD_LRS",
  632. },
  633. expected: "eastus,standard_ssd",
  634. },
  635. {
  636. name: "managed disk csi skuname standard hdd",
  637. parameters: map[string]string{
  638. "skuname": "Standard_LRS",
  639. },
  640. expected: "eastus,standard_hdd",
  641. },
  642. {
  643. name: "azure files skuName remains file pricing",
  644. parameters: map[string]string{
  645. "skuName": "Premium_LRS",
  646. },
  647. expected: "eastus,premium_smb",
  648. },
  649. {
  650. name: "azure files skuName standard remains file pricing",
  651. parameters: map[string]string{
  652. "skuName": "Standard_LRS",
  653. },
  654. expected: "eastus,standard_smb",
  655. },
  656. {
  657. name: "unknown skuname keeps raw legacy class key",
  658. parameters: map[string]string{
  659. "skuname": "Custom_LRS",
  660. },
  661. expected: "eastus,Custom_LRS",
  662. },
  663. }
  664. for _, tc := range tests {
  665. t.Run(tc.name, func(t *testing.T) {
  666. key := &azurePvKey{
  667. StorageClassParameters: tc.parameters,
  668. DefaultRegion: "eastus",
  669. }
  670. require.Equal(t, tc.expected, key.Features())
  671. })
  672. }
  673. }
  674. func Test_buildAzureRetailPricesURL(t *testing.T) {
  675. testCases := []struct {
  676. name string
  677. region string
  678. skuName string
  679. currencyCode string
  680. expected string
  681. }{
  682. {
  683. name: "all parameters provided",
  684. region: "eastus",
  685. skuName: "Standard_D8ds_v5",
  686. currencyCode: "USD",
  687. 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",
  688. },
  689. {
  690. name: "no currency code",
  691. region: "westus",
  692. skuName: "Standard_D4s_v3",
  693. currencyCode: "",
  694. 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",
  695. },
  696. {
  697. name: "no region",
  698. region: "",
  699. skuName: "Standard_D8s_v3",
  700. currencyCode: "EUR",
  701. 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",
  702. },
  703. {
  704. name: "no sku name",
  705. region: "northeurope",
  706. skuName: "",
  707. currencyCode: "GBP",
  708. 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",
  709. },
  710. {
  711. name: "only currency code",
  712. region: "",
  713. skuName: "",
  714. currencyCode: "JPY",
  715. 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",
  716. },
  717. {
  718. name: "no parameters",
  719. region: "",
  720. skuName: "",
  721. currencyCode: "",
  722. 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",
  723. },
  724. {
  725. name: "region with special characters",
  726. region: "south-central-us",
  727. skuName: "Standard_B2s",
  728. currencyCode: "USD",
  729. 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",
  730. },
  731. {
  732. name: "sku name with underscores",
  733. region: "eastus2",
  734. skuName: "Standard_E16_v3",
  735. currencyCode: "CAD",
  736. 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",
  737. },
  738. }
  739. for _, tc := range testCases {
  740. t.Run(tc.name, func(t *testing.T) {
  741. result := buildAzureRetailPricesURL(tc.region, tc.skuName, tc.currencyCode)
  742. require.Equal(t, tc.expected, result, "URL mismatch for test case: %s", tc.name)
  743. })
  744. }
  745. }
  746. func TestAzureKeyFeaturesOS(t *testing.T) {
  747. tests := []struct {
  748. name string
  749. labels map[string]string
  750. expected string
  751. }{
  752. {
  753. name: "windows node via kubernetes.io/os",
  754. labels: map[string]string{
  755. "kubernetes.io/os": "windows",
  756. "node.kubernetes.io/instance-type": "Standard_D4s_v3",
  757. "topology.kubernetes.io/region": "eastus",
  758. },
  759. expected: "eastus,Standard_D4s_v3,ondemand,windows",
  760. },
  761. {
  762. name: "windows node via beta.kubernetes.io/os",
  763. labels: map[string]string{
  764. "beta.kubernetes.io/os": "windows",
  765. "node.kubernetes.io/instance-type": "Standard_D4s_v3",
  766. "topology.kubernetes.io/region": "eastus",
  767. },
  768. expected: "eastus,Standard_D4s_v3,ondemand,windows",
  769. },
  770. {
  771. name: "linux node",
  772. labels: map[string]string{
  773. "kubernetes.io/os": "linux",
  774. "node.kubernetes.io/instance-type": "Standard_D4s_v3",
  775. "topology.kubernetes.io/region": "eastus",
  776. },
  777. expected: "eastus,Standard_D4s_v3,ondemand",
  778. },
  779. {
  780. name: "no OS label defaults to linux key",
  781. labels: map[string]string{
  782. "node.kubernetes.io/instance-type": "Standard_D4s_v3",
  783. "topology.kubernetes.io/region": "eastus",
  784. },
  785. expected: "eastus,Standard_D4s_v3,ondemand",
  786. },
  787. {
  788. name: "windows case-insensitive",
  789. labels: map[string]string{
  790. "kubernetes.io/os": "Windows",
  791. "node.kubernetes.io/instance-type": "Standard_D4s_v3",
  792. "topology.kubernetes.io/region": "eastus",
  793. },
  794. expected: "eastus,Standard_D4s_v3,ondemand,windows",
  795. },
  796. }
  797. for _, tc := range tests {
  798. t.Run(tc.name, func(t *testing.T) {
  799. key := &azureKey{Labels: tc.labels}
  800. require.Equal(t, tc.expected, key.Features())
  801. })
  802. }
  803. }
  804. func Test_extractAzureVMRetailAndSpotPrices(t *testing.T) {
  805. testCases := []struct {
  806. name string
  807. jsonResponse string
  808. expectedRetail string
  809. expectedWindowsRetail string
  810. expectedSpot string
  811. expectedWindowsSpot string
  812. expectedError bool
  813. expectedErrorMsg string
  814. }{
  815. {
  816. name: "valid response with retail and spot prices",
  817. jsonResponse: `{
  818. "BillingCurrency": "USD",
  819. "CustomerEntityId": "Default",
  820. "CustomerEntityType": "Retail",
  821. "Items": [
  822. {
  823. "currencyCode": "USD",
  824. "tierMinimumUnits": 0,
  825. "retailPrice": 0.384,
  826. "unitPrice": 0.384,
  827. "armRegionName": "eastus2",
  828. "location": "US East 2",
  829. "effectiveStartDate": "2023-01-01T00:00:00Z",
  830. "meterId": "abc-123",
  831. "meterName": "D8ds v5",
  832. "productId": "DZH318Z0BQ4B",
  833. "skuId": "DZH318Z0BQ4B/00G1",
  834. "productName": "Virtual Machines Ddsv5 Series",
  835. "skuName": "D8ds v5",
  836. "serviceName": "Virtual Machines",
  837. "serviceId": "DZH313Z7MMC8",
  838. "serviceFamily": "Compute",
  839. "unitOfMeasure": "1 Hour",
  840. "type": "Consumption",
  841. "isPrimaryMeterRegion": true,
  842. "armSkuName": "Standard_D8ds_v5"
  843. },
  844. {
  845. "currencyCode": "USD",
  846. "tierMinimumUnits": 0,
  847. "retailPrice": 0.0768,
  848. "unitPrice": 0.0768,
  849. "armRegionName": "eastus2",
  850. "location": "US East 2",
  851. "effectiveStartDate": "2023-01-01T00:00:00Z",
  852. "meterId": "def-456",
  853. "meterName": "D8ds v5 Spot",
  854. "productId": "DZH318Z0BQ4B",
  855. "skuId": "DZH318Z0BQ4B/00G2",
  856. "productName": "Virtual Machines Ddsv5 Series",
  857. "skuName": "D8ds v5 Spot",
  858. "serviceName": "Virtual Machines",
  859. "serviceId": "DZH313Z7MMC8",
  860. "serviceFamily": "Compute",
  861. "unitOfMeasure": "1 Hour",
  862. "type": "Consumption",
  863. "isPrimaryMeterRegion": true,
  864. "armSkuName": "Standard_D8ds_v5"
  865. }
  866. ],
  867. "NextPageLink": "",
  868. "Count": 2
  869. }`,
  870. expectedRetail: "0.384000",
  871. expectedSpot: "0.076800",
  872. expectedError: false,
  873. },
  874. {
  875. name: "only retail price available",
  876. jsonResponse: `{
  877. "BillingCurrency": "USD",
  878. "CustomerEntityId": "Default",
  879. "CustomerEntityType": "Retail",
  880. "Items": [
  881. {
  882. "currencyCode": "USD",
  883. "retailPrice": 0.192,
  884. "armRegionName": "westus",
  885. "productName": "Virtual Machines Dsv3 Series",
  886. "skuName": "D4s v3",
  887. "armSkuName": "Standard_D4s_v3"
  888. }
  889. ],
  890. "Count": 1
  891. }`,
  892. expectedRetail: "0.192000",
  893. expectedSpot: "",
  894. expectedError: false,
  895. },
  896. {
  897. name: "only spot price available",
  898. jsonResponse: `{
  899. "BillingCurrency": "USD",
  900. "CustomerEntityId": "Default",
  901. "CustomerEntityType": "Retail",
  902. "Items": [
  903. {
  904. "currencyCode": "USD",
  905. "retailPrice": 0.0384,
  906. "armRegionName": "eastus",
  907. "productName": "Virtual Machines Dsv3 Series",
  908. "skuName": "D4s v3 Spot",
  909. "armSkuName": "Standard_D4s_v3"
  910. }
  911. ],
  912. "Count": 1
  913. }`,
  914. expectedRetail: "",
  915. expectedSpot: "0.038400",
  916. expectedError: false,
  917. },
  918. {
  919. name: "returns separate Windows and Linux prices",
  920. jsonResponse: `{
  921. "BillingCurrency": "USD",
  922. "CustomerEntityId": "Default",
  923. "CustomerEntityType": "Retail",
  924. "Items": [
  925. {
  926. "currencyCode": "USD",
  927. "retailPrice": 0.5,
  928. "armRegionName": "eastus",
  929. "productName": "Virtual Machines Dsv3 Series Windows",
  930. "skuName": "D4s v3",
  931. "armSkuName": "Standard_D4s_v3"
  932. },
  933. {
  934. "currencyCode": "USD",
  935. "retailPrice": 0.192,
  936. "armRegionName": "eastus",
  937. "productName": "Virtual Machines Dsv3 Series",
  938. "skuName": "D4s v3",
  939. "armSkuName": "Standard_D4s_v3"
  940. }
  941. ],
  942. "Count": 2
  943. }`,
  944. expectedRetail: "0.192000",
  945. expectedWindowsRetail: "0.500000",
  946. expectedSpot: "",
  947. expectedWindowsSpot: "",
  948. expectedError: false,
  949. },
  950. {
  951. name: "windows spot price available",
  952. jsonResponse: `{
  953. "BillingCurrency": "USD",
  954. "CustomerEntityId": "Default",
  955. "CustomerEntityType": "Retail",
  956. "Items": [
  957. {
  958. "currencyCode": "USD",
  959. "retailPrice": 0.12,
  960. "armRegionName": "eastus",
  961. "productName": "Virtual Machines Dsv3 Series Windows",
  962. "skuName": "D4s v3 Spot",
  963. "armSkuName": "Standard_D4s_v3"
  964. }
  965. ],
  966. "Count": 1
  967. }`,
  968. expectedRetail: "",
  969. expectedWindowsRetail: "",
  970. expectedSpot: "",
  971. expectedWindowsSpot: "0.120000",
  972. expectedError: false,
  973. },
  974. {
  975. name: "filters out low priority instances",
  976. jsonResponse: `{
  977. "BillingCurrency": "USD",
  978. "CustomerEntityId": "Default",
  979. "CustomerEntityType": "Retail",
  980. "Items": [
  981. {
  982. "currencyCode": "USD",
  983. "retailPrice": 0.05,
  984. "armRegionName": "eastus",
  985. "productName": "Virtual Machines Dsv3 Series",
  986. "skuName": "D4s v3 Low Priority",
  987. "armSkuName": "Standard_D4s_v3"
  988. },
  989. {
  990. "currencyCode": "USD",
  991. "retailPrice": 0.192,
  992. "armRegionName": "eastus",
  993. "productName": "Virtual Machines Dsv3 Series",
  994. "skuName": "D4s v3",
  995. "armSkuName": "Standard_D4s_v3"
  996. }
  997. ],
  998. "Count": 2
  999. }`,
  1000. expectedRetail: "0.192000",
  1001. expectedSpot: "",
  1002. expectedError: false,
  1003. },
  1004. {
  1005. name: "empty items array",
  1006. jsonResponse: `{
  1007. "BillingCurrency": "USD",
  1008. "CustomerEntityId": "Default",
  1009. "CustomerEntityType": "Retail",
  1010. "Items": [],
  1011. "Count": 0
  1012. }`,
  1013. expectedRetail: "",
  1014. expectedSpot: "",
  1015. expectedError: false,
  1016. },
  1017. {
  1018. name: "invalid JSON",
  1019. jsonResponse: `{
  1020. "BillingCurrency": "USD",
  1021. "Items": [
  1022. {
  1023. "retailPrice": "invalid"
  1024. }
  1025. ]
  1026. `,
  1027. expectedRetail: "",
  1028. expectedSpot: "",
  1029. expectedError: true,
  1030. expectedErrorMsg: "error unmarshalling data",
  1031. },
  1032. }
  1033. for _, tc := range testCases {
  1034. t.Run(tc.name, func(t *testing.T) {
  1035. // Create a mock http.Response with the JSON response as the body
  1036. resp := &http.Response{
  1037. StatusCode: 200,
  1038. Body: io.NopCloser(bytes.NewBufferString(tc.jsonResponse)),
  1039. }
  1040. linuxRetail, windowsRetail, spotPrice, windowsSpotPrice, err := extractAzureVMRetailAndSpotPrices(resp)
  1041. if tc.expectedError {
  1042. require.Error(t, err)
  1043. if tc.expectedErrorMsg != "" {
  1044. require.Contains(t, err.Error(), tc.expectedErrorMsg)
  1045. }
  1046. } else {
  1047. require.NoError(t, err)
  1048. require.Equal(t, tc.expectedRetail, linuxRetail, "Linux retail price mismatch")
  1049. require.Equal(t, tc.expectedWindowsRetail, windowsRetail, "Windows retail price mismatch")
  1050. require.Equal(t, tc.expectedSpot, spotPrice, "Spot price mismatch")
  1051. require.Equal(t, tc.expectedWindowsSpot, windowsSpotPrice, "Windows spot price mismatch")
  1052. }
  1053. })
  1054. }
  1055. }
  1056. // failingReader is an io.Reader that always errors, used to exercise the
  1057. // response body read-failure path in extractAzureVMRetailAndSpotPrices.
  1058. type failingReader struct{}
  1059. func (failingReader) Read(_ []byte) (int, error) {
  1060. return 0, fmt.Errorf("simulated read failure")
  1061. }
  1062. func Test_extractAzureVMRetailAndSpotPrices_bodyReadError(t *testing.T) {
  1063. resp := &http.Response{
  1064. StatusCode: 200,
  1065. Body: io.NopCloser(failingReader{}),
  1066. }
  1067. _, _, _, _, err := extractAzureVMRetailAndSpotPrices(resp)
  1068. require.Error(t, err)
  1069. require.Contains(t, err.Error(), "error getting response")
  1070. }