Private/Azure/Get-AzActualPricing.ps1
|
function Get-AzActualPricing { <# .SYNOPSIS Retrieves negotiated VM pricing using a tiered API strategy. .DESCRIPTION Tier 1: Consumption Price Sheet API — returns negotiated unitPrice AND retail pretaxStandardRate for ALL meters (deployed or not). Works for EA and MCA billing types. Tier 2: Cost Management Query API — derives effective hourly rate from actual month-to-date usage (cost / hours). Works for ALL billing types but only covers currently-deployed SKUs. Returns a hashtable keyed by ARM SKU name (e.g. Standard_D2s_v3) with Hourly, Monthly, Currency, Meter, and IsNegotiated fields. #> param( [Parameter(Mandatory = $true)] [string]$SubscriptionId, [Parameter(Mandatory = $true)] [string]$Region, [int]$MaxRetries = 3, [int]$HoursPerMonth = 730, [hashtable]$AzureEndpoints, [string]$TargetEnvironment = 'AzureCloud', [System.Collections.IDictionary]$Caches = @{} ) if (-not $Caches.ActualPricing) { $Caches.ActualPricing = @{} } $armLocation = $Region.ToLower() -replace '\s', '' # Price Sheet meterLocation values use a different naming convention than ARM: # ARM is '<locality><geo>' or '<geoword><locality>' (westus, centralindia, japaneast) # Cache key is '<geoshort><locality>' (uswest, incentral, jaeast) # Each ARM key maps to an *ordered list* of candidate meterLocation keys (after # normalization: lowercased, spaces/hyphens stripped). First candidate present # in the cache wins. Order: most-specific name first, then legacy fallbacks. # # Examples (normalized form): # ARM 'westus' -> meterLocation 'US West' -> 'uswest' # ARM 'eastus2' -> meterLocation 'US East 2' -> 'useast2' # ARM 'westeurope' -> meterLocation 'EU West' -> 'euwest' # ARM 'southeastasia' -> meterLocation 'AP Southeast' -> 'apsoutheast' # ARM 'germanywestcentral' -> meterLocation 'DE West Central' -> 'dewestcentral' # ARM 'japaneast' -> meterLocation 'JA East' -> 'jaeast' (note: 'ja', not 'jp') # ARM 'usgovvirginia' -> meterLocation 'US Gov Virginia' / 'US Gov VA' / 'US Gov' $armToMeterLocation = @{ # ── US commercial ── 'westus' = @('uswest') 'westus2' = @('uswest2') 'westus3' = @('uswest3') 'eastus' = @('useast') 'eastus2' = @('useast2') 'eastus3' = @('useast3') 'centralus' = @('uscentral') 'northcentralus' = @('usnorthcentral') 'southcentralus' = @('ussouthcentral') 'southcentralus2' = @('ussouthcentral2') 'westcentralus' = @('uswestcentral') 'southeastus' = @('ussoutheast') 'southeastus3' = @('ussoutheast3') 'southeastus5' = @('ussoutheast5') 'southwestus' = @('ussouthwest') # ── Europe ── 'westeurope' = @('euwest') 'northeurope' = @('eunorth') 'uksouth' = @('uksouth') 'uksouth2' = @('uksouth2') 'ukwest' = @('ukwest') 'francecentral' = @('frcentral') 'francesouth' = @('frsouth') 'germanywestcentral' = @('dewestcentral') 'germanynorth' = @('denorth') 'swedencentral' = @('secentral') 'swedensouth' = @('sesouth') 'norwayeast' = @('noeast') 'norwaywest' = @('nowest') 'switzerlandnorth' = @('chnorth') 'switzerlandwest' = @('chwest') 'italynorth' = @('itnorth') 'spaincentral' = @('escentral') 'polandcentral' = @('plcentral') 'israelcentral' = @('ilcentral') 'israelnorthwest' = @('ilnorthwest') 'denmarkeast' = @('dkeast') 'austriaeast' = @('ateast') 'belgiumcentral' = @('becentral') # ── Asia Pacific ── 'southeastasia' = @('apsoutheast') 'eastasia' = @('apeast') 'japaneast' = @('jaeast') # NOT 'jp' — EA price sheet uses 'ja' 'japanwest' = @('jawest') 'koreacentral' = @('krcentral') 'koreasouth' = @('krsouth') 'centralindia' = @('incentral') 'southindia' = @('insouth') 'westindia' = @('inwest') 'southcentralindia' = @('insouthcentral') 'australiaeast' = @('aueast') 'australiasoutheast' = @('ausoutheast') 'australiacentral' = @('aucentral') 'australiacentral2' = @('aucentral2') 'newzealandnorth' = @('nznorth') 'malaysiawest' = @('mywest') 'indonesiacentral' = @('idcentral') 'taiwannorth' = @('twnorth') 'taiwannorthwest' = @('twnorthwest') # ── Americas (non-US) ── 'canadacentral' = @('cacentral') 'canadaeast' = @('caeast') 'brazilsouth' = @('brsouth') 'brazilsoutheast' = @('brsoutheast') 'chilecentral' = @('clcentral') 'mexicocentral' = @('mxcentral') # ── Middle East / Africa ── 'uaecentral' = @('aecentral') 'uaenorth' = @('aenorth') 'qatarcentral' = @('qacentral') 'southafricanorth' = @('zanorth') 'southafricawest' = @('zawest') # ── US sovereign (Gov / DoD) ── 'usgovarizona' = @('usgovarizona', 'usgovaz') 'usgovtexas' = @('usgovtexas', 'usgovtx') 'usgovvirginia' = @('usgovvirginia', 'usgovva', 'usgov') 'usdodcentral' = @('usdodcentral', 'usdodc', 'usdod') 'usdodeast' = @('usdodeast', 'usdode', 'usdod') } # Generic fallback: derive likely cache keys from an ARM region by swapping # locality and geo tokens. Catches future regions added before the explicit # table is updated. Tries every (locality, geoLong, geoShort) combo. $geoLongToShort = @{ 'us' = 'us'; 'europe' = 'eu'; 'asia' = 'ap'; 'uk' = 'uk' 'australia' = 'au'; 'japan' = 'ja'; 'korea' = 'kr'; 'india' = 'in' 'canada' = 'ca'; 'brazil' = 'br'; 'chile' = 'cl'; 'mexico' = 'mx' 'france' = 'fr'; 'germany' = 'de'; 'sweden' = 'se'; 'norway' = 'no' 'switzerland' = 'ch'; 'italy' = 'it'; 'spain' = 'es'; 'poland' = 'pl' 'israel' = 'il'; 'denmark' = 'dk'; 'austria' = 'at'; 'belgium' = 'be' 'newzealand' = 'nz'; 'malaysia' = 'my'; 'indonesia' = 'id' 'taiwan' = 'tw'; 'uae' = 'ae'; 'qatar' = 'qa'; 'southafrica' = 'za' } $deriveAliasCandidates = { param($arn) $candidates = [System.Collections.Generic.List[string]]::new() foreach ($geoLong in $geoLongToShort.Keys) { $geoShort = $geoLongToShort[$geoLong] # Pattern A: ARM ends with geoLong (westeurope, japaneast, southeastasia) # -> swap to <geoShort><locality> (euwest, jaeast, apsoutheast) if ($arn.EndsWith($geoLong) -and $arn.Length -gt $geoLong.Length) { $locality = $arn.Substring(0, $arn.Length - $geoLong.Length) $candidates.Add("$geoShort$locality") | Out-Null } # Pattern B: ARM starts with geoLong (centralindia would be 'india'+'central' # but ARM is centralindia which is 'central'+'india', already pattern A) # ARM germanywestcentral -> 'germany' prefix, locality 'westcentral' # -> 'de' + 'westcentral' = 'dewestcentral' if ($arn.StartsWith($geoLong) -and $arn.Length -gt $geoLong.Length) { $locality = $arn.Substring($geoLong.Length) $candidates.Add("$geoShort$locality") | Out-Null } # Pattern C: ARM also ends with trailing digit (eastus2, westus3) — the # digit is part of the locality+geo+digit pattern. Try # stripping the digit for a locality match too. if ($arn -match "^(.+?)($geoLong)(\d+)$") { $candidates.Add("$geoShort$($Matches[1])$($Matches[3])") | Out-Null } } # Deduplicate while preserving order $seen = @{} $unique = [System.Collections.Generic.List[string]]::new() foreach ($c in $candidates) { if (-not $seen.ContainsKey($c)) { $seen[$c] = $true; $unique.Add($c) | Out-Null } } return $unique } # ── Disk cache ── # EA/MCA negotiated rates are enrollment-level (tenant-scoped). Cache by # TenantId so all subscriptions in the same tenant share one cache file. # Uses shared filename prefix so both AzVMAvailability and AzVMLifecycle # modules share the same disk cache (identical data format). $PriceSheetCacheTTLDays = 30 $tenantId = try { (Get-AzContext -ErrorAction SilentlyContinue).Tenant.Id } catch { $null } $cacheKey = if ($tenantId) { $tenantId } else { $SubscriptionId } $cacheDir = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() } # Cache schema v3: structured container { Regular, SP1Yr, SP3Yr } per region. # Negotiated Savings Plan rates are now harvested from each row's savingsPlan # sub-object (effectivePrice + term=P1Y/P3Y per Consumption Price Sheet API spec). # v4 splits paired meter names like 'D3/DS3 v2' into both ARM SKUs (D3_v2 + DS3_v2). # v2 stored only the flat Regular map; v1 mistakenly accepted Spot meters as PAYG. $cacheFile = Join-Path $cacheDir "AzVMLifecycle-PriceSheet-v4-$cacheKey.json" # Negative cache sidecar \u2014 records last Tier 1 failure (typically HTTP 429) so we # don't keep banging the throttle wall for ~11 minutes per attempt across runs. $negCacheFile = Join-Path $cacheDir "AzVMLifecycle-PriceSheet-v4-$cacheKey.failed.json" # Best-effort cleanup of obsolete cache files. Get-ChildItem $cacheDir -Filter "AzVMLifecycle-PriceSheet-$cacheKey.json" -ErrorAction SilentlyContinue | Remove-Item -ErrorAction SilentlyContinue Get-ChildItem $cacheDir -Filter "AzVMLifecycle-PriceSheet-v2-$cacheKey.json" -ErrorAction SilentlyContinue | Remove-Item -ErrorAction SilentlyContinue Get-ChildItem $cacheDir -Filter "AzVMLifecycle-PriceSheet-v3-$cacheKey*.json" -ErrorAction SilentlyContinue | Remove-Item -ErrorAction SilentlyContinue # Helper: resolve an ARM region to the first matching cached meterLocation key. # Order: exact ARM name -> explicit alias table -> generic geo-permutation fallback. $resolvePriceSheetKey = { param($arn, $cache) if ($cache.ContainsKey($arn) -and $cache[$arn] -and $cache[$arn].Count -gt 0) { return $arn } if ($armToMeterLocation.ContainsKey($arn)) { foreach ($cand in @($armToMeterLocation[$arn])) { if ($cache.ContainsKey($cand) -and $cache[$cand] -and $cache[$cand].Count -gt 0) { return $cand } } } # Generic fallback — try permuted aliases derived from known geo tokens. foreach ($cand in (& $deriveAliasCandidates $arn)) { if ($cache.ContainsKey($cand) -and $cache[$cand] -and $cache[$cand].Count -gt 0) { return $cand } } return $null } # Helper: walk a region map and copy each meterLocation-keyed bucket to its # ARM-region key (e.g. 'useast2' -> 'eastus2'). Used for the Regular PAYG map # and both Savings Plan term maps so downstream consumers can look them up by # ARM region without needing the alias table. Mutates the input map in place # and returns a list of "arm -> alias (count)" strings for diagnostics. $applyArmAliases = { param($map) $applied = [System.Collections.Generic.List[string]]::new() if (-not $map) { return $applied } foreach ($arnKey in $armToMeterLocation.Keys) { if ($map.ContainsKey($arnKey) -and $map[$arnKey] -and $map[$arnKey].Count -gt 0) { continue } foreach ($cand in @($armToMeterLocation[$arnKey])) { if ($cand -eq $arnKey) { continue } if ($map.ContainsKey($cand) -and $map[$cand] -and $map[$cand].Count -gt 0) { $map[$arnKey] = $map[$cand] $applied.Add("$arnKey -> $cand ($($map[$cand].Count))") | Out-Null break } } } return $applied } # EA/MCA negotiated rates are set at the enrollment level — identical across # all subscriptions. Page through the Price Sheet once, group all Linux VM # meters by meterLocation, and serve every subsequent region from cache. if ($Caches.ActualPricing.ContainsKey('AllRegions')) { $cached = $Caches.ActualPricing['AllRegions'] # Schema v3: structured container. Schema v2 (legacy in-memory): flat region map. if ($cached -is [hashtable] -and $cached.ContainsKey('Regular')) { $allRegionPrices = $cached.Regular if (-not $Caches.NegotiatedSavingsPlan) { $Caches.NegotiatedSavingsPlan = @{ '1Yr' = $cached.SP1Yr; '3Yr' = $cached.SP3Yr } } } else { $allRegionPrices = $cached } $lookupKey = & $resolvePriceSheetKey $armLocation $allRegionPrices $regionPrices = if ($lookupKey) { $allRegionPrices[$lookupKey] } else { @{} } if ($regionPrices.Count -gt 0) { $aliasSuffix = if ($lookupKey -ne $armLocation) { " (alias → '$lookupKey')" } else { '' } Write-Host " Tier 1 (Price Sheet): $($regionPrices.Count) negotiated SKU prices for '$Region'$aliasSuffix (cached)" -ForegroundColor DarkGray } else { $isSovereignRegion = $armLocation -match '^(usgov|usdod)' $candList = if ($armToMeterLocation.ContainsKey($armLocation)) { @($armToMeterLocation[$armLocation]) -join "', '" } else { '' } $aliasNote = if ($candList) { " (tried '$candList')" } else { '' } if ($isSovereignRegion) { $govKeys = @($allRegionPrices.Keys | Where-Object { $_ -match 'gov|dod|china|german|virginia|arizona|texas' } | Sort-Object) $hint = if ($govKeys.Count -gt 0) { " Sovereign keys present in cache: $($govKeys -join ', ')." } else { ' No sovereign keys present in cache.' } } else { $hint = if ($allRegionPrices.Count -gt 0) { " $($allRegionPrices.Count) other region(s) cached — enrollment may not have meters for this region." } else { '' } } Write-Host " Tier 1 (Price Sheet): no negotiated rates for '$Region'$aliasNote — falling back to retail.$hint" -ForegroundColor DarkYellow } return $regionPrices } # Check disk cache before calling the API if (Test-Path $cacheFile) { try { $cacheAge = (Get-Date) - (Get-Item $cacheFile).LastWriteTime if ($cacheAge.TotalDays -le $PriceSheetCacheTTLDays) { $ageDays = [math]::Floor($cacheAge.TotalDays) $ageLabel = if ($ageDays -eq 0) { 'today' } elseif ($ageDays -eq 1) { '1 day old' } else { "$ageDays days old" } Write-Host " Loading cached discounted pricing data ($ageLabel)..." -ForegroundColor DarkGray $loaded = Get-Content $cacheFile -Raw | ConvertFrom-Json -AsHashtable # v3: structured. Older v2 caches were already wiped above; defensive fallback in case of partial schema. if ($loaded -is [hashtable] -and $loaded.ContainsKey('Regular')) { $allRegionPrices = $loaded.Regular $loadedSP1 = if ($loaded.ContainsKey('SP1Yr')) { $loaded.SP1Yr } else { @{} } $loadedSP3 = if ($loaded.ContainsKey('SP3Yr')) { $loaded.SP3Yr } else { @{} } } else { $allRegionPrices = $loaded $loadedSP1 = @{} $loadedSP3 = @{} } $Caches.NegotiatedSavingsPlan = @{ '1Yr' = $loadedSP1; '3Yr' = $loadedSP3 } # Resolve sovereign + commercial region pricing — meterLocation abbreviations # differ from ARM names. Apply the same alias pass to the Regular PAYG map # and both Savings Plan term maps so older v4 caches (written before the # SP-alias fix in v2.2.1) get fixed up on load instead of returning retail # SP rates for commercial regions. $aliasSummary = & $applyArmAliases $allRegionPrices $null = & $applyArmAliases $loadedSP1 $null = & $applyArmAliases $loadedSP3 if ($aliasSummary.Count -gt 0) { Write-Host " Tier 1 (Price Sheet): sovereign aliases resolved — $($aliasSummary -join '; ')" -ForegroundColor DarkGray } $Caches.ActualPricing['AllRegions'] = $allRegionPrices $totalSkus = ($allRegionPrices.Values | ForEach-Object { $_.Count } | Measure-Object -Sum).Sum Write-Host " Tier 1 (Price Sheet): $totalSkus negotiated SKU prices across $($allRegionPrices.Count) region(s) (from cache file)" -ForegroundColor DarkGray $lookupKey = & $resolvePriceSheetKey $armLocation $allRegionPrices $regionPrices = if ($lookupKey) { $allRegionPrices[$lookupKey] } else { @{} } if ($regionPrices.Count -gt 0) { $aliasSuffix = if ($lookupKey -ne $armLocation) { " (alias → '$lookupKey')" } else { '' } Write-Host " Tier 1 (Price Sheet): $($regionPrices.Count) negotiated SKU prices for '$Region'$aliasSuffix (cached)" -ForegroundColor DarkGray } else { $isSovereignRegion = $armLocation -match '^(usgov|usdod)' $candList = if ($armToMeterLocation.ContainsKey($armLocation)) { @($armToMeterLocation[$armLocation]) -join "', '" } else { '' } $aliasNote = if ($candList) { " (tried '$candList')" } else { '' } if ($isSovereignRegion) { $govKeys = @($allRegionPrices.Keys | Where-Object { $_ -match 'gov|dod|china|german|virginia|arizona|texas' } | Sort-Object) $hint = if ($govKeys.Count -gt 0) { " Sovereign keys present in cache: $($govKeys -join ', ')." } else { ' No sovereign keys present in cache.' } } else { $hint = if ($allRegionPrices.Count -gt 0) { " $($allRegionPrices.Count) other region(s) cached — enrollment may not have meters for this region." } else { '' } } Write-Host " Tier 1 (Price Sheet): no negotiated rates for '$Region'$aliasNote — falling back to retail.$hint" -ForegroundColor DarkYellow } return $regionPrices } else { Write-Verbose "Price Sheet cache expired ($([math]::Floor($cacheAge.TotalDays)) days old, TTL=$PriceSheetCacheTTLDays days). Refreshing from API." } } catch { Write-Verbose "Price Sheet cache file unreadable, will refresh from API: $($_.Exception.Message)" } } if (-not $AzureEndpoints) { $AzureEndpoints = Get-AzureEndpoints -EnvironmentName $TargetEnvironment } $armUrl = $AzureEndpoints.ResourceManagerUrl # Corporate-proxy support: Az.Accounts uses its own HttpClient that already # negotiates with the proxy, but plain Invoke-RestMethod calls (used below for # the Price Sheet API) inherit the system default proxy WITHOUT credentials, # which trips HTTP 407 (Proxy Authentication Required) on auth-required # corporate proxies (e.g. web-proxy.web.boeing.com). Attach the current # user's default credentials once so the Price Sheet calls can punch through. try { if ([System.Net.WebRequest]::DefaultWebProxy -and -not [System.Net.WebRequest]::DefaultWebProxy.Credentials) { [System.Net.WebRequest]::DefaultWebProxy.Credentials = [System.Net.CredentialCache]::DefaultCredentials } } catch { Write-Verbose "Could not set DefaultWebProxy.Credentials: $($_.Exception.Message)" } # Negative-cache check: if a recent Tier 1 attempt failed (e.g. HTTP 429), skip # straight to Tier 2 until the cool-down expires. Avoids 30+ minute throttling # storms across consecutive runs while the EA Price Sheet API is rate-limiting. if (Test-Path $negCacheFile) { try { $neg = Get-Content $negCacheFile -Raw | ConvertFrom-Json $negAt = [datetime]$neg.At $cool = [int]($neg.CoolDownSeconds) $negStatus = [int]($neg.Status) $remaining = ($negAt.AddSeconds($cool) - (Get-Date)).TotalSeconds # Discard stale cooldown entries whose status isn't a real HTTP error code # (e.g. older builds misparsed proxy port numbers as status 310). Without # this guard, those entries would keep us locked out of Tier 1 forever. if ($negStatus -lt 400 -or $negStatus -gt 599) { Write-Verbose "Discarding implausible Tier 1 negative-cache status ($negStatus); retrying." Remove-Item $negCacheFile -ErrorAction SilentlyContinue } elseif ($remaining -gt 0) { $remMin = [math]::Ceiling($remaining / 60) Write-Host " Tier 1 (Price Sheet): skipped — prior failure ($negStatus) cooling down for ~$remMin min. Using retail." -ForegroundColor DarkYellow return $null } else { Remove-Item $negCacheFile -ErrorAction SilentlyContinue } } catch { Remove-Item $negCacheFile -ErrorAction SilentlyContinue } } $token = $null $headers = $null try { $token = (Get-AzAccessToken -ResourceUrl $armUrl -ErrorAction Stop).Token $headers = @{ 'Authorization' = "Bearer $token" 'Content-Type' = 'application/json' } } catch { if (-not $Caches.NegotiatedPricingWarned) { $Caches.NegotiatedPricingWarned = $true Write-Warning "Cost Management: cannot obtain access token. Run: Connect-AzAccount" Write-Warning "Falling back to retail pricing (public list prices without negotiated discounts)." } return $null } # ── Tier 1: Consumption Price Sheet API ── # Returns negotiated unitPrice for ALL meters (deployed or not). # pretaxStandardRate = retail listing price (for discount calculation). # Requires $expand=properties/meterDetails to populate category/region/meter fields. # Only works for EA/MCA billing; returns 404 for PAYG/Sponsorship/MSDN. # # We page through the entire Price Sheet ONCE and group all Linux VM meters # by their normalized meterLocation. No region filtering — we capture everything. $tier1Success = $false $allRegionPrices = @{} # key = normalized location, value = hashtable of SKU → pricing $allRegionSP1Yr = @{} # negotiated Savings Plan P1Y per region $allRegionSP3Yr = @{} # negotiated Savings Plan P3Y per region $totalSP1Meters = 0 $totalSP3Meters = 0 $MaxPricesheetPages = 500 $EstimatedPages = 500 # Based on observed EA price sheets (~484 pages typical) try { $psUrl = "$armUrl/subscriptions/$SubscriptionId/providers/Microsoft.Consumption/pricesheets/default?api-version=2023-05-01&`$expand=properties/meterDetails&`$top=1000" Write-Verbose "Tier 1 (Price Sheet): calling $psUrl" Write-Host " Initial download of discounted pricing data (duration varies by connection speed, one-time)..." -ForegroundColor Cyan Write-Host " Subsequent runs will use cached data (valid $PriceSheetCacheTTLDays days)." -ForegroundColor DarkGray $totalItems = 0 $pageCount = 0 $totalVmMeters = 0 $unitMeasureCounts = @{} # Track unitOfMeasure values for diagnostics $firstVmPage = 0 # Track page distribution of VM meters $lastVmPage = 0 $vmMetersPerPage = @{} # page number → VM meter count on that page # Skip-reason diagnostics — surface gaps in the negotiated price-sheet ingest # so we can see WHY a region might end up with zero negotiated SKUs. $skipReasons = [ordered]@{ NoMeterDetails = 0 NotVirtualMachine = 0 WindowsSubcategory = 0 SpotOrLowPriority = 0 EmptyMeterLocation = 0 UnparsableMeterName = 0 ZeroOrNegativeUnitPrice = 0 } $savedProgressPref = $ProgressPreference $ProgressPreference = 'Continue' # Restore progress bar (suppressed globally for Invoke-RestMethod noise) $scanStopwatch = [System.Diagnostics.Stopwatch]::StartNew() do { $pageCount++ $pctComplete = [math]::Min(99, [math]::Floor(($pageCount / $EstimatedPages) * 100)) $elapsed = $scanStopwatch.Elapsed $elapsedStr = '{0:mm\:ss}' -f $elapsed if ($pageCount -gt 2) { $secsPerPage = $elapsed.TotalSeconds / ($pageCount - 1) $remainPages = [math]::Max(0, $EstimatedPages - $pageCount) $etaSecs = [math]::Ceiling($secsPerPage * $remainPages) $etaMin = [math]::Floor($etaSecs / 60) $etaSec = $etaSecs % 60 $etaStr = if ($etaMin -gt 0) { "${etaMin}m ${etaSec}s remaining" } else { "${etaSec}s remaining" } } else { $etaStr = 'estimating...' } Write-Progress -Activity "Downloading discounted pricing data" -Status "Page $pageCount - $totalVmMeters VM SKUs found - $elapsedStr elapsed - $etaStr" -PercentComplete $pctComplete $psResponse = Invoke-WithRetry -MaxRetries $MaxRetries -OperationName "Consumption Price Sheet (page $pageCount)" -ScriptBlock { Invoke-RestMethod -Uri $psUrl -Method Get -Headers $headers -TimeoutSec 120 } if ($psResponse.properties.pricesheets) { $totalItems += $psResponse.properties.pricesheets.Count $pageVmCount = 0 foreach ($item in $psResponse.properties.pricesheets) { $md = $item.meterDetails if (-not $md) { $skipReasons.NoMeterDetails++; continue } if ($md.meterCategory -ne 'Virtual Machines') { $skipReasons.NotVirtualMachine++; continue } if ($md.meterSubCategory -match 'Windows') { $skipReasons.WindowsSubcategory++; continue } # CRITICAL: Skip non-PAYG VM meters (Spot, Low Priority). # Previous code stripped these suffixes and treated them as Regular, # causing Spot rates (~1/8 of PAYG) to be cached as negotiated PAYG # whenever the Spot meter appeared first in the price sheet pages. # meterSubCategory or meterName carry these markers; check both. $meterNameRaw = [string]$md.meterName $meterSub = [string]$md.meterSubCategory if ($meterNameRaw -match '\b(Spot|Low Priority)\b' -or $meterSub -match '\b(Spot|Low Priority)\b') { $skipReasons.SpotOrLowPriority++; continue } # Normalize meterLocation to ARM-style key (lowercase, no spaces/hyphens) $meterLoc = $md.meterLocation $normalizedRegion = ($meterLoc -replace '[\s-]', '').ToLower() if (-not $normalizedRegion) { $skipReasons.EmptyMeterLocation++; continue } # Convert billing meter name to ARM SKU name(s). # Older paired sizes (e.g. 'D3 v2/DS3 v2', 'D8 v3/D8s v3', 'D1 v2/DS1 v2') # ship as a single combined meter where each side already carries its own # version suffix and the slash sits between two space-separated tokens. # The basic and premium-storage 's' variants share the exact same rate, # so we split on '/' and emit one ARM SKU per fragment. We also strip the # legacy " - Expired" suffix some meters carry so expired meters still # resolve to the live ARM SKU name (rates remain valid for billing). # No longer strip Spot/Low Priority — those meters are filtered out above. $cleanName = $meterNameRaw.Trim() -replace '^Standard[\s_]+', '' if ($cleanName -notmatch '^[A-Z]') { $skipReasons.UnparsableMeterName++; continue } $cleanName = $cleanName -replace '\s*-\s*Expired\s*$', '' $vmSizes = @() if ($cleanName -like '*/*') { foreach ($frag in ($cleanName -split '/')) { $f = $frag.Trim() if ($f) { $vmSizes += "Standard_$($f -replace '\s+', '_')" } } } else { $vmSizes = @("Standard_$($cleanName -replace '\s+', '_')") } if (-not $vmSizes -or $vmSizes.Count -eq 0) { $skipReasons.UnparsableMeterName++; continue } # Determine the hourly divisor from unitOfMeasure # Common values: "1 Hour", "100 Hours", "1/Month", "1/Day" $unitOfMeasure = if ($item.unitOfMeasure) { $item.unitOfMeasure } elseif ($md.unit) { $md.unit } else { '1 Hour' } $unitKey = $unitOfMeasure.Trim() if ($unitMeasureCounts.ContainsKey($unitKey)) { $unitMeasureCounts[$unitKey]++ } else { $unitMeasureCounts[$unitKey] = 1 } $hourlyDivisor = switch -Regex ($unitKey) { '^\d+\s+Hour' { if ($unitKey -match '^(\d+)') { [double]$Matches[1] } else { 1 } } 'Month' { $HoursPerMonth } 'Day' { 24 } default { 1 } } # Savings Plan rows: per Consumption Price Sheet API spec, rows for SP-eligible # meters carry a savingsPlan sub-object with effectivePrice (negotiated) + # term (P1Y/P3Y). Route these to the per-term SP map and SKIP populating the # Regular PAYG entry (those come from rows without savingsPlan). Sovereign # regions don't expose SP product, so these rows simply won't appear there. if ($item.savingsPlan -and $item.savingsPlan.term -and $item.savingsPlan.effectivePrice) { $spRaw = [double]$item.savingsPlan.effectivePrice if ($spRaw -gt 0) { $spHourly = $spRaw / $hourlyDivisor $spMonthly = [math]::Round($spHourly * $HoursPerMonth, 2) $spEntry = @{ Hourly = [math]::Round($spHourly, 4) Monthly = $spMonthly Total = if ($item.savingsPlan.term -eq 'P1Y') { [math]::Round($spMonthly * 12, 2) } else { [math]::Round($spMonthly * 36, 2) } Currency = $item.currencyCode IsNegotiated = $true } switch ($item.savingsPlan.term) { 'P1Y' { if (-not $allRegionSP1Yr.ContainsKey($normalizedRegion)) { $allRegionSP1Yr[$normalizedRegion] = @{} } foreach ($vmSize in $vmSizes) { if (-not $allRegionSP1Yr[$normalizedRegion].ContainsKey($vmSize)) { $allRegionSP1Yr[$normalizedRegion][$vmSize] = $spEntry $totalSP1Meters++ } } } 'P3Y' { if (-not $allRegionSP3Yr.ContainsKey($normalizedRegion)) { $allRegionSP3Yr[$normalizedRegion] = @{} } foreach ($vmSize in $vmSizes) { if (-not $allRegionSP3Yr[$normalizedRegion].ContainsKey($vmSize)) { $allRegionSP3Yr[$normalizedRegion][$vmSize] = $spEntry $totalSP3Meters++ } } } } } continue # SP rows do not populate the Regular PAYG map } # Initialize region bucket if needed if (-not $allRegionPrices.ContainsKey($normalizedRegion)) { $allRegionPrices[$normalizedRegion] = @{} } $rawRate = [double]$item.unitPrice if ($rawRate -le 0) { $skipReasons.ZeroOrNegativeUnitPrice++; continue } $negotiatedRate = $rawRate / $hourlyDivisor $retailRate = if ($md.pretaxStandardRate) { [double]$md.pretaxStandardRate / $hourlyDivisor } else { $null } $newEntry = @{ Hourly = [math]::Round($negotiatedRate, 4) Monthly = [math]::Round($negotiatedRate * $HoursPerMonth, 2) Currency = $item.currencyCode Meter = $md.meterName IsNegotiated = $true } if ($retailRate -and $retailRate -gt 0) { $newEntry.RetailHourly = [math]::Round($retailRate, 4) $newEntry.DiscountPct = [math]::Round((1 - ($negotiatedRate / $retailRate)) * 100, 1) } foreach ($vmSize in $vmSizes) { if (-not $allRegionPrices[$normalizedRegion].ContainsKey($vmSize)) { $allRegionPrices[$normalizedRegion][$vmSize] = $newEntry $totalVmMeters++ $pageVmCount++ } } } if ($pageVmCount -gt 0) { $vmMetersPerPage[$pageCount] = $pageVmCount if ($firstVmPage -eq 0) { $firstVmPage = $pageCount } $lastVmPage = $pageCount } } $psUrl = $psResponse.properties.nextLink } while ($psUrl -and $pageCount -lt $MaxPricesheetPages) if ($totalVmMeters -gt 0) { $tier1Success = $true $scanStopwatch.Stop() Write-Progress -Activity "Downloading discounted pricing data" -Completed $ProgressPreference = $savedProgressPref $scanDuration = $scanStopwatch.Elapsed $scanDurationStr = '{0:mm\:ss}' -f $scanDuration # Resolve sovereign + commercial region pricing — meterLocation abbreviations # differ from ARM names (e.g. 'useast2' vs 'eastus2'). Apply the same alias # pass to the Regular PAYG map and both Savings Plan term maps so downstream # consumers can look them up by ARM region without re-implementing the alias # table. Without this pass on SP maps, commercial-region overlays in # Get-AzVMAvailability silently fall back to retail SP rates. $aliasSummary = & $applyArmAliases $allRegionPrices $null = & $applyArmAliases $allRegionSP1Yr $null = & $applyArmAliases $allRegionSP3Yr if ($aliasSummary.Count -gt 0) { Write-Host " Tier 1 (Price Sheet): sovereign aliases resolved — $($aliasSummary -join '; ')" -ForegroundColor DarkGray } # Cache the full scan — all subsequent calls for any region are served from here $Caches.ActualPricing['AllRegions'] = $allRegionPrices $Caches.NegotiatedSavingsPlan = @{ '1Yr' = $allRegionSP1Yr; '3Yr' = $allRegionSP3Yr } # Persist to disk so subsequent runs skip the API entirely (v3 structured shape). try { $cacheBundle = @{ Regular = $allRegionPrices; SP1Yr = $allRegionSP1Yr; SP3Yr = $allRegionSP3Yr } $cacheJson = ConvertTo-Json -InputObject $cacheBundle -Depth 5 -Compress $tmpFile = "$cacheFile.tmp" [System.IO.File]::WriteAllText($tmpFile, $cacheJson, [System.Text.Encoding]::UTF8) Move-Item -Path $tmpFile -Destination $cacheFile -Force $cacheSizeMB = [math]::Round((Get-Item $cacheFile).Length / 1MB, 1) Write-Host " Pricing data cached to disk (${cacheSizeMB}MB, valid $PriceSheetCacheTTLDays days): $cacheFile" -ForegroundColor DarkGray } catch { Write-Warning "Could not write Price Sheet cache file: $($_.Exception.Message)" Write-Verbose "Cache path: $cacheFile" } $locationSummary = ($allRegionPrices.GetEnumerator() | Sort-Object { $_.Value.Count } -Descending | ForEach-Object { "'$($_.Key)' ($($_.Value.Count))" }) -join ', ' Write-Host " Tier 1 (Price Sheet): $totalVmMeters negotiated SKU prices across $($allRegionPrices.Count) region(s) in $scanDurationStr" -ForegroundColor DarkGray if ($totalSP1Meters -gt 0 -or $totalSP3Meters -gt 0) { Write-Host " Tier 1 (Price Sheet): negotiated Savings Plan rates — $totalSP1Meters x P1Y, $totalSP3Meters x P3Y" -ForegroundColor DarkGray } Write-Verbose "Tier 1 (Price Sheet): $totalItems items across $pageCount page(s), $totalVmMeters VM SKU prices." Write-Verbose " Regions: $locationSummary" $unitSummary = ($unitMeasureCounts.GetEnumerator() | Sort-Object Value -Descending | ForEach-Object { "'$($_.Key)' ($($_.Value))" }) -join ', ' Write-Verbose " unitOfMeasure values: $unitSummary" $sampleDiscount = $null foreach ($rp in $allRegionPrices.Values) { $sampleDiscount = $rp.Values | Where-Object { $_.DiscountPct } | Select-Object -First 1 if ($sampleDiscount) { break } } if ($sampleDiscount) { Write-Verbose " Sample discount: $($sampleDiscount.DiscountPct)% off retail" } Write-Verbose " VM meter page distribution: first=$firstVmPage, last=$lastVmPage of $pageCount pages" if ($vmMetersPerPage.Count -gt 0) { $pagesWithVMs = $vmMetersPerPage.Count $pagesWithoutVMs = $pageCount - $pagesWithVMs Write-Verbose " Pages with VM meters: $pagesWithVMs/$pageCount ($pagesWithoutVMs empty pages)" } # Surface skip-reason counters to help diagnose missing regions/SKUs. $skipParts = @() foreach ($k in $skipReasons.Keys) { if ($skipReasons[$k] -gt 0) { $skipParts += "$k=$($skipReasons[$k])" } } if ($skipParts.Count -gt 0) { Write-Host " Tier 1 (Price Sheet): $totalItems total meters, $totalVmMeters VM SKUs kept; skipped: $($skipParts -join ', ')" -ForegroundColor DarkGray } } else { Write-Progress -Activity "Downloading discounted pricing data" -Completed $ProgressPreference = $savedProgressPref $scanStopwatch.Stop() Write-Host " Tier 1 (Price Sheet): no VM matches ($totalItems items across $pageCount pages). Trying Tier 2..." -ForegroundColor DarkGray Write-Verbose "Tier 1 (Price Sheet): $totalItems items across $pageCount page(s), 0 VM matches. Falling through to Tier 2." } } catch { Write-Progress -Activity "Downloading discounted pricing data" -Completed $ProgressPreference = $savedProgressPref $psError = $_ $psStatus = $null if ($psError.Exception.Response) { $psStatus = [int]$psError.Exception.Response.StatusCode } if (-not $psStatus) { # Prefer explicit 'status code NNN' / 'HTTP NNN' patterns over any 3-digit # number — corporate-proxy errors embed port numbers (e.g. ':31060') that # otherwise misclassify as HTTP 310. $msg = $psError.Exception.Message if ($msg -match "status code\s*['""]?(\d{3})") { $psStatus = [int]$Matches[1] } elseif ($msg -match '\bHTTP\s+(\d{3})\b') { $psStatus = [int]$Matches[1] } elseif ($msg -match '\((\d{3})\)') { $psStatus = [int]$Matches[1] } } if ($psStatus -eq 407) { Write-Warning "Tier 1 (Price Sheet): proxy authentication required (HTTP 407) at '$([System.Net.WebRequest]::DefaultWebProxy.GetProxy($armUrl))'." Write-Warning " The current user's default credentials were sent but rejected. Try one of:" Write-Warning " \$cred = Get-Credential; [System.Net.WebRequest]::DefaultWebProxy.Credentials = \$cred" Write-Warning " or set HTTPS_PROXY/HTTP_PROXY env vars with embedded credentials" Write-Warning " or run from a network without a proxy in front of management.azure.com." } Write-Host " Tier 1 (Price Sheet): failed$(if ($psStatus) { " (HTTP $psStatus)" }) — trying Tier 2..." -ForegroundColor DarkGray Write-Verbose "Tier 1 (Price Sheet) failed$(if ($psStatus) { " (HTTP $psStatus)" }): $($psError.Exception.Message). Falling through to Tier 2." # Persist negative cache so subsequent runs short-circuit straight to Tier 2. # 429 cools down for 30 min; other server-side errors get a shorter 5 min hold. $coolDown = if ($psStatus -eq 429) { 1800 } elseif ($psStatus -ge 500) { 300 } else { 300 } try { $negPayload = @{ At = (Get-Date).ToString('o'); Status = $psStatus; CoolDownSeconds = $coolDown; Message = ($psError.Exception.Message -replace '\s+', ' ').Substring(0, [math]::Min(200, $psError.Exception.Message.Length)) } $negJson = ConvertTo-Json -InputObject $negPayload -Compress [System.IO.File]::WriteAllText($negCacheFile, $negJson, [System.Text.Encoding]::UTF8) Write-Host " Tier 1 cool-down: $([math]::Ceiling($coolDown / 60)) min (subsequent runs will skip Tier 1)." -ForegroundColor DarkGray } catch { Write-Verbose "Could not write Tier 1 negative-cache file: $($_.Exception.Message)" } } # ── Tier 2: Cost Management Query API ── # Derives effective rate from actual usage. Covers deployed SKUs only. # Works for all billing types (EA, MCA, CSP, PAYG). # Tier 2 is region-specific (filters by ResourceLocation) since Cost Management # doesn't support unfiltered queries efficiently. if (-not $tier1Success) { try { # Group by ResourceLocation so we can verify each row belongs to $armLocation # before adopting its rate. Cost Management does not honour ResourceLocation # in $filter for ActualCost queries, so we filter client-side after grouping. $queryBody = @{ type = 'ActualCost' timeframe = 'MonthToDate' dataset = @{ granularity = 'None' aggregation = @{ PreTaxCost = @{ name = 'PreTaxCost'; function = 'Sum' } UsageQuantity = @{ name = 'UsageQuantity'; function = 'Sum' } } filter = @{ dimensions = @{ name = 'MeterCategory'; operator = 'In'; values = @('Virtual Machines') } } grouping = @( @{ type = 'Dimension'; name = 'ResourceLocation' } @{ type = 'Dimension'; name = 'MeterSubcategory' } @{ type = 'Dimension'; name = 'Meter' } ) } } | ConvertTo-Json -Depth 10 $queryUrl = "$armUrl/subscriptions/$SubscriptionId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" $cmResponse = Invoke-WithRetry -MaxRetries $MaxRetries -OperationName 'Cost Management Query' -ScriptBlock { Invoke-RestMethod -Uri $queryUrl -Method Post -Headers $headers -Body $queryBody -ContentType 'application/json' -TimeoutSec 60 } $colMap = @{} for ($i = 0; $i -lt $cmResponse.properties.columns.Count; $i++) { $colMap[$cmResponse.properties.columns[$i].name] = $i } $costIdx = $colMap['PreTaxCost'] $qtyIdx = $colMap['UsageQuantity'] $locIdx = if ($colMap.ContainsKey('ResourceLocation')) { $colMap['ResourceLocation'] } else { $null } $subCatIdx = $colMap['MeterSubcategory'] $meterIdx = $colMap['Meter'] $currIdx = if ($colMap.ContainsKey('Currency')) { $colMap['Currency'] } else { $null } $rowCount = if ($cmResponse.properties.rows) { $cmResponse.properties.rows.Count } else { 0 } $tier2Skipped = [ordered]@{ LocationMismatch = 0 SpotOrLowPriority = 0 WindowsSubcategory = 0 ZeroOrNegative = 0 UnparsableMeter = 0 } foreach ($row in $cmResponse.properties.rows) { $cost = [double]$row[$costIdx] $quantity = [double]$row[$qtyIdx] $subCategory = $row[$subCatIdx] $meterName = $row[$meterIdx] $currency = if ($null -ne $currIdx) { $row[$currIdx] } else { 'USD' } # Region scoping: skip rows whose ResourceLocation does not normalize to # the queried region. Without this guard, multi-region usage in the same # subscription would have other regions' rates written under $armLocation. if ($null -ne $locIdx) { $rowLoc = [string]$row[$locIdx] $rowLocNorm = ($rowLoc.ToLower() -replace '[\s-]', '') if (-not $rowLocNorm -or $rowLocNorm -ne $armLocation) { $tier2Skipped.LocationMismatch++ continue } } if ($subCategory -match 'Windows') { $tier2Skipped.WindowsSubcategory++; continue } # Spot / Low Priority meters bill at ~1/8 of PAYG. Keeping them in the # negotiated PAYG map (which the v2.2.0 release surface used to do via a # regex strip) corrupts the cached rate. Drop them here; spot pricing is # served separately via the retail Spot map. if ($meterName -match '\b(Spot|Low Priority)\b' -or $subCategory -match '\b(Spot|Low Priority)\b') { $tier2Skipped.SpotOrLowPriority++ continue } if ($quantity -le 0 -or $cost -le 0) { $tier2Skipped.ZeroOrNegative++; continue } $hourlyRate = $cost / $quantity $cleanName = $meterName.Trim() -replace '^Standard[\s_]+', '' if ($cleanName -match '^[A-Z]') { $vmSize = "Standard_$($cleanName -replace '\s+', '_')" } else { $tier2Skipped.UnparsableMeter++; continue } if (-not $allRegionPrices.ContainsKey($armLocation)) { $allRegionPrices[$armLocation] = @{} } if (-not $allRegionPrices[$armLocation].ContainsKey($vmSize)) { $allRegionPrices[$armLocation][$vmSize] = @{ Hourly = [math]::Round($hourlyRate, 4) Monthly = [math]::Round($hourlyRate * $HoursPerMonth, 2) Currency = $currency Meter = $meterName IsNegotiated = $true } } } $tier2Count = if ($allRegionPrices[$armLocation]) { $allRegionPrices[$armLocation].Count } else { 0 } Write-Host " Tier 2 (Cost Query): $tier2Count SKU prices from $rowCount usage rows for '$Region'" -ForegroundColor DarkGray $skipParts = @() foreach ($k in $tier2Skipped.Keys) { if ($tier2Skipped[$k] -gt 0) { $skipParts += "$k=$($tier2Skipped[$k])" } } if ($skipParts.Count -gt 0) { Write-Verbose "Tier 2 skip reasons: $($skipParts -join ', ')" } Write-Verbose "Tier 2 (Cost Query): $rowCount usage rows, $tier2Count VM SKU prices for region '$armLocation'." } catch { $errorMsg = $_.Exception.Message $statusCode = $null if ($_.Exception.Response) { $statusCode = [int]$_.Exception.Response.StatusCode } if (-not $statusCode) { # See Tier 1 catch — avoid misclassifying proxy port numbers as the HTTP status. if ($errorMsg -match "status code\s*['""]?(\d{3})") { $statusCode = [int]$Matches[1] } elseif ($errorMsg -match '\bHTTP\s+(\d{3})\b') { $statusCode = [int]$Matches[1] } elseif ($errorMsg -match '\((\d{3})\)') { $statusCode = [int]$Matches[1] } } if (-not $Caches.NegotiatedPricingWarned) { $Caches.NegotiatedPricingWarned = $true switch ($statusCode) { 401 { Write-Warning "Cost Management: authentication failed (HTTP 401). Run: Connect-AzAccount" } 403 { Write-Warning "Cost Management: access denied (HTTP 403). Required RBAC (any one):" Write-Warning " - Cost Management Reader (scope: subscription)" Write-Warning " - Reader (scope: subscription)" Write-Warning " To assign: New-AzRoleAssignment -SignInName <user@domain> -RoleDefinitionName 'Cost Management Reader' -Scope /subscriptions/$SubscriptionId" } 407 { $proxyUri = try { [System.Net.WebRequest]::DefaultWebProxy.GetProxy($armUrl) } catch { '<unknown>' } Write-Warning "Cost Management: proxy authentication required (HTTP 407) at '$proxyUri'." Write-Warning " Default Windows credentials were sent but rejected. Options:" Write-Warning " \$cred = Get-Credential; [System.Net.WebRequest]::DefaultWebProxy.Credentials = \$cred" Write-Warning " or set HTTPS_PROXY/HTTP_PROXY env vars with embedded credentials" Write-Warning " or run from a network without a proxy in front of management.azure.com." } {$_ -in 429, 503} { Write-Warning "Cost Management: throttled/unavailable (HTTP $statusCode). Retries exhausted." } default { Write-Warning "Cost Management failed$(if ($statusCode) { " (HTTP $statusCode)" }): $errorMsg" } } Write-Warning "Falling back to retail pricing (public list prices without negotiated discounts)." } $headers['Authorization'] = $null $token = $null return $null } } $headers['Authorization'] = $null $token = $null $lookupKey = & $resolvePriceSheetKey $armLocation $allRegionPrices $regionPrices = if ($lookupKey) { $allRegionPrices[$lookupKey] } else { @{} } return $regionPrices } |