feat: initial implementation of NSX Security Group IP Sync tool

- Added main script (main.py) for syncing IPs between NSX managers.
- Implemented logging setup and YAML configuration loading.
- Created NSXClient for interacting with NSX Policy API.
- Developed SyncEngine for comparing and syncing IPs.
- Added interactive group selection feature.
- Implemented data export functionality for JSON and CSV formats.
- Created requirements.txt for dependencies.
- Added __init__.py for package versioning.
- Included error handling and validation for configuration.
This commit is contained in:
2026-05-01 09:46:04 -05:00
commit 5b76457ad8
9 changed files with 1579 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
# NSX Security Group IP Sync
Syncs effective IP members from Security Groups on a **source** NSX manager to static IP entries on matching groups on a **target** NSX manager. The source is the source of truth for each pair.
Supports NSX **3.x, 4.x, and 9.x** via the Policy REST API.
---
## How it works
```
Source NSX Target NSX
────────────────────────── ──────────────────────────
Security-Group-A Security-Group-A
[VM Criteria: Name contains [VM Criteria: Name contains
win-svr-web] win-svr-web]
Effective IPs (computed): Static IP entries:
10.0.1.10 10.0.1.10 ← already synced
10.0.1.11 ← missing on target (nothing)
(10.0.1.99 removed from source) 10.0.1.99 ← will be removed
```
The script:
1. Fetches **effective (computed) IPs** from each group on the source.
2. Fetches **static IP entries** from matching groups on the target.
3. Diffs: source IPs are the desired state.
4. Patches the target group: **adds** missing IPs, **removes** extra IPs.
Only the `IPAddressExpression` entries are touched. Existing VM/Tag/Condition criteria on the target group are left unchanged.
---
## Requirements
- Python 3.10+
- `pip install -r requirements.txt`
---
## Quick start
```bash
# 1. Install dependencies
pip install -r requirements.txt
# 2. Copy and edit the config
cp config.example.yaml config.yaml
$EDITOR config.yaml
# 3. Dry-run (WhatIf no changes written)
python main.py --config config.yaml --whatif
# 4. Live sync
python main.py --config config.yaml
```
---
## Usage
```
python main.py [--config FILE] [options]
```
### Connection flags (override config)
| Flag | Description |
|---|---|
| `--source-host HOST` | Source NSX manager hostname or IP |
| `--source-user USER` | Source username |
| `--source-pass PASS` | Source password *(prefer env var)* |
| `--source-domain DOMAIN` | Policy domain (default: `default`) |
| `--target-host HOST` | Target NSX manager hostname or IP |
| `--target-user USER` | Target username |
| `--target-pass PASS` | Target password *(prefer env var)* |
| `--target-domain DOMAIN` | Policy domain (default: `default`) |
| `--no-verify-ssl` | Skip SSL certificate verification |
### Sync flags
| Flag | Description |
|---|---|
| `--groups NAME [NAME ...]` | Security group names to sync |
| `--whatif` | Show what would change, write nothing |
| `--interactive` | Terminal UI for group selection |
| `--workers N` | Concurrent API threads (default: 4) |
### Output / export flags
| Flag | Description |
|---|---|
| `--export json\|csv\|both` | Export source/target snapshots + diff |
| `--export-dir DIR` | Output directory (default: `exports/`) |
| `--silent` | Suppress console output (automation) |
| `--log-file FILE` | Write logs to a file |
| `--debug` | Verbose API logging |
---
## Credentials via environment variables
Store passwords in environment variables instead of the config file:
```bash
export NSX_SOURCE_PASSWORD="your-source-password"
export NSX_TARGET_PASSWORD="your-target-password"
python main.py --config config.yaml
```
The config file also supports `${ENV_VAR}` interpolation:
```yaml
password: "${NSX_SOURCE_PASSWORD}"
```
---
## WhatIf / Test mode
```bash
python main.py --config config.yaml --whatif
```
Prints exactly what would be added or removed per group without writing any changes to the target. Use this to validate before a live run.
---
## Export for auditing
```bash
# Export JSON and CSV before syncing
python main.py --config config.yaml --export both --whatif
```
Creates timestamped files in `exports/`:
| File | Contents |
|---|---|
| `source_<ts>.json/csv` | Effective IPs per group on source |
| `target_<ts>.json/csv` | Current static IPs per group on target |
| `diff_<ts>.json/csv` | Per-IP action: `add`, `remove`, or `in_sync` |
---
## Automation (silent mode)
```bash
python main.py --config config.yaml --silent --log-file logs/sync.log
echo "Exit code: $?"
```
Exit codes:
| Code | Meaning |
|---|---|
| `0` | Success all groups synced (or already in sync) |
| `1` | Configuration error check args/config |
| `2` | Runtime error one or more groups failed; check logs |
---
## Interactive mode
```bash
python main.py --config config.yaml --interactive
```
Pulls the full group list from the source manager and presents a paginated terminal selector. Use row numbers to toggle selection, then press `d` to confirm.
---
## Project structure
```
nsx_fed_sync_script/
├── main.py Entry point / CLI
├── config.example.yaml Configuration template
├── requirements.txt
├── .gitignore
├── nsx_sync/
│ ├── nsx_client.py NSX Policy API client (auth, pagination, retries)
│ ├── sync_engine.py Diff + apply logic (concurrent fetch & patch)
│ ├── exporter.py JSON / CSV export helpers
│ └── interactive.py Terminal group-selection UI
├── exports/ Runtime export files (git-ignored)
└── logs/ Runtime log files (git-ignored)
```
---
## Notes on NSX version compatibility
The script exclusively uses the **NSX Policy API** (`/policy/api/v1/...`), which is available and recommended on NSX 3.x, 4.x, and later. The deprecated Manager API (`/api/v1/ns-groups/...`) is not used.
For NSX 3.x environments where the Policy API may not yet be the primary interface, ensure that Security Groups are managed through the Policy plane (not the legacy Manager plane) for the effective-IP endpoint to return accurate results.
+54
View File
@@ -0,0 +1,54 @@
# NSX Security Group IP Sync Example Configuration
# Copy this file to config.yaml and fill in your values.
# Passwords can be supplied via environment variables using ${VAR_NAME} syntax.
# CLI flags always take precedence over values in this file.
# ---------------------------------------------------------------------------
# Source NSX Manager (source of truth)
# ---------------------------------------------------------------------------
source:
host: nsx-source.example.com # Hostname or IP of the source NSX manager
username: admin
password: "${NSX_SOURCE_PASSWORD}" # Or paste password directly (not recommended)
verify_ssl: true # Set to false for self-signed certificates
domain: default # Policy domain almost always 'default'
# ---------------------------------------------------------------------------
# Target NSX Manager (will be updated to match source)
# ---------------------------------------------------------------------------
target:
host: nsx-target.example.com
username: admin
password: "${NSX_TARGET_PASSWORD}"
verify_ssl: true
domain: default
# ---------------------------------------------------------------------------
# Security Groups to sync (display_name or group ID)
# These must exist on both source and target managers.
# The script will add/remove static IP entries on the TARGET to match the
# computed effective IP membership on the SOURCE.
# ---------------------------------------------------------------------------
groups:
- Security-Group-A
- Security-Group-B
- win-svr-web-group
- win-svr-app-group
# ---------------------------------------------------------------------------
# Behaviour options (all overridable with CLI flags)
# ---------------------------------------------------------------------------
options:
whatif: false # true = show changes without applying them (--whatif)
silent: false # true = suppress console output, useful for automation
debug: false # true = verbose API request/response logging
log_file: logs/sync.log # Set to null/empty to disable file logging
workers: 4 # Concurrent API threads (source+target fetched in parallel)
# Export: generate JSON/CSV snapshots of source, target, and diff
# Values: json | csv | both | null (disabled)
export: null
export_dir: exports
# interactive: launch group selection TUI on startup (nice-to-have, low priority)
interactive: false
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env python3
"""
NSX Security Group IP Sync
===========================
Syncs effective IP members from Security Groups on a source NSX manager to
static IP entries on matching groups on a target NSX manager.
The source manager is the source of truth: IPs present in source but missing
on target are added; IPs on target not in source are removed.
Usage examples
--------------
# Sync with a config file (recommended for automation):
python main.py --config config.yaml
# Dry-run / WhatIf (no changes written):
python main.py --config config.yaml --whatif
# Export source + target snapshots and diff before syncing:
python main.py --config config.yaml --export json
# Interactive group selection:
python main.py --source-host nsx1.example.com --target-host nsx2.example.com \\
--source-user admin --target-user admin --interactive
# Silent mode for automation (logs to file only):
python main.py --config config.yaml --silent --log-file logs/sync.log
"""
import argparse
import logging
import os
import sys
import yaml
# ---------------------------------------------------------------------------
# Logging setup (called before imports that use logger)
# ---------------------------------------------------------------------------
def setup_logging(silent: bool, log_file: str | None, debug: bool) -> None:
level = logging.DEBUG if debug else (logging.WARNING if silent else logging.INFO)
handlers: list = []
if not silent:
handlers.append(logging.StreamHandler(sys.stdout))
if log_file:
os.makedirs(os.path.dirname(log_file) or ".", exist_ok=True)
handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
if not handlers:
handlers.append(logging.NullHandler())
logging.basicConfig(
level=level,
format="%(asctime)s [%(levelname)s] %(name)s %(message)s",
handlers=handlers,
force=True,
)
# ---------------------------------------------------------------------------
# Config loading
# ---------------------------------------------------------------------------
def load_yaml_config(path: str) -> dict:
with open(path, encoding="utf-8") as fh:
raw = fh.read()
# Expand ${ENV_VAR} patterns
import re
def _expand(m):
val = os.environ.get(m.group(1), "")
if not val:
logging.warning("Environment variable '%s' is not set.", m.group(1))
return val
raw = re.sub(r"\$\{([^}]+)\}", _expand, raw)
return yaml.safe_load(raw)
def build_config(args: argparse.Namespace) -> dict:
"""Merge YAML config file and CLI flags (CLI takes precedence)."""
cfg: dict = {}
if args.config:
cfg = load_yaml_config(args.config)
def _cli(key, section=None, default=None):
"""Return CLI arg value if set, else config value, else default."""
cli_val = getattr(args, key.replace("-", "_"), None)
if cli_val is not None:
return cli_val
if section and section in cfg:
return cfg[section].get(key.replace("-", "_"), default)
return cfg.get(key.replace("-", "_"), default)
source_host = _cli("source_host", "source") or args.__dict__.get("source_host")
target_host = _cli("target_host", "target") or args.__dict__.get("target_host")
config = {
"source": {
"host": source_host or cfg.get("source", {}).get("host"),
"username": args.source_user or cfg.get("source", {}).get("username"),
"password": args.source_pass
or os.environ.get("NSX_SOURCE_PASSWORD")
or cfg.get("source", {}).get("password"),
"verify_ssl": not args.no_verify_ssl
if args.no_verify_ssl is not None
else cfg.get("source", {}).get("verify_ssl", True),
"domain": args.source_domain or cfg.get("source", {}).get("domain", "default"),
},
"target": {
"host": target_host or cfg.get("target", {}).get("host"),
"username": args.target_user or cfg.get("target", {}).get("username"),
"password": args.target_pass
or os.environ.get("NSX_TARGET_PASSWORD")
or cfg.get("target", {}).get("password"),
"verify_ssl": not args.no_verify_ssl
if args.no_verify_ssl is not None
else cfg.get("target", {}).get("verify_ssl", True),
"domain": args.target_domain or cfg.get("target", {}).get("domain", "default"),
},
"groups": args.groups or cfg.get("groups", []),
"options": {
"whatif": args.whatif or cfg.get("options", {}).get("whatif", False),
"silent": args.silent or cfg.get("options", {}).get("silent", False),
"debug": args.debug or cfg.get("options", {}).get("debug", False),
"log_file": args.log_file or cfg.get("options", {}).get("log_file"),
"export": args.export or cfg.get("options", {}).get("export"),
"export_dir": args.export_dir or cfg.get("options", {}).get("export_dir", "exports"),
"interactive": args.interactive or cfg.get("options", {}).get("interactive", False),
"workers": args.workers or cfg.get("options", {}).get("workers", 4),
},
}
return config
def validate_config(cfg: dict) -> list[str]:
errors = []
for side in ("source", "target"):
s = cfg.get(side, {})
if not s.get("host"):
errors.append(f"Missing {side} host.")
if not s.get("username"):
errors.append(f"Missing {side} username.")
if not s.get("password"):
errors.append(f"Missing {side} password.")
return errors
# ---------------------------------------------------------------------------
# Argument parser
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description="Sync NSX Security Group IPs between two NSX managers.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
p.add_argument("--config", metavar="FILE",
help="Path to YAML config file.")
conn = p.add_argument_group("Connection (override config)")
conn.add_argument("--source-host", metavar="HOST",
help="Source NSX manager hostname or IP.")
conn.add_argument("--source-user", metavar="USER",
help="Source NSX manager username.")
conn.add_argument("--source-pass", metavar="PASS",
help="Source NSX manager password (prefer NSX_SOURCE_PASSWORD env var).")
conn.add_argument("--source-domain", metavar="DOMAIN", default=None,
help="Source policy domain (default: 'default').")
conn.add_argument("--target-host", metavar="HOST",
help="Target NSX manager hostname or IP.")
conn.add_argument("--target-user", metavar="USER",
help="Target NSX manager username.")
conn.add_argument("--target-pass", metavar="PASS",
help="Target NSX manager password (prefer NSX_TARGET_PASSWORD env var).")
conn.add_argument("--target-domain", metavar="DOMAIN", default=None,
help="Target policy domain (default: 'default').")
conn.add_argument("--no-verify-ssl", action="store_true", default=None,
help="Disable SSL certificate verification (self-signed certs).")
sync = p.add_argument_group("Sync options")
sync.add_argument("--groups", nargs="+", metavar="GROUP",
help="Security group names to sync (space-separated).")
sync.add_argument("--whatif", action="store_true",
help="Show what would change without making any modifications.")
sync.add_argument("--interactive", action="store_true",
help="Launch interactive group selection TUI.")
sync.add_argument("--workers", type=int, default=None, metavar="N",
help="Number of concurrent API workers (default: 4).")
out = p.add_argument_group("Output / export")
out.add_argument("--export", choices=["json", "csv", "both"],
help="Export source/target snapshots and diff report.")
out.add_argument("--export-dir", metavar="DIR", default=None,
help="Directory for export files (default: exports/).")
out.add_argument("--silent", action="store_true",
help="Suppress console output (for automation).")
out.add_argument("--log-file", metavar="FILE",
help="Write logs to this file.")
out.add_argument("--debug", action="store_true",
help="Enable verbose debug logging.")
return p
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
parser = build_parser()
args = parser.parse_args()
# Quick early exit: no args at all → show help
if len(sys.argv) == 1:
parser.print_help()
return 0
# Config must be built before we can know silent/log_file
cfg = build_config(args)
opts = cfg["options"]
setup_logging(
silent=opts["silent"],
log_file=opts["log_file"],
debug=opts["debug"],
)
log = logging.getLogger(__name__)
# Deferred imports (after logging is configured)
from nsx_sync.nsx_client import NSXClient
from nsx_sync.sync_engine import SyncEngine
from nsx_sync.exporter import export_full_report
from nsx_sync.interactive import select_groups
errors = validate_config(cfg)
if errors:
for e in errors:
print(f"[ERROR] {e}", file=sys.stderr)
return 1
src_cfg = cfg["source"]
tgt_cfg = cfg["target"]
source = NSXClient(
host=src_cfg["host"],
username=src_cfg["username"],
password=src_cfg["password"],
verify_ssl=src_cfg["verify_ssl"],
label=f"source({src_cfg['host']})",
)
target = NSXClient(
host=tgt_cfg["host"],
username=tgt_cfg["username"],
password=tgt_cfg["password"],
verify_ssl=tgt_cfg["verify_ssl"],
label=f"target({tgt_cfg['host']})",
)
# Resolve group list
group_names: list[str] = list(cfg["groups"] or [])
if opts["interactive"]:
group_names = select_groups(
source=source,
domain=src_cfg["domain"],
preselected=group_names,
)
if not group_names:
return 0
if not group_names:
print("[ERROR] No groups specified. Use --groups, --config, or --interactive.",
file=sys.stderr)
return 1
log.info(
"Starting sync: %s%s | %d group(s) | whatif=%s",
src_cfg["host"], tgt_cfg["host"], len(group_names), opts["whatif"],
)
engine = SyncEngine(
source=source,
target=target,
source_domain=src_cfg["domain"],
target_domain=tgt_cfg["domain"],
max_workers=opts["workers"],
)
diffs, results = engine.sync(group_names, whatif=opts["whatif"])
# Print summary unless silent
if not opts["silent"]:
SyncEngine.print_summary(diffs, results, whatif=opts["whatif"])
# Export if requested
if opts["export"]:
written = export_full_report(
source_client=source,
target_client=target,
group_names=group_names,
diffs=diffs,
output_dir=opts["export_dir"],
fmt=opts["export"],
source_domain=src_cfg["domain"],
target_domain=tgt_cfg["domain"],
)
if not opts["silent"]:
print("Export files written:")
for key, path in written.items():
print(f" {key}: {path}")
# Exit code: 0 = success, 2 = one or more groups had errors
had_errors = any(r.status == "error" for r in results)
return 2 if had_errors else 0
if __name__ == "__main__":
sys.exit(main())
+3
View File
@@ -0,0 +1,3 @@
"""NSX Security Group IP Sync package."""
__version__ = "1.0.0"
+272
View File
@@ -0,0 +1,272 @@
"""
Export source/target group IP data to JSON or CSV for auditing and testing.
"""
import csv
import json
import logging
import os
from datetime import datetime
from typing import Dict, List, Optional
from .nsx_client import NSXClient
from .sync_engine import GroupDiff
logger = logging.getLogger(__name__)
def _timestamp() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S")
def _ensure_dir(path: str) -> None:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
# ------------------------------------------------------------------
# Data collection helpers
# ------------------------------------------------------------------
def collect_group_data(
client: NSXClient,
group_names: List[str],
domain: str = "default",
include_effective_ips: bool = True,
) -> List[Dict]:
"""
Build a list of dicts, one per group, with metadata and IP lists.
Effective IP fetching is optional (slow for large environments).
"""
records = []
for name in group_names:
record: Dict = {
"group_name": name,
"domain": domain,
"nsx_manager": client.label,
"timestamp": datetime.utcnow().isoformat() + "Z",
"static_ips": [],
"effective_ips": [],
"group_found": True,
"error": None,
}
try:
group = client.get_group(name, domain)
record["group_id"] = group.get("id", "")
record["display_name"] = group.get("display_name", "")
record["description"] = group.get("description", "")
for expr in group.get("expression", []):
if expr.get("resource_type") == "IPAddressExpression":
record["static_ips"].extend(expr.get("ip_addresses", []))
record["static_ips"] = sorted(set(record["static_ips"]))
if include_effective_ips:
record["effective_ips"] = client.get_group_effective_ips(name, domain)
except Exception as exc:
msg = str(exc)
if "not found" in msg.lower():
record["group_found"] = False
record["error"] = msg
records.append(record)
return records
def collect_diff_data(diffs: List[GroupDiff]) -> List[Dict]:
"""Serialize a list of GroupDiff objects to plain dicts for export."""
return [
{
"group_name": d.group_name,
"source_ip_count": len(d.source_ips),
"target_static_ip_count": len(d.target_static_ips),
"ips_to_add_count": len(d.ips_to_add),
"ips_to_remove_count": len(d.ips_to_remove),
"source_found": d.source_found,
"target_found": d.target_found,
"source_ips": d.source_ips,
"target_static_ips": d.target_static_ips,
"ips_to_add": d.ips_to_add,
"ips_to_remove": d.ips_to_remove,
"error": d.error,
}
for d in diffs
]
# ------------------------------------------------------------------
# JSON export
# ------------------------------------------------------------------
def export_json(
data: List[Dict],
output_path: Optional[str] = None,
label: str = "export",
) -> str:
if not output_path:
output_path = os.path.join("exports", f"{label}_{_timestamp()}.json")
_ensure_dir(output_path)
with open(output_path, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, default=str)
logger.info("JSON export written to: %s", output_path)
return output_path
# ------------------------------------------------------------------
# CSV export
# ------------------------------------------------------------------
def _flatten_for_csv(record: Dict) -> List[Dict]:
"""
Expand a group record so each IP occupies its own row.
If there are no IPs we still emit one row.
"""
base = {
"group_name": record.get("group_name", ""),
"group_id": record.get("group_id", ""),
"display_name": record.get("display_name", ""),
"nsx_manager": record.get("nsx_manager", ""),
"domain": record.get("domain", ""),
"timestamp": record.get("timestamp", ""),
"group_found": record.get("group_found", ""),
"error": record.get("error", ""),
}
all_ips = sorted(
set(record.get("effective_ips", []) + record.get("static_ips", []))
)
if not all_ips:
return [{**base, "ip_address": "", "ip_type": ""}]
rows = []
static_set = set(record.get("static_ips", []))
effective_set = set(record.get("effective_ips", []))
for ip in all_ips:
types = []
if ip in static_set:
types.append("static")
if ip in effective_set:
types.append("effective")
rows.append({**base, "ip_address": ip, "ip_type": "/".join(types)})
return rows
def _flatten_diff_for_csv(record: Dict) -> List[Dict]:
"""Expand a diff record so each IP occupies its own row."""
base = {
"group_name": record.get("group_name", ""),
"source_found": record.get("source_found", ""),
"target_found": record.get("target_found", ""),
"error": record.get("error", ""),
}
all_ips = sorted(
set(
record.get("source_ips", [])
+ record.get("target_static_ips", [])
+ record.get("ips_to_add", [])
+ record.get("ips_to_remove", [])
)
)
if not all_ips:
return [{**base, "ip_address": "", "action": "no_change"}]
src = set(record.get("source_ips", []))
tgt = set(record.get("target_static_ips", []))
add = set(record.get("ips_to_add", []))
rem = set(record.get("ips_to_remove", []))
rows = []
for ip in all_ips:
if ip in add:
action = "add"
elif ip in rem:
action = "remove"
else:
action = "in_sync"
rows.append(
{
**base,
"ip_address": ip,
"in_source": ip in src,
"in_target": ip in tgt,
"action": action,
}
)
return rows
def export_csv(
data: List[Dict],
output_path: Optional[str] = None,
label: str = "export",
is_diff: bool = False,
) -> str:
if not output_path:
output_path = os.path.join("exports", f"{label}_{_timestamp()}.csv")
_ensure_dir(output_path)
rows: List[Dict] = []
for record in data:
if is_diff:
rows.extend(_flatten_diff_for_csv(record))
else:
rows.extend(_flatten_for_csv(record))
if not rows:
logger.warning("No data to write to CSV.")
return output_path
with open(output_path, "w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
logger.info("CSV export written to: %s", output_path)
return output_path
# ------------------------------------------------------------------
# Convenience: export both source and target snapshots + diff
# ------------------------------------------------------------------
def export_full_report(
source_client: NSXClient,
target_client: NSXClient,
group_names: List[str],
diffs: List[GroupDiff],
output_dir: str = "exports",
fmt: str = "both",
source_domain: str = "default",
target_domain: str = "default",
) -> Dict[str, str]:
"""
Write source snapshot, target snapshot, and diff report.
fmt: 'json' | 'csv' | 'both'
Returns a dict of {report_name: file_path}.
"""
ts = _timestamp()
written: Dict[str, str] = {}
src_data = collect_group_data(source_client, group_names, source_domain)
tgt_data = collect_group_data(target_client, group_names, target_domain)
diff_data = collect_diff_data(diffs)
def _path(name: str, ext: str) -> str:
return os.path.join(output_dir, f"{name}_{ts}.{ext}")
if fmt in ("json", "both"):
written["source_json"] = export_json(src_data, _path("source", "json"), "source")
written["target_json"] = export_json(tgt_data, _path("target", "json"), "target")
written["diff_json"] = export_json(diff_data, _path("diff", "json"), "diff")
if fmt in ("csv", "both"):
written["source_csv"] = export_csv(src_data, _path("source", "csv"), "source")
written["target_csv"] = export_csv(tgt_data, _path("target", "csv"), "target")
written["diff_csv"] = export_csv(
diff_data, _path("diff", "csv"), "diff", is_diff=True
)
return written
+172
View File
@@ -0,0 +1,172 @@
"""
Interactive mode: list all Security Groups from the source manager and
allow the user to select which ones to sync via a terminal UI.
This is a nice-to-have feature and only runs when --interactive is passed.
Requires no additional dependencies beyond the standard library.
"""
import logging
from typing import List, Optional
from .nsx_client import NSXClient
logger = logging.getLogger(__name__)
_PAGE = 20 # groups per screen page
def _print_page(groups: List[dict], page: int, selected: set) -> None:
start = page * _PAGE
end = min(start + _PAGE, len(groups))
total_pages = (len(groups) + _PAGE - 1) // _PAGE
print(f"\n Security Groups (page {page + 1}/{total_pages}) "
f" {len(selected)} selected")
print(f" {'#':<5} {'*':<3} {'Display Name':<50} {'ID'}")
print(f" {'-'*5} {'-'*3} {'-'*50} {'-'*36}")
for i, g in enumerate(groups[start:end], start=start):
marker = "*" if g["id"] in selected else " "
name = g.get("display_name", g["id"])[:50]
print(f" {i:<5} {marker:<3} {name:<50} {g['id']}")
def _prompt(msg: str, default: str = "") -> str:
try:
val = input(msg).strip()
return val if val else default
except (KeyboardInterrupt, EOFError):
return "q"
def select_groups(
source: NSXClient,
domain: str = "default",
preselected: Optional[List[str]] = None,
) -> List[str]:
"""
Interactive terminal selector. Returns a list of group names (display_name
if unique, otherwise id) the user wants to sync.
Navigation:
<number(s)> toggle selection by row index (comma-separated)
a select all on current page
n next page
p previous page
s show currently selected list
d done / confirm
q quit without selecting
"""
print(f"\nFetching Security Groups from [{source.label}]...")
try:
all_groups = source.list_groups(domain)
except Exception as exc:
logger.error("Failed to list groups: %s", exc)
print(f"\nError: {exc}")
return []
if not all_groups:
print("No Security Groups found.")
return []
all_groups.sort(key=lambda g: g.get("display_name", g["id"]).lower())
selected: set = set()
if preselected:
for g in all_groups:
if g.get("display_name") in preselected or g["id"] in preselected:
selected.add(g["id"])
page = 0
total_pages = (len(all_groups) + _PAGE - 1) // _PAGE
print(
"\nControls: enter row number(s) to toggle | 'a' all on page | "
"'n' next | 'p' prev | 's' show selected | 'd' done | 'q' quit"
)
while True:
_print_page(all_groups, page, selected)
cmd = _prompt("\n > ").lower()
if cmd == "q":
print("Cancelled.")
return []
if cmd == "d":
break
if cmd == "n":
if page < total_pages - 1:
page += 1
else:
print(" Already on last page.")
continue
if cmd == "p":
if page > 0:
page -= 1
else:
print(" Already on first page.")
continue
if cmd == "a":
start = page * _PAGE
end = min(start + _PAGE, len(all_groups))
for g in all_groups[start:end]:
selected.add(g["id"])
continue
if cmd == "s":
if not selected:
print(" Nothing selected yet.")
else:
print(f"\n Selected ({len(selected)}):")
for gid in sorted(selected):
for g in all_groups:
if g["id"] == gid:
print(f" - {g.get('display_name', gid)}")
break
continue
# Try to parse as comma-separated indices
tokens = [t.strip() for t in cmd.replace(" ", ",").split(",") if t.strip()]
valid = True
indices = []
for tok in tokens:
if tok.isdigit():
idx = int(tok)
if 0 <= idx < len(all_groups):
indices.append(idx)
else:
print(f" Index {idx} is out of range.")
valid = False
else:
print(f" Unknown command or index: '{tok}'")
valid = False
if valid and indices:
for idx in indices:
gid = all_groups[idx]["id"]
if gid in selected:
selected.discard(gid)
print(
f" Deselected: {all_groups[idx].get('display_name', gid)}"
)
else:
selected.add(gid)
print(
f" Selected: {all_groups[idx].get('display_name', gid)}"
)
if not selected:
print("No groups selected nothing to sync.")
return []
# Return display_names if they're unique, otherwise IDs
display_names = [g.get("display_name", g["id"]) for g in all_groups if g["id"] in selected]
print(f"\nConfirmed {len(display_names)} group(s) for sync:")
for name in display_names:
print(f" - {name}")
return display_names
+300
View File
@@ -0,0 +1,300 @@
"""
NSX Policy API client supporting NSX 3.x, 4.x, and 9.x managers.
Uses the Policy API (preferred over the deprecated Manager API).
"""
import logging
import time
from typing import Any, Dict, List, Optional, Tuple
import requests
import urllib3
logger = logging.getLogger(__name__)
_POLICY_BASE = "/policy/api/v1"
_PAGE_SIZE = 1000
_MAX_RETRIES = 3
_RETRY_BACKOFF = 2 # seconds
class NSXAuthError(Exception):
pass
class NSXAPIError(Exception):
def __init__(self, message: str, status_code: int = 0, body: str = ""):
super().__init__(message)
self.status_code = status_code
self.body = body
class NSXClient:
"""
Thin wrapper around the NSX Policy REST API.
Supports basic auth (username + password) and handles:
- Cursor-based pagination
- Automatic retries on transient errors
- Optional SSL verification bypass (for self-signed certs)
"""
def __init__(
self,
host: str,
username: str,
password: str,
verify_ssl: bool = True,
timeout: int = 30,
label: str = "",
):
self.base_url = f"https://{host.rstrip('/')}{_POLICY_BASE}"
self.timeout = timeout
self.label = label or host
if not verify_ssl:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self._session = requests.Session()
self._session.auth = (username, password)
self._session.verify = verify_ssl
self._session.headers.update(
{"Content-Type": "application/json", "Accept": "application/json"}
)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _url(self, path: str) -> str:
return f"{self.base_url}/{path.lstrip('/')}"
def _request(
self,
method: str,
path: str,
**kwargs,
) -> requests.Response:
url = self._url(path)
last_exc: Optional[Exception] = None
for attempt in range(1, _MAX_RETRIES + 1):
try:
resp = self._session.request(
method, url, timeout=self.timeout, **kwargs
)
if resp.status_code == 401:
raise NSXAuthError(
f"[{self.label}] Authentication failed check credentials."
)
if resp.status_code == 403:
raise NSXAuthError(
f"[{self.label}] Access denied to {path} check permissions."
)
if resp.status_code >= 500 and attempt < _MAX_RETRIES:
logger.warning(
"[%s] HTTP %s on %s, retry %d/%d",
self.label, resp.status_code, path, attempt, _MAX_RETRIES,
)
time.sleep(_RETRY_BACKOFF * attempt)
continue
if not resp.ok:
raise NSXAPIError(
f"[{self.label}] HTTP {resp.status_code} on {method} {path}",
status_code=resp.status_code,
body=resp.text,
)
return resp
except (requests.ConnectionError, requests.Timeout) as exc:
last_exc = exc
if attempt < _MAX_RETRIES:
logger.warning(
"[%s] Connection error on %s, retry %d/%d: %s",
self.label, path, attempt, _MAX_RETRIES, exc,
)
time.sleep(_RETRY_BACKOFF * attempt)
raise NSXAPIError(
f"[{self.label}] Failed to reach {path} after {_MAX_RETRIES} attempts: {last_exc}"
)
def _get_paged(self, path: str, params: Optional[Dict] = None) -> List[Dict]:
"""Collect all pages for a list endpoint using cursor-based pagination."""
params = dict(params or {})
params["page_size"] = _PAGE_SIZE
results: List[Dict] = []
while True:
resp = self._request("GET", path, params=params)
data = resp.json()
results.extend(data.get("results", []))
cursor = data.get("cursor")
if not cursor:
break
params["cursor"] = cursor
return results
# ------------------------------------------------------------------
# Version / connectivity check
# ------------------------------------------------------------------
def get_version(self) -> str:
"""Return the NSX manager version string."""
resp = self._request("GET", "/infra/sites")
# Version is available via a different endpoint
try:
resp2 = self._session.get(
f"https://{self.base_url.split('//')[1].split('/')[0]}/api/v1/node/version",
timeout=self.timeout,
)
if resp2.ok:
return resp2.json().get("product_version", "unknown")
except Exception:
pass
return "unknown"
# ------------------------------------------------------------------
# Groups
# ------------------------------------------------------------------
def list_groups(self, domain: str = "default") -> List[Dict]:
"""Return all security groups in the given policy domain."""
logger.debug("[%s] Listing groups in domain '%s'", self.label, domain)
return self._get_paged(f"/infra/domains/{domain}/groups")
def get_group(self, group_id: str, domain: str = "default") -> Dict:
"""Return a single group definition by ID or display_name."""
groups = self.list_groups(domain)
# Try exact ID match first, then display_name
for g in groups:
if g.get("id") == group_id or g.get("display_name") == group_id:
return g
raise NSXAPIError(
f"[{self.label}] Group '{group_id}' not found in domain '{domain}'"
)
def get_group_effective_ips(
self, group_id: str, domain: str = "default"
) -> List[str]:
"""
Return the list of effective (computed) IP addresses that are members
of the group. This resolves VM-based, tag-based, etc. criteria.
"""
logger.debug(
"[%s] Fetching effective IPs for group '%s'", self.label, group_id
)
path = f"/infra/domains/{domain}/groups/{group_id}/members/ip-addresses"
raw = self._get_paged(path)
# Each item is a plain string (the IP / CIDR)
ips = [item if isinstance(item, str) else item.get("ip_address", "") for item in raw]
return sorted(ip for ip in ips if ip)
# ------------------------------------------------------------------
# IP Address Expression helpers
# ------------------------------------------------------------------
@staticmethod
def _extract_ip_expressions(
expressions: List[Dict],
) -> Tuple[List[Dict], List[str]]:
"""
Split expressions into (non-ip-entries, existing-static-ips).
Removes IPAddressExpression items and their preceding ConjunctionOperators.
Returns a clean expression list ready for re-injection and the existing IPs.
"""
existing_ips: List[str] = []
clean: List[Dict] = []
skip_next_conjunction = False
for expr in expressions:
rtype = expr.get("resource_type", "")
if rtype == "IPAddressExpression":
existing_ips.extend(expr.get("ip_addresses", []))
# Remove the conjunction that precedes this entry (if we added it)
if clean and clean[-1].get("resource_type") == "ConjunctionOperator":
clean.pop()
continue
clean.append(expr)
_ = skip_next_conjunction # unused, kept for clarity
return clean, existing_ips
def patch_group_ips(
self,
group_id: str,
ips_to_add: List[str],
ips_to_remove: List[str],
domain: str = "default",
whatif: bool = False,
) -> Dict:
"""
Modify the static IP address entries in a target group:
- Adds ips_to_add as static entries
- Removes ips_to_remove from static entries
- Leaves all non-IP expressions (Conditions, Tags, etc.) untouched
Returns a result dict describing what was (or would have been) done.
"""
group = self.get_group(group_id, domain)
gid = group["id"]
display = group.get("display_name", gid)
expressions = list(group.get("expression", []))
base_exprs, current_static_ips = self._extract_ip_expressions(expressions)
current_set = set(current_static_ips)
desired_set = (current_set | set(ips_to_add)) - set(ips_to_remove)
actually_added = sorted(set(ips_to_add) - current_set)
actually_removed = sorted(current_set & set(ips_to_remove))
result = {
"group_id": gid,
"display_name": display,
"ips_added": actually_added,
"ips_removed": actually_removed,
"whatif": whatif,
}
if not actually_added and not actually_removed:
logger.info("[%s] Group '%s': no changes needed.", self.label, display)
result["status"] = "no_change"
return result
if whatif:
logger.info(
"[%s] [WHATIF] Group '%s': would add %d, remove %d IPs.",
self.label, display, len(actually_added), len(actually_removed),
)
result["status"] = "whatif"
return result
# Build the new expression list
new_exprs = list(base_exprs)
if desired_set:
if new_exprs:
new_exprs.append(
{"resource_type": "ConjunctionOperator", "conjunction_operator": "OR"}
)
new_exprs.append(
{
"resource_type": "IPAddressExpression",
"ip_addresses": sorted(desired_set),
}
)
patch_body = dict(group)
patch_body["expression"] = new_exprs
self._request(
"PATCH",
f"/infra/domains/{domain}/groups/{gid}",
json=patch_body,
)
logger.info(
"[%s] Group '%s': added %d, removed %d IPs.",
self.label, display, len(actually_added), len(actually_removed),
)
result["status"] = "updated"
return result
+261
View File
@@ -0,0 +1,261 @@
"""
Core sync logic: compare source effective IPs to target static IPs and
produce a change plan, then optionally apply it.
"""
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from .nsx_client import NSXClient
logger = logging.getLogger(__name__)
@dataclass
class GroupDiff:
group_name: str
source_ips: List[str] = field(default_factory=list)
target_static_ips: List[str] = field(default_factory=list)
ips_to_add: List[str] = field(default_factory=list)
ips_to_remove: List[str] = field(default_factory=list)
source_found: bool = True
target_found: bool = True
error: Optional[str] = None
@dataclass
class SyncResult:
group_name: str
status: str # updated | no_change | whatif | error | skipped
ips_added: List[str] = field(default_factory=list)
ips_removed: List[str] = field(default_factory=list)
error: Optional[str] = None
class SyncEngine:
"""
Orchestrates a full source→target sync for a list of Security Group names.
Strategy
--------
1. Fetch effective IPs from each group on the source manager.
2. Fetch the current static IP entries from matching groups on the target.
3. Diff: source effective IPs are the desired state for static entries on target.
4. Apply: PATCH target groups to add/remove IPs (skipped in whatif mode).
"""
def __init__(
self,
source: NSXClient,
target: NSXClient,
source_domain: str = "default",
target_domain: str = "default",
max_workers: int = 4,
):
self.source = source
self.target = target
self.source_domain = source_domain
self.target_domain = target_domain
self.max_workers = max_workers
# ------------------------------------------------------------------
# Phase 1: gather data concurrently
# ------------------------------------------------------------------
def _fetch_source_ips(self, group_name: str) -> tuple:
"""Return (group_name, ips_list, error_or_None)."""
try:
ips = self.source.get_group_effective_ips(group_name, self.source_domain)
return group_name, ips, None
except Exception as exc:
return group_name, [], str(exc)
def _fetch_target_static_ips(self, group_name: str) -> tuple:
"""Return (group_name, static_ips_list, found_bool, error_or_None)."""
try:
group = self.target.get_group(group_name, self.target_domain)
expressions = group.get("expression", [])
ips: List[str] = []
for expr in expressions:
if expr.get("resource_type") == "IPAddressExpression":
ips.extend(expr.get("ip_addresses", []))
return group_name, sorted(ips), True, None
except Exception as exc:
msg = str(exc)
if "not found" in msg.lower():
return group_name, [], False, None
return group_name, [], True, msg
def build_diff(self, group_names: List[str]) -> List[GroupDiff]:
"""
Concurrently fetch source and target data, then compute per-group diffs.
"""
source_map: Dict[str, tuple] = {}
target_map: Dict[str, tuple] = {}
logger.info(
"Fetching data from source [%s] and target [%s] for %d group(s)...",
self.source.label, self.target.label, len(group_names),
)
with ThreadPoolExecutor(max_workers=self.max_workers) as pool:
src_futures = {
pool.submit(self._fetch_source_ips, g): g for g in group_names
}
tgt_futures = {
pool.submit(self._fetch_target_static_ips, g): g for g in group_names
}
for fut in as_completed(src_futures):
name, ips, err = fut.result()
source_map[name] = (ips, err)
for fut in as_completed(tgt_futures):
name, ips, found, err = fut.result()
target_map[name] = (ips, found, err)
diffs: List[GroupDiff] = []
for name in group_names:
src_ips, src_err = source_map.get(name, ([], "not fetched"))
tgt_ips, tgt_found, tgt_err = target_map.get(name, ([], True, "not fetched"))
diff = GroupDiff(group_name=name)
if src_err:
diff.error = f"Source error: {src_err}"
diff.source_found = False
diffs.append(diff)
continue
if tgt_err:
diff.error = f"Target error: {tgt_err}"
diffs.append(diff)
continue
if not tgt_found:
diff.target_found = False
diff.error = f"Group '{name}' not found on target skipping."
diffs.append(diff)
continue
diff.source_ips = src_ips
diff.target_static_ips = tgt_ips
src_set = set(src_ips)
tgt_set = set(tgt_ips)
diff.ips_to_add = sorted(src_set - tgt_set)
diff.ips_to_remove = sorted(tgt_set - src_set)
diffs.append(diff)
return diffs
# ------------------------------------------------------------------
# Phase 2: apply changes
# ------------------------------------------------------------------
def _apply_diff(self, diff: GroupDiff, whatif: bool) -> SyncResult:
if diff.error:
return SyncResult(
group_name=diff.group_name,
status="skipped",
error=diff.error,
)
if not diff.ips_to_add and not diff.ips_to_remove:
logger.info("Group '%s': already in sync.", diff.group_name)
return SyncResult(group_name=diff.group_name, status="no_change")
try:
patch_result = self.target.patch_group_ips(
group_id=diff.group_name,
ips_to_add=diff.ips_to_add,
ips_to_remove=diff.ips_to_remove,
domain=self.target_domain,
whatif=whatif,
)
return SyncResult(
group_name=diff.group_name,
status=patch_result["status"],
ips_added=patch_result.get("ips_added", []),
ips_removed=patch_result.get("ips_removed", []),
)
except Exception as exc:
logger.error("Group '%s': error applying changes: %s", diff.group_name, exc)
return SyncResult(
group_name=diff.group_name,
status="error",
error=str(exc),
)
def sync(
self, group_names: List[str], whatif: bool = False
) -> tuple[List[GroupDiff], List[SyncResult]]:
"""
Full pipeline: build diffs, then apply (or simulate) changes.
Returns (diffs, results) so callers can inspect both.
"""
diffs = self.build_diff(group_names)
if whatif:
logger.info("--- WHATIF MODE: no changes will be written ---")
results: List[SyncResult] = []
with ThreadPoolExecutor(max_workers=self.max_workers) as pool:
futures = {
pool.submit(self._apply_diff, diff, whatif): diff.group_name
for diff in diffs
}
for fut in as_completed(futures):
results.append(fut.result())
# Sort results to match original group order
order = {name: i for i, name in enumerate(group_names)}
results.sort(key=lambda r: order.get(r.group_name, 9999))
return diffs, results
# ------------------------------------------------------------------
# Summary printer
# ------------------------------------------------------------------
@staticmethod
def print_summary(
diffs: List[GroupDiff],
results: List[SyncResult],
whatif: bool = False,
) -> None:
label = "[WHATIF] " if whatif else ""
print(f"\n{'='*60}")
print(f" {label}Sync Summary")
print(f"{'='*60}")
result_map = {r.group_name: r for r in results}
for diff in diffs:
r = result_map.get(diff.group_name)
print(f"\n Group: {diff.group_name}")
if diff.error:
print(f" STATUS : SKIPPED {diff.error}")
continue
print(f" Source IPs : {len(diff.source_ips)}")
print(f" Target IPs : {len(diff.target_static_ips)}")
print(f" To Add : {len(diff.ips_to_add)}")
print(f" To Remove : {len(diff.ips_to_remove)}")
if r:
status_label = r.status.upper()
if r.status == "no_change":
status_label = "IN SYNC"
print(f" STATUS : {status_label}")
if r.error:
print(f" ERROR : {r.error}")
if r.ips_added:
for ip in r.ips_added:
print(f" + {ip}")
if r.ips_removed:
for ip in r.ips_removed:
print(f" - {ip}")
print(f"\n{'='*60}\n")
+3
View File
@@ -0,0 +1,3 @@
requests>=2.31.0
urllib3>=2.0.0
PyYAML>=6.0.1