Skip to content
This repository was archived by the owner on Oct 29, 2025. It is now read-only.

Commit ccdb1e9

Browse files
authored
Merge pull request #35 from dohughes-msft/region-comparison-cost
New region comparison
2 parents f5b7263 + c246a1e commit ccdb1e9

3 files changed

Lines changed: 378 additions & 5 deletions

File tree

3-CostInformation/Get-CostInformation.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
The stem of the output file to be created. The extension will be added automatically based on the output format. Not used if outputFormat is 'console'.
2121
2222
.PARAMETER outputFormat
23-
The format of the output file. Supported formats are 'json', 'csv', and 'console'. Default is 'json'.
23+
The format of the output file. Supported formats are 'json', 'csv', 'excel' and 'console'. Default is 'json'.
2424
2525
.PARAMETER testMode
2626
If set, only the first subscription ID will be used to retrieve a quick result set for testing purposes.
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
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

Comments
 (0)