Thursday, May 21, 2026

Copy groups from one NSX to another

Migrating from one NSX deployment to another in parallel, there are some things that VMware hasn't offered. Among them is the easy export/import of configurations, particularly static items that (theoretically) could be created before any VMs are actually alive and present on the new system.

I'm slowly working through these details, and I've found that while I can manually create groups, it won't let me create most of the criteria—like membership via tag—until VMs are present. But playing with the REST API—which I wanted to do anyway, because who actually wants to manually create several hundred groups?!—I discovered that I could programmatically create the criteria—including tags!—for groups. The result is this script, which will copy non-system-owned groups from one NSX instance to another.


Note: Group IDs are case sensitive and may not match the display name! I have several instances where I would end up with a seemingly-duplicated group when I ran the script, but it was just a group I had built manually having a different ID than what was in the source, even though the display names were identical.

Copy-NsxGroups.ps1

<# Copy-NsxGroups.ps1 .SYNOPSIS Copies groups and their criteria from one NSX manager instance to another. .DESCRIPTION Using the REST API, copy groups from source NSX manager to another. If the target group doesn't exist, it is created and criterion for group membership is added. If the target group exists, criterion for group membership is updated. System-owned groups are ignored. .PARAMETER SrcNSXmgr (mandatory) The NSX Manager instance with groups to copy .PARAMETER SrcUsr (mandatory) .PARAMETER SrcPwd (mandatory) The username & password used to connect to the source NSX Manager .PARAMETER DstNSXmgr (mandatory) The vCenter where one or more VMs will be migrated to .PARAMETER DstUsr (mandatory) .PARAMETER DstPwd (mandatory) The username & password used to connect to the NSX Manager .NOTES Version: 1.0 Author: Jim Millard Creation Date: 19-May-2026 Purpose/Change: Initial development #> param ( [string] $SrcNSXmgr, [string] $SrcUsr, [string] $SrcPwd, [string] $DstNSXmgr, [string] $DstUsr, [string] $DstPwd ) #---------------------------------------------------------[Initialisations]-------------------------------------------------------- #Requires -Modules VMware.VimAutomation.Core #Set Error Action to throw an exception $ErrorActionPreference = 'Stop' $DfltSrcURL = $null $DfltDstURL = $null #-----------------------------------------------------------[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-ApiCred { param ( [string] $usr, [string] $pswd ) $pair = "$usr`:$pswd" return [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($pair)) } function Get-NSXApi { param ( [string] $URL, #NSX Manager 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 NSX Manager instannce ) # Get NSX Manager details if not provided on the command line if(-not $URL) { $URL = Read-Param "Enter $FriendlyName NSX Manager 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.' } # Create API object $hNSXmgr = [PSCustomObject]@{ URL = "https://$URL/policy/api/v1" Credential = Get-ApiCred -usr $usr -pswd $pswd } return $hNSXmgr } function Tidy-Expressions{ param ( $expression ) $explist = @() foreach ($exp in $expression) { $newExp = [PSCustomObject]@{ resource_type = $exp.resource_type } switch ($newExp.resource_type){ 'Condition' { $newExp | Add-Member -MemberType NoteProperty -Name 'member_type' -Value $exp.member_type $newExp | Add-Member -MemberType NoteProperty -Name 'key' -Value $exp.key $newExp | Add-Member -MemberType NoteProperty -Name 'operator' -Value $exp.operator if ($null -ne $exp.scope_operator) { $newExp | Add-Member -MemberType NoteProperty -Name 'scope_operator' -Value $exp.scope_operator } $newExp | Add-Member -MemberType NoteProperty -Name 'value' -Value $exp.value } 'ConjunctionOperator' { $newExp | Add-Member -MemberType NoteProperty -Name 'conjunction_operator' -Value $exp.conjunction_operator } 'NestedExpression' { # nested expressions are not individual expressions, so recurse through the nested criteria $nested = @(Tidy-Expressions $exp.expressions) $newExp | Add-Member -MemberType NoteProperty -Name 'expressions' -Value $nested } 'MACAddressExpression' { $newExp | Add-Member -MemberType NoteProperty -Name 'mac_addresses' -Value $exp.mac_addresses } 'IPAddressExpression' { $newExp | Add-Member -MemberType NoteProperty -Name 'ip_addresses' -Value $exp.ip_addresses } 'ExternalIDExpression' { $newExp | Add-Member -MemberType NoteProperty -Name 'member_type' -Value $exp.member_type $newExp | Add-Member -MemberType NoteProperty -Name 'external_ids' -Value $exp.external_ids } default { throw ("unexpected resource type: {$}" -f $exp.resource_type) } } $explist += $newExp } return $explist } #-----------------------------------------------------------[Execution]------------------------------------------------------------ $DfltSrcUsr = $null $DfltSrcPwd = $null $DfltDstUsr = $null $DfltDstPwd = $null $SrcAPI = Get-NSXApi -FriendlyName '*SOURCE*' -DfltURL $DfltSrcURL -DfltUsr $DfltSrcUsr -DfltPwd $DfltSrcPwd $DstAPI = Get-NSXApi -FriendlyName '*DESTINATION*' -DfltURL $DfltDstURL -DfltUsr $DfltDstUsr -DfltPwd $DfltDstPwd $SrcJSON = Invoke-RestMethod -Uri "$($SrcAPI.URL)/infra/domains/default/groups?sort_ascending=true&sort_by=display_name" -Method Get -Headers @{ "Authorization" = "Basic $($SrcAPI.Credential)" "Content-Type" = "application/json" } -Body '{}' -SkipCertificateCheck #### Get the source details foreach ($grp in $SrcJSON.results) { ###ignore system-owned groups; these should be auto-created & maintained anyway if ($grp._system_owned) { continue } ### DefaultMaliciousIpGroup isn't identified as a system group, but we need to treat it as one if ($grp.display_name -eq 'DefaultMaliciousIpGroup') {continue} <# In order to revise an existing object, the _revision property is required to keep multiple clients from causing conflicting updates. Get the current revision #> $rev = -1 try { $DstGrp = Invoke-RestMethod -Uri "$($DstAPI.URL)/infra/domains/default/groups/$($grp.id)" -Method Get -Headers @{ "Authorization" = "Basic $($DstAPI.Credential)" "Content-Type" = "application/json" } -Body '' -SkipCertificateCheck } catch { # group doesn't exist, so create it as part of updating it $rev = 0 } if($rev -eq -1) { $rev = $DstGrp._revision } $oBody = [PSCustomObject]@{ expression = @(Tidy-Expressions $grp.expression) group_type = $grp.group_type id = $grp.id display_name = $grp.display_name description = "programmatically transferred from $($SrcAPI.URL)" _revision = $rev } $jBody = ConvertTo-Json $oBody -Depth 10 Write-Host ("Copying [{0}]..." -f $grp.display_name) try { Invoke-RestMethod -Uri "$($DstAPI.URL)/infra/domains/default/groups/$($grp.id)" -Method Patch -Headers @{ "Authorization" = "Basic $($DstAPI.Credential)" "Content-Type" = "application/json" } -Body $jBody -SkipCertificateCheck } catch { Write-Host $_.ErrorDetails } }

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.