Skip to content

Instantly share code, notes, and snippets.

@reconbot
Created May 28, 2026 17:31
Show Gist options
  • Select an option

  • Save reconbot/60ae9fb8206080154ee18732b622727d to your computer and use it in GitHub Desktop.

Select an option

Save reconbot/60ae9fb8206080154ee18732b622727d to your computer and use it in GitHub Desktop.
Gets a table of prices for your subscription.
#Requires -Version 7.0
<#
.SYNOPSIS
Look up Azure VM pricing by SKU and region, and find sizes matching hardware minimums.
.DESCRIPTION
Queries the public Azure Retail Prices API for VM compute pricing.
Returns on-demand, spot, and reservation prices with hardware specs (vCPUs, memory, etc.).
Supports filtering the VM size catalog by minimum vCPUs, memory, and max NIC
capacity, and by availability zone within each region. SKU specs and prices
are cached to disk per region to speed up repeat lookups.
.PARAMETER Subscription
Subscription name or ID to use for per-family quota lookups (Get-AzVMUsage).
Defaults to whichever subscription the current Az context is pointed at
(`Get-AzContext`). Pricing always comes from the public Azure Retail Prices
API — the CSP agreement does not expose subscription-specific pricing
through any Azure-side API.
.PARAMETER Region
One or more Azure region names (e.g. eastus2, westus3). Required; accepts multiple
values and results are unioned across them (each row carries its Region).
.PARAMETER VmSku
One or more VM SKU names (e.g. Standard_D4s_v5, Standard_E8as_v5).
If omitted, returns pricing for all VM SKUs in the region.
.PARAMETER MinCpus
Only return sizes with at least this many vCPUs.
.PARAMETER MaxCpus
Only return sizes with at most this many vCPUs.
.PARAMETER Cpus
Only return sizes with exactly this many vCPUs.
.PARAMETER MinRamGB
Only return sizes with at least this much memory in GB.
.PARAMETER MaxRamGB
Only return sizes with at most this much memory in GB.
.PARAMETER RamGB
Only return sizes with exactly this much memory in GB.
.PARAMETER MinNics
Only return sizes that support at least this many network interfaces.
.PARAMETER MaxNics
Only return sizes that support at most this many network interfaces.
.PARAMETER Nics
Only return sizes that support exactly this many network interfaces.
.PARAMETER Zone
Only return sizes available in all of the specified availability zones (e.g. 1, 2, 3)
within each region.
.PARAMETER RefreshCache
Ignore any cached SKU/price data and re-fetch from Azure.
.PARAMETER CacheMaxAgeHours
Maximum age of cached data before it is re-fetched. Defaults to 72 hours.
.EXAMPLE
.\Get-AzureVmPricing.ps1 -Region eastus2
.EXAMPLE
.\Get-AzureVmPricing.ps1 -Region eastus2 -VmSku Standard_D4s_v5
.EXAMPLE
.\Get-AzureVmPricing.ps1 -Region eastus2, westus3 -MinCpus 4 -MinRamGB 16 -MinNics 2
.EXAMPLE
.\Get-AzureVmPricing.ps1 -Region eastus2 -MinCpus 4 -MaxCpus 16 -MaxRamGB 64
.EXAMPLE
.\Get-AzureVmPricing.ps1 -Region eastus2 -Cpus 8 -RamGB 32
.EXAMPLE
.\Get-AzureVmPricing.ps1 -Region eastus2 -Zone 3 | Format-Table
.NOTES
Required modules: Az.Accounts, Az.Compute
Uses the public Azure Retail Prices API: https://prices.azure.com/api/retail/prices
#>
[CmdletBinding()]
param(
[string] $Subscription,
[Parameter(Mandatory)]
[string[]] $Region,
[string[]] $VmSku,
[int] $MinCpus,
[int] $MaxCpus,
[int] $Cpus,
[double] $MinRamGB,
[double] $MaxRamGB,
[double] $RamGB,
[int] $MinNics,
[int] $MaxNics,
[int] $Nics,
[string[]] $Zone,
[switch] $RefreshCache,
[int] $CacheMaxAgeHours = 72
)
$ErrorActionPreference = 'Stop'
# ── VM size categorization ────────────────────────────────────────────────────
# Derived from Microsoft's VM size naming convention:
# https://learn.microsoft.com/en-us/azure/virtual-machines/sizes/overview
function Get-VmCategory {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$SkuName)
$size = $SkuName -replace '^Standard_', ''
switch -Regex ($size) {
'^B' {
return 'Burstable'
}
'^DC' {
return 'Confidential (general purpose)'
}
'^EC' {
return 'Confidential (memory optimized)'
}
'^D' {
return 'General purpose'
}
'^E' {
return 'Memory optimized'
}
'^M' {
return 'Memory optimized (high memory)'
}
'^F' {
return 'Compute optimized'
}
'^L' {
return 'Storage optimized'
}
'^N[CD]' {
return 'GPU (compute/AI)'
}
'^NV' {
return 'GPU (visualization)'
}
'^NG' {
return 'GPU (gaming)'
}
'^N' {
return 'GPU'
}
'^H[BC]?' {
return 'High performance compute'
}
'^A' {
return 'Entry-level general purpose'
}
'^G' {
return 'Memory and storage optimized (legacy)'
}
default {
return 'Other'
}
}
}
# ── Caching helper ─────────────────────────────────────────────────────────────
# Reads $script:cacheDir (set by the main script body). Key is a logical name
# like "specs-eastus2" — the function appends ".json" and joins under $cacheDir.
function Get-CachedOrFetch {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string] $Key,
[int] $MaxAgeHours,
[switch] $Refresh,
[Parameter(Mandatory)][scriptblock] $Fetch
)
$path = Join-Path $script:cacheDir "$Key.json"
if (-not $Refresh -and (Test-Path $path)) {
$age = (Get-Date) - (Get-Item $path).LastWriteTime
if ($age.TotalHours -lt $MaxAgeHours) {
Write-Verbose " Cache hit: $path ($([math]::Round($age.TotalHours, 1))h old)"
return Get-Content -Path $path -Raw | ConvertFrom-Json
}
}
Write-Verbose " Cache miss: fetching and writing $path"
$data = & $Fetch
$data | ConvertTo-Json -Depth 10 -EnumsAsStrings | Set-Content -Path $path
return $data
}
# ── HTTP retry helper ─────────────────────────────────────────────────────────
function Invoke-RestWithRetry {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string] $Uri,
[int] $MaxAttempts = 6
)
for ($attempt = 1; ; $attempt++) {
try {
return Invoke-RestMethod -Uri $Uri
}
catch {
$response = $_.Exception.Response
$status = if ($response) {
[int]$response.StatusCode
}
else {
0
}
$retryable = $status -eq 429 -or ($status -ge 500 -and $status -lt 600)
if (-not $retryable -or $attempt -ge $MaxAttempts) {
throw
}
$retryAfter = $response.Headers.RetryAfter
$wait =
if ($retryAfter -and $retryAfter.Delta) {
[math]::Min(120, $retryAfter.Delta.TotalSeconds)
}
elseif ($retryAfter -and $retryAfter.Date) {
[math]::Min(120, ($retryAfter.Date - [DateTimeOffset]::UtcNow).TotalSeconds)
}
else {
[math]::Min(60, [math]::Pow(2, $attempt - 1)) + (Get-Random -Maximum 1.0)
}
$wait = [math]::Max(1, $wait)
Write-Warning "HTTP $status from pricing API (attempt $attempt/$MaxAttempts). Sleeping $([math]::Round($wait, 1))s..."
Start-Sleep -Seconds $wait
}
}
}
# ── Spec construction ────────────────────────────────────────────────────────
function ConvertTo-VmSpec {
[CmdletBinding()]
param(
[Parameter(ValueFromPipeline)] $SkuInfo,
[Parameter(Mandatory)][string] $Region
)
process {
$caps = @{}
foreach ($capability in $SkuInfo.Capabilities) {
$caps[$capability.Name] = $capability.Value
}
# Zones in this region minus zone restrictions; "Location" restrictions wipe zones.
$locInfo = $SkuInfo.LocationInfo | Where-Object { $_.Location -eq $Region } | Select-Object -First 1
$zones = if ($locInfo) {
@($locInfo.Zones)
}
else {
@()
}
$locationReason = $null
$allRestrictedZones = @()
foreach ($restriction in $SkuInfo.Restrictions) {
if ($restriction.Type -eq 'Location') {
$zones = @()
$locationReason = "$($restriction.ReasonCode)"
}
elseif ($restriction.Type -eq 'Zone' -and $restriction.RestrictionInfo) {
$restrictedZones = @($restriction.RestrictionInfo.Zones)
$allRestrictedZones += $restrictedZones
$zones = @($zones | Where-Object { $_ -notin $restrictedZones })
}
}
$zones = @($zones | Sort-Object { [int]$_ })
# Availability reflects hard SKU-API restrictions only; the portal's softer
# "high demand" capacity advisories aren't exposed via SDK.
$availability = if ($locationReason) {
$locationReason
}
elseif ($allRestrictedZones.Count -gt 0) {
"RestrictedZones: $((@($allRestrictedZones | Sort-Object -Unique)) -join ',')"
}
else {
'Available'
}
# Standard always supported. TrustedLaunch requires Gen2 and is opt-out via
# TrustedLaunchDisabled. ConfidentialVM requires a ConfidentialComputingType (e.g. "SNP").
$securityTypes = @('Standard')
if ($caps['HyperVGenerations'] -match 'V2' -and $caps['TrustedLaunchDisabled'] -ne 'True') {
$securityTypes += 'TrustedLaunch'
}
if ($caps['ConfidentialComputingType']) {
$securityTypes += 'ConfidentialVM'
}
$toBool = { param($value) $value -eq 'True' }
$localDiskMB = [int]$caps['MaxResourceVolumeMB']
$localDiskGB = if ($localDiskMB) {
[math]::Round($localDiskMB / 1024, 1)
}
else {
0
}
$uncachedIOPS = if ($caps['UncachedDiskIOPS']) {
[int]$caps['UncachedDiskIOPS']
}
else {
$null
}
$uncachedBps = if ($caps['UncachedDiskBytesPerSecond']) {
[int64]$caps['UncachedDiskBytesPerSecond']
}
else {
$null
}
$uncachedMBps = if ($uncachedBps) {
[math]::Round($uncachedBps / 1000000, 0)
}
else {
$null
}
[pscustomobject]@{
Name = $SkuInfo.Name
vCPUs = [int]$caps['vCPUs']
MemoryGB = [math]::Round([decimal]$caps['MemoryGB'], 1)
MaxDisks = [int]$caps['MaxDataDiskCount']
MaxNICs = [int]$caps['MaxNetworkInterfaces']
OSDiskSizeMB = [int]$caps['OSVhdSizeMB']
LocalDiskGB = $localDiskGB
GPUs = [int]$caps['GPUs']
Zones = $zones
Family = $SkuInfo.Family
SecurityTypes = $securityTypes -join ','
Architecture = $caps['CpuArchitectureType']
Category = Get-VmCategory -SkuName $SkuInfo.Name
Availability = $availability
PremiumIO = & $toBool $caps['PremiumIO']
AcceleratedNetworking = & $toBool $caps['AcceleratedNetworkingEnabled']
EphemeralOSDisk = & $toBool $caps['EphemeralOSDiskSupported']
MaxUncachedIOPS = $uncachedIOPS
MaxUncachedThroughputMBps = $uncachedMBps
}
}
}
# ── Spec filter (guard-clause style) ─────────────────────────────────────────
function Test-VmSpecMatch {
[CmdletBinding()]
param(
[Parameter(ValueFromPipeline)] $Spec,
[int] $MinCpus, [int] $MaxCpus, [int] $Cpus,
[double] $MinRamGB, [double] $MaxRamGB, [double] $RamGB,
[int] $MinNics, [int] $MaxNics, [int] $Nics,
[string[]] $Zone
)
process {
if ($PSBoundParameters.ContainsKey('MinCpus') -and $Spec.vCPUs -lt $MinCpus) {
return
}
if ($PSBoundParameters.ContainsKey('MaxCpus') -and $Spec.vCPUs -gt $MaxCpus) {
return
}
if ($PSBoundParameters.ContainsKey('Cpus') -and $Spec.vCPUs -ne $Cpus) {
return
}
if ($PSBoundParameters.ContainsKey('MinRamGB') -and $Spec.MemoryGB -lt $MinRamGB) {
return
}
if ($PSBoundParameters.ContainsKey('MaxRamGB') -and $Spec.MemoryGB -gt $MaxRamGB) {
return
}
if ($PSBoundParameters.ContainsKey('RamGB') -and $Spec.MemoryGB -ne $RamGB) {
return
}
if ($PSBoundParameters.ContainsKey('MinNics') -and $Spec.MaxNICs -lt $MinNics) {
return
}
if ($PSBoundParameters.ContainsKey('MaxNics') -and $Spec.MaxNICs -gt $MaxNics) {
return
}
if ($PSBoundParameters.ContainsKey('Nics') -and $Spec.MaxNICs -ne $Nics) {
return
}
if ($PSBoundParameters.ContainsKey('Zone')) {
$missingZones = @($Zone | Where-Object { $_ -notin $Spec.Zones })
if ($missingZones.Count -gt 0) {
return
}
}
$Spec
}
}
# ── Quota lookup (cached per-region + per-subscription) ──────────────────────
function Get-VmFamilyQuota {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string] $Region,
[Parameter(Mandatory)][string] $CacheKey,
[int] $CacheMaxAgeHours,
[switch] $Refresh
)
# Build as ordered hashtable, return as pscustomobject so cache hit (JSON
# round-trip) and cache miss yield the same shape.
$rawQuota = Get-CachedOrFetch -Key $CacheKey -MaxAgeHours $CacheMaxAgeHours -Refresh:$Refresh -Fetch {
$byFamily = [ordered]@{}
foreach ($usage in Get-AzVMUsage -Location $Region) {
$byFamily[$usage.Name.Value] = [pscustomobject]@{
UsedCores = [int]$usage.CurrentValue
AvailableCores = [int]($usage.Limit - $usage.CurrentValue)
}
}
[pscustomobject]$byFamily
}
# Normalize back to a hashtable for caller-side indexing by family key.
$quotaByFamily = @{}
foreach ($prop in $rawQuota.PSObject.Properties) {
$quotaByFamily[$prop.Name] = $prop.Value
}
return $quotaByFamily
}
# ── Retail-price fetch (cached + paginated + retry-aware) ────────────────────
function Get-AzureRegionRetailPrice {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string] $Region,
[string[]] $VmSku,
[Parameter(Mandatory)][string] $CacheKey,
[int] $CacheMaxAgeHours,
[switch] $Refresh
)
$filter = if ($VmSku) {
"armRegionName eq '$Region' and serviceFamily eq 'Compute' and priceType ne 'DevTestConsumption' and (" +
(($VmSku | ForEach-Object { "armSkuName eq '$_'" }) -join ' or ') + ')'
}
else {
"armRegionName eq '$Region' and serviceFamily eq 'Compute' and serviceName eq 'Virtual Machines' and priceType ne 'DevTestConsumption'"
}
Get-CachedOrFetch -Key $CacheKey -MaxAgeHours $CacheMaxAgeHours -Refresh:$Refresh -Fetch {
$acc = @()
$page = 0
$uri = "https://prices.azure.com/api/retail/prices?`$filter=$filter"
do {
$page++
Write-Progress -Id 1 -ParentId 0 -Activity "Loading data for $Region" `
-Status "Fetching pricing... page $page, $($acc.Count) items so far"
$response = Invoke-RestWithRetry -Uri $uri
$acc += $response.Items
$uri = $response.NextPageLink
Write-Verbose " Fetched $($acc.Count) price items so far..."
} while ($uri)
$acc
}
}
# ── Per-SKU price-meter → output-row reducer ─────────────────────────────────
function ConvertTo-VmPricingRow {
[CmdletBinding()]
param(
[Parameter(ValueFromPipeline)] $SkuGroup,
[Parameter(Mandatory)][hashtable] $VmSpecs,
[Parameter(Mandatory)][hashtable] $QuotaByFamily,
[Parameter(Mandatory)][string] $Region,
[System.Collections.Generic.HashSet[string]] $MatchingSkus
)
process {
$sku = $SkuGroup.Name
if ($MatchingSkus -and -not $MatchingSkus.Contains($sku)) {
return
}
# Drop spot, low priority, and non-compute (e.g. cloud services) up front.
$relevant = $SkuGroup.Group | Where-Object {
$_.skuName -notlike '*Spot*' -and
$_.meterName -notlike '*Spot*' -and
$_.skuName -notlike '*Low Priority*' -and
$_.productName -notlike '*Cloud Services*'
}
$linuxOnDemand = $relevant | Where-Object {
$_.type -eq 'Consumption' -and $_.productName -notmatch 'Windows'
} | Select-Object -First 1
$windowsOnDemand = $relevant | Where-Object {
$_.type -eq 'Consumption' -and $_.productName -match 'Windows'
} | Select-Object -First 1
$linux1Yr = $relevant | Where-Object {
$_.type -eq 'Reservation' -and $_.productName -notmatch 'Windows' -and $_.reservationTerm -match '^1\b'
} | Select-Object -First 1
$linux3Yr = $relevant | Where-Object {
$_.type -eq 'Reservation' -and $_.productName -notmatch 'Windows' -and $_.reservationTerm -match '^3\b'
} | Select-Object -First 1
# Reservations cover compute only; Windows reservation = Linux reservation + license premium.
$winLicenseHourly = if ($linuxOnDemand -and $windowsOnDemand) {
$windowsOnDemand.retailPrice - $linuxOnDemand.retailPrice
}
else {
0
}
$winLicenseMonthly = $winLicenseHourly * 730
$linuxOD = if ($linuxOnDemand) {
[math]::Round($linuxOnDemand.retailPrice * 730, 2)
}
else {
$null
}
$linux1y = if ($linux1Yr) {
[math]::Round($linux1Yr.retailPrice / 12, 2)
}
else {
$null
}
$linux3y = if ($linux3Yr) {
[math]::Round($linux3Yr.retailPrice / 36, 2)
}
else {
$null
}
$windowsOD = if ($windowsOnDemand) {
[math]::Round($windowsOnDemand.retailPrice * 730, 2)
}
else {
$null
}
$windows1y = if ($linux1Yr -and $winLicenseHourly -gt 0) {
[math]::Round(($linux1Yr.retailPrice / 12) + $winLicenseMonthly, 2)
}
else {
$null
}
$windows3y = if ($linux3Yr -and $winLicenseHourly -gt 0) {
[math]::Round(($linux3Yr.retailPrice / 36) + $winLicenseMonthly, 2)
}
else {
$null
}
$spec = $VmSpecs[$sku]
$zoneStr = if ($spec -and $spec.Zones.Count) {
($spec.Zones) -join ','
}
else {
'None'
}
$osDiskGB = if ($spec -and $spec.OSDiskSizeMB) {
[math]::Round($spec.OSDiskSizeMB / 1024, 1)
}
else {
$null
}
$currency = ($linuxOnDemand, $linux1Yr, $linux3Yr, $windowsOnDemand | Where-Object { $_ } | Select-Object -First 1).currencyCode
$quota = if ($spec -and $spec.Family) {
$QuotaByFamily[$spec.Family]
}
else {
$null
}
$quotaUsed = if ($quota) {
$quota.UsedCores
}
else {
$null
}
$quotaAvail = if ($quota) {
$quota.AvailableCores
}
else {
$null
}
$baseRow = [ordered]@{
VmSku = $sku
Region = $Region
Category = if ($spec) {
$spec.Category
}
else {
$null
}
Architecture = if ($spec) {
$spec.Architecture
}
else {
$null
}
vCPUs = if ($spec) {
$spec.vCPUs
}
else {
$null
}
MemoryGB = if ($spec) {
$spec.MemoryGB
}
else {
$null
}
LocalDiskGB = if ($spec) {
$spec.LocalDiskGB
}
else {
$null
}
MaxDisks = if ($spec) {
$spec.MaxDisks
}
else {
$null
}
MaxUncachedIOPS = if ($spec) {
$spec.MaxUncachedIOPS
}
else {
$null
}
MaxUncachedThroughputMBps = if ($spec) {
$spec.MaxUncachedThroughputMBps
}
else {
$null
}
PremiumIO = if ($spec) {
$spec.PremiumIO
}
else {
$null
}
MaxNICs = if ($spec) {
$spec.MaxNICs
}
else {
$null
}
AcceleratedNetworking = if ($spec) {
$spec.AcceleratedNetworking
}
else {
$null
}
AvailableZones = $zoneStr
OSDiskSizeGB = $osDiskGB
EphemeralOSDisk = if ($spec) {
$spec.EphemeralOSDisk
}
else {
$null
}
GPUs = if ($spec) {
$spec.GPUs
}
else {
$null
}
SecurityTypes = if ($spec) {
$spec.SecurityTypes
}
else {
$null
}
Availability = if ($spec) {
$spec.Availability
}
else {
$null
}
QuotaUsedCores = $quotaUsed
QuotaAvailableCores = $quotaAvail
}
if ($linuxOD -or $linux1y -or $linux3y) {
[pscustomobject]($baseRow + [ordered]@{
OS = 'Linux'
OnDemand = $linuxOD
Reservation1Yr = $linux1y
Reservation3Yr = $linux3y
CurrencyCode = $currency
})
}
if ($windowsOD -or $windows1y -or $windows3y) {
[pscustomobject]($baseRow + [ordered]@{
OS = 'Windows'
OnDemand = $windowsOD
Reservation1Yr = $windows1y
Reservation3Yr = $windows3y
CurrencyCode = $currency
})
}
}
}
# ── Module check ──────────────────────────────────────────────────────────────
$requiredModules = @('Az.Accounts', 'Az.Compute')
$missing = $requiredModules | Where-Object { -not (Get-Module -ListAvailable -Name $_) }
if ($missing) {
Write-Host "`nThe following required modules are not installed:`n" -ForegroundColor Red
foreach ($mod in $missing) {
Write-Host " $mod" -ForegroundColor Yellow
}
Write-Host "`nInstall them with:`n" -ForegroundColor Cyan
foreach ($mod in $missing) {
Write-Host " Install-Module $mod -Scope CurrentUser -Force" -ForegroundColor White
}
Write-Host ''
exit 1
}
$requiredModules | ForEach-Object { Import-Module $_ }
# ── Azure Auth ───────────────────────────────────────────────────────────────
$ctx = Get-AzContext
if (-not $ctx) {
Write-Verbose 'No active Azure session. Connecting...'
Connect-AzAccount
}
else {
Write-Verbose "Using existing Azure session ($($ctx.Account))."
}
# ── Resolve subscription (accepts GUID or name; defaults to current context) ──
$selectedSub = if ($Subscription) {
$guidPattern = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$'
if ($Subscription -match $guidPattern) {
Get-AzSubscription -SubscriptionId $Subscription
}
else {
Get-AzSubscription -SubscriptionName $Subscription
}
}
else {
(Get-AzContext).Subscription
}
if (-not $selectedSub) {
throw "Could not resolve subscription '$Subscription'. Use a name or ID visible in 'Get-AzSubscription'."
}
if ((Get-AzContext).Subscription.Id -ne $selectedSub.Id) {
Write-Verbose "Switching context to subscription '$($selectedSub.Name)' ($($selectedSub.Id))."
Set-AzContext -SubscriptionId $selectedSub.Id | Out-Null
}
else {
Write-Verbose "Using subscription '$($selectedSub.Name)' ($($selectedSub.Id))."
}
# ── Cache setup ──────────────────────────────────────────────────────────────
$cacheDir = Join-Path ([System.IO.Path]::GetTempPath()) 'AzureVmPricing'
if (-not (Test-Path $cacheDir)) {
New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
}
# ── Validate regions ─────────────────────────────────────────────────────────
$validRegions = Get-CachedOrFetch -Key 'locations' -MaxAgeHours $CacheMaxAgeHours -Refresh:$RefreshCache -Fetch {
Get-AzLocation | Select-Object -ExpandProperty Location
}
$validRegionSet = [System.Collections.Generic.HashSet[string]]::new(
[string[]]@($validRegions),
[StringComparer]::OrdinalIgnoreCase)
$invalidRegions = @($Region | Where-Object { -not $validRegionSet.Contains($_) })
if ($invalidRegions) {
throw "Invalid Azure region name(s): $($invalidRegions -join ', '). Run 'Get-AzLocation | Select-Object -ExpandProperty Location' to see the full list."
}
$specFilterKeys = @(
'MinCpus', 'MaxCpus', 'Cpus',
'MinRamGB', 'MaxRamGB', 'RamGB',
'MinNics', 'MaxNics', 'Nics',
'Zone'
)
$specFiltersActive = @($specFilterKeys | Where-Object { $PSBoundParameters.ContainsKey($_) }).Count -gt 0
# Forward the script's bound filter params into Test-VmSpecMatch via splat. Inside
# the function $PSBoundParameters only reflects its own args, so we have to copy.
$filterParams = @{}
foreach ($key in $specFilterKeys) {
if ($PSBoundParameters.ContainsKey($key)) {
$filterParams[$key] = $PSBoundParameters[$key]
}
}
# Price cache key depends on VmSku (it narrows the query); spec filters are client-side.
$priceSuffix = ''
if ($VmSku) {
$hashBytes = [System.Security.Cryptography.MD5]::Create().ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes((($VmSku | Sort-Object) -join ',')))
$priceSuffix = '-' + [System.BitConverter]::ToString($hashBytes).Replace('-', '').Substring(0, 8)
}
# ── Process each region ──────────────────────────────────────────────────────
$regionCount = $Region.Count
$regionIndex = 0
$allRows = foreach ($regionName in $Region) {
$regionIndex++
Write-Progress -Id 0 -Activity 'Processing Azure regions' `
-Status "$regionName ($regionIndex of $regionCount)" `
-PercentComplete ((($regionIndex - 1) / $regionCount) * 100)
Write-Verbose "Processing region $regionName..."
Write-Progress -Id 1 -ParentId 0 -Activity "Loading data for $($selectedSub.Name) - $regionName" -Status 'Fetching VM SKU specs...'
$skus = Get-CachedOrFetch -Key "specs-$regionName" -MaxAgeHours $CacheMaxAgeHours -Refresh:$RefreshCache -Fetch {
Get-AzComputeResourceSku -Location $regionName | Where-Object { $_.ResourceType -eq 'virtualMachines' }
}
$vmSpecs = @{}
$skus | ConvertTo-VmSpec -Region $regionName | ForEach-Object { $vmSpecs[$_.Name] = $_ }
Write-Verbose " $($vmSpecs.Count) VM sizes found."
Write-Progress -Id 1 -ParentId 0 -Activity "Loading data for $($selectedSub.Name) - $regionName" -Status 'Fetching VM quota...'
$quotaByFamily = Get-VmFamilyQuota -Region $regionName `
-CacheKey "quota-$($selectedSub.Id)-$regionName" `
-CacheMaxAgeHours $CacheMaxAgeHours -Refresh:$RefreshCache
$matchingSkus = $null
if ($specFiltersActive) {
$matching = @($vmSpecs.Values | Test-VmSpecMatch @filterParams | Select-Object -ExpandProperty Name)
$matchingSkus = [System.Collections.Generic.HashSet[string]]::new([string[]]$matching)
Write-Verbose " $($matchingSkus.Count) sizes match the requested filters."
}
$prices = Get-AzureRegionRetailPrice -Region $regionName -VmSku $VmSku `
-CacheKey "prices-$regionName$priceSuffix" `
-CacheMaxAgeHours $CacheMaxAgeHours -Refresh:$RefreshCache
if (-not $prices) {
Write-Warning "No pricing found in $regionName."
continue
}
Write-Verbose " Total price items: $($prices.Count)"
$prices | Group-Object armSkuName |
ConvertTo-VmPricingRow -VmSpecs $vmSpecs -QuotaByFamily $quotaByFamily `
-Region $regionName -MatchingSkus $matchingSkus
}
Write-Progress -Id 1 -Activity ' ' -Completed
Write-Progress -Id 0 -Activity ' ' -Completed
$allRows | Sort-Object VmSku
@reconbot

Copy link
Copy Markdown
Author

eg

"VmSku","Region","Category","Architecture","vCPUs","MemoryGB","LocalDiskGB","MaxDisks","MaxUncachedIOPS","MaxUncachedThroughputMBps","PremiumIO","MaxNICs","AcceleratedNetworking","AvailableZones","OSDiskSizeGB","EphemeralOSDisk","GPUs","SecurityTypes","Availability","QuotaUsedCores","QuotaAvailableCores","OS","OnDemand","Reservation1Yr","Reservation3Yr","CurrencyCode"
"Standard_B1ls","eastus2","Burstable","x64","1","0.5","4","2","320","22","True","2","False","None","1023","True","0","Standard,TrustedLaunch","NotAvailableForSubscription","0","50","Linux","3.8","2.25",,"USD"
"Standard_B2pts_v2","eastus2","Burstable","Arm64","2","1","0","4","3750","85","True","2","True","None","1023","False","0","Standard","NotAvailableForSubscription","0","50","Linux","6.13","3.58","2.33","USD"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment