src/collect/Get-ScoutVmQuotas.ps1

#Requires -Version 7.0
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

<#
.SYNOPSIS
    Per-subscription, per-location VM/VMSS quota usage -- ported into src/collect from
    Modules/Private/Extraction/ResourceDetails/Get-AZTIVMQuotas.ps1 (AB#5639/5646).
 
.DESCRIPTION
    Quota usage (`Get-AzVMUsage`) is an ARM data-plane call scoped to a subscription context
    and a region -- it has no Resource Graph table, so it cannot be folded into
    `Get-ScoutRawInventory`'s ARG queries no matter how the raw row set is shaped. The type
    map this task derived from the 176 legacy collectors (`scripts/
    Get-CollectorResourceTypeMap.ps1`) surfaces exactly this: three collectors
    (AKS, VirtualMachine, VirtualMachineScaleSet) filter `$Resources` for a synthetic
    `'AZSC/VM/Quotas'`-typed row that only this call can produce -- confirming the legacy
    engine already depends on this exact REST-API-backed dataset, not an ARG one.
 
    Only queries the (subscription, location) pairs that actually HAVE a VM or VMSS in
    `$Resources` -- the same targeted-location strategy the legacy function uses, avoiding a
    quota lookup for every Azure region regardless of where the tenant actually deploys.
 
.PARAMETER Subscriptions
    Subscription objects with `.id` (and ideally `.name`).
 
.PARAMETER Resources
    The mixed resource row set (Resource Graph rows, optionally alongside REST-API rows from
    `Get-ScoutApiResources` that do not carry `subscriptionId`/`Type`) to derive
    (subscription, location) pairs from. Every row is guarded with a `PSObject.Properties`
    check before either property is read, so a mixed array never aborts the whole
    call (AB#5633's exact defect class, carried forward as a fix rather than reintroduced).
 
.OUTPUTS
    `[pscustomobject]` with `type = 'AZSC/VM/Quotas'` and `properties` holding one entry per
    (subscription, location) pair queried: `Location`, `SubId`, `Subscription`, `Data` (the
    `Get-AzVMUsage` result, filtered to `CurrentValue -ge 1`). The literal `type` string is
    preserved EXACTLY as the legacy function emits it -- the three collectors above match on
    it case-insensitively but it must still be present for that match to succeed at all.
 
.NOTES
    Tracks ADO AB#5639 (Task AB#5646, Epic AB#5638).
 
    Restores the caller's original Az context in a `finally` block, exactly like the legacy
    function -- `Set-AzContext` is called once per subscription to run the quota lookup in
    that subscription's scope, and a caller left parked in the last-iterated subscription
    (or wherever an error surfaced from) is a real defect the legacy function already fixed
    once (AB#368); this keeps that fix.
#>

function Get-ScoutVmQuotas {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object[]] $Subscriptions,
        [Parameter(Mandatory)] [AllowNull()] [object[]] $Resources
    )

    $originalContext = Get-AzContext -ErrorAction SilentlyContinue
    $vmTypes = @('microsoft.compute/virtualmachines', 'microsoft.compute/virtualmachinescalesets')

    $quotas = try {
        foreach ($sub in $Subscriptions) {
            $locations = @(
                @($Resources) | Where-Object {
                    $null -ne $_ -and
                    $_.PSObject.Properties.Name -contains 'subscriptionId' -and
                    $_.PSObject.Properties.Name -contains 'type' -and
                    $_.subscriptionId -eq $sub.id -and
                    ([string] $_.type).ToLowerInvariant() -in $vmTypes
                } | Group-Object -Property location | ForEach-Object { $_.Name } | Where-Object { $_ }
            )
            if ($locations.Count -eq 0) { continue }

            Set-AzContext -Subscription $sub.id -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-Null
            foreach ($loc in $locations) {
                try {
                    # NOT wrapped in @(): the legacy function assigned the filtered pipeline
                    # unwrapped, so a single matching quota row arrives as a scalar and a
                    # region with none arrives as $null. Every consumer reads it as
                    # `$Quota.Data | Where-Object ...`, which is shape-agnostic, but the
                    # emitted object is kept byte-identical to v1's rather than "improved"
                    # into a shape no consumer asked for. (AB#5648)
                    $usage = Get-AzVMUsage -Location $loc -ErrorAction Stop | Where-Object { $_.CurrentValue -ge 1 }
                }
                catch {
                    Write-Warning "Get-ScoutVmQuotas: quota lookup failed for subscription '$($sub.id)' / location '$loc' -- skipping: $($_.Exception.Message)"
                    continue
                }
                [pscustomobject]@{
                    Location     = $loc
                    SubId        = $sub.id
                    Subscription = if ($sub.PSObject.Properties['name']) { $sub.name } else { $sub.id }
                    Data         = $usage
                }
            }
        }
    }
    finally {
        $restoreId = $null
        if ($originalContext -and $originalContext.PSObject.Properties.Name -contains 'Subscription' -and $originalContext.Subscription) {
            if ($originalContext.Subscription.PSObject.Properties.Name -contains 'Id') { $restoreId = $originalContext.Subscription.Id }
        }
        if ($restoreId) { Set-AzContext -SubscriptionId $restoreId -ErrorAction SilentlyContinue | Out-Null }
    }

    return [pscustomobject]@{
        type       = 'AZSC/VM/Quotas'
        properties = @($quotas)
    }
}