Private/Azure/Get-AzVMPricing.ps1

function Get-AzVMPricing {
    <#
    .SYNOPSIS
        Fetches VM pricing from Azure Retail Prices API.
    .DESCRIPTION
        Retrieves Linux VM pricing from the public Azure Retail Prices API (no auth required).
        Returns PAYG, Spot, Savings Plan (1yr/3yr), and Reserved Instance (1yr/3yr) pricing maps.
        Implements caching to minimize API calls.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$Region,

        [int]$MaxRetries = 3,

        [int]$HoursPerMonth = 730,

        [hashtable]$AzureEndpoints,

        [string]$TargetEnvironment = 'AzureCloud',

        [System.Collections.IDictionary]$Caches = @{}
    )

    # Derive term-hour constants from HoursPerMonth for savings plan total calculations
    $HoursPerYear    = $HoursPerMonth * 12
    $HoursPer3Years  = $HoursPerMonth * 36

    if (-not $Caches.Pricing) {
        $Caches.Pricing = @{}
    }

    $armLocation = $Region.ToLower() -replace '\s', ''

    # Return cached pricing if already fetched this region
    if ($Caches.Pricing.ContainsKey($armLocation) -and $Caches.Pricing[$armLocation]) {
        return $Caches.Pricing[$armLocation]
    }

    # Get environment-specific endpoints (supports sovereign clouds)
    if (-not $AzureEndpoints) {
        $AzureEndpoints = Get-AzureEndpoints -EnvironmentName $TargetEnvironment
    }

    # Build filter for the API - get all VM pricing (consumption + reservation)
    $filter = "armRegionName eq '$armLocation' and serviceName eq 'Virtual Machines'"

    $regularPrices = @{}
    $spotPrices = @{}

    $savingsPlan1YrPrices = @{}
    $savingsPlan3YrPrices = @{}
    $reservation1YrPrices = @{}
    $reservation3YrPrices = @{}

    $apiUrl = "$($AzureEndpoints.PricingApiUrl)?api-version=2023-01-01-preview&`$filter=$([uri]::EscapeDataString($filter))"

    try {
        $nextLink = $apiUrl
        $pageCount = 0
        $maxPages = 20  # Fetch up to 20 pages (~20,000 price entries)

        while ($nextLink -and $pageCount -lt $maxPages) {
            $uri = $nextLink
            $response = Invoke-WithRetry -MaxRetries $MaxRetries -OperationName "Retail Pricing API (page $($pageCount + 1))" -ScriptBlock {
                Invoke-RestMethod -Uri $uri -Method Get -TimeoutSec 30
            }
            $pageCount++

            foreach ($item in $response.Items) {
                # Filter for Linux pricing, skip Windows, Low Priority, and DevTest
                if ($item.productName -match 'Windows' -or
                    $item.skuName -match 'Low Priority' -or
                    $item.meterName -match 'Low Priority' -or
                    $item.type -eq 'DevTestConsumption') {
                    continue
                }

                # Extract the VM size from armSkuName
                $vmSize = $item.armSkuName
                if (-not $vmSize) { continue }

                if ($item.type -eq 'Reservation') {
                    if ($item.reservationTerm -eq '1 Year' -and -not $reservation1YrPrices[$vmSize]) {
                        $reservation1YrPrices[$vmSize] = @{
                            Total    = [math]::Round($item.retailPrice, 2)
                            Monthly  = [math]::Round($item.retailPrice / 12, 2)
                            Currency = $item.currencyCode
                        }
                    }
                    elseif ($item.reservationTerm -eq '3 Years' -and -not $reservation3YrPrices[$vmSize]) {
                        $reservation3YrPrices[$vmSize] = @{
                            Total    = [math]::Round($item.retailPrice, 2)
                            Monthly  = [math]::Round($item.retailPrice / 36, 2)
                            Currency = $item.currencyCode
                        }
                    }
                    continue
                }

                $isSpot = ($item.skuName -match 'Spot' -or $item.meterName -match 'Spot')
                $targetMap = if ($isSpot) { $spotPrices } else { $regularPrices }

                if (-not $targetMap[$vmSize]) {
                    $targetMap[$vmSize] = @{
                        Hourly   = [math]::Round($item.retailPrice, 4)
                        Monthly  = [math]::Round($item.retailPrice * $HoursPerMonth, 2)
                        Currency = $item.currencyCode
                        Meter    = $item.meterName
                    }
                }

                # Capture savings plan pricing from consumption items
                if (-not $isSpot -and $item.savingsPlan) {
                    foreach ($sp in $item.savingsPlan) {
                        if ($sp.term -eq '1 Year' -and -not $savingsPlan1YrPrices[$vmSize]) {
                            $savingsPlan1YrPrices[$vmSize] = @{
                                Hourly   = [math]::Round($sp.retailPrice, 4)
                                Monthly  = [math]::Round($sp.retailPrice * $HoursPerMonth, 2)
                                Total    = [math]::Round($sp.retailPrice * $HoursPerYear, 2)
                                Currency = $item.currencyCode
                            }
                        }
                        elseif ($sp.term -eq '3 Years' -and -not $savingsPlan3YrPrices[$vmSize]) {
                            $savingsPlan3YrPrices[$vmSize] = @{
                                Hourly   = [math]::Round($sp.retailPrice, 4)
                                Monthly  = [math]::Round($sp.retailPrice * $HoursPerMonth, 2)
                                Total    = [math]::Round($sp.retailPrice * $HoursPer3Years, 2)
                                Currency = $item.currencyCode
                            }
                        }
                    }
                }
            }

            $nextLink = $response.NextPageLink
        }

        $result = [ordered]@{
            Regular          = $regularPrices
            Spot             = $spotPrices
            SavingsPlan1Yr   = $savingsPlan1YrPrices
            SavingsPlan3Yr   = $savingsPlan3YrPrices
            Reservation1Yr   = $reservation1YrPrices
            Reservation3Yr   = $reservation3YrPrices
        }

        $Caches.Pricing[$armLocation] = $result

        return $result
    }
    catch {
        Write-Verbose "Failed to fetch pricing for region $Region`: $_"
        return [ordered]@{
            Regular          = @{}
            Spot             = @{}
            SavingsPlan1Yr   = @{}
            SavingsPlan3Yr   = @{}
            Reservation1Yr   = @{}
            Reservation3Yr   = @{}
        }
    }
}