From 56da5251512144ed67af58473d16c5fb4af7daf3 Mon Sep 17 00:00:00 2001 From: Donovan Hughes Date: Fri, 5 Sep 2025 17:18:52 +0200 Subject: [PATCH 1/6] New region comparison --- 3-CostInformation/Get-CostInformation.ps1 | 2 +- .../Perform-RegionComparison.ps1 | 271 ++++++++++++++++++ 3-CostInformation/README.md | 49 +++- 3 files changed, 317 insertions(+), 5 deletions(-) create mode 100644 3-CostInformation/Perform-RegionComparison.ps1 diff --git a/3-CostInformation/Get-CostInformation.ps1 b/3-CostInformation/Get-CostInformation.ps1 index c7d605f..acd2eb0 100644 --- a/3-CostInformation/Get-CostInformation.ps1 +++ b/3-CostInformation/Get-CostInformation.ps1 @@ -20,7 +20,7 @@ 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'. .PARAMETER outputFormat - The format of the output file. Supported formats are 'json', 'csv', and 'console'. Default is 'json'. + The format of the output file. Supported formats are 'json', 'csv', 'excel' and 'console'. Default is 'json'. .PARAMETER testMode If set, only the first subscription ID will be used to retrieve a quick result set for testing purposes. diff --git a/3-CostInformation/Perform-RegionComparison.ps1 b/3-CostInformation/Perform-RegionComparison.ps1 new file mode 100644 index 0000000..55ba3e5 --- /dev/null +++ b/3-CostInformation/Perform-RegionComparison.ps1 @@ -0,0 +1,271 @@ +<# +.SYNOPSIS + Take a list of meter IDs and a list of regions, and return the pricing information for the + equivalent Azure meters in those regions. + Requires ImportExcel module if Excel output is requested. + PS1> Install-Module -Name ImportExcel + +.PARAMETER resourceCostFile + A JSON file containing the resource cost information. This file is created by the Get-CostInformation.ps1 script. + +.PARAMETER targetRregions + An array of regions to compare. + +.PARAMETER outputFormat + The format of the output file. Supported formats are 'json', 'excel', 'csv' or 'console'. If not specified, output is written to the console. + +.PARAMETER outputFilePrefix + 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'. + +.EXAMPLE + .\Perform-RegionComparison.ps1 -regions @("eastus", "westeurope", "southeastasia") +#> + +param ( + [string[]]$resourceCostFile = "resource_cost.json", # the JSON file containing the resource cost information + [string[]]$regions, # array of regions to compare + [string]$outputFormat = "console", # json, excel or csv. If not specified, output is written to the console + [string]$outputFilePrefix = "region_comparison" # the output file prefix. Not used if outputFormat is not specified +) + +function Write-ToFileOrConsole { + param( + [string]$outputFormat, + [string]$outputFilePrefix, + [object[]]$data, + [string]$label + ) + + switch ($outputFormat) { + "json" { + $outputFilePrefix += "_$label" + if ($outputFilePrefix -notmatch '\.json$') { + $outputFilePrefix += ".json" + } + $data | ConvertTo-Json | Out-File -FilePath $outputFilePrefix -Encoding UTF8 + Write-Output "$($data.Count) rows written to $outputFilePrefix" + } + "csv" { + $outputFilePrefix += "_$label" + if ($outputFilePrefix -notmatch '\.csv$') { + $outputFilePrefix += ".csv" + } + $data | Export-Csv -Path $outputFilePrefix -NoTypeInformation -Encoding UTF8 + Write-Output "$($data.Count) rows written to $outputFilePrefix" + } + "excel" { + if ($outputFilePrefix -notmatch '\.xlsx$') { + $outputFilePrefix += ".xlsx" + } + $data | Export-Excel -WorksheetName $label -TableName $label -Path .\$outputFilePrefix + Write-Output "$($data.Count) rows written to tab $label of $outputFilePrefix" + } + Default { + # Display the table in the console + $data | Format-Table -AutoSize + } +} + +} + +# Input checking +# Check that the resource cost file exists +if (-not (Test-Path -Path $resourceCostFile)) { + Write-Error "Resource cost file '$resourceCostFile' does not exist." + exit 1 +} + +# Check that the requested output format is valid +if ($outputFormat -notin @("json", "csv", "excel", "console")) { + Write-Error "Output format '$outputFormat' is not supported. Supported formats are 'json', 'csv', 'excel', and 'console'." + exit 1 +} + +# If output format is specified, check that the output file prefix is also specified +if ($null -ne $outputFormat -and $null -eq $outputFilePrefix -or $outputFilePrefix -eq "") { + Write-Error "Output file prefix must be specified if output format is specified." + exit 1 +} + +# If output format is excel, check that the ImportExcel module is installed +if ($outputFormat -eq "excel" -and -not (Get-Module -ListAvailable -Name ImportExcel)) { + Write-Error "ImportExcel module is not installed. Please install it using 'Install-Module -Name ImportExcel'." + exit 1 +} + +# Read the resource cost file into a variable +$jsonContent = Get-Content -Path $resourceCostFile -Raw +$resourceCostData = $jsonContent | ConvertFrom-Json +if ($null -eq $resourceCostData -or $resourceCostData.Count -eq 0) { + Write-Error "No data found in $resourceCostFile. Please run the Get-CostInformation script first." + exit 1 +} + +# Extract the unique meter IDs from the resource cost data +$meterIds = $resourceCostData.ResourceGuid | Sort-Object -Unique +if ($null -eq $meterIds -or $meterIds.Count -eq 0) { + Write-Error "No meter IDs found in $resourceCostFile. Please run the Get-CostInformation phase first." + exit 1 +} + +Write-Verbose "Meter IDs: $($meterIds -join ', ')" +Write-Verbose "Regions: $($regions -join ', ')" + +$baseUri = "https://prices.azure.com/api/retail/prices?api-version=2023-01-01-preview" + +# Query the API using meterID as the filter to get the product ID and Meter Name +# For some services this will give unique results, but for others there may be multiple entries +# some meterIDs stretch across regions although this is unusual +# usually tierMinimumUnits is the most common reason for this + +Write-Verbose "Querying pricing API for meter names and product IDs..." + +$inputTable = @() + +# Process meterIDs in batches of 10 to avoid URL length issues +for ($i = 0; $i -lt $meterIds.Count; $i += 10) { + $batchMeterIds = $meterIds[$i..([math]::Min($i+9, $meterIds.Count-1))] + $filterString = '$filter=currencyCode eq ''USD''' + $filterString += " and type eq 'Consumption'" + $filterString += " and isPrimaryMeterRegion eq true" + $filterString += " and (meterId eq '$($batchMeterIds -join "' or meterId eq '")')" + + Write-Verbose "Filter string in use is $filterString" + + $uri = "$baseUri&$filterString" + + $queryResult = Invoke-RestMethod -Uri $uri -Method Get + + if ($null -eq $queryResult) { + Write-Error "Failed to retrieve data for the supplied meter IDs" + exit 1 + } + + foreach ($item in $queryResult.Items | Select-Object meterId, meterName, productId, skuName, armRegionName -Unique) { + $row = [PSCustomObject]@{ + "MeterId" = $item.meterId + "PreTaxCost" = ($resourceCostData | Where-Object { $_.ResourceGuid -eq $item.meterId } | Measure-Object -Property PreTaxCost -Sum).Sum + "MeterName" = $item.meterName + "ProductId" = $item.productId + "SkuName" = $item.skuName + "ArmRegionName" = $item.armRegionName + "TierMinimumUnits" = ($queryResult.Items | Where-Object { $_.meterId -eq $item.meterId }).tierMinimumUnits | Sort-Object | Select-Object -First 1 + } + $inputTable += $row + } +} + +Write-ToFileOrConsole -outputFormat $outputFormat -outputFilePrefix $outputFilePrefix -data $inputTable -label "inputs" + +# Using the input table, query the pricing API for each meterName+productId+skuName combination across the specified regions +Write-Output "Querying pricing API for region comparisons. Please be patient..." + +$resultTable = @() +foreach ($inputRow in $inputTable) { + $tempRegions = $regions + $inputRow.ArmRegionName + $filterString = '$filter=currencyCode eq ''USD''' + $filterString += " and type eq 'Consumption'" + $filterString += " and isPrimaryMeterRegion eq true" + $filterString += " and meterName eq '$($inputRow.MeterName)'" + $filterString += " and productId eq '$($inputRow.ProductId)'" + $filterString += " and skuName eq '$($inputRow.SkuName)'" + $filterString += " and (armRegionName eq '$($tempRegions -join "' or armRegionName eq '")')" + + Write-Verbose "Filter string in use is $filterString" + + $uri = "$baseUri&$filterString" + $queryResult = Invoke-RestMethod -Uri $uri -Method Get + + Write-Verbose "Query for meter ID $($inputRow.MeterId) returned $($queryResult.Count) items" + + # Exclude rows with retail price zero + $queryResult.Items = $queryResult.Items | Where-Object { $_.retailPrice -gt 0 } + + # If there are multiple entries for the same meterId, filter to only those with the same tierMinimumUnits as the original region + $queryResult.Items = $queryResult.Items | Where-Object { $_.tierMinimumUnits -eq $inputRow.TierMinimumUnits } + + foreach ($item in $queryResult.Items) { + $row = [PSCustomObject]@{ + "OrigMeterId" = $inputRow.MeterId + "OrigRegion" = if ($inputRow.ArmRegionName -eq $item.armRegionName) { "X" } + "OrigCost" = $inputRow.PreTaxCost + "MeterId" = $item.meterId + "ServiceFamily" = $item.serviceFamily + "ServiceName" = $item.serviceName + "MeterName" = $item.meterName + "ProductId" = $item.productId + "ProductName" = $item.productName + "SkuName" = $item.skuName + "UnitOfMeasure" = $item.unitOfMeasure + "RetailPrice" = $item.retailPrice + "Region" = $item.armRegionName + } + $resultTable += $row + } +} + +# If at this point there are duplicate combinations of MeterName, ProductId, SkuName then +# this indicates that there are multiple target meters for the same region, which will cause issues later +$tempTable1 = $resultTable | Where-Object { $_.OrigRegion -eq "X" } | Select-Object -Property OrigMeterId, MeterName, ProductId, SkuName | Sort-Object +$tempTable2 = $tempTable1 | Sort-Object -Property OrigMeterId, MeterName, ProductId, SkuName -Unique + +if ($tempTable1.Count -ne $tempTable2.Count) { + Write-Error "There are duplicate target meters for the same region. Please report this issue to the script author." + Write-ToFileOrConsole -outputFormat $outputFormat -outputFilePrefix $outputFilePrefix -data $resultTable -label "RegionComparison" + exit +} + +# For each row, add the percentage difference in retail price between the current row and the original region for that meter ID +foreach ($row in $resultTable) { + $origPrice = ($resultTable | Where-Object { $_.OrigMeterId -eq $row.OrigMeterId -and $_.OrigRegion -eq "X" }).RetailPrice + $row | Add-Member -MemberType NoteProperty -Name "PriceDiffToOrigin" -Value ($row.RetailPrice - $origPrice) + if ($origPrice -ne 0) { + $row | Add-Member -MemberType NoteProperty -Name "PercentageDiffToOrigin" -Value ([math]::Round((($row.RetailPrice - $origPrice) / $origPrice), 2)) + $row | Add-Member -MemberType NoteProperty -Name "CostDiffToOrigin" -Value ([math]::Round(($row.PercentageDiffToOrigin * $row.OrigCost), 2)) + } else { + $row | Add-Member -MemberType NoteProperty -Name "PercentageDiffToOrigin" -Value $null + $row | Add-Member -MemberType NoteProperty -Name "CostDiffToOrigin" -Value $null + } +} + +Write-ToFileOrConsole -outputFormat $outputFormat -outputFilePrefix $outputFilePrefix -data $resultTable -label "prices" + +# Construct a table showing the total possible savings for each target region +$savingsTable = @() +foreach ($region in $regions) { + $totalOrigCost = ($resultTable | Where-Object { $_.OrigRegion -eq "X" }).OrigCost | Measure-Object -Sum | Select-Object -ExpandProperty Sum + $regionSavings = ($resultTable | Where-Object { $_.Region -eq $region }).CostDiffToOrigin | Measure-Object -Sum | Select-Object -ExpandProperty Sum + $percentageSavings = if ($totalOrigCost -ne 0) { [math]::Round(($regionSavings / $totalOrigCost), 4) } else { $null } + $row = [PSCustomObject]@{ + "Region" = $region + "OriginalCost" = [math]::Round($totalOrigCost, 2) + "Difference" = [math]::Round($regionSavings, 2) + "PercentageDifference" = $percentageSavings + } + $savingsTable += $row +} + +Write-ToFileOrConsole -outputFormat $outputFormat -outputFilePrefix $outputFilePrefix -data $savingsTable -label "savings" + +# Construct a summary table for only the original meterIDs and region that shows the cheapest region(s) and the price difference +$summaryTable = @() +foreach ($inputRow in $inputTable) { + $origRow = $resultTable | Where-Object { $_.OrigMeterId -eq $inputRow.MeterId -and $_.OrigRegion -eq "X" } + $origPrice = if ($null -ne $origRow) { $origRow.RetailPrice } else { $null } + if ($null -ne $origRow) { + $row = [PSCustomObject]@{ + "MeterId" = $origRow.MeterId + "MeterName" = $origRow.MeterName + "ProductName" = $origRow.ProductName + "SkuName" = $origRow.SkuName + "OriginalRegion" = $origRow.Region + "LowerPricedRegions" = ($resultTable | Where-Object { $_.OrigMeterId -eq $inputRow.MeterId -and $_.RetailPrice -lt $origPrice }).Region -join ", " + "SamePricedRegions" = ($resultTable | Where-Object { $_.OrigMeterId -eq $inputRow.MeterId -and $_.RetailPrice -eq $origPrice -and $_.Region -ne $origRow.Region }).Region -join ", " + "HigherPricedRegions" = ($resultTable | Where-Object { $_.OrigMeterId -eq $inputRow.MeterId -and $_.RetailPrice -gt $origPrice }).Region -join ", " + } + $summaryTable += $row + } +} + +Write-ToFileOrConsole -outputFormat $outputFormat -outputFilePrefix $outputFilePrefix -data $summaryTable -label "pricemap" +Write-Output "Script completed successfully." \ No newline at end of file diff --git a/3-CostInformation/README.md b/3-CostInformation/README.md index 2387843..8c942d0 100644 --- a/3-CostInformation/README.md +++ b/3-CostInformation/README.md @@ -1,9 +1,13 @@ -# Cost data retrieval +# Cost data retrieval and region comparison -## About the script +## About the scripts + +### Get-CostInformation.ps1 This script is intended to take a collection of given resource IDs and return the cost incurred during previous months, grouped as needed. For this we use the Microsoft.CostManagement provider of each subscription. This means one call of the Cost Management PowerShell module per subscription. +The input file is produced by the Get-AzureServices.ps1 script. + Requires Az.CostManagement module version 0.4.2. `PS1> Install-Module -Name Az.CostManagement` @@ -13,7 +17,7 @@ Instructions for use: 1. Log on to Azure using `Connect-AzAccount`. Ensure that you have Cost Management Reader access to each subscription listed in the resources file (default `resources.json`) 2. Navigate to the 3-CostInformation folder and run the script using `.\Get-CostInformation.ps1`. The script will generate a CSV file in the current folder. -## Documentation links +#### Documentation links Documentation regarding the Az.CostManagement module is not always straightforward. Helpful links are: | Documentation | Link | @@ -57,4 +61,41 @@ ServiceName ServiceTier SubscriptionId SubscriptionName -``` \ No newline at end of file +``` + +### Perform-RegionComparison.ps1 + +This script builds on the previous step by comparing pricing across Azure regions for the meter ID's retrieved earlier. +The Azure public pricing API is used, meaning that: +* No login is needed for this step +* Prices are *not* customer-specific, but are only used to calculate the relative cost difference between regions for each meter + +Instructions for use: + +1. Prepare a list of target regions for comparison. This can be provided at the command line or stored in a variable before calling the script. +2. Ensure the `resource_cost.json` file is present. +2. Run the script using `.\Perform-RegionComparison.ps1`. The script will generate output files in the current folder. + +#### Example + +``` text +$regions = @("eastus", "brazilsouth", "australiaeast") +.\Perform-RegionComparison.ps1 -regions $regions -outputType json +``` + +#### Outputs + +Depending on the chosen output format, the script outputs four sets of data: + +| Dataset | Contents | +| -------- | ------- | +| `inputs` | The input data used for calling the pricing API (for reference only) | +| `pricemap` | An overview of which regions are cheaper / similarly-priced / more expensive for each meter ID | +| `prices` | Prices for each source/target region mapping by meter ID | +| `savings` | An estimate of the potential savings for each target region | + +#### Documentation links + +| Documentation | Link | +| -------- | ------- | +| Azure pricing API | [Link](https://learn.microsoft.com/en-us/rest/api/cost-management/retail-prices/azure-retail-prices) | From 480a0d624ee787b107bff76abf3d192600b85e99 Mon Sep 17 00:00:00 2001 From: Donovan Hughes Date: Fri, 5 Sep 2025 17:25:42 +0200 Subject: [PATCH 2/6] Region comparison --- 3-CostInformation/Perform-RegionComparison.ps1 | 2 +- 3-CostInformation/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/3-CostInformation/Perform-RegionComparison.ps1 b/3-CostInformation/Perform-RegionComparison.ps1 index 55ba3e5..42cc876 100644 --- a/3-CostInformation/Perform-RegionComparison.ps1 +++ b/3-CostInformation/Perform-RegionComparison.ps1 @@ -206,7 +206,7 @@ foreach ($inputRow in $inputTable) { # If at this point there are duplicate combinations of MeterName, ProductId, SkuName then # this indicates that there are multiple target meters for the same region, which will cause issues later -$tempTable1 = $resultTable | Where-Object { $_.OrigRegion -eq "X" } | Select-Object -Property OrigMeterId, MeterName, ProductId, SkuName | Sort-Object +$tempTable1 = $resultTable | Where-Object { $_.OrigRegion -eq "X" } | Select-Object -Property OrigMeterId, MeterName, ProductId, SkuName | Sort-Object $tempTable2 = $tempTable1 | Sort-Object -Property OrigMeterId, MeterName, ProductId, SkuName -Unique if ($tempTable1.Count -ne $tempTable2.Count) { diff --git a/3-CostInformation/README.md b/3-CostInformation/README.md index 8c942d0..633dce7 100644 --- a/3-CostInformation/README.md +++ b/3-CostInformation/README.md @@ -17,7 +17,7 @@ Instructions for use: 1. Log on to Azure using `Connect-AzAccount`. Ensure that you have Cost Management Reader access to each subscription listed in the resources file (default `resources.json`) 2. Navigate to the 3-CostInformation folder and run the script using `.\Get-CostInformation.ps1`. The script will generate a CSV file in the current folder. -#### Documentation links +#### Documentation links - cost retrieval Documentation regarding the Az.CostManagement module is not always straightforward. Helpful links are: | Documentation | Link | @@ -94,7 +94,7 @@ Depending on the chosen output format, the script outputs four sets of data: | `prices` | Prices for each source/target region mapping by meter ID | | `savings` | An estimate of the potential savings for each target region | -#### Documentation links +#### Documentation links - region comparison | Documentation | Link | | -------- | ------- | From bf6ff7f78fb792da4d94f93ac4bca9c291063f0c Mon Sep 17 00:00:00 2001 From: Donovan Hughes Date: Mon, 8 Sep 2025 13:39:33 +0200 Subject: [PATCH 3/6] Improved handling of API calls and unit of measure mismatches --- .../Perform-RegionComparison.ps1 | 138 +++++++++++++----- 3-CostInformation/README.md | 3 + 2 files changed, 101 insertions(+), 40 deletions(-) diff --git a/3-CostInformation/Perform-RegionComparison.ps1 b/3-CostInformation/Perform-RegionComparison.ps1 index 42cc876..f8b7846 100644 --- a/3-CostInformation/Perform-RegionComparison.ps1 +++ b/3-CostInformation/Perform-RegionComparison.ps1 @@ -25,7 +25,7 @@ param ( [string[]]$resourceCostFile = "resource_cost.json", # the JSON file containing the resource cost information [string[]]$regions, # array of regions to compare [string]$outputFormat = "console", # json, excel or csv. If not specified, output is written to the console - [string]$outputFilePrefix = "region_comparison" # the output file prefix. Not used if outputFormat is not specified + [string]$outputFilePrefix = "region_comparison" # the output file prefix. Not used if outputFormat is not specified ) function Write-ToFileOrConsole { @@ -68,6 +68,12 @@ function Write-ToFileOrConsole { } +# Internal script parameters +#$ErrorActionPreference = "Stop" +#$VerbosePreference = "Continue" +$meterIdBatchSize = 10 +$regionBatchSize = 10 + # Input checking # Check that the resource cost file exists if (-not (Test-Path -Path $resourceCostFile)) { @@ -75,6 +81,12 @@ if (-not (Test-Path -Path $resourceCostFile)) { exit 1 } +# Check that at least one region is specified +if ($null -eq $regions -or $regions.Count -eq 0) { + Write-Error "At least one region must be specified." + exit 1 +} + # Check that the requested output format is valid if ($outputFormat -notin @("json", "csv", "excel", "console")) { Write-Error "Output format '$outputFormat' is not supported. Supported formats are 'json', 'csv', 'excel', and 'console'." @@ -122,9 +134,9 @@ Write-Verbose "Querying pricing API for meter names and product IDs..." $inputTable = @() -# Process meterIDs in batches of 10 to avoid URL length issues -for ($i = 0; $i -lt $meterIds.Count; $i += 10) { - $batchMeterIds = $meterIds[$i..([math]::Min($i+9, $meterIds.Count-1))] +# Process meterIDs in batches to avoid URL length issues +for ($i = 0; $i -lt $meterIds.Count; $i += $meterIdBatchSize) { + $batchMeterIds = $meterIds[$i..([math]::Min($i+$meterIdBatchSize-1, $meterIds.Count-1))] $filterString = '$filter=currencyCode eq ''USD''' $filterString += " and type eq 'Consumption'" $filterString += " and isPrimaryMeterRegion eq true" @@ -141,7 +153,9 @@ for ($i = 0; $i -lt $meterIds.Count; $i += 10) { exit 1 } - foreach ($item in $queryResult.Items | Select-Object meterId, meterName, productId, skuName, armRegionName -Unique) { + # The tierMinimumUnits property is used to indicate bulk discounts for the same meter ID + # For comparison purposes we will use the lowest tierMinimumUnits value for each meter ID + foreach ($item in $queryResult.Items | Select-Object meterId, meterName, productId, skuName, armRegionName, unitOfMeasure -Unique) { $row = [PSCustomObject]@{ "MeterId" = $item.meterId "PreTaxCost" = ($resourceCostData | Where-Object { $_.ResourceGuid -eq $item.meterId } | Measure-Object -Property PreTaxCost -Sum).Sum @@ -150,6 +164,7 @@ for ($i = 0; $i -lt $meterIds.Count; $i += 10) { "SkuName" = $item.skuName "ArmRegionName" = $item.armRegionName "TierMinimumUnits" = ($queryResult.Items | Where-Object { $_.meterId -eq $item.meterId }).tierMinimumUnits | Sort-Object | Select-Object -First 1 + "unitOfMeasure" = $item.unitOfMeasure } $inputTable += $row } @@ -161,49 +176,92 @@ Write-ToFileOrConsole -outputFormat $outputFormat -outputFilePrefix $outputFileP Write-Output "Querying pricing API for region comparisons. Please be patient..." $resultTable = @() -foreach ($inputRow in $inputTable) { - $tempRegions = $regions + $inputRow.ArmRegionName - $filterString = '$filter=currencyCode eq ''USD''' - $filterString += " and type eq 'Consumption'" - $filterString += " and isPrimaryMeterRegion eq true" - $filterString += " and meterName eq '$($inputRow.MeterName)'" - $filterString += " and productId eq '$($inputRow.ProductId)'" - $filterString += " and skuName eq '$($inputRow.SkuName)'" - $filterString += " and (armRegionName eq '$($tempRegions -join "' or armRegionName eq '")')" - Write-Verbose "Filter string in use is $filterString" +# Azure pricing has the unfortunate characteristic that some meter IDs have different units of measure in different regions. +# Instead of trying to handle this and convert between units, it is better to exclude them and flag them for manual processing. +$uomError = $false +$uomErrorTable = @() - $uri = "$baseUri&$filterString" - $queryResult = Invoke-RestMethod -Uri $uri -Method Get - - Write-Verbose "Query for meter ID $($inputRow.MeterId) returned $($queryResult.Count) items" - - # Exclude rows with retail price zero - $queryResult.Items = $queryResult.Items | Where-Object { $_.retailPrice -gt 0 } +$counter = 0 +foreach ($inputRow in $inputTable) { + $counter++ + Write-Host -NoNewline "`rProcessing meter IDs: $counter of $($inputTable.Count)..." + $tempRegions = $regions + $inputRow.ArmRegionName - # If there are multiple entries for the same meterId, filter to only those with the same tierMinimumUnits as the original region - $queryResult.Items = $queryResult.Items | Where-Object { $_.tierMinimumUnits -eq $inputRow.TierMinimumUnits } + # Process regions in batches to avoid URL length issues + for ($i = 0; $i -lt $tempRegions.Count; $i += $regionBatchSize) { + $regionBatch = $tempRegions[$i..([math]::Min($i+$regionBatchSize-1, $tempRegions.Count-1))] + + $filterString = '$filter=currencyCode eq ''USD''' + $filterString += " and type eq 'Consumption'" + $filterString += " and isPrimaryMeterRegion eq true" + $filterString += " and meterName eq '$($inputRow.MeterName)'" + $filterString += " and productId eq '$($inputRow.ProductId)'" + $filterString += " and skuName eq '$($inputRow.SkuName)'" + $filterString += " and (armRegionName eq '$($regionBatch -join "' or armRegionName eq '")')" + + Write-Verbose "`r`nFilter string in use is $filterString" + + $uri = "$baseUri&$filterString" + $queryResult = Invoke-RestMethod -Uri $uri -Method Get + + $batchProgress = [int][Math]::Truncate($i / 10) + 1 + Write-Verbose "`r`nQuery for meter ID $($inputRow.MeterId) batch $batchProgress returned $($queryResult.Count) items" + + # Exclude rows with retail price zero + $queryResult.Items = $queryResult.Items | Where-Object { $_.retailPrice -gt 0 } + + # If there are multiple entries for the same meterId, filter to only those with the same tierMinimumUnits as the original region + $queryResult.Items = $queryResult.Items | Where-Object { $_.tierMinimumUnits -eq $inputRow.TierMinimumUnits } + + # Check if rows have a different unit of measure from the input row + $uomCheck = $queryResult.Items | Where-Object { $_.unitOfMeasure -ne $inputRow.unitOfMeasure } | Select-Object meterId, unitOfMeasure + if ($uomCheck.Count -gt 0) { + $uomError = $true + foreach ($item in $uomCheck) { + $row = [PSCustomObject]@{ + "OrigMeterID" = $inputRow.MeterId + "OrigUoM" = $inputRow.unitOfMeasure + "TargetMeterID" = $item.meterId + "TargetUoM" = $item.unitOfMeasure + } + $uomErrorTable += $row + } + } - foreach ($item in $queryResult.Items) { - $row = [PSCustomObject]@{ - "OrigMeterId" = $inputRow.MeterId - "OrigRegion" = if ($inputRow.ArmRegionName -eq $item.armRegionName) { "X" } - "OrigCost" = $inputRow.PreTaxCost - "MeterId" = $item.meterId - "ServiceFamily" = $item.serviceFamily - "ServiceName" = $item.serviceName - "MeterName" = $item.meterName - "ProductId" = $item.productId - "ProductName" = $item.productName - "SkuName" = $item.skuName - "UnitOfMeasure" = $item.unitOfMeasure - "RetailPrice" = $item.retailPrice - "Region" = $item.armRegionName + # Remove rows where the unit of measure is different from the original + $queryResult.Items = $queryResult.Items | Where-Object { $_.unitOfMeasure -eq $inputRow.unitOfMeasure } + + foreach ($item in $queryResult.Items) { + $row = [PSCustomObject]@{ + "OrigMeterId" = $inputRow.MeterId + "OrigRegion" = if ($inputRow.ArmRegionName -eq $item.armRegionName) { "X" } + "OrigCost" = $inputRow.PreTaxCost + "MeterId" = $item.meterId + "ServiceFamily" = $item.serviceFamily + "ServiceName" = $item.serviceName + "MeterName" = $item.meterName + "ProductId" = $item.productId + "ProductName" = $item.productName + "SkuName" = $item.skuName + "UnitOfMeasure" = $item.unitOfMeasure + "RetailPrice" = $item.retailPrice + "Region" = $item.armRegionName + } + $resultTable += $row } - $resultTable += $row } } +Write-Host "" + +# If there were any UoM errors, write them to the output +if ($uomError) { + Write-Output "Warning: Different unit of measure detected between source and target region(s). These target meters will be excluded from the comparison." + Write-Output "Please review the uomerrors output and handle these meters manually." + Write-ToFileOrConsole -outputFormat $outputFormat -outputFilePrefix $outputFilePrefix -data $uomErrorTable -label "uomerrors" +} + # If at this point there are duplicate combinations of MeterName, ProductId, SkuName then # this indicates that there are multiple target meters for the same region, which will cause issues later $tempTable1 = $resultTable | Where-Object { $_.OrigRegion -eq "X" } | Select-Object -Property OrigMeterId, MeterName, ProductId, SkuName | Sort-Object diff --git a/3-CostInformation/README.md b/3-CostInformation/README.md index 633dce7..cdee626 100644 --- a/3-CostInformation/README.md +++ b/3-CostInformation/README.md @@ -70,6 +70,8 @@ The Azure public pricing API is used, meaning that: * No login is needed for this step * Prices are *not* customer-specific, but are only used to calculate the relative cost difference between regions for each meter +As customer discounts tend to be linear (for example, ACD is a flat rate discount across all PAYG Azure spend), the relative price differents between regions can still be used to make an intelligent estimate of the cost impact of a workload move. + Instructions for use: 1. Prepare a list of target regions for comparison. This can be provided at the command line or stored in a variable before calling the script. @@ -93,6 +95,7 @@ Depending on the chosen output format, the script outputs four sets of data: | `pricemap` | An overview of which regions are cheaper / similarly-priced / more expensive for each meter ID | | `prices` | Prices for each source/target region mapping by meter ID | | `savings` | An estimate of the potential savings for each target region | +| `uomerrors` | A list of any eventual mismatches of Unit Of Measure between regions | #### Documentation links - region comparison From 2214f14a6e974bb98974b93628558ca9b2b8a6c2 Mon Sep 17 00:00:00 2001 From: Donovan Hughes Date: Mon, 8 Sep 2025 13:47:24 +0200 Subject: [PATCH 4/6] Fix linting errors and add progress bar --- 3-CostInformation/Perform-RegionComparison.ps1 | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/3-CostInformation/Perform-RegionComparison.ps1 b/3-CostInformation/Perform-RegionComparison.ps1 index f8b7846..4959f21 100644 --- a/3-CostInformation/Perform-RegionComparison.ps1 +++ b/3-CostInformation/Perform-RegionComparison.ps1 @@ -185,7 +185,7 @@ $uomErrorTable = @() $counter = 0 foreach ($inputRow in $inputTable) { $counter++ - Write-Host -NoNewline "`rProcessing meter IDs: $counter of $($inputTable.Count)..." + Write-Progress -Activity "Processing meter IDs" -Status "Processing meter ID $counter of $($inputTable.Count)" -PercentComplete (($counter / $inputTable.Count) * 100) $tempRegions = $regions + $inputRow.ArmRegionName # Process regions in batches to avoid URL length issues @@ -200,13 +200,13 @@ foreach ($inputRow in $inputTable) { $filterString += " and skuName eq '$($inputRow.SkuName)'" $filterString += " and (armRegionName eq '$($regionBatch -join "' or armRegionName eq '")')" - Write-Verbose "`r`nFilter string in use is $filterString" + Write-Verbose "Filter string in use is $filterString" $uri = "$baseUri&$filterString" $queryResult = Invoke-RestMethod -Uri $uri -Method Get $batchProgress = [int][Math]::Truncate($i / 10) + 1 - Write-Verbose "`r`nQuery for meter ID $($inputRow.MeterId) batch $batchProgress returned $($queryResult.Count) items" + Write-Verbose "Query for meter ID $($inputRow.MeterId) batch $batchProgress returned $($queryResult.Count) items" # Exclude rows with retail price zero $queryResult.Items = $queryResult.Items | Where-Object { $_.retailPrice -gt 0 } @@ -253,8 +253,6 @@ foreach ($inputRow in $inputTable) { } } -Write-Host "" - # If there were any UoM errors, write them to the output if ($uomError) { Write-Output "Warning: Different unit of measure detected between source and target region(s). These target meters will be excluded from the comparison." From 3d2938b56b6f3d225e40dbed3568ac910e306c87 Mon Sep 17 00:00:00 2001 From: Donovan Hughes Date: Mon, 8 Sep 2025 14:57:05 +0200 Subject: [PATCH 5/6] README typo fix --- 3-CostInformation/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/3-CostInformation/README.md b/3-CostInformation/README.md index cdee626..18c9045 100644 --- a/3-CostInformation/README.md +++ b/3-CostInformation/README.md @@ -70,7 +70,7 @@ The Azure public pricing API is used, meaning that: * No login is needed for this step * Prices are *not* customer-specific, but are only used to calculate the relative cost difference between regions for each meter -As customer discounts tend to be linear (for example, ACD is a flat rate discount across all PAYG Azure spend), the relative price differents between regions can still be used to make an intelligent estimate of the cost impact of a workload move. +As customer discounts tend to be linear (for example, ACD is a flat rate discount across all PAYG Azure spend), the relative price difference between regions can still be used to make an intelligent estimate of the cost impact of a workload move. Instructions for use: From c246a1e7067eb1ef6f9828574c11ce1d05f387ee Mon Sep 17 00:00:00 2001 From: Donovan Hughes Date: Wed, 17 Sep 2025 13:37:01 +0200 Subject: [PATCH 6/6] Remove savings calculation (future feature) --- .../Perform-RegionComparison.ps1 | 44 ++++++++++--------- 3-CostInformation/README.md | 6 +-- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/3-CostInformation/Perform-RegionComparison.ps1 b/3-CostInformation/Perform-RegionComparison.ps1 index 4959f21..478732b 100644 --- a/3-CostInformation/Perform-RegionComparison.ps1 +++ b/3-CostInformation/Perform-RegionComparison.ps1 @@ -5,7 +5,7 @@ Requires ImportExcel module if Excel output is requested. PS1> Install-Module -Name ImportExcel -.PARAMETER resourceCostFile +.PARAMETER resourceFile A JSON file containing the resource cost information. This file is created by the Get-CostInformation.ps1 script. .PARAMETER targetRregions @@ -22,7 +22,7 @@ #> param ( - [string[]]$resourceCostFile = "resource_cost.json", # the JSON file containing the resource cost information + [string[]]$resourceFile = "resources.json", # the JSON file containing the resource cost information [string[]]$regions, # array of regions to compare [string]$outputFormat = "console", # json, excel or csv. If not specified, output is written to the console [string]$outputFilePrefix = "region_comparison" # the output file prefix. Not used if outputFormat is not specified @@ -73,11 +73,12 @@ function Write-ToFileOrConsole { #$VerbosePreference = "Continue" $meterIdBatchSize = 10 $regionBatchSize = 10 +$baseUri = "https://prices.azure.com/api/retail/prices?api-version=2023-01-01-preview" # Input checking -# Check that the resource cost file exists -if (-not (Test-Path -Path $resourceCostFile)) { - Write-Error "Resource cost file '$resourceCostFile' does not exist." +# Check that the resource file exists +if (-not (Test-Path -Path $resourceFile)) { + Write-Error "Resource file '$resourceFile' does not exist." exit 1 } @@ -105,26 +106,24 @@ if ($outputFormat -eq "excel" -and -not (Get-Module -ListAvailable -Name ImportE exit 1 } -# Read the resource cost file into a variable -$jsonContent = Get-Content -Path $resourceCostFile -Raw -$resourceCostData = $jsonContent | ConvertFrom-Json -if ($null -eq $resourceCostData -or $resourceCostData.Count -eq 0) { - Write-Error "No data found in $resourceCostFile. Please run the Get-CostInformation script first." +# Read the resource file into a variable +$jsonContent = Get-Content -Path $resourceFile -Raw +$resourceData = $jsonContent | ConvertFrom-Json +if ($null -eq $resourceData -or $resourceData.Count -eq 0) { + Write-Error "No data found in $resourceFile. Please run the Get-AzureServices.ps1 collection script first." exit 1 } -# Extract the unique meter IDs from the resource cost data -$meterIds = $resourceCostData.ResourceGuid | Sort-Object -Unique +# Extract the unique meter IDs from the resource data +$meterIds = $resourceData.meterIds | Sort-Object -Unique if ($null -eq $meterIds -or $meterIds.Count -eq 0) { - Write-Error "No meter IDs found in $resourceCostFile. Please run the Get-CostInformation phase first." + Write-Error "No meter IDs found in $resourceFile. Please run the Get-AzureServices.ps1 collection script first." exit 1 } Write-Verbose "Meter IDs: $($meterIds -join ', ')" Write-Verbose "Regions: $($regions -join ', ')" -$baseUri = "https://prices.azure.com/api/retail/prices?api-version=2023-01-01-preview" - # Query the API using meterID as the filter to get the product ID and Meter Name # For some services this will give unique results, but for others there may be multiple entries # some meterIDs stretch across regions although this is unusual @@ -158,7 +157,7 @@ for ($i = 0; $i -lt $meterIds.Count; $i += $meterIdBatchSize) { foreach ($item in $queryResult.Items | Select-Object meterId, meterName, productId, skuName, armRegionName, unitOfMeasure -Unique) { $row = [PSCustomObject]@{ "MeterId" = $item.meterId - "PreTaxCost" = ($resourceCostData | Where-Object { $_.ResourceGuid -eq $item.meterId } | Measure-Object -Property PreTaxCost -Sum).Sum + #"PreTaxCost" = ($resourceData | Where-Object { $_.ResourceGuid -eq $item.meterId } | Measure-Object -Property PreTaxCost -Sum).Sum "MeterName" = $item.meterName "ProductId" = $item.productId "SkuName" = $item.skuName @@ -185,8 +184,9 @@ $uomErrorTable = @() $counter = 0 foreach ($inputRow in $inputTable) { $counter++ - Write-Progress -Activity "Processing meter IDs" -Status "Processing meter ID $counter of $($inputTable.Count)" -PercentComplete (($counter / $inputTable.Count) * 100) - $tempRegions = $regions + $inputRow.ArmRegionName + Write-Progress -Activity "Processing meter IDs" -Status "Meter ID $counter of $($inputTable.Count)" -PercentComplete (($counter / $inputTable.Count) * 100) + # Add the source region to the regions to get source pricing information + $tempRegions = $regions + $inputRow.ArmRegionName | Sort-Object -Unique # Process regions in batches to avoid URL length issues for ($i = 0; $i -lt $tempRegions.Count; $i += $regionBatchSize) { @@ -236,7 +236,7 @@ foreach ($inputRow in $inputTable) { $row = [PSCustomObject]@{ "OrigMeterId" = $inputRow.MeterId "OrigRegion" = if ($inputRow.ArmRegionName -eq $item.armRegionName) { "X" } - "OrigCost" = $inputRow.PreTaxCost + #"OrigCost" = $inputRow.PreTaxCost "MeterId" = $item.meterId "ServiceFamily" = $item.serviceFamily "ServiceName" = $item.serviceName @@ -277,15 +277,16 @@ foreach ($row in $resultTable) { $row | Add-Member -MemberType NoteProperty -Name "PriceDiffToOrigin" -Value ($row.RetailPrice - $origPrice) if ($origPrice -ne 0) { $row | Add-Member -MemberType NoteProperty -Name "PercentageDiffToOrigin" -Value ([math]::Round((($row.RetailPrice - $origPrice) / $origPrice), 2)) - $row | Add-Member -MemberType NoteProperty -Name "CostDiffToOrigin" -Value ([math]::Round(($row.PercentageDiffToOrigin * $row.OrigCost), 2)) + #$row | Add-Member -MemberType NoteProperty -Name "CostDiffToOrigin" -Value ([math]::Round(($row.PercentageDiffToOrigin * $row.OrigCost), 2)) } else { $row | Add-Member -MemberType NoteProperty -Name "PercentageDiffToOrigin" -Value $null - $row | Add-Member -MemberType NoteProperty -Name "CostDiffToOrigin" -Value $null + #$row | Add-Member -MemberType NoteProperty -Name "CostDiffToOrigin" -Value $null } } Write-ToFileOrConsole -outputFormat $outputFormat -outputFilePrefix $outputFilePrefix -data $resultTable -label "prices" +<# Future functionality - removed for now # Construct a table showing the total possible savings for each target region $savingsTable = @() foreach ($region in $regions) { @@ -302,6 +303,7 @@ foreach ($region in $regions) { } Write-ToFileOrConsole -outputFormat $outputFormat -outputFilePrefix $outputFilePrefix -data $savingsTable -label "savings" +#> # Construct a summary table for only the original meterIDs and region that shows the cheapest region(s) and the price difference $summaryTable = @() diff --git a/3-CostInformation/README.md b/3-CostInformation/README.md index 18c9045..d674b57 100644 --- a/3-CostInformation/README.md +++ b/3-CostInformation/README.md @@ -65,7 +65,7 @@ SubscriptionName ### Perform-RegionComparison.ps1 -This script builds on the previous step by comparing pricing across Azure regions for the meter ID's retrieved earlier. +This script builds on the collection step by comparing pricing across Azure regions for the meter ID's retrieved earlier. The Azure public pricing API is used, meaning that: * No login is needed for this step * Prices are *not* customer-specific, but are only used to calculate the relative cost difference between regions for each meter @@ -75,7 +75,7 @@ As customer discounts tend to be linear (for example, ACD is a flat rate discoun Instructions for use: 1. Prepare a list of target regions for comparison. This can be provided at the command line or stored in a variable before calling the script. -2. Ensure the `resource_cost.json` file is present. +2. Ensure the `resources.json` file is present (from the running of the collector script). 2. Run the script using `.\Perform-RegionComparison.ps1`. The script will generate output files in the current folder. #### Example @@ -94,8 +94,8 @@ Depending on the chosen output format, the script outputs four sets of data: | `inputs` | The input data used for calling the pricing API (for reference only) | | `pricemap` | An overview of which regions are cheaper / similarly-priced / more expensive for each meter ID | | `prices` | Prices for each source/target region mapping by meter ID | -| `savings` | An estimate of the potential savings for each target region | | `uomerrors` | A list of any eventual mismatches of Unit Of Measure between regions | + #### Documentation links - region comparison