modules/Invoke-AzureQuotaReports.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Wrapper for Azure Quota Reports (az vm/network usage fanout).
.DESCRIPTION
    Enumerates subscriptions and locations, executes Azure CLI quota usage calls,
    and emits a v1 wrapper envelope with raw findings.
#>

[CmdletBinding()]
param (
    [string] $SubscriptionId,
    [string[]] $Subscriptions,
    [string[]] $Locations,
    [ValidateRange(1, 100)] [int] $Threshold = 80,
    [string] $OutputPath
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$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 }

$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 Remove-Credentials -ErrorAction SilentlyContinue)) {
    function Remove-Credentials { param([string]$Text) return $Text }
}

$installerPath = Join-Path $PSScriptRoot 'shared' 'Installer.ps1'
if (-not (Get-Command Invoke-WithTimeout -ErrorAction SilentlyContinue)) {
    if (Test-Path $installerPath) { . $installerPath }
}
if (-not (Get-Command Invoke-WithTimeout -ErrorAction SilentlyContinue)) {
    function Invoke-WithTimeout {
        param (
            [Parameter(Mandatory)][string]$Command,
            [Parameter(Mandatory)][string[]]$Arguments,
            [int]$TimeoutSec = 300
        )
        $output = & $Command @Arguments 2>&1 | Out-String
        return [PSCustomObject]@{ ExitCode = $LASTEXITCODE; Output = $output.Trim() }
    }
}
if (-not (Get-Command New-InstallerError -ErrorAction SilentlyContinue)) {
    function New-InstallerError {
        param (
            [Parameter(Mandatory)][string] $Tool,
            [Parameter(Mandatory)][ValidateSet('psmodule','cli','gitclone','none')][string] $Kind,
            [Parameter(Mandatory)][string] $Reason,
            [string] $Package,
            [string] $Url,
            [string] $Remediation,
            [string] $Output,
            [string] $Category = 'InstallFailed'
        )
        return [PSCustomObject]@{
            Tool = $Tool
            Kind = $Kind
            Category = $Category
            Reason = $Reason
            Package = $Package
            Url = $Url
            Remediation = $Remediation
            Output = Remove-Credentials ([string]$Output)
            TimestampUtc = (Get-Date).ToUniversalTime().ToString('o')
        }
    }
}

$result = [ordered]@{
    SchemaVersion = '1.0'
    Source        = 'azure-quota'
    Status        = 'Success'
    Message       = ''
    ToolVersion   = ''
    Findings      = @()
    Errors        = @()
    Timestamp     = (Get-Date).ToUniversalTime().ToString('o')
}

function Throw-QuotaFailure {
    param(
        [Parameter(Mandatory)][string] $Reason,
        [string] $Output = '',
        [string] $Category = 'ExecutionFailed',
        [string] $Remediation = 'Verify az login, Reader access, and subscription/region scope.'
    )
    $err = New-InstallerError -Tool 'azure-quota' -Kind 'cli' -Reason $Reason -Package 'az' -Category $Category -Remediation $Remediation -Output $Output
    throw ($err | ConvertTo-Json -Depth 8 -Compress)
}

function Invoke-AzJson {
    param(
        [Parameter(Mandatory)][string[]] $Arguments,
        [Parameter(Mandatory)][string] $Context
    )

    $argsForAz = $Arguments + @('--output', 'json', '--only-show-errors')
    try {
        $exec = Invoke-WithRetry -MaxAttempts 4 -InitialDelaySeconds 2 -MaxDelaySeconds 30 -ScriptBlock {
            $attempt = Invoke-WithTimeout -Command 'az' -Arguments $argsForAz -TimeoutSec 300
            if (-not $attempt) { throw [System.Exception]::new("$Context failed with no CLI response.") }
            if ($attempt.ExitCode -ne 0) {
                throw [System.Exception]::new(([string]$attempt.Output))
            }
            return $attempt
        }
    } catch {
        Throw-QuotaFailure -Reason "$Context failed." -Output ([string]$_.Exception.Message)
    }
    if (-not $exec) {
        Throw-QuotaFailure -Reason "$Context failed." -Output ''
    }
    $raw = [string]$exec.Output
    if ([string]::IsNullOrWhiteSpace($raw)) { return @() }
    try { return ($raw | ConvertFrom-Json -Depth 20) }
    catch { Throw-QuotaFailure -Reason "$Context returned invalid JSON." -Output $raw -Category 'ParseFailed' }
}

function Invoke-AzNoOutput {
    param(
        [Parameter(Mandatory)][string[]] $Arguments,
        [Parameter(Mandatory)][string] $Context
    )
    $argsForAz = $Arguments + @('--only-show-errors')
    try {
        $exec = Invoke-WithRetry -MaxAttempts 4 -InitialDelaySeconds 2 -MaxDelaySeconds 30 -ScriptBlock {
            $attempt = Invoke-WithTimeout -Command 'az' -Arguments $argsForAz -TimeoutSec 300
            if (-not $attempt) { throw [System.Exception]::new("$Context failed with no CLI response.") }
            if ($attempt.ExitCode -ne 0) {
                throw [System.Exception]::new(([string]$attempt.Output))
            }
            return $attempt
        }
    } catch {
        Throw-QuotaFailure -Reason "$Context failed." -Output ([string]$_.Exception.Message)
    }
    if (-not $exec) {
        Throw-QuotaFailure -Reason "$Context failed." -Output ''
    }
}

function Get-AzCliVersion {
    try {
        $exec = Invoke-WithTimeout -Command 'az' -Arguments @('version', '--output', 'json', '--only-show-errors') -TimeoutSec 300
        if (-not $exec -or $exec.ExitCode -ne 0 -or [string]::IsNullOrWhiteSpace([string]$exec.Output)) { return '' }
        $parsed = ([string]$exec.Output | ConvertFrom-Json -Depth 10 -ErrorAction Stop)
        $version = [string]($parsed.'azure-cli' ?? '')
        return $version.Trim()
    } catch {
        return ''
    }
}

function Get-UsagePercent {
    param([double]$CurrentValue, [double]$Limit)
    if ($Limit -le 0) {
        if ($CurrentValue -gt 0) { return 100.0 }
        return 0.0
    }
    return [math]::Round(($CurrentValue / $Limit) * 100.0, 2)
}

function Get-SeverityFromPercent {
    param([double]$UsagePercent, [int]$ThresholdPercent)
    if ($UsagePercent -ge 95) { return 'Critical' }
    if ($UsagePercent -ge 90) { return 'High' }
    if ($UsagePercent -ge $ThresholdPercent) { return 'Medium' }
    return 'Low'
}

function Convert-UsageRowsToFindings {
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]] $Rows,
        [Parameter(Mandatory)][string] $Subscription,
        [Parameter(Mandatory)][string] $SubscriptionName,
        [Parameter(Mandatory)][string] $Location,
        [Parameter(Mandatory)][string] $Service,
        [Parameter(Mandatory)][int] $ThresholdPercent,
        [string] $ToolVersion = ''
    )

    $items = [System.Collections.Generic.List[object]]::new()
    foreach ($row in $Rows) {
        if (-not $row) { continue }
        $metricName = [string]$row.name?.value
        if ([string]::IsNullOrWhiteSpace($metricName)) {
            $metricName = [string]$row.name?.localizedValue
        }
        if ([string]::IsNullOrWhiteSpace($metricName)) { $metricName = 'unknown-metric' }

        $currentValue = [double]($row.currentValue ?? 0)
        $limit = [double]($row.limit ?? 0)
        $usagePercent = Get-UsagePercent -CurrentValue $currentValue -Limit $limit
        $compliant = ($usagePercent -lt $ThresholdPercent)
        $severity = Get-SeverityFromPercent -UsagePercent $usagePercent -ThresholdPercent $ThresholdPercent
        $safeMetric = (($metricName.ToLowerInvariant() -replace '[^a-z0-9._-]', '-').Trim('-'))
        if ([string]::IsNullOrWhiteSpace($safeMetric)) { $safeMetric = 'metric' }

        $items.Add([PSCustomObject]@{
                Id               = "azure-quota/$Subscription/$Location/$Service/$safeMetric"
                Source           = 'azure-quota'
                Category         = 'Capacity'
                Pillar           = 'Reliability'
                EntityType       = 'Subscription'
                Severity         = $severity
                Compliant        = $compliant
                Title            = "$Service quota usage for '$metricName' in $Location"
                Detail           = "Usage=$currentValue, Limit=$limit, UsagePercent=$usagePercent%, Threshold=$ThresholdPercent%."
                Remediation      = "Request quota increase or rebalance workload before usage reaches $ThresholdPercent%."
                ResourceId       = "/subscriptions/$Subscription"
                SubscriptionId   = $Subscription
                SubscriptionName = $SubscriptionName
                Location         = $Location
                Service          = $Service
                Sku              = $metricName
                MetricName       = $metricName
                Unit             = [string]($row.unit ?? '')
                CurrentValue     = $currentValue
                Limit            = $limit
                UsagePercent     = $usagePercent
                Threshold        = $ThresholdPercent
                ToolVersion      = $ToolVersion
            }) | Out-Null
    }
    return @($items)
}

try {
    if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
        $result.Status = 'Skipped'
        $result.Message = 'Azure CLI (az) is not installed or not on PATH.'
        return [PSCustomObject]$result
    }

    $toolVersion = Get-AzCliVersion
    $result.ToolVersion = $toolVersion
    $accounts = @(Invoke-AzJson -Arguments @('account', 'list') -Context 'az account list')
    $enabledAccounts = @($accounts | Where-Object { [string]$_.state -eq 'Enabled' })

    $subscriptionSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    if ($SubscriptionId) { $null = $subscriptionSet.Add($SubscriptionId) }
    foreach ($sub in @($Subscriptions)) {
        if (-not [string]::IsNullOrWhiteSpace([string]$sub)) { $null = $subscriptionSet.Add([string]$sub) }
    }
    if ($subscriptionSet.Count -eq 0) {
        foreach ($acc in $enabledAccounts) {
            if ($acc.id) { $null = $subscriptionSet.Add([string]$acc.id) }
        }
    }

    $targetSubscriptions = @($subscriptionSet | Sort-Object)
    if (-not $targetSubscriptions -or $targetSubscriptions.Count -eq 0) {
        $result.Status = 'Skipped'
        $result.Message = 'No enabled subscriptions found for quota scan.'
        return [PSCustomObject]$result
    }

    $accountNameById = @{}
    foreach ($acc in $enabledAccounts) {
        if ($acc.id) { $accountNameById[[string]$acc.id] = [string]($acc.name ?? '') }
    }

    $findings = [System.Collections.Generic.List[object]]::new()
    foreach ($subId in $targetSubscriptions) {
        Invoke-AzNoOutput -Arguments @('account', 'set', '--subscription', $subId) -Context "az account set for subscription $subId"

        $subLocations = @()
        if ($Locations -and $Locations.Count -gt 0) {
            $subLocations = @($Locations | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } | Select-Object -Unique)
        } else {
            $subLocations = @(Invoke-AzJson -Arguments @('account', 'list-locations', '--subscription', $subId, '--query', "[?metadata.regionType=='Physical'].name") -Context "az account list-locations for subscription $subId")
        }

        foreach ($location in @($subLocations)) {
            $locationName = [string]$location
            if ([string]::IsNullOrWhiteSpace($locationName)) { continue }

            $vmUsage = @(Invoke-AzJson -Arguments @('vm', 'list-usage', '--location', $locationName, '--subscription', $subId) -Context "az vm list-usage ($subId/$locationName)")
            $netUsage = @(Invoke-AzJson -Arguments @('network', 'list-usages', '--location', $locationName, '--subscription', $subId) -Context "az network list-usages ($subId/$locationName)")

            foreach ($f in (Convert-UsageRowsToFindings -Rows $vmUsage -Subscription $subId -SubscriptionName $accountNameById[$subId] -Location $locationName -Service 'vm' -ThresholdPercent $Threshold -ToolVersion $toolVersion)) {
                $findings.Add($f) | Out-Null
            }
            foreach ($f in (Convert-UsageRowsToFindings -Rows $netUsage -Subscription $subId -SubscriptionName $accountNameById[$subId] -Location $locationName -Service 'network' -ThresholdPercent $Threshold -ToolVersion $toolVersion)) {
                $findings.Add($f) | Out-Null
            }
        }
    }

    $result.Findings = @($findings)
    $result.Message = "Processed $($targetSubscriptions.Count) subscription(s); emitted $($findings.Count) quota usage finding(s)."

    if ($OutputPath) {
        $parent = Split-Path -Parent $OutputPath
        if ($parent -and -not (Test-Path $parent)) {
            $null = New-Item -ItemType Directory -Path $parent -Force
        }
        $serialized = ($result | ConvertTo-Json -Depth 20)
        [System.IO.File]::WriteAllText($OutputPath, (Remove-Credentials -Text $serialized))
    }

    return [PSCustomObject]$result
} catch {
    $result.Status = 'Failed'
    $result.Message = Remove-Credentials -Text ([string]$_.Exception.Message)
    $result.Findings = @()
    return [PSCustomObject]$result
}