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 -Detailed # --------------------------------------------------------------------------- #Requires -Version 7.0 $ErrorActionPreference = "Stop" # Dot-source the pricing engine for Bicep compilation helpers . (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 -DeploymentFile ./deployments/hub-networking.yaml 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. .DESCRIPTION Looks up the ARM resource type in the cache. If found, uses the cached isFree flag and consumption meters. If not found, tries to dynamically derive a classification from the ARM type's namespace. .OUTPUTS A hashtable with: IsFree, EstimatedMonthlyCost, MatchedMeters, Notes, Source #> function Get-ResourceCostClassification { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$ArmResourceType, [string]$Sku, [string]$Tier, [hashtable]$PricingCache ) $resources = $PricingCache.resources # Direct match in cache if ($resources.ContainsKey($ArmResourceType)) { $entry = $resources[$ArmResourceType] if ($entry.isFree) { return @{ IsFree = $true EstimatedDailyCost = 0.0 EstimatedMonthlyCost = 0.0 EstimatedAnnualCost = 0.0 MatchedMeters = @() Notes = "Free resource (all consumption meters are `$0 or none exist)" Source = 'cache' } } # Paid resource — try to find matching meters $matchedMeters = @() $estimatedDailyCost = 0.0 $estimatedMonthlyCost = 0.0 $estimatedAnnualCost = 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 -and $_.skuName -like "*$Sku*") -or ($_.armSkuName -and $_.armSkuName -like "*$Sku*") }) if ($skuMatches.Count -gt 0) { $matchedMeters = $skuMatches } } # If no SKU match, show cheapest non-zero meters as baseline if ($matchedMeters.Count -eq 0) { $matchedMeters = @($meters | Where-Object { $_.retailPrice -gt 0 } | Sort-Object { $_.retailPrice } | Select-Object -First 3) } # Cost estimates from the cheapest matched meter if ($matchedMeters.Count -gt 0) { $cheapest = $matchedMeters | Sort-Object { $_.retailPrice } | Select-Object -First 1 # Use pre-calculated prices from cache if available, otherwise derive them if ($null -ne $cheapest.dailyPrice) { $estimatedDailyCost = [math]::Round($cheapest.dailyPrice, 2) $estimatedMonthlyCost = [math]::Round($cheapest.monthlyPrice, 2) $estimatedAnnualCost = [math]::Round($cheapest.annualPrice, 2) } else { $unit = $cheapest.unitOfMeasure ?? '' $estimatedDailyCost = switch -Wildcard ($unit) { '*Hour*' { [math]::Round($cheapest.retailPrice * 24, 2) } '*Day*' { [math]::Round($cheapest.retailPrice, 2) } '*Month*' { [math]::Round($cheapest.retailPrice / 30, 2) } '*Year*' { [math]::Round($cheapest.retailPrice / 365, 2) } default { [math]::Round($cheapest.retailPrice, 4) } } $estimatedMonthlyCost = switch -Wildcard ($unit) { '*Hour*' { [math]::Round($cheapest.retailPrice * 730, 2) } '*Day*' { [math]::Round($cheapest.retailPrice * 30, 2) } '*Month*' { [math]::Round($cheapest.retailPrice, 2) } '*Year*' { [math]::Round($cheapest.retailPrice / 12, 2) } default { [math]::Round($cheapest.retailPrice, 4) } } $estimatedAnnualCost = switch -Wildcard ($unit) { '*Hour*' { [math]::Round($cheapest.retailPrice * 8760, 2) } '*Day*' { [math]::Round($cheapest.retailPrice * 365, 2) } '*Month*' { [math]::Round($cheapest.retailPrice * 12, 2) } '*Year*' { [math]::Round($cheapest.retailPrice, 2) } default { [math]::Round($cheapest.retailPrice, 4) } } } } } return @{ IsFree = $false EstimatedDailyCost = $estimatedDailyCost EstimatedMonthlyCost = $estimatedMonthlyCost EstimatedAnnualCost = $estimatedAnnualCost MatchedMeters = $matchedMeters Notes = "Paid resource" Source = 'cache' } } # Not found in cache — unknown return @{ IsFree = $false EstimatedDailyCost = 0.0 EstimatedMonthlyCost = 0.0 EstimatedAnnualCost = 0.0 MatchedMeters = @() Notes = "Not in pricing cache. Run: Update-AxeonPricing -DeploymentFile <your-deployment.yaml>" Source = 'unknown' } } # ============================================================================ # PUBLIC FUNCTIONS # ============================================================================ <# .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 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]$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 } 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 = @() $totalDailyEstimate = 0.0 $totalMonthlyEstimate = 0.0 $totalAnnualEstimate = 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 DailyCost = 0.0 MonthlyCost = 0.0 AnnualCost = 0.0 Status = 'NoResources' } continue } $moduleResources = @() $moduleDailyCost = 0.0 $moduleMonthlyCost = 0.0 $moduleAnnualCost = 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 DailyCost = $classification.EstimatedDailyCost MonthlyCost = $classification.EstimatedMonthlyCost AnnualCost = $classification.EstimatedAnnualCost Notes = $classification.Notes Meters = $classification.MatchedMeters Source = $classification.Source } $moduleResources += $resourceResult if (-not $classification.IsFree) { $moduleFree = $false $allFree = $false $moduleDailyCost += $classification.EstimatedDailyCost $moduleMonthlyCost += $classification.EstimatedMonthlyCost $moduleAnnualCost += $classification.EstimatedAnnualCost } # Display resource line $freeLabel = if ($classification.IsFree) { "FREE" } else { "PAID" } $color = if ($classification.IsFree) { 'Green' } else { 'Yellow' } $costStr = if ($classification.IsFree) { "" } else { " (~$($classification.EstimatedDailyCost)/day | $($classification.EstimatedMonthlyCost)/mo | $($classification.EstimatedAnnualCost)/yr)" } $skuStr = if ($r.Sku) { " [$($r.Sku)]" } else { "" } if ($classification.Source -eq 'unknown') { Write-Host " [????] $($r.Type)$skuStr — not in cache" -ForegroundColor DarkYellow } 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) { $dailyStr = if ($null -ne $meter.dailyPrice) { "$($meter.dailyPrice)/day" } else { "" } $monthlyStr = if ($null -ne $meter.monthlyPrice) { "$($meter.monthlyPrice)/mo" } else { "" } $annualStr = if ($null -ne $meter.annualPrice) { "$($meter.annualPrice)/yr" } else { "" } $priceBreakdown = @($dailyStr, $monthlyStr, $annualStr) | Where-Object { $_ } | Join-String -Separator ' | ' $breakdownStr = if ($priceBreakdown) { " (~$priceBreakdown)" } else { "" } Write-Host " $($meter.meterName): $($cache.currency ?? 'USD') $($meter.retailPrice)/$($meter.unitOfMeasure)$breakdownStr" -ForegroundColor DarkGray } } } $totalDailyEstimate += $moduleDailyCost $totalMonthlyEstimate += $moduleMonthlyCost $totalAnnualEstimate += $moduleAnnualCost $moduleResults += @{ Module = $moduleName Template = $mod.template Resources = $moduleResources IsFree = $moduleFree DailyCost = [math]::Round($moduleDailyCost, 2) MonthlyCost = [math]::Round($moduleMonthlyCost, 2) AnnualCost = [math]::Round($moduleAnnualCost, 2) Status = if ($moduleFree) { 'Free' } else { 'Paid' } } Write-Host "" } # Summary $unknownCount = 0 foreach ($mr in $moduleResults) { foreach ($res in $mr.Resources) { if ($res.Source -eq 'unknown') { $unknownCount++ } } } 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.MonthlyCost -gt 0) { " — ~$($cache.currency ?? 'USD') $($mr.DailyCost)/day | $($mr.MonthlyCost)/mo | $($mr.AnnualCost)/yr" } else { "" } Write-Host " [$($mr.Status)] $($mr.Module)$costStr" -ForegroundColor $statusColor } Write-Host "" $totalColor = if ($totalMonthlyEstimate -eq 0) { 'Green' } else { 'Yellow' } Write-Host " Estimated daily total : $($cache.currency ?? 'USD') $([math]::Round($totalDailyEstimate, 2))" -ForegroundColor $totalColor Write-Host " Estimated monthly total : $($cache.currency ?? 'USD') $([math]::Round($totalMonthlyEstimate, 2))" -ForegroundColor $totalColor Write-Host " Estimated annual total : $($cache.currency ?? 'USD') $([math]::Round($totalAnnualEstimate, 2))" -ForegroundColor $totalColor if ($unknownCount -gt 0) { Write-Host "" Write-Host " $unknownCount resource type(s) not in pricing cache." -ForegroundColor DarkYellow Write-Host " Run: Update-AxeonPricing -DeploymentFile $DeploymentFile" -ForegroundColor DarkYellow } Write-Host "" if ($totalMonthlyEstimate -eq 0 -and $allFree -and $unknownCount -eq 0) { Write-Host " All resources in this deployment are FREE." -ForegroundColor Green } elseif ($totalMonthlyEstimate -gt 0) { 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 TotalDailyCost = [math]::Round($totalDailyEstimate, 2) TotalMonthlyCost = [math]::Round($totalMonthlyEstimate, 2) TotalAnnualCost = [math]::Round($totalAnnualEstimate, 2) AllFree = $allFree UnknownCount = $unknownCount 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 the pricing cache. Resources not found in the cache are treated as potentially paid (unknown = not free, for safety). .PARAMETER TemplatePath Absolute path to the Bicep template file. .PARAMETER PricingCachePath Path to the pricing cache JSON. .OUTPUTS Hashtable with: IsFree, PaidResources, UnknownResources, 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 = @() UnknownResources = @() AllResources = @() } } $paidResources = @() $unknownResources = @() $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 Source = $classification.Source } if ($classification.Source -eq 'unknown') { $unknownResources += @{ Type = $r.Type Sku = $r.Sku Notes = $classification.Notes } } elseif (-not $classification.IsFree) { $paidResources += @{ Type = $r.Type Sku = $r.Sku Cost = $classification.EstimatedMonthlyCost Notes = $classification.Notes } } } return @{ IsFree = ($paidResources.Count -eq 0 -and $unknownResources.Count -eq 0) PaidResources = $paidResources UnknownResources = $unknownResources AllResources = $allResources } } |