Copy-AzSubnets.ps1

<#PSScriptInfo

.VERSION 1.2.4

.GUID 0ce538a5-e9c7-44e6-acac-13f306290b38

.AUTHOR groovy-sky

.COMPANYNAME groovy-sky

.COPYRIGHT groovy-sky

.TAGS Azure Network VirtualNetwork Subnet

.LICENSEURI

.PROJECTURI https://github.com/groovy-sky/az-ip

.ICONURI https://raw.githubusercontent.com/groovy-sky/az-ip/refs/heads/main/logo.png

.EXTERNALMODULEDEPENDENCIES Az.Network

.REQUIREDSCRIPTS

.EXTERNALSCRIPTDEPENDENCIES

.RELEASENOTES
Version 1.2.4 - Added proper subscription context handling before accessing resources
Version 1.2.3 - Fixed resource group name parsing from VNet ID
Version 1.2.2 - Fixed syntax errors and switched to Az.Network module
Version 1.2.1 - Fixed subnet object structure to match Azure Network API schema
Version 1.2.0 - Fixed subnet object structure and error handling

.PRIVATEDATA

#>


<#

.DESCRIPTION
This script allows to clone existing subnets for Azure Virtual Network. It requires a new address space using which it creates duplicates of subnets by size and name (with prefix 'n-').

#>
 

[CmdletBinding()]    
param (
    [Parameter(Mandatory = $true)][string]$vnet_id,  # Virtual Network ID
    [Parameter(Mandatory = $true)][string]$new_address_space,  # New Address Space
    [Parameter(Mandatory = $false)][string]$new_subnet_prefix = "n-"
)

# Import required module
Import-Module Az.Network -ErrorAction Stop

# Divides provided IP CIDR
function DivideSubnet {  
    param (  
        [Parameter(Mandatory)]  
        [string]$CIDR  
    )  
    Write-Verbose "Dividing CIDR: $CIDR into two smaller subnets"  
  
    # Split CIDR into IP and PrefixLength
    $parts = $CIDR -split '/'
    $IPAddress = [IPAddress]$parts[0]
    $PrefixLength = [int]$parts[1]
  
    # New PrefixLength for next subnets
    $NewPrefixLength = $PrefixLength + 1  
    if ($NewPrefixLength -gt 32) {  
        throw "Cannot divide the subnet further. New PrefixLength exceeds 32."  
    }  
  
    # Step size for each new subnet
    $StepSize = [math]::Pow(2, 32 - $NewPrefixLength)  
  
    # Convert IP to integer
    $IPAddressBytes = $IPAddress.GetAddressBytes()  
    [array]::Reverse($IPAddressBytes)  
    $IPAddressInt = [BitConverter]::ToUInt32($IPAddressBytes, 0)  
  
    # Generate the two subnets
    $SubnetsList = @()  
    for ($i = 0; $i -lt 2; $i++) {  
        $SubnetStart = $IPAddressInt + ($i * $StepSize)  
        $SubnetBytes = [BitConverter]::GetBytes([uint32]$SubnetStart)  
        [array]::Reverse($SubnetBytes) # Convert back to big-endian
        $SubnetIP = [IPAddress]::new($SubnetBytes)  
  
        $SubnetCIDR = "$SubnetIP/$NewPrefixLength"  
        $SubnetsList += $SubnetCIDR  
    }  
    return $SubnetsList  
}   

# Divides IP CIDR to specified IP mask
function DivideSubnetMultipleTimes {
    param (
        [Parameter(Mandatory)]
        [string]$CIDR,
        [Parameter(Mandatory)]
        [int]$DesiredMaskSize
    )
    Write-Verbose "Dividing CIDR: $CIDR to reach the desired mask size: $DesiredMaskSize"

    # Split CIDR into IP and PrefixLength
    $parts = $CIDR -split '/'
    $IPAddress = [IPAddress]$parts[0]
    $PrefixLength = [int]$parts[1]

    # Validate the desired mask size
    if ($DesiredMaskSize -le $PrefixLength) {
        throw "Desired mask size must be greater than the current prefix length."
    }

    if ($DesiredMaskSize -gt 32) {
        throw "Desired mask size exceeds 32."
    }

    # Initialize the current subnet list with the initial CIDR as a dynamic ArrayList
    $SubnetsList = [System.Collections.ArrayList]@($CIDR)

    # Iterate over the subnets and divide only when necessary
    $i = 0
    while ($i -lt $SubnetsList.Count) {
        $CurrentSubnet = $SubnetsList[$i]
        $subnetParts = $CurrentSubnet -split '/'
        $SubnetIP = $subnetParts[0]
        $SubnetPrefixLength = [int]$subnetParts[1]

        # If the current subnet's prefix length is smaller than the desired mask size, divide it
        if ($SubnetPrefixLength -lt $DesiredMaskSize) {
            $StepSize = [math]::Pow(2, 32 - ($SubnetPrefixLength + 1))

            # Convert the IP to an integer
            $SubnetBytes = ([IPAddress]$SubnetIP).GetAddressBytes()
            [array]::Reverse($SubnetBytes)
            $SubnetInt = [BitConverter]::ToUInt32($SubnetBytes, 0)

            # Generate two subnets
            $NewSubnets = @()
            for ($j = 0; $j -lt 2; $j++) {
                $NewSubnetStart = $SubnetInt + ($j * $StepSize)
                $NewSubnetBytes = [BitConverter]::GetBytes([uint32]$NewSubnetStart)
                [array]::Reverse($NewSubnetBytes) # Convert back to big-endian
                $NewSubnetIP = [IPAddress]::new($NewSubnetBytes)
                $NewSubnets += "$NewSubnetIP/$($SubnetPrefixLength + 1)"
            }

            # Replace the current subnet with the two new subnets
            $SubnetsList[$i] = $NewSubnets[0]
            $SubnetsList.Insert($i + 1, $NewSubnets[1])
        }
        # Move to the next subnet
        $i++
    }
    # Return the final list of subnets
    return $SubnetsList
} 

# Find available IP by specified IP mask
function findAvailableIPbyMask {  
    param (  
        [Parameter(Mandatory)]  
        [array]$IPs, # List of available CIDRs
        [Parameter(Mandatory)]  
        [int]$Mask   # Required mask size
    )  
    # Initialize variables
    $resultCIDR = $null  
    $updatedIPs = @()  
  
    # Iterate through the IPs to find a suitable CIDR
    foreach ($cidr in $IPs) {  
        # Extract the prefix length from the CIDR
        $prefixLength = [int](($cidr -split '/')[1])
          
        # If the prefix length matches the required mask, return the CIDR
        if ($prefixLength -eq $Mask) {  
            $resultCIDR = $cidr  
            $updatedIPs = @($IPs | Where-Object {$_ -ne $cidr})  
            return @($resultCIDR, $updatedIPs)  
        }  
          
        # If the prefix length is smaller (larger block), split the CIDR further
        if ($prefixLength -lt $Mask) {  
            $dividedSubnets = DivideSubnetMultipleTimes -CIDR $cidr -DesiredMaskSize $Mask  
            # Take the last subnet and update the IPs list
            $resultCIDR = $dividedSubnets[-1]  
            
            # Remove the selected subnet from the divided subnets
            $remainingDividedSubnets = @($dividedSubnets | Where-Object {$_ -ne $resultCIDR})  
            
            # Create updated IPs list: all IPs except the current one, plus the remaining divided subnets
            $updatedIPs = @($IPs | Where-Object {$_ -ne $cidr})
            if ($remainingDividedSubnets.Count -gt 0) {
                $updatedIPs += $remainingDividedSubnets  
            }
            
            return @($resultCIDR, $updatedIPs)  
        }  
    }  
    # If no suitable CIDR found, return null for resultCIDR but preserve the IPs list
    return @($null, $IPs)  
}  

# Check if one IP is a part of another
function Test-IPAddressInRange {  
    [CmdletBinding()]  
    param (  
        [Parameter(Mandatory)]  
        [string]$CIDR1,  
        [Parameter(Mandatory)]  
        [string]$CIDR2  
    )  
  
    Write-Verbose "Testing if CIDR $CIDR1 overlaps with CIDR $CIDR2"  
  
    # Helper function to convert IP address to uint32
    function ConvertTo-UInt32 {  
        param (  
            [IPAddress]$IPAddress  
        )  
  
        $bytes = $IPAddress.GetAddressBytes()  
        [array]::Reverse($bytes) # Convert to little-endian
        return [BitConverter]::ToUInt32($bytes, 0)  
    }  
  
    # Parse CIDRs
    $cidr1Parts = $CIDR1 -split '/'
    $cidr2Parts = $CIDR2 -split '/'
    
    $ip1 = [IPAddress]$cidr1Parts[0]
    $ip2 = [IPAddress]$cidr2Parts[0]
  
    $prefix1 = [int]$cidr1Parts[1]
    $prefix2 = [int]$cidr2Parts[1]
  
    # Convert IPs to UInt32
    $ip1UInt32 = ConvertTo-UInt32 -IPAddress $ip1  
    $ip2UInt32 = ConvertTo-UInt32 -IPAddress $ip2  
  
    # Calculate the range for each CIDR
    $totalHosts1 = [math]::Pow(2, 32 - $prefix1) - 1  
    $lastIP1UInt32 = $ip1UInt32 + [uint32]$totalHosts1  
  
    $totalHosts2 = [math]::Pow(2, 32 - $prefix2) - 1  
    $lastIP2UInt32 = $ip2UInt32 + [uint32]$totalHosts2  
  
    # Check if ranges overlap
    if (($ip1UInt32 -le $lastIP2UInt32 -and $ip1UInt32 -ge $ip2UInt32) -or  
        ($ip2UInt32 -le $lastIP1UInt32 -and $ip2UInt32 -ge $ip1UInt32)) {  
        return "overlap"
    } else {
        return "differ"
    }  
}  

function Sort-IPRanges {
    param (
        [Parameter(Mandatory)]
        [array]$IPs # Array of CIDRs (e.g., "10.0.0.0/24", "10.0.0.0/16")
    )

    # Sort the IPs by prefix length in ascending order (smaller prefix = larger range)
    $sortedRanges = $IPs | Sort-Object {
        # Extract the prefix length from the CIDR
        $cidrPrefix = ($_ -split '/')[1]
        [int]$cidrPrefix
    }

    # Reverse the order to prioritize the largest ranges first
    [array]::Reverse($sortedRanges)

    return $sortedRanges
}

# Main script logic
$available_ips = @($new_address_space)  

# Parse the VNet ID to get resource group and VNet name
# Format: /subscriptions/{subscription-id}/resourceGroups/{resource-group}/providers/Microsoft.Network/virtualNetworks/{vnet-name}
Write-Output "[INFO]: Parsing VNet ID: $vnet_id"

if ($vnet_id -match '^/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft.Network/virtualNetworks/([^/]+)$') {
    $subscriptionId = $Matches[1]
    $resourceGroupName = $Matches[2]
    $vnetName = $Matches[3]
    Write-Output "[INFO]: Extracted - Subscription: $subscriptionId, Resource Group: $resourceGroupName, VNet: $vnetName"
} else {
    Write-Error "Invalid VNet ID format. Expected format: /subscriptions/{subscription-id}/resourceGroups/{resource-group}/providers/Microsoft.Network/virtualNetworks/{vnet-name}"
    exit 1
}

# Check Azure connection and set correct subscription
Write-Output "[INFO]: Checking Azure connection..."
try {
    $currentContext = Get-AzContext -ErrorAction Stop
    if ($null -eq $currentContext) {
        Write-Error "No Azure context found. Please run Connect-AzAccount first."
        exit 1
    }
    
    Write-Output "[INFO]: Currently connected to Azure account: $($currentContext.Account.Id)"
    Write-Output "[INFO]: Current subscription: $($currentContext.Subscription.Name) ($($currentContext.Subscription.Id))"
    
    # Switch to the target subscription if different
    if ($currentContext.Subscription.Id -ne $subscriptionId) {
        Write-Output "[INFO]: Switching to target subscription $subscriptionId..."
        try {
            $null = Set-AzContext -SubscriptionId $subscriptionId -ErrorAction Stop
            $newContext = Get-AzContext
            Write-Output "[INFO]: Successfully switched to subscription: $($newContext.Subscription.Name) ($($newContext.Subscription.Id))"
        } catch {
            Write-Error "Failed to switch to subscription $subscriptionId. Error: $_"
            Write-Error "Please ensure you have access to this subscription."
            exit 1
        }
    } else {
        Write-Output "[INFO]: Already in the correct subscription context"
    }
} catch {
    Write-Error "Failed to get Azure context: $_"
    Write-Error "Please run Connect-AzAccount to authenticate to Azure."
    exit 1
}

# Retrieve the existing virtual network using Az cmdlet
Write-Output "[INFO]: Retrieving existing virtual network '$vnetName' in resource group '$resourceGroupName'"
try {
    $vnet = Get-AzVirtualNetwork -ResourceGroupName $resourceGroupName -Name $vnetName -ErrorAction Stop
    Write-Output "[INFO]: Successfully retrieved VNet with $($vnet.Subnets.Count) existing subnets"
    Write-Output "[INFO]: Current address spaces: $($vnet.AddressSpace.AddressPrefixes -join ', ')"
} catch {
    Write-Error "Failed to retrieve virtual network: $_"
    Write-Error "Please verify that:"
    Write-Error " - You are in the correct subscription: $subscriptionId"
    Write-Error " - Resource Group '$resourceGroupName' exists"
    Write-Error " - VNet '$vnetName' exists in the resource group"
    Write-Error " - You have appropriate permissions to read the VNet"
    
    # Try to list resource groups to help diagnose
    try {
        $rgs = Get-AzResourceGroup -ErrorAction Stop | Select-Object -ExpandProperty ResourceGroupName
        if ($rgs -contains $resourceGroupName) {
            Write-Output "[DEBUG]: Resource group '$resourceGroupName' exists in the subscription"
            # Try to list VNets in the resource group
            $vnets = Get-AzVirtualNetwork -ResourceGroupName $resourceGroupName -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name
            if ($vnets) {
                Write-Output "[DEBUG]: VNets in resource group: $($vnets -join ', ')"
            } else {
                Write-Output "[DEBUG]: No VNets found in resource group '$resourceGroupName'"
            }
        } else {
            Write-Output "[DEBUG]: Resource group '$resourceGroupName' not found. Available resource groups:"
            $rgs | ForEach-Object { Write-Output " - $_" }
        }
    } catch {
        Write-Verbose "Could not list resource groups for debugging"
    }
    
    exit 1
}

# Add the new address space to the virtual network if needed
$current_prefixes = $vnet.AddressSpace.AddressPrefixes
$new_prefixes = $current_prefixes + $new_address_space | Sort-Object | Get-Unique

if ($current_prefixes.Count -ne $new_prefixes.Count) {
    Write-Output "[INFO]: Adding new address space to the virtual network: $new_address_space"
    Write-Output "[INFO]: Current address spaces: $($current_prefixes -join ', ')"
    $vnet.AddressSpace.AddressPrefixes = $new_prefixes
    
    try {
        Write-Output "[INFO]: Updating VNet with new address space..."
        $null = Set-AzVirtualNetwork -VirtualNetwork $vnet -ErrorAction Stop
        # Re-retrieve the vnet to get the updated state
        $vnet = Get-AzVirtualNetwork -ResourceGroupName $resourceGroupName -Name $vnetName -ErrorAction Stop
        Write-Output "[INFO]: Successfully added new address space. New address spaces: $($vnet.AddressSpace.AddressPrefixes -join ', ')"
    } catch {
        Write-Error "Failed to add new address space: $_"
        exit 1
    }
} else {
    Write-Output "[INFO]: Address space $new_address_space already exists in the VNet"
}

# Process subnets
Write-Output "[INFO]: Processing subnets for the new address space"
$existing_subnets = @{}
$skipped_subnets = @()

foreach ($subnet in $vnet.Subnets) {
    $subnet_name = $subnet.Name
    $subnet_prefix = $null
    
    # Handle both AddressPrefix and AddressPrefixes
    if ($subnet.AddressPrefixes -and $subnet.AddressPrefixes.Count -gt 0) {  
        $subnet_prefix = $subnet.AddressPrefixes[0]  
    } elseif ($subnet.AddressPrefix) {  
        $subnet_prefix = $subnet.AddressPrefix  
    }
    
    if ([string]::IsNullOrEmpty($subnet_prefix)) {
        Write-Warning "Subnet $subnet_name has no address prefix, skipping"
        continue
    }
    
    Write-Output "[INFO]: Checking if $subnet_prefix is part of $new_address_space"

    if ((Test-IPAddressInRange -CIDR1 $new_address_space -CIDR2 $subnet_prefix) -eq "differ") {
        $existing_subnets[$subnet_name] = $subnet_prefix
        Write-Output "[INFO]: Subnet $subnet_name ($subnet_prefix) will be cloned to new address space"
    } else {
        # Store subnet to $skipped_subnets to check if a new IP have not been already allocated
        if ($subnet_name.StartsWith($new_subnet_prefix)) {
            $skipped_subnets += $subnet_name.Substring($new_subnet_prefix.Length)
        } else {
            $skipped_subnets += $subnet_name
        }
        Write-Output "[INFO]: Subnet $subnet_name ($subnet_prefix) already in new address space, skipping"
    }  
}

if ($existing_subnets.Count -eq 0) {
    Write-Output "[INFO]: No subnets to clone from existing address space"
    exit 0
}

# Generate new subnets
Write-Output "[INFO]: Generating new subnets for $($existing_subnets.Count) existing subnets"
$new_subnets = @{}

# Sort the existing_subnets hashtable by prefix length (smaller subnets first)
$sorted_existing_subnets = $existing_subnets.GetEnumerator() | Sort-Object {
    $prefixLength = ($_.Value -split '/')[1]
    [int]$prefixLength
}

# Process the sorted subnets
foreach ($entry in $sorted_existing_subnets) {
    $subnet_name = $entry.Key
    $subnet_prefix = $entry.Value

    if (-not ($skipped_subnets -contains $subnet_name)) {
        try {
            $mask = [int](($subnet_prefix -split '/')[1])
            Write-Verbose "Allocating subnet for $subnet_name with mask /$mask"
            
            $result = findAvailableIPbyMask -IPs $available_ips -Mask $mask
            $allocated_subnet = $result[0]
            $available_ips = $result[1]
            
            # Check if we successfully allocated a subnet
            if ($null -eq $allocated_subnet) {
                Write-Warning "Unable to allocate IP space for subnet $subnet_name with mask /$mask - insufficient space in new address range"
                continue
            }
            
            $new_subnet_name = $new_subnet_prefix + $subnet_name
            $new_subnets[$new_subnet_name] = $allocated_subnet
            Write-Output "[INFO]: Allocated $allocated_subnet for new subnet $new_subnet_name"
            
        } catch {
            Write-Error "Failed to allocate new IP for subnet $subnet_name. Exception: $_"
            continue
        }
    } else {
        Write-Output "[INFO]: Skipping subnet $subnet_name as it already exists in the new address space"
    }
}

if ($new_subnets.Count -eq 0) {
    Write-Output "[INFO]: No new subnets to add"
    exit 0
}

# Add new subnets to the virtual network
Write-Output "[INFO]: Adding $($new_subnets.Count) new subnets to the virtual network"

# Create subnet configurations and add them to VNet
$addedCount = 0
foreach ($subnet in $new_subnets.GetEnumerator()) {
    try {
        Write-Output "[INFO]: Creating subnet configuration for $($subnet.Key) with prefix $($subnet.Value)"
        $subnetConfig = New-AzVirtualNetworkSubnetConfig `
            -Name $subnet.Key `
            -AddressPrefix $subnet.Value `
            -ErrorAction Stop
        
        $vnet.Subnets.Add($subnetConfig)
        $addedCount++
    } catch {
        Write-Error "Failed to create subnet configuration for $($subnet.Key): $_"
    }
}

if ($addedCount -eq 0) {
    Write-Error "No subnets were successfully configured"
    exit 1
}

# Apply changes
Write-Output "[INFO]: Applying changes to the virtual network (adding $addedCount subnets)"
try {
    $null = Set-AzVirtualNetwork -VirtualNetwork $vnet -ErrorAction Stop
    Write-Output "[SUCCESS]: Virtual network updated successfully with $addedCount new subnets"
    
    # List the newly created subnets
    Write-Output "`n[SUCCESS]: The following subnets were created:"
    foreach ($subnet in $new_subnets.GetEnumerator()) {
        Write-Output " - $($subnet.Key): $($subnet.Value)"
    }
} catch {
    Write-Error "Failed to update virtual network: $_"
    if ($_.Exception.Message) {
        Write-Error "Error details: $($_.Exception.Message)"
    }
    exit 1
}