AzureAvSetBasicPublicIPUpgrade.psm1
Function Start-AzAvSetPublicIPUpgrade { <# .SYNOPSIS Upgrades all public IP addresses attached to VMs in an Availability Set to Standard SKU. .DESCRIPTION This script upgrades the Public IP Addresses attached to VMs in an Availability Set to Standard SKU. In order to perform the upgrade, the Public IP Address allocation method is set to static before being disassociated from the VM. Once disassociated, the Public IP SKU is upgraded to Standard, then the IP is reassociated with the VM. Because the Public IP allocation is set to 'Static' before detaching from the VM, the IP address will not change during the upgrade process, even in the event of a script failure. Because Standard SKU Public IPs require an associated Network Security Group, the script will prompt to proceed if a VM is processed where both the NIC and subnet do not have an NSG associated with them. If the script is run with the '-ignoreMissingNSG' parameter, the script will not prompt and will continue with the upgrade process; if -skipVMMissingNSG is specified, the script will skip upgrading that VM. Recovering from a failure: The script exports the Public IP address and IP configuration associations to a CSV file before beginning the upgrade process. In the event of a failure during the upgrade, this file can be used to retry the migration and attach public IPs with the appropriate IP configuration. To initate a recovery, follow these steps: 1. Review the log 'PublicIPUpgrade.log' file to determine which VM was in process during the failure 2. Determine if the script failed due to a configuration issue that needs to be addressed before retrying the migration. If so, address the error. 2. Get the name and resource group or full ID of the Av Set to recover (e.g. '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myRG/providers/Microsoft.Compute/availabilitySets/avset-01') 3. Execute the script with the following syntax: ./Start-AzAvSetPublicIPUpgrade.ps1 -RecoverFromFile ./PublicIPUpgrade_Recovery_2020-01-01-00-00.csv -AvailiabilitySetName avset-01 -ResourceGroupName rg-01 4. The script will attempt to re-execute each step the migration. .NOTES PREREQUISITES: - VMs must not be associated with a Load Balancer to use this script. - VM NICs or associated subnets should have an NSG associated with them. If the VM NIC or subnet does not have an NSG associated with it, the script will prompt. .LINK https://github.com/Azure/AzLoadBalancerMigration Please report issues at https://github.com/Azure/AzLoadBalancerMigration/issues .EXAMPLE Start-AzAvSetPublicIPUpgrade -AvailabilitySetName 'myAvSet' -ResourceGroupName 'myRG' # Upgrade a single Av Set, passing the VM name and resource group name as parameters. .EXAMPLE Start-AzAvSetPublicIPUpgrade -AvailabilitySetName 'myAvSet' -ResourceGroupName 'myRG' -WhatIf # Evaluate upgrading a single Av Set, without making any changes .EXAMPLE Get-AzAvailabilitySet -ResourceGroupName 'myRG' | Start-AzAvSetPublicIPUpgrade -skipVMMissingNSG # Attempt upgrade of every AV Set the user has access to. VMs without Public IPs, which are already upgraded, or which do not have NSGs will be skipped. .EXAMPLE Start-AzAvSetPublicIPUpgrade -RecoverFromFile ./PublicIPUpgrade_Recovery_2020-01-01-00-00.csv -AvailabilitySetName myAvSet -ResourceGroup rg-myrg # Recover from a failed migration, passing the name and resource group of the VM to recover, along with the recovery log file. .EXAMPLE $AvSets = Get-AzAvailabilitySet -ResourceGroupName rg-*-prod ForEach ($avset in $AvSets) { Start-Job -Name $avset.Name -ScriptBlock { $params = @{ availabilitySetName = $args[0].Name resourceGroupName = $args[0].ResourceGroupName logFilePath = '{0}{1}' -f $args[0].Name,'name_PublicIPUpgrade.log' recoveryLogFilePath = '{0}{1}' -f $args[0].Name,'name_PublicIPUpgrade_recovery.csv' } Start-AvSetPublicIPUpgrade.ps1 @params -WhatIf } -ArgumentList $avset -InitializationScript {Import-Module Az.Accounts, Az.Compute, Az.Network, Az.Resources} } # Upgrade all AvSetss in Resource Groups with '-prod' in the name, using PowerShell jobs to run the script in parallel. #> param ( # av set name [Parameter(Mandatory = $true, ParameterSetName = 'AvailabilitySetName')] [Parameter(Mandatory = $true, ParameterSetName = 'Recovery-ByName')] [string] $availabilitySetName, # av set resource group name [Parameter(Mandatory = $true, ParameterSetName = 'AvailabilitySetName')] [Parameter(Mandatory = $true, ParameterSetName = 'Recovery-ByName')] [string] $resourceGroupName, # av set object [Parameter(Mandatory = $true, ParameterSetName = 'AVSetObject', ValueFromPipeline = $true)] [Microsoft.Azure.Commands.Compute.Models.PSAvailabilitySet] $availabilitySet, # av set resource id [Parameter(Mandatory = $true, ParameterSetName = 'AVSetResourceID')] [Parameter(Mandatory = $true, ParameterSetName = 'Recovery-ById')] [string] $availabilitySetResourceId, # recovery file path [Parameter(Mandatory = $true, ParameterSetName = 'Recovery-ById')] [Parameter(Mandatory = $true, ParameterSetName = 'Recovery-ByName')] [string] $recoverFromFile, # recovery log file path - log Public IP address and IP configuration associations for recovery purposes [Parameter(Mandatory = $false)] [string] $recoveryLogFilePath = "PublicIPUpgrade_Recovery_$(Get-Date -Format 'yyyy-MM-dd-HH-mm').csv", # log file path [Parameter(Mandatory = $false)] [string] $logFilePath = "PublicIPUpgrade.log", # skip check for NSG association, migrate anyway - Basic Public IPs allow inbound traffic without an NSG, but Standard Public IPs require an NSG. Migrating without an NSG will break inbound traffic flows! [Parameter(Mandatory = $false)] [switch] $ignoreMissingNSG, # skip VMs missing NSGs - if a VM is missing an NSG, skip migrating it [Parameter(Mandatory = $false)] [switch] $skipVMMissingNSG, # prompt for confirmation to migrate IPs [Parameter(Mandatory = $false)] [boolean] $confirm = $true, # whatif [Parameter(Mandatory = $false)] [switch] $WhatIf ) BEGIN { Function Add-LogEntry { param ( [parameter(Position = 0, Mandatory = $true)]$message, [parameter(Position = 1, Mandatory = $false)][ValidateSet('INFO', 'WARNING', 'ERROR')]$severity = 'INFO' ) # add timestamp to message, output to log and host $timestamp = Get-Date -Format 'yyyy-MM-ddTHH:mm:sszz' switch ($severity) { 'INFO' { "[{0}][{1}] {2}" -f $timestamp, $severity, $message | Tee-Object -FilePath $logFilePath -Append | Write-Host } 'WARNING' { "[{0}][{1}] {2}" -f $timestamp, $severity, $message | Tee-Object -FilePath $logFilePath -Append | Write-Warning } 'ERROR' { "[{0}][{1}] {2}" -f $timestamp, $severity, $message | Tee-Object -FilePath $logFilePath -Append | Write-Error } } } # set error action preference If ($WhatIf) { $ErrorActionPreference = 'Continue' } Else { $ErrorActionPreference = 'Stop' } Add-LogEntry "####### Starting Availability Set Public IP Upgrade process... #######" # prompt to continue if -Confirm is $false or -WhatIf are not specified If (!$WhatIf -and $confirm) { While ($promptResponse -notmatch '[yYnN]') { $promptResponse = Read-Host "This script will upgrade all public IP addresses attached to all VMs in the specificed Availability Set(s) to Standard SKU. This will cause a brief interruption to network connectivity. Do you want to continue? (y/n)" } If ($promptResponse -match '[nN]') { Add-LogEntry "Exiting script..." -severity WARNING return } Else { Add-LogEntry "Continuing with script..." } } # initalize recovery log file header Add-Content -Path $recoveryLogFilePath -Value 'publicIPAddress,publicIPID,ipConfigId,VMId,availabilitySetResourceId' -Force Add-LogEntry "Creating recovery log file at '$($recoveryLogFilePath)'" } PROCESS { # get avset object, depending on parameters passed If ($PSCmdlet.ParameterSetName -in 'AvailabilitySetName', 'Recovery-ByName') { Add-LogEntry "Getting Availability Set '$($AvailabilitySetName)' in resource group '$($resourceGroupName)'..." $AvSet = Get-AzAvailabilitySet -Name $availabilitySetName -ResourceGroupName $resourceGroupName } ElseIf ($PSCmdlet.ParameterSetName -in 'AVSetResourceID', 'Recovery-ById') { Add-LogEntry "Getting Availability Set with resource ID '$($availabilitySetResourceId)'..." $AvSet = Get-AzResource -ResourceId $availabilitySetResourceId | Get-AzAvailabilitySet } Add-LogEntry "Processing Availability Set '$($AvSet.Name)', id: $($AvSet.Id)..." # validate scenario # get all VMs in the availability set # check that availabilit set has vms If ($AvSet.VirtualMachinesReferences.count -lt 1) { Add-LogEntry "Availability Set '$($AvSet.Name)' does not have any VMs. Skipping upgrade." -severity WARNING return } $VMs = @() Add-LogEntry "Getting all VMs in Availability Set '$($AvSet.Name)'..." # create array of VM objects $avSet.VirtualMachinesReferences.Id | ForEach-Object { $VM = $_ | Get-AzVM $VMs += @{VM = $VM; vmNICs = @(); publicIPs = @(); publicIPIPConfigAssociations = @() } } If ($PSCmdlet.ParameterSetName -notin 'Recovery-ByName', 'Recovery-ById') { ForEach ($VM in $VMs) { Add-LogEntry "Validating VM '$($VM.VM.Name)' in Availability Set '$($AvSet.Name)'..." # confirm VM has public IPs attached, build dictionary of public IPs and ip configurations Add-LogEntry "Checking that VM '$($VM.VM.Name)' has public IP addresses attached..." ## get NICs with public IPs attached $VM.vmNICs = $VM.VM.NetworkProfile.NetworkInterfaces | Get-AzResource | Get-AzNetworkInterface | Where-Object { $_.IpConfigurations.PublicIPAddress } ## build ipconfig/public IP table $publicIPIDs = @() $VM.publicIPIPConfigAssociations = @() ForEach ($ipConfig in $VM.vmNICs.IpConfigurations) { If ($ipConfig.PublicIPAddress) { $publicIPIDs += $ipConfig.PublicIPAddress.id $VM.publicIPIPConfigAssociations += @{ publicIPId = $ipConfig.PublicIPAddress.id ipConfig = $ipConfig publicIP = '' publicIPAddress = '' } } } If ($VM.publicIPIPConfigAssociations.count -lt 1) { Add-LogEntry "VM '$($VM.VM.Name)' does not have any public IP addresses attached. Skipping upgrade." -severity WARNING return } Else { Add-LogEntry "VM '$($VM.VM.Name)' has $($VM.publicIPIPConfigAssociations.count) public IP addresses attached." } # confirm public IPs are Basic SKU (VM should only have one SKU) Add-LogEntry "Checking that VM '$($VM.VM.Name)' has Basic SKU public IP addresses..." $VM.publicIPs = $publicIPIDs | ForEach-Object { Get-AzResource -ResourceId $_ | Get-AzPublicIpAddress } If (( $publicIPSKUs = $VM.publicIPs.Sku.Name | Get-Unique) -ne @('Basic')) { Add-LogEntry "Public IP address SKUs for VM '$($VM.VM.Name)' are not Basic. SKUs are: '$($publicIPSKUs -join ',')'. Skipping upgrade." WARNING return } Else { Add-LogEntry "Public IP address SKUs for VM '$($VM.VM.Name)' are Basic." } # confirm VM is not associated with a load balancer Add-LogEntry "Checking that VM '$($VM.VM.Name)' is not associated with a load balancer..." If ($VM.VMNICs.IpConfigurations.LoadBalancerBackendAddressPools -or $VM.VMNICs.IpConfigurations.LoadBalancerInboundNatRules) { Add-LogEntry "VM '$($VM.VM.Name)' is associated with a load balancer. The Load Balancer cannot be a different SKU from the VM's Public IP address(s) and must be upgraded simultaneously. See: https://learn.microsoft.com/azure/load-balancer/load-balancer-basic-upgrade-guidance" ERROR return } Else { Add-LogEntry "VM '$($VM.VM.Name)' is not associated with a load balancer." } # confirm that each NIC with a public IP address associated has a Network Security Group Add-LogEntry "Checking that VM '$($VM.VM.Name)' has a Network Security Group associated with each NIC..." ## build hash of subnets associated with VM NICs $VMNICSubnets = @{} ForEach ($nic in $VM.vmNICs) { ForEach ($subnetId in $nic.IpConfigurations.Subnet.id) { $subnet = Get-AzResource -ResourceId $subnetId | Get-AzVirtualNetworkSubnetConfig $VMNICSubnets[$subnet.id] = $subnet } } ## check that each NIC or all subnets have NSGs associated $nicsMissingNSGs = 0 $ipConfigNSGReport = @() ForEach ($vmNIC in $VM.vmNICs) { $ipconfigSubnetsWithoutNSGs = 0 $ipconfigSubnetNSGs = @() ForEach ($ipconfig in $VM.vmNIC.IpConfigurations) { If ($VMNICSubnets[$ipconfig.Subnet.id].NetworkSecurityGroup) { $ipconfigSubnetNSGs += @{ ipConfigId = $ipconfig.id subnetId = $ipconfig.Subnet.Id subnetHasNSG = $true subnetNSGID = $VMNICSubnets[$ipconfig.Subnet.id].NetworkSecurityGroup.id nicHasNSG = $null -ne $vmNIC.NetworkSecurityGroup nicNSGId = $VM.vmNIC.NetworkSecurityGroup.id } } Else { $ipconfigSubnetsWithoutNSGs++ $ipconfigSubnetNSGs += @{ ipConfigId = $ipconfig.id subnetId = $ipconfig.Subnet.Id subnetHasNSG = $false subnetNSGId = '' nicHasNSG = $null -ne $VM.vmNIC.NetworkSecurityGroup nicNSGId = $VM.vmNIC.NetworkSecurityGroup.id } } } If ($ipconfigSubnetsWithoutNSGs -gt 0 -and !$VM.vmNIC.NetworkSecurityGroup) { $ipCOnfigNSGReport += $ipconfigSubnetNSGs $nicsMissingNSGs++ } } If ($nicsMissingNSGs -gt 0) { Add-LogEntry "VM '$($VM.VM.Name)' has associated Public IP Addresses, but IP Configurations where neither the NIC nor Subnet have an associated Network Security Group. Standard SKU Public IPs are secure by default, meaning no inbound traffic is allowed unless an NSG explicitly permits it, whereas a Basic SKU Public IP allows all traffic by default. See: https://learn.microsoft.com/en-us/azure/virtual-network/ip-services/public-ip-addresses#sku." WARNING Add-LogEntry "IP Configs Missing NGSs Report: $($ipConfigNSGReport | ConvertTo-Json -Depth 3)" WARNING While ($promptResponse -notmatch '[yYnN]' -and !$ignoreMissingNSG -and !$skipVMMissingNSG) { $promptResponse = Read-Host "Do you want to proceed with upgrading this VM's Public IP address without an NSG? (y/n)" } If ($promptResponse -match '[nN]' -or $skipVMMissingNSG) { Add-LogEntry "Skipping migrating this VM due to missing NSG..." -severity WARNING return } ElseIf ($ignoreMissingNSG) { Add-LogEntry "Skipping NSG check because -ignoreMissingNSG was specified" WARNING } Else { Add-LogEntry "Continuing with script..." } } Else { Add-LogEntry "VM '$($VM.VM.Name)' has a Network Security Group associated with each NIC or subnet." } } } Else { ### Failed Migration Recovery ### # import recovery info Add-LogEntry "Importing recovery file for Availability Set '$($AvSet.Name)' from file '$($recoverFromFile)'" $recoveryInfo = Import-Csv -path $recoverFromFile | Where-Object { $_.availabilitySetResourceId -eq $AvSet.Id } $VMs = @() ForEach ($vmRecoveryItem in ($recoveryInfo | Group-Object -Property VMId).group) { Add-LogEntry "Building recovery objects for VM '$($vmRecoveryItem.VMId.split('/')[-1])' based on recovery file '$($recoverFromFile)'..." $VM = @{ VM = Get-AzVM -ResourceId $vmRecoveryItem.VMId publicIPIDs = $vmRecoveryItem.PublicIPID vmNICs = @() vmNICsById = @{} publicIPIPConfigAssociations = @() } # rebuild migration objects from recovery to retry $vmRecoveryItem.ipConfigId | ForEach-Object { ($_ -split '/ipConfigurations/')[0] } | Select-Object -Unique | ForEach-Object { $_ | Get-AzNetworkInterface } | ForEach-Object { $VM.vmNICs += $_ $VM.vmNICsById[$_.id] = $_ } ForEach ($recoveryItem in $vmRecoveryItem) { $ipConfigSplit = $recoveryItem.ipConfigId -split '/ipConfigurations/' $publicIPIDs += $ipConfig.PublicIPAddress.id $VM.publicIPIPConfigAssociations += @{ publicIPId = $recoveryItem.publicIPID ipConfig = Get-AzNetworkInterfaceIpConfig -NetworkInterface $VM.vmNICsById[$ipConfigSplit[0]] -Name $ipConfigSplit[1] publicIP = Get-AzResource -ResourceId $recoveryItem.publicIPID | Get-AzPublicIpAddress publicIPAddress = $recoveryItem.publicIPAddress } } $VM.publicIPs = $VM.publicIPIPConfigAssociations.publicIP $VMs += $VM } } # start prepare upgrade process Add-LogEntry "####### Starting prepare upgrade process... #######" ForEach ($VM in $VMs) { Add-LogEntry "Backing up config for VM '$($VM.VM.Name)' to '$recoveryLogFilePath'.." # export recovery data and add public ip object to association object ForEach ($publicIP in $VM.publicIPs) { $VM.publicIPIPConfigAssociations | Where-Object { $_.publicIPId -eq $publicIP.id } | ForEach-Object { $_.publicIPAddress = $VM.publicIP.IpAddress $_.publicIP = $publicIP Add-Content -Path $recoveryLogFilePath -Value ('{0},{1},{2},{3},{4}' -f $publicIP.IPAddress, $_.publicIPId, $_.ipConfig.id, $VM.VM.Id, $AvSet.id) -Force } } } ForEach ($VM in $VMs) { Add-LogEntry "--> Preparing VM '$($VM.VM.Name)' for upgrade..." try { # set all public IPs to static assignment Add-LogEntry "Setting all public IP addresses to static assignment..." ForEach ($publicIP in $VM.publicIPIPConfigAssociations.publicIP) { Add-LogEntry "Setting public IP address '$($publicIP.Name)' ('$($publicIP.IpAddress)') to static assignment..." $publicIP.PublicIpAllocationMethod = 'Static' If (!$WhatIf) { $publicIP = Set-AzPublicIpAddress -PublicIpAddress $publicIP } Else { Add-LogEntry "WhatIf: Set-AzPublicIpAddress -PublicIpAddress $($publicIP.id)" } } # disassociate all public IPs from the VM Add-LogEntry "Disassociating all public IP addresses from the VM..." Foreach ($nic in $VM.vmNICs) { ForEach ($ipConfig in $nic.IpConfigurations | Where-Object { $_.PublicIPAddress }) { Add-LogEntry "Confirming that Public IP allocation is 'static' before disassociating..." If ((Get-AzResource -ResourceId $ipConfig.PublicIpAddress.Id | Get-AzPublicIpAddress).PublicIpAllocationMethod -ne 'Static') { If (!$WhatIf) { Write-Error "Public IP address '$($ipConfig.PublicIpAddress.Id)' is not set to static allocation! Script will exit to ensure that the VM's public IP addresses are not lost." return } Else { Add-LogEntry "WhatIf: Public IP address '$($ipConfig.PublicIpAddress.Id)' not changed from 'Dynamic' in WhatIf mode." } } If (!$WhatIf) { Add-LogEntry "Disassociating public IP address '$($ipConfig.PublicIpAddress.Id)' from VM '$($VM.VM.Name)', NIC '$($nic.Name)'..." Set-AzNetworkInterfaceIpConfig -NetworkInterface $nic -Name $ipConfig.Name -PublicIpAddress $null | Out-Null } Else { Add-LogEntry "WhatIf: Disassociating public IP address '$($ipConfig.PublicIpAddress.Id)' from VM '$($VM.VM.Name)', NIC '$($nic.Name)'..." } } Add-LogEntry "Applying updates to the NIC '$($nic.Name)'..." If (!$WhatIf) { $nic | Set-AzNetworkInterface | Out-Null } Else { Add-LogEntry "WhatIf: Updating NIC with: `$nic | Set-AzNetworkInterface" } } } catch { Write-Error "An error occurred during the prepare upgrade process. $_" } } #start upgrade process Add-LogEntry "####### Starting upgrade process... #######" ForEach ($VM in $VMs) { Add-LogEntry "--> Starting upgrade process for VM '$($VM.VM.Name)'..." try { # upgrade all public IP addresses Add-LogEntry "Upgrading all public IP addresses to Standard SKU..." ForEach ($publicIP in $VM.publicIPIPConfigAssociations.publicIP) { Add-LogEntry "Upgrading public IP address '$($publicIP.Name)' to Standard SKU..." $publicIP.Sku.Name = 'Standard' If (!$WhatIf) { Set-AzPublicIpAddress -PublicIpAddress $publicIP | Out-Null } Else { Add-LogEntry "WhatIf: Set-AzPublicIpAddress -PublicIpAddress $($_.id)" } } } catch { Write-Error "An error occurred during the upgrade process. We will try to reassociate all IPs with the VM: $_" } finally { # always reassociate all public IPs to the VM Add-LogEntry "Reassociating all public IP addresses to the VM..." try { Foreach ($nic in $VM.vmNICs) { Add-LogEntry "Reassociating public IP addresses to VM '$($VM.VM.Name)', NIC '$($nic.Name)'..." ForEach ($association in ($VM.publicIPIPConfigAssociations | Where-Object { $_.ipconfig.Id -like "$($nic.Id)/*" })) { Add-LogEntry "Reassociating public IP address '$($association.publicIPId)' to VM '$($VM.VM.Name)', NIC '$($nic.Name)', IpConfig '$($association.ipconfig.Name)'..." Set-AzNetworkInterfaceIpConfig -NetworkInterface $nic -Name $association.ipConfig.Name -PublicIpAddress $association.publicIP | Out-Null } Add-LogEntry "Applying updates to the NIC '$($nic.Name)'..." If (!$WhatIf) { $nic | Set-AzNetworkInterface | Out-Null } Else { Add-LogEntry "WhatIf: Updating NIC with: `$nic | Set-AzNetworkIntereface" } } } catch { Add-LogEntry "An error occurred while reassociating public IP addresses to the VM: $_" ERROR } } Add-LogEntry "Upgrade of VM '$($VM.VM.Name)' complete.'" } Add-LogEntry "Upgrade of Availability Set '$($AvSet.Name)' complete.'" } END { Add-LogEntry "####### Upgrade process complete. #######" } } |