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.

Thursday, May 14, 2026

Retrieving Groups from NSX-T

This might seem like a bit of a theme developing, but in my work life I'm assisting in the migration from a "standalone" vSphere environment to a VCF 9 environment, and having some of this information from the old environment is helpful for building and auditing the new environment.

So this script will dump groups from NSX-T into a CSV-formatted text. It was a fun one to code because of the way groups are handled: some sort of boolean logic—up to and including selection of objects by individual ID—is possible, and when you're mixing & matching [AND] and [OR] clauses, you are starting to nest evaluation scope, similar to the way parenthesis work in math (remember PEMDAS?).

At any rate, this code uses 'recursion,' where a function calls itself with new arguments rather than creating deeper and deeper loops. When the language permits it—and PowerShell does—it becomes a powerful tool for writing efficient and highly readable (in my experience) code. It can also create pitfalls for a coder who doesn't build in the "escape hatch" that keeps the recursion from happening infinitely, but that's an entirely different discussion.

So with the help of recursion, this script will dive into the criteria used to define group membership and assemble it all together in a single field. This is output along with the group name and any description that is available from the source environment

Get-NsxGroups.ps1

FUNCTION Decode-Expression() { # decode an individual Expression # these can come in several types, so handle each using the switch control param ( [System.Object]$expr ) switch ($expr.resource_type){ 'Condition' { $str += '(' + $expr.member_type + ' [' + $expr.key + '] ' + $expr.operator + ' "' + $expr.value.replace('|','') + '")' } 'ConjunctionOperator' { $str += ' ' + $expr.conjunction_operator + ' ' } 'NestedExpression' { # nested expressions are not individual expressions, so recurse through the nested criteria $str += Decode-Expressions($expr.expressions) } 'MACAddressExpression' { $addr_string = '' foreach ($addr in $expr.mac_addresses){ $addr_string += "$addr," } $addr_string = $addr_string.SubString(0,$addr_string.Length-1) $str += '[MAC in (' + $addr_string + ')]' } 'IPAddressExpression' { $addr_string = '' foreach ($addr in $expr.ip_addresses){ $addr_string += "$addr," } $addr_string = $addr_string.SubString(0,$addr_string.Length-1) $str += '[IP in (' + $addr_string + ')]' } 'ExternalIDExpression' { $str += ($expr.external_ids.Length.ToString() + ' explicit VMs') } default { $str += '<<unhandled>>' } } return $str } FUNCTION Decode-Expressions(){ # One or more expressions--including multi-level nesting--can be used to define # what makes a group member. # This function handles iterating and recursing through all criteria that could be in # any given expression param ( [System.Object]$expr ) $str = '' if (($expr -is [Array]) -and ($expr.Length -gt 1)){ #passed object is an array of objects for ($i = 0; $i -lt $expr.Length; $i++) { $str += Decode-Expressions($expr[$i]) } } else { if ($expr.Length) { $str += Decode-Expression($expr) } else { $str += 'No criteria set' } } return $str } $dfltNSXMGR = "default NSX manager" $nsxtManager = Read-Host ("Enter NSX-T IP or FQDN [$dfltNSXMGR]" -f $dfltNSXMGR) if (-not $nsxtManager) { $nsxtManager = "https://$dfltNSXMGR" } else { $nsxtManager = "https://$nsxtManager" } $output = Read-Host "Enter output filepath" $username = Read-Host "Enter username" $secPwd = Read-Host "Enter password" -AsSecureString $password = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secPwd)) $response = Invoke-RestMethod -Uri "$nsxtManager/api/v1/infra/domains/default/groups" -Method Get -Headers @{ "Authorization" = "Basic $( [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("${username}:${password}"))) )" "Content-Type" = "application/json" } -Body '{}' -SkipCertificateCheck $grp_list = @() foreach ($grp in $response.results) { if ($grp._system_owned -ne 'false') { #ignore system-owned groups; these should be auto-created anyway $grp_name = $grp.display_name $grp_descr = $grp.description $rules = Decode-Expressions($grp.expression) $row = New-Object PSObject -Property @{ Grp = $grp.display_name Descr = $grp.description Crit = $rules } $grp_list += $row } } $grp_list | Select-Object 'Grp','Descr','Crit' | Export-CSV -Path $output -NoTypeInformation

Note: I made the decision to ignore any explicit VMs that were in these groups. It was a business decision driven by several factors:

  • There weren't many groups using this feature
  • Most groups using the feature had references to VMs that no longer existed

Wednesday, May 13, 2026

Retrieving tags from NSX-T

In the process of preparing a new NSX-T environment, a request came for a dump of the tags in the production environment so that we could match those in the new one. PowerShell & RESTful API to the rescue!

Note: after working with VMware solutions since 2005 and for the company itself for just over 6 years, I have it ingrained to call it "VMware". Aside from this paragraph, you'll probably never see me write "VMware by Broadcom" or even something as gross as "Broadcom vSphere." Just understand that "by Broadcom" is implied until such time as the tech stack finds new ownership.

At any rate: PowerCLI, the module set for PowerShell that VMware publishes for automating parts/pieces of their software stack, is extremely light in cmdlets for interacting with NSX-T. But NSX-T has a very rich RESTful API, so that's what I'm taking advantage of.

I found several solutions that other folks had written, and for one reason or another, it just wasn't working to create output the way we needed it. So here are a couple of iterations that I wrote. The first dumps a CSV-formatted file that lists the tags and the entities that are associated with them; the second dumps a CSV-formatted file that list the VMs and the tags (if any) that are applied to them.

Get-NsxTags.ps1

function Read-Param { <# generic function to grab input from the user, providing for defaults if there's no entry provided #> param( $prompt, $default ) $value = Read-Host ("$prompt [$default]" -f $default) if (-not $value) { return $default } else { return $value } } function Get-Data { <# Perform a RESTful API call against the NSX-T manager #> param( $nsx, $usr, $secPwd, $apiPath ) $password = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secPwd)) $credPair = "$($usr):$($password)" $encCreds = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($credPair)) $params = @{ Uri = "$nsx$apiPath" Method = 'GET' Headers = @{ "Authorization" = "Basic $encCreds" "Content-Type" = "application/json" } Body = '{}' SkipCertificateCheck = $true } $results = Invoke-RestMethod @params return $results.results } # All NSX-T API paths "hang" off the base path $ApiBase = "/policy/api/v1" # DFW-related stuff is off the Policies path, below. Note: the "default" domain seems to be the only one we have; technically it's a variable... $ApiTags = "$ApiBase/infra/tags" <# INPUT SECTION #> # The actual URI must have the "https://" prefix $nsxtManager = "https://" + (Read-Param "Enter NSX-T IP or FQDN" "Default VIP") $output = Read-Param "Enter output filepath" "c:\temp\get-nsxtags.csv" $username = Read-Param "Enter username" "defaultusername" $secPwd = Read-Host "Enter password" -AsSecureString <# ALGORITHM NSX provides an API to list the tags, and then individual entries provide the tag name and the scope required to retrieve the effective resources associated with them #> $itemlist=@() #storage for the output list #get all the tags $tags = Get-Data -nsx $nsxtManager -usr $username -secPwd $secPwd -apiPath $ApiTags write-host '.' -NoNewline #loop through the tags and grab the associated resources, appending the resource names to a single string field for later output for($t =0; $t -lt $tags.Length; $t++) { $items = '' if($tags[$t].tagged_objects_count -gt 0) { #if there are no objects associated with the tag, don't try and retrieve them $ApiItems = $ApiTags + '/effective-resources?scope=' + $tags[$t].scope + '&tag=' + $tags[$t].tag write-host '.' -NoNewline #this will take a while; show progress happening... $itemSet = Get-Data -nsx $nsxtManager -usr $username -secPwd $secPwd -apiPath $ApiItems foreach ($item in $itemSet) { switch ($item.target_type) { 'VirtualMachine' { $items += $item.target_display_name + ' [vm],' } 'HostTransportNode' { $items += $item.target_display_name + ' [Host],' } default { $items += $item.target_display_name + '<<undefined>>,' } } } } #add a row to the output, removing a trailing comma if needed $row = New-Object PSObject -Property @{ Tag = $tags[$t].tag Count = $tags[$t].tagged_objects_count Items = '' } if ($items.Length -gt 0) { $row.Items = $items.substring(0, $items.length-1) } $itemlist += $row } write-host '.' write-host 'Done' $itemlist | Select-Object 'Tag','Count','Items' | Export-CSV -Path $output -NoTypeInformation


Get-NSXvmTags.ps1

$dfltNSXMGR = "default" $nsxtManager = Read-Host ("Enter NSX-T IP or FQDN [$dfltNSXMGR]" -f $dfltNSXMGR) if (-not $nsxtManager) { $nsxtManager = "https://$dfltNSXMGR" } else { $nsxtManager = "https://$nsxtManager" } $output = Read-Host "Enter output filepath" $username = Read-Host "Enter username" $secPwd = Read-Host "Enter password" -AsSecureString $password = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secPwd)) $response = Invoke-RestMethod -Uri "$nsxtManager/api/v1/fabric/virtual-machines?included_fields=display_name,tags" -Method Get -Headers @{ "Authorization" = "Basic $( [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(("${username}:${password}"))) )" "Content-Type" = "application/json" } -Body '{}' -SkipCertificateCheck $vmlist = @(); foreach ($vm in $response.results) { $tags = '' foreach ($tag in $vm.tags) { $tags += $tag.tag + ',' } if ($tags.Length -gt 0) { $row = New-Object PSObject -Property @{ VM = $vm.display_name Tags = $tags.substring(0, $tags.length-1) } } else { $row = New-Object PSObject -Property @{ VM = $vm.display_name Tags = '' } } $vmlist += $row } $vmlist | Select-Object 'VM','Tags' | Export-CSV -Path $output -NoTypeInformation

These two are interesting in that in the first case, you use one API URI to get the tags—which provide the information (scope, tag) to use as parameters—and a child URI to get the items that are associated with that tag. But in the second case, a single URI provides a list of VMs as well as the tags that are applied to them as properties.