Created
May 28, 2026 17:31
-
-
Save reconbot/60ae9fb8206080154ee18732b622727d to your computer and use it in GitHub Desktop.
Gets a table of prices for your subscription.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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 |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
eg