Monday, May 18, 2026

Migrating tags after cross-vCenter vMotion

It has been noted that when a VM is migrated from one vCenter to another using cross-vCenter vMotion, the tags on the VM do not get migrated with it even if the corresponding tags & categories are present in the target vCenter. I've built a pair of scripts that we're using to remediate that oversight.

The first script copies the tags & categories from one vCenter to another. The second script is used to migrate tag assignments as part of the vMotion process.

An important distinction between the two is that you can perform the category/tag copy at any time: it isn't a full CRUD implementation, just making copies from a source to the destination. The migrate script must be run before (and during) a cross-vCenter migration because it needs to capture the tagging details on a VM before it migrates, or that information is lost forever. So the procedure would be as follows:

  1. Run Copy-vcTags.ps1 to make sure all the destination system tags are current
  2. Run Migrate-vcTags.ps1 to get a snapshot of the tag assignments on the source system before the migration is performed. Allow it to pause at the "reprocess" prompt; do no close it out.
  3. Perform cross-vCenter migrations.
  4. Select 'Y' or otherwise allow Migrate-vcTags.ps1 to reprocess any time a cross-vCenter migration completes for a VM.
  5. Stop Migrate-vcTags.ps1 after all migrations for the session have completed and have been reprocessed.

Copy-vcTags.ps1

#Requires -Modules VMware.VimAutomation.Core Write-Host 'Loading VCF.PowerCLI...' $ErrorActionPreference = 'Stop' function Read-Param { <# generic function to grab input from the user, providing for defaults if there's no entry provided #> param( $prompt, $default, [switch] $AsSecureString ) # Get the input if(-not $AsSecureString) { $value = Read-Host ("$prompt [$default]" -f $default) if ($value) { return $value } else { return $default } } else { # Special handling for password entry $value = Read-Host ("$prompt [$default]" -f $default) -MaskInput if ($value) { #user entered something return ConvertTo-SecureString -AsPlainText -Force $value } if ($default) { #user didn't enter anything, and default is non-null return ConvertTo-SecureString -AsPlainText -Force $default } return '' } } # Get source vCenter details $SrcVcsa = Read-Param 'Enter *SOURCE* vCenter IP or FQDN' 'appvcb22.infra.local' $SrcUsr = Read-Param 'Enter *SOURCE* username' 'kcjkmadm@infra.local' $SrcPwd = Read-Param 'Enter *SOURCE* password' '' -AsSecureString if (-not $SrcPwd) { throw 'Password is NULL.' } $srcCred = New-Object System.Management.Automation.PSCredential($SrcUsr,$SrcPwd) if (-not $srcCred) { throw 'Source credential is NULL.' } # Connect to the source vCenter Write-Host "Connecting to $SrcVcsa..." $hSrcVcsa = Connect-VIServer -Server $SrcVcsa -Credential $SrcCred if (-not $hSrcVcsa) { throw 'Cannot connect to source vCenter.' } # Get destination vCenter details. Supply source input as default $DstVcsa = Read-Param 'Enter *DESTINATION* vCenter IP or FQDN' 'le-w01-vc01.infra.local' $DstUsr = Read-Param 'Enter *DESTINATION* username' $SrcUsr $DstPwd = Read-Param 'Enter *DESTINATION* password' '' -AsSecureString if (-not $DstPwd) { $DstPwd = $SrcPwd } $DstCred = New-Object System.Management.Automation.PSCredential($DstUsr,$DstPwd) if (-not $DstCred) { throw 'Destination credential is NULL.' } # Connect to the destination vCenter Write-Host "Connecting to $DstVcsa..." $hDstVcsa = Connect-VIServer -Server $DstVcsa -Credential $DstCred if (-not $hDstVcsa) { throw 'Cannot connect to destination vCenter.' } <# Loop through the tags on the source. For each tag, see if it exists on the destination; if it does, continue. Collect them all into an object and dump them into a CSV for consideration. #> $SrcTags = Get-Tag -Server $hSrcVcsa foreach ($SrcTag in $SrcTags) { # Check to see if the tag already exists try { $DstTag = Get-Tag -Server $hDstVcsa -Category $SrcTag.Category.Name -Name $SrcTag.Name } catch { #Tag doesn't exist, so away we go... # Note: need a category to exist before a tag can be created # BUT it's possible that the category already exists even if the tag doesn't try { $DstCat = Get-TagCategory -Server $hDstVcsa -Name $SrcTag.Category.Name } catch { #Category doesn't exist, so create it as a copy of the source category try { $SrcCat = $SrcTag.Category $DstCat = New-TagCategory -Server $hDstVcsa -Name $SrcCat.Name -Cardinality $SrcCat.Cardinality -Description $SrcCat.Description -EntityType $SrcCat.EntityType Write-Host ("Creating tag category [{0}]" -f $DstCat.Name) } catch { Write-Host 'Yikes! Couldn''t create the new category' } } # At this point, we have a category but no tag. Let's do that now... try { $DstTag = New-Tag -Server $hDstVcsa -Name $SrcTag.Name -Description $SrcTag.Description -Category $DstCat } catch { Write-Host 'Yikes! Couldn''t create the new tag' } Write-Host ("Creating tag [{0}] under category [{1}]" -f $DstTag.Name, $DstCat.Name) } }


Migrate-vcTags.ps1

<# Migrate-vcTags.ps1 .SYNOPSIS Migrates VM tags during cross-vCenter migrations. .DESCRIPTION To work correctly, the tags (and categories) must already exist on the destination vCenter. Pre-creating the Category/Tag entries is a pre-requisite and can be done with Copy-vcTags.ps1 The script is started AND LEFT RUNNING before beginning migrations! It works by taking a snapshot of the source vCenter inventory--specifically the VMs (by name & inventory path) and their tags--and watching for the creation of the same path/vmname in the destination inventory. The "watching" process is a simple infinite loop that waits for input from the user to check the inventory on the target/destination vCenter; if a match is found, the tags are updated, and the loop pauses again, until input tells it to shut down or recheck the target. .PARAMETER SrcVcsa (mandatory) The vCenter where one or more VMs that will be migrated reside .PARAMETER DstUsr (mandatory) .PARAMETER DstPwd (mandatory) The username & password used to connect to the destination vCenter .PARAMETER SrcUsr (mandatory) .PARAMETER SrcPwd (mandatory) The username & password used to connect to the source vCenter .PARAMETER DestinationVCenter (mandatory) The vCenter where one or more VMs will be migrated to .NOTES Version: 1.0 Author: Jim Millard Creation Date: 15-May-2026 Purpose/Change: Initial development #> param ( [string] $SrcVcsa, [string] $SrcUsr, [string] $SrcPwd, [string] $DstVcsa, [string] $DstUsr, [string] $DstPwd ) #---------------------------------------------------------[Initialisations]-------------------------------------------------------- #Requires -Modules VMware.VimAutomation.Core #Set Error Action to throw an exception $ErrorActionPreference = 'Stop' $DfltSrcURL = 'Default Source Server' $DfltDstURL = 'Default Destination Server' $myUser = 'Default username' $myPwd = 'Default password' # Saving this in the .ps1 is not recommended for security reasons #-----------------------------------------------------------[Functions]------------------------------------------------------------ function Read-Param { <# generic function to grab input from the user, providing for defaults if there's no entry provided #> param( $prompt, $default, [switch] $AsSecureString, [switch] $MaskInput ) # Get the input if($AsSecureString) { # Special handling for password entry if($default){ $value = Read-Host "$prompt [*****]" -MaskInput } else { $value = Read-Host "$prompt" -MaskInput } if ($value) { #user entered something return ConvertTo-SecureString -AsPlainText -Force $value } if ($default.Value) { #user didn't enter anything, and default is non-null return ConvertTo-SecureString -AsPlainText -Force $default.Value } return '' } elseif($MaskInput){ if($default){ $value = Read-Host "$prompt [*****]" -MaskInput } else { $value = Read-Host "$prompt" -MaskInput } if ($value) { return $value } else { return $default } } else { if($default){ $value = Read-Host ("$prompt [{0}]" -f $default) } else { $value = Read-Host "$prompt" } if ($value) { return $value } else { return $default } } } function Get-Vcenter { param ( [string] $URL, #vCenter address by FQDN or input [string] $usr, #username [string] $pswd, #plaintext password [string] $DfltURL, #use as default URL [string] $DfltUsr, [string] $DfltPwd, [string] $FriendlyName # Friendly/common name to identify vCenter ) # Get vCenter details if not provided on the command line if(-not $URL) { $URL = Read-Param "Enter $FriendlyName vCenter IP or FQDN" $DfltURL } if(-not $usr) { $usr = Read-Param "Enter $FriendlyName username" $DfltUsr } if(-not $pswd) { $pswd = Read-Param "Enter $FriendlyName password" $DfltPwd -MaskInput } if (-not $pswd) { throw 'Password is NULL.' } # Connect to vCenter Write-Host "Connecting to $URL..." $hVcsa = Connect-VIServer -Server $URL -User $usr -Password $pswd if (-not $hVcsa) { throw "Cannot connect to $FriendlyName vCenter." } return $hVcsa } #-----------------------------------------------------------[Execution]------------------------------------------------------------ # Get connected to the destination and source vCenters $hDstVcsa = Get-Vcenter -URL $DstVcsa -usr $DstUsr -pswd $DstPwd -DfltURL $DfltDstURL -DfltUsr $myUser -DfltPwd $myPwd -FriendlyName '*DESTINATION*' $hSrcVcsa = Get-Vcenter -URL $SrcVcsa -usr $SrcUsr -pswd $SrcPwd -DfltURL $DfltSrcURL -DfltUsr $myUser -DfltPwd $myPwd -FriendlyName '*SOURCE*' <# Collect inventory details of the source vCenter #> $SrcInv = @{} Write-Host 'Collecting inventory...' -NoNewline $SrcTags = Get-TagAssignment -Server $hSrcVcsa Write-Host ' and collating tags...' -NoNewline foreach($t in $SrcTags){ if($SrcInv[$t.Entity.Name]) { $SrcInv[$t.Entity.Name].Tags += [PSCustomObject]@{ Name = $t.Tag.Name Category = $t.Tag.Category } } else { $newobj = [PSCustomObject]@{ Name = $t.Entity.Name Tags = @([PSCustomObject]@{ Name = $t.Tag.Name Category = $t.Tag.Category }) } $SrcInv.Add($t.Entity.Name,$newobj) } } Write-Host ' for source server.' Disconnect-VIServer -Server $hSrcVcsa -confirm:$false <# Update destination server VMs #> Do { $DstInv = Get-VM -Server $hDstVcsa #Get the destination VMs foreach ($DstVM in $DstInv){ if($SrcInv[$DstVM.Name]) { # Source information exists for destination VM if(-not (Get-TagAssignment -Server $hDstVcsa -Entity $DstVM)){ #Only tag if destination VM hasn't been tagged foreach ($SrcTag in $SrcInv[$DstVM.Name].Tags) { # get the right tag(s) for the VM based on the name and category for the corresponding source tag $newTag = Get-Tag -name $SrcTag.Name -Category $SrcTag.Category.Name -Server $hDstVcsa New-TagAssignment -Tag $newTag -Entity $DstVM -Server $hDstVcsa } } } } Write-Host 'Confirm' -ForegroundColor White Write-Host 'Are you sure you want to perform this action?' Write-Host 'Performing the operation "Reprocess vCenter" on target "Server: '$hDstVcsa.Name'"' Write-Host '[Y] Yes [N] No (default is "Y"): ' -NoNewline $key = $Host.UI.RawUI.ReadKey() if (($key.Character -eq 'Y') -or ($key.VirtualKeyCode -eq 13)) { $continue = $true Write-Host 'Re-processing DESTINATION vCenter.' } else { $continue = $false Write-Host "`nDone processing." } } While ($continue)

Note: I'm trying to bring a little more sophistication into my scripts, which is why the beginning comment block on Migrate looks so different from Copy. I hope it doesn't drive anyone nuts.

No comments:

Post a Comment