Initial Commit of NSX Migration Script

This commit is contained in:
brandon committed 2026-04-18 09:30:44 -05:00
commit 1068b2997d
2 files changed
+1049

No files matched your search

+358
View File
@@ -0,0 +1,358 @@
# NSX Migration Tool
A Python script for exporting and importing NSX Distributed Firewall (DFW) rules, Security Groups, and VM Tags between NSX Manager instances running version 3.x or 4.x.
No third-party libraries required — uses Python standard library only.
---
## Requirements
- Python 3.6 or later
- Network access to the NSX Manager API (port 443)
- NSX credentials with at least **Enterprise Admin** or **Security Admin** role
---
## Supported NSX Versions
- NSX-T 3.x
- NSX-T 4.x
- NSX 9.x (including VCF-mode deployments)
---
## What Gets Exported / Imported
| Resource | Export Source | Import Target | Notes |
|---|---|---|---|
| **Tags** | `/api/v1/fabric/virtual-machines` | `?action=update_tags` | Stored as VM tag assignments, not standalone objects |
| **Security Groups** | `/policy/api/v1/infra/domains/default/groups` | Same path via PATCH | Full group definitions including membership criteria |
| **DFW Rules** | `/policy/api/v1/infra/domains/default/security-policies` | Same path via PATCH | All policies and their rules, preserving order |
### Output Files (Export)
| File | Default Name | Contents |
|---|---|---|
| Tags | `tags.json` | Unique tag list + per-VM assignment records |
| Security Groups | `sec_groups.json` | Full group definitions |
| DFW Rules | `dfw_rules.json` | Policies with embedded `_rules` arrays |
---
## Usage
```
python nsx_migrate.py <action> --host <NSX_IP> -u <username> -p <password> [options]
```
### Actions
| Action | Description |
|---|---|
| `export` | Export all resources to JSON files |
| `import` | Import all resources in order: Tags → Security Groups → DFW Rules |
| `import-tags` | Import only VM tag assignments |
| `import-groups` | Import only security groups |
---
## Arguments
### Connection
| Argument | Short | Required | Default | Description |
|---|---|---|---|---|
| `--host` | | Yes | | NSX Manager hostname or IP address |
| `--username` | `-u` | Yes | | NSX Manager username |
| `--password` | `-p` | Yes | | NSX Manager password |
| `--no-verify-ssl` | | No | `False` | Disable SSL certificate verification (for self-signed certs) |
### Source context (export)
| Argument | Required | Default | Description |
|---|---|---|---|
| `--src-org` | No | `default` | Source VCF org name |
| `--src-project` | No | _(none)_ | Source VCF project name — omit to use the default infra space |
| `--src-vpc` | No | _(none)_ | Source VPC name within the project — requires `--src-project` |
### Destination context (import)
| Argument | Required | Default | Description |
|---|---|---|---|
| `--dst-org` | No | `default` | Destination VCF org name |
| `--dst-project` | No | _(none)_ | Destination VCF project name — omit to use the default infra space |
| `--dst-vpc` | No | _(none)_ | Destination VPC name within the project — requires `--dst-project` |
### Import behaviour
| Argument | Required | Default | Description |
|---|---|---|---|
| `--conflict` | No | `overwrite` | Conflict handling mode — see below |
| `--merge-tags` | No | `False` | Merge imported tags with existing VM tags instead of replacing |
### File paths
| Argument | Required | Default | Description |
|---|---|---|---|
| `--dfw-rules-file` | No | `dfw_rules.json` | Path to DFW rules JSON file |
| `--sec-groups-file` | No | `sec_groups.json` | Path to security groups JSON file |
| `--tags-file` | No | `tags.json` | Path to tags JSON file |
---
## Project and VPC Context
NSX 9.x running in VCF mode supports multi-tenancy through **Projects** and **VPCs**. The script resolves API paths based on the context arguments provided.
| Context | API path used |
|---|---|
| No project/VPC (default) | `/policy/api/v1/infra/domains/default/...` |
| Project only | `/policy/api/v1/orgs/{org}/projects/{project}/infra/domains/default/...` |
| Project + VPC | `/policy/api/v1/orgs/{org}/projects/{project}/vpcs/{vpc}/...` |
**Source and destination contexts are independent.** You can export from a project and import to a different project, to a VPC, or to the default space — in any combination.
> **Note:** VM tag lookups always use the global fabric API (`/api/v1/fabric/virtual-machines`) regardless of project or VPC context. Tags are a fabric-level concept and are not scoped to projects or VPCs.
---
## Conflict Handling (`--conflict`)
Controls what happens when an object already exists on the destination NSX.
| Mode | Behavior |
|---|---|
| `overwrite` | Always write the object, replacing any existing version **(default)** |
| `skip` | Leave existing objects untouched; only import objects that do not exist |
| `prompt` | Ask interactively for each conflict |
### Prompt Mode Keys
When `--conflict prompt` is used, the following keys are accepted per conflict:
| Key | Action |
|---|---|
| `y` | Yes — overwrite this object |
| `n` | No — skip this object |
| `a` | Yes to all — overwrite this and all remaining conflicts without prompting |
> **Note:** In non-interactive environments (piped input, CI/CD), `prompt` mode defaults to **skip** when stdin is unavailable.
---
## Tag Merge (`--merge-tags`)
Applies to `import` and `import-tags` actions only.
| Mode | Behavior |
|---|---|
| Without flag | Replace all tags on the VM with the imported set (existing tags are removed) |
| `--merge-tags` | Union of imported tags and existing tags — no tags are removed, only new ones added |
Tag deduplication is based on the `(scope, tag)` pair.
---
## VM Resolution (Cross-vCenter)
When importing tags, the script resolves VMs on the destination using a two-step lookup:
1. **Primary — `external_id`**: Matches the vCenter MoRef ID. Works when both environments use the same vCenter or the IDs happen to align.
2. **Fallback — `display_name`**: Matches by VM name. Used automatically when the `external_id` lookup returns no results.
If multiple VMs share the same display name, the first result is used and a warning is printed. If neither lookup finds the VM, a warning is logged and the assignment is skipped — the import continues.
---
## Examples
### Export from source NSX
```bash
python nsx_migrate.py export \
--host 192.168.1.10 \
--username admin \
--password VMware1! \
--no-verify-ssl
```
Produces `tags.json`, `sec_groups.json`, and `dfw_rules.json` in the current directory.
---
### Full import to destination NSX (overwrite existing)
```bash
python nsx_migrate.py import \
--host 192.168.2.10 \
--username admin \
--password VMware1! \
--no-verify-ssl
```
---
### Full import — skip objects that already exist
```bash
python nsx_migrate.py import \
--host 192.168.2.10 \
--username admin \
--password VMware1! \
--no-verify-ssl \
--conflict skip
```
---
### Full import — prompt on each conflict
```bash
python nsx_migrate.py import \
--host 192.168.2.10 \
--username admin \
--password VMware1! \
--no-verify-ssl \
--conflict prompt
```
---
### Import only tags, merging with existing
```bash
python nsx_migrate.py import-tags \
--host 192.168.2.10 \
--username admin \
--password VMware1! \
--no-verify-ssl \
--merge-tags
```
---
### Import only security groups, skip existing
```bash
python nsx_migrate.py import-groups \
--host 192.168.2.10 \
--username admin \
--password VMware1! \
--no-verify-ssl \
--conflict skip
```
---
### Export from a VCF project
```bash
python nsx_migrate.py export \
--host 192.168.1.10 -u admin -p VMware1! --no-verify-ssl \
--src-project my-project
```
---
### Export from a VPC inside a project
```bash
python nsx_migrate.py export \
--host 192.168.1.10 -u admin -p VMware1! --no-verify-ssl \
--src-project my-project --src-vpc my-vpc
```
---
### Import into a specific project
```bash
python nsx_migrate.py import \
--host 192.168.2.10 -u admin -p VMware1! --no-verify-ssl \
--dst-project my-project --conflict skip
```
---
### Import into a VPC
```bash
python nsx_migrate.py import \
--host 192.168.2.10 -u admin -p VMware1! --no-verify-ssl \
--dst-project my-project --dst-vpc my-vpc
```
---
### Cross-project migration on the same NSX host
```bash
# Step 1 — export from project-a
python nsx_migrate.py export \
--host 192.168.1.10 -u admin -p VMware1! --no-verify-ssl \
--src-project project-a
# Step 2 — import to project-b
python nsx_migrate.py import \
--host 192.168.1.10 -u admin -p VMware1! --no-verify-ssl \
--dst-project project-b --conflict skip
```
---
### Use custom file names
```bash
# Export to custom paths
python nsx_migrate.py export \
--host 192.168.1.10 -u admin -p VMware1! \
--dfw-rules-file backup_dfw.json \
--sec-groups-file backup_groups.json \
--tags-file backup_tags.json
# Import from custom paths
python nsx_migrate.py import \
--host 192.168.2.10 -u admin -p VMware1! \
--dfw-rules-file backup_dfw.json \
--sec-groups-file backup_groups.json \
--tags-file backup_tags.json
```
---
## Import Order
When running a full `import`, resources are always applied in the following order to ensure dependencies are satisfied:
```
1. Tags — VMs must be present; no dependency on groups or rules
2. Security Groups — may reference tags in dynamic membership criteria
3. DFW Rules — policies reference security groups as sources/destinations
```
---
## Output / Status Codes
Each resource line during import prints one of the following prefixes:
| Prefix | Meaning |
|---|---|
| `OK` | Successfully written to destination |
| `SKIP` | Object already exists and was left untouched (`skip` or `prompt`=no) |
| `WARNING` | Non-fatal issue (e.g. VM not found for tag assignment) |
| `FAIL` | API call failed — error detail printed on the same line |
A summary line is printed after each resource type showing total counts.
---
## Notes and Limitations
- **Tags are not standalone objects in NSX.** They exist only as attributes on VMs. The export captures tag assignments per VM; the import re-applies them. VMs must already exist and be visible to NSX on the destination.
- **Security group membership expressions** are exported as-is. If expressions reference objects that do not exist on the destination (e.g. segments, tags not yet applied), the group will be created but membership may be empty until those dependencies exist.
- **DFW rule references** (source/destination groups, services) are exported by path/ID. If referenced groups or services do not exist on the destination, rules will be created but may not match traffic as expected. Importing groups before rules (the default order) mitigates this for group references.
- **Read-only fields** (`_revision`, `_create_time`, `_last_modified_*`, etc.) are stripped before import so PATCH requests do not fail due to server-managed metadata.
- **VCF deployments (NSX 9.x):** VCF-mode NSX adds extra fields to objects (`remote_path`, `unique_id`, `realization_id`, `owner_id`) that reference the source environment's org/project context. These are automatically stripped before import so they are not carried across to the destination.
- The script operates on the **default domain** only (`/infra/domains/default`).
+691
View File
@@ -0,0 +1,691 @@
#!/usr/bin/env python3
"""
NSX Migration Tool
Exports and imports DFW rules, Security Groups, and Tags from/to NSX Manager (3.x/4.x/9.x)
Supports default infra, VCF Projects, and VPC contexts on both source and destination.
"""
import argparse
import json
import sys
import urllib.request
import urllib.error
import urllib.parse
import base64
import ssl
import os
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
def build_headers(username, password):
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
return {
"Authorization": f"Basic {credentials}",
"Content-Type": "application/json",
"Accept": "application/json",
}
def make_request(url, headers, method="GET", data=None, verify_ssl=True):
ctx = ssl.create_default_context() if verify_ssl else ssl._create_unverified_context()
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(req, context=ctx) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")
raise RuntimeError(f"HTTP {e.code} {e.reason} — {url}\n{body}")
def paginate(base_url, headers, verify_ssl):
"""Collect all results from a paginated NSX endpoint."""
results = []
cursor = None
while True:
url = base_url + (f"&cursor={cursor}" if cursor else "")
resp = make_request(url, headers, verify_ssl=verify_ssl)
results.extend(resp.get("results", []))
cursor = resp.get("cursor")
if not cursor:
break
return results
# ---------------------------------------------------------------------------
# Context / path builder
# ---------------------------------------------------------------------------
def build_api_paths(base_url, org="default", project=None, vpc=None):
"""
Build a dict of endpoint base paths for the given NSX context.
Three contexts are supported:
1. Default (no project/VPC):
Policy base → /policy/api/v1/infra/domains/default
2. Project (VCF multi-tenancy, no VPC):
Policy base → /policy/api/v1/orgs/{org}/projects/{project}/infra/domains/default
3. VPC (isolated network within a project):
Policy base → /policy/api/v1/orgs/{org}/projects/{project}/vpcs/{vpc}
(VPCs have groups/security-policies directly; no domain level)
The fabric VM API (/api/v1/fabric/...) is always global — it is not
scoped to a project or VPC.
"""
fabric_base = f"{base_url}/api/v1/fabric"
if vpc:
if not project:
raise ValueError("--vpc requires --project to be set")
policy_base = (
f"{base_url}/policy/api/v1/orgs/{org}/projects/{project}/vpcs/{vpc}"
)
context_label = f"org={org}, project={project}, vpc={vpc}"
elif project:
policy_base = (
f"{base_url}/policy/api/v1/orgs/{org}/projects/{project}/infra/domains/default"
)
context_label = f"org={org}, project={project}"
else:
policy_base = f"{base_url}/policy/api/v1/infra/domains/default"
context_label = "default"
return {
"context_label": context_label,
"fabric_vms": f"{fabric_base}/virtual-machines",
"fabric_vm_tags": f"{fabric_base}/virtual-machines?action=update_tags",
"groups": f"{policy_base}/groups",
"security_policies": f"{policy_base}/security-policies",
}
def paths_from_args(base_url, org, project, vpc):
"""Validate and build API paths, exiting on misconfiguration."""
try:
return build_api_paths(base_url, org=org, project=project, vpc=vpc)
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
# ---------------------------------------------------------------------------
# EXPORT
# ---------------------------------------------------------------------------
def export_tags(paths, headers, verify_ssl):
"""
NSX tags live on VMs — not as standalone objects.
Collect all unique tags and their per-VM assignments.
The fabric VM API is always global (not project/VPC scoped).
"""
print(" Collecting tags from virtual machines...")
vms = paginate(f"{paths['fabric_vms']}?page_size=1000", headers, verify_ssl)
tag_assignments = []
seen_tags = set()
all_tags = []
for vm in vms:
vm_tags = vm.get("tags", [])
if vm_tags:
tag_assignments.append({
"resource_type": "VirtualMachine",
"external_id": vm.get("external_id"),
"display_name": vm.get("display_name"),
"tags": vm_tags,
})
for t in vm_tags:
key = (t.get("scope", ""), t.get("tag", ""))
if key not in seen_tags:
seen_tags.add(key)
all_tags.append({"scope": t.get("scope", ""), "tag": t.get("tag", "")})
print(f" Found {len(all_tags)} unique tags across {len(vms)} VMs.")
return {"tags": all_tags, "assignments": tag_assignments}
def export_security_groups(paths, headers, verify_ssl):
"""Export NSX Groups (security groups) from the resolved Policy API path."""
print(" Collecting security groups...")
groups = paginate(f"{paths['groups']}?page_size=1000", headers, verify_ssl)
print(f" Found {len(groups)} security groups.")
return groups
def export_dfw_rules(paths, headers, verify_ssl):
"""Export all DFW security policies and their rules."""
print(" Collecting DFW security policies...")
policies = paginate(f"{paths['security_policies']}?page_size=1000", headers, verify_ssl)
exported = []
for policy in policies:
policy_id = policy.get("id")
print(f" Fetching rules for policy: {policy.get('display_name', policy_id)}")
rules = paginate(
f"{paths['security_policies']}/{policy_id}/rules?page_size=1000",
headers, verify_ssl,
)
exported.append({**policy, "_rules": rules})
total_rules = sum(len(p["_rules"]) for p in exported)
print(f" Found {len(exported)} policies with {total_rules} total rules.")
return exported
def run_export(args):
base_url = f"https://{args.host}"
headers = build_headers(args.username, args.password)
verify_ssl = not args.no_verify_ssl
paths = paths_from_args(base_url, args.src_org, args.src_project, args.src_vpc)
print(f"\n[Export] Connecting to {args.host}")
print(f" Context : {paths['context_label']}")
# Tags
print("\n[1/3] Exporting tags...")
tags_data = export_tags(paths, headers, verify_ssl)
with open(args.tags_file, "w") as f:
json.dump(tags_data, f, indent=2)
print(f" Saved -> {args.tags_file}")
# Security Groups
print("\n[2/3] Exporting security groups...")
groups_data = export_security_groups(paths, headers, verify_ssl)
with open(args.sec_groups_file, "w") as f:
json.dump(groups_data, f, indent=2)
print(f" Saved -> {args.sec_groups_file}")
# DFW Rules
print("\n[3/3] Exporting DFW rules...")
dfw_data = export_dfw_rules(paths, headers, verify_ssl)
with open(args.dfw_rules_file, "w") as f:
json.dump(dfw_data, f, indent=2)
print(f" Saved -> {args.dfw_rules_file}")
print("\n[Export complete]")
# ---------------------------------------------------------------------------
# IMPORT — helpers
# ---------------------------------------------------------------------------
IMPORT_STRIP_KEYS = {
# Standard read-only metadata
"_create_time", "_last_modified_time", "_create_user", "_last_modified_user",
"_system_owned", "_protection", "_revision", "_self",
# Server-assigned identity fields (also present in nested objects like rule conditions)
"unique_id", "realization_id", "owner_id",
# VCF/multi-tenancy path — environment-specific, must not carry across to a different instance
"remote_path",
}
def clean_for_import(obj):
"""Recursively remove read-only/server-managed fields before PUT/PATCH."""
if isinstance(obj, dict):
return {k: clean_for_import(v) for k, v in obj.items() if k not in IMPORT_STRIP_KEYS}
if isinstance(obj, list):
return [clean_for_import(i) for i in obj]
return obj
def object_exists(url, headers, verify_ssl):
"""Return True if the object exists (HTTP 200), False on 404, raise on other errors."""
try:
make_request(url, headers, verify_ssl=verify_ssl)
return True
except RuntimeError as e:
if "HTTP 404" in str(e):
return False
raise
def prompt_overwrite(name, resource_type, yes_to_all):
"""
Ask the user whether to overwrite an existing object.
Returns (should_write: bool, updated_yes_to_all: bool).
Accepts: y=yes, n=no, a=yes to all remaining.
"""
if yes_to_all:
return True, True
while True:
try:
answer = input(
f" {resource_type} '{name}' already exists. Overwrite? [y=yes / n=no / a=yes to all]: "
).strip().lower()
except EOFError:
print(" (non-interactive, defaulting to skip)")
return False, False
if answer == "y":
return True, False
if answer == "n":
return False, False
if answer == "a":
return True, True
def resolve_vm(paths, headers, verify_ssl, ext_id, display_name):
"""
Resolve a VM on the destination NSX by external_id first, then fall back
to display_name. The fabric VM API is always global (not project/VPC scoped).
Returns (external_id, existing_tags) or (None, []).
"""
# Primary: match by external_id (same vCenter MoRef)
try:
vms = paginate(
f"{paths['fabric_vms']}?external_id={ext_id}&page_size=10",
headers, verify_ssl,
)
if vms:
return vms[0]["external_id"], vms[0].get("tags", [])
except RuntimeError:
pass
# Fallback: match by display_name (cross-vCenter)
if display_name:
try:
vms = paginate(
f"{paths['fabric_vms']}?display_name={urllib.parse.quote(display_name)}&page_size=10",
headers, verify_ssl,
)
if vms:
if len(vms) > 1:
print(f" WARNING {display_name}: {len(vms)} VMs matched by name — using first result")
return vms[0]["external_id"], vms[0].get("tags", [])
except RuntimeError:
pass
return None, []
def merge_tag_lists(existing, incoming):
"""Union of two tag lists, deduplicated by (scope, tag) pair."""
seen = {(t.get("scope", ""), t.get("tag", "")) for t in existing}
merged = list(existing)
for t in incoming:
key = (t.get("scope", ""), t.get("tag", ""))
if key not in seen:
merged.append(t)
seen.add(key)
return merged
# ---------------------------------------------------------------------------
# IMPORT — resources
# ---------------------------------------------------------------------------
def import_tags(paths, headers, verify_ssl, tags_file, conflict, merge_tags, step_label="[1/3]"):
"""
Import VM tag assignments.
conflict: "overwrite" | "skip" | "prompt"
merge_tags: if True, union imported tags with existing instead of replacing.
Ignored when conflict=skip (existing tags are left untouched entirely).
Note: the fabric VM API used for tag updates is always global, not scoped to
the destination project/VPC.
"""
print(f"\n{step_label} Importing tags from {tags_file} ...")
print(f" Conflict mode : {conflict}" + (" + merge" if merge_tags else ""))
with open(tags_file) as f:
data = json.load(f)
assignments = data.get("assignments", [])
ok, skipped, warn, fail = 0, 0, 0, 0
yes_to_all = False
for entry in assignments:
ext_id = entry.get("external_id")
incoming_tags = entry.get("tags", [])
name = entry.get("display_name", ext_id)
resolved_id, existing_tags = resolve_vm(paths, headers, verify_ssl, ext_id, name)
if not resolved_id:
print(f" WARNING {name}: VM not found by external_id or display_name — skipping")
warn += 1
continue
has_existing_tags = bool(existing_tags)
if has_existing_tags:
if conflict == "skip":
print(f" SKIP {name}: VM already has {len(existing_tags)} tag(s)")
skipped += 1
continue
elif conflict == "prompt":
do_write, yes_to_all = prompt_overwrite(name, "VM tags", yes_to_all)
if not do_write:
skipped += 1
continue
if has_existing_tags and merge_tags:
final_tags = merge_tag_lists(existing_tags, incoming_tags)
tag_note = (
f"{len(final_tags)} tags "
f"({len(incoming_tags)} imported + {len(existing_tags)} existing, merged)"
)
else:
final_tags = incoming_tags
tag_note = f"{len(final_tags)} tags"
payload = {"external_id": resolved_id, "tags": final_tags}
try:
make_request(paths["fabric_vm_tags"], headers, method="POST", data=payload, verify_ssl=verify_ssl)
print(f" OK {name} ({tag_note})")
ok += 1
except RuntimeError as e:
print(f" FAIL {name}: {e}")
fail += 1
print(f" Tags: {ok} ok, {skipped} skipped, {warn} warnings, {fail} failed.")
def import_security_groups(paths, headers, verify_ssl, sec_groups_file, conflict, step_label="[2/3]"):
"""
Import security groups into the resolved destination context.
conflict: "overwrite" | "skip" | "prompt"
"""
print(f"\n{step_label} Importing security groups from {sec_groups_file} ...")
print(f" Conflict mode : {conflict}")
print(f" Target : {paths['groups']}")
with open(sec_groups_file) as f:
groups = json.load(f)
ok, skipped, fail = 0, 0, 0
yes_to_all = False
for group in groups:
group_id = group.get("id")
name = group.get("display_name", group_id)
group_url = f"{paths['groups']}/{group_id}"
if conflict != "overwrite":
exists = object_exists(group_url, headers, verify_ssl)
if exists:
if conflict == "skip":
print(f" SKIP {name}: group already exists")
skipped += 1
continue
elif conflict == "prompt":
do_write, yes_to_all = prompt_overwrite(name, "Security Group", yes_to_all)
if not do_write:
skipped += 1
continue
payload = clean_for_import(group)
try:
make_request(group_url, headers, method="PATCH", data=payload, verify_ssl=verify_ssl)
print(f" OK {name}")
ok += 1
except RuntimeError as e:
print(f" FAIL {name}: {e}")
fail += 1
print(f" Security groups: {ok} ok, {skipped} skipped, {fail} failed.")
def import_dfw_rules(paths, headers, verify_ssl, dfw_rules_file, conflict, step_label="[3/3]"):
"""
Import DFW security policies and their rules into the resolved destination context.
conflict: "overwrite" | "skip" | "prompt"
Rules inside a brand-new policy are always written without individual checks.
Rules inside an existing policy are individually checked against the conflict mode.
"""
print(f"\n{step_label} Importing DFW rules from {dfw_rules_file} ...")
print(f" Conflict mode : {conflict}")
print(f" Target : {paths['security_policies']}")
with open(dfw_rules_file) as f:
policies = json.load(f)
ok_policies, skip_policies, fail_policies = 0, 0, 0
ok_rules, skip_rules, fail_rules = 0, 0, 0
yes_to_all = False
for policy in policies:
policy_id = policy.get("id")
policy_name = policy.get("display_name", policy_id)
rules = policy.pop("_rules", [])
policy_url = f"{paths['security_policies']}/{policy_id}"
policy_existed = False
if conflict != "overwrite":
policy_existed = object_exists(policy_url, headers, verify_ssl)
if policy_existed:
if conflict == "skip":
print(f" SKIP policy: {policy_name} (and its {len(rules)} rule(s))")
skip_policies += 1
skip_rules += len(rules)
continue
elif conflict == "prompt":
do_write, yes_to_all = prompt_overwrite(policy_name, "DFW Policy", yes_to_all)
if not do_write:
skip_policies += 1
skip_rules += len(rules)
continue
payload = clean_for_import(policy)
try:
make_request(policy_url, headers, method="PATCH", data=payload, verify_ssl=verify_ssl)
print(f" OK policy: {policy_name}")
ok_policies += 1
except RuntimeError as e:
print(f" FAIL policy: {policy_name}: {e}")
fail_policies += 1
continue
for rule in rules:
rule_id = rule.get("id")
rule_name = rule.get("display_name", rule_id)
rule_url = f"{policy_url}/rules/{rule_id}"
if conflict != "overwrite" and policy_existed:
rule_exists = object_exists(rule_url, headers, verify_ssl)
if rule_exists:
if conflict == "skip":
print(f" SKIP rule: {rule_name}")
skip_rules += 1
continue
elif conflict == "prompt":
do_write, yes_to_all = prompt_overwrite(rule_name, "DFW Rule", yes_to_all)
if not do_write:
skip_rules += 1
continue
rule_payload = clean_for_import(rule)
try:
make_request(rule_url, headers, method="PATCH", data=rule_payload, verify_ssl=verify_ssl)
ok_rules += 1
except RuntimeError as e:
print(f" FAIL rule: {rule_name}: {e}")
fail_rules += 1
print(f" Policies: {ok_policies} ok, {skip_policies} skipped, {fail_policies} failed.")
print(f" Rules: {ok_rules} ok, {skip_rules} skipped, {fail_rules} failed.")
def run_import(args):
base_url = f"https://{args.host}"
headers = build_headers(args.username, args.password)
verify_ssl = not args.no_verify_ssl
conflict = args.conflict
merge_tags = args.merge_tags
paths = paths_from_args(base_url, args.dst_org, args.dst_project, args.dst_vpc)
print(f"\n[Import] Connecting to {args.host}")
print(f" Context : {paths['context_label']}")
action = args.action
# Validate required input files before starting
if action in ("import", "import-tags"):
if not os.path.exists(args.tags_file):
print(f"ERROR: Tags file not found: {args.tags_file}", file=sys.stderr)
sys.exit(1)
if action in ("import", "import-groups"):
if not os.path.exists(args.sec_groups_file):
print(f"ERROR: Security groups file not found: {args.sec_groups_file}", file=sys.stderr)
sys.exit(1)
if action == "import":
if not os.path.exists(args.dfw_rules_file):
print(f"ERROR: DFW rules file not found: {args.dfw_rules_file}", file=sys.stderr)
sys.exit(1)
if action == "import-tags":
import_tags(paths, headers, verify_ssl, args.tags_file, conflict, merge_tags, step_label="[1/1]")
elif action == "import-groups":
import_security_groups(paths, headers, verify_ssl, args.sec_groups_file, conflict, step_label="[1/1]")
else:
# Full import — order matters: tags → groups → rules
import_tags(paths, headers, verify_ssl, args.tags_file, conflict, merge_tags, step_label="[1/3]")
import_security_groups(paths, headers, verify_ssl, args.sec_groups_file, conflict, step_label="[2/3]")
import_dfw_rules(paths, headers, verify_ssl, args.dfw_rules_file, conflict, step_label="[3/3]")
print("\n[Import complete]")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="NSX Manager export/import tool for DFW rules, security groups, and tags.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Actions:
export Export all resources (tags, security groups, DFW rules) to JSON files
import Import all resources in order: tags → security groups → DFW rules
import-tags Import only tags (applies VM tag assignments)
import-groups Import only security groups
Context (project / VPC):
Use --src-* args for export, --dst-* args for import.
If no project is specified the default infra space is used.
--dst-vpc / --src-vpc requires the corresponding --*-project to also be set.
Context resolution:
No project/VPC → /policy/api/v1/infra/domains/default/...
Project only → /policy/api/v1/orgs/{org}/projects/{project}/infra/domains/default/...
Project + VPC → /policy/api/v1/orgs/{org}/projects/{project}/vpcs/{vpc}/...
Note: VM tag lookups always use the global fabric API regardless of project/VPC context.
Conflict modes (--conflict):
overwrite Always write, replacing any existing object [default]
skip Leave existing objects untouched, import only new ones
prompt Ask interactively for each conflict (y / n / a=yes-to-all)
Tag merge (--merge-tags, import/import-tags only):
Without flag Replace all tags on the VM with the imported set
With flag Union imported tags with existing VM tags (no removals)
Examples:
# Export from default space
python nsx_migrate.py export --host 192.168.1.10 -u admin -p VMware1! --no-verify-ssl
# Export from a VCF project
python nsx_migrate.py export --host 192.168.1.10 -u admin -p VMware1! --no-verify-ssl \\
--src-project my-project
# Export from a VPC inside a project
python nsx_migrate.py export --host 192.168.1.10 -u admin -p VMware1! --no-verify-ssl \\
--src-project my-project --src-vpc my-vpc
# Import to a specific project on the destination
python nsx_migrate.py import --host 192.168.2.10 -u admin -p VMware1! --no-verify-ssl \\
--dst-project my-project --conflict skip
# Import to a VPC on the destination
python nsx_migrate.py import --host 192.168.2.10 -u admin -p VMware1! --no-verify-ssl \\
--dst-project my-project --dst-vpc my-vpc
# Cross-project migration: export from project-a, import to project-b (same host)
python nsx_migrate.py export --host 192.168.1.10 -u admin -p VMware1! --no-verify-ssl \\
--src-project project-a
python nsx_migrate.py import --host 192.168.1.10 -u admin -p VMware1! --no-verify-ssl \\
--dst-project project-b --conflict skip
# Import only tags with merge, into a specific project
python nsx_migrate.py import-tags --host 192.168.2.10 -u admin -p VMware1! --no-verify-ssl \\
--dst-project my-project --merge-tags
# Import only security groups, skip existing, into a VPC
python nsx_migrate.py import-groups --host 192.168.2.10 -u admin -p VMware1! --no-verify-ssl \\
--dst-project my-project --dst-vpc my-vpc --conflict skip
""",
)
parser.add_argument(
"action",
choices=["export", "import", "import-tags", "import-groups"],
help="Action to perform",
)
# Connection
conn = parser.add_argument_group("Connection")
conn.add_argument("--host", required=True, help="NSX Manager hostname or IP")
conn.add_argument("--username", "-u", required=True, help="NSX Manager username")
conn.add_argument("--password", "-p", required=True, help="NSX Manager password")
conn.add_argument(
"--no-verify-ssl", action="store_true", default=False,
help="Disable SSL certificate verification (for self-signed certs)",
)
# Source context (used by export)
src = parser.add_argument_group("Source context (export)")
src.add_argument("--src-org", default="default", metavar="ORG",
help="Source VCF org (default: 'default')")
src.add_argument("--src-project", default=None, metavar="PROJECT",
help="Source VCF project name (omit for default infra space)")
src.add_argument("--src-vpc", default=None, metavar="VPC",
help="Source VPC name within the project (requires --src-project)")
# Destination context (used by import)
dst = parser.add_argument_group("Destination context (import)")
dst.add_argument("--dst-org", default="default", metavar="ORG",
help="Destination VCF org (default: 'default')")
dst.add_argument("--dst-project", default=None, metavar="PROJECT",
help="Destination VCF project name (omit for default infra space)")
dst.add_argument("--dst-vpc", default=None, metavar="VPC",
help="Destination VPC name within the project (requires --dst-project)")
# Import behaviour
imp = parser.add_argument_group("Import behaviour")
imp.add_argument(
"--conflict", choices=["overwrite", "skip", "prompt"], default="overwrite",
help="How to handle objects that already exist on the destination (default: overwrite)",
)
imp.add_argument(
"--merge-tags", action="store_true", default=False,
help="Merge imported tags with existing VM tags instead of replacing (import/import-tags only)",
)
# Files
files = parser.add_argument_group("File paths")
files.add_argument("--dfw-rules-file", default="dfw_rules.json",
help="DFW rules file (default: dfw_rules.json)")
files.add_argument("--sec-groups-file", default="sec_groups.json",
help="Security groups file (default: sec_groups.json)")
files.add_argument("--tags-file", default="tags.json",
help="Tags file (default: tags.json)")
args = parser.parse_args()
if args.action == "export":
run_export(args)
else:
run_import(args)
if __name__ == "__main__":
main()