modules/Invoke-AzureCost.ps1
|
#requires -Version 7.0 <# .SYNOPSIS Wrapper for Azure Consumption API — 30-day subscription spend + top costly resources. .DESCRIPTION Queries Microsoft.Consumption/usageDetails for a trailing 30-day window, aggregates cost per resource ID, and returns a standardized v1 tool-result shape. The Normalize-AzureCost normalizer downstream converts this into v2 FindingRows that populate MonthlyCost / Currency / CostTrend on the existing AzureResource entities. Uses Invoke-WithRetry for transient 429/503/timeout handling. Gracefully skips when the subscription has no consumption data (new sub, trial, CSP without Consumption API access). .PARAMETER SubscriptionId Azure subscription ID (GUID). Required. .PARAMETER TopN Number of top costly resources to emit. Default 20 per the acceptance criteria. .PARAMETER OutputPath Optional directory for raw API JSON (for audit). #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string] $SubscriptionId, [ValidateRange(1, 100)] [int] $TopN = 20, [string] $OutputPath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' # Dot-source shared Retry helper if available $retryPath = Join-Path $PSScriptRoot 'shared' 'Retry.ps1' if (Test-Path $retryPath) { . $retryPath } if (-not (Get-Command Invoke-WithRetry -ErrorAction SilentlyContinue)) { function Invoke-WithRetry { param([scriptblock]$ScriptBlock, [int]$MaxAttempts = 3) & $ScriptBlock } } $sanitizePath = Join-Path $PSScriptRoot 'shared' 'Sanitize.ps1' if (Test-Path $sanitizePath) { . $sanitizePath } if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) { function Remove-Credentials { param([string]$Text) return $Text } } $errorsPath = Join-Path $PSScriptRoot 'shared' 'Errors.ps1' if (Test-Path $errorsPath) { . $errorsPath } $envelopePath = Join-Path $PSScriptRoot 'shared' 'New-WrapperEnvelope.ps1' if (Test-Path $envelopePath) { . $envelopePath } if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } } if (-not (Get-Command New-FindingError -ErrorAction SilentlyContinue)) { function New-FindingError { param([string]$Source,[string]$Category,[string]$Reason,[string]$Remediation,[string]$Details) return [pscustomobject]@{ Source=$Source; Category=$Category; Reason=$Reason; Remediation=$Remediation; Details=$Details } } } if (-not (Get-Command Format-FindingErrorMessage -ErrorAction SilentlyContinue)) { function Format-FindingErrorMessage { param([Parameter(Mandatory)]$FindingError) $line = "[{0}] {1}: {2}" -f $FindingError.Source, $FindingError.Category, $FindingError.Reason if ($FindingError.Remediation) { $line += " Action: $($FindingError.Remediation)" } return $line } } $result = [ordered]@{ SchemaVersion = '1.0' Source = 'azure-cost' ToolVersion = 'Microsoft.Consumption/usageDetails@2021-10-01' Status = 'Success' Message = '' Findings = @() Errors = @() Subscription = $SubscriptionId Timestamp = (Get-Date).ToUniversalTime().ToString('o') } # --- Prereq: Az.Accounts authenticated --- if (-not (Get-Module -ListAvailable -Name Az.Accounts)) { $result.Status = 'Skipped' $result.Message = 'Az.Accounts module not installed. Run: Install-Module Az.Accounts -Scope CurrentUser' return [pscustomobject]$result } Import-Module Az.Accounts -ErrorAction SilentlyContinue try { $ctx = Get-AzContext -ErrorAction Stop if (-not $ctx) { Write-Error 'No Az context' -ErrorAction Stop } } catch { $result.Status = 'Skipped' $result.Message = 'Not signed in. Run Connect-AzAccount first.' return [pscustomobject]$result } # --- Build REST URL for Consumption API (list usageDetails) --- $consumptionApiVersion = '2021-10-01' $toDate = (Get-Date).ToUniversalTime().Date $fromDate = $toDate.AddDays(-30) $filter = "properties/usageStart ge '$($fromDate.ToString('yyyy-MM-dd'))' and properties/usageEnd le '$($toDate.ToString('yyyy-MM-dd'))'" $uri = "https://management.azure.com/subscriptions/$SubscriptionId/providers/Microsoft.Consumption/usageDetails?api-version=$consumptionApiVersion&`$filter=$([System.Uri]::EscapeDataString($filter))&`$top=5000" $allRecords = [System.Collections.Generic.List[object]]::new() $currency = '' $nextLink = $uri $pageCount = 0 $maxPages = 20 try { while ($nextLink -and $pageCount -lt $maxPages) { $pageCount++ $resp = Invoke-WithRetry -MaxAttempts 3 -ScriptBlock { Invoke-AzRestMethod -Method GET -Uri $nextLink -ErrorAction Stop } if (-not $resp -or $resp.StatusCode -ge 400) { # 404/204 typically means "no consumption data available" — skip gracefully. if ($resp -and $resp.StatusCode -in 204, 404) { $result.Status = 'Skipped' $result.Message = "Consumption API returned $($resp.StatusCode) — no cost data for this subscription." return [pscustomobject]$result } throw (Format-FindingErrorMessage (New-FindingError ` -Source 'wrapper:azure-cost' ` -Category 'TransientFailure' ` -Reason "Consumption API returned status $($resp.StatusCode)." ` -Remediation 'Retry; verify subscription scope and Consumption / Cost Management Reader role.' ` -Details (Remove-Credentials -Text ([string]$resp.Content)))) } $body = $resp.Content | ConvertFrom-Json -Depth 20 if ($body.value) { foreach ($row in $body.value) { $allRecords.Add($row) | Out-Null if (-not $currency -and $row.properties.billingCurrency) { $currency = $row.properties.billingCurrency } } } $nextLink = $body.nextLink } } catch { $result.Status = 'Failed' $result.Message = "Consumption API query failed: $(Remove-Credentials -Text ([string]$_.Exception.Message))" return [pscustomobject]$result } if ($allRecords.Count -eq 0) { $result.Status = 'Skipped' $result.Message = 'No cost data returned from Consumption API for this subscription.' return [pscustomobject]$result } # --- Aggregate per resource + per subscription --- $byResource = @{} $subTotal = 0.0 foreach ($r in $allRecords) { $rid = if ($r.properties.instanceId) { $r.properties.instanceId } elseif ($r.properties.resourceId) { $r.properties.resourceId } else { '' } $cost = 0.0 if ($r.properties.cost) { $cost = [double]$r.properties.cost } elseif ($r.properties.costInBillingCurrency) { $cost = [double]$r.properties.costInBillingCurrency } $subTotal += $cost if (-not $rid) { continue } if (-not $byResource.ContainsKey($rid)) { $byResource[$rid] = [ordered]@{ ResourceId = $rid ResourceName = $r.properties.resourceName ResourceType = if ($r.properties.consumedService) { $r.properties.consumedService } else { $r.properties.resourceType } Location = $r.properties.resourceLocation TotalCost = 0.0 } } $byResource[$rid].TotalCost += $cost } $top = @($byResource.Values | Sort-Object -Property TotalCost -Descending | Select-Object -First $TopN) $findings = [System.Collections.Generic.List[object]]::new() # Subscription-entity roll-up $findings.Add([pscustomobject]@{ Id = "azure-cost/subscription/$SubscriptionId" Source = 'azure-cost' RuleId = 'azure-cost-subscription-spend' Category = 'Cost' Severity = 'Info' Compliant = $true Title = "30-day subscription spend: $([math]::Round($subTotal,2)) $currency" Detail = "Aggregated from $($allRecords.Count) usageDetails records for window $($fromDate.ToString('yyyy-MM-dd'))..$($toDate.ToString('yyyy-MM-dd')). Top-$TopN resources follow." ResourceId = "/subscriptions/$SubscriptionId" ResourceType = 'Microsoft.Resources/subscriptions' CostCategory = 'SubscriptionSpend' MonthlyCost = [math]::Round($subTotal, 2) Currency = $currency CostTrend = '' Recommendation = 'Review Cost Analysis for high-spend services and enforce subscription budgets with alerts.' Remediation = 'Review Cost Analysis for high-spend services and enforce subscription budgets with alerts.' LearnMoreUrl = 'https://learn.microsoft.com/azure/cost-management-billing/' ToolVersion = "Microsoft.Consumption/usageDetails@$consumptionApiVersion" }) | Out-Null foreach ($item in $top) { $findings.Add([pscustomobject]@{ Id = "azure-cost/resource/$($item.ResourceId)" Source = 'azure-cost' RuleId = 'azure-cost-top-resource-spend' Category = 'Cost' Severity = 'Info' Compliant = $true Title = "Top costly resource: $([math]::Round($item.TotalCost,2)) $currency" Detail = "$($item.ResourceType) in $($item.Location). 30-day total $([math]::Round($item.TotalCost,2)) $currency." ResourceId = $item.ResourceId ResourceType = $item.ResourceType ResourceName = $item.ResourceName Location = $item.Location CostCategory = 'TopResourceSpend' MonthlyCost = [math]::Round($item.TotalCost, 2) Currency = $currency CostTrend = '' Recommendation = 'Use Cost Analysis and Advisor to rightsize this resource or apply autoscale and schedule controls.' Remediation = 'Use Cost Analysis and Advisor to rightsize this resource or apply autoscale and schedule controls.' LearnMoreUrl = 'https://learn.microsoft.com/azure/advisor/advisor-cost-recommendations' ToolVersion = "Microsoft.Consumption/usageDetails@$consumptionApiVersion" }) | Out-Null } $result.Findings = @($findings) $result.Message = "Collected $($allRecords.Count) usage records; emitted 1 subscription roll-up + $($top.Count) top-resource findings." if ($OutputPath) { try { if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } $raw = Join-Path $OutputPath "cost-$SubscriptionId-$(Get-Date -Format yyyyMMddHHmmss).json" Set-Content -Path $raw -Value (Remove-Credentials ($result | ConvertTo-Json -Depth 20)) -Encoding utf8 } catch { Write-Warning "Failed to write raw cost JSON: $(Remove-Credentials -Text ([string]$_.Exception.Message))" } } return [pscustomobject]$result |