Get-VNetSubnetAvailability.ps1

<#
.SYNOPSIS
Analyzes an Azure Virtual Network (VNet) to discover the first available subnet address blocks for requested CIDR sizes.
  
.DESCRIPTION
Get-VNetSubnetAvailability queries an Azure VNet (via Azure CLI) to:
    1. Gather the VNet's address space prefixes.
    2. Enumerate existing subnet prefixes (single and multi-addressPrefix forms).
    3. Compute free (unallocated) IP ranges within each VNet address space.
    4. For each requested CIDR size, find the first contiguous block that can be carved out.
If multiple VNet address space prefixes exist and no -VNetPrefix filter is supplied, a summary of the first available CIDR (in size order) across all prefixes is produced.
  
All IP math is performed using native .NET / PowerShell types (System.Net.IPAddress and bitwise operations). No external PowerShell modules are required beyond what ships with Windows PowerShell / PowerShell 7. Azure CLI (az) must be installed and authenticated.
  
.PARAMETER SubscriptionId
Azure Subscription ID containing the VNet.
  
.PARAMETER ResourceGroup
Resource group name where the target VNet resides.
  
.PARAMETER VNetName
Name of the target Virtual Network.
  
.PARAMETER Sizes
Array of CIDR sizes (string form: "/24","/25", etc.) to evaluate for availability.
Defaults to /23 through /28 if not specified. Order matters; first match per size is returned.
  
.PARAMETER VNetPrefix
Optional single VNet address space prefix (e.g. 10.10.0.0/16) to restrict calculations.
If omitted, all VNet address prefixes are scanned.
  
.PARAMETER ReturnJson
Switch. If supplied, function writes JSON (array of per-prefix results) instead of returning a PSCustomObject.
  
.OUTPUTS
When -ReturnJson is used:
    JSON string representing an array of objects:
        [
            {
                "VNetPrefix": "10.10.0.0/16",
                "Sizes": [
                    {
                        "Size": "/24",
                        "Prefix": 24,
                        "AvailableSubnets": { "CIDR": "10.10.5.0/24", "Start": "10.10.5.0", "End": "10.10.5.255" }
                    },
                    ...
                ]
            },
            ...
        ]
  
When -ReturnJson is NOT used:
    PSCustomObject with properties:
        Results : Collection of per-prefix availability detail.
        FirstAvailable : First size match across all prefixes (null if none or filter applied).
        VNetName : Provided VNet name.
        ResourceGroup : Provided resource group.
        SubscriptionId : Provided subscription ID.
        FilteredPrefixUsed : Boolean indicating whether -VNetPrefix was applied.
  
Each entry in Results.Sizes contains:
    Size : Original CIDR size string (e.g. "/26").
    Prefix : Numeric CIDR prefix length (e.g. 26).
    AvailableSubnets : Object with CIDR, Start, End of FIRST available subnet, or $null if none.
  
.EXAMPLES
# Basic usage with defaults (/23 - /28)
Get-VNetSubnetAvailability -SubscriptionId "00000000-0000-0000-0000-000000000000" `
    -ResourceGroup "rg-network" `
    -VNetName "vnet-hub"
  
# Restrict to a single VNet address space prefix and request specific sizes
Get-VNetSubnetAvailability -SubscriptionId "00000000-0000-0000-0000-000000000000" `
    -ResourceGroup "rg-network" `
    -VNetName "vnet-hub" `
    -VNetPrefix "10.20.0.0/16" `
    -Sizes "/24","/26","/27"
  
# Return JSON for downstream processing
Get-VNetSubnetAvailability -SubscriptionId "00000000-0000-0000-0000-000000000000" `
    -ResourceGroup "rg-network" `
    -VNetName "vnet-spoke" `
    -Sizes "/26","/27" `
    -ReturnJson
  
# Pipe JSON into jq or other tooling
Get-VNetSubnetAvailability -SubscriptionId "00000000-0000-0000-0000-000000000000" `
    -ResourceGroup "rg-network" `
    -VNetName "vnet-spoke" `
    -ReturnJson | jq '.[] | {prefix: .VNetPrefix, first26: (.Sizes[] | select(.Size == "/26") | .AvailableSubnets.CIDR)}'
  
.NOTES
Prerequisites:
    - Azure CLI (az) installed and authenticated (az login).
    - Caller must have permissions to read the VNet and its subnets.
  
Performance:
    - Designed for modest VNet sizes; very large numbers of subnets may increase scan time.
    - The algorithm scans free ranges sequentially; earliest (lowest address) qualifying block per size is returned.
  
Limitations:
    - Only IPv4 address spaces are supported.
    - Does not validate service-specific reserved IPs (e.g., excluding first/last usable host addresses). It reports full network ranges.
  
Error Handling:
    - Throws if the VNet cannot be retrieved or if a specified -VNetPrefix is not part of the VNet.
    - Writes progress and diagnostic messages to host output.
  
Future Enhancements (suggestions):
    - Add option to return ALL candidate subnets per size (not just the first).
    - Support exclusion lists (manual reservations).
    - Add IPv6 support.
    - Parallelize per-prefix scans if needed.
  
#>


function Get-VNetSubnetAvailability {
    param(
        [Parameter(Mandatory)][string] $SubscriptionId,
        [Parameter(Mandatory)][string] $ResourceGroup,
        [Parameter(Mandatory)][string] $VNetName,
        [string[]] $Sizes = @("/23", "/24", "/25", "/26", "/27", "/28"),
        [string] $VNetPrefix,
        [switch] $ReturnJson
    )
    Write-Host ('-'*75)
    ################################################################
    ## Signature
    $t = @'
    Package designed and managed by
    _ _
   / \ __ _ _ __ ___ (_)_ __
  / _ \ / _` | '_ ` _ \| | '__|
 / ___ \ (_| | | | | | | | |
/_/ \_\__,_|_| |_| |_|_|_|
                    Powershell package designed to
                    perform subnet carve-out from VNet.
   
               Contact-
               aammir.mirza@hotmail.com
'@


    Write-Host "`n$($t)"
    ################################################################
    Write-Host ('-'*75)
    Write-Host "[START] Subnet carve-out process initiated"
    Write-Host "[SUBSCRIPTION] Setting context to subscription: $SubscriptionId"
    az account set --subscription $SubscriptionId | Out-Null

    Write-Host "[VNET] Retrieving VNet address space for: $VNetName (RG: $ResourceGroup)"
    $VNetAddressSpace = az network vnet show `
        --resource-group $ResourceGroup `
        --name $VNetName `
        --query "addressSpace.addressPrefixes" `
        --output json | ConvertFrom-Json

    Write-Host "[VNET] Retrieving existing subnets from VNet: $VNetName"
    $SubnetData = az network vnet subnet list `
        --resource-group $ResourceGroup `
        --vnet-name $VNetName `
        --query "[].{Single:addressPrefix,Multi:addressPrefixes}" `
        --output json | ConvertFrom-Json

    Write-Host "[SUBNETS] Flattening subnet prefixes"
    $SubnetPrefixes = @()
    foreach ($sd in $SubnetData) {
        if ($sd.Single) { $SubnetPrefixes += $sd.Single }
        if ($sd.Multi) { $SubnetPrefixes += $sd.Multi }
    }

    if (-not $VNetAddressSpace) {
        Write-Host "[ERROR] Failed to retrieve VNet: $VNetName"
        throw "VNet lookup failed."
    }

    $VNetPrefixes = $VNetAddressSpace
    $UsedSubnets = $SubnetPrefixes

    Write-Host "[ADDRESS_SPACE] VNet prefixes detected: $($VNetPrefixes -join ', ')"
    if ($VNetPrefix) {
        Write-Host "[FILTER] Requested single VNet prefix filter: $VNetPrefix"
        if (-not ($VNetPrefixes -contains $VNetPrefix)) {
            Write-Host "[ERROR] Provided VNetPrefix '$VNetPrefix' not found."
            throw "Specified VNetPrefix '$VNetPrefix' not found in $($VNetPrefixes -join ', ')"
        }
        $EffectivePrefixes = @($VNetPrefix)
        Write-Host "[FILTER] Using filtered prefix list: $VNetPrefix"
    }
    else {
        $EffectivePrefixes = $VNetPrefixes
        Write-Host "[FILTER] No prefix filter provided; using all prefixes"
    }

    Write-Host "[EXISTING_SUBNETS] Listing existing subnet CIDRs:"
    $UsedSubnets | ForEach-Object { Write-Host " - $_" }

    # Local helpers
    function ConvertTo-IPRange {
        param([string] $CIDR)
        $split = $CIDR.Split('/')
        $ip = [System.Net.IPAddress]::Parse($split[0])
        $prefix = [int]$split[1]
        $ipBytes = $ip.GetAddressBytes(); [Array]::Reverse($ipBytes)
        $ipInt = [BitConverter]::ToUInt32($ipBytes, 0)
        $mask = ([uint32]::MaxValue -shl (32 - $prefix))
        $network = $ipInt -band $mask
        $broadcast = $network + ([uint32]::MaxValue -bxor $mask)
        $startBytes = [BitConverter]::GetBytes($network)
        $endBytes = [BitConverter]::GetBytes($broadcast)
        [Array]::Reverse($startBytes); [Array]::Reverse($endBytes)
        [PSCustomObject]@{
            CIDR   = $CIDR
            Start  = [System.Net.IPAddress]::new($startBytes).ToString()
            End    = [System.Net.IPAddress]::new($endBytes).ToString()
            Prefix = $prefix
        }
    }
    function Compare-IP {
        param([string]$IP1, [string]$IP2)
        $b1 = [System.Net.IPAddress]::Parse($IP1).GetAddressBytes()
        $b2 = [System.Net.IPAddress]::Parse($IP2).GetAddressBytes()
        [Array]::Reverse($b1); [Array]::Reverse($b2)
        $i1 = [BitConverter]::ToUInt32($b1, 0)
        $i2 = [BitConverter]::ToUInt32($b2, 0)
        $i1 - $i2
    }
    function Next-IP {
        param([string]$IP)
        $b = [System.Net.IPAddress]::Parse($IP).GetAddressBytes(); [Array]::Reverse($b)
        $i = [BitConverter]::ToUInt32($b, 0); $i++
        $nb = [BitConverter]::GetBytes($i); [Array]::Reverse($nb)
        [System.Net.IPAddress]::new($nb).ToString()
    }
    function Previous-IP {
        param([string]$IP)
        $b = [System.Net.IPAddress]::Parse($IP).GetAddressBytes(); [Array]::Reverse($b)
        $i = [BitConverter]::ToUInt32($b, 0); $i--
        $nb = [BitConverter]::GetBytes($i); [Array]::Reverse($nb)
        [System.Net.IPAddress]::new($nb).ToString()
    }
    function Get-FreeRanges {
        param([string] $VNetCIDR, [string[]] $UsedCIDRs)
        Write-Host "[RANGE_SCAN] Computing free ranges inside $VNetCIDR"
        $parent = ConvertTo-IPRange $VNetCIDR
        $used = $UsedCIDRs | Where-Object { $_ -like "$($VNetCIDR.Split('/')[0])*" } | ForEach-Object { ConvertTo-IPRange $_ }
        $used = $used | Sort-Object { [System.Net.IPAddress]::Parse($_.Start).GetAddressBytes() }
        $free = @(); $current = $parent.Start
        foreach ($u in $used) {
            if ((Compare-IP $current $u.Start) -lt 0) {
                $free += [PSCustomObject]@{ Start = $current; End = (Previous-IP $u.Start) }
            }
            $current = Next-IP $u.End
        }
        if ((Compare-IP $current $parent.End) -le 0) {
            $free += [PSCustomObject]@{ Start = $current; End = $parent.End }
        }
        Write-Host "[RANGE_SCAN] Free range count for $($VNetCIDR): $($free.Count)"
        $free
    }
    function Get-SubnetRange {
        param([string]$Start, [int]$Prefix)
        $ip = [System.Net.IPAddress]::Parse($Start)
        $bytes = $ip.GetAddressBytes(); [Array]::Reverse($bytes)
        $startInt = [BitConverter]::ToUInt32($bytes, 0)
        $blockSize = [math]::Pow(2, (32 - $Prefix))
        $endInt = $startInt + $blockSize - 1
        $startBytes = [BitConverter]::GetBytes([uint32]$startInt)
        $endBytes = [BitConverter]::GetBytes([uint32]$endInt)
        [Array]::Reverse($startBytes); [Array]::Reverse($endBytes)
        [PSCustomObject]@{
            CIDR  = "$Start/$Prefix"
            Start = [System.Net.IPAddress]::new($startBytes).ToString()
            End   = [System.Net.IPAddress]::new($endBytes).ToString()
        }
    }

    Write-Host "[PROCESS] Beginning availability scan across effective prefixes: $($EffectivePrefixes -join ', ')"
    $results = @()

    foreach ($vnetPrefix in $EffectivePrefixes) {
        ('-' * 75)
        Write-Host "[PREFIX] Processing VNet prefix: $vnetPrefix"
        $freeRanges = Get-FreeRanges -VNetCIDR $vnetPrefix -UsedCIDRs $UsedSubnets
        $sizeEntries = @()
        foreach ($size in $Sizes) {
            Write-Host "[SIZE] Evaluating first available subnet for size $size in $vnetPrefix"
            $prefix = [int]($size.TrimStart('/'))
            $available = @()
            foreach ($fr in $freeRanges) {
                Write-Host "[FREE_RANGE] Scan $($fr.Start) -> $($fr.End) for /$prefix"
                $cursor = $fr.Start
                while ($true) {
                    $subnet = Get-SubnetRange -Start $cursor -Prefix $prefix
                    if ((Compare-IP $subnet.End $fr.End) -le 0) {
                        $available += $subnet
                        $cursor = Next-IP $subnet.End
                    }
                    else { break }
                }
            }
            $chosen = $available | Select-Object -First 1
            if ($chosen) {
                Write-Host "[ALLOC_CANDIDATE] Found $($chosen.CIDR)"
            }
            else {
                Write-Host "[ALLOC_CANDIDATE] None for $size"
            }
            $sizeEntries += [PSCustomObject]@{
                Size             = $size
                Prefix           = $prefix
                AvailableSubnets = if ($chosen) { [PSCustomObject]@{ CIDR = $chosen.CIDR; Start = $chosen.Start; End = $chosen.End } } else { $null }
            }
        }
        $results += [PSCustomObject]@{ VNetPrefix = $vnetPrefix; Sizes = $sizeEntries }
        Write-Host ('-' * 75)
    }

    $firstSizeResult = $null
    if (-not $VNetPrefix -and $VNetPrefixes.Count -gt 1) {
        Write-Host "[SUMMARY] Computing first available size across all prefixes"
        foreach ($size in $Sizes) {
            foreach ($r in $results) {
                $entry = $r.Sizes | Where-Object { $_.Size -eq $size -and $_.AvailableSubnets }
                if ($entry) {
                    $candidate = $entry.AvailableSubnets
                    $firstSizeResult = [PSCustomObject]@{
                        Size       = $size
                        CIDR       = $candidate.CIDR
                        Start      = $candidate.Start
                        End        = $candidate.End
                        VNetPrefix = $r.VNetPrefix
                    }
                    Write-Host "[SUMMARY] First available: Size=$size CIDR=$($candidate.CIDR) Prefix=$($r.VNetPrefix)"
                    break
                }
            }
            if ($firstSizeResult) { break }
        }
        if (-not $firstSizeResult) { Write-Host "[SUMMARY] No available subnet found for any requested size." }
    }

    Write-Host "[RESULT] Raw allocation scan complete."
    if ($ReturnJson) {
        ($results | ConvertTo-Json -Depth 6)
    }
    else {
        # [PSCustomObject]@{
        # Results = $results
        # FirstAvailable = $firstSizeResult
        # VNetName = $VNetName
        # ResourceGroup = $ResourceGroup
        # SubscriptionId = $SubscriptionId
        # FilteredPrefixUsed = [bool]$VNetPrefix
        # }
        # Return simplified availability objects: one per VNet prefix
        $results | ForEach-Object {
            $avail = $_.Sizes | Where-Object { $_.AvailableSubnets }
            [PSCustomObject]@{
            Available       = [bool]$avail
            VNET_Prefix     = $_.VNetPrefix
            Vaibale_CIDR    = $avail.AvailableSubnets.CIDR
            Requested_Sizes = $Sizes
            }
        }
    }
}