content/src/scripts/Get-AzurePricing.ps1
|
# --------------------------------------------------------------------------- # Get-AzurePricing.ps1 # Axeon Pricing Engine — Dynamic Azure Retail Prices API Integration # # Fetches live pricing from the public (no auth required) Azure Retail # Prices REST API and caches results locally. Dynamically resolves ARM # resource types to pricing API queries — no curated mapping needed. # # 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 -DeploymentFile ./deployments/hub-networking.yaml # Update-AxeonPricing -Region uksouth # --------------------------------------------------------------------------- $ErrorActionPreference = "Stop" # ============================================================================ # INTERNAL — ARM Resource Type Discovery # ============================================================================ <# .SYNOPSIS Compiles a Bicep file to ARM JSON and extracts resource types with SKU info. .DESCRIPTION Requires the Azure CLI with Bicep extension installed. Returns an array of objects with Type, ApiVersion, Sku, Tier, Kind properties. #> function Get-BicepResourceTypes { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$TemplatePath ) if (-not (Test-Path $TemplatePath)) { Write-Warning "Template not found: $TemplatePath" return @() } $azCmd = Get-Command 'az' -ErrorAction SilentlyContinue if (-not $azCmd) { Write-Warning "Azure CLI ('az') not found. 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 = [System.Collections.Generic.List[PSCustomObject]]::new() # Recursively extract resources (handles nested + module deployments) function Extract-Resources { param([array]$ResourceArray) foreach ($resource in $ResourceArray) { $info = @{ Type = $resource.type ApiVersion = $resource.apiVersion Sku = $null Tier = $null Kind = $null NameParam = $null } # Extract the parameter name from the resource name expression # ARM JSON uses expressions like "[parameters('ip1')]" or # "[format('{0}-pip', parameters('ip1'))]" etc. if ($resource.ContainsKey('name') -and $resource.name) { $nameExpr = "$($resource.name)" if ($nameExpr -match "parameters\('([^']+)'\)") { $info.NameParam = $Matches[1] } } 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)" } } if ($resource.ContainsKey('kind')) { $info.Kind = $resource.kind } $resources.Add([PSCustomObject]$info) if ($resource.ContainsKey('resources') -and $resource.resources) { Extract-Resources -ResourceArray $resource.resources } # Handle inline module deployments if ($resource.type -eq 'Microsoft.Resources/deployments' -and $resource.ContainsKey('properties') -and $resource.properties.ContainsKey('template') -and $resource.properties.template.ContainsKey('resources')) { Extract-Resources -ResourceArray $resource.properties.template.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 @() } } <# .SYNOPSIS Extracts all ARM resource types from a parsed ARM template object. .DESCRIPTION Recursively walks resources, nested resources, and inline module deployments. Returns a deduplicated list of resource type strings. #> function Extract-ResourceTypesFromArm { [CmdletBinding()] param( [Parameter(Mandatory)] $ArmTemplate ) $types = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) function Recurse-Resources { param($Resources) if (-not $Resources) { return } foreach ($resource in $Resources) { if ($resource.type) { [void]$types.Add($resource.type) } if ($resource.resources) { Recurse-Resources -Resources $resource.resources } if ($resource.properties -and $resource.properties.template -and $resource.properties.template.resources) { Recurse-Resources -Resources $resource.properties.template.resources } } } if ($ArmTemplate.resources) { Recurse-Resources -Resources $ArmTemplate.resources } return @($types) } <# .SYNOPSIS Discovers ARM resource types from a deployment YAML file by compiling its Bicep modules. .DESCRIPTION Parses the deployment YAML, resolves each module's template path, compiles Bicep to ARM JSON, and returns all unique resource types found. #> function Get-ResourceTypesFromDeployment { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$DeploymentFile, [string]$BicepRoot ) if (-not (Test-Path $DeploymentFile)) { Write-Warning "Deployment file not found: $DeploymentFile" return @() } $yamlModule = Get-Module -ListAvailable -Name 'powershell-yaml' -ErrorAction SilentlyContinue if (-not $yamlModule) { Write-Warning "Module 'powershell-yaml' not found. Install: Install-Module powershell-yaml" return @() } Import-Module powershell-yaml -ErrorAction SilentlyContinue $raw = Get-Content $DeploymentFile -Raw $deploy = $raw | ConvertFrom-Yaml if (-not $deploy.modules -or $deploy.modules.Count -eq 0) { Write-Warning "Deployment has no modules defined." return @() } if (-not $BicepRoot) { $BicepRoot = Join-Path $PSScriptRoot ".." "bicep" } $allTypes = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($mod in $deploy.modules) { $templatePath = Join-Path $BicepRoot $mod.template if (-not (Test-Path $templatePath)) { Write-Warning " Template not found: $templatePath" continue } $resources = Get-BicepResourceTypes -TemplatePath $templatePath foreach ($r in $resources) { [void]$allTypes.Add($r.Type) } } return @($allTypes) } # ============================================================================ # INTERNAL — Dynamic ARM Type to Pricing API Query # ============================================================================ <# .SYNOPSIS Derives an ordered list of pricing API search terms from an ARM resource type. .DESCRIPTION Returns terms from most specific to least specific. The caller should try each in order and use the first that returns results. Well-known mappings handle namespaces like Microsoft.Network where many unrelated services share the same namespace. For unknown resource types, the function falls back to splitting the resource name and namespace via camelCase boundaries. Examples: Microsoft.Network/virtualNetworks -> ["Virtual Network"] Microsoft.Network/publicIPAddresses -> ["IP Addresses"] Microsoft.KeyVault/vaults -> ["Key Vault"] Microsoft.Storage/storageAccounts -> ["Storage Accounts", "Storage"] Microsoft.Compute/virtualMachines -> ["Virtual Machines", "Compute"] Microsoft.ContainerRegistry/registries -> ["Container Registry"] Microsoft.OperationalInsights/workspaces -> ["Log Analytics"] #> function ConvertTo-PricingSearchTerm { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ArmResourceType ) $parts = $ArmResourceType -split '/' if ($parts.Count -lt 2) { return @() } # Well-known overrides for resource types whose Azure service names # don't match the namespace/resource naming pattern $knownMappings = @{ 'Microsoft.Network/virtualNetworks' = @('Virtual Network') 'Microsoft.Network/publicIPAddresses' = @('IP Addresses') 'Microsoft.Network/publicIPPrefixes' = @('IP Addresses') 'Microsoft.Network/networkSecurityGroups' = @('Network Watcher') 'Microsoft.Network/loadBalancers' = @('Load Balancer') 'Microsoft.Network/applicationGateways' = @('Application Gateway') 'Microsoft.Network/azureFirewalls' = @('Azure Firewall') 'Microsoft.Network/bastionHosts' = @('Azure Bastion') 'Microsoft.Network/virtualNetworkGateways' = @('VPN Gateway', 'Virtual Network Gateway') 'Microsoft.Network/privateDnsZones' = @('Azure DNS') 'Microsoft.Network/privateEndpoints' = @('Private Link') 'Microsoft.Network/networkInterfaces' = @('Virtual Network') 'Microsoft.Network/frontDoors' = @('Azure Front Door') 'Microsoft.Network/natGateways' = @('NAT Gateway') 'Microsoft.Network/routeTables' = @('Virtual Network') 'Microsoft.Network/expressRouteCircuits' = @('ExpressRoute') 'Microsoft.Network/trafficManagerProfiles' = @('Traffic Manager') 'Microsoft.Compute/virtualMachines' = @('Virtual Machines') 'Microsoft.Compute/virtualMachineScaleSets' = @('Virtual Machines') 'Microsoft.Compute/disks' = @('Managed Disks') 'Microsoft.Compute/snapshots' = @('Managed Disks') 'Microsoft.Storage/storageAccounts' = @('Storage Accounts', 'Storage') 'Microsoft.KeyVault/vaults' = @('Key Vault') 'Microsoft.ContainerRegistry/registries' = @('Container Registry') 'Microsoft.ContainerService/managedClusters' = @('Azure Kubernetes Service') 'Microsoft.Sql/servers' = @('SQL Database') 'Microsoft.Sql/servers/databases' = @('SQL Database') 'Microsoft.Web/sites' = @('Azure App Service') 'Microsoft.Web/serverfarms' = @('Azure App Service') 'Microsoft.Web/staticSites' = @('Azure Static Web Apps') 'Microsoft.ManagedIdentity/userAssignedIdentities' = @('Managed Identity') 'Microsoft.OperationalInsights/workspaces' = @('Log Analytics') 'Microsoft.Insights/components' = @('Application Insights') 'Microsoft.Cdn/profiles' = @('Content Delivery Network') 'Microsoft.Cache/redis' = @('Azure Cache for Redis') 'Microsoft.DocumentDB/databaseAccounts' = @('Azure Cosmos DB') 'Microsoft.ServiceBus/namespaces' = @('Service Bus') 'Microsoft.EventHub/namespaces' = @('Event Hubs') 'Microsoft.CognitiveServices/accounts' = @('Cognitive Services') 'Microsoft.SignalRService/signalR' = @('Azure SignalR Service') 'Microsoft.DBforPostgreSQL/flexibleServers' = @('Azure Database for PostgreSQL') 'Microsoft.DBforMySQL/flexibleServers' = @('Azure Database for MySQL') 'Microsoft.ApiManagement/service' = @('API Management') } # Check known mappings (exact match on full type) if ($knownMappings.ContainsKey($ArmResourceType)) { return @($knownMappings[$ArmResourceType]) } # Check known mappings for parent type (e.g., Microsoft.Sql/servers/databases) $parentType = ($parts[0..1]) -join '/' if ($parts.Count -gt 2 -and $knownMappings.ContainsKey($parentType)) { return @($knownMappings[$parentType]) } # Fallback: derive from resource name (specific) then namespace (broad) $namespacePart = $parts[0] -replace '^Microsoft\.', '' $resourcePart = $parts[1] $namespaceWords = ($namespacePart -creplace '([a-z])([A-Z])', '$1 $2') -creplace '([A-Z]+)([A-Z][a-z])', '$1 $2' $resourceWords = ($resourcePart -creplace '([a-z])([A-Z])', '$1 $2') -creplace '([A-Z]+)([A-Z][a-z])', '$1 $2' $resourceWords = (Get-Culture).TextInfo.ToTitleCase($resourceWords) $terms = [System.Collections.Generic.List[string]]::new() if ($resourceWords -ne $namespaceWords) { $terms.Add($resourceWords) } $terms.Add($namespaceWords) return @($terms) } <# .SYNOPSIS Queries the Azure Retail Prices API using a contains() filter on serviceName. .DESCRIPTION Uses the free, no-auth-required endpoint. Handles pagination. Returns an array of meter objects. #> function Invoke-PricingApiQuery { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Region, [Parameter(Mandatory)] [string]$ServiceSearchTerm, [string]$CurrencyCode = 'USD', [int]$MaxPages = 5 ) $allMeters = [System.Collections.Generic.List[object]]::new() $filter = "armRegionName eq '$Region' and contains(serviceName, '$ServiceSearchTerm')" $encodedFilter = [System.Uri]::EscapeDataString($filter) $url = "https://prices.azure.com/api/retail/prices?currencyCode=${CurrencyCode}&`$filter=${encodedFilter}" $pageCount = 0 try { while ($url -and $pageCount -lt $MaxPages) { $pageCount++ Write-Verbose " Fetching page $pageCount for '$ServiceSearchTerm'..." $response = Invoke-RestMethod -Uri $url -Method Get -TimeoutSec 30 if ($response.Items) { foreach ($item in $response.Items) { $allMeters.Add($item) } } $url = $response.NextPageLink } } catch { Write-Warning "Failed to query pricing for '$ServiceSearchTerm': $($_.Exception.Message)" } return $allMeters } # ============================================================================ # PUBLIC FUNCTION # ============================================================================ <# .SYNOPSIS Fetches live Azure pricing data and caches it locally. .DESCRIPTION Dynamically discovers ARM resource types — either from a deployment file's Bicep templates or from the resource providers registered in your Azure subscription — then queries the Azure Retail Prices API for each. No curated mapping file is needed. The ARM resource type namespace is algorithmically converted to a pricing API search term. Results are written to a local JSON cache file used by Get-AxeonCostEstimate and the -FreeOnly bootstrap flag. .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 Path to a deployment YAML file. Its Bicep templates are compiled and resource types are discovered automatically. This is the recommended approach — only fetches pricing for resources you actually deploy. .PARAMETER BicepRoot Root path for resolving Bicep template paths from deployment YAML. Defaults to src/bicep relative to the script directory. .EXAMPLE Update-AxeonPricing -PlatformSpecPath ./platform-spec.json -DeploymentFile ./deployments/hub-networking.yaml .EXAMPLE Update-AxeonPricing -Region uksouth -DeploymentFile ./deployments/hub-networking.yaml .EXAMPLE Update-AxeonPricing -PlatformSpecPath ./platform-spec.json #> function Update-AxeonPricing { [CmdletBinding()] param( [string]$PlatformSpecPath, [string]$Region, [string]$CurrencyCode = 'USD', [string]$OutputPath = './azure-pricing.json', [string]$DeploymentFile, [string]$BicepRoot ) Write-Host "" Write-Host " Axeon Pricing Update" -ForegroundColor Cyan Write-Host " ─────────────────────────────────────────" -ForegroundColor DarkCyan Write-Host "" # Resolve region from platform spec if not provided 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 "" # ── Step 1: Discover resource types ────────────────────────────────── $resourceTypes = @() if ($DeploymentFile) { Write-Host " Discovering resource types from deployment..." -ForegroundColor Gray if (-not $BicepRoot) { $BicepRoot = Join-Path $PSScriptRoot ".." "bicep" } $resourceTypes = Get-ResourceTypesFromDeployment ` -DeploymentFile $DeploymentFile ` -BicepRoot $BicepRoot if ($resourceTypes.Count -gt 0) { Write-Host " Found $($resourceTypes.Count) resource type(s):" -ForegroundColor Green foreach ($rt in $resourceTypes) { Write-Host " - $rt" -ForegroundColor DarkGray } } else { Write-Host " No resource types discovered from deployment." -ForegroundColor DarkYellow } } # If no deployment file or no resource types found, try subscription providers if ($resourceTypes.Count -eq 0 -and -not $DeploymentFile) { Write-Host " No deployment file specified." -ForegroundColor DarkYellow Write-Host " Trying to discover registered resource providers from Azure subscription..." -ForegroundColor Gray try { $providersJson = az provider list --query "[?registrationState=='Registered'].namespace" -o json 2>$null if ($LASTEXITCODE -eq 0 -and $providersJson) { $namespaces = $providersJson | ConvertFrom-Json # Use namespaces directly — we query by derived service name $resourceTypes = @($namespaces | ForEach-Object { "$_/all" }) Write-Host " Found $($namespaces.Count) registered resource providers." -ForegroundColor Green } } catch { Write-Verbose " Could not query Azure providers: $($_.Exception.Message)" } } # Final fallback: common resource types if ($resourceTypes.Count -eq 0) { Write-Host " Using common resource types as fallback." -ForegroundColor DarkYellow $resourceTypes = @( 'Microsoft.Compute/virtualMachines' 'Microsoft.Network/virtualNetworks' 'Microsoft.Network/publicIPAddresses' 'Microsoft.Network/loadBalancers' 'Microsoft.Network/applicationGateways' 'Microsoft.Network/bastionHosts' 'Microsoft.Network/azureFirewalls' 'Microsoft.Network/natGateways' 'Microsoft.Network/privateEndpoints' 'Microsoft.Network/privateDnsZones' 'Microsoft.Storage/storageAccounts' 'Microsoft.KeyVault/vaults' 'Microsoft.ContainerRegistry/registries' 'Microsoft.ContainerService/managedClusters' 'Microsoft.Sql/servers' 'Microsoft.Web/sites' 'Microsoft.Web/serverfarms' 'Microsoft.OperationalInsights/workspaces' 'Microsoft.Insights/components' 'Microsoft.ManagedIdentity/userAssignedIdentities' 'Microsoft.Cdn/profiles' ) } Write-Host "" # ── Step 2: Derive search terms per resource type ──────────────────── $resourceSearchTerms = @{} # armType -> ordered list of search terms foreach ($rt in $resourceTypes) { $terms = ConvertTo-PricingSearchTerm -ArmResourceType $rt if ($terms.Count -gt 0) { $resourceSearchTerms[$rt] = $terms } } Write-Host " Fetching pricing for $($resourceSearchTerms.Count) resource type(s)..." -ForegroundColor Gray Write-Host "" # ── Step 3: Query pricing API per resource type ─────────────────────── # Uses a query cache so identical search terms are only fetched once. $queryCache = @{} # searchTerm -> meters (avoids duplicate API calls) $resourcePricing = @{} $totalResources = $resourceSearchTerms.Count $currentResource = 0 foreach ($entry in $resourceSearchTerms.GetEnumerator()) { $currentResource++ $armType = $entry.Key $searchTerms = $entry.Value Write-Host " [$currentResource/$totalResources] $armType" -ForegroundColor Gray $meters = @() $usedTerm = $searchTerms[0] # Try each search term in order (most specific first) foreach ($term in $searchTerms) { if ($queryCache.ContainsKey($term)) { $meters = $queryCache[$term] Write-Host " -> cached: '$term' ($(@($meters).Count) meter(s))" -ForegroundColor DarkGray } else { Write-Host " -> querying: '$term'" -ForegroundColor DarkGray $meters = @(Invoke-PricingApiQuery ` -Region $Region ` -ServiceSearchTerm $term ` -CurrencyCode $CurrencyCode) $queryCache[$term] = $meters Write-Host " -> $(@($meters).Count) meter(s)" -ForegroundColor DarkGray } $usedTerm = $term if (@($meters).Count -gt 0) { break # got results with this term, no need to try broader ones } } # Filter: only Consumption type (not Reservation, DevTestConsumption etc.) $consumptionMeters = @($meters | Where-Object { $_.type -eq 'Consumption' }) # Determine if free: no consumption meters OR all have $0 price $paidConsumption = @($consumptionMeters | Where-Object { $_.retailPrice -gt 0 }) $isFree = ($consumptionMeters.Count -eq 0) -or ($paidConsumption.Count -eq 0) # Store a compact subset of meters in the cache $cachedMeters = @($consumptionMeters | Sort-Object retailPrice | Select-Object -First 30 | ForEach-Object { $unit = $_.unitOfMeasure ?? '' $price = $_.retailPrice # Calculate daily, monthly, and annual prices based on unit of measure $daily = switch -Wildcard ($unit) { '*Hour*' { [math]::Round($price * 24, 4) } # 24 hours/day '*Day*' { [math]::Round($price, 4) } # already daily '*Month*' { [math]::Round($price / 30, 4) } # monthly to daily '*Year*' { [math]::Round($price / 365, 4) } # yearly to daily default { [math]::Round($price, 4) } # usage-dependent — per-unit } $monthly = switch -Wildcard ($unit) { '*Hour*' { [math]::Round($price * 730, 4) } # ~730 hours/month '*Day*' { [math]::Round($price * 30, 4) } # ~30 days/month '*Month*' { [math]::Round($price, 4) } # already monthly '*Year*' { [math]::Round($price / 12, 4) } # yearly to monthly default { [math]::Round($price, 4) } # usage-dependent — per-unit } $annual = switch -Wildcard ($unit) { '*Hour*' { [math]::Round($price * 8760, 4) } # 8760 hours/year '*Day*' { [math]::Round($price * 365, 4) } # 365 days/year '*Month*' { [math]::Round($price * 12, 4) } # 12 months/year '*Year*' { [math]::Round($price, 4) } # already annual default { [math]::Round($price, 4) } # usage-dependent — per-unit } @{ meterName = $_.meterName skuName = $_.skuName armSkuName = $_.armSkuName retailPrice = $price dailyPrice = $daily monthlyPrice = $monthly annualPrice = $annual unitOfMeasure = $unit productName = $_.productName } }) $resourcePricing[$armType] = @{ searchTerm = $usedTerm isFree = $isFree meterCount = $consumptionMeters.Count consumptionMeters = $cachedMeters } } # ── Step 4: Write cache ────────────────────────────────────────────── $cache = @{ lastUpdated = (Get-Date -Format 'o') region = $Region currency = $CurrencyCode resources = $resourcePricing } $cache | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputPath -Encoding UTF8 # Summary $freeCount = @($resourcePricing.GetEnumerator() | Where-Object { $_.Value.isFree }).Count $paidCount = @($resourcePricing.GetEnumerator() | Where-Object { -not $_.Value.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 } |