1+ <#
2+ . SYNOPSIS
3+ Take a list of meter IDs and a list of regions, and return the pricing information for the
4+ equivalent Azure meters in those regions.
5+ Requires ImportExcel module if Excel output is requested.
6+ PS1> Install-Module -Name ImportExcel
7+
8+ . PARAMETER resourceFile
9+ A JSON file containing the resource cost information. This file is created by the Get-CostInformation.ps1 script.
10+
11+ . PARAMETER targetRregions
12+ An array of regions to compare.
13+
14+ . PARAMETER outputFormat
15+ The format of the output file. Supported formats are 'json', 'excel', 'csv' or 'console'. If not specified, output is written to the console.
16+
17+ . PARAMETER outputFilePrefix
18+ The prefix of the output file to be created. The extension will be added automatically based on the output format. Not used if outputFormat is 'console'.
19+
20+ . EXAMPLE
21+ .\Perform-RegionComparison.ps1 -regions @("eastus", "westeurope", "southeastasia")
22+ #>
23+
24+ param (
25+ [string []]$resourceFile = " resources.json" , # the JSON file containing the resource cost information
26+ [string []]$regions , # array of regions to compare
27+ [string ]$outputFormat = " console" , # json, excel or csv. If not specified, output is written to the console
28+ [string ]$outputFilePrefix = " region_comparison" # the output file prefix. Not used if outputFormat is not specified
29+ )
30+
31+ function Write-ToFileOrConsole {
32+ param (
33+ [string ]$outputFormat ,
34+ [string ]$outputFilePrefix ,
35+ [object []]$data ,
36+ [string ]$label
37+ )
38+
39+ switch ($outputFormat ) {
40+ " json" {
41+ $outputFilePrefix += " _$label "
42+ if ($outputFilePrefix -notmatch ' \.json$' ) {
43+ $outputFilePrefix += " .json"
44+ }
45+ $data | ConvertTo-Json | Out-File - FilePath $outputFilePrefix - Encoding UTF8
46+ Write-Output " $ ( $data.Count ) rows written to $outputFilePrefix "
47+ }
48+ " csv" {
49+ $outputFilePrefix += " _$label "
50+ if ($outputFilePrefix -notmatch ' \.csv$' ) {
51+ $outputFilePrefix += " .csv"
52+ }
53+ $data | Export-Csv - Path $outputFilePrefix - NoTypeInformation - Encoding UTF8
54+ Write-Output " $ ( $data.Count ) rows written to $outputFilePrefix "
55+ }
56+ " excel" {
57+ if ($outputFilePrefix -notmatch ' \.xlsx$' ) {
58+ $outputFilePrefix += " .xlsx"
59+ }
60+ $data | Export-Excel - WorksheetName $label - TableName $label - Path .\$outputFilePrefix
61+ Write-Output " $ ( $data.Count ) rows written to tab $label of $outputFilePrefix "
62+ }
63+ Default {
64+ # Display the table in the console
65+ $data | Format-Table - AutoSize
66+ }
67+ }
68+
69+ }
70+
71+ # Internal script parameters
72+ # $ErrorActionPreference = "Stop"
73+ # $VerbosePreference = "Continue"
74+ $meterIdBatchSize = 10
75+ $regionBatchSize = 10
76+ $baseUri = " https://prices.azure.com/api/retail/prices?api-version=2023-01-01-preview"
77+
78+ # Input checking
79+ # Check that the resource file exists
80+ if (-not (Test-Path - Path $resourceFile )) {
81+ Write-Error " Resource file '$resourceFile ' does not exist."
82+ exit 1
83+ }
84+
85+ # Check that at least one region is specified
86+ if ($null -eq $regions -or $regions.Count -eq 0 ) {
87+ Write-Error " At least one region must be specified."
88+ exit 1
89+ }
90+
91+ # Check that the requested output format is valid
92+ if ($outputFormat -notin @ (" json" , " csv" , " excel" , " console" )) {
93+ Write-Error " Output format '$outputFormat ' is not supported. Supported formats are 'json', 'csv', 'excel', and 'console'."
94+ exit 1
95+ }
96+
97+ # If output format is specified, check that the output file prefix is also specified
98+ if ($null -ne $outputFormat -and $null -eq $outputFilePrefix -or $outputFilePrefix -eq " " ) {
99+ Write-Error " Output file prefix must be specified if output format is specified."
100+ exit 1
101+ }
102+
103+ # If output format is excel, check that the ImportExcel module is installed
104+ if ($outputFormat -eq " excel" -and -not (Get-Module - ListAvailable - Name ImportExcel)) {
105+ Write-Error " ImportExcel module is not installed. Please install it using 'Install-Module -Name ImportExcel'."
106+ exit 1
107+ }
108+
109+ # Read the resource file into a variable
110+ $jsonContent = Get-Content - Path $resourceFile - Raw
111+ $resourceData = $jsonContent | ConvertFrom-Json
112+ if ($null -eq $resourceData -or $resourceData.Count -eq 0 ) {
113+ Write-Error " No data found in $resourceFile . Please run the Get-AzureServices.ps1 collection script first."
114+ exit 1
115+ }
116+
117+ # Extract the unique meter IDs from the resource data
118+ $meterIds = $resourceData.meterIds | Sort-Object - Unique
119+ if ($null -eq $meterIds -or $meterIds.Count -eq 0 ) {
120+ Write-Error " No meter IDs found in $resourceFile . Please run the Get-AzureServices.ps1 collection script first."
121+ exit 1
122+ }
123+
124+ Write-Verbose " Meter IDs: $ ( $meterIds -join ' , ' ) "
125+ Write-Verbose " Regions: $ ( $regions -join ' , ' ) "
126+
127+ # Query the API using meterID as the filter to get the product ID and Meter Name
128+ # For some services this will give unique results, but for others there may be multiple entries
129+ # some meterIDs stretch across regions although this is unusual
130+ # usually tierMinimumUnits is the most common reason for this
131+
132+ Write-Verbose " Querying pricing API for meter names and product IDs..."
133+
134+ $inputTable = @ ()
135+
136+ # Process meterIDs in batches to avoid URL length issues
137+ for ($i = 0 ; $i -lt $meterIds.Count ; $i += $meterIdBatchSize ) {
138+ $batchMeterIds = $meterIds [$i .. ([math ]::Min($i + $meterIdBatchSize - 1 , $meterIds.Count - 1 ))]
139+ $filterString = ' $filter=currencyCode eq '' USD'' '
140+ $filterString += " and type eq 'Consumption'"
141+ $filterString += " and isPrimaryMeterRegion eq true"
142+ $filterString += " and (meterId eq '$ ( $batchMeterIds -join " ' or meterId eq '" ) ')"
143+
144+ Write-Verbose " Filter string in use is $filterString "
145+
146+ $uri = " $baseUri &$filterString "
147+
148+ $queryResult = Invoke-RestMethod - Uri $uri - Method Get
149+
150+ if ($null -eq $queryResult ) {
151+ Write-Error " Failed to retrieve data for the supplied meter IDs"
152+ exit 1
153+ }
154+
155+ # The tierMinimumUnits property is used to indicate bulk discounts for the same meter ID
156+ # For comparison purposes we will use the lowest tierMinimumUnits value for each meter ID
157+ foreach ($item in $queryResult.Items | Select-Object meterId, meterName, productId, skuName, armRegionName, unitOfMeasure - Unique) {
158+ $row = [PSCustomObject ]@ {
159+ " MeterId" = $item.meterId
160+ # "PreTaxCost" = ($resourceData | Where-Object { $_.ResourceGuid -eq $item.meterId } | Measure-Object -Property PreTaxCost -Sum).Sum
161+ " MeterName" = $item.meterName
162+ " ProductId" = $item.productId
163+ " SkuName" = $item.skuName
164+ " ArmRegionName" = $item.armRegionName
165+ " TierMinimumUnits" = ($queryResult.Items | Where-Object { $_.meterId -eq $item.meterId }).tierMinimumUnits | Sort-Object | Select-Object - First 1
166+ " unitOfMeasure" = $item.unitOfMeasure
167+ }
168+ $inputTable += $row
169+ }
170+ }
171+
172+ Write-ToFileOrConsole - outputFormat $outputFormat - outputFilePrefix $outputFilePrefix - data $inputTable - label " inputs"
173+
174+ # Using the input table, query the pricing API for each meterName+productId+skuName combination across the specified regions
175+ Write-Output " Querying pricing API for region comparisons. Please be patient..."
176+
177+ $resultTable = @ ()
178+
179+ # Azure pricing has the unfortunate characteristic that some meter IDs have different units of measure in different regions.
180+ # Instead of trying to handle this and convert between units, it is better to exclude them and flag them for manual processing.
181+ $uomError = $false
182+ $uomErrorTable = @ ()
183+
184+ $counter = 0
185+ foreach ($inputRow in $inputTable ) {
186+ $counter ++
187+ Write-Progress - Activity " Processing meter IDs" - Status " Meter ID $counter of $ ( $inputTable.Count ) " - PercentComplete (($counter / $inputTable.Count ) * 100 )
188+ # Add the source region to the regions to get source pricing information
189+ $tempRegions = $regions + $inputRow.ArmRegionName | Sort-Object - Unique
190+
191+ # Process regions in batches to avoid URL length issues
192+ for ($i = 0 ; $i -lt $tempRegions.Count ; $i += $regionBatchSize ) {
193+ $regionBatch = $tempRegions [$i .. ([math ]::Min($i + $regionBatchSize - 1 , $tempRegions.Count - 1 ))]
194+
195+ $filterString = ' $filter=currencyCode eq '' USD'' '
196+ $filterString += " and type eq 'Consumption'"
197+ $filterString += " and isPrimaryMeterRegion eq true"
198+ $filterString += " and meterName eq '$ ( $inputRow.MeterName ) '"
199+ $filterString += " and productId eq '$ ( $inputRow.ProductId ) '"
200+ $filterString += " and skuName eq '$ ( $inputRow.SkuName ) '"
201+ $filterString += " and (armRegionName eq '$ ( $regionBatch -join " ' or armRegionName eq '" ) ')"
202+
203+ Write-Verbose " Filter string in use is $filterString "
204+
205+ $uri = " $baseUri &$filterString "
206+ $queryResult = Invoke-RestMethod - Uri $uri - Method Get
207+
208+ $batchProgress = [int ][Math ]::Truncate($i / 10 ) + 1
209+ Write-Verbose " Query for meter ID $ ( $inputRow.MeterId ) batch $batchProgress returned $ ( $queryResult.Count ) items"
210+
211+ # Exclude rows with retail price zero
212+ $queryResult.Items = $queryResult.Items | Where-Object { $_.retailPrice -gt 0 }
213+
214+ # If there are multiple entries for the same meterId, filter to only those with the same tierMinimumUnits as the original region
215+ $queryResult.Items = $queryResult.Items | Where-Object { $_.tierMinimumUnits -eq $inputRow.TierMinimumUnits }
216+
217+ # Check if rows have a different unit of measure from the input row
218+ $uomCheck = $queryResult.Items | Where-Object { $_.unitOfMeasure -ne $inputRow.unitOfMeasure } | Select-Object meterId, unitOfMeasure
219+ if ($uomCheck.Count -gt 0 ) {
220+ $uomError = $true
221+ foreach ($item in $uomCheck ) {
222+ $row = [PSCustomObject ]@ {
223+ " OrigMeterID" = $inputRow.MeterId
224+ " OrigUoM" = $inputRow.unitOfMeasure
225+ " TargetMeterID" = $item.meterId
226+ " TargetUoM" = $item.unitOfMeasure
227+ }
228+ $uomErrorTable += $row
229+ }
230+ }
231+
232+ # Remove rows where the unit of measure is different from the original
233+ $queryResult.Items = $queryResult.Items | Where-Object { $_.unitOfMeasure -eq $inputRow.unitOfMeasure }
234+
235+ foreach ($item in $queryResult.Items ) {
236+ $row = [PSCustomObject ]@ {
237+ " OrigMeterId" = $inputRow.MeterId
238+ " OrigRegion" = if ($inputRow.ArmRegionName -eq $item.armRegionName ) { " X" }
239+ # "OrigCost" = $inputRow.PreTaxCost
240+ " MeterId" = $item.meterId
241+ " ServiceFamily" = $item.serviceFamily
242+ " ServiceName" = $item.serviceName
243+ " MeterName" = $item.meterName
244+ " ProductId" = $item.productId
245+ " ProductName" = $item.productName
246+ " SkuName" = $item.skuName
247+ " UnitOfMeasure" = $item.unitOfMeasure
248+ " RetailPrice" = $item.retailPrice
249+ " Region" = $item.armRegionName
250+ }
251+ $resultTable += $row
252+ }
253+ }
254+ }
255+
256+ # If there were any UoM errors, write them to the output
257+ if ($uomError ) {
258+ Write-Output " Warning: Different unit of measure detected between source and target region(s). These target meters will be excluded from the comparison."
259+ Write-Output " Please review the uomerrors output and handle these meters manually."
260+ Write-ToFileOrConsole - outputFormat $outputFormat - outputFilePrefix $outputFilePrefix - data $uomErrorTable - label " uomerrors"
261+ }
262+
263+ # If at this point there are duplicate combinations of MeterName, ProductId, SkuName then
264+ # this indicates that there are multiple target meters for the same region, which will cause issues later
265+ $tempTable1 = $resultTable | Where-Object { $_.OrigRegion -eq " X" } | Select-Object - Property OrigMeterId, MeterName, ProductId, SkuName | Sort-Object
266+ $tempTable2 = $tempTable1 | Sort-Object - Property OrigMeterId, MeterName, ProductId, SkuName - Unique
267+
268+ if ($tempTable1.Count -ne $tempTable2.Count ) {
269+ Write-Error " There are duplicate target meters for the same region. Please report this issue to the script author."
270+ Write-ToFileOrConsole - outputFormat $outputFormat - outputFilePrefix $outputFilePrefix - data $resultTable - label " RegionComparison"
271+ exit
272+ }
273+
274+ # For each row, add the percentage difference in retail price between the current row and the original region for that meter ID
275+ foreach ($row in $resultTable ) {
276+ $origPrice = ($resultTable | Where-Object { $_.OrigMeterId -eq $row.OrigMeterId -and $_.OrigRegion -eq " X" }).RetailPrice
277+ $row | Add-Member - MemberType NoteProperty - Name " PriceDiffToOrigin" - Value ($row.RetailPrice - $origPrice )
278+ if ($origPrice -ne 0 ) {
279+ $row | Add-Member - MemberType NoteProperty - Name " PercentageDiffToOrigin" - Value ([math ]::Round((($row.RetailPrice - $origPrice ) / $origPrice ), 2 ))
280+ # $row | Add-Member -MemberType NoteProperty -Name "CostDiffToOrigin" -Value ([math]::Round(($row.PercentageDiffToOrigin * $row.OrigCost), 2))
281+ } else {
282+ $row | Add-Member - MemberType NoteProperty - Name " PercentageDiffToOrigin" - Value $null
283+ # $row | Add-Member -MemberType NoteProperty -Name "CostDiffToOrigin" -Value $null
284+ }
285+ }
286+
287+ Write-ToFileOrConsole - outputFormat $outputFormat - outputFilePrefix $outputFilePrefix - data $resultTable - label " prices"
288+
289+ <# Future functionality - removed for now
290+ # Construct a table showing the total possible savings for each target region
291+ $savingsTable = @()
292+ foreach ($region in $regions) {
293+ $totalOrigCost = ($resultTable | Where-Object { $_.OrigRegion -eq "X" }).OrigCost | Measure-Object -Sum | Select-Object -ExpandProperty Sum
294+ $regionSavings = ($resultTable | Where-Object { $_.Region -eq $region }).CostDiffToOrigin | Measure-Object -Sum | Select-Object -ExpandProperty Sum
295+ $percentageSavings = if ($totalOrigCost -ne 0) { [math]::Round(($regionSavings / $totalOrigCost), 4) } else { $null }
296+ $row = [PSCustomObject]@{
297+ "Region" = $region
298+ "OriginalCost" = [math]::Round($totalOrigCost, 2)
299+ "Difference" = [math]::Round($regionSavings, 2)
300+ "PercentageDifference" = $percentageSavings
301+ }
302+ $savingsTable += $row
303+ }
304+
305+ Write-ToFileOrConsole -outputFormat $outputFormat -outputFilePrefix $outputFilePrefix -data $savingsTable -label "savings"
306+ #>
307+
308+ # Construct a summary table for only the original meterIDs and region that shows the cheapest region(s) and the price difference
309+ $summaryTable = @ ()
310+ foreach ($inputRow in $inputTable ) {
311+ $origRow = $resultTable | Where-Object { $_.OrigMeterId -eq $inputRow.MeterId -and $_.OrigRegion -eq " X" }
312+ $origPrice = if ($null -ne $origRow ) { $origRow.RetailPrice } else { $null }
313+ if ($null -ne $origRow ) {
314+ $row = [PSCustomObject ]@ {
315+ " MeterId" = $origRow.MeterId
316+ " MeterName" = $origRow.MeterName
317+ " ProductName" = $origRow.ProductName
318+ " SkuName" = $origRow.SkuName
319+ " OriginalRegion" = $origRow.Region
320+ " LowerPricedRegions" = ($resultTable | Where-Object { $_.OrigMeterId -eq $inputRow.MeterId -and $_.RetailPrice -lt $origPrice }).Region -join " , "
321+ " SamePricedRegions" = ($resultTable | Where-Object { $_.OrigMeterId -eq $inputRow.MeterId -and $_.RetailPrice -eq $origPrice -and $_.Region -ne $origRow.Region }).Region -join " , "
322+ " HigherPricedRegions" = ($resultTable | Where-Object { $_.OrigMeterId -eq $inputRow.MeterId -and $_.RetailPrice -gt $origPrice }).Region -join " , "
323+ }
324+ $summaryTable += $row
325+ }
326+ }
327+
328+ Write-ToFileOrConsole - outputFormat $outputFormat - outputFilePrefix $outputFilePrefix - data $summaryTable - label " pricemap"
329+ Write-Output " Script completed successfully."
0 commit comments