content/src/scripts/Get-AzurePricing.ps1
|
# --------------------------------------------------------------------------- # Get-AzurePricing.ps1 # Axeon Pricing Engine — Fetches live pricing from Azure Retail Prices API # # Uses the public (no auth required) Azure Retail Prices REST API to fetch # current pricing data and caches it locally in a JSON file. # # API docs: https://learn.microsoft.com/en-us/rest/api/cost-management/retail-prices/azure-retail-prices # # Usage: # . ./Get-AzurePricing.ps1 # Update-AxeonPricing -PlatformSpecPath ./platform-spec.json # Update-AxeonPricing -Region uksouth -OutputPath ./azure-pricing.json # Update-AxeonPricing -PlatformSpecPath ./platform-spec.json -DeploymentFile ./deployments/hub-networking.yaml # --------------------------------------------------------------------------- $ErrorActionPreference = "Stop" # ============================================================================ # INTERNAL HELPERS # ============================================================================ <# .SYNOPSIS Loads the ARM-to-pricing mapping file shipped with Axeon. #> function Get-ArmPricingMap { [CmdletBinding()] param( [string]$MapPath ) if (-not $MapPath) { # Try to find next to this script (scaffolded layout) $MapPath = Join-Path $PSScriptRoot ".." "schemas" "arm-pricing-map.json" } if (-not (Test-Path $MapPath)) { throw "ARM pricing map not found at '$MapPath'. Ensure Axeon is properly scaffolded." } $raw = Get-Content $MapPath -Raw | ConvertFrom-Json -AsHashtable return $raw.resources } <# .SYNOPSIS Fetches pricing data from Azure Retail Prices API for a given service name and region. .DESCRIPTION Handles OData pagination automatically. Returns an array of price items. #> function Get-RetailPrices { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ServiceName, [Parameter(Mandatory)] [string]$Region, [string]$CurrencyCode = 'USD' ) $baseUrl = "https://prices.azure.com/api/retail/prices" $filter = "armRegionName eq '$Region' and serviceName eq '$ServiceName'" $url = "${baseUrl}?currencyCode=${CurrencyCode}&`$filter=${filter}" $allItems = @() $pageCount = 0 $maxPages = 50 # Safety limit while ($url -and $pageCount -lt $maxPages) { $pageCount++ Write-Verbose " Fetching page $pageCount for '$ServiceName'..." try { $response = Invoke-RestMethod -Uri $url -Method Get -TimeoutSec 30 } catch { Write-Warning "Failed to fetch pricing for '$ServiceName': $($_.Exception.Message)" return $allItems } if ($response.Items) { $allItems += $response.Items } $url = $response.NextPageLink } return $allItems } <# .SYNOPSIS Compiles a Bicep file to ARM JSON and extracts resource types and SKUs. .DESCRIPTION Requires the Azure CLI with Bicep extension installed. Returns an array of objects with Type and Sku properties. #> function Get-BicepResourceTypes { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$TemplatePath ) if (-not (Test-Path $TemplatePath)) { Write-Warning "Template not found: $TemplatePath" return @() } # Check for az CLI $azCmd = Get-Command 'az' -ErrorAction SilentlyContinue if (-not $azCmd) { Write-Warning "Azure CLI ('az') not found. Cannot compile Bicep templates for resource discovery. Install from https://aka.ms/installazurecli" return @() } try { $armJson = az bicep build --file $TemplatePath --stdout 2>$null if ($LASTEXITCODE -ne 0) { Write-Warning "Bicep compilation failed for '$TemplatePath'. Ensure 'az bicep' is installed." return @() } $arm = $armJson | ConvertFrom-Json -AsHashtable $resources = @() # Recursively extract resources (handles nested resources) function Extract-Resources { param([array]$ResourceArray) foreach ($resource in $ResourceArray) { $info = @{ Type = $resource.type ApiVersion = $resource.apiVersion Sku = $null Tier = $null Kind = $null } # Extract SKU if present if ($resource.ContainsKey('sku')) { if ($resource.sku -is [hashtable]) { $info.Sku = $resource.sku.name ?? $resource.sku.Name ?? ($resource.sku | ConvertTo-Json -Compress) $info.Tier = $resource.sku.tier ?? $resource.sku.Tier } else { $info.Sku = "$($resource.sku)" } } # Extract kind if present (e.g., StorageV2, FunctionApp) if ($resource.ContainsKey('kind')) { $info.Kind = $resource.kind } $resources += [PSCustomObject]$info # Handle nested child resources if ($resource.ContainsKey('resources') -and $resource.resources) { Extract-Resources -ResourceArray $resource.resources } } } if ($arm.ContainsKey('resources') -and $arm.resources) { Extract-Resources -ResourceArray $arm.resources } return $resources } catch { Write-Warning "Error processing Bicep template '$TemplatePath': $($_.Exception.Message)" return @() } } # ============================================================================ # PUBLIC FUNCTION # ============================================================================ <# .SYNOPSIS Fetches live Azure pricing data and caches it locally. .DESCRIPTION Queries the Azure Retail Prices API (free, no auth required) for all resource types known to Axeon's ARM pricing map. Optionally, when -DeploymentFile is specified, also compiles the deployment's Bicep templates and discovers additional resource types not in the curated map. Results are written to a local JSON cache file that other Axeon commands (like Get-AxeonCostEstimate) read for cost estimation. .PARAMETER PlatformSpecPath Path to platform-spec.json. Used to read the target region. .PARAMETER Region Azure region for pricing lookup (e.g., 'uksouth'). Overrides the region from platform-spec.json if both are provided. .PARAMETER CurrencyCode ISO currency code (default: USD). .PARAMETER OutputPath Path where the pricing cache JSON is written (default: ./azure-pricing.json). .PARAMETER DeploymentFile Optional. Path to a deployment YAML file. When specified, its Bicep templates are compiled and any discovered resource types not in the curated map are also fetched from the pricing API. .PARAMETER BicepRoot Root path for resolving Bicep template paths from deployment YAML. Defaults to src/bicep relative to the script directory. .PARAMETER MapPath Path to the ARM pricing map JSON. Defaults to the one shipped with Axeon. .EXAMPLE Update-AxeonPricing -PlatformSpecPath ./platform-spec.json .EXAMPLE Update-AxeonPricing -Region uksouth -OutputPath ./pricing-cache.json .EXAMPLE Update-AxeonPricing -PlatformSpecPath ./platform-spec.json -DeploymentFile ./deployments/hub-networking.yaml #> function Update-AxeonPricing { [CmdletBinding()] param( [string]$PlatformSpecPath, [string]$Region, [string]$CurrencyCode = 'USD', [string]$OutputPath = './azure-pricing.json', [string]$DeploymentFile, [string]$BicepRoot, [string]$MapPath ) Write-Host "" Write-Host " Axeon Pricing Update" -ForegroundColor Cyan Write-Host " ─────────────────────────────────────────" -ForegroundColor DarkCyan Write-Host "" # Resolve region if (-not $Region -and $PlatformSpecPath) { if (Test-Path $PlatformSpecPath) { $spec = Get-Content $PlatformSpecPath -Raw | ConvertFrom-Json -AsHashtable $Region = $spec.location } } if (-not $Region) { throw "Region is required. Provide -Region or -PlatformSpecPath with a valid platform-spec.json." } Write-Host " Region : $Region" -ForegroundColor White Write-Host " Currency : $CurrencyCode" -ForegroundColor White Write-Host " Output : $OutputPath" -ForegroundColor White Write-Host "" # Load ARM pricing map $armMap = Get-ArmPricingMap -MapPath $MapPath # Collect all service names to query $serviceNames = @{} foreach ($armType in $armMap.Keys) { $entry = $armMap[$armType] $svcName = $entry.serviceName if ($svcName -and -not $serviceNames.ContainsKey($svcName)) { $serviceNames[$svcName] = $true } } # If deployment file provided, also discover resource types from Bicep $discoveredTypes = @{} if ($DeploymentFile) { Write-Host " Scanning deployment for additional resource types..." -ForegroundColor Gray if (-not (Test-Path $DeploymentFile)) { Write-Warning "Deployment file not found: $DeploymentFile" } else { if (-not $BicepRoot) { $BicepRoot = Join-Path $PSScriptRoot ".." "bicep" } # Need powershell-yaml for parsing $yamlModule = Get-Module -ListAvailable -Name 'powershell-yaml' -ErrorAction SilentlyContinue if (-not $yamlModule) { Write-Warning "Module 'powershell-yaml' not found. Cannot parse deployment YAML. Install: Install-Module powershell-yaml" } else { Import-Module powershell-yaml -ErrorAction SilentlyContinue $raw = Get-Content $DeploymentFile -Raw $deploy = $raw | ConvertFrom-Yaml if ($deploy.modules) { foreach ($mod in $deploy.modules) { $templatePath = Join-Path $BicepRoot $mod.template Write-Host " Compiling: $($mod.template)" -ForegroundColor DarkGray $resources = Get-BicepResourceTypes -TemplatePath $templatePath foreach ($r in $resources) { $discoveredTypes[$r.Type] = $r # If not in curated map, try to determine service name if (-not $armMap.ContainsKey($r.Type)) { # Derive service name from the resource provider namespace $provider = ($r.Type -split '/')[0] $guessedService = switch -Wildcard ($provider) { 'Microsoft.Network' { 'Virtual Network' } 'Microsoft.Storage' { 'Storage' } 'Microsoft.Compute' { 'Virtual Machines' } 'Microsoft.KeyVault' { 'Key Vault' } 'Microsoft.Web' { 'Azure App Service' } 'Microsoft.Sql' { 'SQL Database' } 'Microsoft.ContainerService' { 'Azure Kubernetes Service' } 'Microsoft.ContainerRegistry' { 'Container Registry' } default { $null } } if ($guessedService -and -not $serviceNames.ContainsKey($guessedService)) { $serviceNames[$guessedService] = $true Write-Host " Discovered: $($r.Type) → $guessedService" -ForegroundColor DarkYellow } } } } } } } } # Fetch pricing for each service $pricingData = @{} $totalServices = $serviceNames.Count $currentService = 0 foreach ($svcName in $serviceNames.Keys) { $currentService++ Write-Host " [$currentService/$totalServices] Fetching: $svcName" -ForegroundColor Gray $prices = Get-RetailPrices -ServiceName $svcName -Region $Region -CurrencyCode $CurrencyCode if ($prices.Count -gt 0) { Write-Host " → $($prices.Count) price items" -ForegroundColor DarkGray } else { Write-Host " → No pricing data for region '$Region'" -ForegroundColor DarkYellow } $pricingData[$svcName] = $prices } # Build the cache structure: map each ARM resource type to pricing info $resourcePricing = @{} foreach ($armType in $armMap.Keys) { $entry = $armMap[$armType] $svcName = $entry.serviceName $isFree = [bool]$entry.defaultFree $meters = @() if ($pricingData.ContainsKey($svcName)) { $meters = @($pricingData[$svcName] | ForEach-Object { @{ meterName = $_.meterName skuName = $_.skuName retailPrice = $_.retailPrice unitPrice = $_.unitPrice unitOfMeasure = $_.unitOfMeasure productName = $_.productName type = $_.type # Consumption vs Reservation isPrimaryMeterRegion = $_.isPrimaryMeterRegion } }) } # Determine if truly free: defaultFree or all consumption meters are $0 $consumptionMeters = @($meters | Where-Object { $_.type -eq 'Consumption' }) $allZero = ($consumptionMeters.Count -eq 0) -or ($consumptionMeters | Where-Object { $_.retailPrice -gt 0 }).Count -eq 0 $resourcePricing[$armType] = @{ serviceName = $svcName defaultFree = $isFree isFree = $isFree -or $allZero notes = $entry.notes meterCount = $meters.Count consumptionMeters = @($consumptionMeters | Select-Object -First 20) # Keep cache manageable } } # Add any discovered types not in the curated map foreach ($armType in $discoveredTypes.Keys) { if (-not $resourcePricing.ContainsKey($armType)) { $resourcePricing[$armType] = @{ serviceName = 'Unknown' defaultFree = $false isFree = $false notes = "Discovered from Bicep template. No curated pricing data — run Update-AxeonPricing to refresh." meterCount = 0 consumptionMeters = @() } } } # Write cache file $cache = @{ '$schema' = 'https://json-schema.org/draft-07/schema#' lastUpdated = (Get-Date -Format 'o') region = $Region currency = $CurrencyCode resources = $resourcePricing } $cacheJson = $cache | ConvertTo-Json -Depth 10 Set-Content -Path $OutputPath -Value $cacheJson -Encoding UTF8 # Summary $freeCount = ($resourcePricing.Values | Where-Object { $_.isFree }).Count $paidCount = ($resourcePricing.Values | Where-Object { -not $_.isFree }).Count Write-Host "" Write-Host " Pricing cache updated:" -ForegroundColor Green Write-Host " File : $OutputPath" -ForegroundColor White Write-Host " Region : $Region" -ForegroundColor White Write-Host " Resource types: $($resourcePricing.Count)" -ForegroundColor White Write-Host " Free : $freeCount" -ForegroundColor Green Write-Host " Paid : $paidCount" -ForegroundColor Yellow Write-Host " Last updated : $($cache.lastUpdated)" -ForegroundColor DarkGray Write-Host "" return $cache } |