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