content/src/scripts/Get-CostEstimate.ps1
|
# --------------------------------------------------------------------------- # Get-CostEstimate.ps1 # Axeon Cost Estimation Engine # # Compiles Bicep templates from a deployment YAML to ARM JSON, extracts # resource types and SKUs, then looks up costs from the local pricing cache # generated by Update-AxeonPricing. # # Usage: # . ./Get-CostEstimate.ps1 # Get-AxeonCostEstimate -DeploymentFile ./deployments/hub-networking.yaml # Get-AxeonCostEstimate -DeploymentFile ./deployments/hub-networking.yaml -PricingCachePath ./azure-pricing.json # --------------------------------------------------------------------------- #Requires -Version 7.0 $ErrorActionPreference = "Stop" # Dot-source the pricing engine for Bicep compilation helper . (Join-Path $PSScriptRoot "Get-AzurePricing.ps1") # ============================================================================ # INTERNAL HELPERS # ============================================================================ <# .SYNOPSIS Reads the pricing cache file and returns the parsed data. #> function Read-PricingCache { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$CachePath ) if (-not (Test-Path $CachePath)) { throw @" Pricing cache not found at '$CachePath'. Run 'Update-AxeonPricing' first to fetch live pricing data. Example: Update-AxeonPricing -PlatformSpecPath ./platform-spec.json Update-AxeonPricing -Region uksouth "@ } $raw = Get-Content $CachePath -Raw | ConvertFrom-Json -AsHashtable # Validate cache structure if (-not $raw.ContainsKey('resources') -or -not $raw.ContainsKey('region')) { throw "Invalid pricing cache file at '$CachePath'. Re-run Update-AxeonPricing to regenerate." } # Warn if cache is old (> 30 days) if ($raw.ContainsKey('lastUpdated')) { $lastUpdated = [DateTime]::Parse($raw.lastUpdated) $age = (Get-Date) - $lastUpdated if ($age.TotalDays -gt 30) { Write-Warning "Pricing cache is $([math]::Round($age.TotalDays)) days old. Run Update-AxeonPricing to refresh." } } return $raw } <# .SYNOPSIS Classifies a resource as Free or Paid based on the pricing cache. .OUTPUTS A hashtable with: IsFree, EstimatedMonthlyCost, MatchedMeters, Notes #> function Get-ResourceCostClassification { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ArmResourceType, [string]$Sku, [string]$Tier, [hashtable]$PricingCache ) $resources = $PricingCache.resources # Direct match if ($resources.ContainsKey($ArmResourceType)) { $entry = $resources[$ArmResourceType] # If marked as free in the curated map and confirmed by API if ($entry.isFree) { return @{ IsFree = $true EstimatedMonthlyCost = 0.0 MatchedMeters = @() Notes = $entry.notes ?? "Free resource" Source = 'cache' } } # Paid resource — try to find matching meters $matchedMeters = @() $estimatedCost = 0.0 if ($entry.consumptionMeters -and $entry.consumptionMeters.Count -gt 0) { $meters = $entry.consumptionMeters # If SKU provided, try to match if ($Sku) { $skuMatches = @($meters | Where-Object { $_.skuName -like "*$Sku*" }) if ($skuMatches.Count -gt 0) { $matchedMeters = $skuMatches } } # If no SKU match, show the cheapest consumption meter as a baseline if ($matchedMeters.Count -eq 0) { $matchedMeters = @($meters | Where-Object { $_.retailPrice -gt 0 } | Sort-Object { $_.retailPrice } | Select-Object -First 3) } # Rough monthly estimate: cheapest meter × 730 hours (or per-unit for non-hourly) if ($matchedMeters.Count -gt 0) { $cheapest = $matchedMeters | Sort-Object { $_.retailPrice } | Select-Object -First 1 $unit = $cheapest.unitOfMeasure ?? '' $estimatedCost = switch -Wildcard ($unit) { '*Hour*' { [math]::Round($cheapest.retailPrice * 730, 2) } '*Day*' { [math]::Round($cheapest.retailPrice * 30, 2) } '*Month*' { [math]::Round($cheapest.retailPrice, 2) } '*GB*' { [math]::Round($cheapest.retailPrice, 4) } # per GB — shown as unit '*10K*' { [math]::Round($cheapest.retailPrice, 4) } # per 10K ops '*10,000*' { [math]::Round($cheapest.retailPrice, 4) } default { [math]::Round($cheapest.retailPrice, 4) } } } } return @{ IsFree = $false EstimatedMonthlyCost = $estimatedCost MatchedMeters = $matchedMeters Notes = $entry.notes ?? "Paid resource" Source = 'cache' } } # Not found in cache — fall back to ARM pricing map $armMap = $null try { $armMap = Get-ArmPricingMap } catch {} if ($armMap -and $armMap.ContainsKey($ArmResourceType)) { $mapEntry = $armMap[$ArmResourceType] return @{ IsFree = [bool]$mapEntry.defaultFree EstimatedMonthlyCost = 0.0 MatchedMeters = @() Notes = "$($mapEntry.notes) (no live pricing in cache — run Update-AxeonPricing)" Source = 'map-only' } } # Completely unknown resource type return @{ IsFree = $false EstimatedMonthlyCost = 0.0 MatchedMeters = @() Notes = "Unknown resource type. Run Update-AxeonPricing -DeploymentFile to discover pricing." Source = 'unknown' } } # ============================================================================ # PUBLIC FUNCTION # ============================================================================ <# .SYNOPSIS Estimates the cost of an Axeon deployment by analyzing its Bicep templates. .DESCRIPTION For each module in a deployment YAML file: 1. Compiles the Bicep template to ARM JSON (requires 'az bicep') 2. Extracts all Azure resource types and SKUs 3. Looks up each resource in the local pricing cache 4. Classifies each as Free or Paid with estimated monthly cost The pricing cache must exist (generated by Update-AxeonPricing). .PARAMETER DeploymentFile Path to the deployment YAML file (e.g., deployments/hub-networking.yaml). .PARAMETER PricingCachePath Path to the pricing cache JSON (default: ./azure-pricing.json). .PARAMETER PlatformSpecPath Path to platform-spec.json (used for naming resolution in deployment YAML). .PARAMETER BicepRoot Root path for resolving Bicep template paths. Defaults to src/bicep relative to the script directory. .PARAMETER Detailed Show per-meter pricing details for paid resources. .EXAMPLE Get-AxeonCostEstimate -DeploymentFile ./deployments/hub-networking.yaml .EXAMPLE Get-AxeonCostEstimate -DeploymentFile ./deployments/hub-networking.yaml -Detailed #> function Get-AxeonCostEstimate { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateScript({ Test-Path $_ -PathType Leaf })] [string]$DeploymentFile, [string]$PricingCachePath = './azure-pricing.json', [string]$PlatformSpecPath, [string]$BicepRoot, [switch]$Detailed ) Write-Host "" Write-Host " Axeon Cost Estimate" -ForegroundColor Cyan Write-Host " ─────────────────────────────────────────" -ForegroundColor DarkCyan Write-Host "" # Read pricing cache $cache = Read-PricingCache -CachePath $PricingCachePath Write-Host " Pricing cache : $PricingCachePath" -ForegroundColor DarkGray Write-Host " Region : $($cache.region)" -ForegroundColor DarkGray Write-Host " Currency : $($cache.currency ?? 'USD')" -ForegroundColor DarkGray Write-Host " Last updated : $($cache.lastUpdated)" -ForegroundColor DarkGray Write-Host "" # Parse deployment YAML $yamlModule = Get-Module -ListAvailable -Name 'powershell-yaml' -ErrorAction SilentlyContinue if (-not $yamlModule) { throw "Module 'powershell-yaml' is required. Install: Install-Module powershell-yaml" } 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 } # Resolve Bicep root if (-not $BicepRoot) { $BicepRoot = Join-Path $PSScriptRoot ".." "bicep" } Write-Host " Deployment : $($deploy.name)" -ForegroundColor White Write-Host " Modules : $($deploy.modules.Count)" -ForegroundColor White Write-Host "" # Analyze each module $moduleResults = @() $totalEstimate = 0.0 $allFree = $true foreach ($mod in $deploy.modules) { $moduleName = $mod.name $templatePath = Join-Path $BicepRoot $mod.template Write-Host " Module: $moduleName" -ForegroundColor Cyan Write-Host " Template: $($mod.template)" -ForegroundColor DarkGray # Compile Bicep and extract resource types $resources = Get-BicepResourceTypes -TemplatePath $templatePath if ($resources.Count -eq 0) { Write-Host " No Azure resources found (template may not exist or failed to compile)" -ForegroundColor DarkYellow $moduleResults += @{ Module = $moduleName Template = $mod.template Resources = @() IsFree = $true TotalCost = 0.0 Status = 'NoResources' } continue } $moduleResources = @() $moduleCost = 0.0 $moduleFree = $true foreach ($r in $resources) { $classification = Get-ResourceCostClassification ` -ArmResourceType $r.Type ` -Sku $r.Sku ` -Tier $r.Tier ` -PricingCache $cache $resourceResult = @{ Type = $r.Type Sku = $r.Sku IsFree = $classification.IsFree MonthlyCost = $classification.EstimatedMonthlyCost Notes = $classification.Notes Meters = $classification.MatchedMeters Source = $classification.Source } $moduleResources += $resourceResult if (-not $classification.IsFree) { $moduleFree = $false $allFree = $false $moduleCost += $classification.EstimatedMonthlyCost } # Display resource line $freeLabel = if ($classification.IsFree) { "FREE" } else { "PAID" } $color = if ($classification.IsFree) { 'Green' } else { 'Yellow' } $costStr = if ($classification.IsFree) { "" } else { " (~$($classification.EstimatedMonthlyCost)/mo)" } $skuStr = if ($r.Sku) { " [$($r.Sku)]" } else { "" } Write-Host " [$freeLabel] $($r.Type)$skuStr$costStr" -ForegroundColor $color # Detailed meter info if ($Detailed -and $classification.MatchedMeters.Count -gt 0) { foreach ($meter in $classification.MatchedMeters) { Write-Host " $($meter.meterName): $($cache.currency ?? 'USD') $($meter.retailPrice)/$($meter.unitOfMeasure)" -ForegroundColor DarkGray } } } $totalEstimate += $moduleCost $moduleResults += @{ Module = $moduleName Template = $mod.template Resources = $moduleResources IsFree = $moduleFree TotalCost = [math]::Round($moduleCost, 2) Status = if ($moduleFree) { 'Free' } else { 'Paid' } } Write-Host "" } # Summary Write-Host " ═══════════════════════════════════════════════════════" -ForegroundColor DarkCyan Write-Host " Cost Estimate Summary" -ForegroundColor Cyan Write-Host " ─────────────────────────────────────────────────────" -ForegroundColor DarkCyan Write-Host "" foreach ($mr in $moduleResults) { $statusColor = switch ($mr.Status) { 'Free' { 'Green' } 'Paid' { 'Yellow' } 'NoResources' { 'DarkGray' } default { 'Gray' } } $costStr = if ($mr.TotalCost -gt 0) { " — ~$($cache.currency ?? 'USD') $($mr.TotalCost)/mo" } else { "" } Write-Host " [$($mr.Status)] $($mr.Module)$costStr" -ForegroundColor $statusColor } Write-Host "" $totalColor = if ($totalEstimate -eq 0) { 'Green' } else { 'Yellow' } Write-Host " Estimated monthly total: $($cache.currency ?? 'USD') $([math]::Round($totalEstimate, 2))" -ForegroundColor $totalColor Write-Host "" if ($totalEstimate -eq 0 -and $allFree) { Write-Host " All resources in this deployment are FREE." -ForegroundColor Green } else { Write-Host " Note: Estimates are based on base pricing (lowest consumption meter)." -ForegroundColor DarkGray Write-Host " Actual costs depend on usage, reserved instances, and configuration." -ForegroundColor DarkGray } Write-Host "" # Return structured result for programmatic use return @{ Deployment = $deploy.name Region = $cache.region Currency = $cache.currency ?? 'USD' Modules = $moduleResults TotalMonthlyCost = [math]::Round($totalEstimate, 2) AllFree = $allFree PricingCacheDate = $cache.lastUpdated } } <# .SYNOPSIS Tests whether a specific deployment module contains only free resources. .DESCRIPTION Used internally by the -FreeOnly flag in the bootstrap engine. Compiles a single Bicep template and checks all resources against pricing cache. .PARAMETER TemplatePath Absolute path to the Bicep template file. .PARAMETER PricingCachePath Path to the pricing cache JSON. .OUTPUTS Hashtable with: IsFree, PaidResources (list of paid ARM types), AllResources #> function Test-ModuleFreeStatus { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$TemplatePath, [Parameter(Mandatory)] [string]$PricingCachePath ) $cache = Read-PricingCache -CachePath $PricingCachePath $resources = Get-BicepResourceTypes -TemplatePath $TemplatePath if ($resources.Count -eq 0) { return @{ IsFree = $true PaidResources = @() AllResources = @() } } $paidResources = @() $allResources = @() foreach ($r in $resources) { $classification = Get-ResourceCostClassification ` -ArmResourceType $r.Type ` -Sku $r.Sku ` -Tier $r.Tier ` -PricingCache $cache $allResources += @{ Type = $r.Type Sku = $r.Sku IsFree = $classification.IsFree Cost = $classification.EstimatedMonthlyCost Notes = $classification.Notes } if (-not $classification.IsFree) { $paidResources += @{ Type = $r.Type Sku = $r.Sku Cost = $classification.EstimatedMonthlyCost Notes = $classification.Notes } } } return @{ IsFree = ($paidResources.Count -eq 0) PaidResources = $paidResources AllResources = $allResources } } |