Private/ConvertTo-AACSkuAvailability.ps1

function ConvertTo-AACSkuAvailability {
    <#
    .SYNOPSIS
        Builds Get-AACSkuAvailability's result: one row per VM size, region
        and subscription - whether it can be used there, and why not - with
        the quota and zone tables behind it.
    .DESCRIPTION
        -Read is one entry per subscription and region: @{ SubscriptionId;
        SubscriptionName; Location; Skus (Microsoft.Compute/skus items);
        Usages (Microsoft.Compute/locations/usages items); ZoneMappings
        (availabilityZoneMappings of the subscription's locations) }.
 
        Each size's status, the first that applies:
          Restricted not offered to this subscription in the region
                           (a Location restriction: NotAvailableForSubscription)
          NotSupported -Service Aks: fewer than 2 vCPUs - AKS can't use it
          ZoneUnavailable -Zone asked: none of those zones can host it
          Partial -Zone asked: some of them can't
          NoQuota the family's or the region's free vCPUs are fewer
                           than vCPUs x -NodeCount
          Available none of the above
        Zones a size is offered in, less any Zone restriction for the
        subscription, are where it can run. Quota is judged only where
        Azure reports it.
 
        -Pool (AKS: @{ Name; Mode; VmSize; Count; Zones; OsType }) marks the
        sizes a cluster uses and returns its node pools with their size's
        status and free quota.
 
        Returns @{ Skus (AAC.SkuAvailability); Quotas (AAC.SkuQuota); Zones
        (AAC.SkuZone); Pools (AAC.AksNodePool); Notice; Stats }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [AllowEmptyCollection()]
        [object[]] $Read = @(),

        [ValidateSet('VirtualMachine', 'Aks')]
        [string] $Service = 'VirtualMachine',

        [string[]] $Zone = @(),

        [string[]] $Series = @(),

        [string[]] $Sku = @(),

        [string] $Architecture,

        [ValidateRange(1, 5000)]
        [int] $NodeCount = 1,

        [AllowEmptyCollection()]
        [object[]] $Pool = @(),

        [string] $PoolLocation
    )

    $value = { param($Object, [string] $Key) if ($Object -is [System.Collections.IDictionary]) { if ($Object.Contains($Key)) { $Object[$Key] } } else { Get-AACPropertyValue -InputObject $Object -Name $Key } }
    $text = { param($Object, [string] $Key) $v = & $value $Object $Key; if ($null -eq $v) { '' } else { [string]$v } }
    $flag = { param($Caps, [string] $Key) $Caps.Contains($Key) -and $Caps[$Key] -eq 'True' }
    $statusRank = @{ Available = 0; Partial = 1; NoQuota = 2; ZoneUnavailable = 3; NotSupported = 4; Restricted = 5 }
    $zones = @($Zone | Where-Object { $_ } | ForEach-Object { ([string]$_).Trim() } | Sort-Object -Unique)
    # A size asked for by name: as given (wildcards allowed), or without its tier prefix.
    $skuPatterns = @(foreach ($pattern in @($Sku | Where-Object { $_ })) { $pattern; if ($pattern -notmatch '^(?i)(Standard|Basic)_') { "Standard_$pattern" } })
    $poolsBySize = @{}
    foreach ($entry in $Pool) {
        $size = (& $text $entry 'VmSize').ToLowerInvariant()
        if (-not $poolsBySize.Contains($size)) { $poolsBySize[$size] = [System.Collections.Generic.List[string]]::new() }
        $poolsBySize[$size].Add((& $text $entry 'Name'))
    }

    $rows = [System.Collections.Generic.List[object]]::new()
    $quotas = [System.Collections.Generic.List[object]]::new()
    $zoneRows = [System.Collections.Generic.List[object]]::new()
    $notices = [System.Collections.Generic.List[string]]::new()
    $usageBy = @{}
    foreach ($region in $Read) {
        $location = ([string]$region.Location).ToLowerInvariant()
        $subscription = [string]$region.SubscriptionId
        $subscriptionName = if ($region.SubscriptionName) { [string]$region.SubscriptionName } else { $subscription }

        # --- Quota: each family's, and the region's, vCPUs --------------------------------------------------------
        $usage = @{}
        foreach ($item in @($region.Usages)) {
            $name = & $value $item 'name'
            $key = (& $text $name 'value').ToLowerInvariant()
            if (-not $key) { continue }
            $usage[$key] = @{ Name = $(if (& $text $name 'localizedValue') { & $text $name 'localizedValue' } else { $key }); Current = [long](& $value $item 'currentValue'); Limit = [long](& $value $item 'limit') }
        }
        $usageBy["$subscription|$location"] = $usage
        $regional = if ($usage.Contains('cores')) { $usage['cores'] } else { $null }
        if (-not $usage.Count) { $notices.Add("The vCPU quota in $location ($subscriptionName) couldn't be read, so it isn't checked.") }

        # --- Zones: logical to physical ---------------------------------------------------------------------------
        foreach ($mapping in @($region.ZoneMappings)) {
            $zoneRows.Add([pscustomobject][ordered]@{
                    PSTypeName       = 'AAC.SkuZone'
                    SubscriptionName = $subscriptionName
                    Location         = $location
                    LogicalZone      = & $text $mapping 'logicalZone'
                    PhysicalZone     = & $text $mapping 'physicalZone'
                    SubscriptionId   = $subscription
                })
        }

        # --- VM sizes ----------------------------------------------------------------------------------------
        $vmSkus = @($region.Skus | Where-Object { (& $text $_ 'resourceType') -eq 'virtualMachines' })
        if (-not $vmSkus.Count) { $notices.Add("No VM sizes were returned for '$location' in $subscriptionName - check the region's name (for example uksouth, not UK South).") }
        foreach ($item in $vmSkus) {
            $name = & $text $item 'name'
            $seriesName = if ($name -match '^(?i)(?:Standard|Basic)_([A-Za-z]+)') { $Matches[1].ToUpperInvariant() } else { '' }
            if ($Series.Count -and -not @($Series | Where-Object { $seriesName -like $_ }).Count) { continue }
            if ($skuPatterns.Count -and -not @($skuPatterns | Where-Object { $name -like $_ }).Count) { continue }
            $caps = @{}
            foreach ($capability in @(& $value $item 'capabilities')) { $caps[(& $text $capability 'name')] = & $text $capability 'value' }
            $arch = if ($caps.Contains('CpuArchitectureType')) { $caps['CpuArchitectureType'] } else { 'x64' }
            if ($Architecture -and $arch -ne $Architecture) { continue }
            $vCpus = if ($caps.Contains('vCPUs')) { [int]$caps['vCPUs'] } else { 0 }
            $memory = if ($caps.Contains('MemoryGB')) { [double]$caps['MemoryGB'] } else { 0 }
            $family = & $text $item 'family'

            # Offered zones, less the ones restricted for this subscription.
            $info = @(& $value $item 'locationInfo' | Where-Object { (& $text $_ 'location') -eq $location })
            $offered = @(foreach ($entry in $info) { @(& $value $entry 'zones') }) | Where-Object { $_ } | ForEach-Object { [string]$_ } | Sort-Object -Unique
            $offered = @($offered)
            $locationRestriction = $null
            $restrictedZones = [System.Collections.Generic.List[string]]::new()
            foreach ($restriction in @(& $value $item 'restrictions')) {
                $reasonCode = & $text $restriction 'reasonCode'
                if ((& $text $restriction 'type') -eq 'Location') {
                    if (@(& $value $restriction 'values' | Where-Object { ([string]$_).ToLowerInvariant() -eq $location }).Count -or -not @(& $value $restriction 'values').Count) { $locationRestriction = $(if ($reasonCode) { $reasonCode } else { 'restricted' }) }
                }
                elseif ((& $text $restriction 'type') -eq 'Zone') {
                    foreach ($z in @(& $value (& $value $restriction 'restrictionInfo') 'zones')) { if ($z) { $restrictedZones.Add([string]$z) } }
                }
            }
            $available = @($offered | Where-Object { $restrictedZones -notcontains $_ })
            $missing = @($zones | Where-Object { $available -notcontains $_ })

            # Quota for vCPUs x -NodeCount: the family's and the region's.
            $needed = $vCpus * $NodeCount
            $familyUsage = if ($family -and $usage.Contains($family.ToLowerInvariant())) { $usage[$family.ToLowerInvariant()] } else { $null }
            $familyFree = if ($familyUsage) { $familyUsage.Limit - $familyUsage.Current } else { $null }
            $regionalFree = if ($regional) { $regional.Limit - $regional.Current } else { $null }

            $reasons = [System.Collections.Generic.List[string]]::new()
            $status = 'Available'
            $isRestricted = $null -ne $locationRestriction
            if ($isRestricted) { $status = 'Restricted'; $reasons.Add("not offered to this subscription in $location ($locationRestriction)") }
            if ($Service -eq 'Aks' -and $vCpus -lt 2) {
                if ($status -eq 'Available') { $status = 'NotSupported' }
                $reasons.Add('AKS needs at least 2 vCPUs per node')
            }
            if (-not $isRestricted -and $zones.Count -and $missing.Count) {
                if ($status -eq 'Available') { $status = $(if ($missing.Count -eq $zones.Count) { 'ZoneUnavailable' } else { 'Partial' }) }
                $why = if (-not $offered.Count) { 'offered in no availability zone here' } else { "not in zone(s) $($missing -join ', ')$(if (@($missing | Where-Object { $restrictedZones -contains $_ }).Count) { ' (restricted for this subscription)' })" }
                $reasons.Add($why)
            }
            if (-not $isRestricted -and $needed) {
                $short = @(
                    if ($null -ne $familyFree -and $familyFree -lt $needed) { "family quota: $familyFree of $($familyUsage.Limit) vCPUs free, $needed needed" }
                    if ($null -ne $regionalFree -and $regionalFree -lt $needed) { "regional quota: $regionalFree of $($regional.Limit) vCPUs free, $needed needed" }
                )
                if ($short.Count) {
                    if ($status -eq 'Available') { $status = 'NoQuota' }
                    foreach ($line in $short) { $reasons.Add($line) }
                }
            }
            $notes = [System.Collections.Generic.List[string]]::new()
            if ($Service -eq 'Aks' -and $vCpus -ge 2) {
                if ($memory -lt 4) { $notes.Add('user node pools only: system node pools need 4 GB of memory') }
                if ($seriesName -eq 'B') { $notes.Add('burstable: not recommended for system node pools') }
            }
            $inUse = if ($poolsBySize.Contains($name.ToLowerInvariant())) { $poolsBySize[$name.ToLowerInvariant()] -join ', ' } else { '' }
            $rows.Add([pscustomobject][ordered]@{
                    PSTypeName            = 'AAC.SkuAvailability'
                    Service               = $Service
                    Location              = $location
                    Sku                   = $name
                    Status                = $status
                    Reason                = ($reasons -join '; ')
                    Series                = $seriesName
                    Version               = $(if ($name -match '(?i)_(v\d+)$') { $Matches[1].ToLowerInvariant() } else { '' })
                    Family                = $family
                    vCPUs                 = $vCpus
                    MemoryGB              = $memory
                    Zones                 = ($available -join ',')
                    ZonesMissing          = ($missing -join ',')
                    RestrictedZones       = (@($restrictedZones | Sort-Object -Unique) -join ',')
                    VCpuNeeded            = $needed
                    FamilyVCpuFree        = $familyFree
                    FamilyVCpuLimit       = $(if ($familyUsage) { $familyUsage.Limit } else { $null })
                    RegionalVCpuFree      = $regionalFree
                    Architecture          = $arch
                    EphemeralOSDisk       = (& $flag $caps 'EphemeralOSDiskSupported')
                    AcceleratedNetworking = (& $flag $caps 'AcceleratedNetworkingEnabled')
                    PremiumStorage        = (& $flag $caps 'PremiumIO')
                    Spot                  = (& $flag $caps 'LowPriorityCapable')
                    GPUs                  = $(if ($caps.Contains('GPUs')) { [int]$caps['GPUs'] } else { 0 })
                    MaxDataDisks          = $(if ($caps.Contains('MaxDataDiskCount')) { [int]$caps['MaxDataDiskCount'] } else { 0 })
                    HyperVGenerations     = $(if ($caps.Contains('HyperVGenerations')) { $caps['HyperVGenerations'] } else { '' })
                    Note                  = ($notes -join '; ')
                    InUse                 = $inUse
                    SubscriptionName      = $subscriptionName
                    SubscriptionId        = $subscription
                })
        }

        # --- Quota rows: the region's total and each family these sizes use ---------------------------------------
        $families = @($rows | Where-Object { $_.SubscriptionId -eq $subscription -and $_.Location -eq $location -and $_.Family } | ForEach-Object { $_.Family.ToLowerInvariant() } | Sort-Object -Unique)
        foreach ($key in @(@('cores') + $families)) {
            if (-not $usage.Contains($key)) { continue }
            $entry = $usage[$key]
            $quotas.Add([pscustomobject][ordered]@{
                    PSTypeName       = 'AAC.SkuQuota'
                    SubscriptionName = $subscriptionName
                    Location         = $location
                    Quota            = $(if ($key -eq 'cores') { 'Total Regional vCPUs' } else { $entry.Name })
                    Used             = $entry.Current
                    Limit            = $entry.Limit
                    Free             = $entry.Limit - $entry.Current
                    UsedPercent      = $(if ($entry.Limit -gt 0) { [Math]::Round(100 * $entry.Current / $entry.Limit) } else { $null })
                    Family           = $(if ($key -eq 'cores') { '' } else { $key })
                    SubscriptionId   = $subscription
                })
        }
    }

    $skus = @($rows | Sort-Object -Property @{ Expression = { $statusRank[$_.Status] } }, Location, SubscriptionName, Series, vCPUs, Sku)
    $quotaRows = @($quotas | Sort-Object -Property SubscriptionName, Location, @{ Expression = { if ($_.Family) { 1 } else { 0 } } }, @{ Expression = { if ($null -eq $_.UsedPercent) { -1 } else { $_.UsedPercent } }; Descending = $true }, Quota)

    # --- AKS node pools: their size's status, and room to add a node --------------------------------------
    $pools = @(foreach ($entry in $Pool) {
            $size = & $text $entry 'VmSize'
            $match = @($skus | Where-Object { $_.Sku -eq $size -and (-not $PoolLocation -or $_.Location -eq $PoolLocation.ToLowerInvariant()) } | Select-Object -First 1)
            [pscustomobject][ordered]@{
                PSTypeName     = 'AAC.AksNodePool'
                Pool           = & $text $entry 'Name'
                Mode           = & $text $entry 'Mode'
                VmSize         = $size
                Nodes          = [int](& $value $entry 'Count')
                Zones          = (@(& $value $entry 'Zones' | Where-Object { $_ }) -join ',')
                OsType         = & $text $entry 'OsType'
                SizeStatus     = $(if ($match.Count) { $match[0].Status } else { 'NotChecked' })
                FamilyVCpuFree = $(if ($match.Count) { $match[0].FamilyVCpuFree } else { $null })
                Reason         = $(if ($match.Count) { $match[0].Reason } else { 'this size isn''t in the sizes checked (-Series or -Sku leave it out)' })
            }
        })

    $byStatus = @{}
    foreach ($name in $statusRank.Keys) { $byStatus[$name] = @($skus | Where-Object Status -EQ $name).Count }
    @{
        Skus   = $skus
        Quotas = $quotaRows
        Zones  = @($zoneRows)
        Pools  = $pools
        Notice = $notices.ToArray()
        Stats  = @{
            Sizes           = $skus.Count
            Available       = $byStatus.Available
            Partial         = $byStatus.Partial
            NoQuota         = $byStatus.NoQuota
            ZoneUnavailable = $byStatus.ZoneUnavailable
            NotSupported    = $byStatus.NotSupported
            Restricted      = $byStatus.Restricted
            Usable          = $byStatus.Available + $byStatus.Partial
            Series          = @($skus | Where-Object Series | ForEach-Object Series | Select-Object -Unique).Count
            Locations       = @($Read | ForEach-Object { ([string]$_.Location).ToLowerInvariant() } | Select-Object -Unique).Count
            Subscriptions   = @($Read | ForEach-Object { [string]$_.SubscriptionId } | Select-Object -Unique).Count
            QuotaNearLimit  = @($quotaRows | Where-Object { $null -ne $_.UsedPercent -and $_.UsedPercent -ge 80 }).Count
            ByStatus        = @(foreach ($name in 'Available', 'Partial', 'NoQuota', 'ZoneUnavailable', 'NotSupported', 'Restricted') { if ($byStatus[$name]) { [pscustomobject]@{ Label = $name; Value = $byStatus[$name] } } })
        }
    }
}